From 8fd20afc054b0bea7599994bfcb0dcc2f237a95f Mon Sep 17 00:00:00 2001 From: giajoe24 Date: Sat, 17 Jan 2026 10:53:35 +0900 Subject: [PATCH] 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) +})