From 8a8664e960f88c482fb6eefb5e43d7a50e1ce3d7 Mon Sep 17 00:00:00 2001 From: jamilahmadzai Date: Thu, 21 May 2026 21:58:32 +0200 Subject: [PATCH 1/2] Add same-net trace merge phase --- lib/index.ts | 2 + .../SameNetTraceMergeSolver.ts | 50 ++++ .../mergeSameNetTraceSegments.ts | 245 ++++++++++++++++++ .../SchematicTracePipelineSolver.ts | 15 ++ .../same-net-trace-merge-solver.test.ts | 117 +++++++++ 5 files changed, 429 insertions(+) create mode 100644 lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts create mode 100644 lib/solvers/SameNetTraceMergeSolver/mergeSameNetTraceSegments.ts create mode 100644 tests/solvers/SameNetTraceMergeSolver/same-net-trace-merge-solver.test.ts diff --git a/lib/index.ts b/lib/index.ts index 3985b32ac..dd3c2af2b 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,3 +1,5 @@ export * from "./solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver" +export * from "./solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver" +export * from "./solvers/SameNetTraceMergeSolver/mergeSameNetTraceSegments" export * from "./types/InputProblem" export { SchematicTraceSingleLineSolver2 } from "./solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/SchematicTraceSingleLineSolver2" diff --git a/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts new file mode 100644 index 000000000..f34828c57 --- /dev/null +++ b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts @@ -0,0 +1,50 @@ +import type { GraphicsObject, Line } from "graphics-debug" +import { BaseSolver } from "../BaseSolver/BaseSolver" +import type { SolvedTracePath } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { + type MergeSameNetTraceSegmentsOptions, + mergeSameNetTraceSegments, +} from "./mergeSameNetTraceSegments" + +export interface SameNetTraceMergeSolverInput + extends MergeSameNetTraceSegmentsOptions { + traces: SolvedTracePath[] +} + +export class SameNetTraceMergeSolver extends BaseSolver { + private input: SameNetTraceMergeSolverInput + outputTraces: SolvedTracePath[] + + constructor(input: SameNetTraceMergeSolverInput) { + super() + this.input = input + this.outputTraces = input.traces + } + + override _step() { + const result = mergeSameNetTraceSegments(this.input.traces, this.input) + this.outputTraces = result.traces + this.stats.mergedSegmentCount = result.mergedSegmentCount + this.solved = true + } + + getOutput() { + return { + traces: this.outputTraces, + } + } + + override visualize(): GraphicsObject { + const graphics: GraphicsObject = { lines: [] } + + for (const trace of this.outputTraces) { + const line: Line = { + points: trace.tracePath, + strokeColor: "blue", + } + graphics.lines!.push(line) + } + + return graphics + } +} diff --git a/lib/solvers/SameNetTraceMergeSolver/mergeSameNetTraceSegments.ts b/lib/solvers/SameNetTraceMergeSolver/mergeSameNetTraceSegments.ts new file mode 100644 index 000000000..204c3228a --- /dev/null +++ b/lib/solvers/SameNetTraceMergeSolver/mergeSameNetTraceSegments.ts @@ -0,0 +1,245 @@ +import type { Point } from "@tscircuit/math-utils" +import type { SolvedTracePath } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { simplifyPath } from "../TraceCleanupSolver/simplifyPath" + +type SegmentOrientation = "horizontal" | "vertical" + +interface SegmentRef { + traceIndex: number + startIndex: number + orientation: SegmentOrientation + fixedAxis: number + min: number + max: number + length: number + movable: boolean +} + +export interface MergeSameNetTraceSegmentsOptions { + axisTolerance?: number + gapTolerance?: number + maxPasses?: number +} + +const EPS = 1e-6 + +const getNetKey = (trace: SolvedTracePath) => + trace.userNetId ?? trace.globalConnNetId ?? trace.dcConnNetId + +const getOrientation = (p1: Point, p2: Point): SegmentOrientation | null => { + if (Math.abs(p1.y - p2.y) < EPS && Math.abs(p1.x - p2.x) >= EPS) { + return "horizontal" + } + if (Math.abs(p1.x - p2.x) < EPS && Math.abs(p1.y - p2.y) >= EPS) { + return "vertical" + } + return null +} + +const getSegmentRefs = (traces: SolvedTracePath[]): SegmentRef[] => { + const segments: SegmentRef[] = [] + + for (const [traceIndex, trace] of traces.entries()) { + for ( + let startIndex = 0; + startIndex < trace.tracePath.length - 1; + startIndex++ + ) { + const p1 = trace.tracePath[startIndex]! + const p2 = trace.tracePath[startIndex + 1]! + const orientation = getOrientation(p1, p2) + if (!orientation) continue + + const horizontal = orientation === "horizontal" + const a = horizontal ? p1.x : p1.y + const b = horizontal ? p2.x : p2.y + const fixedAxis = horizontal ? p1.y : p1.x + + segments.push({ + traceIndex, + startIndex, + orientation, + fixedAxis, + min: Math.min(a, b), + max: Math.max(a, b), + length: Math.abs(a - b), + movable: startIndex > 0 && startIndex + 1 < trace.tracePath.length - 1, + }) + } + } + + return segments +} + +const getIntervalGap = (a: SegmentRef, b: SegmentRef) => { + if (a.max >= b.min && b.max >= a.min) return 0 + return Math.min(Math.abs(a.max - b.min), Math.abs(b.max - a.min)) +} + +const moveSegmentToAxis = ( + traces: SolvedTracePath[], + segment: SegmentRef, + targetAxis: number, +) => { + const trace = traces[segment.traceIndex]! + const p1 = trace.tracePath[segment.startIndex]! + const p2 = trace.tracePath[segment.startIndex + 1]! + + if (segment.orientation === "horizontal") { + p1.y = targetAxis + p2.y = targetAxis + } else { + p1.x = targetAxis + p2.x = targetAxis + } +} + +const getMovedSegment = (trace: SolvedTracePath, segment: SegmentRef) => { + const p1 = { ...trace.tracePath[segment.startIndex]! } + const p2 = { ...trace.tracePath[segment.startIndex + 1]! } + + if (segment.orientation === "horizontal") { + p1.y = segment.fixedAxis + p2.y = segment.fixedAxis + } else { + p1.x = segment.fixedAxis + p2.x = segment.fixedAxis + } + + return { + ...segment, + min: + segment.orientation === "horizontal" + ? Math.min(p1.x, p2.x) + : Math.min(p1.y, p2.y), + max: + segment.orientation === "horizontal" + ? Math.max(p1.x, p2.x) + : Math.max(p1.y, p2.y), + } +} + +const rangesOverlap = (a: SegmentRef, b: SegmentRef) => + Math.min(a.max, b.max) - Math.max(a.min, b.min) > EPS + +const segmentRefsCollide = (a: SegmentRef, b: SegmentRef) => { + if (a.orientation === b.orientation) { + return Math.abs(a.fixedAxis - b.fixedAxis) < EPS && rangesOverlap(a, b) + } + + const horizontal = a.orientation === "horizontal" ? a : b + const vertical = a.orientation === "vertical" ? a : b + + return ( + vertical.fixedAxis > horizontal.min + EPS && + vertical.fixedAxis < horizontal.max - EPS && + horizontal.fixedAxis > vertical.min + EPS && + horizontal.fixedAxis < vertical.max - EPS + ) +} + +const canMoveSegmentToAxis = ( + traces: SolvedTracePath[], + segment: SegmentRef, + targetAxis: number, +) => { + const trace = traces[segment.traceIndex]! + const movedSegment = getMovedSegment(trace, { + ...segment, + fixedAxis: targetAxis, + }) + const netKey = getNetKey(trace) + + for (const otherSegment of getSegmentRefs(traces)) { + if ( + otherSegment.traceIndex === segment.traceIndex && + otherSegment.startIndex === segment.startIndex + ) { + continue + } + + const otherTrace = traces[otherSegment.traceIndex]! + if (getNetKey(otherTrace) === netKey) continue + + if (segmentRefsCollide(movedSegment, otherSegment)) { + return false + } + } + + return true +} + +export const mergeSameNetTraceSegments = ( + traces: SolvedTracePath[], + opts: MergeSameNetTraceSegmentsOptions = {}, +): { traces: SolvedTracePath[]; mergedSegmentCount: number } => { + const axisTolerance = opts.axisTolerance ?? 0.1 + const gapTolerance = opts.gapTolerance ?? 0.2 + const maxPasses = opts.maxPasses ?? 4 + + const outputTraces = traces.map((trace) => ({ + ...trace, + tracePath: trace.tracePath.map((point) => ({ ...point })), + })) + + let mergedSegmentCount = 0 + + for (let pass = 0; pass < maxPasses; pass++) { + let changedThisPass = false + const segments = getSegmentRefs(outputTraces) + + for (let i = 0; i < segments.length; i++) { + const a = segments[i]! + const traceA = outputTraces[a.traceIndex]! + const netA = getNetKey(traceA) + if (!netA) continue + + for (let j = i + 1; j < segments.length; j++) { + const b = segments[j]! + const traceB = outputTraces[b.traceIndex]! + + if (a.traceIndex === b.traceIndex) continue + if (a.orientation !== b.orientation) continue + if (netA !== getNetKey(traceB)) continue + if (Math.abs(a.fixedAxis - b.fixedAxis) > axisTolerance) continue + if (getIntervalGap(a, b) > gapTolerance) continue + if (!a.movable && !b.movable) continue + + if (!a.movable) { + if (!canMoveSegmentToAxis(outputTraces, b, a.fixedAxis)) continue + moveSegmentToAxis(outputTraces, b, a.fixedAxis) + } else if (!b.movable) { + if (!canMoveSegmentToAxis(outputTraces, a, b.fixedAxis)) continue + moveSegmentToAxis(outputTraces, a, b.fixedAxis) + } else if (a.length === b.length) { + const targetAxis = (a.fixedAxis + b.fixedAxis) / 2 + if ( + !canMoveSegmentToAxis(outputTraces, a, targetAxis) || + !canMoveSegmentToAxis(outputTraces, b, targetAxis) + ) { + continue + } + moveSegmentToAxis(outputTraces, a, targetAxis) + moveSegmentToAxis(outputTraces, b, targetAxis) + } else if (a.length > b.length) { + if (!canMoveSegmentToAxis(outputTraces, b, a.fixedAxis)) continue + moveSegmentToAxis(outputTraces, b, a.fixedAxis) + } else { + if (!canMoveSegmentToAxis(outputTraces, a, b.fixedAxis)) continue + moveSegmentToAxis(outputTraces, a, b.fixedAxis) + } + + mergedSegmentCount++ + changedThisPass = true + } + } + + for (const trace of outputTraces) { + trace.tracePath = simplifyPath(trace.tracePath) + } + + if (!changedThisPass) break + } + + return { traces: outputTraces, mergedSegmentCount } +} diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index a56b50b7b..0eca6ebd4 100644 --- a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts +++ b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts @@ -27,6 +27,7 @@ import { VccNetLabelCornerPlacementSolver } from "../VccNetLabelCornerPlacementS import { TraceAnchoredNetLabelOverlapSolver } from "../TraceAnchoredNetLabelOverlapSolver/TraceAnchoredNetLabelOverlapSolver" import { NetLabelTraceCollisionSolver } from "../NetLabelTraceCollisionSolver/NetLabelTraceCollisionSolver" import { NetLabelNetLabelCollisionSolver } from "../NetLabelNetLabelCollisionSolver/NetLabelNetLabelCollisionSolver" +import { SameNetTraceMergeSolver } from "../SameNetTraceMergeSolver/SameNetTraceMergeSolver" type PipelineStep BaseSolver> = { solverName: string @@ -76,6 +77,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { labelMergingSolver?: MergedNetLabelObstacleSolver traceLabelOverlapAvoidanceSolver?: TraceLabelOverlapAvoidanceSolver traceCleanupSolver?: TraceCleanupSolver + sameNetTraceMergeSolver?: SameNetTraceMergeSolver example28Solver?: Example28Solver availableNetOrientationSolver?: AvailableNetOrientationSolver vccNetLabelCornerPlacementSolver?: VccNetLabelCornerPlacementSolver @@ -219,11 +221,23 @@ export class SchematicTracePipelineSolver extends BaseSolver { }, ] }), + definePipelineStep( + "sameNetTraceMergeSolver", + SameNetTraceMergeSolver, + (instance) => [ + { + traces: + instance.traceCleanupSolver?.getOutput().traces ?? + instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces, + }, + ], + ), definePipelineStep( "netLabelPlacementSolver", NetLabelPlacementSolver, (instance) => { const traces = + instance.sameNetTraceMergeSolver?.getOutput().traces ?? instance.traceCleanupSolver?.getOutput().traces ?? instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces @@ -239,6 +253,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { ), definePipelineStep("example28Solver", Example28Solver, (instance) => { const traces = + instance.sameNetTraceMergeSolver?.getOutput().traces ?? instance.traceCleanupSolver?.getOutput().traces ?? instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces diff --git a/tests/solvers/SameNetTraceMergeSolver/same-net-trace-merge-solver.test.ts b/tests/solvers/SameNetTraceMergeSolver/same-net-trace-merge-solver.test.ts new file mode 100644 index 000000000..b5a650457 --- /dev/null +++ b/tests/solvers/SameNetTraceMergeSolver/same-net-trace-merge-solver.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from "bun:test" +import type { Point } from "@tscircuit/math-utils" +import { SameNetTraceMergeSolver } from "lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +const makeTrace = ( + id: string, + netId: string, + tracePath: Point[], +): SolvedTracePath => + ({ + mspPairId: id, + dcConnNetId: netId, + globalConnNetId: netId, + userNetId: netId, + pins: [] as any, + mspConnectionPairIds: [id], + pinIds: [], + tracePath, + }) as SolvedTracePath + +test("aligns close horizontal same-net internal segments to an existing trunk", () => { + const solver = new SameNetTraceMergeSolver({ + traces: [ + makeTrace("trunk", "N1", [ + { x: 1, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("branch", "N1", [ + { x: 0, y: 1 }, + { x: 1, y: 0.06 }, + { x: 4, y: 0.06 }, + { x: 5, y: 1 }, + ]), + ], + }) + + solver.solve() + + const branchPath = solver.getOutput().traces[1]!.tracePath + expect(branchPath[1]!.y).toBe(0) + expect(branchPath[2]!.y).toBe(0) + expect(solver.stats.mergedSegmentCount).toBeGreaterThan(0) +}) + +test("aligns close vertical same-net internal segments", () => { + const solver = new SameNetTraceMergeSolver({ + traces: [ + makeTrace("trunk", "N1", [ + { x: 0, y: 1 }, + { x: 0, y: 4 }, + ]), + makeTrace("branch", "N1", [ + { x: 1, y: 0 }, + { x: 0.05, y: 1 }, + { x: 0.05, y: 4 }, + { x: 1, y: 5 }, + ]), + ], + }) + + solver.solve() + + const branchPath = solver.getOutput().traces[1]!.tracePath + expect(branchPath[1]!.x).toBe(0) + expect(branchPath[2]!.x).toBe(0) +}) + +test("does not align segments from different nets", () => { + const solver = new SameNetTraceMergeSolver({ + traces: [ + makeTrace("trunk", "N1", [ + { x: 1, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("other-net", "N2", [ + { x: 0, y: 1 }, + { x: 1, y: 0.06 }, + { x: 4, y: 0.06 }, + { x: 5, y: 1 }, + ]), + ], + }) + + solver.solve() + + const otherPath = solver.getOutput().traces[1]!.tracePath + expect(otherPath[1]!.y).toBe(0.06) + expect(otherPath[2]!.y).toBe(0.06) +}) + +test("rejects same-net alignment that would collide with a different net", () => { + const solver = new SameNetTraceMergeSolver({ + traces: [ + makeTrace("trunk", "N1", [ + { x: 1, y: 0 }, + { x: 4, y: 0 }, + ]), + makeTrace("branch", "N1", [ + { x: 0, y: 1 }, + { x: 1, y: 0.06 }, + { x: 4, y: 0.06 }, + { x: 5, y: 1 }, + ]), + makeTrace("blocked", "N2", [ + { x: 2, y: -1 }, + { x: 2, y: 1 }, + ]), + ], + }) + + solver.solve() + + const branchPath = solver.getOutput().traces[1]!.tracePath + expect(branchPath[1]!.y).toBe(0.06) + expect(branchPath[2]!.y).toBe(0.06) +}) From 9f972520fdf0bed1274362d1725429772daab559 Mon Sep 17 00:00:00 2001 From: jamilahmadzai Date: Thu, 21 May 2026 23:20:06 +0200 Subject: [PATCH 2/2] Update same-net trace snapshots --- .../examples/__snapshots__/example18.snap.svg | 32 +++++++------------ .../examples/__snapshots__/example19.snap.svg | 16 ++++------ 2 files changed, 17 insertions(+), 31 deletions(-) diff --git a/tests/examples/__snapshots__/example18.snap.svg b/tests/examples/__snapshots__/example18.snap.svg index 8c7f05fc8..bc8149cf2 100644 --- a/tests/examples/__snapshots__/example18.snap.svg +++ b/tests/examples/__snapshots__/example18.snap.svg @@ -65,24 +65,19 @@ y-" data-x="1.7580660749999977" data-y="-3.3025814000000002" cx="494.02875093834 y+" data-x="1.757519574999999" data-y="-2.2" cx="493.97982495355666" cy="501.29024555523955" r="3" fill="hsl(248, 100%, 50%, 0.8)" /> - + - + - + - + - + @@ -139,7 +134,7 @@ orientation: x+" data-x="1.757519574999999" data-y="-2" cx="493.97982495355666" - + @@ -167,28 +162,23 @@ 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.5790330374999988" data-y="2.7275814000000005" x="469.0480260561722" y="40" width="17.90520943755456" height="40.28672123449769" 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" /> - + - + - + - + @@ -98,10 +94,10 @@ orientation: y+" data-x="3.3884680250000008" data-y="1.2997267500000007" cx="438 - + - +