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) +})