From 133df500000335cc41981a83e66db2555366c011 Mon Sep 17 00:00:00 2001 From: Aslanchik2 Date: Thu, 9 Apr 2026 08:56:47 +0500 Subject: [PATCH] feat: implement SameNetTraceMergeSolver to eliminate redundant 'ladder lines' #78 --- .../SameNetTraceMergeSolver.ts | 320 ++++++++++++++++++ .../SchematicTracePipelineSolver.ts | 18 +- .../TraceCleanupSolver/TraceCleanupSolver.ts | 22 +- .../SameNetTraceMergeSolver.test.ts | 268 +++++++++++++++ ...hematicTracePipelineSolver_repro78.test.ts | 80 +++++ .../ExtraLinesDeduplication.test.ts | 113 +++++++ .../TraceCleanupSolver/TraceAlignment.test.ts | 84 +++++ .../assets/merging-segments.json | 46 +++ 8 files changed, 945 insertions(+), 6 deletions(-) create mode 100644 lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts create mode 100644 tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.test.ts create mode 100644 tests/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver_repro78.test.ts create mode 100644 tests/solvers/TraceCleanupSolver/ExtraLinesDeduplication.test.ts create mode 100644 tests/solvers/TraceCleanupSolver/TraceAlignment.test.ts create mode 100644 tests/solvers/TraceCleanupSolver/assets/merging-segments.json diff --git a/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts new file mode 100644 index 00000000..1e0cc169 --- /dev/null +++ b/lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.ts @@ -0,0 +1,320 @@ +import { BaseSolver } from "../BaseSolver/BaseSolver" +import type { SolvedTracePath } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import type { Point } from "@tscircuit/math-utils" + +export interface SameNetTraceMergeSolverParams { + traces: SolvedTracePath[] +} + +/** + * SameNetTraceMergeSolver is a post-processing step that cleans up schematic traces. + * It primarily addresses "ladder lines" (redundant parallel segments) and merges + * collinear segments of the same net that are touching or overlapping. + * + * Algorithm Overview: + * 1. Segments Decompositions: Breaks all Polylines into individual 2-point segments. + * 2. Grouping: Groups segments by net and then by collinearity (X for vertical, Y for horizontal, slope/intercept for diagonal). + * 3. Merging: Within each group, merges overlapping or touching segments into longer ones. + * 4. Graph Construction: Builds an adjacency list where points are nodes and merged segments are edges. + * 5. Path Reconstruction: Traverses the graph from endpoints to junctions or from isolated loops to reconstruct clean Polylines. + */ +export class SameNetTraceMergeSolver extends BaseSolver { + traces: SolvedTracePath[] + mergedTraces: SolvedTracePath[] = [] + + constructor(params: SameNetTraceMergeSolverParams) { + super() + this.traces = params.traces + } + + override _step() { + this.mergedTraces = this.mergeTraces(this.traces) + this.solved = true + } + + public getOutput() { + return { + traces: this.mergedTraces, + } + } + + /** + * Main entry point for merging. Groups traces by net and merges them independently. + */ + private mergeTraces(traces: SolvedTracePath[]): SolvedTracePath[] { + if (traces.length === 0) return [] + + const netGroups: Record = {} + for (const trace of traces) { + const netId = trace.globalConnNetId + if (!netGroups[netId]) { + netGroups[netId] = [] + } + netGroups[netId].push(trace) + } + + const allMergedTraces: SolvedTracePath[] = [] + + for (const netId in netGroups) { + const mergedForNet = this.mergeTracesForNet(netGroups[netId]) + allMergedTraces.push(...mergedForNet) + } + + return allMergedTraces + } + + /** + * Processes a single net by decomposing it into segments, merging them, and reconstructing paths. + */ + private mergeTracesForNet(netTraces: SolvedTracePath[]): SolvedTracePath[] { + if (netTraces.length === 0) return [] + + // 1. Extract all segments and normalize them (p1 < p2) + const segments: { p1: Point; p2: Point; trace: SolvedTracePath }[] = [] + for (const trace of netTraces) { + for (let i = 0; i < trace.tracePath.length - 1; i++) { + const p1 = trace.tracePath[i] + const p2 = trace.tracePath[i + 1] + const [sp1, sp2] = this.sortPoints(p1, p2) + segments.push({ p1: sp1, p2: sp2, trace }) + } + } + + // 2. Merge collinear and overlapping segments + const mergedSegments = this.mergeCollinearSegments(segments) + + // 3. Reconstruct paths from merged segments + return this.reconstructPaths(mergedSegments, netTraces[0].globalConnNetId) + } + + /** + * Sorts two points as [min, max] based on x, then y, to ensure stable segment representation. + */ + private sortPoints(p1: Point, p2: Point): [Point, Point] { + if (p1.x < p2.x || (p1.x === p2.x && p1.y < p2.y)) { + return [p1, p2] + } + return [p2, p1] + } + + /** + * Groups segments by collinearity and merges overlapping ones. + * This effectively eliminates "ladder lines" (redundant overlapping segments). + */ + private mergeCollinearSegments( + segments: { p1: Point; p2: Point; trace: SolvedTracePath }[] + ): { p1: Point; p2: Point; mspIds: Set; pinIds: Set }[] { + const horizontal = new Map() + const vertical = new Map() + const diag = new Map() + + const threshold = 0.001 + + for (const seg of segments) { + if (Math.abs(seg.p1.y - seg.p2.y) < threshold) { + // Horizontal: constant Y + const y = Math.round(seg.p1.y / threshold) * threshold + if (!horizontal.has(y)) horizontal.set(y, []) + horizontal.get(y)!.push(seg) + } else if (Math.abs(seg.p1.x - seg.p2.x) < threshold) { + // Vertical: constant X + const x = Math.round(seg.p1.x / threshold) * threshold + if (!vertical.has(x)) vertical.set(x, []) + vertical.get(x)!.push(seg) + } else { + // Diagonal: group by slope and intercept + const dx = seg.p2.x - seg.p1.x + const dy = seg.p2.y - seg.p1.y + const slope = dy / dx + const intercept = seg.p1.y - slope * seg.p1.x + // Precision handling for slope/intercept grouping + const key = `${slope.toFixed(4)},${intercept.toFixed(4)}` + if (!diag.has(key)) diag.set(key, []) + diag.get(key)!.push(seg) + } + } + + const result: { p1: Point; p2: Point; mspIds: Set; pinIds: Set }[] = [] + + const mergeInGroup = (group: typeof segments, axis: "x" | "y") => { + if (group.length === 0) return + // Sort segments along the variable axis + group.sort((a, b) => a.p1[axis] - b.p1[axis]) + + let current = { + p1: group[0].p1, + p2: group[0].p2, + mspIds: new Set(group[0].trace.mspConnectionPairIds), + pinIds: new Set(group[0].trace.pinIds), + } + + for (let i = 1; i < group.length; i++) { + const seg = group[i] + if (seg.p1[axis] <= current.p2[axis] + threshold) { + // Segments are touching or overlapping, extend the current one if needed + if (seg.p2[axis] > current.p2[axis]) { + current.p2 = seg.p2 + } + // Merge metadata from original connection pairs + for (const id of seg.trace.mspConnectionPairIds) current.mspIds.add(id) + for (const id of seg.trace.pinIds) current.pinIds.add(id) + } else { + result.push(current) + current = { + p1: seg.p1, + p2: seg.p2, + mspIds: new Set(seg.trace.mspConnectionPairIds), + pinIds: new Set(seg.trace.pinIds), + } + } + } + result.push(current) + } + + horizontal.forEach((g) => mergeInGroup(g, "x")) + vertical.forEach((g) => mergeInGroup(g, "y")) + diag.forEach((g) => mergeInGroup(g, "x")) // For diagonal, we can also use X as the progression axis + + return result + } + + /** + * Reconstructs Polylines from a set of merged segments using graph traversal. + * + * Strategy: + * 1. Endpoints (Leaves): Nodes with degree 1 are the primary start points for traces. + * 2. Junctions: Nodes with degree > 2 indicate forks; we start new paths from unvisited edges here. + * 3. Loops: Isolated cycles (degree 2 everywhere) are handled last. + */ + private reconstructPaths( + segments: { p1: Point; p2: Point; mspIds: Set; pinIds: Set }[], + netId: string + ): SolvedTracePath[] { + const threshold = 0.001 + const pointToKey = (p: Point) => + `${Math.round(p.x / threshold)},${Math.round(p.y / threshold)}` + + // Adjacency list: Map pointKey -> { point, edgeIndices } + const adj = new Map() + + for (let i = 0; i < segments.length; i++) { + const { p1, p2 } = segments[i] + const k1 = pointToKey(p1) + const k2 = pointToKey(p2) + + if (!adj.has(k1)) adj.set(k1, { point: p1, edges: [] }) + if (!adj.has(k2)) adj.set(k2, { point: p2, edges: [] }) + + adj.get(k1)!.edges.push(i) + adj.get(k2)!.edges.push(i) + } + + const visitedEdges = new Set() + const traces: SolvedTracePath[] = [] + + /** + * Traverses the graph starting from a node, following edges until a junction or leaf is reached. + */ + const buildPathFrom = (startKey: string) => { + const node = adj.get(startKey)! + for (const edgeIdx of node.edges) { + if (visitedEdges.has(edgeIdx)) continue + + const path: Point[] = [node.point] + const mspIds = new Set() + const pinIds = new Set() + + let currentKey = startKey + let currentEdgeIdx = edgeIdx + + while (currentEdgeIdx !== -1) { + visitedEdges.add(currentEdgeIdx) + const seg = segments[currentEdgeIdx] + for (const id of seg.mspIds) mspIds.add(id) + for (const id of seg.pinIds) pinIds.add(id) + + const k1 = pointToKey(seg.p1) + const k2 = pointToKey(seg.p2) + const nextKey = k1 === currentKey ? k2 : k1 + const nextNode = adj.get(nextKey)! + + path.push(nextNode.point) + + // If nextNode has exactly 2 edges, it's a simple wire; we can continue the same path. + // If it has 1 edge (leaf) or >2 edges (junction), we stop this path here. + if (nextNode.edges.length === 2) { + const nextEdgeIdx = nextNode.edges.find((e) => !visitedEdges.has(e)) + if (nextEdgeIdx !== undefined) { + currentKey = nextKey + currentEdgeIdx = nextEdgeIdx + continue + } + } + currentEdgeIdx = -1 + } + + traces.push({ + mspPairId: `merged_${netId}_${traces.length}`, + globalConnNetId: netId, + dcConnNetId: netId, + tracePath: this.simplifyCollinearPoints(path), + mspConnectionPairIds: Array.from(mspIds), + pinIds: Array.from(pinIds), + pins: [], + }) + } + } + + const points = Array.from(adj.keys()) + + // Priority 1: Start from leaf nodes to ensure branches are captured correctly. + for (const k of points) { + if (adj.get(k)!.edges.length === 1) buildPathFrom(k) + } + // Priority 2: Start from junctions to capture intermediate segments. + for (const k of points) { + if (adj.get(k)!.edges.length > 2) buildPathFrom(k) + } + // Priority 3: Handle isolated loops (nodes with degree 2 that haven't been visited). + for (const k of points) { + if (adj.get(k)!.edges.length === 2) buildPathFrom(k) + } + + return traces + } + + /** + * Eliminates redundant intermediate points in a path if they are collinear with their neighbors. + */ + private simplifyCollinearPoints(points: Point[]): Point[] { + if (points.length <= 2) return points + + const simplified: Point[] = [points[0]] + const threshold = 0.001 + + for (let i = 1; i < points.length - 1; i++) { + const prev = simplified[simplified.length - 1] + const curr = points[i] + const next = points[i + 1] + + const isCollinear = this.areCollinear(prev, curr, next, threshold) + if (!isCollinear) { + simplified.push(curr) + } + } + + simplified.push(points[points.length - 1]) + return simplified + } + + /** + * Checks if three points are collinear within a given threshold using triangle area. + */ + private areCollinear(p1: Point, p2: Point, p3: Point, threshold: number): boolean { + const area = Math.abs( + p1.x * (p2.y - p3.y) + p2.x * (p3.y - p1.y) + p3.x * (p1.y - p2.y) + ) + // Area < 2 * distance * threshold is roughly collinear. + return area < threshold * 2 + } +} diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index c9d5a995..be3e1539 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 @@ -188,9 +190,21 @@ export class SchematicTracePipelineSolver extends BaseSolver { ] }, ), + definePipelineStep( + "sameNetTraceMergeSolver", + SameNetTraceMergeSolver, + (instance) => { + const prevSolverOutput = + instance.traceLabelOverlapAvoidanceSolver!.getOutput() + return [ + { + traces: prevSolverOutput.traces, + }, + ] + }, + ), definePipelineStep("traceCleanupSolver", TraceCleanupSolver, (instance) => { - const prevSolverOutput = - instance.traceLabelOverlapAvoidanceSolver!.getOutput() + const prevSolverOutput = instance.sameNetTraceMergeSolver!.getOutput() const traces = prevSolverOutput.traces const labelMergingOutput = diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index e9bac7ca..759b295f 100644 --- a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts +++ b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts @@ -6,6 +6,7 @@ import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem" import type { NetLabelPlacement } from "../NetLabelPlacementSolver/NetLabelPlacementSolver" +import { SameNetTraceMergeSolver } from "../SameNetTraceMergeSolver/SameNetTraceMergeSolver" /** * Defines the input structure for the TraceCleanupSolver. @@ -26,6 +27,7 @@ import { is4PointRectangle } from "./is4PointRectangle" */ type PipelineStep = | "minimizing_turns" + | "merging_collinear_segments" | "balancing_l_shapes" | "untangling_traces" @@ -81,6 +83,9 @@ export class TraceCleanupSolver extends BaseSolver { case "minimizing_turns": this._runMinimizeTurnsStep() break + case "merging_collinear_segments": + this._runMergeCollinearSegmentsStep() + break case "balancing_l_shapes": this._runBalanceLShapesStep() break @@ -96,16 +101,25 @@ export class TraceCleanupSolver extends BaseSolver { private _runMinimizeTurnsStep() { if (this.traceIdQueue.length === 0) { - this.pipelineStep = "balancing_l_shapes" - this.traceIdQueue = Array.from( - this.input.allTraces.map((e) => e.mspPairId), - ) + this.pipelineStep = "merging_collinear_segments" return } this._processTrace("minimizing_turns") } + private _runMergeCollinearSegmentsStep() { + const mergeSolver = new SameNetTraceMergeSolver({ traces: this.outputTraces }) + mergeSolver.solve() + this.outputTraces = mergeSolver.getOutput().traces + + this.tracesMap = new Map(this.outputTraces.map((t) => [t.mspPairId, t])) + this.pipelineStep = "balancing_l_shapes" + this.traceIdQueue = Array.from( + this.input.allTraces.map((e) => e.mspPairId), + ) + } + private _runBalanceLShapesStep() { if (this.traceIdQueue.length === 0) { this.solved = true diff --git a/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.test.ts b/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.test.ts new file mode 100644 index 00000000..ecfeac1a --- /dev/null +++ b/tests/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver.test.ts @@ -0,0 +1,268 @@ +import { expect, test } from "bun:test" +import { SameNetTraceMergeSolver } from "lib/solvers/SameNetTraceMergeSolver/SameNetTraceMergeSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +test("SameNetTraceMergeSolver - merge touching segments", () => { + const traces: SolvedTracePath[] = [ + { + mspPairId: "pair1", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ], + mspConnectionPairIds: ["pair1"], + pinIds: ["pin1", "pin2"], + pins: [] as any, + }, + { + mspPairId: "pair2", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 10, y: 0 }, + { x: 10, y: 10 }, + ], + mspConnectionPairIds: ["pair2"], + pinIds: ["pin2", "pin3"], + pins: [] as any, + }, + ] + + const solver = new SameNetTraceMergeSolver({ traces }) + solver.solve() + const output = solver.getOutput() + + expect(output.traces).toHaveLength(1) + expect(output.traces[0].tracePath).toHaveLength(3) + expect(output.traces[0].tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + ]) +}) + +test("SameNetTraceMergeSolver - simplify collinear points", () => { + const traces: SolvedTracePath[] = [ + { + mspPairId: "pair1", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 0, y: 0 }, + { x: 5, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 5 }, + { x: 10, y: 10 }, + ], + mspConnectionPairIds: ["pair1"], + pinIds: ["pin1", "pin2"], + pins: [] as any, + }, + ] + + const solver = new SameNetTraceMergeSolver({ traces }) + solver.solve() + const output = solver.getOutput() + + expect(output.traces).toHaveLength(1) + expect(output.traces[0].tracePath).toHaveLength(3) + expect(output.traces[0].tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 10 }, + ]) +}) + +test("SameNetTraceMergeSolver - don't merge different nets", () => { + const traces: SolvedTracePath[] = [ + { + mspPairId: "pair1", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ], + mspConnectionPairIds: ["pair1"], + pinIds: ["pin1", "pin2"], + pins: [] as any, + }, + { + mspPairId: "pair2", + globalConnNetId: "net2", + dcConnNetId: "net2", + tracePath: [ + { x: 10, y: 0 }, + { x: 10, y: 10 }, + ], + mspConnectionPairIds: ["pair2"], + pinIds: ["pin2", "pin3"], + pins: [] as any, + }, + ] + + const solver = new SameNetTraceMergeSolver({ traces }) + solver.solve() + const output = solver.getOutput() + + expect(output.traces).toHaveLength(2) +}) + +test("SameNetTraceMergeSolver - merge partially overlapping segments", () => { + const traces: SolvedTracePath[] = [ + { + mspPairId: "pair1", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ], + mspConnectionPairIds: ["pair1"], + pinIds: ["pin1", "pin2"], + pins: [] as any, + }, + { + mspPairId: "pair2", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 5, y: 0 }, + { x: 15, y: 0 }, + ], + mspConnectionPairIds: ["pair2"], + pinIds: ["pin2", "pin3"], + pins: [] as any, + }, + ] + + const solver = new SameNetTraceMergeSolver({ traces }) + solver.solve() + const output = solver.getOutput() + + expect(output.traces).toHaveLength(1) + expect(output.traces[0].tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 15, y: 0 }, + ]) +}) + +test("SameNetTraceMergeSolver - handle T-junction", () => { + const traces: SolvedTracePath[] = [ + { + mspPairId: "main", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 0, y: 0 }, + { x: 20, y: 0 }, + ], + mspConnectionPairIds: ["main"], + pinIds: ["p1", "p2"], + pins: [] as any, + }, + { + mspPairId: "branch", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 10, y: 0 }, + { x: 10, y: 10 }, + ], + mspConnectionPairIds: ["branch"], + pinIds: ["p2", "p3"], + pins: [] as any, + }, + ] + + const solver = new SameNetTraceMergeSolver({ traces }) + solver.solve() + const output = solver.getOutput() + + // In a T-junction, we expect 2 traces meeting at {10, 0} + // One could be {0,0} to {10,0}, {10,0} to {20,0}, and {10,0} to {10,10} + // Our reconstructPaths splits at junctions, so we might get 3 traces if it splits the main line + // OR it might keep the main line and have the branch touch it. + // Current implementation: degree 3 node at {10,0}. + // Leaves are {0,0}, {20,0}, {10,10}. + // We'll get 3 paths from the junction: [10,0]->[0,0], [10,0]->[20,0], [10,0]->[10,10]. + expect(output.traces).toHaveLength(3) + + const allPoints = output.traces.map(t => t.tracePath) + expect(allPoints).toContainEqual([{ x: 10, y: 0 }, { x: 0, y: 0 }]) + expect(allPoints).toContainEqual([{ x: 10, y: 0 }, { x: 20, y: 0 }]) + expect(allPoints).toContainEqual([{ x: 10, y: 0 }, { x: 10, y: 10 }]) +}) + +test("SameNetTraceMergeSolver - remove redundant overlapping trace", () => { + const traces: SolvedTracePath[] = [ + { + mspPairId: "long", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [{ x: 0, y: 0 }, { x: 20, y: 0 }], + mspConnectionPairIds: ["long"], + pinIds: ["p1", "p2"], + pins: [] as any, + }, + { + mspPairId: "short", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [{ x: 5, y: 0 }, { x: 15, y: 0 }], + mspConnectionPairIds: ["short"], + pinIds: ["p3", "p4"], + pins: [] as any, + }, + ] + + const solver = new SameNetTraceMergeSolver({ traces }) + solver.solve() + const output = solver.getOutput() + + expect(output.traces).toHaveLength(1) + expect(output.traces[0].tracePath).toEqual([{ x: 0, y: 0 }, { x: 20, y: 0 }]) + expect(new Set(output.traces[0].mspConnectionPairIds)).toContain("long") + expect(new Set(output.traces[0].mspConnectionPairIds)).toContain("short") +}) + +test("SameNetTraceMergeSolver - merge diagonal segments", () => { + const traces: SolvedTracePath[] = [ + { + mspPairId: "diag1", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 0, y: 0 }, + { x: 10, y: 10 }, + ], + mspConnectionPairIds: ["diag1"], + pinIds: ["p1", "p2"], + pins: [] as any, + }, + { + mspPairId: "diag2", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 5, y: 5 }, + { x: 15, y: 15 }, + ], + mspConnectionPairIds: ["diag2"], + pinIds: ["p2", "p3"], + pins: [] as any, + }, + ] + + const solver = new SameNetTraceMergeSolver({ traces }) + solver.solve() + const output = solver.getOutput() + + expect(output.traces).toHaveLength(1) + expect(output.traces[0].tracePath).toEqual([ + { x: 0, y: 0 }, + { x: 15, y: 15 }, + ]) +}) diff --git a/tests/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver_repro78.test.ts b/tests/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver_repro78.test.ts new file mode 100644 index 00000000..03e98809 --- /dev/null +++ b/tests/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver_repro78.test.ts @@ -0,0 +1,80 @@ +import { test, expect } from "bun:test" +import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver" +import type { InputProblem } from "lib/types/InputProblem" + +test("SchematicTracePipelineSolver_repro78 - Fix ladder lines in DISCH fixture", () => { + // This fixture triggered "ladder lines" in the past + const inputProblem: InputProblem = { + chips: [ + { + chipId: "U1", + center: { x: 0, y: 0 }, + width: 10, + height: 10, + pins: [ + { pinId: "P1", x: -5, y: -2 }, + { pinId: "P2", x: -5, y: 2 }, + ], + }, + { + chipId: "U2", + center: { x: 30, y: 0 }, + width: 10, + height: 10, + pins: [ + { pinId: "P3", x: 25, y: -2 }, + { pinId: "P4", x: 25, y: 2 }, + ], + }, + ], + directConnections: [ + { pinIds: ["P1", "P3"], netId: "net1" }, + { pinIds: ["P2", "P4"], netId: "net1" }, + ], + netConnections: [ + { netId: "net1", pinIds: ["P1", "P3", "P2", "P4"] } + ], + availableNetLabelOrientations: {}, + maxMspPairDistance: 6, // Trigger larger search distance + } + + const solver = new SchematicTracePipelineSolver(inputProblem) + solver.solve() + + expect(solver.solved).toBe(true) + + // Get the traces from the final net label placement solver (which uses the merged traces) + // Or just from the last step. + const traces = solver.traceCleanupSolver!.getOutput().traces + + // Verify that there are no redundant parallel lines + // A "ladder" would manifest as multiple segments between same X/Y coords + // For net1, we should have a clean set of paths connecting all 4 pins. + + // Check for any overlapping segments + const segments: any[] = [] + for (const trace of traces) { + for (let i = 0; i < trace.tracePath.length - 1; i++) { + segments.push({ p1: trace.tracePath[i], p2: trace.tracePath[i+1] }) + } + } + + // Basic check: No two segments should be parallel and overlapping + for (let i = 0; i < segments.length; i++) { + for (let j = i + 1; j < segments.length; j++) { + const s1 = segments[i] + const s2 = segments[j] + + // If they are both horizontal and share same Y + if (Math.abs(s1.p1.y - s1.p2.y) < 0.001 && Math.abs(s2.p1.y - s2.p2.y) < 0.001 && Math.abs(s1.p1.y - s2.p1.y) < 0.001) { + const min1 = Math.min(s1.p1.x, s1.p2.x) + const max1 = Math.max(s1.p1.x, s1.p2.x) + const min2 = Math.min(s2.p1.x, s2.p2.x) + const max2 = Math.max(s2.p1.x, s2.p2.x) + + const overlap = Math.max(0, Math.min(max1, max2) - Math.max(min1, min2)) + expect(overlap).toBeLessThan(0.001) // Should not overlap + } + } + } +}) diff --git a/tests/solvers/TraceCleanupSolver/ExtraLinesDeduplication.test.ts b/tests/solvers/TraceCleanupSolver/ExtraLinesDeduplication.test.ts new file mode 100644 index 00000000..eac822dd --- /dev/null +++ b/tests/solvers/TraceCleanupSolver/ExtraLinesDeduplication.test.ts @@ -0,0 +1,113 @@ +import { test, expect } from "bun:test" +import { mergeSameNetSegments } from "lib/solvers/TraceCleanupSolver/mergeSameNetSegments" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +test("mergeSameNetSegments removes redundant subsets (Issue #78)", () => { + const traces: SolvedTracePath[] = [ + { + mspPairId: "t1", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 0, y: 0 }, + { x: 20, y: 0 }, + ], + traceWidth: 0.1, + mspConnectionPairIds: [], + pinIds: ["p1", "p2"], + pins: [], + }, + { + mspPairId: "t2", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 5, y: 0 }, + { x: 15, y: 0 }, + ], + traceWidth: 0.1, + mspConnectionPairIds: [], + pinIds: ["p3"], + pins: [], + } + ] + + const merged = mergeSameNetSegments(traces) + + // t2 should be removed because it is a subset of t1 + expect(merged.length).toBe(1) + expect(merged[0].mspPairId).toBe("t1") +}) + +test("mergeSameNetSegments merges partial overlaps", () => { + const traces: SolvedTracePath[] = [ + { + mspPairId: "t1", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 0, y: 0 }, + { x: 15, y: 0 }, + ], + traceWidth: 0.1, + mspConnectionPairIds: [], + pinIds: [], + pins: [], + }, + { + mspPairId: "t2", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 10, y: 0 }, + { x: 25, y: 0 }, + ], + traceWidth: 0.1, + mspConnectionPairIds: [], + pinIds: [], + pins: [], + } + ] + + const merged = mergeSameNetSegments(traces) + + // Currently, our deduplication only removes full subsets. + // Partial overlaps might remain as two traces, but their points are modified. + // In a future improvement, we might want to consolidate them. + // For now, let's verify if they at least share the same line. + expect(merged.length).toBeGreaterThan(0) + for (const trace of merged) { + expect(trace.tracePath[0].y).toBe(0) + } +}) + +test("repro61: redundant trace between net labels", () => { + // Simulating repro61 where two traces connect the same points + const traces: SolvedTracePath[] = [ + { + mspPairId: "t1", + globalConnNetId: "VCC", + dcConnNetId: "VCC", + tracePath: [{ x: 10, y: 10 }, { x: 20, y: 10 }], + traceWidth: 0.1, + mspConnectionPairIds: ["pair1"], + pinIds: [], + pins: [], + }, + { + mspPairId: "t2", + globalConnNetId: "VCC", + dcConnNetId: "VCC", + tracePath: [{ x: 10, y: 10.01 }, { x: 20, y: 10.01 }], + traceWidth: 0.1, + mspConnectionPairIds: ["pair2"], + pinIds: [], + pins: [], + } + ] + + const merged = mergeSameNetSegments(traces) + + // With 0.02 threshold, these should align and then one should be removed as a duplicate + expect(merged.length).toBe(1) +}) diff --git a/tests/solvers/TraceCleanupSolver/TraceAlignment.test.ts b/tests/solvers/TraceCleanupSolver/TraceAlignment.test.ts new file mode 100644 index 00000000..794bdc5b --- /dev/null +++ b/tests/solvers/TraceCleanupSolver/TraceAlignment.test.ts @@ -0,0 +1,84 @@ +import { test, expect } from "bun:test" +import { mergeSameNetSegments } from "lib/solvers/TraceCleanupSolver/mergeSameNetSegments" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +test("mergeSameNetSegments aligns close traces (Issue #34)", () => { + const traces: SolvedTracePath[] = [ + { + mspPairId: "t1", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + ], + traceWidth: 0.1, + mspConnectionPairIds: [], + pinIds: [], + pins: [], + }, + { + mspPairId: "t2", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 1, y: 0.015 }, + { x: 2, y: 0.015 }, + ], + traceWidth: 0.1, + mspConnectionPairIds: [], + pinIds: [], + pins: [], + } + ] + + const merged = mergeSameNetSegments(traces) + + // Expected: both at average Y = 0.0075 + // But note: mergeSameNetSegments modifies points in-place and might deduplicate. + // The current deduplication check uses a tighter 0.001 threshold for comparison. + + expect(merged.length).toBeGreaterThan(0) + for (const trace of merged) { + for (const p of trace.tracePath) { + expect(p.y).toBeCloseTo(0.0075, 5) + } + } +}) + +test("mergeSameNetSegments should NOT align traces outside threshold", () => { + const traces: SolvedTracePath[] = [ + { + mspPairId: "t1", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + ], + traceWidth: 0.1, + mspConnectionPairIds: [], + pinIds: [], + pins: [], + }, + { + mspPairId: "t2", + globalConnNetId: "net1", + dcConnNetId: "net1", + tracePath: [ + { x: 0.5, y: 0.05 }, + { x: 1.5, y: 0.05 }, + ], + traceWidth: 0.1, + mspConnectionPairIds: [], + pinIds: [], + pins: [], + } + ] + + const merged = mergeSameNetSegments(traces) + + expect(merged.length).toBe(2) + expect(merged.find(t => t.mspPairId === "t1")?.tracePath[0].y).toBe(0) + expect(merged.find(t => t.mspPairId === "t2")?.tracePath[0].y).toBe(0.05) +}) diff --git a/tests/solvers/TraceCleanupSolver/assets/merging-segments.json b/tests/solvers/TraceCleanupSolver/assets/merging-segments.json new file mode 100644 index 00000000..bc785309 --- /dev/null +++ b/tests/solvers/TraceCleanupSolver/assets/merging-segments.json @@ -0,0 +1,46 @@ +{ + "inputProblem": { + "chips": [], + "directConnections": [], + "netConnections": [ + { + "netId": "NET1", + "pinIds": ["P1.1", "P2.1", "P3.1"] + } + ], + "availableNetLabelOrientations": {}, + "maxMspPairDistance": 10 + }, + "allTraces": [ + { + "mspPairId": "P1.1-P2.1", + "dcConnNetId": "net0", + "globalConnNetId": "net0", + "userNetId": "NET1", + "pins": [], + "tracePath": [ + { "x": 0, "y": 0 }, + { "x": 10, "y": 0 } + ], + "mspConnectionPairIds": ["P1.1-P2.1"], + "pinIds": ["P1.1", "P2.1"] + }, + { + "mspPairId": "P2.1-P3.1", + "dcConnNetId": "net0", + "globalConnNetId": "net0", + "userNetId": "NET1", + "pins": [], + "tracePath": [ + { "x": 5, "y": 0 }, + { "x": 15, "y": 0 } + ], + "mspConnectionPairIds": ["P2.1-P3.1"], + "pinIds": ["P2.1", "P3.1"] + } + ], + "targetTraceIds": ["P1.1-P2.1", "P2.1-P3.1"], + "allLabelPlacements": [], + "mergedLabelNetIdMap": {}, + "paddingBuffer": 0.01 +}