From 03136a8bcb2b0e8bc0879c19184c2744bd082974 Mon Sep 17 00:00:00 2001 From: misteromb Date: Mon, 23 Mar 2026 19:29:00 +0000 Subject: [PATCH] fix: merge same-net trace lines that are close together (closes #34) Add a new pipeline step in TraceCleanupSolver that aligns same-net trace segments to share the same Y (horizontal) or X (vertical) coordinate when they are within a threshold distance (0.15 units). The algorithm: - Groups traces by their dcConnNetId (same net) - For each net with multiple traces, extracts horizontal/vertical segments - Finds clusters of close coordinates across different traces - Averages the coordinates and applies the merge if no collisions result - Skips endpoint segments to preserve pin connections Co-Authored-By: Claude Opus 4.6 (1M context) --- .../TraceCleanupSolver/TraceCleanupSolver.ts | 23 +- .../TraceCleanupSolver/mergeSameNetLines.ts | 214 ++++++++++++++++++ .../examples/__snapshots__/example02.snap.svg | 10 +- .../examples/__snapshots__/example18.snap.svg | 8 +- .../examples/__snapshots__/example19.snap.svg | 8 +- .../examples/__snapshots__/example20.snap.svg | 8 +- .../examples/__snapshots__/example28.snap.svg | 134 +++++------ 7 files changed, 319 insertions(+), 86 deletions(-) create mode 100644 lib/solvers/TraceCleanupSolver/mergeSameNetLines.ts diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index e9bac7ca..83806047 100644 --- a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts +++ b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts @@ -2,6 +2,7 @@ import type { InputProblem } from "lib/types/InputProblem" import type { GraphicsObject, Line } from "graphics-debug" import { minimizeTurnsWithFilteredLabels } from "./minimizeTurnsWithFilteredLabels" import { balanceZShapes } from "./balanceZShapes" +import { mergeSameNetLines } from "./mergeSameNetLines" import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem" @@ -27,6 +28,7 @@ import { is4PointRectangle } from "./is4PointRectangle" type PipelineStep = | "minimizing_turns" | "balancing_l_shapes" + | "merging_same_net_lines" | "untangling_traces" /** @@ -34,7 +36,9 @@ type PipelineStep = * It operates in a multi-step pipeline: * 1. **Untangling Traces**: It first attempts to untangle any overlapping or highly convoluted traces using a sub-solver. * 2. **Minimizing Turns**: After untangling, it iterates through each trace to minimize the number of turns, simplifying their paths. - * 3. **Balancing L-Shapes**: Finally, it balances L-shaped trace segments to create more visually appealing and consistent layouts. + * 3. **Balancing L-Shapes**: It balances L-shaped trace segments to create more visually appealing and consistent layouts. + * 4. **Merging Same-Net Lines**: Finally, it merges same-net trace lines that are close together by aligning + * them to the same Y (for horizontal segments) or same X (for vertical segments). * The solver processes traces one by one, applying these cleanup steps sequentially to refine the overall trace layout. */ export class TraceCleanupSolver extends BaseSolver { @@ -84,6 +88,9 @@ export class TraceCleanupSolver extends BaseSolver { case "balancing_l_shapes": this._runBalanceLShapesStep() break + case "merging_same_net_lines": + this._runMergeSameNetLinesStep() + break } } @@ -108,13 +115,25 @@ export class TraceCleanupSolver extends BaseSolver { private _runBalanceLShapesStep() { if (this.traceIdQueue.length === 0) { - this.solved = true + this.pipelineStep = "merging_same_net_lines" return } this._processTrace("balancing_l_shapes") } + private _runMergeSameNetLinesStep() { + const mergedTraces = mergeSameNetLines({ + traces: this.outputTraces, + inputProblem: this.input.inputProblem, + allLabelPlacements: this.input.allLabelPlacements, + mergedLabelNetIdMap: this.input.mergedLabelNetIdMap, + }) + this.outputTraces = mergedTraces + this.tracesMap = new Map(mergedTraces.map((t) => [t.mspPairId, t])) + this.solved = true + } + private _processTrace(step: "minimizing_turns" | "balancing_l_shapes") { const targetMspConnectionPairId = this.traceIdQueue.shift()! this.activeTraceId = targetMspConnectionPairId diff --git a/lib/solvers/TraceCleanupSolver/mergeSameNetLines.ts b/lib/solvers/TraceCleanupSolver/mergeSameNetLines.ts new file mode 100644 index 00000000..2f5065e6 --- /dev/null +++ b/lib/solvers/TraceCleanupSolver/mergeSameNetLines.ts @@ -0,0 +1,214 @@ +import type { Point } from "graphics-debug" +import type { InputProblem } from "lib/types/InputProblem" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import type { NetLabelPlacement } from "../NetLabelPlacementSolver/NetLabelPlacementSolver" +import { getObstacleRects } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/rect" +import { segmentIntersectsRect } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/collisions" +import { simplifyPath } from "./simplifyPath" + +const MERGE_THRESHOLD = 0.15 + +interface Segment { + traceId: string + segIndex: number + orientation: "horizontal" | "vertical" + coordinate: number +} + +/** + * Merges same-net trace lines that are close together by aligning them + * to the same Y (for horizontal segments) or same X (for vertical segments). + * + * When two traces belong to the same net and have parallel segments with + * close coordinates, they are adjusted to share the same coordinate value + * (the average), improving visual clarity. + */ +export const mergeSameNetLines = ({ + traces, + inputProblem, + allLabelPlacements, + mergedLabelNetIdMap, +}: { + traces: SolvedTracePath[] + inputProblem: InputProblem + allLabelPlacements: NetLabelPlacement[] + mergedLabelNetIdMap: Record> +}): SolvedTracePath[] => { + const tracesByNet = new Map() + for (const trace of traces) { + const netId = trace.dcConnNetId + if (!tracesByNet.has(netId)) { + tracesByNet.set(netId, []) + } + tracesByNet.get(netId)!.push(trace) + } + + const TOLERANCE = 1e-5 + const staticObstacles = getObstacleRects(inputProblem).map((obs) => ({ + ...obs, + minX: obs.minX + TOLERANCE, + maxX: obs.maxX - TOLERANCE, + minY: obs.minY + TOLERANCE, + maxY: obs.maxY - TOLERANCE, + })) + + const labelBounds = allLabelPlacements.map((nl) => ({ + chipId: `label-${nl.globalConnNetId}`, + minX: nl.center.x - nl.width / 2 + TOLERANCE, + maxX: nl.center.x + nl.width / 2 - TOLERANCE, + minY: nl.center.y - nl.height / 2 + TOLERANCE, + maxY: nl.center.y + nl.height / 2 - TOLERANCE, + })) + + const updatedTraces = traces.map((t) => ({ + ...t, + tracePath: t.tracePath.map((p) => ({ ...p })), + })) + const traceMap = new Map(updatedTraces.map((t) => [t.mspPairId, t])) + const modifiedTraceIds = new Set() + + for (const [_netId, netTraces] of tracesByNet) { + if (netTraces.length < 2) continue + + const segments: Segment[] = [] + for (const trace of netTraces) { + const path = traceMap.get(trace.mspPairId)!.tracePath + for (let i = 0; i < path.length - 1; i++) { + const p1 = path[i] + const p2 = path[i + 1] + const isHoriz = Math.abs(p1.y - p2.y) < TOLERANCE + const isVert = Math.abs(p1.x - p2.x) < TOLERANCE + + if (isHoriz) { + segments.push({ + traceId: trace.mspPairId, + segIndex: i, + orientation: "horizontal", + coordinate: p1.y, + }) + } else if (isVert) { + segments.push({ + traceId: trace.mspPairId, + segIndex: i, + orientation: "vertical", + coordinate: p1.x, + }) + } + } + } + + for (const orientation of ["horizontal", "vertical"] as const) { + const orientedSegments = segments.filter( + (s) => s.orientation === orientation, + ) + + orientedSegments.sort((a, b) => a.coordinate - b.coordinate) + + const merged = new Set() + for (let i = 0; i < orientedSegments.length; i++) { + if (merged.has(i)) continue + + const cluster: number[] = [i] + const traceIdsInCluster = new Set([orientedSegments[i].traceId]) + + for (let j = i + 1; j < orientedSegments.length; j++) { + if (merged.has(j)) continue + const diff = Math.abs( + orientedSegments[j].coordinate - orientedSegments[i].coordinate, + ) + if (diff > MERGE_THRESHOLD) break + + if (traceIdsInCluster.has(orientedSegments[j].traceId)) continue + + cluster.push(j) + traceIdsInCluster.add(orientedSegments[j].traceId) + } + + if (cluster.length < 2) continue + + const uniqueTraceIds = new Set( + cluster.map((idx) => orientedSegments[idx].traceId), + ) + if (uniqueTraceIds.size < 2) continue + + const avgCoord = + cluster.reduce( + (sum, idx) => sum + orientedSegments[idx].coordinate, + 0, + ) / cluster.length + + let canMerge = true + for (const idx of cluster) { + const seg = orientedSegments[idx] + const trace = traceMap.get(seg.traceId)! + const path = trace.tracePath + + // Skip endpoint segments (first and last) + if (seg.segIndex === 0 || seg.segIndex === path.length - 2) { + canMerge = false + break + } + + // Create test path with the merged coordinate + const testPath = path.map((p) => ({ ...p })) + + if (orientation === "horizontal") { + testPath[seg.segIndex].y = avgCoord + testPath[seg.segIndex + 1].y = avgCoord + } else { + testPath[seg.segIndex].x = avgCoord + testPath[seg.segIndex + 1].x = avgCoord + } + + // Check collisions with obstacles + for (let k = 0; k < testPath.length - 1; k++) { + for (const obs of staticObstacles) { + if (segmentIntersectsRect(testPath[k], testPath[k + 1], obs)) { + canMerge = false + break + } + } + if (!canMerge) break + for (const lb of labelBounds) { + if (segmentIntersectsRect(testPath[k], testPath[k + 1], lb)) { + canMerge = false + break + } + } + if (!canMerge) break + } + if (!canMerge) break + } + + if (!canMerge) continue + + // Apply the merge + for (const idx of cluster) { + const seg = orientedSegments[idx] + const trace = traceMap.get(seg.traceId)! + const path = trace.tracePath + + if (orientation === "horizontal") { + path[seg.segIndex].y = avgCoord + path[seg.segIndex + 1].y = avgCoord + } else { + path[seg.segIndex].x = avgCoord + path[seg.segIndex + 1].x = avgCoord + } + + modifiedTraceIds.add(seg.traceId) + merged.add(idx) + } + } + } + } + + // Only simplify modified traces to avoid unintended changes + for (const trace of updatedTraces) { + if (modifiedTraceIds.has(trace.mspPairId)) { + trace.tracePath = simplifyPath(trace.tracePath) + } + } + + return updatedTraces +} diff --git a/tests/examples/__snapshots__/example02.snap.svg b/tests/examples/__snapshots__/example02.snap.svg index 39a46c70..beabbe6e 100644 --- a/tests/examples/__snapshots__/example02.snap.svg +++ b/tests/examples/__snapshots__/example02.snap.svg @@ -56,7 +56,7 @@ x+" data-x="1" data-y="0.1" cx="500.20151295522464" cy="324.189420823755" r="3" - + @@ -164,13 +164,13 @@ x+" data-x="1" data-y="0.1" cx="500.20151295522464" cy="324.189420823755" r="3" - + - + - + @@ -194,7 +194,7 @@ x+" data-x="1" data-y="0.1" cx="500.20151295522464" cy="324.189420823755" r="3" - + diff --git a/tests/examples/__snapshots__/example18.snap.svg b/tests/examples/__snapshots__/example18.snap.svg index d3b910b0..4c50a93e 100644 --- a/tests/examples/__snapshots__/example18.snap.svg +++ b/tests/examples/__snapshots__/example18.snap.svg @@ -68,7 +68,7 @@ y+" data-x="1.757519574999999" data-y="-2.2" cx="493.97982495355666" cy="501.290 - + @@ -131,10 +131,10 @@ y+" data-x="1.757519574999999" data-y="-2.2" cx="493.97982495355666" cy="501.290 - + - + @@ -164,7 +164,7 @@ y+" data-x="1.757519574999999" data-y="-2.2" cx="493.97982495355666" cy="501.290 - + diff --git a/tests/examples/__snapshots__/example19.snap.svg b/tests/examples/__snapshots__/example19.snap.svg index 1ae98880..a316e839 100644 --- a/tests/examples/__snapshots__/example19.snap.svg +++ b/tests/examples/__snapshots__/example19.snap.svg @@ -58,7 +58,7 @@ x-" data-x="1.5541992" data-y="-1.2014628704999997" cx="255.41992000000002" cy=" - + @@ -97,10 +97,10 @@ x-" data-x="1.5541992" data-y="-1.2014628704999997" cx="255.41992000000002" cy=" - + - + @@ -133,7 +133,7 @@ x-" data-x="1.5541992" data-y="-1.2014628704999997" cx="255.41992000000002" cy=" - + - + @@ -131,10 +131,10 @@ y+" data-x="1.757519574999999" data-y="-2.2" cx="479.91860312291124" cy="509.268 - + - + @@ -164,7 +164,7 @@ y+" data-x="1.757519574999999" data-y="-2.2" cx="479.91860312291124" cy="509.268 - + diff --git a/tests/examples/__snapshots__/example28.snap.svg b/tests/examples/__snapshots__/example28.snap.svg index 5a3afb7e..8d1df26d 100644 --- a/tests/examples/__snapshots__/example28.snap.svg +++ b/tests/examples/__snapshots__/example28.snap.svg @@ -2,216 +2,216 @@ +y+" data-x="-5.005" data-y="2.54" cx="55.35329792453959" cy="255.53595624203547" r="3" fill="hsl(230, 100%, 50%, 0.8)" /> +y-" data-x="-4.995" data-y="1.46" cx="55.841125946798485" cy="308.2213826459981" r="3" fill="hsl(231, 100%, 50%, 0.8)" /> +x-" data-x="-0.58" data-y="2.98" cx="271.2171977741087" cy="234.0715232626433" r="3" fill="hsl(40, 100%, 50%, 0.8)" /> +x+" data-x="0.58" data-y="2.97" cx="327.8052483561426" cy="234.5593512849022" r="3" fill="hsl(41, 100%, 50%, 0.8)" /> +x-" data-x="2.48" data-y="3" cx="420.4925725853361" cy="233.09586721812545" r="3" fill="hsl(32, 100%, 50%, 0.8)" /> +x+" data-x="3.52" data-y="3" cx="471.2266869002631" cy="233.09586721812545" r="3" fill="hsl(33, 100%, 50%, 0.8)" /> +y-" data-x="3" data-y="-0.49500000000000005" cx="445.8596297427996" cy="403.5917609976156" r="3" fill="hsl(122, 100%, 50%, 0.8)" /> +y+" data-x="3" data-y="0.49500000000000005" cx="445.8596297427996" cy="355.29678679398324" r="3" fill="hsl(121, 100%, 50%, 0.8)" /> +y+" data-x="6" data-y="0.55" cx="592.2080364204736" cy="352.6137326715592" r="3" fill="hsl(226, 100%, 50%, 0.8)" /> +y-" data-x="6" data-y="-0.55" cx="592.2080364204736" cy="406.27481512003965" r="3" fill="hsl(227, 100%, 50%, 0.8)" /> +y+" data-x="-2.9999378" data-y="0.4458008" cx="153.16585067775011" cy="357.69686163725527" r="3" fill="hsl(111, 100%, 50%, 0.8)" /> +y-" data-x="-3.0000622" data-y="-0.4458008" cx="153.15978209715323" cy="401.1916861543436" r="3" fill="hsl(112, 100%, 50%, 0.8)" /> +y+" data-x="0.3" data-y="0.58" cx="314.146063732893" cy="351.15024860478246" r="3" fill="hsl(311, 100%, 50%, 0.8)" /> +y-" data-x="0.31" data-y="-0.58" cx="314.633891755152" cy="407.7382991868164" r="3" fill="hsl(312, 100%, 50%, 0.8)" /> +x-" data-x="-0.445" data-y="-0.1" cx="277.802876074604" cy="384.32255411838855" r="3" fill="hsl(313, 100%, 50%, 0.8)" /> - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - +