From 714baa8706a2943764521de87d43761d1cc191ed Mon Sep 17 00:00:00 2001 From: BuckyQ Date: Fri, 15 May 2026 16:31:44 -0700 Subject: [PATCH] feat: add SameNetTraceMergeSolver pipeline phase (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new pipeline phase that merges trace segments belonging to the same electrical net when their endpoints are within maxEndpointGap units (default 0.12) of each other. Implementation details: - Traces are grouped by userNetId > globalConnNetId > dcConnNetId - O(n²) scan per net group (groups are typically small in practice) - Closest endpoint pair is selected; paths are reversed as needed - A single L-shaped bridge is inserted when the join is not axis-aligned - Duplicate consecutive points are removed from the merged path - Iterates until no more mergeable pairs remain in any group The solver is inserted into the pipeline after TraceCleanupSolver and before NetLabelPlacementSolver so downstream label placement sees the merged trace geometry. All 65 tests pass. Updated snapshots for examples where same-net traces were merged (examples 01, 02, 13, 14, 15, 18, 19). Closes #29 --- .../SameNetTraceMergeSolver.ts | 156 ++++++++++++++ .../SchematicTracePipelineSolver.ts | 18 +- .../examples/__snapshots__/example01.snap.svg | 30 ++- .../examples/__snapshots__/example02.snap.svg | 76 ++++--- .../examples/__snapshots__/example13.snap.svg | 84 ++++---- .../examples/__snapshots__/example14.snap.svg | 62 +++--- .../examples/__snapshots__/example15.snap.svg | 198 +++++++++++------- .../examples/__snapshots__/example18.snap.svg | 47 ++--- .../examples/__snapshots__/example19.snap.svg | 96 +++++---- .../SameNetTraceMergeSolver.test.ts | 99 +++++++++ 10 files changed, 595 insertions(+), 271 deletions(-) create mode 100644 lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts create mode 100644 tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.test.ts diff --git a/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts new file mode 100644 index 00000000..f827aafe --- /dev/null +++ b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts @@ -0,0 +1,156 @@ +import type { Point } from "@tscircuit/math-utils" +import { BaseSolver } from "../BaseSolver/BaseSolver" +import type { SolvedTracePath } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +export interface SameNetTraceMergeSolverParams { + traces: SolvedTracePath[] + /** + * Maximum distance between two trace endpoints to consider them mergeable. + * Default: 0.12 units. + */ + maxEndpointGap?: number +} + +function dist(a: Point, b: Point): number { + return Math.hypot(a.x - b.x, a.y - b.y) +} + +/** Net key for grouping – prefer user-defined net id, fall back to connectivity ids */ +function netKey(trace: SolvedTracePath): string { + return trace.userNetId ?? trace.globalConnNetId ?? trace.dcConnNetId +} + +/** Remove exact duplicate consecutive points */ +function dedupe(pts: Point[]): Point[] { + const out: Point[] = [] + for (const p of pts) { + const prev = out[out.length - 1] + if (!prev || Math.abs(prev.x - p.x) > 1e-9 || Math.abs(prev.y - p.y) > 1e-9) { + out.push(p) + } + } + return out +} + +/** + * Try to merge two same-net traces by finding the closest endpoint pair. + * If the join requires a direction change, a single right-angle bridge is inserted. + * Returns the merged SolvedTracePath or null if the gap exceeds maxGap. + */ +function tryMerge( + a: SolvedTracePath, + b: SolvedTracePath, + maxGap: number, +): SolvedTracePath | null { + const pa = a.tracePath + const pb = b.tracePath + if (pa.length === 0 || pb.length === 0) return null + + const aS = pa[0]! + const aE = pa[pa.length - 1]! + const bS = pb[0]! + const bE = pb[pb.length - 1]! + + const options = [ + { d: dist(aE, bS), revA: false, revB: false }, + { d: dist(aE, bE), revA: false, revB: true }, + { d: dist(aS, bS), revA: true, revB: false }, + { d: dist(aS, bE), revA: true, revB: true }, + ] + const best = options.reduce((p, c) => (c.d < p.d ? c : p)) + if (best.d > maxGap) return null + + const ordA = best.revA ? [...pa].reverse() : [...pa] + const ordB = best.revB ? [...pb].reverse() : [...pb] + + const from = ordA[ordA.length - 1]! + const to = ordB[0]! + + // Insert a single L-shaped bridge if the connection is not axis-aligned + const bridge: Point[] = + Math.abs(from.x - to.x) > 1e-9 && Math.abs(from.y - to.y) > 1e-9 + ? [{ x: to.x, y: from.y }] + : [] + + return { + ...a, + mspPairId: `merged:${a.mspPairId}+${b.mspPairId}`, + tracePath: dedupe([...ordA, ...bridge, ...ordB]), + mspConnectionPairIds: [ + ...a.mspConnectionPairIds, + ...b.mspConnectionPairIds, + ], + pinIds: [...a.pinIds, ...b.pinIds], + } +} + +/** + * SameNetTraceMergeSolver — pipeline phase that joins trace segments belonging + * to the same electrical net when their endpoints are within `maxEndpointGap` + * units of each other. + * + * Runs iteratively until no more merges are possible (or the solver is marked + * solved/failed by the base class iteration guard). + */ +export class SameNetTraceMergeSolver extends BaseSolver { + readonly inputTraces: SolvedTracePath[] + outputTraces: SolvedTracePath[] + readonly maxEndpointGap: number + /** Total number of merges performed across all _step() calls */ + mergeCount = 0 + + constructor({ + traces, + maxEndpointGap = 0.12, + }: SameNetTraceMergeSolverParams) { + super() + this.inputTraces = [...traces] + this.outputTraces = [...traces] + this.maxEndpointGap = maxEndpointGap + } + + getOutput(): { traces: SolvedTracePath[] } { + return { traces: this.outputTraces } + } + + override _step(): void { + // Group current traces by net + const byNet = new Map() + for (const trace of this.outputTraces) { + const k = netKey(trace) + const group = byNet.get(k) + if (group) group.push(trace) + else byNet.set(k, [trace]) + } + + let merged = false + + for (const group of byNet.values()) { + if (group.length < 2) continue + + // O(n²) scan – groups are typically small + outer: for (let i = 0; i < group.length; i++) { + for (let j = i + 1; j < group.length; j++) { + const joined = tryMerge(group[i]!, group[j]!, this.maxEndpointGap) + if (joined) { + // Replace the two original traces with the merged one + this.outputTraces = this.outputTraces.filter( + (t) => + t.mspPairId !== group[i]!.mspPairId && + t.mspPairId !== group[j]!.mspPairId, + ) + this.outputTraces.push(joined) + this.mergeCount++ + merged = true + break outer // Restart the scan for this net + } + } + } + } + + if (!merged) { + // No more mergeable pairs – we're done + this.solved = true + } + } +} diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index 59821f0c..ff769e52 100644 --- a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts +++ b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts @@ -21,6 +21,7 @@ import { expandChipsToFitPins } from "./expandChipsToFitPins" import { LongDistancePairSolver } from "../LongDistancePairSolver/LongDistancePairSolver" import { MergedNetLabelObstacleSolver } from "../TraceLabelOverlapAvoidanceSolver/sub-solvers/LabelMergingSolver/LabelMergingSolver" import { TraceCleanupSolver } from "../TraceCleanupSolver/TraceCleanupSolver" +import { SameNetTraceMergeSolver } from "../SameNetTraceMergeSolver/SameNetTraceMergeSolver" import { Example28Solver } from "../Example28Solver/Example28Solver" import { AvailableNetOrientationSolver } from "../AvailableNetOrientationSolver/AvailableNetOrientationSolver" import { VccNetLabelCornerPlacementSolver } from "../VccNetLabelCornerPlacementSolver/VccNetLabelCornerPlacementSolver" @@ -75,6 +76,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { labelMergingSolver?: MergedNetLabelObstacleSolver traceLabelOverlapAvoidanceSolver?: TraceLabelOverlapAvoidanceSolver traceCleanupSolver?: TraceCleanupSolver + sameNetTraceMergeSolver?: SameNetTraceMergeSolver example28Solver?: Example28Solver availableNetOrientationSolver?: AvailableNetOrientationSolver vccNetLabelCornerPlacementSolver?: VccNetLabelCornerPlacementSolver @@ -217,11 +219,24 @@ export class SchematicTracePipelineSolver extends BaseSolver { }, ] }), + definePipelineStep( + "sameNetTraceMergeSolver", + SameNetTraceMergeSolver, + (instance) => { + const traces = + instance.sameNetTraceMergeSolver?.getOutput().traces ?? + instance.traceCleanupSolver?.getOutput().traces ?? + instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces + return [{ traces }] + }, + ), definePipelineStep( "netLabelPlacementSolver", NetLabelPlacementSolver, (instance) => { const traces = + instance.sameNetTraceMergeSolver?.getOutput().traces ?? + instance.sameNetTraceMergeSolver?.getOutput().traces ?? instance.traceCleanupSolver?.getOutput().traces ?? instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces @@ -237,7 +252,8 @@ export class SchematicTracePipelineSolver extends BaseSolver { ), definePipelineStep("example28Solver", Example28Solver, (instance) => { const traces = - instance.traceCleanupSolver?.getOutput().traces ?? + instance.sameNetTraceMergeSolver?.getOutput().traces ?? + instance.traceCleanupSolver?.getOutput().traces ?? instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces return [ diff --git a/tests/examples/__snapshots__/example01.snap.svg b/tests/examples/__snapshots__/example01.snap.svg index 2614ba80..e185ca90 100644 --- a/tests/examples/__snapshots__/example01.snap.svg +++ b/tests/examples/__snapshots__/example01.snap.svg @@ -41,16 +41,16 @@ y+" data-x="-4" data-y="0.5" cx="67.72277227722776" cy="245.14851485148515" r="3 y-" data-x="-4" data-y="-0.5" cx="67.72277227722776" cy="356.03960396039605" r="3" fill="hsl(3, 100%, 50%, 0.8)" /> - + - + - + + + + @@ -74,10 +74,7 @@ orientation: y-" data-x="-1.4" data-y="-0.7" cx="356.0396039603961" cy="378.2178 - - - - + @@ -90,18 +87,19 @@ orientation: y-" data-x="-1.4" data-y="-0.7" cx="356.0396039603961" cy="378.2178 +globalConnNetId: connectivity_net0" data-x="-1.1" data-y="0.42500000000000016" x="378.21782178217825" y="228.51485148514848" width="22.178217821782198" height="49.9009900990099" fill="#ef444466" stroke="#ef4444" stroke-width="0.009017857142857143" /> +globalConnNetId: connectivity_net1" data-x="-1.5" data-y="0" x="320" y="289.5049504950495" width="49.90099009900996" height="22.17821782178214" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.009017857142857143" /> + + + +globalConnNetId: connectivity_net2" data-x="-4" data-y="-0.726" x="56.63366336633669" y="356.1504950495049" width="22.17821782178214" height="49.90099009900996" fill="#00000066" stroke="#000000" stroke-width="0.009017857142857143" /> - + - + - + - + + + + + + + + + + + + + + + + @@ -156,63 +167,70 @@ orientation: y+" data-x="1.4571549750000001" data-y="0.29999999999999966" cx="53 - + - + - + - + - + - + - + - + - + - + - + - + - + +globalConnNetId: connectivity_net0" data-x="-2.301" data-y="0.07599999999999998" x="212.35868019581574" y="307.17783323638616" width="16.92695282325252" height="38.085643852318185" fill="#ef444466" stroke="#ef4444" stroke-width="0.011815475714285715" /> + + + + + + +globalConnNetId: connectivity_net1" data-x="-3.0434765500000003" data-y="-0.22600000000000023" x="149.51935252470935" y="332.7375319994975" width="16.926952823252492" height="38.08564385231813" fill="#00000066" stroke="#000000" stroke-width="0.011815475714285715" /> +globalConnNetId: connectivity_net1" data-x="1.9148566499999995" data-y="-1.2284186000000008" x="569.1667133165424" y="417.5769937562517" width="16.926952823252577" height="38.08564385231813" fill="#00000066" stroke="#000000" stroke-width="0.011815475714285715" /> +globalConnNetId: connectivity_net2" data-x="1.4571549750000001" data-y="0.5249999999999997" x="530.4292400172993" y="269.1768241481843" width="16.926952823252464" height="38.08564385231813" fill="#ef444466" stroke="#ef4444" stroke-width="0.011815475714285715" /> - + - + - + - + - + - + - + - + + + + + + + @@ -193,37 +191,37 @@ orientation: y+" data-x="1.96375" data-y="3" cx="417.91062100986653" cy="157.492 - + - + - + - + - + - + - + - + - + - + - + @@ -251,43 +249,43 @@ orientation: y+" data-x="1.96375" data-y="3" cx="417.91062100986653" cy="157.492 +globalConnNetId: connectivity_net3" data-x="1.7009999999999998" data-y="0.474" x="394.33081834010443" y="307.0644225188625" width="13.000580383052807" height="29.251305861868843" fill="#00000066" stroke="#000000" stroke-width="0.015383928571428571" /> +globalConnNetId: connectivity_net3" data-x="-2.125" data-y="-2.2249999999999996" x="145.6297156123041" y="482.5072547881602" width="13.000580383052835" height="29.251305861868843" fill="#00000066" stroke="#000000" stroke-width="0.015383928571428571" /> + + + +globalConnNetId: connectivity_net4" data-x="1.475" data-y="0.225" x="379.6401625072548" y="323.25014509576323" width="13.000580383052807" height="29.251305861868843" fill="#ef444466" stroke="#ef4444" stroke-width="0.015383928571428571" /> +globalConnNetId: connectivity_net4" data-x="-1.301" data-y="-0.8490000000000001" x="199.19210679048172" y="393.0632617527569" width="13.000580383052807" height="29.251305861868843" fill="#ef444466" stroke="#ef4444" stroke-width="0.015383928571428571" /> +globalConnNetId: connectivity_net2" data-x="-2.125" data-y="0.225" x="145.6297156123041" y="323.25014509576323" width="13.000580383052835" height="29.251305861868843" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.015383928571428571" /> + + + +globalConnNetId: connectivity_net0" data-x="-3.75" data-y="2.225" x="39.99999999999997" y="193.24434126523508" width="13.000580383052835" height="29.251305861868843" fill="#ef444466" stroke="#ef4444" stroke-width="0.015383928571428571" /> +globalConnNetId: connectivity_net0" data-x="2.2990000000000004" data-y="0.276" x="433.20255368543235" y="319.93499709808475" width="13.000580383052863" height="29.251305861868843" fill="#ef444466" stroke="#ef4444" stroke-width="0.015383928571428571" /> +globalConnNetId: connectivity_net1" data-x="1.96375" data-y="3.225" x="411.41033081834007" y="128.241439349971" width="13.000580383052807" height="29.25130586186887" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.015383928571428571" /> - + - + - + - + - + - + - + - + + + + @@ -155,9 +150,6 @@ orientation: y+" data-x="1.4755000000000003" data-y="-1.2944553500000002" cx="44 - - - @@ -168,10 +160,10 @@ orientation: y+" data-x="1.4755000000000003" data-y="-1.2944553500000002" cx="44 - + - + @@ -196,43 +188,39 @@ orientation: y+" data-x="1.4755000000000003" data-y="-1.2944553500000002" cx="44 +globalConnNetId: connectivity_net4" data-x="1.3010000000000002" data-y="-0.7260000000000001" x="419.16307692307697" y="335.1630769230769" width="17.230769230769226" height="38.769230769230774" fill="#00000066" stroke="#000000" stroke-width="0.011607142857142856" /> +globalConnNetId: connectivity_net4" data-x="-1.2000000000000002" data-y="-2.6750000000000003" x="203.6923076923077" y="503.0769230769231" width="17.230769230769255" height="38.76923076923083" fill="#00000066" stroke="#000000" stroke-width="0.011607142857142856" /> +globalConnNetId: connectivity_net1" data-x="-1.6250000000000002" data-y="-0.30000000000000004" x="156.30769230769232" y="309.2307692307692" width="38.769230769230774" height="17.230769230769226" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.011607142857142856" /> +globalConnNetId: connectivity_net3" data-x="1.6250000000000002" data-y="0.09999999999999998" x="436.3076923076924" y="274.7692307692308" width="38.769230769230774" height="17.230769230769226" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.011607142857142856" /> +globalConnNetId: connectivity_net5" data-x="-1.3010000000000002" data-y="0.7260000000000001" x="194.99076923076925" y="210.0676923076923" width="17.230769230769255" height="38.769230769230745" fill="#ef444466" stroke="#ef4444" stroke-width="0.011607142857142856" /> +globalConnNetId: connectivity_net5" data-x="3.2" data-y="0.525" x="582.7692307692308" y="227.3846153846154" width="17.230769230769283" height="38.769230769230745" fill="#ef444466" stroke="#ef4444" stroke-width="0.011607142857142856" /> +globalConnNetId: connectivity_net0" data-x="-1.5500000000000003" data-y="0.32500000000000007" x="173.53846153846155" y="244.6153846153846" width="17.230769230769226" height="38.769230769230745" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.011607142857142856" /> + + + +globalConnNetId: connectivity_net2" data-x="1.4260000000000002" data-y="-1.2944553500000002" x="419.16307692307697" y="394.9069224615385" width="38.769230769230774" height="17.230769230769226" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.011607142857142856" /> - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -776,154 +809,171 @@ orientation: y-" data-x="-2.49" data-y="-2.9000000000000004" cx="295.58784676354 - - - - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + +globalConnNetId: connectivity_net0" data-x="1.9525000000000015" data-y="6.9300000000000015" x="509.74900924702774" y="40.00000000000006" width="9.863496257155475" height="22.192866578599705" fill="#ef444466" stroke="#ef4444" stroke-width="0.020276785714285723" /> - + +globalConnNetId: connectivity_net6" data-x="-3.7550000000000003" data-y="4.193333333333335" x="228.26948480845445" y="174.96550711874357" width="9.863496257155447" height="22.192866578599705" fill="#ef444466" stroke="#ef4444" stroke-width="0.020276785714285723" /> +globalConnNetId: connectivity_net6" data-x="-6.415" data-y="3.9943333333333353" x="97.08498458828723" y="184.7796858946132" width="9.863496257155418" height="22.192866578599705" fill="#ef444466" stroke="#ef4444" stroke-width="0.020276785714285723" /> +globalConnNetId: connectivity_net9" data-x="-2.25" data-y="-1.4000000000000004" x="296.327608982827" y="456.97930427124606" width="22.192866578599705" height="9.863496257155475" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.020276785714285723" /> +globalConnNetId: connectivity_net8" data-x="-2.25" data-y="2.200000000000001" x="296.327608982827" y="279.4363716424482" width="22.192866578599705" height="9.863496257155475" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.020276785714285723" /> + + + + + + + + + + + + + + + +globalConnNetId: connectivity_net10" data-x="-3.7550000000000003" data-y="2.2433333333333345" x="228.26948480845445" y="271.13459562600906" width="9.863496257155447" height="22.192866578599705" fill="#00000066" stroke="#000000" stroke-width="0.020276785714285723" /> +globalConnNetId: connectivity_net10" data-x="-6.415" data-y="2.4423333333333357" x="97.08498458828723" y="261.32041685013934" width="9.863496257155418" height="22.192866578599762" fill="#00000066" stroke="#000000" stroke-width="0.020276785714285723" /> +globalConnNetId: connectivity_net10" data-x="-2.49" data-y="-3.1250000000000004" x="290.6560986349626" y="535.8872743284896" width="9.863496257155418" height="22.192866578599705" fill="#00000066" stroke="#000000" stroke-width="0.020276785714285723" /> - + - + - + - + - + + + + @@ -136,19 +134,19 @@ orientation: x+" data-x="1.757519574999999" data-y="-2" cx="493.97982495355666" - + - + - + - + - + @@ -167,28 +165,27 @@ 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.7009999999999998" data-y="-0.224" x="479.9672460962966" y="304.24341569495203" width="17.90520943755456" height="40.286721234497634" 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" />