From b087d18f0bdbe66fd869dbaa204b9abbf4b60b7f Mon Sep 17 00:00:00 2001 From: Jacob Yeung <11915582+jacobyeung@users.noreply.github.com> Date: Fri, 22 May 2026 16:04:41 -0700 Subject: [PATCH 1/2] Add same-net trace segment combine phase --- .../TraceCleanupSolver/TraceCleanupSolver.ts | 24 ++- .../combineSameNetTraceSegments.ts | 175 ++++++++++++++++++ package.json | 1 + .../combineSameNetTraceSegments.test.ts | 111 +++++++++++ 4 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 lib/solvers/TraceCleanupSolver/combineSameNetTraceSegments.ts create mode 100644 tests/solvers/TraceCleanupSolver/combineSameNetTraceSegments.test.ts diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index e9bac7ca3..512deb1c1 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 { combineSameNetTraceSegments } from "./combineSameNetTraceSegments" import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem" @@ -25,6 +26,7 @@ import { is4PointRectangle } from "./is4PointRectangle" * Represents the different stages or steps within the trace cleanup pipeline. */ type PipelineStep = + | "combining_same_net_segments" | "minimizing_turns" | "balancing_l_shapes" | "untangling_traces" @@ -33,8 +35,9 @@ type PipelineStep = * The TraceCleanupSolver is responsible for improving the aesthetics and readability of schematic traces. * 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. + * 2. **Combining Same-Net Segments**: Close overlapping same-net segments are snapped together to avoid duplicate traces. + * 3. **Minimizing Turns**: After untangling, it iterates through each trace to minimize the number of turns, simplifying their paths. + * 4. **Balancing L-Shapes**: Finally, it balances L-shaped trace segments to create more visually appealing and consistent layouts. * The solver processes traces one by one, applying these cleanup steps sequentially to refine the overall trace layout. */ export class TraceCleanupSolver extends BaseSolver { @@ -66,10 +69,10 @@ export class TraceCleanupSolver extends BaseSolver { this.outputTraces = output.traces this.tracesMap = new Map(this.outputTraces.map((t) => [t.mspPairId, t])) this.activeSubSolver = null - this.pipelineStep = "minimizing_turns" + this.pipelineStep = "combining_same_net_segments" } else if (this.activeSubSolver.failed) { this.activeSubSolver = null - this.pipelineStep = "minimizing_turns" + this.pipelineStep = "combining_same_net_segments" } return } @@ -78,6 +81,9 @@ export class TraceCleanupSolver extends BaseSolver { case "untangling_traces": this._runUntangleTracesStep() break + case "combining_same_net_segments": + this._runCombineSameNetSegmentsStep() + break case "minimizing_turns": this._runMinimizeTurnsStep() break @@ -94,6 +100,16 @@ export class TraceCleanupSolver extends BaseSolver { }) } + private _runCombineSameNetSegmentsStep() { + this.outputTraces = combineSameNetTraceSegments({ + traces: Array.from(this.tracesMap.values()), + maxDistance: this.input.paddingBuffer * 2, + }) + this.tracesMap = new Map(this.outputTraces.map((t) => [t.mspPairId, t])) + this.traceIdQueue = Array.from(this.outputTraces.map((e) => e.mspPairId)) + this.pipelineStep = "minimizing_turns" + } + private _runMinimizeTurnsStep() { if (this.traceIdQueue.length === 0) { this.pipelineStep = "balancing_l_shapes" diff --git a/lib/solvers/TraceCleanupSolver/combineSameNetTraceSegments.ts b/lib/solvers/TraceCleanupSolver/combineSameNetTraceSegments.ts new file mode 100644 index 000000000..1c740b8a9 --- /dev/null +++ b/lib/solvers/TraceCleanupSolver/combineSameNetTraceSegments.ts @@ -0,0 +1,175 @@ +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { simplifyPath } from "./simplifyPath" + +type SegmentOrientation = "horizontal" | "vertical" + +interface SegmentLocator { + traceIndex: number + segmentIndex: number + orientation: SegmentOrientation + fixedCoord: number + spanMin: number + spanMax: number + length: number + isAnchored: boolean +} + +const EPS = 1e-9 + +const cloneTrace = (trace: SolvedTracePath): SolvedTracePath => ({ + ...trace, + tracePath: trace.tracePath.map((point) => ({ ...point })), +}) + +const getTraceNetId = (trace: SolvedTracePath) => + trace.globalConnNetId ?? trace.userNetId ?? trace.dcConnNetId + +const getSegments = ( + trace: SolvedTracePath, + traceIndex: number, +): SegmentLocator[] => { + const segments: SegmentLocator[] = [] + const { tracePath } = trace + + for ( + let segmentIndex = 0; + segmentIndex < tracePath.length - 1; + segmentIndex++ + ) { + const start = tracePath[segmentIndex]! + const end = tracePath[segmentIndex + 1]! + const isHorizontal = Math.abs(start.y - end.y) < EPS + const isVertical = Math.abs(start.x - end.x) < EPS + + if (!isHorizontal && !isVertical) continue + + const orientation = isHorizontal ? "horizontal" : "vertical" + const fixedCoord = isHorizontal ? start.y : start.x + const spanStart = isHorizontal ? start.x : start.y + const spanEnd = isHorizontal ? end.x : end.y + const spanMin = Math.min(spanStart, spanEnd) + const spanMax = Math.max(spanStart, spanEnd) + const length = spanMax - spanMin + + if (length < EPS) continue + + segments.push({ + traceIndex, + segmentIndex, + orientation, + fixedCoord, + spanMin, + spanMax, + length, + isAnchored: + segmentIndex === 0 || segmentIndex + 1 === tracePath.length - 1, + }) + } + + return segments +} + +const getSpanOverlap = (a: SegmentLocator, b: SegmentLocator) => + Math.min(a.spanMax, b.spanMax) - Math.max(a.spanMin, b.spanMin) + +const chooseMove = ( + a: SegmentLocator, + b: SegmentLocator, +): { movable: SegmentLocator; target: SegmentLocator } | null => { + if (a.isAnchored && b.isAnchored) return null + if (a.isAnchored) return { movable: b, target: a } + if (b.isAnchored) return { movable: a, target: b } + + if (a.length >= b.length) { + return { movable: b, target: a } + } + + return { movable: a, target: b } +} + +const moveSegmentToCoord = ( + trace: SolvedTracePath, + segment: SegmentLocator, + targetCoord: number, +) => { + const path = trace.tracePath + const start = path[segment.segmentIndex]! + const end = path[segment.segmentIndex + 1]! + + if (segment.orientation === "horizontal") { + start.y = targetCoord + end.y = targetCoord + } else { + start.x = targetCoord + end.x = targetCoord + } + + trace.tracePath = simplifyPath(path) +} + +export const combineSameNetTraceSegments = ({ + traces, + maxDistance, +}: { + traces: SolvedTracePath[] + maxDistance: number +}): SolvedTracePath[] => { + if (traces.length === 0 || maxDistance <= EPS) return traces + + const nextTraces = traces.map(cloneTrace) + const maxPasses = Math.max(20, nextTraces.length * 20) + + for (let pass = 0; pass < maxPasses; pass++) { + let changed = false + const traceIndexesByNet = new Map() + + for (let traceIndex = 0; traceIndex < nextTraces.length; traceIndex++) { + const trace = nextTraces[traceIndex]! + const netId = getTraceNetId(trace) + if (!netId) continue + if (!traceIndexesByNet.has(netId)) traceIndexesByNet.set(netId, []) + traceIndexesByNet.get(netId)!.push(traceIndex) + } + + for (const traceIndexes of traceIndexesByNet.values()) { + const segments = traceIndexes.flatMap((traceIndex) => + getSegments(nextTraces[traceIndex]!, traceIndex), + ) + + for (let i = 0; i < segments.length; i++) { + const a = segments[i]! + + for (let j = i + 1; j < segments.length; j++) { + const b = segments[j]! + if (a.orientation !== b.orientation) continue + if (Math.abs(a.fixedCoord - b.fixedCoord) > maxDistance) continue + if (getSpanOverlap(a, b) <= EPS) continue + + const move = chooseMove(a, b) + if (!move) continue + if ( + Math.abs(move.movable.fixedCoord - move.target.fixedCoord) <= EPS + ) { + continue + } + + moveSegmentToCoord( + nextTraces[move.movable.traceIndex]!, + move.movable, + move.target.fixedCoord, + ) + changed = true + break + } + + if (changed) break + } + + if (changed) break + } + + if (!changed) break + } + + return nextTraces +} diff --git a/package.json b/package.json index 216f1d8ac..ef43229c1 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react-cosmos-plugin-vite": "^7.0.0", "react-dom": "^19.1.1", "tsup": "^8.5.0", + "typescript": "^5.9.3", "vite": "^7.1.3" }, "peerDependencies": { diff --git a/tests/solvers/TraceCleanupSolver/combineSameNetTraceSegments.test.ts b/tests/solvers/TraceCleanupSolver/combineSameNetTraceSegments.test.ts new file mode 100644 index 000000000..9bf0f41ff --- /dev/null +++ b/tests/solvers/TraceCleanupSolver/combineSameNetTraceSegments.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "bun:test" +import { combineSameNetTraceSegments } from "lib/solvers/TraceCleanupSolver/combineSameNetTraceSegments" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +const makeTrace = ( + mspPairId: string, + globalConnNetId: string, + tracePath: SolvedTracePath["tracePath"], +): SolvedTracePath => + ({ + mspPairId, + dcConnNetId: globalConnNetId, + globalConnNetId, + pins: [] as any, + mspConnectionPairIds: [mspPairId], + pinIds: [], + tracePath, + }) as SolvedTracePath + +test("combines close overlapping same-net segments without moving endpoints", () => { + const traces = combineSameNetTraceSegments({ + maxDistance: 0.1, + traces: [ + makeTrace("a", "net1", [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ]), + makeTrace("b", "net1", [ + { x: 2, y: 0 }, + { x: 2, y: 0.08 }, + { x: 8, y: 0.08 }, + { x: 8, y: 0 }, + ]), + ], + }) + + expect(traces[0]!.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ]) + expect(traces[1]!.tracePath).toEqual([ + { x: 2, y: 0 }, + { x: 8, y: 0 }, + ]) +}) + +test("does not combine close segments from different nets", () => { + const traces = combineSameNetTraceSegments({ + maxDistance: 0.1, + traces: [ + makeTrace("a", "net1", [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ]), + makeTrace("b", "net2", [ + { x: 2, y: 0 }, + { x: 2, y: 0.08 }, + { x: 8, y: 0.08 }, + { x: 8, y: 0 }, + ]), + ], + }) + + expect(traces[1]!.tracePath).toEqual([ + { x: 2, y: 0 }, + { x: 2, y: 0.08 }, + { x: 8, y: 0.08 }, + { x: 8, y: 0 }, + ]) +}) + +test("combines close overlapping segments within a single same-net trace", () => { + const traces = combineSameNetTraceSegments({ + maxDistance: 0.1, + traces: [ + makeTrace("a", "net1", [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 0.08 }, + { x: 2, y: 0.08 }, + { x: 2, y: 0 }, + ]), + ], + }) + + expect(traces[0]!.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + ]) +}) + +test("does not move segments that are anchored to endpoints", () => { + const traces = combineSameNetTraceSegments({ + maxDistance: 0.1, + traces: [ + makeTrace("a", "net1", [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ]), + makeTrace("b", "net1", [ + { x: 0, y: 0.08 }, + { x: 10, y: 0.08 }, + ]), + ], + }) + + expect(traces[1]!.tracePath).toEqual([ + { x: 0, y: 0.08 }, + { x: 10, y: 0.08 }, + ]) +}) From 905c8b17e8e505ccc77fe25fa6f787dd30fa3903 Mon Sep 17 00:00:00 2001 From: Jacob Yeung <11915582+jacobyeung@users.noreply.github.com> Date: Fri, 22 May 2026 16:09:17 -0700 Subject: [PATCH 2/2] Update trace cleanup snapshots --- .../examples/__snapshots__/example06.snap.svg | 29 ++-- .../examples/__snapshots__/example18.snap.svg | 124 ++++++++---------- 2 files changed, 71 insertions(+), 82 deletions(-) diff --git a/tests/examples/__snapshots__/example06.snap.svg b/tests/examples/__snapshots__/example06.snap.svg index 06393ee83..98420a9fa 100644 --- a/tests/examples/__snapshots__/example06.snap.svg +++ b/tests/examples/__snapshots__/example06.snap.svg @@ -2,39 +2,38 @@ +x-" data-x="-3.5512907000000005" data-y="0.0002732499999993365" cx="40" cy="321.17156888419566" r="3" fill="hsl(226, 100%, 50%, 0.8)" /> +x+" data-x="-2.4487092999999995" data-y="-0.0002732499999993365" cx="126.93355635339674" cy="321.21465793734586" r="3" fill="hsl(227, 100%, 50%, 0.8)" /> +x-" data-x="2.4487906999999995" data-y="-0.00027334999999961695" cx="513.0792796902498" cy="321.21466582189356" r="3" fill="hsl(121, 100%, 50%, 0.8)" /> +x+" data-x="3.5512093000000005" data-y="0.00027334999999961695" cx="600" cy="321.17156099964797" r="3" fill="hsl(122, 100%, 50%, 0.8)" /> - + - + - + - + - + +globalConnNetId: connectivity_net0" data-x="-0.6512500000000006" data-y="0.22527324999999934" x="260.77054445617733" y="285.69110425906365" width="15.769095388947562" height="35.480464625132015" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.012683035714285716" />