diff --git a/algos/infinite-grid-ijump-astar/tests/__snapshots__/remove-path-loops-1.snap.svg b/algos/infinite-grid-ijump-astar/tests/__snapshots__/remove-path-loops-1.snap.svg
new file mode 100644
index 0000000..b10f377
--- /dev/null
+++ b/algos/infinite-grid-ijump-astar/tests/__snapshots__/remove-path-loops-1.snap.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/algos/infinite-grid-ijump-astar/tests/__snapshots__/remove-path-loops-2.snap.svg b/algos/infinite-grid-ijump-astar/tests/__snapshots__/remove-path-loops-2.snap.svg
new file mode 100644
index 0000000..c92de33
--- /dev/null
+++ b/algos/infinite-grid-ijump-astar/tests/__snapshots__/remove-path-loops-2.snap.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/algos/infinite-grid-ijump-astar/tests/fixtures/get-path-comparison-svg.ts b/algos/infinite-grid-ijump-astar/tests/fixtures/get-path-comparison-svg.ts
new file mode 100644
index 0000000..e05e2dd
--- /dev/null
+++ b/algos/infinite-grid-ijump-astar/tests/fixtures/get-path-comparison-svg.ts
@@ -0,0 +1,93 @@
+import type { Point } from "@tscircuit/math-utils"
+import type { PointWithLayer } from "solver-utils"
+import {
+ scale,
+ fromTriangles,
+ translate,
+ compose,
+ applyToPoint,
+} from "transformation-matrix"
+
+export const getPathComparisonSvg = (
+ pathMap: Record,
+) => {
+ const svgWidth = 640
+ const svgHeight = 480
+
+ // Find min and max coordinates
+ let minX = Infinity,
+ maxX = -Infinity,
+ minY = Infinity,
+ maxY = -Infinity
+ Object.values(pathMap)
+ .flat()
+ .forEach((point) => {
+ minX = Math.min(minX, point.x)
+ maxX = Math.max(maxX, point.x)
+ minY = Math.min(minY, point.y)
+ maxY = Math.max(maxY, point.y)
+ })
+
+ // Compute scale and translation
+ const padding = 20 // Padding around the edges
+
+ // Define triangles in path coordinate space and SVG coordinate space
+ const pathTriangle = [
+ { x: minX, y: minY },
+ { x: maxX, y: minY },
+ { x: minX, y: maxY },
+ ]
+ const svgTriangle = [
+ { x: padding, y: padding },
+ { x: svgWidth - padding, y: padding },
+ { x: padding, y: svgHeight - padding },
+ ]
+
+ // Compute the transform using fromTriangles
+ const transform = fromTriangles(pathTriangle, svgTriangle)
+
+ let svg = `"
+ return svg
+}
diff --git a/algos/infinite-grid-ijump-astar/tests/remove-path-loops-1.test.tsx b/algos/infinite-grid-ijump-astar/tests/remove-path-loops-1.test.tsx
new file mode 100644
index 0000000..1200f59
--- /dev/null
+++ b/algos/infinite-grid-ijump-astar/tests/remove-path-loops-1.test.tsx
@@ -0,0 +1,36 @@
+import { Circuit } from "@tscircuit/core"
+import { expect, test } from "bun:test"
+import type { AnyCircuitElement } from "circuit-json"
+import { getSimpleRouteJson } from "solver-utils"
+import { IJumpMultiMarginAutorouter } from "../v2/lib/IJumpMultiMarginAutorouter"
+import { getDebugSvg } from "./fixtures/get-debug-svg"
+import { removePathLoops } from "solver-postprocessing/remove-path-loops"
+import { getPathComparisonSvg } from "./fixtures/get-path-comparison-svg"
+
+test("remove-path-loops 1: simple loop", () => {
+ /**
+ * Ascii art of the path:
+ * ......
+ * . .
+ * ..........
+ * .
+ * .
+ */
+ // Create a path with an intentional loop
+ const pathWithLoop: Array<{ x: number; y: number; layer: string }> = [
+ { x: 0, y: 0, layer: "top" },
+ { x: 5, y: 0, layer: "top" },
+ { x: 5, y: 3, layer: "top" },
+ { x: 3, y: 3, layer: "top" },
+ { x: 3, y: -3, layer: "top" },
+ ]
+
+ const simplifiedPath = removePathLoops(pathWithLoop)
+
+ expect(
+ getPathComparisonSvg({
+ pathWithLoop,
+ simplifiedPath,
+ }),
+ ).toMatchSvgSnapshot(import.meta.path)
+})
diff --git a/algos/infinite-grid-ijump-astar/tests/remove-path-loops-2.test.tsx b/algos/infinite-grid-ijump-astar/tests/remove-path-loops-2.test.tsx
new file mode 100644
index 0000000..a5a0fcf
--- /dev/null
+++ b/algos/infinite-grid-ijump-astar/tests/remove-path-loops-2.test.tsx
@@ -0,0 +1,38 @@
+import { Circuit } from "@tscircuit/core"
+import { expect, test } from "bun:test"
+import type { AnyCircuitElement } from "circuit-json"
+import { getSimpleRouteJson } from "solver-utils"
+import { IJumpMultiMarginAutorouter } from "../v2/lib/IJumpMultiMarginAutorouter"
+import { getDebugSvg } from "./fixtures/get-debug-svg"
+import { removePathLoops } from "solver-postprocessing/remove-path-loops"
+import { getPathComparisonSvg } from "./fixtures/get-path-comparison-svg"
+
+test("remove-path-loops 2: simple loop", () => {
+ /**
+ * Ascii art of the path:
+ * ......
+ * . .
+ * ..........
+ * .
+ * .
+ */
+ // Create a path with an intentional loop
+ const pathWithLoop: Array<{ x: number; y: number; layer: string }> = [
+ { x: 0, y: 0, layer: "top" },
+ { x: 5, y: 0, layer: "top" },
+ { x: 5, y: 3, layer: "top" },
+ { x: 5, y: 3, layer: "top" },
+ { x: 3, y: 3, layer: "top" },
+ { x: 3, y: 3, layer: "bottom" },
+ { x: 3, y: -3, layer: "bottom" },
+ ]
+
+ const simplifiedPath = removePathLoops(pathWithLoop)
+
+ expect(
+ getPathComparisonSvg({
+ pathWithLoop,
+ simplifiedPath,
+ }),
+ ).toMatchSvgSnapshot(import.meta.path)
+})
diff --git a/algos/multi-layer-ijump/index.ts b/algos/multi-layer-ijump/index.ts
index 31fdef4..152ea17 100644
--- a/algos/multi-layer-ijump/index.ts
+++ b/algos/multi-layer-ijump/index.ts
@@ -14,7 +14,7 @@ export function autoroute(soup: AnySoupElement[]): SolutionWithDebugInfo {
const autorouter = new MultilayerIjump({
input,
connMap,
- // isRemovePathLoopsEnabled: true,
+ isRemovePathLoopsEnabled: true,
optimizeWithGoalBoxes: true,
})
diff --git a/algos/multi-layer-ijump/tests/repros/__snapshots__/repro2-path-loop-fails.test.tsx-no-loop-removal.snap.svg b/algos/multi-layer-ijump/tests/repros/__snapshots__/repro2-path-loop-fails.test.tsx-no-loop-removal.snap.svg
new file mode 100644
index 0000000..1498a6f
--- /dev/null
+++ b/algos/multi-layer-ijump/tests/repros/__snapshots__/repro2-path-loop-fails.test.tsx-no-loop-removal.snap.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/algos/multi-layer-ijump/tests/repros/__snapshots__/repro2-path-loop-fails.test.tsx-with-loop-removal.snap.svg b/algos/multi-layer-ijump/tests/repros/__snapshots__/repro2-path-loop-fails.test.tsx-with-loop-removal.snap.svg
new file mode 100644
index 0000000..1498a6f
--- /dev/null
+++ b/algos/multi-layer-ijump/tests/repros/__snapshots__/repro2-path-loop-fails.test.tsx-with-loop-removal.snap.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/algos/multi-layer-ijump/tests/repros/repro2-path-loop-fails.test.tsx b/algos/multi-layer-ijump/tests/repros/repro2-path-loop-fails.test.tsx
new file mode 100644
index 0000000..2bc18aa
--- /dev/null
+++ b/algos/multi-layer-ijump/tests/repros/repro2-path-loop-fails.test.tsx
@@ -0,0 +1,39 @@
+import { MultilayerIjump } from "algos/multi-layer-ijump/MultilayerIjump"
+import { test, expect } from "bun:test"
+import { getDatasetGenerator } from "autorouting-dataset/lib/generators"
+import { getSimpleRouteJson } from "solver-utils"
+import { convertCircuitJsonToPcbSvg } from "circuit-to-svg"
+
+test("repro2 path-loop-fails on single-trace sample 9", async () => {
+ const generator = getDatasetGenerator("single-trace")
+ const circuitJson = await generator.getExample({ seed: 9 })
+ const input = getSimpleRouteJson(circuitJson, { layerCount: 2 })
+
+ // Run with path loop removal disabled
+ const autorouter1 = new MultilayerIjump({
+ input,
+ isRemovePathLoopsEnabled: false,
+ debug: true,
+ })
+ const solution1 = autorouter1.solveAndMapToTraces()
+
+ // Run with path loop removal enabled
+ const autorouter2 = new MultilayerIjump({
+ input,
+ isRemovePathLoopsEnabled: true,
+ debug: true,
+ })
+ const solution2 = autorouter2.solveAndMapToTraces()
+
+ // Compare results
+ expect(solution1).toHaveLength(1)
+ expect(solution2).toHaveLength(1)
+
+ expect(
+ convertCircuitJsonToPcbSvg(circuitJson.concat(solution1 as any) as any),
+ ).toMatchSvgSnapshot(import.meta.path + "-no-loop-removal")
+
+ expect(
+ convertCircuitJsonToPcbSvg(circuitJson.concat(solution2 as any) as any),
+ ).toMatchSvgSnapshot(import.meta.path + "-with-loop-removal")
+})
diff --git a/module/lib/solver-postprocessing/remove-path-loops.ts b/module/lib/solver-postprocessing/remove-path-loops.ts
index a97220b..bbd0e78 100644
--- a/module/lib/solver-postprocessing/remove-path-loops.ts
+++ b/module/lib/solver-postprocessing/remove-path-loops.ts
@@ -4,15 +4,26 @@ interface PointWithLayer {
x: number
y: number
layer: string
+ route_type?: string
}
export function removePathLoops(path: T[]): T[] {
if (path.length < 4) return path // No loops possible with less than 4 points
- const result: PointWithLayer[] = [path[0]]
+ const result: PointWithLayer[] = [{ ...path[0] }]
+ let currentLayer = path[0].layer
for (let i = 1; i < path.length; i++) {
const currentSegment = { start: path[i - 1], end: path[i] }
+ const isVia =
+ path[i].route_type === "via" || path[i - 1].route_type === "via"
+
+ // Handle layer changes
+ if (path[i].layer !== currentLayer || isVia) {
+ result.push({ ...path[i] })
+ currentLayer = path[i].layer
+ continue
+ }
let intersectionFound = false
let intersectionPoint: PointWithLayer | null = null
@@ -24,16 +35,18 @@ export function removePathLoops(path: T[]): T[] {
if (previousSegment.start.layer !== currentSegment.start.layer) {
continue
}
- const intersection = findIntersection(previousSegment, currentSegment)
-
- if (intersection) {
- intersectionFound = true
- intersectionPoint = {
- ...intersection,
- layer: previousSegment.start.layer,
+ // Only check intersections on the same layer
+ if (previousSegment.start.layer === currentSegment.start.layer) {
+ const intersection = findIntersection(previousSegment, currentSegment)
+ if (intersection) {
+ intersectionFound = true
+ intersectionPoint = {
+ ...intersection,
+ layer: currentLayer,
+ }
+ intersectionIndex = j
+ break
}
- intersectionIndex = j
- break
}
}