diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index e9bac7ca3..639cb257a 100644 --- a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts +++ b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts @@ -20,6 +20,10 @@ interface TraceCleanupSolverInput { import { UntangleTraceSubsolver } from "./sub-solver/UntangleTraceSubsolver" import { is4PointRectangle } from "./is4PointRectangle" +import { + findCloseSameNetTraceGroups, + type CloseSameNetTraceGroup, +} from "./sub-solver/findCloseSameNetTraceGroups" /** * Represents the different stages or steps within the trace cleanup pipeline. @@ -42,6 +46,7 @@ export class TraceCleanupSolver extends BaseSolver { private outputTraces: SolvedTracePath[] private traceIdQueue: string[] private tracesMap: Map + private closeSameNetTraceGroups: CloseSameNetTraceGroup[] = [] private pipelineStep: PipelineStep = "untangling_traces" private activeTraceId: string | null = null // New property override activeSubSolver: BaseSolver | null = null @@ -54,6 +59,9 @@ export class TraceCleanupSolver extends BaseSolver { this.traceIdQueue = Array.from( solverInput.allTraces.map((e) => e.mspPairId), ) + this.closeSameNetTraceGroups = findCloseSameNetTraceGroups( + solverInput.allTraces, + ) } override _step() { @@ -149,6 +157,7 @@ export class TraceCleanupSolver extends BaseSolver { getOutput() { return { traces: this.outputTraces, + closeSameNetTraceGroups: this.closeSameNetTraceGroups, } } @@ -171,10 +180,11 @@ export class TraceCleanupSolver extends BaseSolver { for (const trace of this.outputTraces) { const line: Line = { points: trace.tracePath.map((p) => ({ x: p.x, y: p.y })), - strokeColor: trace.mspPairId === this.activeTraceId ? "red" : "blue", // Highlight active trace + strokeColor: trace.mspPairId === this.activeTraceId ? "red" : "blue", } graphics.lines!.push(line) } + return graphics } } diff --git a/lib/solvers/TraceCleanupSolver/sub-solver/findCloseSameNetTraceGroups.ts b/lib/solvers/TraceCleanupSolver/sub-solver/findCloseSameNetTraceGroups.ts new file mode 100644 index 000000000..e1e1d85db --- /dev/null +++ b/lib/solvers/TraceCleanupSolver/sub-solver/findCloseSameNetTraceGroups.ts @@ -0,0 +1,70 @@ +import type { SolvedTracePath } from "../../SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +export interface CloseSameNetTraceGroup { + netId: string + traceIds: string[] + maxEndpointDistance: number +} + +const distance = (a: { x: number; y: number }, b: { x: number; y: number }) => + Math.hypot(a.x - b.x, a.y - b.y) + +const getTraceEndpoints = (trace: SolvedTracePath) => { + const start = trace.tracePath[0] + const end = trace.tracePath[trace.tracePath.length - 1] + return { start, end } +} + +/** + * Finds same-net traces whose endpoints are already close enough that they are + * likely candidates for a later merge/join phase. + * + * This is intentionally conservative: it does not mutate paths, it only groups + * traces so the pipeline can decide whether to combine them. + */ +export const findCloseSameNetTraceGroups = ( + traces: SolvedTracePath[], + maxEndpointDistance = 0.5, +): CloseSameNetTraceGroup[] => { + const groupedByNet = new Map() + + for (const trace of traces) { + const netId = trace.userNetId ?? trace.globalConnNetId ?? trace.dcConnNetId + const current = groupedByNet.get(netId) ?? [] + current.push(trace) + groupedByNet.set(netId, current) + } + + const groups: CloseSameNetTraceGroup[] = [] + + for (const [netId, netTraces] of groupedByNet.entries()) { + if (netTraces.length < 2) continue + + for (let i = 0; i < netTraces.length; i++) { + for (let j = i + 1; j < netTraces.length; j++) { + const left = netTraces[i] + const right = netTraces[j] + const a = getTraceEndpoints(left) + const b = getTraceEndpoints(right) + + const distances = [ + distance(a.start, b.start), + distance(a.start, b.end), + distance(a.end, b.start), + distance(a.end, b.end), + ] + const minDistance = Math.min(...distances) + + if (minDistance <= maxEndpointDistance) { + groups.push({ + netId, + traceIds: [left.mspPairId as string, right.mspPairId as string], + maxEndpointDistance: minDistance, + }) + } + } + } + } + + return groups.sort((a, b) => a.maxEndpointDistance - b.maxEndpointDistance) +} diff --git a/lib/solvers/TraceCleanupSolver/sub-solver/mergeCloseSameNetTraces.ts b/lib/solvers/TraceCleanupSolver/sub-solver/mergeCloseSameNetTraces.ts new file mode 100644 index 000000000..3ff4a8dcb --- /dev/null +++ b/lib/solvers/TraceCleanupSolver/sub-solver/mergeCloseSameNetTraces.ts @@ -0,0 +1,163 @@ +import type { SolvedTracePath } from "../../SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import type { CloseSameNetTraceGroup } from "./findCloseSameNetTraceGroups" + +export interface MergedTraceResult { + originalTraceIds: string[] + mergedPath: { x: number; y: number }[] + netId: string +} + +/** + * Finds the closest pair of endpoints between two traces. + */ +const findClosestEndpointPair = ( + traceA: SolvedTracePath, + traceB: SolvedTracePath, +): { aIdx: number; bIdx: number; aPoint: { x: number; y: number }; bPoint: { x: number; y: number }; distance: number } | null => { + const endpointsA = [traceA.tracePath[0], traceA.tracePath[traceA.tracePath.length - 1]] + const endpointsB = [traceB.tracePath[0], traceB.tracePath[traceB.tracePath.length - 1]] + + let best: { aIdx: number; bIdx: number; distance: number; aPoint: { x: number; y: number }; bPoint: { x: number; y: number } } | null = null + + for (let ai = 0; ai < endpointsA.length; ai++) { + for (let bi = 0; bi < endpointsB.length; bi++) { + const dx = endpointsA[ai].x - endpointsB[bi].x + const dy = endpointsA[ai].y - endpointsB[bi].y + const dist = Math.hypot(dx, dy) + if (!best || dist < best.distance) { + best = { aIdx: ai, bIdx: bi, aPoint: endpointsA[ai], bPoint: endpointsB[bi], distance: dist } + } + } + } + return best +} + +/** + * Builds an L-shaped connecting segment between two points. + * Tries horizontal-then-vertical and vertical-then-horizontal, picks the shorter. + */ +const buildConnectingSegment = ( + from: { x: number; y: number }, + to: { x: number; y: number }, +): { x: number; y: number }[] => { + const route1 = [ + { x: to.x, y: from.y }, // horizontal first, then vertical + ] + const route2 = [ + { x: from.x, y: to.y }, // vertical first, then horizontal + ] + + // Pick the shorter L-route + const len1 = Math.abs(to.x - from.x) + Math.abs(from.y - to.y) + const len2 = Math.abs(from.x - to.x) + Math.abs(to.y - from.y) + + return len1 <= len2 ? route1 : route2 +} + +/** + * Removes consecutive duplicate points from a path. + */ +const deduplicatePath = (points: { x: number; y: number }[]) => { + const result: { x: number; y: number }[] = [] + for (const p of points) { + const last = result[result.length - 1] + if (!last || last.x !== p.x || last.y !== p.y) { + result.push(p) + } + } + return result +} + +/** + * Given two same-net traces whose endpoints are close (or overlapping), + * merges them into a single continuous path. + * + * Strategy: + * 1. Find the closest endpoint pair between the two traces + * 2. Determine the direction of each trace from the closest endpoint + * 3. Construct a merged path: traceA (from far endpoint toward merge point) + + * connecting segment + traceB (from merge point toward far endpoint) + * 4. Deduplicate consecutive identical points + */ +export const mergeTwoSameNetTraces = ( + traceA: SolvedTracePath, + traceB: SolvedTracePath, + maxMergeDistance: number = 0.5, +): { x: number; y: number }[] | null => { + const pair = findClosestEndpointPair(traceA, traceB) + if (!pair || pair.distance > maxMergeDistance) return null + + // Determine which endpoint of traceA and traceB are the merge ends vs far ends + const aPath = traceA.tracePath + const bPath = traceB.tracePath + + // Build merged path + // traceA from its far end to the closest endpoint + const aPoints = pair.aIdx === 0 ? [...aPath] : [...aPath].reverse() + const bPoints = pair.bIdx === 0 ? [...bPath] : [...bPath].reverse() + + // aPoints now starts at the merge-end of traceA and ends at the far end + // bPoints starts at the far end of traceB and ends at the merge-end + // We need: traceA far → ... → traceA merge → connector → traceB merge → ... → traceB far + const aFromFarToMerge = [...aPoints].reverse() + // aFromFarToMerge[0] is the far end, aFromFarToMerge[last] is the merge end + + // Build the connector from traceA's merge-end to traceB's merge-end + const aMergePoint = aFromFarToMerge[aFromFarToMerge.length - 1] + const bMergePoint = bPoints[0] + const connector = buildConnectingSegment(aMergePoint, bMergePoint) + + // bPoints already starts at the merge-end and goes to the far end + // But we need to skip the first point of bPoints to avoid duplicating the merge point + const bFromMergeToFar = bPoints.slice(1) + + const merged = deduplicatePath([ + ...aFromFarToMerge, + ...connector, + ...bFromMergeToFar, + ]) + + return merged +} + +/** + * Takes groups of close same-net traces and attempts to merge each group + * into a single continuous trace path. + */ +export const mergeCloseSameNetTraceGroups = ( + groups: CloseSameNetTraceGroup[], + allTraces: SolvedTracePath[], + maxMergeDistance: number = 0.5, +): MergedTraceResult[] => { + const traceMap = new Map() + for (const t of allTraces) { + traceMap.set(t.mspPairId, t) + } + + const results: MergedTraceResult[] = [] + + for (const group of groups) { + if (group.traceIds.length < 2) continue + + // Get the traces in this group + const groupTraces = group.traceIds + .map((id) => traceMap.get(id)) + .filter((t): t is SolvedTracePath => t !== undefined) + + if (groupTraces.length < 2) continue + + // Merge sequentially: merge first two, then merge result with next, etc. + // For now, simple pairwise merge of first two traces in the group + const merged = mergeTwoSameNetTraces(groupTraces[0], groupTraces[1], maxMergeDistance) + + if (merged) { + results.push({ + originalTraceIds: [groupTraces[0].mspPairId, groupTraces[1].mspPairId], + mergedPath: merged, + netId: group.netId, + }) + } + } + + return results +} diff --git a/tests/solvers/TraceCleanupSolver/findCloseSameNetTraceGroups.test.ts b/tests/solvers/TraceCleanupSolver/findCloseSameNetTraceGroups.test.ts new file mode 100644 index 000000000..a7f46dd20 --- /dev/null +++ b/tests/solvers/TraceCleanupSolver/findCloseSameNetTraceGroups.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test" +import { findCloseSameNetTraceGroups } from "lib/solvers/TraceCleanupSolver/sub-solver/findCloseSameNetTraceGroups" + +const trace = ( + id: string, + netId: string, + path: Array<{ x: number; y: number }>, +) => + ({ + mspPairId: id, + userNetId: netId, + globalConnNetId: `${netId}-global`, + dcConnNetId: `${netId}-dc`, + tracePath: path, + }) as any + +test("findCloseSameNetTraceGroups groups close same-net traces by endpoint distance", () => { + const groups = findCloseSameNetTraceGroups( + [ + trace("a", "N1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + ]), + trace("b", "N1", [ + { x: 1.15, y: 0 }, + { x: 2, y: 0 }, + ]), + trace("c", "N2", [ + { x: 10, y: 10 }, + { x: 11, y: 10 }, + ]), + ], + 0.2, + ) + + expect(groups).toHaveLength(1) + expect(groups[0].netId).toBe("N1") + expect(groups[0].traceIds).toEqual(["a", "b"]) + expect(groups[0].maxEndpointDistance).toBeLessThanOrEqual(0.2) +}) diff --git a/tests/solvers/TraceCleanupSolver/mergeCloseSameNetTraces.test.ts b/tests/solvers/TraceCleanupSolver/mergeCloseSameNetTraces.test.ts new file mode 100644 index 000000000..24e82b42d --- /dev/null +++ b/tests/solvers/TraceCleanupSolver/mergeCloseSameNetTraces.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from "bun:test" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { mergeTwoSameNetTraces, mergeCloseSameNetTraceGroups } from "../../../lib/solvers/TraceCleanupSolver/sub-solver/mergeCloseSameNetTraces" +import type { CloseSameNetTraceGroup } from "../../../lib/solvers/TraceCleanupSolver/sub-solver/findCloseSameNetTraceGroups" + +const makeTrace = ( + mspPairId: string, + points: { x: number; y: number }[], +): SolvedTracePath => ({ + mspPairId, + tracePath: points, + mspConnectionPairIds: [], + pinIds: [], + netWithTraceCount: 0, + globalConnNetId: "net1", +}) as SolvedTracePath + +test("mergeTwoSameNetTraces merges horizontally adjacent traces", () => { + const traceA = makeTrace("a", [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + ]) + const traceB = makeTrace("b", [ + { x: 3, y: 0 }, + { x: 5, y: 0 }, + ]) + + const result = mergeTwoSameNetTraces(traceA, traceB, 2) + expect(result).not.toBeNull() + expect(result!.length).toBeGreaterThan(0) + // First point should match traceA start + expect(result![0]).toEqual({ x: 0, y: 0 }) + // Last point should match traceB end + expect(result![result!.length - 1]).toEqual({ x: 5, y: 0 }) +}) + +test("mergeTwoSameNetTraces returns null when traces are too far apart", () => { + const traceA = makeTrace("a", [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + ]) + const traceB = makeTrace("b", [ + { x: 10, y: 0 }, + { x: 12, y: 0 }, + ]) + + const result = mergeTwoSameNetTraces(traceA, traceB, 0.5) + expect(result).toBeNull() +}) + +test("mergeTwoSameNetTraces merges touching endpoints directly", () => { + const traceA = makeTrace("a", [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + ]) + const traceB = makeTrace("b", [ + { x: 2, y: 0 }, + { x: 4, y: 0 }, + ]) + + const result = mergeTwoSameNetTraces(traceA, traceB, 0.5) + expect(result).not.toBeNull() + // Should produce a continuous path from 0 to 4 + expect(result![0]).toEqual({ x: 0, y: 0 }) + expect(result![result!.length - 1]).toEqual({ x: 4, y: 0 }) +}) + +test("mergeTwoSameNetTraces merges vertically adjacent traces", () => { + const traceA = makeTrace("a", [ + { x: 0, y: 0 }, + { x: 0, y: 2 }, + ]) + const traceB = makeTrace("b", [ + { x: 0, y: 3 }, + { x: 0, y: 5 }, + ]) + + const result = mergeTwoSameNetTraces(traceA, traceB, 2) + expect(result).not.toBeNull() + expect(result![0]).toEqual({ x: 0, y: 0 }) + expect(result![result!.length - 1]).toEqual({ x: 0, y: 5 }) +}) + +test("mergeTwoSameNetTraces handles reversed endpoint matching", () => { + // Trace B is oriented "backwards" — its merge endpoint is at index [0] + const traceA = makeTrace("a", [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + ]) + const traceB = makeTrace("b", [ + { x: 2, y: 0 }, + { x: 0, y: 2 }, + ]) + + const result = mergeTwoSameNetTraces(traceA, traceB, 0.5) + expect(result).not.toBeNull() +}) + +test("mergeCloseSameNetTraceGroups processes multiple groups", () => { + const traces = [ + makeTrace("a", [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + ]), + makeTrace("b", [ + { x: 3, y: 0 }, + { x: 5, y: 0 }, + ]), + makeTrace("c", [ + { x: 10, y: 0 }, + { x: 12, y: 0 }, + ]), + ] + + const groups: CloseSameNetTraceGroup[] = [ + { netId: "net1", traceIds: ["a", "b"], maxEndpointDistance: 1 }, + ] + + const results = mergeCloseSameNetTraceGroups(groups, traces, 2) + expect(results.length).toBe(1) + expect(results[0].originalTraceIds).toContain("a") + expect(results[0].originalTraceIds).toContain("b") + expect(results[0].netId).toBe("net1") +}) + +test("mergeCloseSameNetTraceGroups returns empty for single-trace groups", () => { + const traces = [makeTrace("a", [{ x: 0, y: 0 }, { x: 2, y: 0 }])] + const groups: CloseSameNetTraceGroup[] = [ + { netId: "net1", traceIds: ["a"], maxEndpointDistance: 0 }, + ] + + const results = mergeCloseSameNetTraceGroups(groups, traces) + expect(results.length).toBe(0) +})