From b80eb437827b2979ad839f75e15d38f550640740 Mon Sep 17 00:00:00 2001 From: prokesmic Date: Sat, 16 May 2026 14:04:03 +0200 Subject: [PATCH 1/2] Merge nearby same-net trace lines --- .../TraceCleanupSolver/TraceCleanupSolver.ts | 18 +- .../mergeNearbySameNetTraceLines.ts | 181 ++++++++++++++++++ .../mergeNearbySameNetTraceLines.test.ts | 88 +++++++++ 3 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 lib/solvers/TraceCleanupSolver/mergeNearbySameNetTraceLines.ts create mode 100644 tests/solvers/TraceCleanupSolver/mergeNearbySameNetTraceLines.test.ts diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index e9bac7ca..3c5e80bc 100644 --- a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts +++ b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts @@ -20,6 +20,7 @@ interface TraceCleanupSolverInput { import { UntangleTraceSubsolver } from "./sub-solver/UntangleTraceSubsolver" import { is4PointRectangle } from "./is4PointRectangle" +import { mergeNearbySameNetTraceLines } from "./mergeNearbySameNetTraceLines" /** * Represents the different stages or steps within the trace cleanup pipeline. @@ -27,6 +28,7 @@ import { is4PointRectangle } from "./is4PointRectangle" type PipelineStep = | "minimizing_turns" | "balancing_l_shapes" + | "merging_same_net_lines" | "untangling_traces" /** @@ -84,6 +86,9 @@ export class TraceCleanupSolver extends BaseSolver { case "balancing_l_shapes": this._runBalanceLShapesStep() break + case "merging_same_net_lines": + this._runMergeSameNetLinesStep() + break } } @@ -108,13 +113,24 @@ 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() { + this.outputTraces = mergeNearbySameNetTraceLines( + this.outputTraces, + this.input.paddingBuffer, + ) + this.tracesMap = new Map(this.outputTraces.map((t) => [t.mspPairId, t])) + this.pipelineStep = "balancing_l_shapes" + this.traceIdQueue = [] + 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/mergeNearbySameNetTraceLines.ts b/lib/solvers/TraceCleanupSolver/mergeNearbySameNetTraceLines.ts new file mode 100644 index 00000000..2cf3530b --- /dev/null +++ b/lib/solvers/TraceCleanupSolver/mergeNearbySameNetTraceLines.ts @@ -0,0 +1,181 @@ +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { simplifyPath } from "./simplifyPath" + +const EPS = 1e-6 + +type SegmentOrientation = "horizontal" | "vertical" + +interface SegmentLocator { + traceIndex: number + segmentIndex: number + orientation: SegmentOrientation + coord: number + spanMin: number + spanMax: number + length: number + canMove: boolean +} + +const getSegmentLocator = ( + trace: SolvedTracePath, + traceIndex: number, + segmentIndex: number, +): SegmentLocator | null => { + const p1 = trace.tracePath[segmentIndex] + const p2 = trace.tracePath[segmentIndex + 1] + if (!p1 || !p2) return null + + const isHorizontal = Math.abs(p1.y - p2.y) < EPS + const isVertical = Math.abs(p1.x - p2.x) < EPS + if (!isHorizontal && !isVertical) return null + + if (isHorizontal) { + const spanMin = Math.min(p1.x, p2.x) + const spanMax = Math.max(p1.x, p2.x) + if (spanMax - spanMin < EPS) return null + return { + traceIndex, + segmentIndex, + orientation: "horizontal", + coord: p1.y, + spanMin, + spanMax, + length: spanMax - spanMin, + canMove: segmentIndex > 0 && segmentIndex < trace.tracePath.length - 2, + } + } + + const spanMin = Math.min(p1.y, p2.y) + const spanMax = Math.max(p1.y, p2.y) + if (spanMax - spanMin < EPS) return null + return { + traceIndex, + segmentIndex, + orientation: "vertical", + coord: p1.x, + spanMin, + spanMax, + length: spanMax - spanMin, + canMove: segmentIndex > 0 && segmentIndex < trace.tracePath.length - 2, + } +} + +const getSegmentsByNet = (traces: SolvedTracePath[]) => { + const segmentsByNet = new Map() + + traces.forEach((trace, traceIndex) => { + for ( + let segmentIndex = 0; + segmentIndex < trace.tracePath.length - 1; + segmentIndex++ + ) { + const locator = getSegmentLocator(trace, traceIndex, segmentIndex) + if (!locator) continue + const existing = segmentsByNet.get(trace.globalConnNetId) ?? [] + existing.push(locator) + segmentsByNet.set(trace.globalConnNetId, existing) + } + }) + + return segmentsByNet +} + +const spansOverlap = (a: SegmentLocator, b: SegmentLocator) => + Math.min(a.spanMax, b.spanMax) - Math.max(a.spanMin, b.spanMin) > EPS + +const moveSegmentToCoord = ( + trace: SolvedTracePath, + segmentIndex: number, + orientation: SegmentOrientation, + coord: number, +) => { + const p1 = trace.tracePath[segmentIndex]! + const p2 = trace.tracePath[segmentIndex + 1]! + + if (orientation === "horizontal") { + p1.y = coord + p2.y = coord + } else { + p1.x = coord + p2.x = coord + } + + trace.tracePath = simplifyPath(trace.tracePath) +} + +const pickSourceAndTarget = ( + a: SegmentLocator, + b: SegmentLocator, +): { source: SegmentLocator; target: SegmentLocator } | null => { + if (!a.canMove && !b.canMove) return null + if (a.canMove && !b.canMove) return { source: a, target: b } + if (!a.canMove && b.canMove) return { source: b, target: a } + + // Move the shorter segment onto the longer one to preserve the dominant run. + return a.length <= b.length + ? { source: a, target: b } + : { source: b, target: a } +} + +/** + * Aligns nearby overlapping segments that belong to the same global net. + * + * Endpoint segments are left in place so traces remain connected to their pins. + * Internal segments can be moved onto a nearby same-net segment; adjacent + * orthogonal segments stretch or shrink naturally because they share endpoints. + */ +export const mergeNearbySameNetTraceLines = ( + traces: SolvedTracePath[], + maxDistance: number, +): SolvedTracePath[] => { + const outputTraces = traces.map((trace) => ({ + ...trace, + tracePath: trace.tracePath.map((point) => ({ ...point })), + })) + + let changed = true + let passCount = 0 + const maxPasses = Math.max(1, outputTraces.length * 8) + + while (changed && passCount < maxPasses) { + changed = false + passCount++ + + const segmentsByNet = getSegmentsByNet(outputTraces) + + for (const segments of segmentsByNet.values()) { + 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.traceIndex === b.traceIndex && + Math.abs(a.segmentIndex - b.segmentIndex) <= 1 + ) { + continue + } + if (a.orientation !== b.orientation) continue + if (Math.abs(a.coord - b.coord) < EPS) continue + if (Math.abs(a.coord - b.coord) > maxDistance) continue + if (!spansOverlap(a, b)) continue + + const pair = pickSourceAndTarget(a, b) + if (!pair) continue + + moveSegmentToCoord( + outputTraces[pair.source.traceIndex]!, + pair.source.segmentIndex, + pair.source.orientation, + pair.target.coord, + ) + changed = true + break + } + if (changed) break + } + if (changed) break + } + } + + return outputTraces +} diff --git a/tests/solvers/TraceCleanupSolver/mergeNearbySameNetTraceLines.test.ts b/tests/solvers/TraceCleanupSolver/mergeNearbySameNetTraceLines.test.ts new file mode 100644 index 00000000..c5f9a60b --- /dev/null +++ b/tests/solvers/TraceCleanupSolver/mergeNearbySameNetTraceLines.test.ts @@ -0,0 +1,88 @@ +import { expect, test } from "bun:test" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { mergeNearbySameNetTraceLines } from "lib/solvers/TraceCleanupSolver/mergeNearbySameNetTraceLines" + +const makeTrace = ( + mspPairId: string, + globalConnNetId: string, + tracePath: SolvedTracePath["tracePath"], +): SolvedTracePath => + ({ + mspPairId, + globalConnNetId, + dcConnNetId: globalConnNetId, + userNetId: globalConnNetId, + tracePath, + mspConnectionPairIds: [mspPairId], + pinIds: [], + pins: [], + }) as any + +test("mergeNearbySameNetTraceLines aligns close same-net horizontal segments", () => { + const traces = mergeNearbySameNetTraceLines( + [ + makeTrace("a", "N1", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 0 }, + ]), + makeTrace("b", "N1", [ + { x: 1, y: 0 }, + { x: 1, y: 1.05 }, + { x: 3, y: 1.05 }, + { x: 3, y: 0 }, + ]), + ], + 0.1, + ) + + expect(traces[1]!.tracePath[1]!.y).toBe(1) + expect(traces[1]!.tracePath[2]!.y).toBe(1) +}) + +test("mergeNearbySameNetTraceLines aligns close same-net vertical segments", () => { + const traces = mergeNearbySameNetTraceLines( + [ + makeTrace("a", "N1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 4 }, + { x: 0, y: 4 }, + ]), + makeTrace("b", "N1", [ + { x: 0, y: 1 }, + { x: 1.05, y: 1 }, + { x: 1.05, y: 3 }, + { x: 0, y: 3 }, + ]), + ], + 0.1, + ) + + expect(traces[1]!.tracePath[1]!.x).toBe(1) + expect(traces[1]!.tracePath[2]!.x).toBe(1) +}) + +test("mergeNearbySameNetTraceLines leaves different nets unchanged", () => { + const traces = mergeNearbySameNetTraceLines( + [ + makeTrace("a", "N1", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 0 }, + ]), + makeTrace("b", "N2", [ + { x: 1, y: 0 }, + { x: 1, y: 1.05 }, + { x: 3, y: 1.05 }, + { x: 3, y: 0 }, + ]), + ], + 0.1, + ) + + expect(traces[1]!.tracePath[1]!.y).toBe(1.05) + expect(traces[1]!.tracePath[2]!.y).toBe(1.05) +}) From 8668a483896b381ed803e409c3a05335412397eb Mon Sep 17 00:00:00 2001 From: prokesmic Date: Sun, 17 May 2026 08:46:39 +0200 Subject: [PATCH 2/2] Avoid foreign-net overlaps when merging trace lines --- .../mergeNearbySameNetTraceLines.ts | 59 +++++++++++++++++++ .../mergeNearbySameNetTraceLines.test.ts | 29 +++++++++ 2 files changed, 88 insertions(+) diff --git a/lib/solvers/TraceCleanupSolver/mergeNearbySameNetTraceLines.ts b/lib/solvers/TraceCleanupSolver/mergeNearbySameNetTraceLines.ts index 2cf3530b..c404c43d 100644 --- a/lib/solvers/TraceCleanupSolver/mergeNearbySameNetTraceLines.ts +++ b/lib/solvers/TraceCleanupSolver/mergeNearbySameNetTraceLines.ts @@ -80,6 +80,14 @@ const getSegmentsByNet = (traces: SolvedTracePath[]) => { return segmentsByNet } +const getAllSegments = (traces: SolvedTracePath[]) => + traces.flatMap((trace, traceIndex) => + trace.tracePath.flatMap((_, segmentIndex) => { + const locator = getSegmentLocator(trace, traceIndex, segmentIndex) + return locator ? [locator] : [] + }), + ) + const spansOverlap = (a: SegmentLocator, b: SegmentLocator) => Math.min(a.spanMax, b.spanMax) - Math.max(a.spanMin, b.spanMin) > EPS @@ -103,6 +111,48 @@ const moveSegmentToCoord = ( trace.tracePath = simplifyPath(trace.tracePath) } +const cloneAndMoveTrace = ( + trace: SolvedTracePath, + segmentIndex: number, + orientation: SegmentOrientation, + coord: number, +): SolvedTracePath => { + const clonedTrace = { + ...trace, + tracePath: trace.tracePath.map((point) => ({ ...point })), + } + moveSegmentToCoord(clonedTrace, segmentIndex, orientation, coord) + return clonedTrace +} + +const wouldCreateDifferentNetOverlap = ( + traces: SolvedTracePath[], + source: SegmentLocator, + target: SegmentLocator, +) => { + const movedTrace = cloneAndMoveTrace( + traces[source.traceIndex]!, + source.segmentIndex, + source.orientation, + target.coord, + ) + const movedTraceSegments = getAllSegments([movedTrace]) + const otherNetSegments = getAllSegments(traces).filter( + (segment) => + traces[segment.traceIndex]!.globalConnNetId !== + movedTrace.globalConnNetId, + ) + + return movedTraceSegments.some((candidate) => + otherNetSegments.some( + (other) => + candidate.orientation === other.orientation && + Math.abs(candidate.coord - other.coord) < EPS && + spansOverlap(candidate, other), + ), + ) +} + const pickSourceAndTarget = ( a: SegmentLocator, b: SegmentLocator, @@ -161,6 +211,15 @@ export const mergeNearbySameNetTraceLines = ( const pair = pickSourceAndTarget(a, b) if (!pair) continue + if ( + wouldCreateDifferentNetOverlap( + outputTraces, + pair.source, + pair.target, + ) + ) { + continue + } moveSegmentToCoord( outputTraces[pair.source.traceIndex]!, diff --git a/tests/solvers/TraceCleanupSolver/mergeNearbySameNetTraceLines.test.ts b/tests/solvers/TraceCleanupSolver/mergeNearbySameNetTraceLines.test.ts index c5f9a60b..d7a8abe4 100644 --- a/tests/solvers/TraceCleanupSolver/mergeNearbySameNetTraceLines.test.ts +++ b/tests/solvers/TraceCleanupSolver/mergeNearbySameNetTraceLines.test.ts @@ -86,3 +86,32 @@ test("mergeNearbySameNetTraceLines leaves different nets unchanged", () => { expect(traces[1]!.tracePath[1]!.y).toBe(1.05) expect(traces[1]!.tracePath[2]!.y).toBe(1.05) }) + +test("mergeNearbySameNetTraceLines rejects moves that overlap a different net", () => { + const traces = mergeNearbySameNetTraceLines( + [ + makeTrace("a", "N1", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 0 }, + ]), + makeTrace("b", "N1", [ + { x: 1, y: 0 }, + { x: 1, y: 1.05 }, + { x: 3, y: 1.05 }, + { x: 3, y: 0 }, + ]), + makeTrace("c", "N2", [ + { x: 1.5, y: 2 }, + { x: 1.5, y: 1 }, + { x: 2.5, y: 1 }, + { x: 2.5, y: 2 }, + ]), + ], + 0.1, + ) + + expect(traces[1]!.tracePath[1]!.y).toBe(1.05) + expect(traces[1]!.tracePath[2]!.y).toBe(1.05) +})