From 80b550dba054607264dee10665fe79450bc52d41 Mon Sep 17 00:00:00 2001 From: anytimeatvibe Date: Fri, 22 May 2026 18:02:25 +0800 Subject: [PATCH 1/2] Add same-net trace segment merge solver --- lib/index.ts | 1 + .../SameNetTraceSegmentMergeSolver.ts | 290 ++++++++++++++++++ .../SchematicTracePipelineSolver.ts | 17 + .../SameNetTraceSegmentMergeSolver.test.ts | 102 ++++++ 4 files changed, 410 insertions(+) create mode 100644 lib/solvers/SameNetTraceSegmentMergeSolver/SameNetTraceSegmentMergeSolver.ts create mode 100644 tests/solvers/SameNetTraceSegmentMergeSolver/SameNetTraceSegmentMergeSolver.test.ts diff --git a/lib/index.ts b/lib/index.ts index 3985b32ac..8a10a56ce 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,3 +1,4 @@ export * from "./solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver" export * from "./types/InputProblem" export { SchematicTraceSingleLineSolver2 } from "./solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/SchematicTraceSingleLineSolver2" +export * from "./solvers/SameNetTraceSegmentMergeSolver/SameNetTraceSegmentMergeSolver" diff --git a/lib/solvers/SameNetTraceSegmentMergeSolver/SameNetTraceSegmentMergeSolver.ts b/lib/solvers/SameNetTraceSegmentMergeSolver/SameNetTraceSegmentMergeSolver.ts new file mode 100644 index 000000000..e3e4e3536 --- /dev/null +++ b/lib/solvers/SameNetTraceSegmentMergeSolver/SameNetTraceSegmentMergeSolver.ts @@ -0,0 +1,290 @@ +import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" +import { visualizeInputProblem } from "../SchematicTracePipelineSolver/visualizeInputProblem" +import type { InputProblem } from "lib/types/InputProblem" +import type { SolvedTracePath } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import type { GraphicsObject } from "graphics-debug" + +type Orientation = "horizontal" | "vertical" + +interface SegmentRef { + traceIndex: number + segmentIndex: number + orientation: Orientation + fixedCoord: number + min: number + max: number + length: number +} + +const EPS = 1e-6 +const DEFAULT_MERGE_DISTANCE = 0.18 + +const getTraceNetId = (trace: SolvedTracePath) => + trace.userNetId ?? trace.globalConnNetId ?? trace.dcConnNetId + +const closeEnough = (a: number, b: number) => Math.abs(a - b) <= EPS + +const rangeGap = (a: SegmentRef, b: SegmentRef) => + Math.max(0, Math.max(a.min, b.min) - Math.min(a.max, b.max)) + +const canMoveSegmentFixedCoord = ( + trace: SolvedTracePath, + segmentIndex: number, + orientation: Orientation, +) => { + const path = trace.tracePath + const start = path[segmentIndex]! + const end = path[segmentIndex + 1]! + const prev = path[segmentIndex - 1] + const next = path[segmentIndex + 2] + + if (orientation === "horizontal") { + const prevOk = !prev || closeEnough(prev.x, start.x) + const nextOk = !next || closeEnough(next.x, end.x) + return prevOk && nextOk + } + + const prevOk = !prev || closeEnough(prev.y, start.y) + const nextOk = !next || closeEnough(next.y, end.y) + return prevOk && nextOk +} + +const getSegments = (traces: SolvedTracePath[]) => { + const segmentsByNet: Record = {} + + for (let traceIndex = 0; traceIndex < traces.length; traceIndex++) { + const trace = traces[traceIndex]! + const netId = getTraceNetId(trace) + const path = trace.tracePath + + for (let segmentIndex = 0; segmentIndex < path.length - 1; segmentIndex++) { + const start = path[segmentIndex]! + const end = path[segmentIndex + 1]! + const dx = Math.abs(start.x - end.x) + const dy = Math.abs(start.y - end.y) + + if (dx <= EPS && dy <= EPS) continue + + let segment: SegmentRef | null = null + if (dy <= EPS && dx > EPS) { + segment = { + traceIndex, + segmentIndex, + orientation: "horizontal", + fixedCoord: start.y, + min: Math.min(start.x, end.x), + max: Math.max(start.x, end.x), + length: dx, + } + } else if (dx <= EPS && dy > EPS) { + segment = { + traceIndex, + segmentIndex, + orientation: "vertical", + fixedCoord: start.x, + min: Math.min(start.y, end.y), + max: Math.max(start.y, end.y), + length: dy, + } + } + + if (!segment) continue + if (!canMoveSegmentFixedCoord(trace, segmentIndex, segment.orientation)) { + continue + } + + segmentsByNet[netId] ??= [] + segmentsByNet[netId]!.push(segment) + } + } + + return segmentsByNet +} + +const makeUnionFind = (size: number) => { + const parent = Array.from({ length: size }, (_, index) => index) + + const find = (index: number): number => { + if (parent[index] !== index) parent[index] = find(parent[index]!) + return parent[index]! + } + + const union = (a: number, b: number) => { + const rootA = find(a) + const rootB = find(b) + if (rootA !== rootB) parent[rootB] = rootA + } + + return { find, union } +} + +const alignSegment = ( + trace: SolvedTracePath, + segmentIndex: number, + orientation: Orientation, + fixedCoord: number, +) => { + const start = trace.tracePath[segmentIndex]! + const end = trace.tracePath[segmentIndex + 1]! + + if (orientation === "horizontal") { + start.y = fixedCoord + end.y = fixedCoord + } else { + start.x = fixedCoord + end.x = fixedCoord + } +} + +const simplifyPath = (path: SolvedTracePath["tracePath"]) => { + const deduped = path.filter( + (point, index) => + index === 0 || + !( + closeEnough(point.x, path[index - 1]!.x) && + closeEnough(point.y, path[index - 1]!.y) + ), + ) + + const simplified: typeof path = [] + for (const point of deduped) { + const prev = simplified[simplified.length - 1] + const prevPrev = simplified[simplified.length - 2] + + if ( + prev && + prevPrev && + ((closeEnough(prevPrev.x, prev.x) && closeEnough(prev.x, point.x)) || + (closeEnough(prevPrev.y, prev.y) && closeEnough(prev.y, point.y))) + ) { + simplified[simplified.length - 1] = point + } else { + simplified.push(point) + } + } + + return simplified +} + +export const mergeSameNetCloseTraceSegments = ( + traces: SolvedTracePath[], + mergeDistance = DEFAULT_MERGE_DISTANCE, +) => { + const outputTraces = traces.map((trace) => ({ + ...trace, + tracePath: trace.tracePath.map((point) => ({ ...point })), + })) + + const segmentsByNet = getSegments(outputTraces) + + for (const segments of Object.values(segmentsByNet)) { + const { find, union } = makeUnionFind(segments.length) + + for (let i = 0; i < segments.length; i++) { + for (let j = i + 1; j < segments.length; j++) { + const a = segments[i]! + const b = segments[j]! + + if (a.orientation !== b.orientation) continue + if (Math.abs(a.fixedCoord - b.fixedCoord) > mergeDistance) continue + if (rangeGap(a, b) > mergeDistance) continue + + union(i, j) + } + } + + const segmentGroups = new Map() + for (let i = 0; i < segments.length; i++) { + const root = find(i) + segmentGroups.set(root, [ + ...(segmentGroups.get(root) ?? []), + segments[i]!, + ]) + } + + for (const group of segmentGroups.values()) { + if (group.length < 2) continue + + const totalLength = group.reduce( + (sum, segment) => sum + segment.length, + 0, + ) + const targetCoord = + group.reduce( + (sum, segment) => sum + segment.fixedCoord * segment.length, + 0, + ) / totalLength + + for (const segment of group) { + alignSegment( + outputTraces[segment.traceIndex]!, + segment.segmentIndex, + segment.orientation, + targetCoord, + ) + } + } + } + + return outputTraces.map((trace) => ({ + ...trace, + tracePath: simplifyPath(trace.tracePath), + })) +} + +export class SameNetTraceSegmentMergeSolver extends BaseSolver { + inputProblem: InputProblem + inputTracePaths: SolvedTracePath[] + mergeDistance: number + outputTraces: SolvedTracePath[] + + constructor(params: { + inputProblem: InputProblem + inputTracePaths: SolvedTracePath[] + mergeDistance?: number + }) { + super() + this.inputProblem = params.inputProblem + this.inputTracePaths = params.inputTracePaths + this.mergeDistance = params.mergeDistance ?? DEFAULT_MERGE_DISTANCE + this.outputTraces = params.inputTracePaths + } + + override getConstructorParams(): ConstructorParameters< + typeof SameNetTraceSegmentMergeSolver + >[0] { + return { + inputProblem: this.inputProblem, + inputTracePaths: this.inputTracePaths, + mergeDistance: this.mergeDistance, + } + } + + override _step() { + this.outputTraces = mergeSameNetCloseTraceSegments( + this.inputTracePaths, + this.mergeDistance, + ) + this.solved = true + } + + getOutput() { + return { + traces: this.outputTraces, + } + } + + override visualize(): GraphicsObject { + const graphics = visualizeInputProblem(this.inputProblem) + graphics.lines ??= [] + + for (const trace of this.outputTraces) { + graphics.lines.push({ + points: trace.tracePath, + strokeColor: "purple", + }) + } + + return graphics + } +} diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index 59821f0c1..e25e0b139 100644 --- a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts +++ b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts @@ -26,6 +26,7 @@ import { AvailableNetOrientationSolver } from "../AvailableNetOrientationSolver/ import { VccNetLabelCornerPlacementSolver } from "../VccNetLabelCornerPlacementSolver/VccNetLabelCornerPlacementSolver" import { TraceAnchoredNetLabelOverlapSolver } from "../TraceAnchoredNetLabelOverlapSolver/TraceAnchoredNetLabelOverlapSolver" import { NetLabelTraceCollisionSolver } from "../NetLabelTraceCollisionSolver/NetLabelTraceCollisionSolver" +import { SameNetTraceSegmentMergeSolver } from "../SameNetTraceSegmentMergeSolver/SameNetTraceSegmentMergeSolver" type PipelineStep BaseSolver> = { solverName: string @@ -70,6 +71,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { // guidelinesSolver?: GuidelinesSolver schematicTraceLinesSolver?: SchematicTraceLinesSolver longDistancePairSolver?: LongDistancePairSolver + sameNetTraceSegmentMergeSolver?: SameNetTraceSegmentMergeSolver traceOverlapShiftSolver?: TraceOverlapShiftSolver netLabelPlacementSolver?: NetLabelPlacementSolver labelMergingSolver?: MergedNetLabelObstacleSolver @@ -139,6 +141,20 @@ export class SchematicTracePipelineSolver extends BaseSolver { onSolved: (schematicTraceLinesSolver) => {}, }, ), + definePipelineStep( + "sameNetTraceSegmentMergeSolver", + SameNetTraceSegmentMergeSolver, + () => [ + { + inputProblem: this.inputProblem, + inputTracePaths: + this.longDistancePairSolver?.getOutput().allTracesMerged!, + }, + ], + { + onSolved: (_solver) => {}, + }, + ), definePipelineStep( "traceOverlapShiftSolver", TraceOverlapShiftSolver, @@ -146,6 +162,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { { inputProblem: this.inputProblem, inputTracePaths: + this.sameNetTraceSegmentMergeSolver?.getOutput().traces ?? this.longDistancePairSolver?.getOutput().allTracesMerged!, globalConnMap: this.mspConnectionPairSolver!.globalConnMap, }, diff --git a/tests/solvers/SameNetTraceSegmentMergeSolver/SameNetTraceSegmentMergeSolver.test.ts b/tests/solvers/SameNetTraceSegmentMergeSolver/SameNetTraceSegmentMergeSolver.test.ts new file mode 100644 index 000000000..1c6ef09c3 --- /dev/null +++ b/tests/solvers/SameNetTraceSegmentMergeSolver/SameNetTraceSegmentMergeSolver.test.ts @@ -0,0 +1,102 @@ +import { expect, test } from "bun:test" +import { + SameNetTraceSegmentMergeSolver, + mergeSameNetCloseTraceSegments, +} from "lib/solvers/SameNetTraceSegmentMergeSolver/SameNetTraceSegmentMergeSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import type { InputProblem } from "lib/types/InputProblem" + +const makeTrace = ( + mspPairId: string, + netId: string, + tracePath: SolvedTracePath["tracePath"], +): SolvedTracePath => ({ + mspPairId, + dcConnNetId: netId, + globalConnNetId: netId, + userNetId: netId, + pins: [ + { pinId: `${mspPairId}.1`, chipId: "U1", x: 0, y: 0 }, + { pinId: `${mspPairId}.2`, chipId: "U2", x: 1, y: 1 }, + ], + tracePath, + mspConnectionPairIds: [mspPairId], + pinIds: [`${mspPairId}.1`, `${mspPairId}.2`], +}) + +test("mergeSameNetCloseTraceSegments aligns nearby horizontal same-net segments", () => { + const output = mergeSameNetCloseTraceSegments( + [ + makeTrace("a", "VCC", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 5, y: 1 }, + { x: 5, y: 0 }, + ]), + makeTrace("b", "VCC", [ + { x: 1, y: 0 }, + { x: 1, y: 1.12 }, + { x: 4, y: 1.12 }, + { x: 4, y: 0 }, + ]), + ], + 0.18, + ) + + expect(output[0]!.tracePath[1]!.y).toBeCloseTo(1.045, 3) + expect(output[0]!.tracePath[2]!.y).toBeCloseTo(1.045, 3) + expect(output[1]!.tracePath[1]!.y).toBeCloseTo(1.045, 3) + expect(output[1]!.tracePath[2]!.y).toBeCloseTo(1.045, 3) +}) + +test("mergeSameNetCloseTraceSegments does not align different nets", () => { + const output = mergeSameNetCloseTraceSegments( + [ + makeTrace("a", "VCC", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 5, y: 1 }, + { x: 5, y: 0 }, + ]), + makeTrace("b", "GND", [ + { x: 1, y: 0 }, + { x: 1, y: 1.12 }, + { x: 4, y: 1.12 }, + { x: 4, y: 0 }, + ]), + ], + 0.18, + ) + + expect(output[0]!.tracePath[1]!.y).toBe(1) + expect(output[1]!.tracePath[1]!.y).toBe(1.12) +}) + +test("SameNetTraceSegmentMergeSolver returns aligned output traces", () => { + const inputProblem: InputProblem = { + chips: [], + directConnections: [], + netConnections: [], + availableNetLabelOrientations: {}, + } + const solver = new SameNetTraceSegmentMergeSolver({ + inputProblem, + inputTracePaths: [ + makeTrace("a", "SIG", [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + ]), + makeTrace("b", "SIG", [ + { x: 0.1, y: 0.1 }, + { x: 2.1, y: 0.1 }, + ]), + ], + mergeDistance: 0.18, + }) + + solver.solve() + + const { traces } = solver.getOutput() + expect(traces[0]!.tracePath[0]!.y).toBeCloseTo(0.05, 3) + expect(traces[1]!.tracePath[0]!.y).toBeCloseTo(0.05, 3) +}) From d4db599d59fd4f71d95aad063c1b05415c7c4032 Mon Sep 17 00:00:00 2001 From: anytimeatvibe Date: Fri, 22 May 2026 18:10:03 +0800 Subject: [PATCH 2/2] Update solver snapshots --- .../examples/__snapshots__/example18.snap.svg | 127 +++++++-------- .../examples/__snapshots__/example19.snap.svg | 22 ++- .../examples/__snapshots__/example29.snap.svg | 150 ++++++------------ 3 files changed, 121 insertions(+), 178 deletions(-) diff --git a/tests/examples/__snapshots__/example18.snap.svg b/tests/examples/__snapshots__/example18.snap.svg index 8c7f05fc8..da7c57a68 100644 --- a/tests/examples/__snapshots__/example18.snap.svg +++ b/tests/examples/__snapshots__/example18.snap.svg @@ -2,193 +2,186 @@ +x-" data-x="-1.4" data-y="0.42500000000000004" cx="198.69418654530432" cy="227.58519984468603" r="3" fill="hsl(88, 100%, 50%, 0.8)" /> +x-" data-x="-1.4" data-y="-0.42500000000000004" cx="198.69418654530432" cy="312.5069080955151" r="3" fill="hsl(84, 100%, 50%, 0.8)" /> +x+" data-x="1.4" data-y="0.5" cx="478.4362843127411" cy="220.09210794020112" r="3" fill="hsl(81, 100%, 50%, 0.8)" /> +x+" data-x="1.4" data-y="0.30000000000000004" cx="478.4362843127411" cy="240.0736863521609" r="3" fill="hsl(86, 100%, 50%, 0.8)" /> +x+" data-x="1.4" data-y="0.10000000000000009" cx="478.4362843127411" cy="260.05526476412064" r="3" fill="hsl(85, 100%, 50%, 0.8)" /> +x+" data-x="1.4" data-y="-0.09999999999999998" cx="478.4362843127411" cy="280.0368431760804" r="3" fill="hsl(82, 100%, 50%, 0.8)" /> +x+" data-x="1.4" data-y="-0.3" cx="478.4362843127411" cy="300.0184215880402" r="3" fill="hsl(83, 100%, 50%, 0.8)" /> +x+" data-x="1.4" data-y="-0.5" cx="478.4362843127411" cy="320" r="3" fill="hsl(87, 100%, 50%, 0.8)" /> +y+" data-x="-2.3148566499999994" data-y="0.5512093000000002" cx="107.29278710691517" cy="214.97589472334323" r="3" fill="hsl(140, 100%, 50%, 0.8)" /> +y-" data-x="-2.31430995" data-y="-0.5512093000000002" cx="107.3474067515042" cy="325.1162132168579" r="3" fill="hsl(141, 100%, 50%, 0.8)" /> +y+" data-x="1.7580660749999977" data-y="2.3025814000000002" cx="514.2099110841168" cy="39.99999999999997" r="3" fill="hsl(125, 100%, 50%, 0.8)" /> +y-" data-x="1.757519574999999" data-y="1.2" cx="514.1553114211063" cy="150.15658349834192" r="3" fill="hsl(126, 100%, 50%, 0.8)" /> +y-" data-x="-1.7580660749999977" data-y="-3.3025814000000002" cx="162.9205597739287" cy="600" r="3" fill="hsl(6, 100%, 50%, 0.8)" /> +y+" data-x="-1.757519574999999" data-y="-2.2" cx="162.97515943693924" cy="489.8434165016581" r="3" fill="hsl(7, 100%, 50%, 0.8)" /> +y-" data-x="1.7580660749999977" data-y="-3.3025814000000002" cx="514.2099110841168" cy="600" r="3" fill="hsl(247, 100%, 50%, 0.8)" /> +y+" data-x="1.757519574999999" data-y="-2.2" cx="514.1553114211063" cy="489.8434165016581" r="3" fill="hsl(248, 100%, 50%, 0.8)" /> - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + +globalConnNetId: connectivity_net0" data-x="-1.8574283249999997" data-y="0.9762093000000004" x="143.00269762012985" y="150.03576488447396" width="19.981578411959788" height="44.95855142690948" fill="#ef444466" stroke="#ef4444" stroke-width="0.010009219285714285" /> +globalConnNetId: connectivity_net0" data-x="1.6925229620340012" data-y="-0.07399999999999998" x="497.6708476226669" y="254.95996226907093" width="19.98157841195973" height="44.95855142690951" fill="#ef444466" stroke="#ef4444" stroke-width="0.010009219285714285" /> +globalConnNetId: connectivity_net1" data-x="-2.31430995" data-y="-0.9762093000000004" x="97.3566175455243" y="345.0977916288177" width="19.981578411959788" height="44.95855142690948" fill="#00000066" stroke="#000000" stroke-width="0.010009219285714285" /> +globalConnNetId: connectivity_net2" data-x="1.982519574999999" data-y="0.85" x="514.1553114211063" y="175.13355651329164" width="44.95855142690948" height="19.981578411959788" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.010009219285714285" /> +globalConnNetId: connectivity_net3" data-x="1.982519574999999" data-y="-2" x="514.1553114211063" y="459.8710488837184" width="44.95855142690948" height="19.981578411959788" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.010009219285714285" /> - + - + - + - + @@ -95,16 +91,16 @@ orientation: y+" data-x="3.3884680250000008" data-y="1.2997267500000007" cx="438 - + - + - + - + @@ -141,7 +137,7 @@ globalConnNetId: connectivity_net1" data-x="0.7999316625000001" data-y="0.425000 +globalConnNetId: connectivity_net2" data-x="3.403678819931689" data-y="1.5247267500000008" x="430.3678819931689" y="164.26525749999996" width="20" height="45.00000000000003" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.01" /> - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -859,7 +813,7 @@ orientation: x+" data-x="-8.4" data-y="-16.2" cx="198.31710258539454" cy="385.36 - + @@ -877,7 +831,7 @@ orientation: x+" data-x="-8.4" data-y="-16.2" cx="198.31710258539454" cy="385.36 - + @@ -895,7 +849,7 @@ orientation: x+" data-x="-8.4" data-y="-16.2" cx="198.31710258539454" cy="385.36 - + @@ -1070,7 +1024,7 @@ globalConnNetId: connectivity_net2" data-x="1.9500000000000006" data-y="1.475000 +globalConnNetId: connectivity_net3" data-x="-6.2125" data-y="-1.7749999999999997" x="235.67196263730466" y="123.2096283791052" width="3.5789087474883843" height="8.052544681848872" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.05588295598214286" /> +globalConnNetId: connectivity_net7" data-x="-6.112500000000001" data-y="-5.775" x="237.46141701104887" y="194.7878033288731" width="3.578908747488356" height="8.052544681848872" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.05588295598214286" /> +globalConnNetId: connectivity_net11" data-x="-5.875" data-y="-10" x="239.4745531815111" y="272.6290685867457" width="8.052544681848872" height="3.5789087474884127" fill="hsl(80, 100%, 50%, 0.35)" stroke="black" stroke-width="0.05588295598214286" />