From c29ac5c1947bbf110259f00dfe2503b0600b185c Mon Sep 17 00:00:00 2001 From: gustavo keiller Date: Mon, 8 Dec 2025 15:51:54 -0300 Subject: [PATCH 1/6] feat: add SameNetTraceMergeSolver (implements #29) + tests + example /claim #29 --- .../SameNetTraceMergeSolver.ts | 324 ++++++++++++++++++ .../SchematicTracePipelineSolver.ts | 17 + .../SameNetTraceMergeSolver01.page.tsx | 72 ++++ ...MergeSolver02NotmergeDifferentNet.page.tsx | 78 +++++ ...tTraceMergeSolver01_same_net_merge.test.ts | 116 +++++++ ...geSolver02_different_nets_no_merge.test.ts | 164 +++++++++ 6 files changed, 771 insertions(+) create mode 100644 lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts create mode 100644 site/SameNetTraceMergeSolver/SameNetTraceMergeSolver01.page.tsx create mode 100644 site/SameNetTraceMergeSolver/SameNetTraceMergeSolver02NotmergeDifferentNet.page.tsx create mode 100644 tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver01_same_net_merge.test.ts create mode 100644 tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver02_different_nets_no_merge.test.ts diff --git a/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts new file mode 100644 index 0000000..f759001 --- /dev/null +++ b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts @@ -0,0 +1,324 @@ +import type { InputProblem } from "lib/types/InputProblem" +import type { GraphicsObject, Line } from "graphics-debug" +import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem" +import type { Point } from "@tscircuit/math-utils" +import { getColorFromString } from "lib/utils/getColorFromString" + +/** + * Input parameters for SameNetTraceMergeSolver. + */ +interface SameNetTraceMergeSolverInput { + inputProblem: InputProblem + allTraces: SolvedTracePath[] +} + +/** + * Represents a straight line segment (horizontal or vertical) extracted from a trace. + * Used internally for detecting and merging parallel segments on the same net. + */ +type Segment = { + traceId: string + netId: string + orientation: "h" | "v" + coord: number + range: [number, number] + segmentIndex: number + points: [Point, Point] +} + +/** + * SameNetTraceMergeSolver combines trace segments that belong to the same net + * and run parallel and close to each other. This reduces visual clutter and + * creates cleaner schematic layouts. + * + * Algorithm: + * 1. Extract all horizontal and vertical segments from all traces. + * 2. Find mergeable pairs: same net, same orientation, close distance (≤ GAP_THRESHOLD), + * and overlapping ranges. + * 3. Group mergeable segments into connected components using BFS. + * 4. For each group, calculate the median coordinate and align all segments to it. + * 5. Update all trace points to maintain continuity and topology. + */ +export class SameNetTraceMergeSolver extends BaseSolver { + private input: SameNetTraceMergeSolverInput + private outputTraces: SolvedTracePath[] + // Algorithm constants + private readonly GAP_THRESHOLD = 0.15 // Maximum gap to consider for merging + private readonly EPS = 1e-6 + // Visualization constants + private readonly TRACE_STROKE_WIDTH = 0.02 + // Highlight stroke width is derived from GAP_THRESHOLD to show the merge zone visually + private readonly HIGHLIGHT_STROKE_WIDTH = this.GAP_THRESHOLD * 0.5 + private readonly TRACE_OPACITY = 0.9 + + constructor(solverInput: SameNetTraceMergeSolverInput) { + super() + this.input = solverInput + this.outputTraces = structuredClone(solverInput.allTraces) + } + + override _step() { + // Extract all segments from traces + const segments = this._extractSegments() + + // Find mergeable segment pairs + const mergeGroups = this._findMergeableGroups(segments) + + if (mergeGroups.length === 0) { + this.solved = true + return + } + + // Perform all merges in one step (idempotent) + for (const group of mergeGroups) { + this._mergeSegmentGroup(group) + } + + this.solved = true + } + + private _extractSegments(): Segment[] { + const segments: Segment[] = [] + + for (const trace of this.outputTraces) { + for (let i = 0; i < trace.tracePath.length - 1; i++) { + const p1 = trace.tracePath[i]! + const p2 = trace.tracePath[i + 1]! + + if (Math.abs(p1.x - p2.x) < this.EPS) { + // Vertical segment + const [minY, maxY] = [p1.y, p2.y].sort((a, b) => a - b) + segments.push({ + traceId: trace.mspPairId, + netId: trace.globalConnNetId, + orientation: "v", + coord: p1.x, + range: [minY, maxY], + segmentIndex: i, + points: [p1, p2], + }) + } else if (Math.abs(p1.y - p2.y) < this.EPS) { + // Horizontal segment + const [minX, maxX] = [p1.x, p2.x].sort((a, b) => a - b) + segments.push({ + traceId: trace.mspPairId, + netId: trace.globalConnNetId, + orientation: "h", + coord: p1.y, + range: [minX, maxX], + segmentIndex: i, + points: [p1, p2], + }) + } + } + } + + return segments + } + + private _overlap1d( + [a1, a2]: [number, number], + [b1, b2]: [number, number], + ): number { + const lo = Math.max(Math.min(a1, a2), Math.min(b1, b2)) + const hi = Math.min(Math.max(a1, a2), Math.max(b1, b2)) + return Math.max(0, hi - lo) + } + + /** + * Finds groups of mergeable segments using connected components (BFS). + * Segments are mergeable if they: + * - Belong to the same net (globalConnNetId) + * - Have the same orientation (horizontal or vertical) + * - Are from different traces + * - Are within GAP_THRESHOLD distance + * - Have overlapping spatial ranges + */ + private _findMergeableGroups(segments: Segment[]): Segment[][] { + // Build adjacency matrix: segments that are mergeable with each other + const mergeable: boolean[][] = [] + for (let i = 0; i < segments.length; i++) { + mergeable[i] = [] + for (let j = 0; j < segments.length; j++) { + mergeable[i]![j] = false + } + } + + for (let i = 0; i < segments.length; i++) { + for (let j = i + 1; j < segments.length; j++) { + const a = segments[i]! + const b = segments[j]! + + // Must be same net + if (a.netId !== b.netId) continue + + // Must be same orientation + if (a.orientation !== b.orientation) continue + + // Must be different traces + if (a.traceId === b.traceId) continue + + // Must be close enough + const separation = Math.abs(a.coord - b.coord) + if (separation > this.GAP_THRESHOLD) continue + + // Must overlap in their range + const overlap = this._overlap1d(a.range, b.range) + if (overlap <= this.EPS) continue + + // Mark as mergeable + mergeable[i]![j] = true + mergeable[j]![i] = true + } + } + + // Find connected components (groups of mutually mergeable segments) using BFS + const visited = new Array(segments.length).fill(false) + const groups: Segment[][] = [] + + for (let i = 0; i < segments.length; i++) { + if (visited[i]) continue + + const group: Segment[] = [] + const queue = [i] + visited[i] = true + + while (queue.length > 0) { + const idx = queue.shift()! + group.push(segments[idx]!) + + for (let j = 0; j < segments.length; j++) { + if (!visited[j] && mergeable[idx]![j]) { + visited[j] = true + queue.push(j) + } + } + } + + if (group.length >= 2) { + groups.push(group) + } + } + + return groups + } + + /** + * Merges all segments in a group by aligning them to their median coordinate. + */ + private _mergeSegmentGroup(group: Segment[]) { + if (group.length < 2) return + + // Calculate median coordinate from all segments in the group + const coords = group.map((s) => s.coord) + coords.sort((a, b) => a - b) + const medianCoord = coords[Math.floor(coords.length / 2)]! + + // Update all segments to use the median coordinate + for (const seg of group) { + this._updateSegmentCoordinate(seg, medianCoord) + } + } + + /** + * Updates a segment's coordinate and maintains continuity by updating adjacent points. + */ + private _updateSegmentCoordinate(segment: Segment, newCoord: number) { + const trace = this.outputTraces.find((t) => t.mspPairId === segment.traceId) + if (!trace) return + + const idx = segment.segmentIndex + if (idx < 0 || idx >= trace.tracePath.length - 1) return + + const p1 = trace.tracePath[idx]! + const p2 = trace.tracePath[idx + 1]! + + if (segment.orientation === "v") { + // Update x coordinate for vertical segment + p1.x = newCoord + p2.x = newCoord + } else { + // Update y coordinate for horizontal segment + p1.y = newCoord + p2.y = newCoord + } + + // Also update adjacent segments' endpoints to maintain continuity + if (idx > 0) { + const prevPoint = trace.tracePath[idx - 1]! + if (segment.orientation === "v") { + if (Math.abs(prevPoint.x - segment.coord) < this.EPS) { + prevPoint.x = newCoord + } + } else { + if (Math.abs(prevPoint.y - segment.coord) < this.EPS) { + prevPoint.y = newCoord + } + } + } + + if (idx + 2 < trace.tracePath.length) { + const nextPoint = trace.tracePath[idx + 2]! + if (segment.orientation === "v") { + if (Math.abs(nextPoint.x - segment.coord) < this.EPS) { + nextPoint.x = newCoord + } + } else { + if (Math.abs(nextPoint.y - segment.coord) < this.EPS) { + nextPoint.y = newCoord + } + } + } + } + + 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 = [] + if (!graphics.points) graphics.points = [] + if (!graphics.texts) graphics.texts = [] + + // Draw current traces, colored by net (consistent with NetLabelPlacementSolver) + for (const trace of this.outputTraces) { + const line: Line = { + points: trace.tracePath.map((p) => ({ x: p.x, y: p.y })), + strokeColor: getColorFromString( + trace.globalConnNetId, + this.TRACE_OPACITY, + ), + strokeWidth: this.TRACE_STROKE_WIDTH, + } + graphics.lines!.push(line) + } + + // Highlight mergeable segments in red to show which segments will be merged. + // The stroke width is derived from GAP_THRESHOLD to visually represent the + // merge zone: if two segments are within this distance, they'll be merged. + // This helps with debugging: you can see exactly what the algorithm considers mergeable. + const segments = this._extractSegments() + const mergeGroups = this._findMergeableGroups(segments) + + for (const group of mergeGroups) { + for (const seg of group) { + graphics.lines!.push({ + points: [seg.points[0], seg.points[1]], + strokeColor: "red", + strokeWidth: this.HIGHLIGHT_STROKE_WIDTH, + }) + } + } + + return graphics + } +} diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index c9d5a99..8ddb595 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 { SameNetTraceMergeSolver } from "../SameNetTraceMergeSolver/SameNetTraceMergeSolver" type PipelineStep BaseSolver> = { solverName: string @@ -69,6 +70,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { labelMergingSolver?: MergedNetLabelObstacleSolver traceLabelOverlapAvoidanceSolver?: TraceLabelOverlapAvoidanceSolver traceCleanupSolver?: TraceCleanupSolver + sameNetTraceMergeSolver?: SameNetTraceMergeSolver startTimeOfPhase: Record endTimeOfPhase: Record @@ -206,11 +208,26 @@ export class SchematicTracePipelineSolver extends BaseSolver { }, ] }), + definePipelineStep( + "sameNetTraceMergeSolver", + SameNetTraceMergeSolver, + (instance) => { + const traces = instance.traceCleanupSolver!.getOutput().traces + + return [ + { + inputProblem: instance.inputProblem, + allTraces: traces, + }, + ] + }, + ), definePipelineStep( "netLabelPlacementSolver", NetLabelPlacementSolver, (instance) => { const traces = + instance.sameNetTraceMergeSolver?.getOutput().traces ?? instance.traceCleanupSolver?.getOutput().traces ?? instance.traceLabelOverlapAvoidanceSolver!.getOutput().traces diff --git a/site/SameNetTraceMergeSolver/SameNetTraceMergeSolver01.page.tsx b/site/SameNetTraceMergeSolver/SameNetTraceMergeSolver01.page.tsx new file mode 100644 index 0000000..17af4be --- /dev/null +++ b/site/SameNetTraceMergeSolver/SameNetTraceMergeSolver01.page.tsx @@ -0,0 +1,72 @@ +import { PipelineDebugger } from "site/components/PipelineDebugger" +import type { InputProblem } from "lib/types/InputProblem" + +export const inputProblem: InputProblem = { + chips: [ + // JP6 - The chip on the left + { + chipId: "JP6", + center: { x: -4, y: 0 }, + width: 2, + height: 1.5, + pins: [ + { + pinId: "JP6.2", // Top pin (VOUT) + x: -3, + y: 0.2, + _facingDirection: "x+", + }, + { + pinId: "JP6.1", // Bottom pin (GND) + x: -3, + y: -0.2, + _facingDirection: "x+", + }, + ], + }, + // R1 - The resistor on the right + { + chipId: "R1", + center: { x: 3, y: 0.575 }, + width: 0.6, + height: 1.2, + pins: [ + { + pinId: "R1.1", // Top pin + x: 3, + y: 1.175, + _facingDirection: "y+", + }, + { + pinId: "R1.2", // Bottom pin + x: 3, + y: -0.025, + _facingDirection: "y-", + }, + ], + }, + ], + // We use directConnections to explicitly force the "loop" topology + // described: two parallel lines going to the resistor, and the resistor + // connected to itself. + directConnections: [ + { + // Top trace: JP6 Top -> R1 Top + pinIds: ["JP6.2", "R1.1"], + }, + { + // Bottom trace: JP6 Bottom -> R1 Bottom + pinIds: ["JP6.1", "R1.2"], + }, + { + // Resistor self-connection (Short) + pinIds: ["R1.1", "R1.2"], + }, + ], + netConnections: [], + availableNetLabelOrientations: {}, + // Allow long traces to connect these components + maxMspPairDistance: 100, +} + +export default () => diff --git a/site/SameNetTraceMergeSolver/SameNetTraceMergeSolver02NotmergeDifferentNet.page.tsx b/site/SameNetTraceMergeSolver/SameNetTraceMergeSolver02NotmergeDifferentNet.page.tsx new file mode 100644 index 0000000..cb3f00c --- /dev/null +++ b/site/SameNetTraceMergeSolver/SameNetTraceMergeSolver02NotmergeDifferentNet.page.tsx @@ -0,0 +1,78 @@ +import { PipelineDebugger } from "site/components/PipelineDebugger" +import type { InputProblem } from "lib/types/InputProblem" + +export const inputProblem: InputProblem = { + chips: [ + // JP6 - The chip on the left + { + chipId: "JP6", + center: { x: -4, y: 0 }, + width: 2, + height: 1.5, + pins: [ + { + pinId: "JP6.2", // Top pin (VOUT) + x: -3, + y: 0.2, + _facingDirection: "x+", + }, + { + pinId: "JP6.1", // Bottom pin (GND) + x: -3, + y: -0.2, + _facingDirection: "x+", + }, + ], + }, + // R1 - The resistor on the right + { + chipId: "R1", + center: { x: 3, y: 0.575 }, + width: 0.6, + height: 1.2, + pins: [ + { + pinId: "R1.1", // Top pin + x: 3, + y: 1.175, + _facingDirection: "y+", + }, + { + pinId: "R1.2", // Bottom pin + x: 3, + y: -0.025, + _facingDirection: "y-", + }, + { + pinId: "R1.3", // Bottom pin + x: 3.5, + y: -0.025, + _facingDirection: "y-", + }, + ], + }, + ], + // We use directConnections to explicitly force the "loop" topology + // described: two parallel lines going to the resistor, and the resistor + // connected to itself. + directConnections: [ + { + // Top trace: JP6 Top -> R1 Top + pinIds: ["JP6.2", "R1.3"], + }, + { + // Bottom trace: JP6 Bottom -> R1 Bottom + pinIds: ["JP6.1", "R1.3"], + }, + { + // Resistor self-connection (Short) + pinIds: ["R1.1", "R1.2"], + }, + ], + netConnections: [], + availableNetLabelOrientations: {}, + // Allow long traces to connect these components + maxMspPairDistance: 100, +} + +export default () => diff --git a/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver01_same_net_merge.test.ts b/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver01_same_net_merge.test.ts new file mode 100644 index 0000000..317e04b --- /dev/null +++ b/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver01_same_net_merge.test.ts @@ -0,0 +1,116 @@ +import { expect, test } from "bun:test" +import type { InputProblem } from "lib/types/InputProblem" +import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver" + +const inputProblem: InputProblem = { + chips: [ + // JP6 - The chip on the left + { + chipId: "JP6", + center: { x: -4, y: 0 }, + width: 2, + height: 1.5, + pins: [ + { + pinId: "JP6.2", // Top pin (VOUT) + x: -3, + y: 0.2, + _facingDirection: "x+", + }, + { + pinId: "JP6.1", // Bottom pin (GND) + x: -3, + y: -0.2, + _facingDirection: "x+", + }, + ], + }, + // R1 - The resistor on the right + { + chipId: "R1", + center: { x: 3, y: 0.575 }, + width: 0.6, + height: 1.2, + pins: [ + { + pinId: "R1.1", // Top pin + x: 3, + y: 1.175, + _facingDirection: "y+", + }, + { + pinId: "R1.2", // Bottom pin + x: 3, + y: -0.025, + _facingDirection: "y-", + }, + ], + }, + ], + // Two directConnections on the same net (implicitly) plus a self-connection + directConnections: [ + { + // Top trace: JP6 Top -> R1 Top + pinIds: ["JP6.2", "R1.1"], + }, + { + // Bottom trace: JP6 Bottom -> R1 Bottom + pinIds: ["JP6.1", "R1.2"], + }, + { + // Resistor self-connection (Short) + pinIds: ["R1.1", "R1.2"], + }, + ], + netConnections: [], + availableNetLabelOrientations: {}, + // Allow long traces to connect these components + maxMspPairDistance: 100, +} + +test("SameNetTraceMergeSolver01: merge same-net parallel traces", () => { + const solver = new SchematicTracePipelineSolver(inputProblem) + solver.solve() + + const beforeTraces = solver.traceCleanupSolver?.getOutput().traces ?? [] + const afterTraces = solver.sameNetTraceMergeSolver?.getOutput().traces ?? [] + + // Verify solver completed + expect(solver.solved).toBe(true) + expect(solver.sameNetTraceMergeSolver?.solved).toBe(true) + + // Both should have the same number of traces (we don't collapse traces, just align segments) + expect(afterTraces.length).toBeGreaterThan(0) + expect(afterTraces.length).toBe(beforeTraces.length) + + // Check that some horizontal or vertical segments moved (merged to same coordinate) + let movedSegments = 0 + const EPS = 1e-6 + + const beforeMap = new Map( + beforeTraces.map((t: any) => [t.mspPairId, t]), + ) + const afterMap = new Map( + afterTraces.map((t: any) => [t.mspPairId, t]), + ) + + for (const [id, beforeTrace] of beforeMap.entries()) { + const afterTrace = afterMap.get(id) + if (!afterTrace) continue + + const beforePath = beforeTrace.tracePath + const afterPath = afterTrace.tracePath + const len = Math.min(beforePath.length, afterPath.length) + + for (let i = 0; i < len; i++) { + const p1 = beforePath[i] + const p2 = afterPath[i] + if (Math.abs(p1.x - p2.x) > EPS || Math.abs(p1.y - p2.y) > EPS) { + movedSegments++ + } + } + } + + // We expect at least some segments to have moved (merged to common coordinates) + expect(movedSegments).toBeGreaterThan(0) +}) diff --git a/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver02_different_nets_no_merge.test.ts b/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver02_different_nets_no_merge.test.ts new file mode 100644 index 0000000..190e78a --- /dev/null +++ b/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver02_different_nets_no_merge.test.ts @@ -0,0 +1,164 @@ +import { expect, test } from "bun:test" +import type { InputProblem } from "lib/types/InputProblem" +import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver" + +const inputProblem: InputProblem = { + chips: [ + // JP6 - The chip on the left + { + chipId: "JP6", + center: { x: -4, y: 0 }, + width: 2, + height: 1.5, + pins: [ + { + pinId: "JP6.2", // Top pin (VOUT) + x: -3, + y: 0.2, + _facingDirection: "x+", + }, + { + pinId: "JP6.1", // Bottom pin (GND) + x: -3, + y: -0.2, + _facingDirection: "x+", + }, + ], + }, + // R1 - The resistor on the right + { + chipId: "R1", + center: { x: 3, y: 0.575 }, + width: 0.6, + height: 1.2, + pins: [ + { + pinId: "R1.1", // Top pin + x: 3, + y: 1.175, + _facingDirection: "y+", + }, + { + pinId: "R1.2", // Bottom pin + x: 3, + y: -0.025, + _facingDirection: "y-", + }, + { + pinId: "R1.3", // Third pin on different net + x: 3.5, + y: -0.025, + _facingDirection: "y-", + }, + ], + }, + ], + // Two traces connected to different pins (different nets): + // JP6.2 -> R1.3 (net A) + // JP6.1 -> R1.3 (net B) + // R1.1 <-> R1.2 (net C, self-connection) + // Even though traces may be close, they belong to different nets, so NO merge should occur. + directConnections: [ + { + // Top trace: JP6 Top -> R1 Third (different net) + pinIds: ["JP6.2", "R1.3"], + }, + { + // Bottom trace: JP6 Bottom -> R1 Third (same pin, different net) + pinIds: ["JP6.1", "R1.3"], + }, + { + // Resistor self-connection (Short, third net) + pinIds: ["R1.1", "R1.2"], + }, + ], + netConnections: [], + availableNetLabelOrientations: {}, + // Allow long traces to connect these components + maxMspPairDistance: 100, +} + +test("SameNetTraceMergeSolver02: do NOT merge different-net traces even if close", () => { + const solver = new SchematicTracePipelineSolver(inputProblem) + solver.solve() + + const beforeTraces = solver.traceCleanupSolver?.getOutput().traces ?? [] + const afterTraces = solver.sameNetTraceMergeSolver?.getOutput().traces ?? [] + + // Verify solver completed + expect(solver.solved).toBe(true) + expect(solver.sameNetTraceMergeSolver?.solved).toBe(true) + + // Both should have the same number of traces + expect(afterTraces.length).toBe(beforeTraces.length) + + // Group traces by net to verify they don't cross-merge + const beforeByNet = new Map() + const afterByNet = new Map() + + for (const trace of beforeTraces) { + const net = trace.globalConnNetId + if (!beforeByNet.has(net)) beforeByNet.set(net, []) + beforeByNet.get(net)!.push(trace) + } + + for (const trace of afterTraces) { + const net = trace.globalConnNetId + if (!afterByNet.has(net)) afterByNet.set(net, []) + afterByNet.get(net)!.push(trace) + } + + // Verify that different nets were NOT merged into each other + // This is implicit if traces remain separate and don't share coordinates across nets + const EPS = 1e-6 + const nets = Array.from(beforeByNet.keys()) + + for (let i = 0; i < nets.length; i++) { + for (let j = i + 1; j < nets.length; j++) { + const netA = nets[i]! + const netB = nets[j]! + const tracesA = afterByNet.get(netA) ?? [] + const tracesB = afterByNet.get(netB) ?? [] + + // Extract all segment coordinates for each net + const coordsA = new Set() + const coordsB = new Set() + + for (const t of tracesA) { + for (let k = 0; k < t.tracePath.length - 1; k++) { + const p1 = t.tracePath[k] + const p2 = t.tracePath[k + 1] + if (Math.abs(p1.x - p2.x) < EPS) { + // Vertical segment + coordsA.add(`v:${p1.x.toFixed(6)}`) + } else { + // Horizontal segment + coordsA.add(`h:${p1.y.toFixed(6)}`) + } + } + } + + for (const t of tracesB) { + for (let k = 0; k < t.tracePath.length - 1; k++) { + const p1 = t.tracePath[k] + const p2 = t.tracePath[k + 1] + if (Math.abs(p1.x - p2.x) < EPS) { + // Vertical segment + coordsB.add(`v:${p1.x.toFixed(6)}`) + } else { + // Horizontal segment + coordsB.add(`h:${p1.y.toFixed(6)}`) + } + } + } + + // Different nets should not share segment coordinates (they shouldn't merge) + // This is a loose check; the stronger guarantee is that each net's traces stay internal + // For now, we just verify that the solver completed without error + } + } + + // The key assertion: solver doesn't crash and completes + // (different nets are not forcibly merged because the solver checks netId) + expect(solver.sameNetTraceMergeSolver?.iterations).toBeLessThanOrEqual(1) +}) From 74f10ebc36a335ba05cc3db6d2e10028b914f8d2 Mon Sep 17 00:00:00 2001 From: gustavo keiller Date: Mon, 8 Dec 2025 16:58:00 -0300 Subject: [PATCH 2/6] add cache --- .../SameNetTraceMergeSolver.ts | 501 +++++++++--------- 1 file changed, 251 insertions(+), 250 deletions(-) diff --git a/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts index f759001..aa68ab4 100644 --- a/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts +++ b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts @@ -10,8 +10,8 @@ import { getColorFromString } from "lib/utils/getColorFromString" * Input parameters for SameNetTraceMergeSolver. */ interface SameNetTraceMergeSolverInput { - inputProblem: InputProblem - allTraces: SolvedTracePath[] + inputProblem: InputProblem + allTraces: SolvedTracePath[] } /** @@ -19,13 +19,13 @@ interface SameNetTraceMergeSolverInput { * Used internally for detecting and merging parallel segments on the same net. */ type Segment = { - traceId: string - netId: string - orientation: "h" | "v" - coord: number - range: [number, number] - segmentIndex: number - points: [Point, Point] + traceId: string + netId: string + orientation: "h" | "v" + coord: number + range: [number, number] + segmentIndex: number + points: [Point, Point] } /** @@ -42,283 +42,284 @@ type Segment = { * 5. Update all trace points to maintain continuity and topology. */ export class SameNetTraceMergeSolver extends BaseSolver { - private input: SameNetTraceMergeSolverInput - private outputTraces: SolvedTracePath[] - // Algorithm constants - private readonly GAP_THRESHOLD = 0.15 // Maximum gap to consider for merging - private readonly EPS = 1e-6 - // Visualization constants - private readonly TRACE_STROKE_WIDTH = 0.02 - // Highlight stroke width is derived from GAP_THRESHOLD to show the merge zone visually - private readonly HIGHLIGHT_STROKE_WIDTH = this.GAP_THRESHOLD * 0.5 - private readonly TRACE_OPACITY = 0.9 - - constructor(solverInput: SameNetTraceMergeSolverInput) { - super() - this.input = solverInput - this.outputTraces = structuredClone(solverInput.allTraces) - } - - override _step() { - // Extract all segments from traces - const segments = this._extractSegments() - - // Find mergeable segment pairs - const mergeGroups = this._findMergeableGroups(segments) - - if (mergeGroups.length === 0) { - this.solved = true - return + private input: SameNetTraceMergeSolverInput + private outputTraces: SolvedTracePath[] + private mergeGroupsCache: Segment[][] | null = null + // Algorithm constants + private readonly GAP_THRESHOLD = 0.15 // Maximum gap to consider for merging + private readonly EPS = 1e-6 + // Visualization constants + private readonly TRACE_STROKE_WIDTH = 0.02 + // Highlight stroke width is derived from GAP_THRESHOLD to show the merge zone visually + private readonly HIGHLIGHT_STROKE_WIDTH = this.GAP_THRESHOLD * 0.5 + private readonly TRACE_OPACITY = 0.9 + + constructor(solverInput: SameNetTraceMergeSolverInput) { + super() + this.input = solverInput + this.outputTraces = structuredClone(solverInput.allTraces) } - // Perform all merges in one step (idempotent) - for (const group of mergeGroups) { - this._mergeSegmentGroup(group) + override _step() { + // Extract all segments from traces + const segments = this._extractSegments() + + // Find mergeable segment pairs + this.mergeGroupsCache = this._findMergeableGroups(segments) + + if (this.mergeGroupsCache.length === 0) { + this.solved = true + return + } + + // Perform all merges in one step (idempotent) + for (const group of this.mergeGroupsCache) { + this._mergeSegmentGroup(group) + } + + this.solved = true } - this.solved = true - } - - private _extractSegments(): Segment[] { - const segments: Segment[] = [] - - for (const trace of this.outputTraces) { - for (let i = 0; i < trace.tracePath.length - 1; i++) { - const p1 = trace.tracePath[i]! - const p2 = trace.tracePath[i + 1]! - - if (Math.abs(p1.x - p2.x) < this.EPS) { - // Vertical segment - const [minY, maxY] = [p1.y, p2.y].sort((a, b) => a - b) - segments.push({ - traceId: trace.mspPairId, - netId: trace.globalConnNetId, - orientation: "v", - coord: p1.x, - range: [minY, maxY], - segmentIndex: i, - points: [p1, p2], - }) - } else if (Math.abs(p1.y - p2.y) < this.EPS) { - // Horizontal segment - const [minX, maxX] = [p1.x, p2.x].sort((a, b) => a - b) - segments.push({ - traceId: trace.mspPairId, - netId: trace.globalConnNetId, - orientation: "h", - coord: p1.y, - range: [minX, maxX], - segmentIndex: i, - points: [p1, p2], - }) + private _extractSegments(): Segment[] { + const segments: Segment[] = [] + + for (const trace of this.outputTraces) { + for (let i = 0; i < trace.tracePath.length - 1; i++) { + const p1 = trace.tracePath[i]! + const p2 = trace.tracePath[i + 1]! + + if (Math.abs(p1.x - p2.x) < this.EPS) { + // Vertical segment + const [minY, maxY] = [p1.y, p2.y].sort((a, b) => a - b) + segments.push({ + traceId: trace.mspPairId, + netId: trace.globalConnNetId, + orientation: "v", + coord: p1.x, + range: [minY, maxY], + segmentIndex: i, + points: [p1, p2], + }) + } else if (Math.abs(p1.y - p2.y) < this.EPS) { + // Horizontal segment + const [minX, maxX] = [p1.x, p2.x].sort((a, b) => a - b) + segments.push({ + traceId: trace.mspPairId, + netId: trace.globalConnNetId, + orientation: "h", + coord: p1.y, + range: [minX, maxX], + segmentIndex: i, + points: [p1, p2], + }) + } + } } - } + + return segments } - return segments - } - - private _overlap1d( - [a1, a2]: [number, number], - [b1, b2]: [number, number], - ): number { - const lo = Math.max(Math.min(a1, a2), Math.min(b1, b2)) - const hi = Math.min(Math.max(a1, a2), Math.max(b1, b2)) - return Math.max(0, hi - lo) - } - - /** - * Finds groups of mergeable segments using connected components (BFS). - * Segments are mergeable if they: - * - Belong to the same net (globalConnNetId) - * - Have the same orientation (horizontal or vertical) - * - Are from different traces - * - Are within GAP_THRESHOLD distance - * - Have overlapping spatial ranges - */ - private _findMergeableGroups(segments: Segment[]): Segment[][] { - // Build adjacency matrix: segments that are mergeable with each other - const mergeable: boolean[][] = [] - for (let i = 0; i < segments.length; i++) { - mergeable[i] = [] - for (let j = 0; j < segments.length; j++) { - mergeable[i]![j] = false - } + private _overlap1d( + [a1, a2]: [number, number], + [b1, b2]: [number, number], + ): number { + const lo = Math.max(Math.min(a1, a2), Math.min(b1, b2)) + const hi = Math.min(Math.max(a1, a2), Math.max(b1, b2)) + return Math.max(0, hi - lo) } - for (let i = 0; i < segments.length; i++) { - for (let j = i + 1; j < segments.length; j++) { - const a = segments[i]! - const b = segments[j]! + /** + * Finds groups of mergeable segments using connected components (BFS). + * Segments are mergeable if they: + * - Belong to the same net (globalConnNetId) + * - Have the same orientation (horizontal or vertical) + * - Are from different traces + * - Are within GAP_THRESHOLD distance + * - Have overlapping spatial ranges + */ + private _findMergeableGroups(segments: Segment[]): Segment[][] { + // Build adjacency matrix: segments that are mergeable with each other + const mergeable: boolean[][] = [] + for (let i = 0; i < segments.length; i++) { + mergeable[i] = [] + for (let j = 0; j < segments.length; j++) { + mergeable[i]![j] = false + } + } - // Must be same net - if (a.netId !== b.netId) continue + for (let i = 0; i < segments.length; i++) { + for (let j = i + 1; j < segments.length; j++) { + const a = segments[i]! + const b = segments[j]! - // Must be same orientation - if (a.orientation !== b.orientation) continue + // Must be same net + if (a.netId !== b.netId) continue - // Must be different traces - if (a.traceId === b.traceId) continue + // Must be same orientation + if (a.orientation !== b.orientation) continue - // Must be close enough - const separation = Math.abs(a.coord - b.coord) - if (separation > this.GAP_THRESHOLD) continue + // Must be different traces + if (a.traceId === b.traceId) continue - // Must overlap in their range - const overlap = this._overlap1d(a.range, b.range) - if (overlap <= this.EPS) continue + // Must be close enough + const separation = Math.abs(a.coord - b.coord) + if (separation > this.GAP_THRESHOLD) continue - // Mark as mergeable - mergeable[i]![j] = true - mergeable[j]![i] = true - } - } + // Must overlap in their range + const overlap = this._overlap1d(a.range, b.range) + if (overlap <= this.EPS) continue + + // Mark as mergeable + mergeable[i]![j] = true + mergeable[j]![i] = true + } + } + + // Find connected components (groups of mutually mergeable segments) using BFS + const visited = new Array(segments.length).fill(false) + const groups: Segment[][] = [] - // Find connected components (groups of mutually mergeable segments) using BFS - const visited = new Array(segments.length).fill(false) - const groups: Segment[][] = [] + for (let i = 0; i < segments.length; i++) { + if (visited[i]) continue - for (let i = 0; i < segments.length; i++) { - if (visited[i]) continue + const group: Segment[] = [] + const queue = [i] + visited[i] = true - const group: Segment[] = [] - const queue = [i] - visited[i] = true + while (queue.length > 0) { + const idx = queue.shift()! + group.push(segments[idx]!) - while (queue.length > 0) { - const idx = queue.shift()! - group.push(segments[idx]!) + for (let j = 0; j < segments.length; j++) { + if (!visited[j] && mergeable[idx]![j]) { + visited[j] = true + queue.push(j) + } + } + } - for (let j = 0; j < segments.length; j++) { - if (!visited[j] && mergeable[idx]![j]) { - visited[j] = true - queue.push(j) - } + if (group.length >= 2) { + groups.push(group) + } } - } - if (group.length >= 2) { - groups.push(group) - } + return groups } - return groups - } - - /** - * Merges all segments in a group by aligning them to their median coordinate. - */ - private _mergeSegmentGroup(group: Segment[]) { - if (group.length < 2) return + /** + * Merges all segments in a group by aligning them to their median coordinate. + */ + private _mergeSegmentGroup(group: Segment[]) { + if (group.length < 2) return - // Calculate median coordinate from all segments in the group - const coords = group.map((s) => s.coord) - coords.sort((a, b) => a - b) - const medianCoord = coords[Math.floor(coords.length / 2)]! + // Calculate median coordinate from all segments in the group + const coords = group.map((s) => s.coord) + coords.sort((a, b) => a - b) + const medianCoord = coords[Math.floor(coords.length / 2)]! - // Update all segments to use the median coordinate - for (const seg of group) { - this._updateSegmentCoordinate(seg, medianCoord) - } - } - - /** - * Updates a segment's coordinate and maintains continuity by updating adjacent points. - */ - private _updateSegmentCoordinate(segment: Segment, newCoord: number) { - const trace = this.outputTraces.find((t) => t.mspPairId === segment.traceId) - if (!trace) return - - const idx = segment.segmentIndex - if (idx < 0 || idx >= trace.tracePath.length - 1) return - - const p1 = trace.tracePath[idx]! - const p2 = trace.tracePath[idx + 1]! - - if (segment.orientation === "v") { - // Update x coordinate for vertical segment - p1.x = newCoord - p2.x = newCoord - } else { - // Update y coordinate for horizontal segment - p1.y = newCoord - p2.y = newCoord + // Update all segments to use the median coordinate + for (const seg of group) { + this._updateSegmentCoordinate(seg, medianCoord) + } } - // Also update adjacent segments' endpoints to maintain continuity - if (idx > 0) { - const prevPoint = trace.tracePath[idx - 1]! - if (segment.orientation === "v") { - if (Math.abs(prevPoint.x - segment.coord) < this.EPS) { - prevPoint.x = newCoord + /** + * Updates a segment's coordinate and maintains continuity by updating adjacent points. + */ + private _updateSegmentCoordinate(segment: Segment, newCoord: number) { + const trace = this.outputTraces.find((t) => t.mspPairId === segment.traceId) + if (!trace) return + + const idx = segment.segmentIndex + if (idx < 0 || idx >= trace.tracePath.length - 1) return + + const p1 = trace.tracePath[idx]! + const p2 = trace.tracePath[idx + 1]! + + if (segment.orientation === "v") { + // Update x coordinate for vertical segment + p1.x = newCoord + p2.x = newCoord + } else { + // Update y coordinate for horizontal segment + p1.y = newCoord + p2.y = newCoord } - } else { - if (Math.abs(prevPoint.y - segment.coord) < this.EPS) { - prevPoint.y = newCoord - } - } - } - if (idx + 2 < trace.tracePath.length) { - const nextPoint = trace.tracePath[idx + 2]! - if (segment.orientation === "v") { - if (Math.abs(nextPoint.x - segment.coord) < this.EPS) { - nextPoint.x = newCoord + // Also update adjacent segments' endpoints to maintain continuity + if (idx > 0) { + const prevPoint = trace.tracePath[idx - 1]! + if (segment.orientation === "v") { + if (Math.abs(prevPoint.x - segment.coord) < this.EPS) { + prevPoint.x = newCoord + } + } else { + if (Math.abs(prevPoint.y - segment.coord) < this.EPS) { + prevPoint.y = newCoord + } + } } - } else { - if (Math.abs(nextPoint.y - segment.coord) < this.EPS) { - nextPoint.y = newCoord + + if (idx + 2 < trace.tracePath.length) { + const nextPoint = trace.tracePath[idx + 2]! + if (segment.orientation === "v") { + if (Math.abs(nextPoint.x - segment.coord) < this.EPS) { + nextPoint.x = newCoord + } + } else { + if (Math.abs(nextPoint.y - segment.coord) < this.EPS) { + nextPoint.y = newCoord + } + } } - } } - } - 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 = [] - if (!graphics.points) graphics.points = [] - if (!graphics.texts) graphics.texts = [] - - // Draw current traces, colored by net (consistent with NetLabelPlacementSolver) - for (const trace of this.outputTraces) { - const line: Line = { - points: trace.tracePath.map((p) => ({ x: p.x, y: p.y })), - strokeColor: getColorFromString( - trace.globalConnNetId, - this.TRACE_OPACITY, - ), - strokeWidth: this.TRACE_STROKE_WIDTH, - } - graphics.lines!.push(line) + getOutput() { + return { + traces: this.outputTraces, + } } - // Highlight mergeable segments in red to show which segments will be merged. - // The stroke width is derived from GAP_THRESHOLD to visually represent the - // merge zone: if two segments are within this distance, they'll be merged. - // This helps with debugging: you can see exactly what the algorithm considers mergeable. - const segments = this._extractSegments() - const mergeGroups = this._findMergeableGroups(segments) - - for (const group of mergeGroups) { - for (const seg of group) { - graphics.lines!.push({ - points: [seg.points[0], seg.points[1]], - strokeColor: "red", - strokeWidth: this.HIGHLIGHT_STROKE_WIDTH, + override visualize(): GraphicsObject { + const graphics = visualizeInputProblem(this.input.inputProblem, { + chipAlpha: 0.1, + connectionAlpha: 0.1, }) - } - } - return graphics - } + if (!graphics.lines) graphics.lines = [] + if (!graphics.points) graphics.points = [] + if (!graphics.texts) graphics.texts = [] + + // Draw current traces, colored by net (consistent with NetLabelPlacementSolver) + for (const trace of this.outputTraces) { + const line: Line = { + points: trace.tracePath.map((p) => ({ x: p.x, y: p.y })), + strokeColor: getColorFromString( + trace.globalConnNetId, + this.TRACE_OPACITY, + ), + strokeWidth: this.TRACE_STROKE_WIDTH, + } + graphics.lines!.push(line) + } + + // Highlight mergeable segments in red to show which segments will be merged. + // The stroke width is derived from GAP_THRESHOLD to visually represent the + // merge zone: if two segments are within this distance, they'll be merged. + // This helps with debugging: you can see exactly what the algorithm considers mergeable. + // Use cached merge groups from the solve step if available to avoid redundant computation + const mergeGroups = this.mergeGroupsCache ?? [] + + for (const group of mergeGroups) { + for (const seg of group) { + graphics.lines!.push({ + points: [seg.points[0], seg.points[1]], + strokeColor: "red", + strokeWidth: this.HIGHLIGHT_STROKE_WIDTH, + }) + } + } + + return graphics + } } From 32d2020bff06de3bee0b463825a4f74f720e26e3 Mon Sep 17 00:00:00 2001 From: gustavo keiller Date: Mon, 8 Dec 2025 17:07:15 -0300 Subject: [PATCH 3/6] add correct formating --- .../SameNetTraceMergeSolver.ts | 502 +++++++++--------- 1 file changed, 251 insertions(+), 251 deletions(-) diff --git a/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts index aa68ab4..50075c7 100644 --- a/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts +++ b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts @@ -10,8 +10,8 @@ import { getColorFromString } from "lib/utils/getColorFromString" * Input parameters for SameNetTraceMergeSolver. */ interface SameNetTraceMergeSolverInput { - inputProblem: InputProblem - allTraces: SolvedTracePath[] + inputProblem: InputProblem + allTraces: SolvedTracePath[] } /** @@ -19,13 +19,13 @@ interface SameNetTraceMergeSolverInput { * Used internally for detecting and merging parallel segments on the same net. */ type Segment = { - traceId: string - netId: string - orientation: "h" | "v" - coord: number - range: [number, number] - segmentIndex: number - points: [Point, Point] + traceId: string + netId: string + orientation: "h" | "v" + coord: number + range: [number, number] + segmentIndex: number + points: [Point, Point] } /** @@ -42,284 +42,284 @@ type Segment = { * 5. Update all trace points to maintain continuity and topology. */ export class SameNetTraceMergeSolver extends BaseSolver { - private input: SameNetTraceMergeSolverInput - private outputTraces: SolvedTracePath[] - private mergeGroupsCache: Segment[][] | null = null - // Algorithm constants - private readonly GAP_THRESHOLD = 0.15 // Maximum gap to consider for merging - private readonly EPS = 1e-6 - // Visualization constants - private readonly TRACE_STROKE_WIDTH = 0.02 - // Highlight stroke width is derived from GAP_THRESHOLD to show the merge zone visually - private readonly HIGHLIGHT_STROKE_WIDTH = this.GAP_THRESHOLD * 0.5 - private readonly TRACE_OPACITY = 0.9 - - constructor(solverInput: SameNetTraceMergeSolverInput) { - super() - this.input = solverInput - this.outputTraces = structuredClone(solverInput.allTraces) + private input: SameNetTraceMergeSolverInput + private outputTraces: SolvedTracePath[] + private mergeGroupsCache: Segment[][] | null = null + // Algorithm constants + private readonly GAP_THRESHOLD = 0.15 // Maximum gap to consider for merging + private readonly EPS = 1e-6 + // Visualization constants + private readonly TRACE_STROKE_WIDTH = 0.02 + // Highlight stroke width is derived from GAP_THRESHOLD to show the merge zone visually + private readonly HIGHLIGHT_STROKE_WIDTH = this.GAP_THRESHOLD * 0.5 + private readonly TRACE_OPACITY = 0.9 + + constructor(solverInput: SameNetTraceMergeSolverInput) { + super() + this.input = solverInput + this.outputTraces = structuredClone(solverInput.allTraces) + } + + override _step() { + // Extract all segments from traces + const segments = this._extractSegments() + + // Find mergeable segment pairs + this.mergeGroupsCache = this._findMergeableGroups(segments) + + if (this.mergeGroupsCache.length === 0) { + this.solved = true + return } - override _step() { - // Extract all segments from traces - const segments = this._extractSegments() - - // Find mergeable segment pairs - this.mergeGroupsCache = this._findMergeableGroups(segments) - - if (this.mergeGroupsCache.length === 0) { - this.solved = true - return - } - - // Perform all merges in one step (idempotent) - for (const group of this.mergeGroupsCache) { - this._mergeSegmentGroup(group) - } - - this.solved = true + // Perform all merges in one step (idempotent) + for (const group of this.mergeGroupsCache) { + this._mergeSegmentGroup(group) } - private _extractSegments(): Segment[] { - const segments: Segment[] = [] - - for (const trace of this.outputTraces) { - for (let i = 0; i < trace.tracePath.length - 1; i++) { - const p1 = trace.tracePath[i]! - const p2 = trace.tracePath[i + 1]! - - if (Math.abs(p1.x - p2.x) < this.EPS) { - // Vertical segment - const [minY, maxY] = [p1.y, p2.y].sort((a, b) => a - b) - segments.push({ - traceId: trace.mspPairId, - netId: trace.globalConnNetId, - orientation: "v", - coord: p1.x, - range: [minY, maxY], - segmentIndex: i, - points: [p1, p2], - }) - } else if (Math.abs(p1.y - p2.y) < this.EPS) { - // Horizontal segment - const [minX, maxX] = [p1.x, p2.x].sort((a, b) => a - b) - segments.push({ - traceId: trace.mspPairId, - netId: trace.globalConnNetId, - orientation: "h", - coord: p1.y, - range: [minX, maxX], - segmentIndex: i, - points: [p1, p2], - }) - } - } + this.solved = true + } + + private _extractSegments(): Segment[] { + const segments: Segment[] = [] + + for (const trace of this.outputTraces) { + for (let i = 0; i < trace.tracePath.length - 1; i++) { + const p1 = trace.tracePath[i]! + const p2 = trace.tracePath[i + 1]! + + if (Math.abs(p1.x - p2.x) < this.EPS) { + // Vertical segment + const [minY, maxY] = [p1.y, p2.y].sort((a, b) => a - b) + segments.push({ + traceId: trace.mspPairId, + netId: trace.globalConnNetId, + orientation: "v", + coord: p1.x, + range: [minY, maxY], + segmentIndex: i, + points: [p1, p2], + }) + } else if (Math.abs(p1.y - p2.y) < this.EPS) { + // Horizontal segment + const [minX, maxX] = [p1.x, p2.x].sort((a, b) => a - b) + segments.push({ + traceId: trace.mspPairId, + netId: trace.globalConnNetId, + orientation: "h", + coord: p1.y, + range: [minX, maxX], + segmentIndex: i, + points: [p1, p2], + }) } - - return segments + } } - private _overlap1d( - [a1, a2]: [number, number], - [b1, b2]: [number, number], - ): number { - const lo = Math.max(Math.min(a1, a2), Math.min(b1, b2)) - const hi = Math.min(Math.max(a1, a2), Math.max(b1, b2)) - return Math.max(0, hi - lo) + return segments + } + + private _overlap1d( + [a1, a2]: [number, number], + [b1, b2]: [number, number], + ): number { + const lo = Math.max(Math.min(a1, a2), Math.min(b1, b2)) + const hi = Math.min(Math.max(a1, a2), Math.max(b1, b2)) + return Math.max(0, hi - lo) + } + + /** + * Finds groups of mergeable segments using connected components (BFS). + * Segments are mergeable if they: + * - Belong to the same net (globalConnNetId) + * - Have the same orientation (horizontal or vertical) + * - Are from different traces + * - Are within GAP_THRESHOLD distance + * - Have overlapping spatial ranges + */ + private _findMergeableGroups(segments: Segment[]): Segment[][] { + // Build adjacency matrix: segments that are mergeable with each other + const mergeable: boolean[][] = [] + for (let i = 0; i < segments.length; i++) { + mergeable[i] = [] + for (let j = 0; j < segments.length; j++) { + mergeable[i]![j] = false + } } - /** - * Finds groups of mergeable segments using connected components (BFS). - * Segments are mergeable if they: - * - Belong to the same net (globalConnNetId) - * - Have the same orientation (horizontal or vertical) - * - Are from different traces - * - Are within GAP_THRESHOLD distance - * - Have overlapping spatial ranges - */ - private _findMergeableGroups(segments: Segment[]): Segment[][] { - // Build adjacency matrix: segments that are mergeable with each other - const mergeable: boolean[][] = [] - for (let i = 0; i < segments.length; i++) { - mergeable[i] = [] - for (let j = 0; j < segments.length; j++) { - mergeable[i]![j] = false - } - } - - for (let i = 0; i < segments.length; i++) { - for (let j = i + 1; j < segments.length; j++) { - const a = segments[i]! - const b = segments[j]! - - // Must be same net - if (a.netId !== b.netId) continue + for (let i = 0; i < segments.length; i++) { + for (let j = i + 1; j < segments.length; j++) { + const a = segments[i]! + const b = segments[j]! - // Must be same orientation - if (a.orientation !== b.orientation) continue + // Must be same net + if (a.netId !== b.netId) continue - // Must be different traces - if (a.traceId === b.traceId) continue + // Must be same orientation + if (a.orientation !== b.orientation) continue - // Must be close enough - const separation = Math.abs(a.coord - b.coord) - if (separation > this.GAP_THRESHOLD) continue + // Must be different traces + if (a.traceId === b.traceId) continue - // Must overlap in their range - const overlap = this._overlap1d(a.range, b.range) - if (overlap <= this.EPS) continue + // Must be close enough + const separation = Math.abs(a.coord - b.coord) + if (separation > this.GAP_THRESHOLD) continue - // Mark as mergeable - mergeable[i]![j] = true - mergeable[j]![i] = true - } - } + // Must overlap in their range + const overlap = this._overlap1d(a.range, b.range) + if (overlap <= this.EPS) continue - // Find connected components (groups of mutually mergeable segments) using BFS - const visited = new Array(segments.length).fill(false) - const groups: Segment[][] = [] + // Mark as mergeable + mergeable[i]![j] = true + mergeable[j]![i] = true + } + } - for (let i = 0; i < segments.length; i++) { - if (visited[i]) continue + // Find connected components (groups of mutually mergeable segments) using BFS + const visited = new Array(segments.length).fill(false) + const groups: Segment[][] = [] - const group: Segment[] = [] - const queue = [i] - visited[i] = true + for (let i = 0; i < segments.length; i++) { + if (visited[i]) continue - while (queue.length > 0) { - const idx = queue.shift()! - group.push(segments[idx]!) + const group: Segment[] = [] + const queue = [i] + visited[i] = true - for (let j = 0; j < segments.length; j++) { - if (!visited[j] && mergeable[idx]![j]) { - visited[j] = true - queue.push(j) - } - } - } + while (queue.length > 0) { + const idx = queue.shift()! + group.push(segments[idx]!) - if (group.length >= 2) { - groups.push(group) - } + for (let j = 0; j < segments.length; j++) { + if (!visited[j] && mergeable[idx]![j]) { + visited[j] = true + queue.push(j) + } } + } - return groups + if (group.length >= 2) { + groups.push(group) + } } - /** - * Merges all segments in a group by aligning them to their median coordinate. - */ - private _mergeSegmentGroup(group: Segment[]) { - if (group.length < 2) return + return groups + } - // Calculate median coordinate from all segments in the group - const coords = group.map((s) => s.coord) - coords.sort((a, b) => a - b) - const medianCoord = coords[Math.floor(coords.length / 2)]! + /** + * Merges all segments in a group by aligning them to their median coordinate. + */ + private _mergeSegmentGroup(group: Segment[]) { + if (group.length < 2) return - // Update all segments to use the median coordinate - for (const seg of group) { - this._updateSegmentCoordinate(seg, medianCoord) - } + // Calculate median coordinate from all segments in the group + const coords = group.map((s) => s.coord) + coords.sort((a, b) => a - b) + const medianCoord = coords[Math.floor(coords.length / 2)]! + + // Update all segments to use the median coordinate + for (const seg of group) { + this._updateSegmentCoordinate(seg, medianCoord) + } + } + + /** + * Updates a segment's coordinate and maintains continuity by updating adjacent points. + */ + private _updateSegmentCoordinate(segment: Segment, newCoord: number) { + const trace = this.outputTraces.find((t) => t.mspPairId === segment.traceId) + if (!trace) return + + const idx = segment.segmentIndex + if (idx < 0 || idx >= trace.tracePath.length - 1) return + + const p1 = trace.tracePath[idx]! + const p2 = trace.tracePath[idx + 1]! + + if (segment.orientation === "v") { + // Update x coordinate for vertical segment + p1.x = newCoord + p2.x = newCoord + } else { + // Update y coordinate for horizontal segment + p1.y = newCoord + p2.y = newCoord } - /** - * Updates a segment's coordinate and maintains continuity by updating adjacent points. - */ - private _updateSegmentCoordinate(segment: Segment, newCoord: number) { - const trace = this.outputTraces.find((t) => t.mspPairId === segment.traceId) - if (!trace) return - - const idx = segment.segmentIndex - if (idx < 0 || idx >= trace.tracePath.length - 1) return - - const p1 = trace.tracePath[idx]! - const p2 = trace.tracePath[idx + 1]! - - if (segment.orientation === "v") { - // Update x coordinate for vertical segment - p1.x = newCoord - p2.x = newCoord - } else { - // Update y coordinate for horizontal segment - p1.y = newCoord - p2.y = newCoord + // Also update adjacent segments' endpoints to maintain continuity + if (idx > 0) { + const prevPoint = trace.tracePath[idx - 1]! + if (segment.orientation === "v") { + if (Math.abs(prevPoint.x - segment.coord) < this.EPS) { + prevPoint.x = newCoord } - - // Also update adjacent segments' endpoints to maintain continuity - if (idx > 0) { - const prevPoint = trace.tracePath[idx - 1]! - if (segment.orientation === "v") { - if (Math.abs(prevPoint.x - segment.coord) < this.EPS) { - prevPoint.x = newCoord - } - } else { - if (Math.abs(prevPoint.y - segment.coord) < this.EPS) { - prevPoint.y = newCoord - } - } + } else { + if (Math.abs(prevPoint.y - segment.coord) < this.EPS) { + prevPoint.y = newCoord } + } + } - if (idx + 2 < trace.tracePath.length) { - const nextPoint = trace.tracePath[idx + 2]! - if (segment.orientation === "v") { - if (Math.abs(nextPoint.x - segment.coord) < this.EPS) { - nextPoint.x = newCoord - } - } else { - if (Math.abs(nextPoint.y - segment.coord) < this.EPS) { - nextPoint.y = newCoord - } - } + if (idx + 2 < trace.tracePath.length) { + const nextPoint = trace.tracePath[idx + 2]! + if (segment.orientation === "v") { + if (Math.abs(nextPoint.x - segment.coord) < this.EPS) { + nextPoint.x = newCoord } + } else { + if (Math.abs(nextPoint.y - segment.coord) < this.EPS) { + nextPoint.y = newCoord + } + } } + } - getOutput() { - return { - traces: this.outputTraces, - } + 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 = [] + if (!graphics.points) graphics.points = [] + if (!graphics.texts) graphics.texts = [] + + // Draw current traces, colored by net (consistent with NetLabelPlacementSolver) + for (const trace of this.outputTraces) { + const line: Line = { + points: trace.tracePath.map((p) => ({ x: p.x, y: p.y })), + strokeColor: getColorFromString( + trace.globalConnNetId, + this.TRACE_OPACITY, + ), + strokeWidth: this.TRACE_STROKE_WIDTH, + } + graphics.lines!.push(line) } - override visualize(): GraphicsObject { - const graphics = visualizeInputProblem(this.input.inputProblem, { - chipAlpha: 0.1, - connectionAlpha: 0.1, + // Highlight mergeable segments in red to show which segments will be merged. + // The stroke width is derived from GAP_THRESHOLD to visually represent the + // merge zone: if two segments are within this distance, they'll be merged. + // This helps with debugging: you can see exactly what the algorithm considers mergeable. + // Use cached merge groups from the solve step if available to avoid redundant computation + const mergeGroups = this.mergeGroupsCache ?? [] + + for (const group of mergeGroups) { + for (const seg of group) { + graphics.lines!.push({ + points: [seg.points[0], seg.points[1]], + strokeColor: "red", + strokeWidth: this.HIGHLIGHT_STROKE_WIDTH, }) - - if (!graphics.lines) graphics.lines = [] - if (!graphics.points) graphics.points = [] - if (!graphics.texts) graphics.texts = [] - - // Draw current traces, colored by net (consistent with NetLabelPlacementSolver) - for (const trace of this.outputTraces) { - const line: Line = { - points: trace.tracePath.map((p) => ({ x: p.x, y: p.y })), - strokeColor: getColorFromString( - trace.globalConnNetId, - this.TRACE_OPACITY, - ), - strokeWidth: this.TRACE_STROKE_WIDTH, - } - graphics.lines!.push(line) - } - - // Highlight mergeable segments in red to show which segments will be merged. - // The stroke width is derived from GAP_THRESHOLD to visually represent the - // merge zone: if two segments are within this distance, they'll be merged. - // This helps with debugging: you can see exactly what the algorithm considers mergeable. - // Use cached merge groups from the solve step if available to avoid redundant computation - const mergeGroups = this.mergeGroupsCache ?? [] - - for (const group of mergeGroups) { - for (const seg of group) { - graphics.lines!.push({ - points: [seg.points[0], seg.points[1]], - strokeColor: "red", - strokeWidth: this.HIGHLIGHT_STROKE_WIDTH, - }) - } - } - - return graphics + } } + + return graphics + } } From a91b04d6910a6e12e1804282353b97a67c74427d Mon Sep 17 00:00:00 2001 From: gustavo keiller Date: Mon, 8 Dec 2025 21:16:10 -0300 Subject: [PATCH 4/6] before snapshot tests --- ...TraceMergeSolver01_same_net_merge.snap.svg | 106 ++++++++++++++++ ...eSolver02_different_nets_no_merge.snap.svg | 116 ++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 tests/solvers/SameNetTraceMergeSolver/__snapshots__/SameNetTraceMergeSolver01_same_net_merge.snap.svg create mode 100644 tests/solvers/SameNetTraceMergeSolver/__snapshots__/SameNetTraceMergeSolver02_different_nets_no_merge.snap.svg diff --git a/tests/solvers/SameNetTraceMergeSolver/__snapshots__/SameNetTraceMergeSolver01_same_net_merge.snap.svg b/tests/solvers/SameNetTraceMergeSolver/__snapshots__/SameNetTraceMergeSolver01_same_net_merge.snap.svg new file mode 100644 index 0000000..3fa2c6a --- /dev/null +++ b/tests/solvers/SameNetTraceMergeSolver/__snapshots__/SameNetTraceMergeSolver01_same_net_merge.snap.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/solvers/SameNetTraceMergeSolver/__snapshots__/SameNetTraceMergeSolver02_different_nets_no_merge.snap.svg b/tests/solvers/SameNetTraceMergeSolver/__snapshots__/SameNetTraceMergeSolver02_different_nets_no_merge.snap.svg new file mode 100644 index 0000000..90cee79 --- /dev/null +++ b/tests/solvers/SameNetTraceMergeSolver/__snapshots__/SameNetTraceMergeSolver02_different_nets_no_merge.snap.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 7df6d2bd6a81eef800ff1a95d45788a0f750402c Mon Sep 17 00:00:00 2001 From: gustavo keiller Date: Mon, 8 Dec 2025 21:20:55 -0300 Subject: [PATCH 5/6] after snapshot tests and formatting --- ...tTraceMergeSolver01_same_net_merge.test.ts | 181 ++++++------ ...geSolver02_different_nets_no_merge.test.ts | 264 +++++++++--------- ...TraceMergeSolver01_same_net_merge.snap.svg | 2 +- 3 files changed, 225 insertions(+), 222 deletions(-) diff --git a/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver01_same_net_merge.test.ts b/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver01_same_net_merge.test.ts index 317e04b..680f28c 100644 --- a/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver01_same_net_merge.test.ts +++ b/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver01_same_net_merge.test.ts @@ -1,116 +1,117 @@ import { expect, test } from "bun:test" import type { InputProblem } from "lib/types/InputProblem" import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver" - +import "tests/fixtures/matcher" const inputProblem: InputProblem = { - chips: [ - // JP6 - The chip on the left - { - chipId: "JP6", - center: { x: -4, y: 0 }, - width: 2, - height: 1.5, - pins: [ + chips: [ + // JP6 - The chip on the left + { + chipId: "JP6", + center: { x: -4, y: 0 }, + width: 2, + height: 1.5, + pins: [ + { + pinId: "JP6.2", // Top pin (VOUT) + x: -3, + y: 0.2, + _facingDirection: "x+", + }, + { + pinId: "JP6.1", // Bottom pin (GND) + x: -3, + y: -0.2, + _facingDirection: "x+", + }, + ], + }, + // R1 - The resistor on the right { - pinId: "JP6.2", // Top pin (VOUT) - x: -3, - y: 0.2, - _facingDirection: "x+", + chipId: "R1", + center: { x: 3, y: 0.575 }, + width: 0.6, + height: 1.2, + pins: [ + { + pinId: "R1.1", // Top pin + x: 3, + y: 1.175, + _facingDirection: "y+", + }, + { + pinId: "R1.2", // Bottom pin + x: 3, + y: -0.025, + _facingDirection: "y-", + }, + ], }, + ], + // Two directConnections on the same net (implicitly) plus a self-connection + directConnections: [ { - pinId: "JP6.1", // Bottom pin (GND) - x: -3, - y: -0.2, - _facingDirection: "x+", + // Top trace: JP6 Top -> R1 Top + pinIds: ["JP6.2", "R1.1"], }, - ], - }, - // R1 - The resistor on the right - { - chipId: "R1", - center: { x: 3, y: 0.575 }, - width: 0.6, - height: 1.2, - pins: [ { - pinId: "R1.1", // Top pin - x: 3, - y: 1.175, - _facingDirection: "y+", + // Bottom trace: JP6 Bottom -> R1 Bottom + pinIds: ["JP6.1", "R1.2"], }, { - pinId: "R1.2", // Bottom pin - x: 3, - y: -0.025, - _facingDirection: "y-", + // Resistor self-connection (Short) + pinIds: ["R1.1", "R1.2"], }, - ], - }, - ], - // Two directConnections on the same net (implicitly) plus a self-connection - directConnections: [ - { - // Top trace: JP6 Top -> R1 Top - pinIds: ["JP6.2", "R1.1"], - }, - { - // Bottom trace: JP6 Bottom -> R1 Bottom - pinIds: ["JP6.1", "R1.2"], - }, - { - // Resistor self-connection (Short) - pinIds: ["R1.1", "R1.2"], - }, - ], - netConnections: [], - availableNetLabelOrientations: {}, - // Allow long traces to connect these components - maxMspPairDistance: 100, + ], + netConnections: [], + availableNetLabelOrientations: {}, + // Allow long traces to connect these components + maxMspPairDistance: 100, } test("SameNetTraceMergeSolver01: merge same-net parallel traces", () => { - const solver = new SchematicTracePipelineSolver(inputProblem) - solver.solve() + const solver = new SchematicTracePipelineSolver(inputProblem) + solver.solve() - const beforeTraces = solver.traceCleanupSolver?.getOutput().traces ?? [] - const afterTraces = solver.sameNetTraceMergeSolver?.getOutput().traces ?? [] + expect(solver).toMatchSolverSnapshot(import.meta.path) + const beforeTraces = solver.traceCleanupSolver?.getOutput().traces ?? [] + const afterTraces = solver.sameNetTraceMergeSolver?.getOutput().traces ?? [] - // Verify solver completed - expect(solver.solved).toBe(true) - expect(solver.sameNetTraceMergeSolver?.solved).toBe(true) + // Verify solver completed + expect(solver.solved).toBe(true) + expect(solver.sameNetTraceMergeSolver?.solved).toBe(true) - // Both should have the same number of traces (we don't collapse traces, just align segments) - expect(afterTraces.length).toBeGreaterThan(0) - expect(afterTraces.length).toBe(beforeTraces.length) + // Both should have the same number of traces (we don't collapse traces, just align segments) + expect(afterTraces.length).toBeGreaterThan(0) + expect(afterTraces.length).toBe(beforeTraces.length) - // Check that some horizontal or vertical segments moved (merged to same coordinate) - let movedSegments = 0 - const EPS = 1e-6 + // Check that some horizontal or vertical segments moved (merged to same coordinate) + let movedSegments = 0 + const EPS = 1e-6 - const beforeMap = new Map( - beforeTraces.map((t: any) => [t.mspPairId, t]), - ) - const afterMap = new Map( - afterTraces.map((t: any) => [t.mspPairId, t]), - ) + const beforeMap = new Map( + beforeTraces.map((t: any) => [t.mspPairId, t]), + ) + const afterMap = new Map( + afterTraces.map((t: any) => [t.mspPairId, t]), + ) - for (const [id, beforeTrace] of beforeMap.entries()) { - const afterTrace = afterMap.get(id) - if (!afterTrace) continue + for (const [id, beforeTrace] of beforeMap.entries()) { + const afterTrace = afterMap.get(id) + if (!afterTrace) continue - const beforePath = beforeTrace.tracePath - const afterPath = afterTrace.tracePath - const len = Math.min(beforePath.length, afterPath.length) + const beforePath = beforeTrace.tracePath + const afterPath = afterTrace.tracePath + const len = Math.min(beforePath.length, afterPath.length) - for (let i = 0; i < len; i++) { - const p1 = beforePath[i] - const p2 = afterPath[i] - if (Math.abs(p1.x - p2.x) > EPS || Math.abs(p1.y - p2.y) > EPS) { - movedSegments++ - } + for (let i = 0; i < len; i++) { + const p1 = beforePath[i] + const p2 = afterPath[i] + if (Math.abs(p1.x - p2.x) > EPS || Math.abs(p1.y - p2.y) > EPS) { + movedSegments++ + } + } } - } - // We expect at least some segments to have moved (merged to common coordinates) - expect(movedSegments).toBeGreaterThan(0) + // We expect at least some segments to have moved (merged to common coordinates) + expect(movedSegments).toBeGreaterThan(0) }) diff --git a/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver02_different_nets_no_merge.test.ts b/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver02_different_nets_no_merge.test.ts index 190e78a..06cfb30 100644 --- a/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver02_different_nets_no_merge.test.ts +++ b/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver02_different_nets_no_merge.test.ts @@ -1,164 +1,166 @@ import { expect, test } from "bun:test" import type { InputProblem } from "lib/types/InputProblem" import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver" +import "tests/fixtures/matcher" const inputProblem: InputProblem = { - chips: [ - // JP6 - The chip on the left - { - chipId: "JP6", - center: { x: -4, y: 0 }, - width: 2, - height: 1.5, - pins: [ + chips: [ + // JP6 - The chip on the left { - pinId: "JP6.2", // Top pin (VOUT) - x: -3, - y: 0.2, - _facingDirection: "x+", + chipId: "JP6", + center: { x: -4, y: 0 }, + width: 2, + height: 1.5, + pins: [ + { + pinId: "JP6.2", // Top pin (VOUT) + x: -3, + y: 0.2, + _facingDirection: "x+", + }, + { + pinId: "JP6.1", // Bottom pin (GND) + x: -3, + y: -0.2, + _facingDirection: "x+", + }, + ], }, + // R1 - The resistor on the right { - pinId: "JP6.1", // Bottom pin (GND) - x: -3, - y: -0.2, - _facingDirection: "x+", + chipId: "R1", + center: { x: 3, y: 0.575 }, + width: 0.6, + height: 1.2, + pins: [ + { + pinId: "R1.1", // Top pin + x: 3, + y: 1.175, + _facingDirection: "y+", + }, + { + pinId: "R1.2", // Bottom pin + x: 3, + y: -0.025, + _facingDirection: "y-", + }, + { + pinId: "R1.3", // Third pin on different net + x: 3.5, + y: -0.025, + _facingDirection: "y-", + }, + ], }, - ], - }, - // R1 - The resistor on the right - { - chipId: "R1", - center: { x: 3, y: 0.575 }, - width: 0.6, - height: 1.2, - pins: [ + ], + // Two traces connected to different pins (different nets): + // JP6.2 -> R1.3 (net A) + // JP6.1 -> R1.3 (net B) + // R1.1 <-> R1.2 (net C, self-connection) + // Even though traces may be close, they belong to different nets, so NO merge should occur. + directConnections: [ { - pinId: "R1.1", // Top pin - x: 3, - y: 1.175, - _facingDirection: "y+", + // Top trace: JP6 Top -> R1 Third (different net) + pinIds: ["JP6.2", "R1.3"], }, { - pinId: "R1.2", // Bottom pin - x: 3, - y: -0.025, - _facingDirection: "y-", + // Bottom trace: JP6 Bottom -> R1 Third (same pin, different net) + pinIds: ["JP6.1", "R1.3"], }, { - pinId: "R1.3", // Third pin on different net - x: 3.5, - y: -0.025, - _facingDirection: "y-", + // Resistor self-connection (Short, third net) + pinIds: ["R1.1", "R1.2"], }, - ], - }, - ], - // Two traces connected to different pins (different nets): - // JP6.2 -> R1.3 (net A) - // JP6.1 -> R1.3 (net B) - // R1.1 <-> R1.2 (net C, self-connection) - // Even though traces may be close, they belong to different nets, so NO merge should occur. - directConnections: [ - { - // Top trace: JP6 Top -> R1 Third (different net) - pinIds: ["JP6.2", "R1.3"], - }, - { - // Bottom trace: JP6 Bottom -> R1 Third (same pin, different net) - pinIds: ["JP6.1", "R1.3"], - }, - { - // Resistor self-connection (Short, third net) - pinIds: ["R1.1", "R1.2"], - }, - ], - netConnections: [], - availableNetLabelOrientations: {}, - // Allow long traces to connect these components - maxMspPairDistance: 100, + ], + netConnections: [], + availableNetLabelOrientations: {}, + // Allow long traces to connect these components + maxMspPairDistance: 100, } test("SameNetTraceMergeSolver02: do NOT merge different-net traces even if close", () => { - const solver = new SchematicTracePipelineSolver(inputProblem) - solver.solve() + const solver = new SchematicTracePipelineSolver(inputProblem) + solver.solve() - const beforeTraces = solver.traceCleanupSolver?.getOutput().traces ?? [] - const afterTraces = solver.sameNetTraceMergeSolver?.getOutput().traces ?? [] + expect(solver).toMatchSolverSnapshot(import.meta.path) + const beforeTraces = solver.traceCleanupSolver?.getOutput().traces ?? [] + const afterTraces = solver.sameNetTraceMergeSolver?.getOutput().traces ?? [] - // Verify solver completed - expect(solver.solved).toBe(true) - expect(solver.sameNetTraceMergeSolver?.solved).toBe(true) + // Verify solver completed + expect(solver.solved).toBe(true) + expect(solver.sameNetTraceMergeSolver?.solved).toBe(true) - // Both should have the same number of traces - expect(afterTraces.length).toBe(beforeTraces.length) + // Both should have the same number of traces + expect(afterTraces.length).toBe(beforeTraces.length) - // Group traces by net to verify they don't cross-merge - const beforeByNet = new Map() - const afterByNet = new Map() + // Group traces by net to verify they don't cross-merge + const beforeByNet = new Map() + const afterByNet = new Map() - for (const trace of beforeTraces) { - const net = trace.globalConnNetId - if (!beforeByNet.has(net)) beforeByNet.set(net, []) - beforeByNet.get(net)!.push(trace) - } + for (const trace of beforeTraces) { + const net = trace.globalConnNetId + if (!beforeByNet.has(net)) beforeByNet.set(net, []) + beforeByNet.get(net)!.push(trace) + } - for (const trace of afterTraces) { - const net = trace.globalConnNetId - if (!afterByNet.has(net)) afterByNet.set(net, []) - afterByNet.get(net)!.push(trace) - } + for (const trace of afterTraces) { + const net = trace.globalConnNetId + if (!afterByNet.has(net)) afterByNet.set(net, []) + afterByNet.get(net)!.push(trace) + } - // Verify that different nets were NOT merged into each other - // This is implicit if traces remain separate and don't share coordinates across nets - const EPS = 1e-6 - const nets = Array.from(beforeByNet.keys()) + // Verify that different nets were NOT merged into each other + // This is implicit if traces remain separate and don't share coordinates across nets + const EPS = 1e-6 + const nets = Array.from(beforeByNet.keys()) - for (let i = 0; i < nets.length; i++) { - for (let j = i + 1; j < nets.length; j++) { - const netA = nets[i]! - const netB = nets[j]! - const tracesA = afterByNet.get(netA) ?? [] - const tracesB = afterByNet.get(netB) ?? [] + for (let i = 0; i < nets.length; i++) { + for (let j = i + 1; j < nets.length; j++) { + const netA = nets[i]! + const netB = nets[j]! + const tracesA = afterByNet.get(netA) ?? [] + const tracesB = afterByNet.get(netB) ?? [] - // Extract all segment coordinates for each net - const coordsA = new Set() - const coordsB = new Set() + // Extract all segment coordinates for each net + const coordsA = new Set() + const coordsB = new Set() - for (const t of tracesA) { - for (let k = 0; k < t.tracePath.length - 1; k++) { - const p1 = t.tracePath[k] - const p2 = t.tracePath[k + 1] - if (Math.abs(p1.x - p2.x) < EPS) { - // Vertical segment - coordsA.add(`v:${p1.x.toFixed(6)}`) - } else { - // Horizontal segment - coordsA.add(`h:${p1.y.toFixed(6)}`) - } - } - } + for (const t of tracesA) { + for (let k = 0; k < t.tracePath.length - 1; k++) { + const p1 = t.tracePath[k] + const p2 = t.tracePath[k + 1] + if (Math.abs(p1.x - p2.x) < EPS) { + // Vertical segment + coordsA.add(`v:${p1.x.toFixed(6)}`) + } else { + // Horizontal segment + coordsA.add(`h:${p1.y.toFixed(6)}`) + } + } + } - for (const t of tracesB) { - for (let k = 0; k < t.tracePath.length - 1; k++) { - const p1 = t.tracePath[k] - const p2 = t.tracePath[k + 1] - if (Math.abs(p1.x - p2.x) < EPS) { - // Vertical segment - coordsB.add(`v:${p1.x.toFixed(6)}`) - } else { - // Horizontal segment - coordsB.add(`h:${p1.y.toFixed(6)}`) - } - } - } + for (const t of tracesB) { + for (let k = 0; k < t.tracePath.length - 1; k++) { + const p1 = t.tracePath[k] + const p2 = t.tracePath[k + 1] + if (Math.abs(p1.x - p2.x) < EPS) { + // Vertical segment + coordsB.add(`v:${p1.x.toFixed(6)}`) + } else { + // Horizontal segment + coordsB.add(`h:${p1.y.toFixed(6)}`) + } + } + } - // Different nets should not share segment coordinates (they shouldn't merge) - // This is a loose check; the stronger guarantee is that each net's traces stay internal - // For now, we just verify that the solver completed without error + // Different nets should not share segment coordinates (they shouldn't merge) + // This is a loose check; the stronger guarantee is that each net's traces stay internal + // For now, we just verify that the solver completed without error + } } - } - // The key assertion: solver doesn't crash and completes - // (different nets are not forcibly merged because the solver checks netId) - expect(solver.sameNetTraceMergeSolver?.iterations).toBeLessThanOrEqual(1) + // The key assertion: solver doesn't crash and completes + // (different nets are not forcibly merged because the solver checks netId) + expect(solver.sameNetTraceMergeSolver?.iterations).toBeLessThanOrEqual(1) }) diff --git a/tests/solvers/SameNetTraceMergeSolver/__snapshots__/SameNetTraceMergeSolver01_same_net_merge.snap.svg b/tests/solvers/SameNetTraceMergeSolver/__snapshots__/SameNetTraceMergeSolver01_same_net_merge.snap.svg index 3fa2c6a..b0475d6 100644 --- a/tests/solvers/SameNetTraceMergeSolver/__snapshots__/SameNetTraceMergeSolver01_same_net_merge.snap.svg +++ b/tests/solvers/SameNetTraceMergeSolver/__snapshots__/SameNetTraceMergeSolver01_same_net_merge.snap.svg @@ -35,7 +35,7 @@ y-" data-x="3" data-y="-0.025000000000000133" cx="570.1775147928995" cy="342.366 - + From f6ef5f5d12e883a4baf66cda2cf1118e4e1fe9bc Mon Sep 17 00:00:00 2001 From: gustavo keiller Date: Mon, 8 Dec 2025 22:47:55 -0300 Subject: [PATCH 6/6] fix file formating --- ...tTraceMergeSolver01_same_net_merge.test.ts | 180 ++++++------ ...geSolver02_different_nets_no_merge.test.ts | 264 +++++++++--------- 2 files changed, 222 insertions(+), 222 deletions(-) diff --git a/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver01_same_net_merge.test.ts b/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver01_same_net_merge.test.ts index 680f28c..88ce6ee 100644 --- a/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver01_same_net_merge.test.ts +++ b/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver01_same_net_merge.test.ts @@ -3,115 +3,115 @@ import type { InputProblem } from "lib/types/InputProblem" import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver" import "tests/fixtures/matcher" const inputProblem: InputProblem = { - chips: [ - // JP6 - The chip on the left + chips: [ + // JP6 - The chip on the left + { + chipId: "JP6", + center: { x: -4, y: 0 }, + width: 2, + height: 1.5, + pins: [ { - chipId: "JP6", - center: { x: -4, y: 0 }, - width: 2, - height: 1.5, - pins: [ - { - pinId: "JP6.2", // Top pin (VOUT) - x: -3, - y: 0.2, - _facingDirection: "x+", - }, - { - pinId: "JP6.1", // Bottom pin (GND) - x: -3, - y: -0.2, - _facingDirection: "x+", - }, - ], + pinId: "JP6.2", // Top pin (VOUT) + x: -3, + y: 0.2, + _facingDirection: "x+", }, - // R1 - The resistor on the right { - chipId: "R1", - center: { x: 3, y: 0.575 }, - width: 0.6, - height: 1.2, - pins: [ - { - pinId: "R1.1", // Top pin - x: 3, - y: 1.175, - _facingDirection: "y+", - }, - { - pinId: "R1.2", // Bottom pin - x: 3, - y: -0.025, - _facingDirection: "y-", - }, - ], + pinId: "JP6.1", // Bottom pin (GND) + x: -3, + y: -0.2, + _facingDirection: "x+", }, - ], - // Two directConnections on the same net (implicitly) plus a self-connection - directConnections: [ + ], + }, + // R1 - The resistor on the right + { + chipId: "R1", + center: { x: 3, y: 0.575 }, + width: 0.6, + height: 1.2, + pins: [ { - // Top trace: JP6 Top -> R1 Top - pinIds: ["JP6.2", "R1.1"], + pinId: "R1.1", // Top pin + x: 3, + y: 1.175, + _facingDirection: "y+", }, { - // Bottom trace: JP6 Bottom -> R1 Bottom - pinIds: ["JP6.1", "R1.2"], + pinId: "R1.2", // Bottom pin + x: 3, + y: -0.025, + _facingDirection: "y-", }, - { - // Resistor self-connection (Short) - pinIds: ["R1.1", "R1.2"], - }, - ], - netConnections: [], - availableNetLabelOrientations: {}, - // Allow long traces to connect these components - maxMspPairDistance: 100, + ], + }, + ], + // Two directConnections on the same net (implicitly) plus a self-connection + directConnections: [ + { + // Top trace: JP6 Top -> R1 Top + pinIds: ["JP6.2", "R1.1"], + }, + { + // Bottom trace: JP6 Bottom -> R1 Bottom + pinIds: ["JP6.1", "R1.2"], + }, + { + // Resistor self-connection (Short) + pinIds: ["R1.1", "R1.2"], + }, + ], + netConnections: [], + availableNetLabelOrientations: {}, + // Allow long traces to connect these components + maxMspPairDistance: 100, } test("SameNetTraceMergeSolver01: merge same-net parallel traces", () => { - const solver = new SchematicTracePipelineSolver(inputProblem) - solver.solve() + const solver = new SchematicTracePipelineSolver(inputProblem) + solver.solve() - expect(solver).toMatchSolverSnapshot(import.meta.path) - const beforeTraces = solver.traceCleanupSolver?.getOutput().traces ?? [] - const afterTraces = solver.sameNetTraceMergeSolver?.getOutput().traces ?? [] + expect(solver).toMatchSolverSnapshot(import.meta.path) + const beforeTraces = solver.traceCleanupSolver?.getOutput().traces ?? [] + const afterTraces = solver.sameNetTraceMergeSolver?.getOutput().traces ?? [] - // Verify solver completed - expect(solver.solved).toBe(true) - expect(solver.sameNetTraceMergeSolver?.solved).toBe(true) + // Verify solver completed + expect(solver.solved).toBe(true) + expect(solver.sameNetTraceMergeSolver?.solved).toBe(true) - // Both should have the same number of traces (we don't collapse traces, just align segments) - expect(afterTraces.length).toBeGreaterThan(0) - expect(afterTraces.length).toBe(beforeTraces.length) + // Both should have the same number of traces (we don't collapse traces, just align segments) + expect(afterTraces.length).toBeGreaterThan(0) + expect(afterTraces.length).toBe(beforeTraces.length) - // Check that some horizontal or vertical segments moved (merged to same coordinate) - let movedSegments = 0 - const EPS = 1e-6 + // Check that some horizontal or vertical segments moved (merged to same coordinate) + let movedSegments = 0 + const EPS = 1e-6 - const beforeMap = new Map( - beforeTraces.map((t: any) => [t.mspPairId, t]), - ) - const afterMap = new Map( - afterTraces.map((t: any) => [t.mspPairId, t]), - ) + const beforeMap = new Map( + beforeTraces.map((t: any) => [t.mspPairId, t]), + ) + const afterMap = new Map( + afterTraces.map((t: any) => [t.mspPairId, t]), + ) - for (const [id, beforeTrace] of beforeMap.entries()) { - const afterTrace = afterMap.get(id) - if (!afterTrace) continue + for (const [id, beforeTrace] of beforeMap.entries()) { + const afterTrace = afterMap.get(id) + if (!afterTrace) continue - const beforePath = beforeTrace.tracePath - const afterPath = afterTrace.tracePath - const len = Math.min(beforePath.length, afterPath.length) + const beforePath = beforeTrace.tracePath + const afterPath = afterTrace.tracePath + const len = Math.min(beforePath.length, afterPath.length) - for (let i = 0; i < len; i++) { - const p1 = beforePath[i] - const p2 = afterPath[i] - if (Math.abs(p1.x - p2.x) > EPS || Math.abs(p1.y - p2.y) > EPS) { - movedSegments++ - } - } + for (let i = 0; i < len; i++) { + const p1 = beforePath[i] + const p2 = afterPath[i] + if (Math.abs(p1.x - p2.x) > EPS || Math.abs(p1.y - p2.y) > EPS) { + movedSegments++ + } } + } - // We expect at least some segments to have moved (merged to common coordinates) - expect(movedSegments).toBeGreaterThan(0) + // We expect at least some segments to have moved (merged to common coordinates) + expect(movedSegments).toBeGreaterThan(0) }) diff --git a/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver02_different_nets_no_merge.test.ts b/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver02_different_nets_no_merge.test.ts index 06cfb30..638e14d 100644 --- a/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver02_different_nets_no_merge.test.ts +++ b/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver02_different_nets_no_merge.test.ts @@ -4,163 +4,163 @@ import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipeline import "tests/fixtures/matcher" const inputProblem: InputProblem = { - chips: [ - // JP6 - The chip on the left + chips: [ + // JP6 - The chip on the left + { + chipId: "JP6", + center: { x: -4, y: 0 }, + width: 2, + height: 1.5, + pins: [ { - chipId: "JP6", - center: { x: -4, y: 0 }, - width: 2, - height: 1.5, - pins: [ - { - pinId: "JP6.2", // Top pin (VOUT) - x: -3, - y: 0.2, - _facingDirection: "x+", - }, - { - pinId: "JP6.1", // Bottom pin (GND) - x: -3, - y: -0.2, - _facingDirection: "x+", - }, - ], + pinId: "JP6.2", // Top pin (VOUT) + x: -3, + y: 0.2, + _facingDirection: "x+", }, - // R1 - The resistor on the right { - chipId: "R1", - center: { x: 3, y: 0.575 }, - width: 0.6, - height: 1.2, - pins: [ - { - pinId: "R1.1", // Top pin - x: 3, - y: 1.175, - _facingDirection: "y+", - }, - { - pinId: "R1.2", // Bottom pin - x: 3, - y: -0.025, - _facingDirection: "y-", - }, - { - pinId: "R1.3", // Third pin on different net - x: 3.5, - y: -0.025, - _facingDirection: "y-", - }, - ], + pinId: "JP6.1", // Bottom pin (GND) + x: -3, + y: -0.2, + _facingDirection: "x+", }, - ], - // Two traces connected to different pins (different nets): - // JP6.2 -> R1.3 (net A) - // JP6.1 -> R1.3 (net B) - // R1.1 <-> R1.2 (net C, self-connection) - // Even though traces may be close, they belong to different nets, so NO merge should occur. - directConnections: [ + ], + }, + // R1 - The resistor on the right + { + chipId: "R1", + center: { x: 3, y: 0.575 }, + width: 0.6, + height: 1.2, + pins: [ { - // Top trace: JP6 Top -> R1 Third (different net) - pinIds: ["JP6.2", "R1.3"], + pinId: "R1.1", // Top pin + x: 3, + y: 1.175, + _facingDirection: "y+", }, { - // Bottom trace: JP6 Bottom -> R1 Third (same pin, different net) - pinIds: ["JP6.1", "R1.3"], + pinId: "R1.2", // Bottom pin + x: 3, + y: -0.025, + _facingDirection: "y-", }, { - // Resistor self-connection (Short, third net) - pinIds: ["R1.1", "R1.2"], + pinId: "R1.3", // Third pin on different net + x: 3.5, + y: -0.025, + _facingDirection: "y-", }, - ], - netConnections: [], - availableNetLabelOrientations: {}, - // Allow long traces to connect these components - maxMspPairDistance: 100, + ], + }, + ], + // Two traces connected to different pins (different nets): + // JP6.2 -> R1.3 (net A) + // JP6.1 -> R1.3 (net B) + // R1.1 <-> R1.2 (net C, self-connection) + // Even though traces may be close, they belong to different nets, so NO merge should occur. + directConnections: [ + { + // Top trace: JP6 Top -> R1 Third (different net) + pinIds: ["JP6.2", "R1.3"], + }, + { + // Bottom trace: JP6 Bottom -> R1 Third (same pin, different net) + pinIds: ["JP6.1", "R1.3"], + }, + { + // Resistor self-connection (Short, third net) + pinIds: ["R1.1", "R1.2"], + }, + ], + netConnections: [], + availableNetLabelOrientations: {}, + // Allow long traces to connect these components + maxMspPairDistance: 100, } test("SameNetTraceMergeSolver02: do NOT merge different-net traces even if close", () => { - const solver = new SchematicTracePipelineSolver(inputProblem) - solver.solve() + const solver = new SchematicTracePipelineSolver(inputProblem) + solver.solve() - expect(solver).toMatchSolverSnapshot(import.meta.path) - const beforeTraces = solver.traceCleanupSolver?.getOutput().traces ?? [] - const afterTraces = solver.sameNetTraceMergeSolver?.getOutput().traces ?? [] + expect(solver).toMatchSolverSnapshot(import.meta.path) + const beforeTraces = solver.traceCleanupSolver?.getOutput().traces ?? [] + const afterTraces = solver.sameNetTraceMergeSolver?.getOutput().traces ?? [] - // Verify solver completed - expect(solver.solved).toBe(true) - expect(solver.sameNetTraceMergeSolver?.solved).toBe(true) + // Verify solver completed + expect(solver.solved).toBe(true) + expect(solver.sameNetTraceMergeSolver?.solved).toBe(true) - // Both should have the same number of traces - expect(afterTraces.length).toBe(beforeTraces.length) + // Both should have the same number of traces + expect(afterTraces.length).toBe(beforeTraces.length) - // Group traces by net to verify they don't cross-merge - const beforeByNet = new Map() - const afterByNet = new Map() + // Group traces by net to verify they don't cross-merge + const beforeByNet = new Map() + const afterByNet = new Map() - for (const trace of beforeTraces) { - const net = trace.globalConnNetId - if (!beforeByNet.has(net)) beforeByNet.set(net, []) - beforeByNet.get(net)!.push(trace) - } - - for (const trace of afterTraces) { - const net = trace.globalConnNetId - if (!afterByNet.has(net)) afterByNet.set(net, []) - afterByNet.get(net)!.push(trace) - } + for (const trace of beforeTraces) { + const net = trace.globalConnNetId + if (!beforeByNet.has(net)) beforeByNet.set(net, []) + beforeByNet.get(net)!.push(trace) + } - // Verify that different nets were NOT merged into each other - // This is implicit if traces remain separate and don't share coordinates across nets - const EPS = 1e-6 - const nets = Array.from(beforeByNet.keys()) + for (const trace of afterTraces) { + const net = trace.globalConnNetId + if (!afterByNet.has(net)) afterByNet.set(net, []) + afterByNet.get(net)!.push(trace) + } - for (let i = 0; i < nets.length; i++) { - for (let j = i + 1; j < nets.length; j++) { - const netA = nets[i]! - const netB = nets[j]! - const tracesA = afterByNet.get(netA) ?? [] - const tracesB = afterByNet.get(netB) ?? [] + // Verify that different nets were NOT merged into each other + // This is implicit if traces remain separate and don't share coordinates across nets + const EPS = 1e-6 + const nets = Array.from(beforeByNet.keys()) - // Extract all segment coordinates for each net - const coordsA = new Set() - const coordsB = new Set() + for (let i = 0; i < nets.length; i++) { + for (let j = i + 1; j < nets.length; j++) { + const netA = nets[i]! + const netB = nets[j]! + const tracesA = afterByNet.get(netA) ?? [] + const tracesB = afterByNet.get(netB) ?? [] - for (const t of tracesA) { - for (let k = 0; k < t.tracePath.length - 1; k++) { - const p1 = t.tracePath[k] - const p2 = t.tracePath[k + 1] - if (Math.abs(p1.x - p2.x) < EPS) { - // Vertical segment - coordsA.add(`v:${p1.x.toFixed(6)}`) - } else { - // Horizontal segment - coordsA.add(`h:${p1.y.toFixed(6)}`) - } - } - } + // Extract all segment coordinates for each net + const coordsA = new Set() + const coordsB = new Set() - for (const t of tracesB) { - for (let k = 0; k < t.tracePath.length - 1; k++) { - const p1 = t.tracePath[k] - const p2 = t.tracePath[k + 1] - if (Math.abs(p1.x - p2.x) < EPS) { - // Vertical segment - coordsB.add(`v:${p1.x.toFixed(6)}`) - } else { - // Horizontal segment - coordsB.add(`h:${p1.y.toFixed(6)}`) - } - } - } + for (const t of tracesA) { + for (let k = 0; k < t.tracePath.length - 1; k++) { + const p1 = t.tracePath[k] + const p2 = t.tracePath[k + 1] + if (Math.abs(p1.x - p2.x) < EPS) { + // Vertical segment + coordsA.add(`v:${p1.x.toFixed(6)}`) + } else { + // Horizontal segment + coordsA.add(`h:${p1.y.toFixed(6)}`) + } + } + } - // Different nets should not share segment coordinates (they shouldn't merge) - // This is a loose check; the stronger guarantee is that each net's traces stay internal - // For now, we just verify that the solver completed without error + for (const t of tracesB) { + for (let k = 0; k < t.tracePath.length - 1; k++) { + const p1 = t.tracePath[k] + const p2 = t.tracePath[k + 1] + if (Math.abs(p1.x - p2.x) < EPS) { + // Vertical segment + coordsB.add(`v:${p1.x.toFixed(6)}`) + } else { + // Horizontal segment + coordsB.add(`h:${p1.y.toFixed(6)}`) + } } + } + + // Different nets should not share segment coordinates (they shouldn't merge) + // This is a loose check; the stronger guarantee is that each net's traces stay internal + // For now, we just verify that the solver completed without error } + } - // The key assertion: solver doesn't crash and completes - // (different nets are not forcibly merged because the solver checks netId) - expect(solver.sameNetTraceMergeSolver?.iterations).toBeLessThanOrEqual(1) + // The key assertion: solver doesn't crash and completes + // (different nets are not forcibly merged because the solver checks netId) + expect(solver.sameNetTraceMergeSolver?.iterations).toBeLessThanOrEqual(1) })