diff --git a/fixtures/via-graph-solver/via-graph-convex-dataset02.fixture.tsx b/fixtures/via-graph-solver/via-graph-convex-dataset02.fixture.tsx
new file mode 100644
index 0000000..a8b2c2f
--- /dev/null
+++ b/fixtures/via-graph-solver/via-graph-convex-dataset02.fixture.tsx
@@ -0,0 +1,193 @@
+import { GenericSolverDebugger } from "@tscircuit/solver-utils/react"
+import viasByNet from "assets/ViaGraphSolver/vias-by-net.json"
+import type { XYConnection } from "lib/JumperGraphSolver/jumper-graph-generator/createGraphWithConnectionsFromBaseGraph"
+import { ViaGraphSolver } from "lib/ViaGraphSolver/ViaGraphSolver"
+import { createConvexViaGraphFromXYConnections } from "lib/ViaGraphSolver/via-graph-generator/createConvexViaGraphFromXYConnections"
+import { useMemo, useState } from "react"
+import dataset from "../../datasets/jumper-graph-solver/dataset02.json"
+
+interface DatasetSample {
+ config: {
+ numCrossings: number
+ seed: number
+ rows: number
+ cols: number
+ orientation: "vertical" | "horizontal"
+ }
+ connections: {
+ connectionId: string
+ startRegionId: string
+ endRegionId: string
+ }[]
+ connectionRegions: {
+ regionId: string
+ pointIds: string[]
+ d: {
+ bounds: { minX: number; maxX: number; minY: number; maxY: number }
+ center: { x: number; y: number }
+ isPad: boolean
+ isConnectionRegion: boolean
+ }
+ }[]
+}
+
+const typedDataset = dataset as DatasetSample[]
+
+const extractXYConnections = (sample: DatasetSample): XYConnection[] => {
+ const regionMap = new Map(
+ sample.connectionRegions.map((r) => [r.regionId, r.d.center]),
+ )
+
+ return sample.connections.map((conn) => {
+ const start = regionMap.get(conn.startRegionId)
+ const end = regionMap.get(conn.endRegionId)
+
+ if (!start || !end) {
+ throw new Error(
+ `Missing region for connection ${conn.connectionId}: start=${conn.startRegionId}, end=${conn.endRegionId}`,
+ )
+ }
+
+ return {
+ connectionId: conn.connectionId,
+ start,
+ end,
+ }
+ })
+}
+
+export default () => {
+ const [selectedIndex, setSelectedIndex] = useState(0)
+ const [key, setKey] = useState(0)
+
+ const entry = typedDataset[selectedIndex]
+
+ const problem = useMemo(() => {
+ if (!entry) return null
+
+ const xyConnections = extractXYConnections(entry)
+ const result = createConvexViaGraphFromXYConnections(
+ xyConnections,
+ viasByNet,
+ )
+
+ return {
+ graph: result,
+ connections: result.connections,
+ tileCount: result.tileCount,
+ tiledViasByNet: result.tiledViasByNet,
+ }
+ }, [selectedIndex])
+
+ if (!entry || !problem) {
+ return (
+
+ No dataset loaded. Ensure dataset02.json exists at:
+
datasets/jumper-graph-solver/dataset02.json
+
+ )
+ }
+
+ const { config } = entry
+ const { tileCount } = problem
+
+ // Count region types
+ const convexRegions = problem.graph.regions.filter((r) =>
+ r.regionId.startsWith("convex:"),
+ )
+ const viaRegions = problem.graph.regions.filter((r) => r.d.isViaRegion)
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Config: {config.rows}x{config.cols}{" "}
+ {config.orientation}, {config.numCrossings} crossings, seed=
+ {config.seed}
+
+
+ Tiles: {tileCount.cols}x{tileCount.rows}
+
+
+ Convex regions: {convexRegions.length}
+
+
+ Via regions: {viaRegions.length}
+
+
+
+
+
+ new ViaGraphSolver({
+ inputGraph: {
+ regions: problem.graph.regions,
+ ports: problem.graph.ports,
+ },
+ inputConnections: problem.connections,
+ viasByNet: problem.tiledViasByNet,
+ })
+ }
+ />
+
+
+ )
+}
diff --git a/lib/ViaGraphSolver/via-graph-generator/createConvexViaGraphFromXYConnections.ts b/lib/ViaGraphSolver/via-graph-generator/createConvexViaGraphFromXYConnections.ts
new file mode 100644
index 0000000..8a954de
--- /dev/null
+++ b/lib/ViaGraphSolver/via-graph-generator/createConvexViaGraphFromXYConnections.ts
@@ -0,0 +1,99 @@
+import defaultViasByNet from "assets/ViaGraphSolver/vias-by-net.json"
+import type { XYConnection } from "../../JumperGraphSolver/jumper-graph-generator/createGraphWithConnectionsFromBaseGraph"
+import type { JumperGraph } from "../../JumperGraphSolver/jumper-types"
+import type { Connection } from "../../types"
+import type { ViasByNet } from "../ViaGraphSolver"
+import { createViaGraphWithConnections } from "./createViaGraphWithConnections"
+import { generateConvexViaTopologyRegions } from "./generateConvexViaTopologyRegions"
+
+export type ConvexViaGraphFromXYConnectionsResult = JumperGraph & {
+ connections: Connection[]
+ tiledViasByNet: ViasByNet
+ tileCount: { rows: number; cols: number }
+}
+
+/**
+ * Calculate the bounds from XY connections with no margin.
+ * The bounds go edge-to-edge with the connection points.
+ */
+function calculateBoundsFromConnections(xyConnections: XYConnection[]): {
+ minX: number
+ maxX: number
+ minY: number
+ maxY: number
+} {
+ if (xyConnections.length === 0) {
+ throw new Error("Cannot calculate bounds from empty connections array")
+ }
+
+ let minX = Infinity
+ let maxX = -Infinity
+ let minY = Infinity
+ let maxY = -Infinity
+
+ for (const conn of xyConnections) {
+ minX = Math.min(minX, conn.start.x, conn.end.x)
+ maxX = Math.max(maxX, conn.start.x, conn.end.x)
+ minY = Math.min(minY, conn.start.y, conn.end.y)
+ maxY = Math.max(maxY, conn.start.y, conn.end.y)
+ }
+
+ return { minX, maxX, minY, maxY }
+}
+
+/**
+ * Creates a complete via topology graph from XY connections using convex regions.
+ *
+ * This function uses ConvexRegionsSolver to compute convex regions around
+ * via region obstacles, instead of the manual T/B/L/R outer regions.
+ *
+ * It:
+ * 1. Calculates bounds from connection XY coordinates (no margin)
+ * 2. Generates per-net via region polygons on a tiled grid
+ * 3. Uses ConvexRegionsSolver to compute convex regions around via regions
+ * 4. Creates ports between adjacent convex regions and via regions
+ * 5. Attaches connection regions to the graph
+ *
+ * @param xyConnections - Array of connections with start/end XY coordinates
+ * @param viasByNet - Via positions grouped by net name (defaults to built-in vias-by-net.json)
+ * @param opts - Optional configuration
+ */
+export function createConvexViaGraphFromXYConnections(
+ xyConnections: XYConnection[],
+ viasByNet: ViasByNet = defaultViasByNet as ViasByNet,
+ opts?: {
+ tileSize?: number
+ portPitch?: number
+ clearance?: number
+ concavityTolerance?: number
+ },
+): ConvexViaGraphFromXYConnectionsResult {
+ // Calculate bounds from connections (no margin)
+ const bounds = calculateBoundsFromConnections(xyConnections)
+
+ // Generate the via topology with convex regions
+ const { regions, ports, tiledViasByNet, tileCount } =
+ generateConvexViaTopologyRegions({
+ viasByNet,
+ bounds,
+ tileSize: opts?.tileSize,
+ portPitch: opts?.portPitch,
+ clearance: opts?.clearance,
+ concavityTolerance: opts?.concavityTolerance,
+ })
+
+ // Create base graph from regions
+ const baseGraph: JumperGraph = { regions, ports }
+
+ // Add connections to the graph
+ const graphWithConnections = createViaGraphWithConnections(
+ baseGraph,
+ xyConnections,
+ )
+
+ return {
+ ...graphWithConnections,
+ tiledViasByNet,
+ tileCount,
+ }
+}
diff --git a/lib/ViaGraphSolver/via-graph-generator/findSharedEdges.ts b/lib/ViaGraphSolver/via-graph-generator/findSharedEdges.ts
new file mode 100644
index 0000000..08e65d7
--- /dev/null
+++ b/lib/ViaGraphSolver/via-graph-generator/findSharedEdges.ts
@@ -0,0 +1,187 @@
+type Point = { x: number; y: number }
+
+type SharedEdge = {
+ from: Point
+ to: Point
+}
+
+/**
+ * Check if two line segments are collinear (lie on the same line).
+ */
+function areCollinear(
+ a1: Point,
+ a2: Point,
+ b1: Point,
+ b2: Point,
+ tolerance: number,
+): boolean {
+ // Direction vectors
+ const dax = a2.x - a1.x
+ const day = a2.y - a1.y
+ const dbx = b2.x - b1.x
+ const dby = b2.y - b1.y
+
+ // Check if parallel (cross product ~ 0)
+ const cross = dax * dby - day * dbx
+ const lenA = Math.sqrt(dax * dax + day * day)
+ const lenB = Math.sqrt(dbx * dbx + dby * dby)
+
+ if (lenA < tolerance || lenB < tolerance) return false
+
+ // Normalize cross product
+ const normalizedCross = Math.abs(cross) / (lenA * lenB)
+ if (normalizedCross > tolerance) return false
+
+ // Check if b1 lies on the line defined by a1-a2
+ // Distance from b1 to line a1-a2
+ const vx = b1.x - a1.x
+ const vy = b1.y - a1.y
+ const crossToB1 = Math.abs(dax * vy - day * vx) / lenA
+
+ return crossToB1 < tolerance
+}
+
+/**
+ * Project a point onto a line segment and return the parameter t (0-1 if on segment).
+ */
+function projectOntoSegment(
+ p: Point,
+ a: Point,
+ b: Point,
+): { t: number; distance: number } {
+ const dx = b.x - a.x
+ const dy = b.y - a.y
+ const lenSq = dx * dx + dy * dy
+
+ if (lenSq < 1e-12) {
+ // Degenerate segment
+ const dist = Math.sqrt((p.x - a.x) ** 2 + (p.y - a.y) ** 2)
+ return { t: 0, distance: dist }
+ }
+
+ const t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq
+
+ // Closest point on infinite line
+ const closestX = a.x + t * dx
+ const closestY = a.y + t * dy
+ const distance = Math.sqrt((p.x - closestX) ** 2 + (p.y - closestY) ** 2)
+
+ return { t, distance }
+}
+
+/**
+ * Compute the overlap of two 1D ranges, returns null if no overlap.
+ */
+function rangeOverlap(
+ t1Start: number,
+ t1End: number,
+ t2Start: number,
+ t2End: number,
+): { from: number; to: number } | null {
+ const min1 = Math.min(t1Start, t1End)
+ const max1 = Math.max(t1Start, t1End)
+ const min2 = Math.min(t2Start, t2End)
+ const max2 = Math.max(t2Start, t2End)
+
+ const overlapStart = Math.max(min1, min2)
+ const overlapEnd = Math.min(max1, max2)
+
+ if (overlapEnd - overlapStart < 1e-6) return null
+ return { from: overlapStart, to: overlapEnd }
+}
+
+/**
+ * Find shared edges between two polygons.
+ *
+ * Returns an array of line segments that are shared (overlapping collinear edges)
+ * between the two polygons.
+ *
+ * @param polygon1 - First polygon as array of points (closed loop, last connects to first)
+ * @param polygon2 - Second polygon as array of points
+ * @param tolerance - Distance tolerance for considering edges as shared (default 0.01)
+ * @returns Array of shared edge segments
+ */
+export function findSharedEdges(
+ polygon1: Point[],
+ polygon2: Point[],
+ tolerance = 0.01,
+): SharedEdge[] {
+ const sharedEdges: SharedEdge[] = []
+
+ // Iterate all edges of polygon1
+ for (let i = 0; i < polygon1.length; i++) {
+ const a1 = polygon1[i]
+ const a2 = polygon1[(i + 1) % polygon1.length]
+
+ // Iterate all edges of polygon2
+ for (let j = 0; j < polygon2.length; j++) {
+ const b1 = polygon2[j]
+ const b2 = polygon2[(j + 1) % polygon2.length]
+
+ // Check if edges are collinear
+ if (!areCollinear(a1, a2, b1, b2, tolerance)) continue
+
+ // Project polygon2 edge endpoints onto polygon1 edge
+ const proj1 = projectOntoSegment(b1, a1, a2)
+ const proj2 = projectOntoSegment(b2, a1, a2)
+
+ // Check if both endpoints are close to the line
+ if (proj1.distance > tolerance || proj2.distance > tolerance) continue
+
+ // Find overlap in parameter space of edge a1-a2
+ const overlap = rangeOverlap(0, 1, proj1.t, proj2.t)
+ if (!overlap) continue
+
+ // Convert back to world coordinates
+ const dx = a2.x - a1.x
+ const dy = a2.y - a1.y
+ const from: Point = {
+ x: a1.x + overlap.from * dx,
+ y: a1.y + overlap.from * dy,
+ }
+ const to: Point = {
+ x: a1.x + overlap.to * dx,
+ y: a1.y + overlap.to * dy,
+ }
+
+ // Only add if the edge has non-trivial length
+ const edgeLen = Math.sqrt((to.x - from.x) ** 2 + (to.y - from.y) ** 2)
+ if (edgeLen > tolerance) {
+ sharedEdges.push({ from, to })
+ }
+ }
+ }
+
+ return sharedEdges
+}
+
+/**
+ * Create evenly-spaced ports along a shared edge.
+ *
+ * @param edge - The shared edge segment
+ * @param portPitch - Distance between ports (default 0.4mm)
+ * @returns Array of port positions
+ */
+export function createPortsAlongEdge(
+ edge: SharedEdge,
+ portPitch = 0.4,
+): Point[] {
+ const dx = edge.to.x - edge.from.x
+ const dy = edge.to.y - edge.from.y
+ const length = Math.sqrt(dx * dx + dy * dy)
+
+ if (length < 0.001) return []
+
+ const count = Math.max(1, Math.floor(length / portPitch))
+ const ports: Point[] = []
+
+ for (let i = 0; i < count; i++) {
+ const t = (i + 0.5) / count
+ ports.push({
+ x: edge.from.x + t * dx,
+ y: edge.from.y + t * dy,
+ })
+ }
+
+ return ports
+}
diff --git a/lib/ViaGraphSolver/via-graph-generator/generateConvexViaTopologyRegions.ts b/lib/ViaGraphSolver/via-graph-generator/generateConvexViaTopologyRegions.ts
new file mode 100644
index 0000000..d7b76da
--- /dev/null
+++ b/lib/ViaGraphSolver/via-graph-generator/generateConvexViaTopologyRegions.ts
@@ -0,0 +1,374 @@
+import { ConvexRegionsSolver } from "@tscircuit/find-convex-regions"
+import type {
+ JPort,
+ JRegion,
+ JumperGraph,
+} from "../../JumperGraphSolver/jumper-types"
+import type { ViasByNet } from "../ViaGraphSolver"
+import { createPortsAlongEdge, findSharedEdges } from "./findSharedEdges"
+
+/**
+ * Default port pitch (mm) for distributing ports along shared boundaries.
+ */
+const DEFAULT_PORT_PITCH = 0.4
+
+/**
+ * Default tile size (mm) for via placement.
+ */
+const DEFAULT_TILE_SIZE = 5
+
+/**
+ * Default clearance (mm) around via regions for convex region computation.
+ */
+const DEFAULT_CLEARANCE = 0.1
+
+type Point = { x: number; y: number }
+type Bounds = { minX: number; maxX: number; minY: number; maxY: number }
+
+type HorizontalSegment = { xStart: number; xEnd: number; y: number }
+type VerticalSegment = { x: number; yStart: number; yEnd: number }
+
+/**
+ * Remove consecutive duplicate points from a polygon.
+ * Points are considered duplicates if they are within tolerance distance.
+ */
+function deduplicateConsecutivePoints(
+ points: Point[],
+ tolerance = 0.001,
+): Point[] {
+ if (points.length <= 1) return points
+
+ const result: Point[] = [points[0]]
+ for (let i = 1; i < points.length; i++) {
+ const prev = result[result.length - 1]
+ const curr = points[i]
+ const dist = Math.sqrt((curr.x - prev.x) ** 2 + (curr.y - prev.y) ** 2)
+ if (dist > tolerance) {
+ result.push(curr)
+ }
+ }
+
+ // Also check if last point equals first point (for closed polygons)
+ if (result.length > 1) {
+ const first = result[0]
+ const last = result[result.length - 1]
+ const dist = Math.sqrt((last.x - first.x) ** 2 + (last.y - first.y) ** 2)
+ if (dist < tolerance) {
+ result.pop()
+ }
+ }
+
+ return result
+}
+
+/**
+ * Compute bounding box from polygon points.
+ */
+function boundsFromPolygon(points: Point[]): Bounds {
+ let minX = Infinity
+ let maxX = -Infinity
+ let minY = Infinity
+ let maxY = -Infinity
+ for (const p of points) {
+ if (p.x < minX) minX = p.x
+ if (p.x > maxX) maxX = p.x
+ if (p.y < minY) minY = p.y
+ if (p.y > maxY) maxY = p.y
+ }
+ return { minX, maxX, minY, maxY }
+}
+
+/**
+ * Compute centroid of polygon.
+ */
+function centroid(points: Point[]): Point {
+ let cx = 0
+ let cy = 0
+ for (const p of points) {
+ cx += p.x
+ cy += p.y
+ }
+ return { x: cx / points.length, y: cy / points.length }
+}
+
+/**
+ * Create a JRegion from a polygon.
+ */
+function createRegionFromPolygon(
+ regionId: string,
+ polygon: Point[],
+ opts?: { isViaRegion?: boolean },
+): JRegion {
+ const bounds = boundsFromPolygon(polygon)
+ return {
+ regionId,
+ ports: [],
+ d: {
+ bounds,
+ center: centroid(polygon),
+ polygon,
+ isPad: false,
+ isViaRegion: opts?.isViaRegion,
+ },
+ }
+}
+
+/**
+ * Generate a via region polygon for a single net's vias.
+ * The polygon wraps around all vias for that net.
+ */
+function generateViaRegionPolygon(
+ vias: Array<{ viaId: string; diameter: number; position: Point }>,
+): Point[] {
+ if (vias.length === 0) return []
+
+ // Find extreme vias
+ const topVia = vias.reduce((best, v) =>
+ v.position.y > best.position.y ? v : best,
+ )
+ const bottomVia = vias.reduce((best, v) =>
+ v.position.y < best.position.y ? v : best,
+ )
+ const leftVia = vias.reduce((best, v) =>
+ v.position.x < best.position.x ? v : best,
+ )
+ const rightVia = vias.reduce((best, v) =>
+ v.position.x > best.position.x ? v : best,
+ )
+
+ // Compute edge segments
+ const topSeg: HorizontalSegment = {
+ xStart: topVia.position.x - topVia.diameter / 2,
+ xEnd: topVia.position.x + topVia.diameter / 2,
+ y: topVia.position.y + topVia.diameter / 2,
+ }
+ const botSeg: HorizontalSegment = {
+ xStart: bottomVia.position.x - bottomVia.diameter / 2,
+ xEnd: bottomVia.position.x + bottomVia.diameter / 2,
+ y: bottomVia.position.y - bottomVia.diameter / 2,
+ }
+ const leftSeg: VerticalSegment = {
+ x: leftVia.position.x - leftVia.diameter / 2,
+ yStart: leftVia.position.y - leftVia.diameter / 2,
+ yEnd: leftVia.position.y + leftVia.diameter / 2,
+ }
+ const rightSeg: VerticalSegment = {
+ x: rightVia.position.x + rightVia.diameter / 2,
+ yStart: rightVia.position.y - rightVia.diameter / 2,
+ yEnd: rightVia.position.y + rightVia.diameter / 2,
+ }
+
+ // Build polygon (clockwise):
+ // top-left -> top-right -> right-top -> right-bottom ->
+ // bottom-right -> bottom-left -> left-bottom -> left-top -> close
+ const rawPolygon = [
+ { x: topSeg.xStart, y: topSeg.y },
+ { x: topSeg.xEnd, y: topSeg.y },
+ { x: rightSeg.x, y: rightSeg.yEnd },
+ { x: rightSeg.x, y: rightSeg.yStart },
+ { x: botSeg.xEnd, y: botSeg.y },
+ { x: botSeg.xStart, y: botSeg.y },
+ { x: leftSeg.x, y: leftSeg.yStart },
+ { x: leftSeg.x, y: leftSeg.yEnd },
+ ]
+
+ // Remove consecutive duplicate points (happens when same via is extreme in multiple directions)
+ return deduplicateConsecutivePoints(rawPolygon)
+}
+
+/**
+ * Translate via positions by (dx, dy).
+ */
+function translateVias(
+ vias: Array<{ viaId: string; diameter: number; position: Point }>,
+ dx: number,
+ dy: number,
+ prefix: string,
+): Array<{ viaId: string; diameter: number; position: Point }> {
+ return vias.map((v) => ({
+ viaId: `${prefix}:${v.viaId}`,
+ diameter: v.diameter,
+ position: {
+ x: v.position.x + dx,
+ y: v.position.y + dy,
+ },
+ }))
+}
+
+/**
+ * Generates a via topology using convex regions computed by ConvexRegionsSolver.
+ *
+ * 1. Via tiles are placed on a grid (5mm tiles by default)
+ * 2. Per-net via region polygons are created within each tile
+ * 3. Convex regions are computed globally with via region polygons as obstacles
+ * 4. Ports are created between adjacent convex regions and between convex/via regions
+ */
+export function generateConvexViaTopologyRegions(opts: {
+ viasByNet: ViasByNet
+ bounds: Bounds
+ tileSize?: number
+ portPitch?: number
+ clearance?: number
+ concavityTolerance?: number
+}): {
+ regions: JRegion[]
+ ports: JPort[]
+ tiledViasByNet: ViasByNet
+ tileCount: { rows: number; cols: number }
+} {
+ const tileSize = opts.tileSize ?? DEFAULT_TILE_SIZE
+ const portPitch = opts.portPitch ?? DEFAULT_PORT_PITCH
+ const clearance = opts.clearance ?? DEFAULT_CLEARANCE
+ const concavityTolerance = opts.concavityTolerance ?? 0
+ const { bounds, viasByNet } = opts
+
+ const width = bounds.maxX - bounds.minX
+ const height = bounds.maxY - bounds.minY
+
+ const cols = Math.floor(width / tileSize)
+ const rows = Math.floor(height / tileSize)
+
+ const allRegions: JRegion[] = []
+ const allPorts: JPort[] = []
+ const tiledViasByNet: ViasByNet = {}
+ const viaRegions: JRegion[] = []
+
+ // Calculate tile grid position (centered within bounds)
+ const gridWidth = cols * tileSize
+ const gridHeight = rows * tileSize
+ const gridMinX = bounds.minX + (width - gridWidth) / 2
+ const gridMinY = bounds.minY + (height - gridHeight) / 2
+ const half = tileSize / 2
+
+ // Step 1: Generate tiled via regions
+ if (rows > 0 && cols > 0) {
+ for (let row = 0; row < rows; row++) {
+ for (let col = 0; col < cols; col++) {
+ const tileCenterX = gridMinX + col * tileSize + half
+ const tileCenterY = gridMinY + row * tileSize + half
+ const prefix = `t${row}_${col}`
+
+ // Create per-net via regions for this tile
+ for (const [netName, vias] of Object.entries(viasByNet)) {
+ if (vias.length === 0) continue
+
+ // Translate vias to tile position
+ const translatedVias = translateVias(
+ vias,
+ tileCenterX,
+ tileCenterY,
+ prefix,
+ )
+
+ // Add to tiledViasByNet
+ if (!tiledViasByNet[netName]) tiledViasByNet[netName] = []
+ tiledViasByNet[netName].push(...translatedVias)
+
+ // Generate via region polygon
+ const polygon = generateViaRegionPolygon(translatedVias)
+ if (polygon.length === 0) continue
+
+ const viaRegion = createRegionFromPolygon(
+ `${prefix}:v:${netName}`,
+ polygon,
+ { isViaRegion: true },
+ )
+ viaRegions.push(viaRegion)
+ allRegions.push(viaRegion)
+ }
+ }
+ }
+ }
+
+ // Step 2: Compute convex regions using ConvexRegionsSolver
+ // Via region polygons are used as obstacles
+ const obstaclePolygons = viaRegions.map((r) => ({
+ points: r.d.polygon!,
+ }))
+
+ const solver = new ConvexRegionsSolver({
+ bounds,
+ polygons: obstaclePolygons,
+ clearance,
+ concavityTolerance,
+ })
+
+ solver.solve()
+ const solverOutput = solver.getOutput()
+
+ if (!solverOutput) {
+ throw new Error("ConvexRegionsSolver failed to compute regions")
+ }
+
+ // Step 3: Convert solver output to JRegions
+ const convexRegions: JRegion[] = solverOutput.regions.map((polygon, i) =>
+ createRegionFromPolygon(`convex:${i}`, polygon),
+ )
+ allRegions.push(...convexRegions)
+
+ // Step 4: Create ports between adjacent convex regions
+ let portIdCounter = 0
+
+ for (let i = 0; i < convexRegions.length; i++) {
+ for (let j = i + 1; j < convexRegions.length; j++) {
+ const region1 = convexRegions[i]
+ const region2 = convexRegions[j]
+
+ const sharedEdges = findSharedEdges(
+ region1.d.polygon!,
+ region2.d.polygon!,
+ clearance * 2, // tolerance slightly larger than clearance
+ )
+
+ for (const edge of sharedEdges) {
+ const portPositions = createPortsAlongEdge(edge, portPitch)
+
+ for (const pos of portPositions) {
+ const port: JPort = {
+ portId: `convex:${i}-${j}:${portIdCounter++}`,
+ region1,
+ region2,
+ d: { x: pos.x, y: pos.y },
+ }
+ region1.ports.push(port)
+ region2.ports.push(port)
+ allPorts.push(port)
+ }
+ }
+ }
+ }
+
+ // Step 5: Create ports between convex regions and via regions
+ for (const viaRegion of viaRegions) {
+ for (const convexRegion of convexRegions) {
+ const sharedEdges = findSharedEdges(
+ viaRegion.d.polygon!,
+ convexRegion.d.polygon!,
+ clearance * 2,
+ )
+
+ for (const edge of sharedEdges) {
+ const portPositions = createPortsAlongEdge(edge, portPitch)
+
+ for (const pos of portPositions) {
+ const port: JPort = {
+ portId: `via-convex:${viaRegion.regionId}-${convexRegion.regionId}:${portIdCounter++}`,
+ region1: viaRegion,
+ region2: convexRegion,
+ d: { x: pos.x, y: pos.y },
+ }
+ viaRegion.ports.push(port)
+ convexRegion.ports.push(port)
+ allPorts.push(port)
+ }
+ }
+ }
+ }
+
+ return {
+ regions: allRegions,
+ ports: allPorts,
+ tiledViasByNet,
+ tileCount: { rows, cols },
+ }
+}
diff --git a/lib/index.ts b/lib/index.ts
index 3d260f7..ad823d2 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -12,7 +12,9 @@ export * from "./topology"
export * from "./types"
export * from "./ViaGraphSolver/defaultTopology"
export * from "./ViaGraphSolver/ViaGraphSolver"
+export * from "./ViaGraphSolver/via-graph-generator/createConvexViaGraphFromXYConnections"
export * from "./ViaGraphSolver/via-graph-generator/createViaGraphFromXYConnections"
export * from "./ViaGraphSolver/via-graph-generator/createViaGraphWithConnections"
+export * from "./ViaGraphSolver/via-graph-generator/generateConvexViaTopologyRegions"
export * from "./ViaGraphSolver/via-graph-generator/generateViaTopologyGrid"
export * from "./ViaGraphSolver/via-graph-generator/generateViaTopologyRegions"
diff --git a/package.json b/package.json
index ffdd749..f140195 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"repository": "https://github.com/tscircuit/hypergraph",
"devDependencies": {
"@biomejs/biome": "^2.3.11",
+ "@tscircuit/find-convex-regions": "^0.0.7",
"@tscircuit/math-utils": "^0.0.29",
"@types/bun": "latest",
"bun-match-svg": "^0.0.15",
diff --git a/scripts/benchmark-via-graph-convex-dataset02-parallel.ts b/scripts/benchmark-via-graph-convex-dataset02-parallel.ts
new file mode 100644
index 0000000..143b09d
--- /dev/null
+++ b/scripts/benchmark-via-graph-convex-dataset02-parallel.ts
@@ -0,0 +1,598 @@
+/**
+ * Parallel benchmark for ViaGraphSolver using Dataset02 with convex regions.
+ *
+ * Usage: npx tsx scripts/benchmark-via-graph-convex-dataset02-parallel.ts [options]
+ *
+ * This script uses a single file approach where worker code is embedded as a string
+ * and executed via eval in the worker thread.
+ */
+import * as fs from "fs"
+import { cpus } from "os"
+import * as path from "path"
+import { fileURLToPath } from "url"
+import { Worker } from "worker_threads"
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+
+// Parse command line arguments
+const args = process.argv.slice(2)
+const limitArg = args.find((a) => a.startsWith("--limit="))
+const concurrencyArg = args.find((a) => a.startsWith("--concurrency="))
+const SAMPLE_LIMIT = limitArg ? parseInt(limitArg.split("=")[1], 10) : undefined
+const CONCURRENCY = concurrencyArg
+ ? parseInt(concurrencyArg.split("=")[1], 10)
+ : cpus().length
+const QUICK_MODE = args.includes("--quick")
+const HELP = args.includes("--help") || args.includes("-h")
+
+if (HELP) {
+ console.log(`
+Usage: npx tsx scripts/benchmark-via-graph-convex-dataset02-parallel.ts [options]
+
+Options:
+ --limit=N Only run first N samples (default: all 1000)
+ --concurrency=N Number of parallel workers (default: CPU count = ${cpus().length})
+ --quick Use reduced MAX_ITERATIONS for faster but less accurate results
+ --help, -h Show this help message
+
+Examples:
+ npx tsx scripts/benchmark-via-graph-convex-dataset02-parallel.ts --limit=100
+ npx tsx scripts/benchmark-via-graph-convex-dataset02-parallel.ts --concurrency=4 --quick
+ npx tsx scripts/benchmark-via-graph-convex-dataset02-parallel.ts --limit=200 --concurrency=8
+`)
+ process.exit(0)
+}
+
+// Types for dataset03 structure
+type DatasetSample = {
+ config: {
+ numCrossings: number
+ seed: number
+ rows: number
+ cols: number
+ orientation: "vertical" | "horizontal"
+ }
+ connections: {
+ connectionId: string
+ startRegionId: string
+ endRegionId: string
+ }[]
+ connectionRegions: {
+ regionId: string
+ pointIds: string[]
+ d: {
+ bounds: { minX: number; maxX: number; minY: number; maxY: number }
+ center: { x: number; y: number }
+ isPad: boolean
+ isConnectionRegion: boolean
+ }
+ }[]
+}
+
+type ViasByNet = Record<
+ string,
+ { viaId: string; diameter: number; position: { x: number; y: number } }[]
+>
+
+type BenchmarkResult = {
+ sampleIndex: number
+ numCrossings: number
+ seed: number
+ rows: number
+ cols: number
+ orientation: "vertical" | "horizontal"
+ solved: boolean
+ failed: boolean
+ iterations: number
+ duration: number
+ tileRows: number
+ tileCols: number
+ convexRegions: number
+ viaRegions: number
+ error?: string
+}
+
+const median = (numbers: number[]): number | undefined => {
+ if (numbers.length === 0) return undefined
+ const sorted = numbers.slice().sort((a, b) => a - b)
+ const middle = Math.floor(sorted.length / 2)
+ return sorted[middle]
+}
+
+const percentile = (numbers: number[], p: number): number | undefined => {
+ if (numbers.length === 0) return undefined
+ const sorted = numbers.slice().sort((a, b) => a - b)
+ const index = Math.floor((p / 100) * (sorted.length - 1))
+ return sorted[index]
+}
+
+const mean = (numbers: number[]): number | undefined => {
+ if (numbers.length === 0) return undefined
+ return numbers.reduce((sum, n) => sum + n, 0) / numbers.length
+}
+
+// Worker code as a string that uses the built dist/index.js
+const workerCode = `
+const { parentPort, workerData } = require('worker_threads');
+const { ViaGraphSolver, createConvexViaGraphFromXYConnections } = require(workerData.distPath);
+
+const { sample, sampleIndex, viasByNet, quickMode } = workerData;
+
+function extractXYConnections(sample) {
+ const regionMap = new Map(
+ sample.connectionRegions.map((r) => [r.regionId, r.d.center])
+ );
+
+ return sample.connections.map((conn) => {
+ const start = regionMap.get(conn.startRegionId);
+ const end = regionMap.get(conn.endRegionId);
+
+ if (!start || !end) {
+ throw new Error(
+ 'Missing region for connection ' + conn.connectionId
+ );
+ }
+
+ return {
+ connectionId: conn.connectionId,
+ start,
+ end,
+ };
+ });
+}
+
+function solveSample() {
+ try {
+ const xyConnections = extractXYConnections(sample);
+ const result = createConvexViaGraphFromXYConnections(xyConnections, viasByNet);
+
+ // Count region types
+ const convexRegions = result.regions.filter(r => r.regionId.startsWith('convex:')).length;
+ const viaRegions = result.regions.filter(r => r.d.isViaRegion).length;
+
+ const solverOpts = {
+ inputGraph: {
+ regions: result.regions,
+ ports: result.ports,
+ },
+ inputConnections: result.connections,
+ viasByNet: result.tiledViasByNet,
+ };
+
+ if (quickMode) {
+ solverOpts.baseMaxIterations = 50000;
+ }
+
+ const solver = new ViaGraphSolver(solverOpts);
+
+ const startTime = performance.now();
+ solver.solve();
+ const duration = performance.now() - startTime;
+
+ return {
+ sampleIndex,
+ numCrossings: sample.config.numCrossings,
+ seed: sample.config.seed,
+ rows: sample.config.rows,
+ cols: sample.config.cols,
+ orientation: sample.config.orientation,
+ solved: solver.solved,
+ failed: solver.failed,
+ iterations: solver.iterations,
+ duration,
+ tileRows: result.tileCount.rows,
+ tileCols: result.tileCount.cols,
+ convexRegions,
+ viaRegions,
+ };
+ } catch (e) {
+ return {
+ sampleIndex,
+ numCrossings: sample.config.numCrossings,
+ seed: sample.config.seed,
+ rows: sample.config.rows,
+ cols: sample.config.cols,
+ orientation: sample.config.orientation,
+ solved: false,
+ failed: true,
+ iterations: 0,
+ duration: 0,
+ tileRows: 0,
+ tileCols: 0,
+ convexRegions: 0,
+ viaRegions: 0,
+ error: e instanceof Error ? e.message : String(e),
+ };
+ }
+}
+
+const result = solveSample();
+parentPort.postMessage(result);
+`
+
+/**
+ * Run a single sample in a worker thread
+ */
+function runSampleInWorker(
+ sampleIndex: number,
+ sample: DatasetSample,
+ viasByNet: ViasByNet,
+ quickMode: boolean,
+ distPath: string,
+): Promise {
+ return new Promise((resolve) => {
+ const worker = new Worker(workerCode, {
+ eval: true,
+ workerData: {
+ sample,
+ sampleIndex,
+ viasByNet,
+ quickMode,
+ distPath,
+ },
+ })
+
+ worker.on("message", (result: BenchmarkResult) => {
+ resolve(result)
+ })
+
+ worker.on("error", (err: Error) => {
+ resolve({
+ sampleIndex,
+ numCrossings: sample.config.numCrossings,
+ seed: sample.config.seed,
+ rows: sample.config.rows,
+ cols: sample.config.cols,
+ orientation: sample.config.orientation,
+ solved: false,
+ failed: true,
+ iterations: 0,
+ duration: 0,
+ tileRows: 0,
+ tileCols: 0,
+ convexRegions: 0,
+ viaRegions: 0,
+ error: err.message,
+ })
+ })
+
+ worker.on("exit", (code) => {
+ if (code !== 0) {
+ resolve({
+ sampleIndex,
+ numCrossings: sample.config.numCrossings,
+ seed: sample.config.seed,
+ rows: sample.config.rows,
+ cols: sample.config.cols,
+ orientation: sample.config.orientation,
+ solved: false,
+ failed: true,
+ iterations: 0,
+ duration: 0,
+ tileRows: 0,
+ tileCols: 0,
+ convexRegions: 0,
+ viaRegions: 0,
+ error: `Worker exited with code ${code}`,
+ })
+ }
+ })
+ })
+}
+
+/**
+ * Process samples in parallel using worker threads with limited concurrency
+ */
+async function runParallelBenchmark(
+ samples: DatasetSample[],
+ viasByNet: ViasByNet,
+ concurrency: number,
+ quickMode: boolean,
+ distPath: string,
+ onProgress: (completed: number, results: BenchmarkResult[]) => void,
+): Promise {
+ const results: BenchmarkResult[] = []
+ let nextIndex = 0
+ let completed = 0
+
+ const processNext = async (): Promise => {
+ while (nextIndex < samples.length) {
+ const currentIndex = nextIndex++
+ const sample = samples[currentIndex]
+
+ const result = await runSampleInWorker(
+ currentIndex,
+ sample,
+ viasByNet,
+ quickMode,
+ distPath,
+ )
+
+ results.push(result)
+ completed++
+ onProgress(completed, results)
+ }
+ }
+
+ // Start workers up to concurrency limit
+ const workers = Array(Math.min(concurrency, samples.length))
+ .fill(null)
+ .map(() => processNext())
+
+ await Promise.all(workers)
+
+ // Sort results by sample index
+ return results.sort((a, b) => a.sampleIndex - b.sampleIndex)
+}
+
+// Load dataset02
+const datasetPath = path.join(
+ __dirname,
+ "../datasets/jumper-graph-solver/dataset02.json",
+)
+const dataset: DatasetSample[] = JSON.parse(
+ fs.readFileSync(datasetPath, "utf8"),
+)
+
+// Load vias-by-net
+const viasByNetPath = path.join(
+ __dirname,
+ "../assets/ViaGraphSolver/vias-by-net.json",
+)
+const viasByNet: ViasByNet = JSON.parse(fs.readFileSync(viasByNetPath, "utf8"))
+
+// Path to built dist
+const distPath = path.join(__dirname, "../dist/index.js")
+
+// Check if dist exists
+if (!fs.existsSync(distPath)) {
+ console.error(
+ "Error: dist/index.js not found. Please run 'npm run build' first.",
+ )
+ process.exit(1)
+}
+
+// Apply sample limit
+const samplesToRun = SAMPLE_LIMIT ? dataset.slice(0, SAMPLE_LIMIT) : dataset
+const totalSamples = samplesToRun.length
+
+console.log(
+ "Benchmark: ViaGraphSolver with Dataset02 (Convex Regions, Parallel)",
+)
+console.log("=".repeat(70))
+console.log(`Loaded ${dataset.length} samples from dataset02`)
+console.log(`Via topology loaded from vias-by-net.json`)
+console.log(`Concurrency: ${CONCURRENCY} workers`)
+if (SAMPLE_LIMIT) {
+ console.log(`Sample limit: ${SAMPLE_LIMIT}`)
+}
+if (QUICK_MODE) {
+ console.log(`Quick mode: enabled (reduced MAX_ITERATIONS)`)
+}
+console.log()
+
+const startTime = Date.now()
+let lastProgressTime = Date.now()
+
+const printProgress = (completed: number, results: BenchmarkResult[]) => {
+ const now = Date.now()
+ if (now - lastProgressTime >= 1000 || completed === totalSamples) {
+ const solvedCount = results.filter((r) => r.solved).length
+ const failedCount = results.filter((r) => r.failed && !r.solved).length
+ const elapsed = ((now - startTime) / 1000).toFixed(1)
+ const rate =
+ completed > 0 ? ((solvedCount / completed) * 100).toFixed(1) : "0.0"
+ const samplesPerSec = (completed / ((now - startTime) / 1000)).toFixed(1)
+ console.log(
+ `[${elapsed}s] ${completed}/${totalSamples} (${samplesPerSec}/s) | ` +
+ `Solved: ${solvedCount} | Failed: ${failedCount} | Rate: ${rate}%`,
+ )
+ lastProgressTime = now
+ }
+}
+
+// Run the benchmark
+runParallelBenchmark(
+ samplesToRun,
+ viasByNet,
+ CONCURRENCY,
+ QUICK_MODE,
+ distPath,
+ printProgress,
+).then((results) => {
+ const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1)
+ console.log(`\nCompleted in ${totalElapsed}s\n`)
+
+ // Calculate statistics
+ const solvedResults = results.filter((r) => r.solved)
+ const failedResults = results.filter((r) => r.failed && !r.solved)
+ const unsolved = results.filter((r) => !r.solved)
+
+ const successRate = (solvedResults.length / results.length) * 100
+
+ console.log("=".repeat(70))
+ console.log("Overall Results")
+ console.log("=".repeat(70))
+ console.log(`Total samples: ${results.length}`)
+ console.log(
+ `Solved: ${solvedResults.length} (${successRate.toFixed(1)}%)`,
+ )
+ console.log(
+ `Failed: ${failedResults.length} (${((failedResults.length / results.length) * 100).toFixed(1)}%)`,
+ )
+ console.log(
+ `Unsolved: ${unsolved.length} (${((unsolved.length / results.length) * 100).toFixed(1)}%)`,
+ )
+
+ // Region statistics
+ const avgConvexRegions = mean(solvedResults.map((r) => r.convexRegions))
+ const avgViaRegions = mean(solvedResults.map((r) => r.viaRegions))
+ console.log(`\nAvg convex regions: ${avgConvexRegions?.toFixed(1) ?? "N/A"}`)
+ console.log(`Avg via regions: ${avgViaRegions?.toFixed(1) ?? "N/A"}`)
+
+ // Iteration statistics for solved samples
+ const solvedIterations = solvedResults.map((r) => r.iterations)
+ const solvedDurations = solvedResults.map((r) => r.duration)
+
+ console.log("\n" + "=".repeat(70))
+ console.log("Performance Statistics (Solved Samples)")
+ console.log("=".repeat(70))
+ console.log(
+ `Iterations - Mean: ${mean(solvedIterations)?.toFixed(0) ?? "N/A"}, ` +
+ `Median: ${median(solvedIterations)?.toFixed(0) ?? "N/A"}, ` +
+ `P90: ${percentile(solvedIterations, 90)?.toFixed(0) ?? "N/A"}, ` +
+ `P99: ${percentile(solvedIterations, 99)?.toFixed(0) ?? "N/A"}`,
+ )
+ console.log(
+ `Duration (ms) - Mean: ${mean(solvedDurations)?.toFixed(1) ?? "N/A"}, ` +
+ `Median: ${median(solvedDurations)?.toFixed(1) ?? "N/A"}, ` +
+ `P90: ${percentile(solvedDurations, 90)?.toFixed(1) ?? "N/A"}, ` +
+ `P99: ${percentile(solvedDurations, 99)?.toFixed(1) ?? "N/A"}`,
+ )
+
+ // Tile distribution
+ const tileCounts = solvedResults.map((r) => `${r.tileCols}x${r.tileRows}`)
+ const tileCountMap = new Map()
+ for (const t of tileCounts) {
+ tileCountMap.set(t, (tileCountMap.get(t) || 0) + 1)
+ }
+ console.log("\nTile grid distribution (solved):")
+ const sortedTiles = Array.from(tileCountMap.entries()).sort((a, b) => {
+ const [aCols, aRows] = a[0].split("x").map(Number)
+ const [bCols, bRows] = b[0].split("x").map(Number)
+ return aCols * aRows - bCols * bRows
+ })
+ for (const [tile, count] of sortedTiles) {
+ const pct = ((count / solvedResults.length) * 100).toFixed(1)
+ console.log(` ${tile}: ${count} (${pct}%)`)
+ }
+
+ // Success rate by crossing count
+ console.log("\n" + "=".repeat(70))
+ console.log("Success Rate by Crossing Count")
+ console.log("=".repeat(70))
+ const crossingGroups = new Map<
+ number,
+ {
+ solved: number
+ total: number
+ iterations: number[]
+ durations: number[]
+ }
+ >()
+ for (const r of results) {
+ const crossings = r.numCrossings
+ if (!crossingGroups.has(crossings)) {
+ crossingGroups.set(crossings, {
+ solved: 0,
+ total: 0,
+ iterations: [],
+ durations: [],
+ })
+ }
+ const group = crossingGroups.get(crossings)!
+ group.total++
+ if (r.solved) {
+ group.solved++
+ group.iterations.push(r.iterations)
+ group.durations.push(r.duration)
+ }
+ }
+ const sortedCrossings = Array.from(crossingGroups.entries()).sort(
+ (a, b) => a[0] - b[0],
+ )
+ for (const [crossings, { solved, total, iterations }] of sortedCrossings) {
+ const pct = ((solved / total) * 100).toFixed(0)
+ const medIters = median(iterations)?.toFixed(0) ?? "N/A"
+ console.log(
+ ` ${crossings.toString().padStart(2)} crossings: ${solved.toString().padStart(3)}/${total.toString().padStart(3)} (${pct.padStart(3)}%) | Median iters: ${medIters}`,
+ )
+ }
+
+ // Success rate by grid size (rows x cols from config)
+ console.log("\n" + "=".repeat(70))
+ console.log("Success Rate by Problem Grid Size (rows x cols)")
+ console.log("=".repeat(70))
+ const gridGroups = new Map<
+ string,
+ { solved: number; total: number; iterations: number[] }
+ >()
+ for (const r of results) {
+ const grid = `${r.rows}x${r.cols}`
+ if (!gridGroups.has(grid)) {
+ gridGroups.set(grid, { solved: 0, total: 0, iterations: [] })
+ }
+ const group = gridGroups.get(grid)!
+ group.total++
+ if (r.solved) {
+ group.solved++
+ group.iterations.push(r.iterations)
+ }
+ }
+ const sortedGrids = Array.from(gridGroups.entries()).sort((a, b) => {
+ const [aRows, aCols] = a[0].split("x").map(Number)
+ const [bRows, bCols] = b[0].split("x").map(Number)
+ return aRows * aCols - bRows * bCols
+ })
+ for (const [grid, { solved, total, iterations }] of sortedGrids) {
+ const pct = ((solved / total) * 100).toFixed(0)
+ const medIters = median(iterations)?.toFixed(0) ?? "N/A"
+ console.log(
+ ` ${grid.padStart(4)}: ${solved.toString().padStart(3)}/${total.toString().padStart(3)} (${pct.padStart(3)}%) | Median iters: ${medIters}`,
+ )
+ }
+
+ // Success rate by orientation
+ console.log("\n" + "=".repeat(70))
+ console.log("Success Rate by Orientation")
+ console.log("=".repeat(70))
+ const orientationGroups = new Map<
+ string,
+ { solved: number; total: number; iterations: number[] }
+ >()
+ for (const r of results) {
+ const orient = r.orientation
+ if (!orientationGroups.has(orient)) {
+ orientationGroups.set(orient, { solved: 0, total: 0, iterations: [] })
+ }
+ const group = orientationGroups.get(orient)!
+ group.total++
+ if (r.solved) {
+ group.solved++
+ group.iterations.push(r.iterations)
+ }
+ }
+ for (const [orient, { solved, total, iterations }] of orientationGroups) {
+ const pct = ((solved / total) * 100).toFixed(0)
+ const medIters = median(iterations)?.toFixed(0) ?? "N/A"
+ console.log(
+ ` ${orient.padEnd(10)}: ${solved.toString().padStart(3)}/${total.toString().padStart(3)} (${pct.padStart(3)}%) | Median iters: ${medIters}`,
+ )
+ }
+
+ // Show some unsolved samples
+ if (unsolved.length > 0 && unsolved.length <= 30) {
+ console.log("\n" + "=".repeat(70))
+ console.log("Unsolved Samples")
+ console.log("=".repeat(70))
+ for (const r of unsolved) {
+ console.log(
+ ` Sample ${r.sampleIndex}: ${r.numCrossings} crossings, ${r.rows}x${r.cols} ${r.orientation}, seed=${r.seed}${r.error ? ` (error: ${r.error})` : ""}`,
+ )
+ }
+ } else if (unsolved.length > 30) {
+ console.log(
+ `\n${unsolved.length} samples could not be solved (showing first 30):`,
+ )
+ for (const r of unsolved.slice(0, 30)) {
+ console.log(
+ ` Sample ${r.sampleIndex}: ${r.numCrossings} crossings, ${r.rows}x${r.cols} ${r.orientation}, seed=${r.seed}`,
+ )
+ }
+ }
+
+ console.log("\n" + "=".repeat(70))
+ console.log("Benchmark Complete")
+ console.log("=".repeat(70))
+})
diff --git a/scripts/benchmark-via-graph-convex-dataset02.ts b/scripts/benchmark-via-graph-convex-dataset02.ts
new file mode 100644
index 0000000..a132860
--- /dev/null
+++ b/scripts/benchmark-via-graph-convex-dataset02.ts
@@ -0,0 +1,434 @@
+import * as fs from "fs"
+import * as path from "path"
+import type { XYConnection } from "../lib/JumperGraphSolver/jumper-graph-generator/createGraphWithConnectionsFromBaseGraph"
+import { ViaGraphSolver } from "../lib/ViaGraphSolver/ViaGraphSolver"
+import { createConvexViaGraphFromXYConnections } from "../lib/ViaGraphSolver/via-graph-generator/createConvexViaGraphFromXYConnections"
+
+const args = process.argv.slice(2)
+const limitArg = args.find((a) => a.startsWith("--limit="))
+const SAMPLE_LIMIT = limitArg ? parseInt(limitArg.split("=")[1], 10) : undefined
+const QUICK_MODE = args.includes("--quick")
+const HELP = args.includes("--help") || args.includes("-h")
+
+if (HELP) {
+ console.log(`
+Usage: bun run scripts/benchmark-via-graph-convex-dataset02.ts [options]
+
+Options:
+ --limit=N Only run first N samples (default: all 1000)
+ --quick Use reduced MAX_ITERATIONS for faster but less accurate results
+ --help, -h Show this help message
+
+Examples:
+ bun run scripts/benchmark-via-graph-convex-dataset02.ts --limit=50
+ bun run scripts/benchmark-via-graph-convex-dataset02.ts --quick --limit=100
+`)
+ process.exit(0)
+}
+
+type DatasetSample = {
+ config: {
+ numCrossings: number
+ seed: number
+ rows: number
+ cols: number
+ orientation: "vertical" | "horizontal"
+ }
+ connections: {
+ connectionId: string
+ startRegionId: string
+ endRegionId: string
+ }[]
+ connectionRegions: {
+ regionId: string
+ pointIds: string[]
+ d: {
+ bounds: { minX: number; maxX: number; minY: number; maxY: number }
+ center: { x: number; y: number }
+ isPad: boolean
+ isConnectionRegion: boolean
+ }
+ }[]
+}
+
+type ViasByNet = Record<
+ string,
+ { viaId: string; diameter: number; position: { x: number; y: number } }[]
+>
+
+const median = (numbers: number[]): number | undefined => {
+ if (numbers.length === 0) return undefined
+ const sorted = numbers.slice().sort((a, b) => a - b)
+ const middle = Math.floor(sorted.length / 2)
+ return sorted[middle]
+}
+
+const percentile = (numbers: number[], p: number): number | undefined => {
+ if (numbers.length === 0) return undefined
+ const sorted = numbers.slice().sort((a, b) => a - b)
+ const index = Math.floor((p / 100) * (sorted.length - 1))
+ return sorted[index]
+}
+
+const mean = (numbers: number[]): number | undefined => {
+ if (numbers.length === 0) return undefined
+ return numbers.reduce((sum, n) => sum + n, 0) / numbers.length
+}
+
+const extractXYConnections = (sample: DatasetSample): XYConnection[] => {
+ const regionMap = new Map(
+ sample.connectionRegions.map((r) => [r.regionId, r.d.center]),
+ )
+
+ return sample.connections.map((conn) => {
+ const start = regionMap.get(conn.startRegionId)
+ const end = regionMap.get(conn.endRegionId)
+
+ if (!start || !end) {
+ throw new Error(
+ `Missing region for connection ${conn.connectionId}: start=${conn.startRegionId}, end=${conn.endRegionId}`,
+ )
+ }
+
+ return {
+ connectionId: conn.connectionId,
+ start,
+ end,
+ }
+ })
+}
+
+const tryToSolve = (
+ xyConnections: XYConnection[],
+ quickMode: boolean,
+): {
+ solved: boolean
+ failed: boolean
+ iterations: number
+ duration: number
+ tileRows: number
+ tileCols: number
+ convexRegions: number
+ viaRegions: number
+ error?: string
+} => {
+ try {
+ const result = createConvexViaGraphFromXYConnections(xyConnections)
+
+ const convexRegions = result.regions.filter((r) =>
+ r.regionId.startsWith("convex:"),
+ ).length
+ const viaRegions = result.regions.filter((r) => r.d.isViaRegion).length
+
+ const solverOpts: ConstructorParameters[0] = {
+ inputGraph: {
+ regions: result.regions,
+ ports: result.ports,
+ },
+ inputConnections: result.connections,
+ viasByNet: result.tiledViasByNet,
+ }
+
+ if (quickMode) {
+ solverOpts.baseMaxIterations = 50000
+ }
+
+ const solver = new ViaGraphSolver(solverOpts)
+
+ const startTime = performance.now()
+ solver.solve()
+ const duration = performance.now() - startTime
+
+ return {
+ solved: solver.solved,
+ failed: solver.failed,
+ iterations: solver.iterations,
+ duration,
+ tileRows: result.tileCount.rows,
+ tileCols: result.tileCount.cols,
+ convexRegions,
+ viaRegions,
+ }
+ } catch (e) {
+ return {
+ solved: false,
+ failed: true,
+ iterations: 0,
+ duration: 0,
+ tileRows: 0,
+ tileCols: 0,
+ convexRegions: 0,
+ viaRegions: 0,
+ error: e instanceof Error ? e.message : String(e),
+ }
+ }
+}
+
+const datasetPath = path.join(
+ __dirname,
+ "../datasets/jumper-graph-solver/dataset02.json",
+)
+const dataset: DatasetSample[] = JSON.parse(
+ fs.readFileSync(datasetPath, "utf8"),
+)
+
+console.log("Benchmark: ViaGraphSolver with Dataset02 (Convex Regions)")
+console.log("=".repeat(70))
+console.log(`Loaded ${dataset.length} samples from dataset02`)
+if (SAMPLE_LIMIT) {
+ console.log(`Sample limit: ${SAMPLE_LIMIT}`)
+}
+if (QUICK_MODE) {
+ console.log(`Quick mode: enabled (reduced MAX_ITERATIONS)`)
+}
+console.log()
+
+type BenchmarkResult = {
+ sampleIndex: number
+ numCrossings: number
+ seed: number
+ rows: number
+ cols: number
+ orientation: "vertical" | "horizontal"
+ solved: boolean
+ failed: boolean
+ iterations: number
+ duration: number
+ tileRows: number
+ tileCols: number
+ convexRegions: number
+ viaRegions: number
+ error?: string
+}
+
+const results: BenchmarkResult[] = []
+
+let lastProgressTime = Date.now()
+const startTime = Date.now()
+
+const printProgress = (sampleIndex: number, total: number) => {
+ const solvedSoFar = results.filter((r) => r.solved).length
+ const failedSoFar = results.filter((r) => r.failed && !r.solved).length
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
+ console.log(
+ `[${elapsed}s] Sample ${sampleIndex + 1}/${total} | ` +
+ `Solved: ${solvedSoFar} | Failed: ${failedSoFar} | ` +
+ `Rate: ${((solvedSoFar / results.length) * 100).toFixed(1)}%`,
+ )
+}
+
+const samplesToRun = SAMPLE_LIMIT ? dataset.slice(0, SAMPLE_LIMIT) : dataset
+const totalSamples = samplesToRun.length
+
+for (let i = 0; i < totalSamples; i++) {
+ const sample = samplesToRun[i]
+ const xyConnections = extractXYConnections(sample)
+
+ const now = Date.now()
+ if (now - lastProgressTime >= 1000) {
+ printProgress(i, totalSamples)
+ lastProgressTime = now
+ }
+
+ const result = tryToSolve(xyConnections, QUICK_MODE)
+
+ results.push({
+ sampleIndex: i,
+ numCrossings: sample.config.numCrossings,
+ seed: sample.config.seed,
+ rows: sample.config.rows,
+ cols: sample.config.cols,
+ orientation: sample.config.orientation,
+ solved: result.solved,
+ failed: result.failed,
+ iterations: result.iterations,
+ duration: result.duration,
+ tileRows: result.tileRows,
+ tileCols: result.tileCols,
+ convexRegions: result.convexRegions,
+ viaRegions: result.viaRegions,
+ error: result.error,
+ })
+}
+
+const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1)
+console.log(`\nCompleted in ${totalElapsed}s\n`)
+
+const solvedResults = results.filter((r) => r.solved)
+const failedResults = results.filter((r) => r.failed && !r.solved)
+const unsolved = results.filter((r) => !r.solved)
+
+const successRate = (solvedResults.length / results.length) * 100
+
+console.log("=".repeat(70))
+console.log("Overall Results")
+console.log("=".repeat(70))
+console.log(`Total samples: ${results.length}`)
+console.log(
+ `Solved: ${solvedResults.length} (${successRate.toFixed(1)}%)`,
+)
+console.log(
+ `Failed: ${failedResults.length} (${((failedResults.length / results.length) * 100).toFixed(1)}%)`,
+)
+console.log(
+ `Unsolved: ${unsolved.length} (${((unsolved.length / results.length) * 100).toFixed(1)}%)`,
+)
+
+const avgConvexRegions = mean(solvedResults.map((r) => r.convexRegions))
+const avgViaRegions = mean(solvedResults.map((r) => r.viaRegions))
+console.log(`\nAvg convex regions: ${avgConvexRegions?.toFixed(1) ?? "N/A"}`)
+console.log(`Avg via regions: ${avgViaRegions?.toFixed(1) ?? "N/A"}`)
+
+const solvedIterations = solvedResults.map((r) => r.iterations)
+const solvedDurations = solvedResults.map((r) => r.duration)
+
+console.log("\n" + "=".repeat(70))
+console.log("Performance Statistics (Solved Samples)")
+console.log("=".repeat(70))
+console.log(
+ `Iterations - Mean: ${mean(solvedIterations)?.toFixed(0) ?? "N/A"}, ` +
+ `Median: ${median(solvedIterations)?.toFixed(0) ?? "N/A"}, ` +
+ `P90: ${percentile(solvedIterations, 90)?.toFixed(0) ?? "N/A"}, ` +
+ `P99: ${percentile(solvedIterations, 99)?.toFixed(0) ?? "N/A"}`,
+)
+console.log(
+ `Duration (ms) - Mean: ${mean(solvedDurations)?.toFixed(1) ?? "N/A"}, ` +
+ `Median: ${median(solvedDurations)?.toFixed(1) ?? "N/A"}, ` +
+ `P90: ${percentile(solvedDurations, 90)?.toFixed(1) ?? "N/A"}, ` +
+ `P99: ${percentile(solvedDurations, 99)?.toFixed(1) ?? "N/A"}`,
+)
+
+const tileCounts = solvedResults.map((r) => `${r.tileCols}x${r.tileRows}`)
+const tileCountMap = new Map()
+for (const t of tileCounts) {
+ tileCountMap.set(t, (tileCountMap.get(t) || 0) + 1)
+}
+console.log("\nTile grid distribution (solved):")
+const sortedTiles = Array.from(tileCountMap.entries()).sort((a, b) => {
+ const [aCols, aRows] = a[0].split("x").map(Number)
+ const [bCols, bRows] = b[0].split("x").map(Number)
+ return aCols * aRows - bCols * bRows
+})
+for (const [tile, count] of sortedTiles) {
+ const pct = ((count / solvedResults.length) * 100).toFixed(1)
+ console.log(` ${tile}: ${count} (${pct}%)`)
+}
+
+console.log("\n" + "=".repeat(70))
+console.log("Success Rate by Crossing Count")
+console.log("=".repeat(70))
+const crossingGroups = new Map<
+ number,
+ { solved: number; total: number; iterations: number[]; durations: number[] }
+>()
+for (const r of results) {
+ const crossings = r.numCrossings
+ if (!crossingGroups.has(crossings)) {
+ crossingGroups.set(crossings, {
+ solved: 0,
+ total: 0,
+ iterations: [],
+ durations: [],
+ })
+ }
+ const group = crossingGroups.get(crossings)!
+ group.total++
+ if (r.solved) {
+ group.solved++
+ group.iterations.push(r.iterations)
+ group.durations.push(r.duration)
+ }
+}
+const sortedCrossings = Array.from(crossingGroups.entries()).sort(
+ (a, b) => a[0] - b[0],
+)
+for (const [crossings, { solved, total, iterations }] of sortedCrossings) {
+ const pct = ((solved / total) * 100).toFixed(0)
+ const medIters = median(iterations)?.toFixed(0) ?? "N/A"
+ console.log(
+ ` ${crossings.toString().padStart(2)} crossings: ${solved.toString().padStart(3)}/${total.toString().padStart(3)} (${pct.padStart(3)}%) | Median iters: ${medIters}`,
+ )
+}
+
+console.log("\n" + "=".repeat(70))
+console.log("Success Rate by Problem Grid Size (rows x cols)")
+console.log("=".repeat(70))
+const gridGroups = new Map<
+ string,
+ { solved: number; total: number; iterations: number[] }
+>()
+for (const r of results) {
+ const grid = `${r.rows}x${r.cols}`
+ if (!gridGroups.has(grid)) {
+ gridGroups.set(grid, { solved: 0, total: 0, iterations: [] })
+ }
+ const group = gridGroups.get(grid)!
+ group.total++
+ if (r.solved) {
+ group.solved++
+ group.iterations.push(r.iterations)
+ }
+}
+const sortedGrids = Array.from(gridGroups.entries()).sort((a, b) => {
+ const [aRows, aCols] = a[0].split("x").map(Number)
+ const [bRows, bCols] = b[0].split("x").map(Number)
+ return aRows * aCols - bRows * bCols
+})
+for (const [grid, { solved, total, iterations }] of sortedGrids) {
+ const pct = ((solved / total) * 100).toFixed(0)
+ const medIters = median(iterations)?.toFixed(0) ?? "N/A"
+ console.log(
+ ` ${grid.padStart(4)}: ${solved.toString().padStart(3)}/${total.toString().padStart(3)} (${pct.padStart(3)}%) | Median iters: ${medIters}`,
+ )
+}
+
+console.log("\n" + "=".repeat(70))
+console.log("Success Rate by Orientation")
+console.log("=".repeat(70))
+const orientationGroups = new Map<
+ string,
+ { solved: number; total: number; iterations: number[] }
+>()
+for (const r of results) {
+ const orient = r.orientation
+ if (!orientationGroups.has(orient)) {
+ orientationGroups.set(orient, { solved: 0, total: 0, iterations: [] })
+ }
+ const group = orientationGroups.get(orient)!
+ group.total++
+ if (r.solved) {
+ group.solved++
+ group.iterations.push(r.iterations)
+ }
+}
+for (const [orient, { solved, total, iterations }] of orientationGroups) {
+ const pct = ((solved / total) * 100).toFixed(0)
+ const medIters = median(iterations)?.toFixed(0) ?? "N/A"
+ console.log(
+ ` ${orient.padEnd(10)}: ${solved.toString().padStart(3)}/${total.toString().padStart(3)} (${pct.padStart(3)}%) | Median iters: ${medIters}`,
+ )
+}
+
+if (unsolved.length > 0 && unsolved.length <= 30) {
+ console.log("\n" + "=".repeat(70))
+ console.log("Unsolved Samples")
+ console.log("=".repeat(70))
+ for (const r of unsolved) {
+ console.log(
+ ` Sample ${r.sampleIndex}: ${r.numCrossings} crossings, ${r.rows}x${r.cols} ${r.orientation}, seed=${r.seed}${r.error ? ` (error: ${r.error})` : ""}`,
+ )
+ }
+} else if (unsolved.length > 30) {
+ console.log(
+ `\n${unsolved.length} samples could not be solved (showing first 30):`,
+ )
+ for (const r of unsolved.slice(0, 30)) {
+ console.log(
+ ` Sample ${r.sampleIndex}: ${r.numCrossings} crossings, ${r.rows}x${r.cols} ${r.orientation}, seed=${r.seed}`,
+ )
+ }
+}
+
+console.log("\n" + "=".repeat(70))
+console.log("Benchmark Complete")
+console.log("=".repeat(70))
diff --git a/tests/via-graph-solver/__snapshots__/via-graph-convex-dataset02.snap.svg b/tests/via-graph-solver/__snapshots__/via-graph-convex-dataset02.snap.svg
new file mode 100644
index 0000000..7a42fd3
--- /dev/null
+++ b/tests/via-graph-solver/__snapshots__/via-graph-convex-dataset02.snap.svg
@@ -0,0 +1,74 @@
+
\ No newline at end of file
diff --git a/tests/via-graph-solver/via-graph-convex-dataset02.test.ts b/tests/via-graph-solver/via-graph-convex-dataset02.test.ts
new file mode 100644
index 0000000..403bf73
--- /dev/null
+++ b/tests/via-graph-solver/via-graph-convex-dataset02.test.ts
@@ -0,0 +1,95 @@
+import { expect, test } from "bun:test"
+import viasByNet from "assets/ViaGraphSolver/vias-by-net.json"
+import { getSvgFromGraphicsObject } from "graphics-debug"
+import type { XYConnection } from "lib/JumperGraphSolver/jumper-graph-generator/createGraphWithConnectionsFromBaseGraph"
+import { ViaGraphSolver } from "lib/ViaGraphSolver/ViaGraphSolver"
+import { createConvexViaGraphFromXYConnections } from "lib/ViaGraphSolver/via-graph-generator/createConvexViaGraphFromXYConnections"
+import dataset02 from "../../datasets/jumper-graph-solver/dataset02.json"
+
+interface DatasetSample {
+ config: {
+ numCrossings: number
+ seed: number
+ rows: number
+ cols: number
+ orientation: "vertical" | "horizontal"
+ }
+ connections: {
+ connectionId: string
+ startRegionId: string
+ endRegionId: string
+ }[]
+ connectionRegions: {
+ regionId: string
+ pointIds: string[]
+ d: {
+ bounds: { minX: number; maxX: number; minY: number; maxY: number }
+ center: { x: number; y: number }
+ isPad: boolean
+ isConnectionRegion: boolean
+ }
+ }[]
+}
+
+const typedDataset = dataset02 as DatasetSample[]
+
+const extractXYConnections = (sample: DatasetSample): XYConnection[] => {
+ const regionMap = new Map(
+ sample.connectionRegions.map((r) => [r.regionId, r.d.center]),
+ )
+
+ return sample.connections.map((conn) => {
+ const start = regionMap.get(conn.startRegionId)
+ const end = regionMap.get(conn.endRegionId)
+
+ if (!start || !end) {
+ throw new Error(
+ `Missing region for connection ${conn.connectionId}: start=${conn.startRegionId}, end=${conn.endRegionId}`,
+ )
+ }
+
+ return {
+ connectionId: conn.connectionId,
+ start,
+ end,
+ }
+ })
+}
+
+test("via-graph-convex-dataset02: solve sample 0 with convex regions", () => {
+ const sample = typedDataset[0]
+ const xyConnections = extractXYConnections(sample)
+
+ const result = createConvexViaGraphFromXYConnections(xyConnections, viasByNet)
+
+ // Verify tiling occurred
+ expect(result.tileCount.rows).toBeGreaterThanOrEqual(0)
+ expect(result.tileCount.cols).toBeGreaterThanOrEqual(0)
+
+ // Verify we have convex regions (regionId starts with "convex:")
+ const convexRegions = result.regions.filter((r) =>
+ r.regionId.startsWith("convex:"),
+ )
+ expect(convexRegions.length).toBeGreaterThan(0)
+
+ // Verify we have via regions
+ const viaRegions = result.regions.filter((r) => r.d.isViaRegion)
+ expect(viaRegions.length).toBeGreaterThan(0)
+
+ const solver = new ViaGraphSolver({
+ inputGraph: {
+ regions: result.regions,
+ ports: result.ports,
+ },
+ inputConnections: result.connections,
+ viasByNet: result.tiledViasByNet,
+ })
+
+ solver.solve()
+
+ expect(solver.solved).toBe(true)
+
+ expect(getSvgFromGraphicsObject(solver.visualize())).toMatchSvgSnapshot(
+ import.meta.path,
+ )
+}, 30000)