diff --git a/commit_msg.txt b/commit_msg.txt new file mode 100644 index 000000000..ce84ac798 --- /dev/null +++ b/commit_msg.txt @@ -0,0 +1 @@ +Fix toMatchSolverSnapshot matcher - add await to properly resolve promise \ No newline at end of file diff --git a/lib/solvers/SameNetSegmentMergeSolver/SameNetSegmentMergeSolver.ts b/lib/solvers/SameNetSegmentMergeSolver/SameNetSegmentMergeSolver.ts new file mode 100644 index 000000000..e84c6bc20 --- /dev/null +++ b/lib/solvers/SameNetSegmentMergeSolver/SameNetSegmentMergeSolver.ts @@ -0,0 +1,382 @@ +import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import type { InputProblem } from "lib/types/InputProblem" +import type { GraphicsObject, Line } from "graphics-debug" +import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem" +import type { Point } from "@tscircuit/math-utils" + +interface SameNetSegmentMergeSolverInput { + inputProblem: InputProblem + allTraces: SolvedTracePath[] + /** + * Maximum perpendicular distance between two collinear/parallel segments of + * the same net before they are considered "close enough" to merge. + * Defaults to 0.15 schematic units. + */ + mergeThreshold?: number +} + +/** + * Returns the net id shared by a trace (using dcConnNetId as the canonical net key). + */ +function getNetId(trace: SolvedTracePath): string { + return trace.dcConnNetId +} + +/** + * Euclidean distance between two points. + */ +function dist(a: Point, b: Point): number { + return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2) +} + +/** + * Returns true if the segment (p1→p2) is horizontal (dy ≈ 0). + */ +function isHorizontal(p1: Point, p2: Point): boolean { + return Math.abs(p2.y - p1.y) < 1e-9 +} + +/** + * Returns true if the segment (p1→p2) is vertical (dx ≈ 0). + */ +function isVertical(p1: Point, p2: Point): boolean { + return Math.abs(p2.x - p1.x) < 1e-9 +} + +/** + * Perpendicular distance from point `p` to the infinite line defined by + * direction of segment (s1→s2). Works for axis-aligned segments only. + */ +function perpendicularDistance(p: Point, s1: Point, s2: Point): number { + if (isHorizontal(s1, s2)) { + // horizontal segment → perpendicular distance is |Δy| + return Math.abs(p.y - s1.y) + } + if (isVertical(s1, s2)) { + // vertical segment → perpendicular distance is |Δx| + return Math.abs(p.x - s1.x) + } + // Non-axis-aligned: general formula + const dx = s2.x - s1.x + const dy = s2.y - s1.y + const len = Math.sqrt(dx * dx + dy * dy) + return Math.abs(dy * p.x - dx * p.y + s2.x * s1.y - s2.y * s1.x) / len +} + +/** + * Checks whether two collinear 1-D intervals [a0,a1] and [b0,b1] overlap or + * are within `gap` of each other. + */ +function intervals1DClose( + a0: number, + a1: number, + b0: number, + b1: number, + gap: number, +): boolean { + const aMin = Math.min(a0, a1) + const aMax = Math.max(a0, a1) + const bMin = Math.min(b0, b1) + const bMax = Math.max(b0, b1) + return aMax + gap >= bMin && bMax + gap >= aMin +} + +/** + * Given two overlapping / adjacent same-net horizontal segments, return the + * merged version: a single horizontal segment spanning the union. + * + * Returns null when the segments are on different y-lines (not collinear). + */ +function mergeHorizontalSegments( + a1: Point, + a2: Point, + b1: Point, + b2: Point, +): [Point, Point] | null { + if (Math.abs(a1.y - b1.y) > 1e-9) return null + const y = a1.y + const xMin = Math.min(a1.x, a2.x, b1.x, b2.x) + const xMax = Math.max(a1.x, a2.x, b1.x, b2.x) + return [ + { x: xMin, y }, + { x: xMax, y }, + ] +} + +/** + * Given two overlapping / adjacent same-net vertical segments, return the + * merged version. + * + * Returns null when the segments are on different x-lines (not collinear). + */ +function mergeVerticalSegments( + a1: Point, + a2: Point, + b1: Point, + b2: Point, +): [Point, Point] | null { + if (Math.abs(a1.x - b1.x) > 1e-9) return null + const x = a1.x + const yMin = Math.min(a1.y, a2.y, b1.y, b2.y) + const yMax = Math.max(a1.y, a2.y, b1.y, b2.y) + return [ + { x, y: yMin }, + { x, y: yMax }, + ] +} + +/** + * Extract all axis-aligned segments from a trace path as pairs of consecutive + * points. + */ +function getSegments(tracePath: Point[]): Array<[Point, Point]> { + const segments: Array<[Point, Point]> = [] + for (let i = 0; i < tracePath.length - 1; i++) { + segments.push([tracePath[i]!, tracePath[i + 1]!]) + } + return segments +} + +/** + * Attempts to merge two traces that share the same net by combining collinear + * overlapping/adjacent segments. Returns a new merged SolvedTracePath when a + * merge is possible, otherwise returns null. + * + * The merge is "bridge-based": for each pair of axis-aligned segments (one from + * each trace) that are collinear and whose projections overlap or are within + * `threshold`, we build a new unified path that spans the union of both traces + * via the merged segment. + */ +function tryMergeTraces( + traceA: SolvedTracePath, + traceB: SolvedTracePath, + threshold: number, +): SolvedTracePath | null { + const segsA = getSegments(traceA.tracePath) + const segsB = getSegments(traceB.tracePath) + + for (const [a1, a2] of segsA) { + for (const [b1, b2] of segsB) { + const bothHoriz = isHorizontal(a1, a2) && isHorizontal(b1, b2) + const bothVert = isVertical(a1, a2) && isVertical(b1, b2) + + if (!bothHoriz && !bothVert) continue + + // Check perpendicular distance (collinearity within threshold) + const perpDist = bothHoriz ? Math.abs(a1.y - b1.y) : Math.abs(a1.x - b1.x) + + if (perpDist > threshold) continue + + // Check that 1-D projections overlap or are close + const overlaps = bothHoriz + ? intervals1DClose(a1.x, a2.x, b1.x, b2.x, threshold) + : intervals1DClose(a1.y, a2.y, b1.y, b2.y, threshold) + + if (!overlaps) continue + + // Only merge when the segments are truly collinear (same axis line) + const merged = bothHoriz + ? mergeHorizontalSegments(a1, a2, b1, b2) + : mergeVerticalSegments(a1, a2, b1, b2) + + if (!merged) continue + + const [mergedStart, mergedEnd] = merged + + // Build new unified path: take all points from traceA path, replace the + // matched segment with the merged segment, then append any remaining + // points from traceB that extend beyond the merged region. + const pathA = traceA.tracePath + const pathB = traceB.tracePath + + // Find insertion indices in pathA + const idxA1 = pathA.findIndex( + (p) => Math.abs(p.x - a1.x) < 1e-9 && Math.abs(p.y - a1.y) < 1e-9, + ) + const idxA2 = pathA.findIndex( + (p) => Math.abs(p.x - a2.x) < 1e-9 && Math.abs(p.y - a2.y) < 1e-9, + ) + if (idxA1 === -1 || idxA2 === -1) continue + + const startIdx = Math.min(idxA1, idxA2) + const endIdx = Math.max(idxA1, idxA2) + + // Replace segment in pathA with merged segment + const newPath: Point[] = [ + ...pathA.slice(0, startIdx), + mergedStart, + mergedEnd, + ...pathA.slice(endIdx + 1), + ] + + // Absorb traceB tail/head that extends beyond merged region + // Find the segment index within pathB + const idxB1 = pathB.findIndex( + (p) => Math.abs(p.x - b1.x) < 1e-9 && Math.abs(p.y - b1.y) < 1e-9, + ) + const idxB2 = pathB.findIndex( + (p) => Math.abs(p.x - b2.x) < 1e-9 && Math.abs(p.y - b2.y) < 1e-9, + ) + if (idxB1 !== -1 && idxB2 !== -1) { + const bStartIdx = Math.min(idxB1, idxB2) + const bEndIdx = Math.max(idxB1, idxB2) + + // Prepend any leading points from pathB + const bPrefix = pathB.slice(0, bStartIdx) + // Append any trailing points from pathB + const bSuffix = pathB.slice(bEndIdx + 1) + + // Only incorporate if they actually extend the path + if (bPrefix.length > 0) { + newPath.unshift(...bPrefix) + } + if (bSuffix.length > 0) { + newPath.push(...bSuffix) + } + } + + // Deduplicate consecutive duplicate points + const dedupedPath: Point[] = [] + for (const pt of newPath) { + const last = dedupedPath[dedupedPath.length - 1] + if ( + !last || + Math.abs(last.x - pt.x) > 1e-9 || + Math.abs(last.y - pt.y) > 1e-9 + ) { + dedupedPath.push(pt) + } + } + + // Build merged trace inheriting metadata from traceA, merging pin sets + const mergedTrace: SolvedTracePath = { + ...traceA, + tracePath: dedupedPath, + pinIds: Array.from(new Set([...traceA.pinIds, ...traceB.pinIds])), + mspConnectionPairIds: Array.from( + new Set([ + ...traceA.mspConnectionPairIds, + ...traceB.mspConnectionPairIds, + ]), + ), + } + + return mergedTrace + } + } + + return null +} + +/** + * SameNetSegmentMergeSolver + * + * A new pipeline phase that combines same-net trace segments that are close + * together (collinear or nearly collinear) into a single merged trace. This + * reduces visual clutter by eliminating near-duplicate parallel wire runs on + * the same net. + * + * The solver iterates over all pairs of traces that share the same net and + * attempts to merge overlapping collinear segments. It repeats until no more + * merges are possible (fixed-point convergence). + */ +export class SameNetSegmentMergeSolver extends BaseSolver { + private input: SameNetSegmentMergeSolverInput + private mergeThreshold: number + outputTraces: SolvedTracePath[] + + /** Pair indices still queued for comparison */ + private pairQueue: Array<[number, number]> + private dirty = false + + constructor(input: SameNetSegmentMergeSolverInput) { + super() + this.input = input + this.mergeThreshold = input.mergeThreshold ?? 0.15 + this.outputTraces = [...input.allTraces] + this.pairQueue = this._buildPairQueue(this.outputTraces) + } + + private _buildPairQueue(traces: SolvedTracePath[]): Array<[number, number]> { + const queue: Array<[number, number]> = [] + for (let i = 0; i < traces.length; i++) { + for (let j = i + 1; j < traces.length; j++) { + // Only pair up traces on the same net + if (getNetId(traces[i]!) === getNetId(traces[j]!)) { + queue.push([i, j]) + } + } + } + return queue + } + + override _step() { + // If we drained the queue and found merges, rebuild for another pass + if (this.pairQueue.length === 0) { + if (this.dirty) { + // Another convergence pass + this.pairQueue = this._buildPairQueue(this.outputTraces) + this.dirty = false + } else { + this.solved = true + } + return + } + + const [i, j] = this.pairQueue.shift()! + + const traceA = this.outputTraces[i] + const traceB = this.outputTraces[j] + + if (!traceA || !traceB) return + + // Skip if the net ids no longer match (traces may have been merged/removed) + if (getNetId(traceA) !== getNetId(traceB)) return + + const merged = tryMergeTraces(traceA, traceB, this.mergeThreshold) + if (merged) { + // Replace traceA with merged result, remove traceB + this.outputTraces[i] = merged + this.outputTraces.splice(j, 1) + + // Re-index: any queued pair index >= j needs adjustment + this.pairQueue = this.pairQueue + .map(([a, b]) => { + const newA = a >= j ? a - 1 : a + const newB = b >= j ? b - 1 : b + // Drop pairs referencing the removed index j (now gone) + if (a === j || b === j) return null + return [newA, newB] as [number, number] + }) + .filter((pair): pair is [number, number] => pair !== null) + + this.dirty = true + } + } + + getOutput() { + return { + traces: this.outputTraces, + } + } + + override visualize(): GraphicsObject { + const graphics = visualizeInputProblem(this.input.inputProblem, { + chipAlpha: 0.1, + connectionAlpha: 0.1, + }) + + if (!graphics.lines) graphics.lines = [] + + for (const trace of this.outputTraces) { + const line: Line = { + points: trace.tracePath.map((p) => ({ x: p.x, y: p.y })), + strokeColor: "blue", + } + graphics.lines!.push(line) + } + + return graphics + } +} diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index 59821f0c1..8b28ec9db 100644 --- a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts +++ b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts @@ -26,6 +26,7 @@ import { AvailableNetOrientationSolver } from "../AvailableNetOrientationSolver/ import { VccNetLabelCornerPlacementSolver } from "../VccNetLabelCornerPlacementSolver/VccNetLabelCornerPlacementSolver" import { TraceAnchoredNetLabelOverlapSolver } from "../TraceAnchoredNetLabelOverlapSolver/TraceAnchoredNetLabelOverlapSolver" import { NetLabelTraceCollisionSolver } from "../NetLabelTraceCollisionSolver/NetLabelTraceCollisionSolver" +import { SameNetSegmentMergeSolver } from "../SameNetSegmentMergeSolver/SameNetSegmentMergeSolver" type PipelineStep BaseSolver> = { solverName: string @@ -80,6 +81,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { vccNetLabelCornerPlacementSolver?: VccNetLabelCornerPlacementSolver traceAnchoredNetLabelOverlapSolver?: TraceAnchoredNetLabelOverlapSolver netLabelTraceCollisionSolver?: NetLabelTraceCollisionSolver + sameNetSegmentMergeSolver?: SameNetSegmentMergeSolver startTimeOfPhase: Record endTimeOfPhase: Record @@ -217,11 +219,28 @@ export class SchematicTracePipelineSolver extends BaseSolver { }, ] }), + definePipelineStep( + "sameNetSegmentMergeSolver", + SameNetSegmentMergeSolver, + (instance) => { + const traces = + instance.traceCleanupSolver?.getOutput().traces ?? + instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces + + return [ + { + inputProblem: instance.inputProblem, + allTraces: traces, + }, + ] + }, + ), definePipelineStep( "netLabelPlacementSolver", NetLabelPlacementSolver, (instance) => { const traces = + instance.sameNetSegmentMergeSolver?.outputTraces ?? instance.traceCleanupSolver?.getOutput().traces ?? instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces @@ -237,6 +256,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { ), definePipelineStep("example28Solver", Example28Solver, (instance) => { const traces = + instance.sameNetSegmentMergeSolver?.outputTraces ?? instance.traceCleanupSolver?.getOutput().traces ?? instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces diff --git a/tests/examples/__snapshots__/example01.snap.svg b/tests/examples/__snapshots__/example01.snap.svg index 2614ba80f..9c5d39def 100644 --- a/tests/examples/__snapshots__/example01.snap.svg +++ b/tests/examples/__snapshots__/example01.snap.svg @@ -2,106 +2,104 @@ +x-" data-x="-0.8" data-y="0.2" cx="422.5742574257426" cy="289.44950495049505" r="3" fill="hsl(319, 100%, 50%, 0.8)" /> +x-" data-x="-0.8" data-y="0" cx="422.5742574257426" cy="311.62772277227725" r="3" fill="hsl(320, 100%, 50%, 0.8)" /> +x-" data-x="-0.8" data-y="-0.2" cx="422.5742574257426" cy="333.80594059405945" r="3" fill="hsl(321, 100%, 50%, 0.8)" /> +x+" data-x="0.8" data-y="-0.2" cx="600" cy="333.80594059405945" r="3" fill="hsl(322, 100%, 50%, 0.8)" /> +x+" data-x="0.8" data-y="0" cx="600" cy="311.62772277227725" r="3" fill="hsl(323, 100%, 50%, 0.8)" /> +x+" data-x="0.8" data-y="0.2" cx="600" cy="289.44950495049505" r="3" fill="hsl(324, 100%, 50%, 0.8)" /> +y+" data-x="-2" data-y="0.5" cx="289.50495049504957" cy="256.1821782178218" r="3" fill="hsl(121, 100%, 50%, 0.8)" /> +y-" data-x="-2" data-y="-0.5" cx="289.50495049504957" cy="367.0732673267327" r="3" fill="hsl(122, 100%, 50%, 0.8)" /> +y+" data-x="-4" data-y="0.5" cx="67.72277227722776" cy="256.1821782178218" r="3" fill="hsl(2, 100%, 50%, 0.8)" /> +y-" data-x="-4" data-y="-0.5" cx="67.72277227722776" cy="367.0732673267327" r="3" fill="hsl(3, 100%, 50%, 0.8)" /> - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + +globalConnNetId: connectivity_net0" data-x="-1.1" data-y="0.42500000000000016" x="378.21782178217825" y="239.54851485148515" width="22.178217821782198" height="49.9009900990099" fill="#ef444466" stroke="#ef4444" stroke-width="0.009017857142857143" /> +globalConnNetId: connectivity_net1" data-x="-1.5" data-y="0" x="320" y="300.5386138613862" width="49.90099009900996" height="22.17821782178214" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.009017857142857143" /> +globalConnNetId: connectivity_net2" data-x="-3.6" data-y="-0.32499999999999996" x="100.99009900990103" y="322.7168316831683" width="22.178217821782198" height="49.9009900990099" fill="#00000066" stroke="#000000" stroke-width="0.009017857142857143" /> + + + - + - + - + - + + + + + + + + + + + + + + + + @@ -156,63 +167,67 @@ orientation: y+" data-x="1.4571549750000001" data-y="0.29999999999999966" cx="53 - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + +globalConnNetId: connectivity_net1" data-x="-1.3" data-y="-0.5250000000000001" x="297.0780790761945" y="358.04332647025996" width="16.926952823252577" height="38.08564385231813" fill="#00000066" stroke="#000000" stroke-width="0.011815475714285715" /> +globalConnNetId: connectivity_net1" data-x="1.9148566499999995" data-y="-1.2284186000000008" x="569.1667133165424" y="417.5769937562517" width="16.926952823252577" height="38.08564385231813" fill="#00000066" stroke="#000000" stroke-width="0.011815475714285715" /> +globalConnNetId: connectivity_net2" data-x="1.4571549750000001" data-y="0.5249999999999997" x="530.4292400172993" y="269.1768241481843" width="16.926952823252464" height="38.08564385231813" fill="#ef444466" stroke="#ef4444" stroke-width="0.011815475714285715" />