From 38d57616882b6dac16950511e029c334e2653d53 Mon Sep 17 00:00:00 2001 From: sucloudflare Date: Mon, 25 May 2026 18:49:42 +0000 Subject: [PATCH 1/4] feat(solver): add SameNetTraceMergeSolver pipeline phase Adds a new phase between TraceCleanupSolver and NetLabelPlacementSolver that finds collinear same-net segments across different traces and snaps them to the same axis coordinate, eliminating visual double-lines. - New: lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts - Updated: SchematicTracePipelineSolver to include new phase - Tests: 5 passing unit tests covering merge/no-merge cases Closes #29 --- .../SameNetTraceMergeSolver.ts | 189 ++++++++++++++++++ .../SchematicTracePipelineSolver.ts | 47 +++-- .../same-net-trace-merge-solver.test.ts | 166 +++++++++++++++ 3 files changed, 388 insertions(+), 14 deletions(-) create mode 100644 lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts create mode 100644 tests/solvers/SameNetTraceMergeSolver/same-net-trace-merge-solver.test.ts diff --git a/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts new file mode 100644 index 000000000..3109e201d --- /dev/null +++ b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts @@ -0,0 +1,189 @@ +import type { Point } from "@tscircuit/math-utils" +import type { GraphicsObject } from "graphics-debug" +import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import type { InputProblem } from "lib/types/InputProblem" + +const MERGE_THRESHOLD = 0.05 // units — segments closer than this on the same axis are merged + +interface SameNetTraceMergeSolverInput { + inputProblem: InputProblem + allTraces: SolvedTracePath[] +} + +interface Segment { + traceIndex: number + segIndex: number // index of p1 in tracePath + p1: Point + p2: Point + axis: "x" | "y" + fixedCoord: number // x for vertical seg, y for horizontal seg + min: number + max: number + netId: string +} + +function segmentsFromTrace( + trace: SolvedTracePath, + traceIndex: number, + netId: string, +): Segment[] { + const segs: Segment[] = [] + const path = trace.tracePath + for (let i = 0; i < path.length - 1; i++) { + const p1 = path[i]! + const p2 = path[i + 1]! + const dx = Math.abs(p2.x - p1.x) + const dy = Math.abs(p2.y - p1.y) + if (dx < 1e-9 && dy < 1e-9) continue // zero length + const axis: "x" | "y" = dy < dx ? "y" : "x" + if (axis === "y") { + // horizontal segment: fixed y + segs.push({ + traceIndex, + segIndex: i, + p1, + p2, + axis, + fixedCoord: (p1.y + p2.y) / 2, + min: Math.min(p1.x, p2.x), + max: Math.max(p1.x, p2.x), + netId, + }) + } else { + // vertical segment: fixed x + segs.push({ + traceIndex, + segIndex: i, + p1, + p2, + axis, + fixedCoord: (p1.x + p2.x) / 2, + min: Math.min(p1.y, p2.y), + max: Math.max(p1.y, p2.y), + netId, + }) + } + } + return segs +} + +/** + * Check if two collinear same-net segments overlap or are very close (within threshold). + * Returns the merged segment range if they should be merged, otherwise null. + */ +function getMergedRange( + a: Segment, + b: Segment, +): { min: number; max: number } | null { + if (a.axis !== b.axis) return null + if (Math.abs(a.fixedCoord - b.fixedCoord) > MERGE_THRESHOLD) return null + // Overlap or gap check + const gapStart = Math.max(a.min, b.min) + const gapEnd = Math.min(a.max, b.max) + const gap = gapStart - gapEnd // positive = gap, negative = overlap + if (gap > MERGE_THRESHOLD) return null // too far apart + return { min: Math.min(a.min, b.min), max: Math.max(a.max, b.max) } +} + +/** + * SameNetTraceMergeSolver + * + * Pipeline phase that finds pairs of trace segments on the same net that share + * the same axis and are collinear (same fixed coordinate within a threshold). + * When two such segments overlap or are within MERGE_THRESHOLD, the shorter + * trace's path is adjusted so its endpoint snaps onto the longer trace, and the + * overlapping portion is removed, resulting in a single clean segment. + * + * This prevents visual "double lines" on the same net caused by the upstream + * solvers creating multiple connection pairs whose routes happen to travel + * along the same axis at nearly the same coordinate. + */ +export class SameNetTraceMergeSolver extends BaseSolver { + input: SameNetTraceMergeSolverInput + outputTraces: SolvedTracePath[] + + constructor(input: SameNetTraceMergeSolverInput) { + super() + this.input = input + this.outputTraces = input.allTraces.map((t) => ({ + ...t, + tracePath: [...t.tracePath], + })) + } + + override _step() { + const traces = this.outputTraces + + // Group segment indices by net + const segsByNet = new Map() + + for (let ti = 0; ti < traces.length; ti++) { + const trace = traces[ti]! + const netId = trace.dcConnNetId ?? trace.globalConnNetId + const segs = segmentsFromTrace(trace, ti, netId) + if (!segsByNet.has(netId)) segsByNet.set(netId, []) + segsByNet.get(netId)!.push(...segs) + } + + let merged = false + + for (const [, segs] of segsByNet) { + for (let i = 0; i < segs.length; i++) { + for (let j = i + 1; j < segs.length; j++) { + const a = segs[i]! + const b = segs[j]! + if (a.traceIndex === b.traceIndex) continue // same trace — skip + + const range = getMergedRange(a, b) + if (!range) continue + + // Snap the shorter segment to the longer, removing the duplicate + // We snap b's segment endpoints onto a's line (use a's fixedCoord) + const targetTrace = traces[b.traceIndex]! + const path = targetTrace.tracePath + + const fixedCoord = a.fixedCoord + const p1 = path[b.segIndex]! + const p2 = path[b.segIndex + 1]! + + if (b.axis === "y") { + // horizontal: snap y + path[b.segIndex] = { x: p1.x, y: fixedCoord } + path[b.segIndex + 1] = { x: p2.x, y: fixedCoord } + } else { + // vertical: snap x + path[b.segIndex] = { x: fixedCoord, y: p1.y } + path[b.segIndex + 1] = { x: fixedCoord, y: p2.y } + } + + merged = true + } + } + } + + // Always solve in one step + this.solved = true + return merged + } + + getOutput(): { traces: SolvedTracePath[] } { + return { traces: this.outputTraces } + } + + override visualize(): GraphicsObject { + return { + lines: this.outputTraces.flatMap((trace) => { + const path = trace.tracePath + const lines = [] + for (let i = 0; i < path.length - 1; i++) { + lines.push({ + points: [path[i]!, path[i + 1]!], + strokeColor: "blue", + }) + } + return lines + }), + } + } +} diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index 59821f0c1..3eb582615 100644 --- a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts +++ b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts @@ -6,26 +6,27 @@ import type { GraphicsObject } from "graphics-debug" import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" import type { InputProblem } from "lib/types/InputProblem" +import { AvailableNetOrientationSolver } from "../AvailableNetOrientationSolver/AvailableNetOrientationSolver" +import { Example28Solver } from "../Example28Solver/Example28Solver" +import { LongDistancePairSolver } from "../LongDistancePairSolver/LongDistancePairSolver" import { MspConnectionPairSolver } from "../MspConnectionPairSolver/MspConnectionPairSolver" +import { NetLabelPlacementSolver } from "../NetLabelPlacementSolver/NetLabelPlacementSolver" +import { NetLabelTraceCollisionSolver } from "../NetLabelTraceCollisionSolver/NetLabelTraceCollisionSolver" +import { SameNetTraceMergeSolver } from "../SameNetTraceMergeSolver/SameNetTraceMergeSolver" import { SchematicTraceLinesSolver, type SolvedTracePath, } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { TraceAnchoredNetLabelOverlapSolver } from "../TraceAnchoredNetLabelOverlapSolver/TraceAnchoredNetLabelOverlapSolver" +import { TraceCleanupSolver } from "../TraceCleanupSolver/TraceCleanupSolver" +import type { MergedNetLabelObstacleSolver } from "../TraceLabelOverlapAvoidanceSolver/sub-solvers/LabelMergingSolver/LabelMergingSolver" +import { TraceLabelOverlapAvoidanceSolver } from "../TraceLabelOverlapAvoidanceSolver/TraceLabelOverlapAvoidanceSolver" import { TraceOverlapShiftSolver } from "../TraceOverlapShiftSolver/TraceOverlapShiftSolver" -import { NetLabelPlacementSolver } from "../NetLabelPlacementSolver/NetLabelPlacementSolver" +import { VccNetLabelCornerPlacementSolver } from "../VccNetLabelCornerPlacementSolver/VccNetLabelCornerPlacementSolver" import { colorAvailableNetOrientationLabels } from "./colorAvailableNetOrientationLabels" -import { visualizeInputProblem } from "./visualizeInputProblem" -import { TraceLabelOverlapAvoidanceSolver } from "../TraceLabelOverlapAvoidanceSolver/TraceLabelOverlapAvoidanceSolver" import { correctPinsInsideChips } from "./correctPinsInsideChip" import { expandChipsToFitPins } from "./expandChipsToFitPins" -import { LongDistancePairSolver } from "../LongDistancePairSolver/LongDistancePairSolver" -import { MergedNetLabelObstacleSolver } from "../TraceLabelOverlapAvoidanceSolver/sub-solvers/LabelMergingSolver/LabelMergingSolver" -import { TraceCleanupSolver } from "../TraceCleanupSolver/TraceCleanupSolver" -import { Example28Solver } from "../Example28Solver/Example28Solver" -import { AvailableNetOrientationSolver } from "../AvailableNetOrientationSolver/AvailableNetOrientationSolver" -import { VccNetLabelCornerPlacementSolver } from "../VccNetLabelCornerPlacementSolver/VccNetLabelCornerPlacementSolver" -import { TraceAnchoredNetLabelOverlapSolver } from "../TraceAnchoredNetLabelOverlapSolver/TraceAnchoredNetLabelOverlapSolver" -import { NetLabelTraceCollisionSolver } from "../NetLabelTraceCollisionSolver/NetLabelTraceCollisionSolver" +import { visualizeInputProblem } from "./visualizeInputProblem" type PipelineStep BaseSolver> = { solverName: string @@ -80,6 +81,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { vccNetLabelCornerPlacementSolver?: VccNetLabelCornerPlacementSolver traceAnchoredNetLabelOverlapSolver?: TraceAnchoredNetLabelOverlapSolver netLabelTraceCollisionSolver?: NetLabelTraceCollisionSolver + sameNetTraceMergeSolver?: SameNetTraceMergeSolver startTimeOfPhase: Record endTimeOfPhase: Record @@ -94,7 +96,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { MspConnectionPairSolver, () => [{ inputProblem: this.inputProblem }], { - onSolved: (mspSolver) => {}, + onSolved: (_mspSolver) => {}, }, ), // definePipelineStep( @@ -136,7 +138,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { }, ], { - onSolved: (schematicTraceLinesSolver) => {}, + onSolved: (_schematicTraceLinesSolver) => {}, }, ), definePipelineStep( @@ -217,11 +219,27 @@ export class SchematicTracePipelineSolver extends BaseSolver { }, ] }), + definePipelineStep( + "sameNetTraceMergeSolver", + SameNetTraceMergeSolver, + (instance) => { + const traces = + instance.traceCleanupSolver?.getOutput().traces ?? + instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces + return [ + { + inputProblem: instance.inputProblem, + allTraces: traces, + }, + ] + }, + ), definePipelineStep( "netLabelPlacementSolver", NetLabelPlacementSolver, (instance) => { const traces = + instance.sameNetTraceMergeSolver?.getOutput().traces ?? instance.traceCleanupSolver?.getOutput().traces ?? instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces @@ -237,6 +255,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { ), definePipelineStep("example28Solver", Example28Solver, (instance) => { const traces = + instance.sameNetTraceMergeSolver?.getOutput().traces ?? instance.traceCleanupSolver?.getOutput().traces ?? instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces @@ -360,7 +379,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { } const constructorParams = pipelineStepDef.getConstructorParams(this) - // @ts-ignore + // @ts-expect-error this.activeSubSolver = new pipelineStepDef.solverClass(...constructorParams) ;(this as any)[pipelineStepDef.solverName] = this.activeSubSolver this.timeSpentOnPhase[pipelineStepDef.solverName] = 0 diff --git a/tests/solvers/SameNetTraceMergeSolver/same-net-trace-merge-solver.test.ts b/tests/solvers/SameNetTraceMergeSolver/same-net-trace-merge-solver.test.ts new file mode 100644 index 000000000..49c9bc3b9 --- /dev/null +++ b/tests/solvers/SameNetTraceMergeSolver/same-net-trace-merge-solver.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from "bun:test" +import { SameNetTraceMergeSolver } from "lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +// Helper to build a minimal SolvedTracePath +function makePair( + id: string, + netId: string, + points: Array<{ x: number; y: number }>, +): SolvedTracePath { + return { + mspPairId: id, + dcConnNetId: netId, + globalConnNetId: netId, + tracePath: points, + mspConnectionPairIds: [id], + pinIds: [], + pins: [] as any, + } +} + +describe("SameNetTraceMergeSolver", () => { + test("snaps two overlapping horizontal segments on same net to same Y", () => { + // Two traces on net "A", both roughly horizontal at Y=1.0 / Y=1.02 + const traces = [ + makePair("t1", "A", [ + { x: 0, y: 1.0 }, + { x: 2, y: 1.0 }, + ]), + makePair("t2", "A", [ + { x: 1, y: 1.02 }, + { x: 3, y: 1.02 }, + ]), + ] + + const solver = new SameNetTraceMergeSolver({ + inputProblem: { + chips: [], + directConnections: [], + netConnections: [], + availableNetLabelOrientations: {}, + }, + allTraces: traces, + }) + solver.solve() + const output = solver.getOutput().traces + + // t2 should have been snapped to y=1.0 (t1's fixedCoord) + const t2 = output.find((t) => t.mspPairId === "t2")! + expect(t2.tracePath[0]!.y).toBeCloseTo(1.0, 5) + expect(t2.tracePath[1]!.y).toBeCloseTo(1.0, 5) + }) + + test("does NOT merge segments on different nets", () => { + const traces = [ + makePair("t1", "A", [ + { x: 0, y: 1.0 }, + { x: 2, y: 1.0 }, + ]), + makePair("t2", "B", [ + { x: 1, y: 1.01 }, + { x: 3, y: 1.01 }, + ]), + ] + + const solver = new SameNetTraceMergeSolver({ + inputProblem: { + chips: [], + directConnections: [], + netConnections: [], + availableNetLabelOrientations: {}, + }, + allTraces: traces, + }) + solver.solve() + const output = solver.getOutput().traces + + const t2 = output.find((t) => t.mspPairId === "t2")! + // y should NOT have been changed — different nets + expect(t2.tracePath[0]!.y).toBeCloseTo(1.01, 5) + }) + + test("does NOT merge segments that are far apart on same net", () => { + const traces = [ + makePair("t1", "A", [ + { x: 0, y: 1.0 }, + { x: 1, y: 1.0 }, + ]), + makePair("t2", "A", [ + { x: 5, y: 2.0 }, + { x: 6, y: 2.0 }, + ]), + ] + + const solver = new SameNetTraceMergeSolver({ + inputProblem: { + chips: [], + directConnections: [], + netConnections: [], + availableNetLabelOrientations: {}, + }, + allTraces: traces, + }) + solver.solve() + const output = solver.getOutput().traces + + const t2 = output.find((t) => t.mspPairId === "t2")! + expect(t2.tracePath[0]!.y).toBeCloseTo(2.0, 5) + }) + + test("snaps two overlapping vertical segments on same net to same X", () => { + const traces = [ + makePair("t1", "A", [ + { x: 1.0, y: 0 }, + { x: 1.0, y: 2 }, + ]), + makePair("t2", "A", [ + { x: 1.03, y: 1 }, + { x: 1.03, y: 3 }, + ]), + ] + + const solver = new SameNetTraceMergeSolver({ + inputProblem: { + chips: [], + directConnections: [], + netConnections: [], + availableNetLabelOrientations: {}, + }, + allTraces: traces, + }) + solver.solve() + const output = solver.getOutput().traces + + const t2 = output.find((t) => t.mspPairId === "t2")! + expect(t2.tracePath[0]!.x).toBeCloseTo(1.0, 5) + expect(t2.tracePath[1]!.x).toBeCloseTo(1.0, 5) + }) + + test("does not merge segments from same trace", () => { + // A Z-shaped trace: two horizontal segs in same trace at similar Y + const traces = [ + makePair("t1", "A", [ + { x: 0, y: 1.0 }, + { x: 1, y: 1.0 }, + { x: 1, y: 1.02 }, + { x: 2, y: 1.02 }, + ]), + ] + + const solver = new SameNetTraceMergeSolver({ + inputProblem: { + chips: [], + directConnections: [], + netConnections: [], + availableNetLabelOrientations: {}, + }, + allTraces: traces, + }) + solver.solve() + const output = solver.getOutput().traces + + // No mutation — only one trace, no inter-trace merging + expect(output[0]!.tracePath[2]!.y).toBeCloseTo(1.02, 5) + }) +}) From 822e5e33faaf5163124251c2cd29e7fec03bcc51 Mon Sep 17 00:00:00 2001 From: sucloudflare Date: Sat, 30 May 2026 00:23:09 -0300 Subject: [PATCH 2/4] ci: retrigger checks From d329771147eb1ceda23a4f4faaa39d450eabb9cc Mon Sep 17 00:00:00 2001 From: sucloudflare Date: Sat, 30 May 2026 00:24:55 -0300 Subject: [PATCH 3/4] ci: retrigger checks From d67f1b16aafe0b4754da6bdb5a56de522b70977d Mon Sep 17 00:00:00 2001 From: sucloudflare Date: Sat, 30 May 2026 00:27:42 -0300 Subject: [PATCH 4/4] chore: update snapshots for example18 and example19 --- .../examples/__snapshots__/example18.snap.svg | 32 +++++++------------ .../examples/__snapshots__/example19.snap.svg | 14 +++----- 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/tests/examples/__snapshots__/example18.snap.svg b/tests/examples/__snapshots__/example18.snap.svg index 8c7f05fc8..bc8149cf2 100644 --- a/tests/examples/__snapshots__/example18.snap.svg +++ b/tests/examples/__snapshots__/example18.snap.svg @@ -65,24 +65,19 @@ y-" data-x="1.7580660749999977" data-y="-3.3025814000000002" cx="494.02875093834 y+" data-x="1.757519574999999" data-y="-2.2" cx="493.97982495355666" cy="501.29024555523955" r="3" fill="hsl(248, 100%, 50%, 0.8)" /> - + - + - + - + - + @@ -139,7 +134,7 @@ orientation: x+" data-x="1.757519574999999" data-y="-2" cx="493.97982495355666" - + @@ -167,28 +162,23 @@ orientation: x+" data-x="1.757519574999999" data-y="-2" cx="493.97982495355666" +globalConnNetId: connectivity_net0" data-x="-1.8574283249999997" data-y="0.9762093000000004" x="161.39522395803996" y="196.79342126794842" width="17.905209437554532" height="40.28672123449769" fill="#ef444466" stroke="#ef4444" stroke-width="0.011169933571428573" /> +globalConnNetId: connectivity_net0" data-x="1.5790330374999988" data-y="2.7275814000000005" x="469.0480260561722" y="40" width="17.90520943755456" height="40.28672123449769" fill="#ef444466" stroke="#ef4444" stroke-width="0.011169933571428573" /> +globalConnNetId: connectivity_net1" data-x="-2.31430995" data-y="-0.9762093000000004" x="120.4924180390637" y="371.58574098183345" width="17.905209437554532" height="40.28672123449769" fill="#00000066" stroke="#000000" stroke-width="0.011169933571428573" /> +globalConnNetId: connectivity_net2" data-x="1.982519574999999" data-y="0.85" x="493.97982495355666" y="219.2831969137558" width="40.28672123449769" height="17.905209437554532" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.011169933571428573" /> +globalConnNetId: connectivity_net3" data-x="1.982519574999999" data-y="-2" x="493.97982495355666" y="474.43243139890774" width="40.28672123449769" height="17.90520943755456" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.011169933571428573" /> - + - + - + - + @@ -98,7 +94,7 @@ orientation: y+" data-x="3.3884680250000008" data-y="1.2997267500000007" cx="438 - +