From ece92195f41425475514cdced7b1a81f0696a23d Mon Sep 17 00:00:00 2001 From: Lucas Santos Rodrigues <70177902+LuSrodri@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:52:49 -0300 Subject: [PATCH] feat: add SameNetSegmentMergingSolver pipeline phase Implements a new pipeline phase that finds trace segments on the same net that are close together (within a configurable gapThreshold, default 0.15) and snaps them to align, reducing visual clutter in schematic diagrams. The solver runs after TraceCleanupSolver and before the final NetLabelPlacementSolver pass. It groups traces by net, detects parallel overlapping interior segments within the gap threshold, and iteratively snaps movable segments toward anchored (endpoint) segments or to their weighted-average coordinate. Endpoint segments are never moved. Adds 5 unit tests covering: same-net merging, cross-net isolation, gapThreshold enforcement, anchor preservation, and empty-trace edge case. --- .../SameNetSegmentMergingSolver.ts | 328 ++++++++++++++++++ .../SchematicTracePipelineSolver.ts | 15 + .../SameNetSegmentMergingSolver.test.ts | 196 +++++++++++ 3 files changed, 539 insertions(+) create mode 100644 lib/solvers/SameNetSegmentMergingSolver/SameNetSegmentMergingSolver.ts create mode 100644 tests/solvers/SameNetSegmentMergingSolver/SameNetSegmentMergingSolver.test.ts diff --git a/lib/solvers/SameNetSegmentMergingSolver/SameNetSegmentMergingSolver.ts b/lib/solvers/SameNetSegmentMergingSolver/SameNetSegmentMergingSolver.ts new file mode 100644 index 00000000..e87c0bca --- /dev/null +++ b/lib/solvers/SameNetSegmentMergingSolver/SameNetSegmentMergingSolver.ts @@ -0,0 +1,328 @@ +import type { GraphicsObject } from "graphics-debug" +import type { Point } from "@tscircuit/math-utils" +import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem" +import type { InputProblem } from "lib/types/InputProblem" +import { simplifyPath } from "../TraceCleanupSolver/simplifyPath" +import { + isHorizontal, + isVertical, +} from "../SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/collisions" + +const EPS = 1e-6 +const DEFAULT_GAP_THRESHOLD = 0.15 +const MAX_PASSES = 10 + +/** + * A pipeline phase that finds same-net trace segments that are close together + * and snaps them to align, reducing visual clutter in schematic diagrams. + * + * For each net, segments from different traces are compared. When two parallel + * segments of the same net are within GAP_THRESHOLD of each other and overlap + * along their primary axis, the movable one(s) are shifted to match the anchored + * (or weighted-average) coordinate. + */ +export class SameNetSegmentMergingSolver extends BaseSolver { + inputProblem: InputProblem + gapThreshold: number + outputTraces: SolvedTracePath[] + + constructor(params: { + inputProblem: InputProblem + allTraces: SolvedTracePath[] + gapThreshold?: number + }) { + super() + this.inputProblem = params.inputProblem + this.gapThreshold = params.gapThreshold ?? DEFAULT_GAP_THRESHOLD + // Deep-clone the traces so we never mutate pipeline inputs + this.outputTraces = params.allTraces.map((trace) => ({ + ...trace, + tracePath: trace.tracePath.map((p) => ({ ...p })), + mspConnectionPairIds: [...trace.mspConnectionPairIds], + pinIds: [...trace.pinIds], + pins: trace.pins.map((pin) => ({ ...pin })) as typeof trace.pins, + })) + } + + override getConstructorParams(): ConstructorParameters< + typeof SameNetSegmentMergingSolver + >[0] { + return { + inputProblem: this.inputProblem, + allTraces: this.outputTraces, + gapThreshold: this.gapThreshold, + } + } + + override _step() { + // Iteratively snap segments until stable + for (let pass = 0; pass < MAX_PASSES; pass++) { + if (!this._mergePass()) break + } + + // Normalize all paths after merging + for (let i = 0; i < this.outputTraces.length; i++) { + this.outputTraces[i] = { + ...this.outputTraces[i]!, + tracePath: normalizePath(this.outputTraces[i]!.tracePath), + } + } + + this.solved = true + } + + /** + * One pass over all same-net segment pairs. Returns true if any change was made. + */ + private _mergePass(): boolean { + let changed = false + + // Group trace indices by globalConnNetId + const netToTraceIndices = new Map() + for (let i = 0; i < this.outputTraces.length; i++) { + const netId = this.outputTraces[i]!.globalConnNetId + if (!netToTraceIndices.has(netId)) { + netToTraceIndices.set(netId, []) + } + netToTraceIndices.get(netId)!.push(i) + } + + for (const traceIndices of netToTraceIndices.values()) { + if (traceIndices.length < 2) continue + + for (const orientation of ["horizontal", "vertical"] as const) { + const segments = this._collectSegments(traceIndices, orientation) + + // Anchored segments: first or last segment of their trace path + const anchors = segments.filter((s) => !s.canMove) + const movables = segments.filter((s) => s.canMove) + + // Phase 1: snap movable segments toward anchored segments of the same net + if (anchors.length > 0 && movables.length > 0) { + for (const seg of movables) { + let best: { coord: number; dist: number } | null = null + for (const anchor of anchors) { + if (seg.traceIndex === anchor.traceIndex) continue + if (!segmentsOverlap(seg, anchor)) continue + const dist = Math.abs(seg.axisCoord - anchor.axisCoord) + if (dist > this.gapThreshold + EPS) continue + if (best === null || dist < best.dist) { + best = { coord: anchor.axisCoord, dist } + } + } + if (best !== null) { + changed = this._applyMove(seg, best.coord) || changed + } + } + } + + // Phase 2: snap movable segments toward each other (weighted by span) + const groups = unionFindGroupBy( + movables, + (a, b) => + a.traceIndex !== b.traceIndex && + segmentsOverlap(a, b) && + Math.abs(a.axisCoord - b.axisCoord) <= this.gapThreshold + EPS, + ) + + for (const group of groups) { + if (group.length < 2) continue + const totalSpan = group.reduce((acc, s) => acc + s.span, 0) + if (totalSpan < EPS) continue + const targetCoord = + group.reduce((acc, s) => acc + s.axisCoord * s.span, 0) / totalSpan + for (const seg of group) { + changed = this._applyMove(seg, targetCoord) || changed + } + } + } + } + + return changed + } + + /** + * Collect segment descriptors for all given trace indices, for a given orientation. + */ + private _collectSegments( + traceIndices: number[], + orientation: "horizontal" | "vertical", + ): SegmentRef[] { + const segments: SegmentRef[] = [] + + for (const traceIndex of traceIndices) { + const trace = this.outputTraces[traceIndex]! + const lastIdx = trace.tracePath.length - 1 + + for (let si = 0; si < trace.tracePath.length - 1; si++) { + const a = trace.tracePath[si]! + const b = trace.tracePath[si + 1]! + + const matches = + orientation === "horizontal" + ? isHorizontal(a, b, EPS) + : isVertical(a, b, EPS) + if (!matches) continue + + const rangeStart = + orientation === "horizontal" ? Math.min(a.x, b.x) : Math.min(a.y, b.y) + const rangeEnd = + orientation === "horizontal" ? Math.max(a.x, b.x) : Math.max(a.y, b.y) + const span = rangeEnd - rangeStart + if (span < EPS) continue + + const axisCoord = orientation === "horizontal" ? a.y : a.x + + // Endpoint segments (si===0 or si===lastIdx-1) are anchors; + // interior segments can be moved + const canMove = si > 0 && si + 1 < lastIdx + + segments.push({ + traceIndex, + segmentIndex: si, + orientation, + axisCoord, + rangeStart, + rangeEnd, + span, + canMove, + }) + } + } + + return segments + } + + /** + * Shift a segment's perpendicular coordinate to targetCoord. + * Returns true if the trace was actually modified. + */ + private _applyMove(seg: SegmentRef, targetCoord: number): boolean { + if (!seg.canMove) return false + if (Math.abs(seg.axisCoord - targetCoord) < EPS) return false + + const trace = this.outputTraces[seg.traceIndex]! + const newPath = trace.tracePath.map((p) => ({ ...p })) + const pa = newPath[seg.segmentIndex]! + const pb = newPath[seg.segmentIndex + 1]! + + if (seg.orientation === "horizontal") { + pa.y = targetCoord + pb.y = targetCoord + } else { + pa.x = targetCoord + pb.x = targetCoord + } + + this.outputTraces[seg.traceIndex] = { + ...trace, + tracePath: normalizePath(newPath), + } + + return true + } + + getOutput() { + return { traces: this.outputTraces } + } + + override visualize(): GraphicsObject { + const graphics = visualizeInputProblem(this.inputProblem, { + chipAlpha: 0.1, + connectionAlpha: 0.1, + }) + + for (const trace of this.outputTraces) { + graphics.lines!.push({ + points: trace.tracePath, + strokeColor: "green", + }) + } + + return graphics + } +} + +// ─── helpers ───────────────────────────────────────────────────────────────── + +type SegmentRef = { + traceIndex: number + segmentIndex: number + orientation: "horizontal" | "vertical" + axisCoord: number + rangeStart: number + rangeEnd: number + span: number + canMove: boolean +} + +/** Do two segments' primary-axis ranges overlap (by more than EPS)? */ +function segmentsOverlap(a: SegmentRef, b: SegmentRef): boolean { + const overlap = + Math.min(a.rangeEnd, b.rangeEnd) - Math.max(a.rangeStart, b.rangeStart) + return overlap > EPS +} + +/** + * Simple union-find grouping: group items where `related(a, b)` is true. + */ +function unionFindGroupBy( + items: T[], + related: (a: T, b: T) => boolean, +): T[][] { + const parent = items.map((_, i) => i) + const find = (i: number): number => { + if (parent[i] !== i) parent[i] = find(parent[i]!) + return parent[i]! + } + for (let i = 0; i < items.length; i++) { + for (let j = i + 1; j < items.length; j++) { + if (related(items[i]!, items[j]!)) { + const ri = find(i) + const rj = find(j) + if (ri !== rj) parent[rj] = ri + } + } + } + const groups = new Map() + for (let i = 0; i < items.length; i++) { + const root = find(i) + if (!groups.has(root)) groups.set(root, []) + groups.get(root)!.push(items[i]!) + } + return Array.from(groups.values()) +} + +/** Remove duplicate consecutive points, then simplify collinear segments. */ +function normalizePath(path: Point[]): Point[] { + const deduped: Point[] = [] + for (const pt of path) { + const prev = deduped[deduped.length - 1] + if ( + prev && + Math.abs(prev.x - pt.x) < EPS && + Math.abs(prev.y - pt.y) < EPS + ) { + continue + } + deduped.push({ ...pt }) + } + if (deduped.length < 3) return deduped + + const simplified = simplifyPath(deduped) + const result: Point[] = [] + for (const pt of simplified) { + const prev = result[result.length - 1] + if ( + prev && + Math.abs(prev.x - pt.x) < EPS && + Math.abs(prev.y - pt.y) < EPS + ) { + continue + } + result.push({ ...pt }) + } + return result +} diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index c9d5a995..b165c677 100644 --- a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts +++ b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts @@ -20,6 +20,7 @@ import { expandChipsToFitPins } from "./expandChipsToFitPins" import { LongDistancePairSolver } from "../LongDistancePairSolver/LongDistancePairSolver" import { MergedNetLabelObstacleSolver } from "../TraceLabelOverlapAvoidanceSolver/sub-solvers/LabelMergingSolver/LabelMergingSolver" import { TraceCleanupSolver } from "../TraceCleanupSolver/TraceCleanupSolver" +import { SameNetSegmentMergingSolver } from "../SameNetSegmentMergingSolver/SameNetSegmentMergingSolver" type PipelineStep BaseSolver> = { solverName: string @@ -69,6 +70,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { labelMergingSolver?: MergedNetLabelObstacleSolver traceLabelOverlapAvoidanceSolver?: TraceLabelOverlapAvoidanceSolver traceCleanupSolver?: TraceCleanupSolver + sameNetSegmentMergingSolver?: SameNetSegmentMergingSolver startTimeOfPhase: Record endTimeOfPhase: Record @@ -206,11 +208,24 @@ export class SchematicTracePipelineSolver extends BaseSolver { }, ] }), + definePipelineStep( + "sameNetSegmentMergingSolver", + SameNetSegmentMergingSolver, + (instance) => [ + { + inputProblem: instance.inputProblem, + allTraces: + instance.traceCleanupSolver?.getOutput().traces ?? + instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces, + }, + ], + ), definePipelineStep( "netLabelPlacementSolver", NetLabelPlacementSolver, (instance) => { const traces = + instance.sameNetSegmentMergingSolver?.getOutput().traces ?? instance.traceCleanupSolver?.getOutput().traces ?? instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces diff --git a/tests/solvers/SameNetSegmentMergingSolver/SameNetSegmentMergingSolver.test.ts b/tests/solvers/SameNetSegmentMergingSolver/SameNetSegmentMergingSolver.test.ts new file mode 100644 index 00000000..2b317507 --- /dev/null +++ b/tests/solvers/SameNetSegmentMergingSolver/SameNetSegmentMergingSolver.test.ts @@ -0,0 +1,196 @@ +import { test, expect } from "bun:test" +import { SameNetSegmentMergingSolver } from "lib/solvers/SameNetSegmentMergingSolver/SameNetSegmentMergingSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import type { InputProblem } from "lib/types/InputProblem" + +const emptyInputProblem: InputProblem = { + chips: [], + directConnections: [], + netConnections: [], + availableNetLabelOrientations: {}, +} + +function makeTrace( + id: string, + netId: string, + path: Array<{ x: number; y: number }>, +): SolvedTracePath { + return { + mspPairId: id, + dcConnNetId: netId, + globalConnNetId: netId, + pins: [ + { pinId: `${id}-a`, chipId: "A", x: path[0]!.x, y: path[0]!.y }, + { + pinId: `${id}-b`, + chipId: "B", + x: path[path.length - 1]!.x, + y: path[path.length - 1]!.y, + }, + ], + tracePath: path, + mspConnectionPairIds: [id], + pinIds: [`${id}-a`, `${id}-b`], + } +} + +test("snaps two close parallel same-net interior segments to a common y coordinate", () => { + // Two Z-shaped traces on the same net whose horizontal middle segments are 0.12 apart + const solver = new SameNetSegmentMergingSolver({ + inputProblem: emptyInputProblem, + allTraces: [ + makeTrace("t1", "net1", [ + { x: -3, y: 0 }, + { x: -2, y: 0 }, + { x: -2, y: 1.0 }, + { x: 2, y: 1.0 }, + { x: 2, y: 0 }, + { x: 3, y: 0 }, + ]), + makeTrace("t2", "net1", [ + { x: -3, y: 0 }, + { x: -1, y: 0 }, + { x: -1, y: 1.12 }, + { x: 1, y: 1.12 }, + { x: 1, y: 0 }, + { x: 3, y: 0 }, + ]), + ], + }) + + solver.solve() + + const traces = solver.getOutput().traces + expect(solver.solved).toBe(true) + + // Both horizontal interior segments should have been pulled to the same y + const y1 = traces[0]!.tracePath.find((p, i, arr) => { + const prev = arr[i - 1] + return prev && Math.abs(prev.y - p.y) < 1e-6 && Math.abs(p.y - 1.0) < 0.2 + }) + const y2 = traces[1]!.tracePath.find((p, i, arr) => { + const prev = arr[i - 1] + return prev && Math.abs(prev.y - p.y) < 1e-6 && Math.abs(p.y - 1.0) < 0.2 + }) + // Both horizontal segments at the same y (merged) + expect(y1).toBeDefined() + expect(y2).toBeDefined() + expect(Math.abs(y1!.y - y2!.y)).toBeLessThan(1e-5) +}) + +test("does not merge segments from different nets", () => { + const solver = new SameNetSegmentMergingSolver({ + inputProblem: emptyInputProblem, + allTraces: [ + makeTrace("t1", "net1", [ + { x: -2, y: 0 }, + { x: -1, y: 0 }, + { x: -1, y: 1.0 }, + { x: 1, y: 1.0 }, + { x: 1, y: 0 }, + { x: 2, y: 0 }, + ]), + makeTrace("t2", "net2", [ + { x: -2, y: 0 }, + { x: -1, y: 0 }, + { x: -1, y: 1.1 }, + { x: 1, y: 1.1 }, + { x: 1, y: 0 }, + { x: 2, y: 0 }, + ]), + ], + }) + + solver.solve() + + const traces = solver.getOutput().traces + // net1 trace horizontal segment stays at 1.0 + const seg1 = traces[0]!.tracePath + const net1HorizY = seg1.find( + (p, i) => i > 0 && Math.abs(p.y - seg1[i - 1]!.y) < 1e-6 && p.y > 0.5, + ) + expect(net1HorizY?.y).toBeCloseTo(1.0, 5) + + // net2 trace horizontal segment stays at 1.1 + const seg2 = traces[1]!.tracePath + const net2HorizY = seg2.find( + (p, i) => i > 0 && Math.abs(p.y - seg2[i - 1]!.y) < 1e-6 && p.y > 0.5, + ) + expect(net2HorizY?.y).toBeCloseTo(1.1, 5) +}) + +test("does not merge segments farther than gapThreshold", () => { + const solver = new SameNetSegmentMergingSolver({ + inputProblem: emptyInputProblem, + gapThreshold: 0.1, + allTraces: [ + makeTrace("t1", "net1", [ + { x: -2, y: 0 }, + { x: -1, y: 0 }, + { x: -1, y: 1.0 }, + { x: 1, y: 1.0 }, + { x: 1, y: 0 }, + { x: 2, y: 0 }, + ]), + makeTrace("t2", "net1", [ + { x: -2, y: 0 }, + { x: -1, y: 0 }, + { x: -1, y: 1.5 }, // far away - gap = 0.5, threshold = 0.1 + { x: 1, y: 1.5 }, + { x: 1, y: 0 }, + { x: 2, y: 0 }, + ]), + ], + }) + + solver.solve() + + const traces = solver.getOutput().traces + // Segments should be unchanged (too far apart) + const seg1Y = traces[0]!.tracePath[2]!.y + const seg2Y = traces[1]!.tracePath[2]!.y + expect(seg1Y).toBeCloseTo(1.0, 5) + expect(seg2Y).toBeCloseTo(1.5, 5) +}) + +test("anchor endpoint segments are not moved", () => { + // t1 has a 2-point horizontal-only path (endpoint segment = anchor) + const solver = new SameNetSegmentMergingSolver({ + inputProblem: emptyInputProblem, + allTraces: [ + // 2-point straight line - both points are endpoints, canMove = false + makeTrace("t1", "net1", [ + { x: -3, y: 1.0 }, + { x: 3, y: 1.0 }, + ]), + makeTrace("t2", "net1", [ + { x: -3, y: 0 }, + { x: -2, y: 0 }, + { x: -2, y: 1.08 }, + { x: 2, y: 1.08 }, + { x: 2, y: 0 }, + { x: 3, y: 0 }, + ]), + ], + }) + + solver.solve() + + const traces = solver.getOutput().traces + // Anchor (t1) stays at y=1.0 + expect(traces[0]!.tracePath[0]!.y).toBeCloseTo(1.0, 5) + expect(traces[0]!.tracePath[1]!.y).toBeCloseTo(1.0, 5) + // Movable (t2) interior segment is snapped toward anchor at 1.0 + expect(traces[1]!.tracePath[2]!.y).toBeCloseTo(1.0, 5) + expect(traces[1]!.tracePath[3]!.y).toBeCloseTo(1.0, 5) +}) + +test("solver marks itself as solved", () => { + const solver = new SameNetSegmentMergingSolver({ + inputProblem: emptyInputProblem, + allTraces: [], + }) + solver.solve() + expect(solver.solved).toBe(true) + expect(solver.failed).toBe(false) +})