From 8fd20afc054b0bea7599994bfcb0dcc2f237a95f Mon Sep 17 00:00:00 2001 From: giajoe24 Date: Sat, 17 Jan 2026 10:53:35 +0900 Subject: [PATCH 1/3] feat: implement TraceCombineSolver to merge same-net trace segments (Issue #29) --- .../SchematicTracePipelineSolver.ts | 14 + .../TraceCombineSolver/TraceCombineSolver.ts | 251 ++++++++++++++++++ tests/reproduction_issue_29.test.ts | 62 +++++ 3 files changed, 327 insertions(+) create mode 100644 lib/solvers/TraceCombineSolver/TraceCombineSolver.ts create mode 100644 tests/reproduction_issue_29.test.ts diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index c9d5a99..dc06191 100644 --- a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts +++ b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts @@ -20,6 +20,7 @@ import { expandChipsToFitPins } from "./expandChipsToFitPins" import { LongDistancePairSolver } from "../LongDistancePairSolver/LongDistancePairSolver" import { MergedNetLabelObstacleSolver } from "../TraceLabelOverlapAvoidanceSolver/sub-solvers/LabelMergingSolver/LabelMergingSolver" import { TraceCleanupSolver } from "../TraceCleanupSolver/TraceCleanupSolver" +import { TraceCombineSolver } from "../TraceCombineSolver/TraceCombineSolver" type PipelineStep BaseSolver> = { solverName: string @@ -68,7 +69,9 @@ export class SchematicTracePipelineSolver extends BaseSolver { netLabelPlacementSolver?: NetLabelPlacementSolver labelMergingSolver?: MergedNetLabelObstacleSolver traceLabelOverlapAvoidanceSolver?: TraceLabelOverlapAvoidanceSolver + traceCleanupSolver?: TraceCleanupSolver + traceCombineSolver?: TraceCombineSolver startTimeOfPhase: Record endTimeOfPhase: Record @@ -206,11 +209,22 @@ export class SchematicTracePipelineSolver extends BaseSolver { }, ] }), + definePipelineStep("traceCombineSolver", TraceCombineSolver, (instance) => { + return [ + { + inputTraces: + instance.traceCleanupSolver?.getOutput().traces ?? + instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces, + inputProblem: instance.inputProblem, + }, + ] + }), definePipelineStep( "netLabelPlacementSolver", NetLabelPlacementSolver, (instance) => { const traces = + instance.traceCombineSolver?.getOutput().traces ?? instance.traceCleanupSolver?.getOutput().traces ?? instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces diff --git a/lib/solvers/TraceCombineSolver/TraceCombineSolver.ts b/lib/solvers/TraceCombineSolver/TraceCombineSolver.ts new file mode 100644 index 0000000..afc6d4d --- /dev/null +++ b/lib/solvers/TraceCombineSolver/TraceCombineSolver.ts @@ -0,0 +1,251 @@ +import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" +import type { InputProblem, PinId } from "lib/types/InputProblem" +import type { SolvedTracePath } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import type { GraphicsObject } from "graphics-debug" + +export class TraceCombineSolver extends BaseSolver { + inputTraces: SolvedTracePath[] + inputProblem: InputProblem + outputTraces: SolvedTracePath[] = [] + + constructor(params: { + inputTraces: SolvedTracePath[] + inputProblem: InputProblem + }) { + super() + this.inputTraces = params.inputTraces + this.inputProblem = params.inputProblem + } + + override getConstructorParams(): ConstructorParameters< + typeof TraceCombineSolver + >[0] { + return { + inputTraces: this.inputTraces, + inputProblem: this.inputProblem, + } + } + + override _step() { + // Group traces by netId + const tracesByNet = new Map() + + for (const trace of this.inputTraces) { + // Use globalConnNetId or dcConnNetId as the grouping key + // Generally globalConnNetId is the best identifier for "same electrical net" + const netId = trace.globalConnNetId || trace.dcConnNetId + if (!tracesByNet.has(netId)) { + tracesByNet.set(netId, []) + } + tracesByNet.get(netId)!.push(trace) + } + + this.outputTraces = [] + + for (const [netId, traces] of tracesByNet) { + this.outputTraces.push(...this.combineTracesForNet(traces)) + } + + this.solved = true + } + + private combineTracesForNet(traces: SolvedTracePath[]): SolvedTracePath[] { + if (traces.length < 2) return traces + + let currentTraces = [...traces] + let changed = true + + while (changed) { + changed = false + const nextTraces: SolvedTracePath[] = [] + const processed = new Set() + + for (let i = 0; i < currentTraces.length; i++) { + if (processed.has(i)) continue + + let mergedTrace = currentTraces[i] + + for (let j = i + 1; j < currentTraces.length; j++) { + if (processed.has(j)) continue + + const otherTrace = currentTraces[j] + + const combined = this.tryCombineTraces(mergedTrace, otherTrace) + + if (combined) { + mergedTrace = combined + processed.add(j) // Mark as merged + changed = true + } + } + nextTraces.push(mergedTrace) + } + currentTraces = nextTraces + } + + return currentTraces + } + + private tryCombineTraces( + t1: SolvedTracePath, + t2: SolvedTracePath, + ): SolvedTracePath | null { + // Try all 4 combinations of connectivity: + // End of t1 -> Start of t2 + // End of t1 -> End of t2 (reverse t2) + // Start of t1 -> Start of t2 (reverse t1) + // Start of t1 -> End of t2 (reverse t1, reverse t2? or just t2->t1) + + // Helper to check connection + const checkConnection = ( + pathA: { x: number; y: number }[], + pathB: { x: number; y: number }[], + ) => { + // Check if pathA ends where pathB starts + // And potentially overlap + const endA = pathA[pathA.length - 1] + const startB = pathB[0] + + if ( + Math.abs(endA.x - startB.x) < 1e-4 && + Math.abs(endA.y - startB.y) < 1e-4 + ) { + return { type: "touch" } + } + + // Check for overlap + // Iterate backwards from end of A, and forwards from start of B + // to find matching sequence. + // Simplified: Check if last segment of A overlaps first segment of B + // Just basic endpoint check for now as reproduction case just meets at a point/segment. + + // If they share a segment: + // pathA: ... -> P_pre -> P_end + // pathB: P_start -> P_post -> ... + // If P_end == P_start AND P_pre == P_post, they overlap. + + if (pathA.length > 1 && pathB.length > 1) { + const prevA = pathA[pathA.length - 2] + const nextB = pathB[1] + + // Compare (prevA->endA) with (startB->nextB) + // If they are same segment, they overlap. + // We know endA approx startB? No we need to check if they overlap. + // If endA == nextB and prevA == startB? That's full overlap of segment. + // But typically we look for: + // A: ... -> X -> Y + // B: X -> Y -> ... + // OR + // A: ... -> X -> Y + // B: Y -> Z -> ... (Touch) + + // Let's rely on points being identical for overlap. + // Find index in B where A ends? + } + return null + } + + const p1 = t1.tracePath + const p2 = t2.tracePath + + // Define reverse paths + const p1Rev = [...p1].reverse() + const p2Rev = [...p2].reverse() + + const tryMerge = ( + pathA: { x: number; y: number }[], + pathB: { x: number; y: number }[], + ) => { + // Find finding common point + // We want to merge if they share a sequence of points at the boundary. + // Start from end of A, look for match in B's start. + + // Optimization: Only check if A's last point exists in B? + // Or B's first point exists in A? + // In the reproduction case: + // A: ... -> P_overlap_start -> P_overlap_end + // B: P_overlap_start -> P_overlap_end -> ... (Wait, B is reverse?) + + // Let's match from the End of A. + // Iterate A backwards from end. + for (let i = pathA.length - 1; i >= 0; i--) { + const ptA = pathA[i] + // Check if this point matches pathB[0] + if ( + Math.abs(ptA.x - pathB[0].x) < 1e-4 && + Math.abs(ptA.y - pathB[0].y) < 1e-4 + ) { + // Potential match point. + // Verify if the sequence matches up to end of A + // pathA[i ... end] should match pathB[0 ... len] + const overlapLen = pathA.length - i + if (overlapLen > pathB.length) continue // Can't overlap more than B has + + let match = true + for (let k = 0; k < overlapLen; k++) { + const pa = pathA[i + k] + const pb = pathB[k] + if (Math.abs(pa.x - pb.x) > 1e-4 || Math.abs(pa.y - pb.y) > 1e-4) { + match = false + break + } + } + + if (match) { + // MERGE! + // Result: pathA[0...i] + pathB + // Ensure we don't duplicate the overlapping part? + // pathA[0...i] excludes the matching start point pathA[i]? + // So pathA.slice(0, i) + pathB. + const newPath = [...pathA.slice(0, i), ...pathB] + return newPath + } + } + } + return null + } + + let mergedPath = tryMerge(p1, p2) + if (!mergedPath) mergedPath = tryMerge(p1, p2Rev) + if (!mergedPath) mergedPath = tryMerge(p1Rev, p2) + if (!mergedPath) mergedPath = tryMerge(p1Rev, p2Rev) + + if (mergedPath) { + return { + ...t1, + tracePath: mergedPath, + mspConnectionPairIds: [ + ...t1.mspConnectionPairIds, + ...t2.mspConnectionPairIds, + ], + pinIds: [...new Set([...t1.pinIds, ...t2.pinIds])], + } + } + + return null + } + + override visualize(): GraphicsObject { + const graphics: GraphicsObject = { + lines: [], + points: [], + texts: [], + } + + for (const trace of this.outputTraces) { + graphics.lines!.push({ + points: trace.tracePath.map((p) => ({ x: p.x, y: p.y })), + strokeColor: "orange", // Distinct color for combined traces + strokeWidth: 0.05, + }) + } + + return graphics + } + + getOutput() { + return { + traces: this.outputTraces, + } + } +} diff --git a/tests/reproduction_issue_29.test.ts b/tests/reproduction_issue_29.test.ts new file mode 100644 index 0000000..771b786 --- /dev/null +++ b/tests/reproduction_issue_29.test.ts @@ -0,0 +1,62 @@ +import type { InputProblem } from "lib/types/InputProblem" +import { test, expect } from "bun:test" +import { SchematicTracePipelineSolver } from "lib/index" + +const inputProblem: InputProblem = { + chips: [ + { + chipId: "U1", + center: { x: 0, y: 0 }, + width: 0.5, + height: 0.5, + pins: [ + { + pinId: "U1.1", + x: -1, + y: 0, + }, + { + pinId: "U1.2", + x: 0, + y: 0, + }, + { + pinId: "U1.3", + x: 1, + y: 0, + }, + ], + }, + ], + directConnections: [], + netConnections: [ + { + pinIds: ["U1.1", "U1.2", "U1.3"], + netId: "NET1", + }, + ], + availableNetLabelOrientations: {}, + maxMspPairDistance: 2, +} + +test("SchematicTracePipelineSolver should combine collinear trace segments", () => { + const solver = new SchematicTracePipelineSolver(inputProblem) + solver.solve() + + // Currently (Pre-fix) expected to have 2 traces: (-1,0)->(0,0) and (0,0)->(1,0) + // After fix, should have 1 trace: (-1,0)->(1,0) + + // Check output of TraceCombineSolver + const combinedTraces = solver.traceCombineSolver?.getOutput().traces ?? [] + + if (combinedTraces.length > 0) { + // Expect 1 consolidated trace for this problem. + expect(combinedTraces.length).toBe(1) + } else { + expect(true).toBe(false) + } + + // Verify that initially we had multiple segments (confirming the issue existed before this phase) + const initialTraces = solver.schematicTraceLinesSolver!.solvedTracePaths + expect(initialTraces.length).toBeGreaterThan(1) +}) From 8c1d1e7469cbc666410c508c2a8030975c2b6e98 Mon Sep 17 00:00:00 2001 From: giajoe24 Date: Sat, 17 Jan 2026 11:57:35 +0900 Subject: [PATCH 2/3] feat: merge parallel close traces in TraceCombineSolver (fixes #34) --- .../TraceCombineSolver/TraceCombineSolver.ts | 170 +++++++++++------- tests/reproduction_issue_34.test.ts | 54 ++++++ 2 files changed, 157 insertions(+), 67 deletions(-) create mode 100644 tests/reproduction_issue_34.test.ts diff --git a/lib/solvers/TraceCombineSolver/TraceCombineSolver.ts b/lib/solvers/TraceCombineSolver/TraceCombineSolver.ts index afc6d4d..53f1022 100644 --- a/lib/solvers/TraceCombineSolver/TraceCombineSolver.ts +++ b/lib/solvers/TraceCombineSolver/TraceCombineSolver.ts @@ -90,64 +90,33 @@ export class TraceCombineSolver extends BaseSolver { t1: SolvedTracePath, t2: SolvedTracePath, ): SolvedTracePath | null { - // Try all 4 combinations of connectivity: - // End of t1 -> Start of t2 - // End of t1 -> End of t2 (reverse t2) - // Start of t1 -> Start of t2 (reverse t1) - // Start of t1 -> End of t2 (reverse t1, reverse t2? or just t2->t1) - - // Helper to check connection - const checkConnection = ( - pathA: { x: number; y: number }[], - pathB: { x: number; y: number }[], - ) => { - // Check if pathA ends where pathB starts - // And potentially overlap - const endA = pathA[pathA.length - 1] - const startB = pathB[0] - - if ( - Math.abs(endA.x - startB.x) < 1e-4 && - Math.abs(endA.y - startB.y) < 1e-4 - ) { - return { type: "touch" } - } + // 1. Try connecting end-to-end (touching) + let mergedPath = this.tryConnectTouchingTraces(t1.tracePath, t2.tracePath) + + // 2. If not touching, try merging close parallel traces + if (!mergedPath) { + mergedPath = this.tryCombineParallelTraces(t1.tracePath, t2.tracePath) + } - // Check for overlap - // Iterate backwards from end of A, and forwards from start of B - // to find matching sequence. - // Simplified: Check if last segment of A overlaps first segment of B - // Just basic endpoint check for now as reproduction case just meets at a point/segment. - - // If they share a segment: - // pathA: ... -> P_pre -> P_end - // pathB: P_start -> P_post -> ... - // If P_end == P_start AND P_pre == P_post, they overlap. - - if (pathA.length > 1 && pathB.length > 1) { - const prevA = pathA[pathA.length - 2] - const nextB = pathB[1] - - // Compare (prevA->endA) with (startB->nextB) - // If they are same segment, they overlap. - // We know endA approx startB? No we need to check if they overlap. - // If endA == nextB and prevA == startB? That's full overlap of segment. - // But typically we look for: - // A: ... -> X -> Y - // B: X -> Y -> ... - // OR - // A: ... -> X -> Y - // B: Y -> Z -> ... (Touch) - - // Let's rely on points being identical for overlap. - // Find index in B where A ends? + if (mergedPath) { + return { + ...t1, + tracePath: mergedPath, + mspConnectionPairIds: [ + ...t1.mspConnectionPairIds, + ...t2.mspConnectionPairIds, + ], + pinIds: [...new Set([...t1.pinIds, ...t2.pinIds])], } - return null } - const p1 = t1.tracePath - const p2 = t2.tracePath + return null + } + private tryConnectTouchingTraces( + p1: { x: number; y: number }[], + p2: { x: number; y: number }[], + ): { x: number; y: number }[] | null { // Define reverse paths const p1Rev = [...p1].reverse() const p2Rev = [...p2].reverse() @@ -205,24 +174,91 @@ export class TraceCombineSolver extends BaseSolver { return null } - let mergedPath = tryMerge(p1, p2) - if (!mergedPath) mergedPath = tryMerge(p1, p2Rev) - if (!mergedPath) mergedPath = tryMerge(p1Rev, p2) - if (!mergedPath) mergedPath = tryMerge(p1Rev, p2Rev) + let merged = tryMerge(p1, p2) + if (!merged) merged = tryMerge(p1, p2Rev) + if (!merged) merged = tryMerge(p1Rev, p2) + if (!merged) merged = tryMerge(p1Rev, p2Rev) - if (mergedPath) { - return { - ...t1, - tracePath: mergedPath, - mspConnectionPairIds: [ - ...t1.mspConnectionPairIds, - ...t2.mspConnectionPairIds, - ], - pinIds: [...new Set([...t1.pinIds, ...t2.pinIds])], + return merged + } + + private tryCombineParallelTraces( + p1: { x: number; y: number }[], + p2: { x: number; y: number }[], + ): { x: number; y: number }[] | null { + const CLOSE_THRESHOLD = 0.05 // Threshold for "close together" + + const isStraightLine = (path: { x: number; y: number }[]) => { + if (path.length < 2) return null + + let isHoriz = true + let isVert = true + const y0 = path[0].y + const x0 = path[0].x + + const xs = path.map((p) => p.x) + const ys = path.map((p) => p.y) + + for (let i = 1; i < path.length; i++) { + if (Math.abs(path[i].y - y0) > 1e-4) isHoriz = false + if (Math.abs(path[i].x - x0) > 1e-4) isVert = false + } + + if (isHoriz) { + return { + type: "h" as const, + val: y0, + min: Math.min(...xs), + max: Math.max(...xs), + } + } + if (isVert) { + return { + type: "v" as const, + val: x0, + min: Math.min(...ys), + max: Math.max(...ys), + } } + return null } - return null + const info1 = isStraightLine(p1) + const info2 = isStraightLine(p2) + + if (!info1 || !info2 || info1.type !== info2.type) return null + + // Check distance between parallel lines + if (Math.abs(info1.val - info2.val) > CLOSE_THRESHOLD) return null + + // Check for overlap or touch (using small epsilon for touch) + const overlapStart = Math.max(info1.min, info2.min) + const overlapEnd = Math.min(info1.max, info2.max) + + // Epsilon choice: 1e-4. If overlapEnd >= overlapStart - epsilon, they at least touch. + // If we want STRICTLY "close parallel" to imply some overlap: + // If they are just touching tip-to-tip, `tryConnectTouchingTraces` should have handled it? + // Not necessarily if they are slightly offset in Y (parallel offset but touching in X). + // So let's allow "touching in projection" too. + if (overlapEnd < overlapStart - 1e-4) return null + + // Align to average center + const newVal = (info1.val + info2.val) / 2 + const newMin = Math.min(info1.min, info2.min) + const newMax = Math.max(info1.max, info2.max) + + const newPath = + info1.type === "h" + ? [ + { x: newMin, y: newVal }, + { x: newMax, y: newVal }, + ] + : [ + { x: newVal, y: newMin }, + { x: newVal, y: newMax }, + ] + + return newPath } override visualize(): GraphicsObject { diff --git a/tests/reproduction_issue_34.test.ts b/tests/reproduction_issue_34.test.ts new file mode 100644 index 0000000..168a6d9 --- /dev/null +++ b/tests/reproduction_issue_34.test.ts @@ -0,0 +1,54 @@ + +import { test, expect } from "bun:test" +import { TraceCombineSolver } from "../lib/solvers/TraceCombineSolver/TraceCombineSolver" +import type { SolvedTracePath } from "../lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import type { InputProblem } from "../lib/types/InputProblem" + +const mockInputProblem: InputProblem = { + chips: [], + directConnections: [], + netConnections: [], + availableNetLabelOrientations: {}, +} + +test("TraceCombineSolver should merge close parallel traces", () => { + const trace1: SolvedTracePath = { + mspPairId: "1", + mspConnectionPairIds: ["1"], + globalConnNetId: "NET1", + pinIds: [], + tracePath: [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ], + } as any + + const trace2: SolvedTracePath = { + mspPairId: "2", + mspConnectionPairIds: ["2"], + globalConnNetId: "NET1", // Same net + pinIds: [], + tracePath: [ + { x: 2, y: 0.01 }, // Very close Y + { x: 12, y: 0.01 }, + ], + } as any + + const solver = new TraceCombineSolver({ + inputTraces: [trace1, trace2], + inputProblem: mockInputProblem, + }) + + solver.solve() + + const output = solver.getOutput() + + // Should assume they are merged because they are on the same net and very close? + expect(output.traces.length).toBe(1) + + // Verify alignment + const merged = output.traces[0] + // Y should be consistent + const uniqueYs = new Set(merged.tracePath.map(p => p.y)) + expect(uniqueYs.size).toBe(1) +}) From c81b98dbb7bee5e3a3f1bd09a8c2f4c11035d2fe Mon Sep 17 00:00:00 2001 From: giajoe24 Date: Sun, 18 Jan 2026 12:27:13 +0900 Subject: [PATCH 3/3] docs: add visual example and snapshot for issue #34 parallel trace merge --- ...example28-issue34-parallel-traces.page.tsx | 43 +++++++ tests/__snapshots__/issue34_snapshot.snap.svg | 109 ++++++++++++++++++ tests/issue34_snapshot.test.ts | 16 +++ 3 files changed, 168 insertions(+) create mode 100644 site/examples/example28-issue34-parallel-traces.page.tsx create mode 100644 tests/__snapshots__/issue34_snapshot.snap.svg create mode 100644 tests/issue34_snapshot.test.ts diff --git a/site/examples/example28-issue34-parallel-traces.page.tsx b/site/examples/example28-issue34-parallel-traces.page.tsx new file mode 100644 index 0000000..189427d --- /dev/null +++ b/site/examples/example28-issue34-parallel-traces.page.tsx @@ -0,0 +1,43 @@ + +import type { InputProblem } from "lib/types/InputProblem" +import { PipelineDebugger } from "site/components/PipelineDebugger" + +export const inputProblem: InputProblem = { + chips: [ + { + chipId: "U1", + center: { x: 0, y: 0 }, + width: 0.5, + height: 0.5, + pins: [ + { pinId: "U1.1", x: -1, y: 0.01 }, + { pinId: "U1.2", x: 1, y: 0.01 }, + ], + }, + { + chipId: "U2", + center: { x: 0, y: 1 }, + width: 0.5, + height: 0.5, + pins: [ + { pinId: "U2.1", x: -1, y: -0.01 }, + { pinId: "U2.2", x: 1, y: -0.01 }, + ], + }, + ], + directConnections: [ + { + pinIds: ["U1.1", "U1.2"], + netId: "NET1", + }, + { + pinIds: ["U2.1", "U2.2"], + netId: "NET1", + }, + ], + netConnections: [], + availableNetLabelOrientations: {}, + maxMspPairDistance: 5, +} + +export default () => diff --git a/tests/__snapshots__/issue34_snapshot.snap.svg b/tests/__snapshots__/issue34_snapshot.snap.svg new file mode 100644 index 0000000..670077a --- /dev/null +++ b/tests/__snapshots__/issue34_snapshot.snap.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/issue34_snapshot.test.ts b/tests/issue34_snapshot.test.ts new file mode 100644 index 0000000..e9dcb92 --- /dev/null +++ b/tests/issue34_snapshot.test.ts @@ -0,0 +1,16 @@ + +import { test, expect } from "bun:test" +import { SchematicTracePipelineSolver } from "lib/index" +import { inputProblem } from "site/examples/example28-issue34-parallel-traces.page.tsx" + +test("snapshot for issue #34 parallel trace merge", async () => { + const solver = new SchematicTracePipelineSolver(inputProblem) + solver.solve() + + // Ensure it's solved and we have the expected combined trace + const combinedTraces = solver.traceCombineSolver?.getOutput().traces ?? [] + expect(combinedTraces.length).toBe(1) + + // @ts-ignore + await expect(solver).toMatchSolverSnapshot(import.meta.path) +})