From 9922e58edd49d447b9660003138de860ec6a74b1 Mon Sep 17 00:00:00 2001 From: onchito-walks <283618237+onchito-walks@users.noreply.github.com> Date: Wed, 20 May 2026 22:10:33 +0000 Subject: [PATCH 1/2] feat: add findCloseSameNetTraceGroups for close same-net trace detection (#378) - Add findCloseSameNetTraceGroups sub-solver that groups same-net traces by endpoint proximity (default threshold: 0.5mm) - Wire into TraceCleanupSolver init and output - Add focused test coverage for same-net grouping - All 62 tests pass, typecheck clean, build clean, format clean Addresses tscircuit/schematic-trace-solver#378 --- .../TraceCleanupSolver/TraceCleanupSolver.ts | 12 +++- .../sub-solver/findCloseSameNetTraceGroups.ts | 70 +++++++++++++++++++ .../findCloseSameNetTraceGroups.test.ts | 40 +++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 lib/solvers/TraceCleanupSolver/sub-solver/findCloseSameNetTraceGroups.ts create mode 100644 tests/solvers/TraceCleanupSolver/findCloseSameNetTraceGroups.test.ts diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index e9bac7ca3..639cb257a 100644 --- a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts +++ b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts @@ -20,6 +20,10 @@ interface TraceCleanupSolverInput { import { UntangleTraceSubsolver } from "./sub-solver/UntangleTraceSubsolver" import { is4PointRectangle } from "./is4PointRectangle" +import { + findCloseSameNetTraceGroups, + type CloseSameNetTraceGroup, +} from "./sub-solver/findCloseSameNetTraceGroups" /** * Represents the different stages or steps within the trace cleanup pipeline. @@ -42,6 +46,7 @@ export class TraceCleanupSolver extends BaseSolver { private outputTraces: SolvedTracePath[] private traceIdQueue: string[] private tracesMap: Map + private closeSameNetTraceGroups: CloseSameNetTraceGroup[] = [] private pipelineStep: PipelineStep = "untangling_traces" private activeTraceId: string | null = null // New property override activeSubSolver: BaseSolver | null = null @@ -54,6 +59,9 @@ export class TraceCleanupSolver extends BaseSolver { this.traceIdQueue = Array.from( solverInput.allTraces.map((e) => e.mspPairId), ) + this.closeSameNetTraceGroups = findCloseSameNetTraceGroups( + solverInput.allTraces, + ) } override _step() { @@ -149,6 +157,7 @@ export class TraceCleanupSolver extends BaseSolver { getOutput() { return { traces: this.outputTraces, + closeSameNetTraceGroups: this.closeSameNetTraceGroups, } } @@ -171,10 +180,11 @@ export class TraceCleanupSolver extends BaseSolver { for (const trace of this.outputTraces) { const line: Line = { points: trace.tracePath.map((p) => ({ x: p.x, y: p.y })), - strokeColor: trace.mspPairId === this.activeTraceId ? "red" : "blue", // Highlight active trace + strokeColor: trace.mspPairId === this.activeTraceId ? "red" : "blue", } graphics.lines!.push(line) } + return graphics } } diff --git a/lib/solvers/TraceCleanupSolver/sub-solver/findCloseSameNetTraceGroups.ts b/lib/solvers/TraceCleanupSolver/sub-solver/findCloseSameNetTraceGroups.ts new file mode 100644 index 000000000..e1e1d85db --- /dev/null +++ b/lib/solvers/TraceCleanupSolver/sub-solver/findCloseSameNetTraceGroups.ts @@ -0,0 +1,70 @@ +import type { SolvedTracePath } from "../../SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +export interface CloseSameNetTraceGroup { + netId: string + traceIds: string[] + maxEndpointDistance: number +} + +const distance = (a: { x: number; y: number }, b: { x: number; y: number }) => + Math.hypot(a.x - b.x, a.y - b.y) + +const getTraceEndpoints = (trace: SolvedTracePath) => { + const start = trace.tracePath[0] + const end = trace.tracePath[trace.tracePath.length - 1] + return { start, end } +} + +/** + * Finds same-net traces whose endpoints are already close enough that they are + * likely candidates for a later merge/join phase. + * + * This is intentionally conservative: it does not mutate paths, it only groups + * traces so the pipeline can decide whether to combine them. + */ +export const findCloseSameNetTraceGroups = ( + traces: SolvedTracePath[], + maxEndpointDistance = 0.5, +): CloseSameNetTraceGroup[] => { + const groupedByNet = new Map() + + for (const trace of traces) { + const netId = trace.userNetId ?? trace.globalConnNetId ?? trace.dcConnNetId + const current = groupedByNet.get(netId) ?? [] + current.push(trace) + groupedByNet.set(netId, current) + } + + const groups: CloseSameNetTraceGroup[] = [] + + for (const [netId, netTraces] of groupedByNet.entries()) { + if (netTraces.length < 2) continue + + for (let i = 0; i < netTraces.length; i++) { + for (let j = i + 1; j < netTraces.length; j++) { + const left = netTraces[i] + const right = netTraces[j] + const a = getTraceEndpoints(left) + const b = getTraceEndpoints(right) + + const distances = [ + distance(a.start, b.start), + distance(a.start, b.end), + distance(a.end, b.start), + distance(a.end, b.end), + ] + const minDistance = Math.min(...distances) + + if (minDistance <= maxEndpointDistance) { + groups.push({ + netId, + traceIds: [left.mspPairId as string, right.mspPairId as string], + maxEndpointDistance: minDistance, + }) + } + } + } + } + + return groups.sort((a, b) => a.maxEndpointDistance - b.maxEndpointDistance) +} diff --git a/tests/solvers/TraceCleanupSolver/findCloseSameNetTraceGroups.test.ts b/tests/solvers/TraceCleanupSolver/findCloseSameNetTraceGroups.test.ts new file mode 100644 index 000000000..a7f46dd20 --- /dev/null +++ b/tests/solvers/TraceCleanupSolver/findCloseSameNetTraceGroups.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test" +import { findCloseSameNetTraceGroups } from "lib/solvers/TraceCleanupSolver/sub-solver/findCloseSameNetTraceGroups" + +const trace = ( + id: string, + netId: string, + path: Array<{ x: number; y: number }>, +) => + ({ + mspPairId: id, + userNetId: netId, + globalConnNetId: `${netId}-global`, + dcConnNetId: `${netId}-dc`, + tracePath: path, + }) as any + +test("findCloseSameNetTraceGroups groups close same-net traces by endpoint distance", () => { + const groups = findCloseSameNetTraceGroups( + [ + trace("a", "N1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + ]), + trace("b", "N1", [ + { x: 1.15, y: 0 }, + { x: 2, y: 0 }, + ]), + trace("c", "N2", [ + { x: 10, y: 10 }, + { x: 11, y: 10 }, + ]), + ], + 0.2, + ) + + expect(groups).toHaveLength(1) + expect(groups[0].netId).toBe("N1") + expect(groups[0].traceIds).toEqual(["a", "b"]) + expect(groups[0].maxEndpointDistance).toBeLessThanOrEqual(0.2) +}) From 7c64f0eb4d154f441a87bceba2bc39783f84a4fa Mon Sep 17 00:00:00 2001 From: onchito-walks <283618237+onchito-walks@users.noreply.github.com> Date: Wed, 20 May 2026 23:42:19 +0000 Subject: [PATCH 2/2] feat: combine close same-net trace segments Adds a TraceCleanupSolver pipeline phase that merges close same-net trace segments after untangling and before turn minimization.\n\nIncludes unit coverage for same-net merging, reversed trace orientation, and keeping different nets separate.\n\nCo-authored-by: rdthree --- .../TraceCleanupSolver/TraceCleanupSolver.ts | 21 ++++- .../combineCloseSameNetTraceGroups.ts | 92 +++++++++++++++++++ .../combineCloseSameNetTraceGroups.test.ts | 90 ++++++++++++++++++ 3 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 lib/solvers/TraceCleanupSolver/sub-solver/combineCloseSameNetTraceGroups.ts create mode 100644 tests/solvers/TraceCleanupSolver/combineCloseSameNetTraceGroups.test.ts diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index 639cb257a..870935ba4 100644 --- a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts +++ b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts @@ -24,11 +24,13 @@ import { findCloseSameNetTraceGroups, type CloseSameNetTraceGroup, } from "./sub-solver/findCloseSameNetTraceGroups" +import { combineCloseSameNetTraceGroups } from "./sub-solver/combineCloseSameNetTraceGroups" /** * Represents the different stages or steps within the trace cleanup pipeline. */ type PipelineStep = + | "combining_same_net_traces" | "minimizing_turns" | "balancing_l_shapes" | "untangling_traces" @@ -74,10 +76,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_traces" } else if (this.activeSubSolver.failed) { this.activeSubSolver = null - this.pipelineStep = "minimizing_turns" + this.pipelineStep = "combining_same_net_traces" } return } @@ -86,6 +88,9 @@ export class TraceCleanupSolver extends BaseSolver { case "untangling_traces": this._runUntangleTracesStep() break + case "combining_same_net_traces": + this._runCombineSameNetTracesStep() + break case "minimizing_turns": this._runMinimizeTurnsStep() break @@ -102,6 +107,18 @@ export class TraceCleanupSolver extends BaseSolver { }) } + private _runCombineSameNetTracesStep() { + this.outputTraces = combineCloseSameNetTraceGroups( + Array.from(this.tracesMap.values()), + ) + this.tracesMap = new Map(this.outputTraces.map((t) => [t.mspPairId, t])) + this.traceIdQueue = Array.from(this.outputTraces.map((e) => e.mspPairId)) + this.closeSameNetTraceGroups = findCloseSameNetTraceGroups( + this.outputTraces, + ) + this.pipelineStep = "minimizing_turns" + } + private _runMinimizeTurnsStep() { if (this.traceIdQueue.length === 0) { this.pipelineStep = "balancing_l_shapes" diff --git a/lib/solvers/TraceCleanupSolver/sub-solver/combineCloseSameNetTraceGroups.ts b/lib/solvers/TraceCleanupSolver/sub-solver/combineCloseSameNetTraceGroups.ts new file mode 100644 index 000000000..a59ead639 --- /dev/null +++ b/lib/solvers/TraceCleanupSolver/sub-solver/combineCloseSameNetTraceGroups.ts @@ -0,0 +1,92 @@ +import type { Point } from "@tscircuit/math-utils" +import type { SolvedTracePath } from "../../SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { findCloseSameNetTraceGroups } from "./findCloseSameNetTraceGroups" + +const pointsEqual = (a: Point, b: Point) => a.x === b.x && a.y === b.y + +const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y) + +const getEndpointJoin = (left: SolvedTracePath, right: SolvedTracePath) => { + const leftStart = left.tracePath[0] + const leftEnd = left.tracePath[left.tracePath.length - 1] + const rightStart = right.tracePath[0] + const rightEnd = right.tracePath[right.tracePath.length - 1] + + const candidates = [ + { + distance: distance(leftEnd, rightStart), + path: [...left.tracePath, ...right.tracePath], + }, + { + distance: distance(leftEnd, rightEnd), + path: [...left.tracePath, ...right.tracePath.toReversed()], + }, + { + distance: distance(leftStart, rightEnd), + path: [...right.tracePath, ...left.tracePath], + }, + { + distance: distance(leftStart, rightStart), + path: [...left.tracePath.toReversed(), ...right.tracePath], + }, + ] + + return candidates.sort((a, b) => a.distance - b.distance)[0] +} + +const dedupeConsecutivePoints = (points: Point[]) => { + const deduped: Point[] = [] + for (const point of points) { + const previous = deduped[deduped.length - 1] + if (!previous || !pointsEqual(previous, point)) { + deduped.push(point) + } + } + return deduped +} + +/** + * Combines close same-net trace segments into a single solved trace path. + * + * The join is intentionally conservative: it only joins the closest endpoint + * pair already discovered by `findCloseSameNetTraceGroups`, then carries over + * all source connection-pair ids and pin ids so downstream consumers still know + * which original connections are represented by the merged path. + */ +export const combineCloseSameNetTraceGroups = ( + traces: SolvedTracePath[], + maxEndpointDistance = 0.5, +): SolvedTracePath[] => { + const tracesMap = new Map(traces.map((trace) => [trace.mspPairId, trace])) + const consumedTraceIds = new Set() + + for (const group of findCloseSameNetTraceGroups( + traces, + maxEndpointDistance, + )) { + const [leftId, rightId] = group.traceIds + if (consumedTraceIds.has(leftId) || consumedTraceIds.has(rightId)) continue + + const left = tracesMap.get(leftId) + const right = tracesMap.get(rightId) + if (!left || !right) continue + + const endpointJoin = getEndpointJoin(left, right) + if (endpointJoin.distance > maxEndpointDistance) continue + + tracesMap.set(leftId, { + ...left, + tracePath: dedupeConsecutivePoints(endpointJoin.path), + mspConnectionPairIds: [ + ...left.mspConnectionPairIds, + ...right.mspConnectionPairIds, + ], + pinIds: [...left.pinIds, ...right.pinIds], + }) + tracesMap.delete(rightId) + consumedTraceIds.add(leftId) + consumedTraceIds.add(rightId) + } + + return [...tracesMap.values()] +} diff --git a/tests/solvers/TraceCleanupSolver/combineCloseSameNetTraceGroups.test.ts b/tests/solvers/TraceCleanupSolver/combineCloseSameNetTraceGroups.test.ts new file mode 100644 index 000000000..391ad7856 --- /dev/null +++ b/tests/solvers/TraceCleanupSolver/combineCloseSameNetTraceGroups.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from "bun:test" +import { combineCloseSameNetTraceGroups } from "lib/solvers/TraceCleanupSolver/sub-solver/combineCloseSameNetTraceGroups" + +const trace = ( + id: string, + netId: string, + path: Array<{ x: number; y: number }>, + pinIds = [`${id}-pin-a`, `${id}-pin-b`], +) => + ({ + mspPairId: id, + mspConnectionPairIds: [id], + pinIds, + userNetId: netId, + globalConnNetId: `${netId}-global`, + dcConnNetId: `${netId}-dc`, + tracePath: path, + }) as any + +test("combineCloseSameNetTraceGroups merges close same-net traces", () => { + const combined = combineCloseSameNetTraceGroups( + [ + trace("a", "N1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + ]), + trace("b", "N1", [ + { x: 1.1, y: 0 }, + { x: 2, y: 0 }, + ]), + trace("c", "N2", [ + { x: 10, y: 10 }, + { x: 11, y: 10 }, + ]), + ], + 0.2, + ) + + expect(combined).toHaveLength(2) + const merged = combined.find((candidate) => candidate.mspPairId === "a")! + expect(merged.mspConnectionPairIds).toEqual(["a", "b"]) + expect(merged.pinIds).toEqual(["a-pin-a", "a-pin-b", "b-pin-a", "b-pin-b"]) + expect(merged.tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1.1, y: 0 }, + { x: 2, y: 0 }, + ]) +}) + +test("combineCloseSameNetTraceGroups orients reversed traces before merging", () => { + const combined = combineCloseSameNetTraceGroups( + [ + trace("a", "N1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + ]), + trace("b", "N1", [ + { x: 2, y: 0 }, + { x: 1, y: 0 }, + ]), + ], + 0.01, + ) + + expect(combined).toHaveLength(1) + expect(combined[0].tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 2, y: 0 }, + ]) +}) + +test("combineCloseSameNetTraceGroups leaves different nets separate", () => { + const combined = combineCloseSameNetTraceGroups( + [ + trace("a", "N1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + ]), + trace("b", "N2", [ + { x: 1, y: 0 }, + { x: 2, y: 0 }, + ]), + ], + 0.01, + ) + + expect(combined.map((candidate) => candidate.mspPairId)).toEqual(["a", "b"]) +})