From 5ccf4e4e46df50eeb57a9cbf88c360dfa8be00eb Mon Sep 17 00:00:00 2001 From: Jin Date: Wed, 9 Sep 2026 10:35:34 +0800 Subject: [PATCH 1/3] fix: near-axis-aligned segments no longer escape obstacle collision segmentIntersectsRect declared any segment that is neither exactly vertical nor exactly horizontal collision-free. Real pin coordinates are not always exactly on the routing grid (e.g. x = 5.3999378 next to x = 5.4), so nearly axis-aligned segments sailed through chip bodies and were accepted by UnroutedTraceRecoverySolver as collision-free. Non-axis-aligned segments now run a slab (Liang-Barsky) clip against the rect. Axis-aligned fast paths keep their historical semantics: a real interior overlap counts, a segment that merely touches an edge or corner does not (tMax - tMin > eps for the slanted path). Fixes #1099 --- .../collisions.ts | 47 +++++++++++++- .../segment-intersects-rect-near-axis.test.ts | 61 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 tests/solvers/SchematicTraceSingleLineSolver2/segment-intersects-rect-near-axis.test.ts diff --git a/lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/collisions.ts b/lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/collisions.ts index 3586a89ff..95aa6ab0f 100644 --- a/lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/collisions.ts +++ b/lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/collisions.ts @@ -16,7 +16,12 @@ export const segmentIntersectsRect = ( ): boolean => { const vert = isVertical(a, b, eps) const horz = isHorizontal(a, b, eps) - if (!vert && !horz) return false + if (!vert && !horz) { + // Near-axis-aligned segments (jittered pin coordinates) previously fell + // through as collision-free here. Slanted segments must still be tested + // against the rect instead of being declared collision-free. + return segmentIntersectsRectSlanted(a, b, r, eps) + } if (vert) { const x = a.x @@ -35,6 +40,46 @@ export const segmentIntersectsRect = ( } } +/** + * General segment/AABB intersection via slab (Liang-Barsky) clipping. + * Only used for non-axis-aligned segments; axis-aligned fast paths above + * keep the historical overlap semantics (strictly inside counts, touching + * an edge does not). + */ +const segmentIntersectsRectSlanted = ( + a: Point, + b: Point, + r: TRect, + eps = EPS, +): boolean => { + const dx = b.x - a.x + const dy = b.y - a.y + let tMin = 0 + let tMax = 1 + const clip = (p: number, q: number): boolean => { + if (Math.abs(p) <= eps) return q > eps + const t = q / p + if (p < 0) { + if (t > tMax) return false + if (t > tMin) tMin = t + } else { + if (t < tMin) return false + if (t < tMax) tMax = t + } + return true + } + + if (!clip(-dx, a.x - r.minX)) return false + if (!clip(dx, r.maxX - a.x)) return false + if (!clip(-dy, a.y - r.minY)) return false + if (!clip(dy, r.maxY - a.y)) return false + + // A degenerate intersection interval (tMin ~= tMax) means the segment + // merely touches an edge or corner — matching the axis-aligned paths' + // "touching does not count" semantics, require a real overlap. + return tMax - tMin > eps +} + export const segmentOverlapsRectBoundary = ( a: Point, b: Point, diff --git a/tests/solvers/SchematicTraceSingleLineSolver2/segment-intersects-rect-near-axis.test.ts b/tests/solvers/SchematicTraceSingleLineSolver2/segment-intersects-rect-near-axis.test.ts new file mode 100644 index 000000000..f84bbf05a --- /dev/null +++ b/tests/solvers/SchematicTraceSingleLineSolver2/segment-intersects-rect-near-axis.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test" +import { segmentIntersectsRect } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/collisions" + +// https://github.com/tscircuit/schematic-trace-solver/issues/1099 +// Real pin coordinates are not always exactly on the routing grid: a segment +// between x = 5.3999378 and x = 5.4 has dx = 6.22e-5 — far below the schematic +// grid but five orders of magnitude above the 1e-9 axis-alignment EPS. Such +// segments used to bypass every obstacle check. + +const rect = { + minX: 0, + maxX: 2, + minY: 0, + maxY: 2, +} + +test("nearly vertical segment crossing a chip body is a collision", () => { + // dx = 6.22e-5 (from issue), dy = 4 — passes straight through the rect + expect( + segmentIntersectsRect( + { x: 0.5 + 6.22e-5, y: -1 }, + { x: 0.5, y: 3 }, + rect, + ), + ).toBe(true) +}) + +test("nearly horizontal segment crossing a chip body is a collision", () => { + expect( + segmentIntersectsRect( + { x: -1, y: 1 + 6.22e-5 }, + { x: 3, y: 1 }, + rect, + ), + ).toBe(true) +}) + +test("nearly vertical segment outside the rect stays collision-free", () => { + expect( + segmentIntersectsRect({ x: 2.5, y: -1 }, { x: 2.5 + 6.22e-5, y: 3 }, rect), + ).toBe(false) +}) + +test("slanted segment through the rect interior is a collision", () => { + expect(segmentIntersectsRect({ x: -1, y: -1 }, { x: 3, y: 3 }, rect)).toBe( + true, + ) +}) + +test("slanted segment missing the rect is collision-free", () => { + expect( + segmentIntersectsRect({ x: -1, y: 2.5 }, { x: 3, y: 6 }, rect), + ).toBe(false) +}) + +test("slanted segment touching only a corner is collision-free", () => { + // Glances off the top-right corner without entering the interior + expect( + segmentIntersectsRect({ x: 2.2, y: 1.8 }, { x: 1.8, y: 2.2 }, rect), + ).toBe(false) +}) From a4e085c77c3074d319d8c615be4d93bf0af913f1 Mon Sep 17 00:00:00 2001 From: Jin Date: Wed, 9 Sep 2026 10:57:00 +0800 Subject: [PATCH 2/3] fix: oscillating net pair suspends only that pair, not the whole pass TraceOverlapShiftSolver treated its A -> B -> A oscillation guard as a reason to stop the entire solve. The first oscillating net pair aborted the pass, leaving every other independent cross-net overlap unresolved. Suspending only the oscillating pair lets the remaining pairs be corrected. The minimized repro from issue #1102 now resolves the RTS/DTR overlap (2 surviving cross-net overlaps -> 1, the genuinely oscillating GND/VBUS pair); board-1273 goes from 6 unresolved pairs down to 1 in 37 iterations (previously 6 pairs after 27). Fixes #1102 --- .../TraceOverlapShiftSolver.ts | 35 ++- .../board-1273-trace-overlap-cycle.snap.svg | 82 +++---- ...sue-1102-oscillating-pair-abort.input.json | 206 ++++++++++++++++++ .../board-1273-trace-overlap-cycle.test.ts | 4 +- 4 files changed, 276 insertions(+), 51 deletions(-) create mode 100644 tests/repros/assets/issue-1102-oscillating-pair-abort.input.json diff --git a/lib/solvers/TraceOverlapShiftSolver/TraceOverlapShiftSolver.ts b/lib/solvers/TraceOverlapShiftSolver/TraceOverlapShiftSolver.ts index 8a54dd22c..49461843e 100644 --- a/lib/solvers/TraceOverlapShiftSolver/TraceOverlapShiftSolver.ts +++ b/lib/solvers/TraceOverlapShiftSolver/TraceOverlapShiftSolver.ts @@ -41,12 +41,15 @@ export class TraceOverlapShiftSolver extends BaseSolver { declare activeSubSolver: TraceOverlapIssueSolver | null + correctedTraceMap: Record = {} + // Net pairs whose corrections oscillated A -> B -> A. They are suspended + // for the rest of the pass so independent pairs can still be corrected. + suspendedNetPairKeys: Set = new Set() /** * A traceNetIsland is a set of traces that are connected via the globalConnMap */ traceNetIslands: Record> = {} - correctedTraceMap: Record = {} // Keep only the current and previous layouts to detect a two-state cycle // without accumulating routing history for the whole solve. recentTraceStates: TraceState[] = [] @@ -105,7 +108,9 @@ export class TraceOverlapShiftSolver extends BaseSolver { overlappingTraceSegments: Array interactionKind: TraceInteractionKind } | null { - // Detect the next set of overlapping segments between two different net islands. + // Detect the next set of overlapping segments between two different net + // islands. Net pairs whose corrections oscillated earlier this pass are + // suspended: they are skipped so independent pairs keep being corrected. const EPS = 2e-3 const netIds = Object.keys(this.traceNetIslands) @@ -114,6 +119,13 @@ export class TraceOverlapShiftSolver extends BaseSolver { for (let j = i + 1; j < netIds.length; j++) { const netA = netIds[i]! const netB = netIds[j]! + if ( + this.suspendedNetPairKeys.has( + [netA, netB].sort().join("~"), + ) + ) { + continue + } const pathsA = this.traceNetIslands[netA] || [] const pathsB = this.traceNetIslands[netB] || [] @@ -374,16 +386,21 @@ export class TraceOverlapShiftSolver extends BaseSolver { ...this.activeSubSolver.correctedTraceMap, } // Returning to the older retained layout means corrections are bouncing - // A -> B -> A. Keep B and finish this pass instead of retrying forever. + // A -> B -> A. Keep B and suspend only this net pair — other net pairs + // are independent and may still need correction this pass. if (this.returnsToPreviousTraceState(nextTraceState)) { + const suspendedPairKey = this.activeSubSolver.overlappingTraceSegments + .map((locator) => locator.connNetId) + .sort() + .join("~") + this.suspendedNetPairKeys.add(suspendedPairKey) this.activeSubSolver = null - this.solved = true - return + } else { + this.correctedTraceMap = nextTraceState + this.rememberTraceState(nextTraceState) + this.activeSubSolver = null + this.traceNetIslands = this.computeTraceNetIslands() } - this.correctedTraceMap = nextTraceState - this.rememberTraceState(nextTraceState) - this.activeSubSolver = null - this.traceNetIslands = this.computeTraceNetIslands() } if (this.activeSubSolver) { diff --git a/tests/repros/__snapshots__/board-1273-trace-overlap-cycle.snap.svg b/tests/repros/__snapshots__/board-1273-trace-overlap-cycle.snap.svg index 454d02558..cdabef9a1 100644 --- a/tests/repros/__snapshots__/board-1273-trace-overlap-cycle.snap.svg +++ b/tests/repros/__snapshots__/board-1273-trace-overlap-cycle.snap.svg @@ -1,18 +1,18 @@ - XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX .pin1 to .U6 > .VBUS", + "pinIds": [ + "schematic_port_108", + "schematic_port_80" + ] + } + ], + "netConnections": [ + { + "netId": "GND", + "isGround": true, + "netLabelWidth": 0.42, + "netLabelHeight": 0.48, + "pinIds": [ + "schematic_port_75", + "schematic_port_101", + "schematic_port_106", + "schematic_port_109" + ] + }, + { + "netId": "VBUS", + "netLabelWidth": 0.42, + "netLabelHeight": 0.6, + "pinIds": [ + "schematic_port_79", + "schematic_port_111" + ] + }, + { + "netId": "RTS", + "netLabelWidth": 0.48, + "pinIds": [ + "schematic_port_96", + "schematic_port_118", + "schematic_port_122" + ] + }, + { + "netId": "DTR", + "netLabelWidth": 0.48, + "pinIds": [ + "schematic_port_100", + "schematic_port_119", + "schematic_port_121" + ] + } + ], + "textBoxes": [], + "availableNetLabelOrientations": { + "GND": [ + "y-" + ], + "VBUS": [ + "y+" + ], + "RTS": [ + "x-", + "x+" + ], + "DTR": [ + "x-", + "x+" + ] + }, + "maxMspPairDistance": 8, + "_hideRatsNet": false +} diff --git a/tests/repros/board-1273-trace-overlap-cycle.test.ts b/tests/repros/board-1273-trace-overlap-cycle.test.ts index 4c15ac8f5..21e4beb7c 100644 --- a/tests/repros/board-1273-trace-overlap-cycle.test.ts +++ b/tests/repros/board-1273-trace-overlap-cycle.test.ts @@ -24,7 +24,9 @@ test("board 1273 solves within the trace overlap iteration limit", async () => { expect(solver.solved).toBe(true) expect(solver.failed).toBe(false) - expect(solver.traceOverlapShiftSolver?.iterations).toBe(27) + // Issue #1102 fix: oscillating pairs are suspended instead of aborting the + // pass, so more independent pairs get corrected before the pass ends. + expect(solver.traceOverlapShiftSolver?.iterations).toBe(37) expect(solver.postLabelTraceOverlapShiftSolver?.iterations).toBe(1) await expect(solver).toMatchSolverSnapshot(import.meta.path) }) From a4abf155fa0f2cbba04bc17f47d47945a06b1f96 Mon Sep 17 00:00:00 2001 From: Jin Date: Wed, 9 Sep 2026 11:11:08 +0800 Subject: [PATCH 3/3] style: apply biome format --- .../TraceOverlapShiftSolver.ts | 6 +----- .../segment-intersects-rect-near-axis.test.ts | 18 +++++------------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/lib/solvers/TraceOverlapShiftSolver/TraceOverlapShiftSolver.ts b/lib/solvers/TraceOverlapShiftSolver/TraceOverlapShiftSolver.ts index 49461843e..684d68f71 100644 --- a/lib/solvers/TraceOverlapShiftSolver/TraceOverlapShiftSolver.ts +++ b/lib/solvers/TraceOverlapShiftSolver/TraceOverlapShiftSolver.ts @@ -119,11 +119,7 @@ export class TraceOverlapShiftSolver extends BaseSolver { for (let j = i + 1; j < netIds.length; j++) { const netA = netIds[i]! const netB = netIds[j]! - if ( - this.suspendedNetPairKeys.has( - [netA, netB].sort().join("~"), - ) - ) { + if (this.suspendedNetPairKeys.has([netA, netB].sort().join("~"))) { continue } const pathsA = this.traceNetIslands[netA] || [] diff --git a/tests/solvers/SchematicTraceSingleLineSolver2/segment-intersects-rect-near-axis.test.ts b/tests/solvers/SchematicTraceSingleLineSolver2/segment-intersects-rect-near-axis.test.ts index f84bbf05a..f6480d92d 100644 --- a/tests/solvers/SchematicTraceSingleLineSolver2/segment-intersects-rect-near-axis.test.ts +++ b/tests/solvers/SchematicTraceSingleLineSolver2/segment-intersects-rect-near-axis.test.ts @@ -17,21 +17,13 @@ const rect = { test("nearly vertical segment crossing a chip body is a collision", () => { // dx = 6.22e-5 (from issue), dy = 4 — passes straight through the rect expect( - segmentIntersectsRect( - { x: 0.5 + 6.22e-5, y: -1 }, - { x: 0.5, y: 3 }, - rect, - ), + segmentIntersectsRect({ x: 0.5 + 6.22e-5, y: -1 }, { x: 0.5, y: 3 }, rect), ).toBe(true) }) test("nearly horizontal segment crossing a chip body is a collision", () => { expect( - segmentIntersectsRect( - { x: -1, y: 1 + 6.22e-5 }, - { x: 3, y: 1 }, - rect, - ), + segmentIntersectsRect({ x: -1, y: 1 + 6.22e-5 }, { x: 3, y: 1 }, rect), ).toBe(true) }) @@ -48,9 +40,9 @@ test("slanted segment through the rect interior is a collision", () => { }) test("slanted segment missing the rect is collision-free", () => { - expect( - segmentIntersectsRect({ x: -1, y: 2.5 }, { x: 3, y: 6 }, rect), - ).toBe(false) + expect(segmentIntersectsRect({ x: -1, y: 2.5 }, { x: 3, y: 6 }, rect)).toBe( + false, + ) }) test("slanted segment touching only a corner is collision-free", () => {