diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index e9bac7c..ae822be 100644 --- a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts +++ b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts @@ -20,6 +20,7 @@ interface TraceCleanupSolverInput { import { UntangleTraceSubsolver } from "./sub-solver/UntangleTraceSubsolver" import { is4PointRectangle } from "./is4PointRectangle" +import { removeNetSegmentDuplicates } from "./removeNetSegmentDuplicates" /** * Represents the different stages or steps within the trace cleanup pipeline. @@ -49,11 +50,14 @@ export class TraceCleanupSolver extends BaseSolver { constructor(solverInput: TraceCleanupSolverInput) { super() this.input = solverInput - this.outputTraces = [...solverInput.allTraces] + // Pre-process: remove duplicate segments that appear in multiple traces of + // the same net. These are created by SchematicTraceLinesSolver when two MSP + // connection pairs share a pin and independently route through the same + // physical segment (issue #78 — "extra trace lines in post-processing"). + const dedupedTraces = removeNetSegmentDuplicates(solverInput.allTraces) + this.outputTraces = dedupedTraces this.tracesMap = new Map(this.outputTraces.map((t) => [t.mspPairId, t])) - this.traceIdQueue = Array.from( - solverInput.allTraces.map((e) => e.mspPairId), - ) + this.traceIdQueue = Array.from(dedupedTraces.map((e) => e.mspPairId)) } override _step() { diff --git a/lib/solvers/TraceCleanupSolver/removeNetSegmentDuplicates.ts b/lib/solvers/TraceCleanupSolver/removeNetSegmentDuplicates.ts new file mode 100644 index 0000000..db37741 --- /dev/null +++ b/lib/solvers/TraceCleanupSolver/removeNetSegmentDuplicates.ts @@ -0,0 +1,120 @@ +import type { Point } from "graphics-debug" +import type { SolvedTracePath } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver" + +const EPS = 1e-9 + +const isSamePoint = (a: Point, b: Point): boolean => + Math.abs(a.x - b.x) < EPS && Math.abs(a.y - b.y) < EPS + +/** + * Returns a canonical key for a segment (direction-independent). + * Two segments that are the same but in opposite directions produce the same key. + */ +const segmentKey = (a: Point, b: Point): string => { + const p1 = `${a.x},${a.y}` + const p2 = `${b.x},${b.y}` + return p1 < p2 ? `${p1}|${p2}` : `${p2}|${p1}` +} + +/** + * Removes duplicate segments that appear in multiple traces of the same net. + * + * When the MSP solver creates trace paths for individual pin pairs, two traces + * in the same net can route through the same physical segment (especially near + * shared pin endpoints). This creates "extra trace lines" when rendered. + * + * This function keeps each unique segment exactly once per net by removing it + * from any trace where it appears as a redundant endpoint segment (first or + * last segment), while the same segment also exists in another trace of the + * same net. + * + * Endpoint segments (leading to pin positions) are candidates for removal when + * the same segment already exists in another trace, because the net remains + * visually connected through the other trace's segment. + */ +export const removeNetSegmentDuplicates = ( + traces: SolvedTracePath[], +): SolvedTracePath[] => { + // Group traces by net + const byNet = new Map() + for (const trace of traces) { + const netId = trace.globalConnNetId + if (!byNet.has(netId)) byNet.set(netId, []) + byNet.get(netId)!.push(trace) + } + + const result: SolvedTracePath[] = [] + + for (const [_netId, netTraces] of byNet) { + if (netTraces.length < 2) { + result.push(...netTraces) + continue + } + + // Count how many times each segment appears across all traces in this net + const segCounts = new Map() + for (const trace of netTraces) { + const seen = new Set() + for (let i = 0; i < trace.tracePath.length - 1; i++) { + const key = segmentKey(trace.tracePath[i], trace.tracePath[i + 1]) + // Only count each segment once per trace (avoid inflating from within-trace dups) + if (!seen.has(key)) { + seen.add(key) + segCounts.set(key, (segCounts.get(key) ?? 0) + 1) + } + } + } + + // Find segments that appear in multiple traces + const duplicateSegs = new Set() + for (const [key, count] of segCounts) { + if (count > 1) duplicateSegs.add(key) + } + + if (duplicateSegs.size === 0) { + result.push(...netTraces) + continue + } + + // For each trace, remove duplicate endpoint segments (first or last segment + // only, to preserve interior routing integrity). The first occurrence of a + // segment across traces is kept; subsequent traces that start/end with the + // same segment have those endpoints trimmed. + const alreadyClaimedSegs = new Set() + + for (const trace of netTraces) { + let { tracePath } = trace + + // Check if first segment is a duplicate — trim it from this trace + // (only if the path has more than 2 points, so trimming leaves a valid path) + let trimmedFront = false + if (tracePath.length > 2) { + const firstKey = segmentKey(tracePath[0], tracePath[1]) + if (duplicateSegs.has(firstKey) && alreadyClaimedSegs.has(firstKey)) { + tracePath = tracePath.slice(1) + trimmedFront = true + } + } + + // Check if last segment is a duplicate — trim it from this trace + if (!trimmedFront && tracePath.length > 2) { + const lastKey = segmentKey( + tracePath[tracePath.length - 2], + tracePath[tracePath.length - 1], + ) + if (duplicateSegs.has(lastKey) && alreadyClaimedSegs.has(lastKey)) { + tracePath = tracePath.slice(0, -1) + } + } + + // Register all segments in this trace as claimed + for (let i = 0; i < tracePath.length - 1; i++) { + alreadyClaimedSegs.add(segmentKey(tracePath[i], tracePath[i + 1])) + } + + result.push({ ...trace, tracePath }) + } + } + + return result +} diff --git a/lib/solvers/TraceCleanupSolver/simplifyPath.ts b/lib/solvers/TraceCleanupSolver/simplifyPath.ts index e17bfb5..c23f481 100644 --- a/lib/solvers/TraceCleanupSolver/simplifyPath.ts +++ b/lib/solvers/TraceCleanupSolver/simplifyPath.ts @@ -4,7 +4,32 @@ import { isVertical, } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/collisions" +const EPS = 1e-9 + +const isSamePoint = (a: Point, b: Point): boolean => + Math.abs(a.x - b.x) < EPS && Math.abs(a.y - b.y) < EPS + +/** + * Removes consecutive duplicate points from a path (zero-length segments). + * These can appear when path segments are concatenated at shared endpoints. + */ +export const removeDuplicateConsecutivePoints = (path: Point[]): Point[] => { + if (path.length < 2) return path + const result: Point[] = [path[0]] + for (let i = 1; i < path.length; i++) { + if (!isSamePoint(result[result.length - 1], path[i])) { + result.push(path[i]) + } + } + return result +} + export const simplifyPath = (path: Point[]): Point[] => { + // First remove any duplicate consecutive points (zero-length segments) + const dedupedPath = removeDuplicateConsecutivePoints(path) + if (dedupedPath.length !== path.length) { + path = dedupedPath + } if (path.length < 3) return path const newPath: Point[] = [path[0]] for (let i = 1; i < path.length - 1; i++) { diff --git a/tests/examples/__snapshots__/example02.snap.svg b/tests/examples/__snapshots__/example02.snap.svg index 39a46c7..90cc5c6 100644 --- a/tests/examples/__snapshots__/example02.snap.svg +++ b/tests/examples/__snapshots__/example02.snap.svg @@ -155,7 +155,7 @@ x+" data-x="1" data-y="0.1" cx="500.20151295522464" cy="324.189420823755" r="3" - + @@ -167,7 +167,7 @@ x+" data-x="1" data-y="0.1" cx="500.20151295522464" cy="324.189420823755" r="3" - + diff --git a/tests/examples/__snapshots__/example13.snap.svg b/tests/examples/__snapshots__/example13.snap.svg index 08e47d6..c6f1b56 100644 --- a/tests/examples/__snapshots__/example13.snap.svg +++ b/tests/examples/__snapshots__/example13.snap.svg @@ -188,19 +188,19 @@ x+" data-x="4.5649999999999995" data-y="3" cx="586.9994196169471" cy="157.492745 - + - + - + diff --git a/tests/examples/__snapshots__/example15.snap.svg b/tests/examples/__snapshots__/example15.snap.svg index fa7b2fb..acdd9fe 100644 --- a/tests/examples/__snapshots__/example15.snap.svg +++ b/tests/examples/__snapshots__/example15.snap.svg @@ -785,22 +785,22 @@ y-" data-x="-2.025" data-y="-2.7" cx="318.5204755614267" cy="526.0237780713342" - + - + - + - + - + @@ -830,7 +830,7 @@ y-" data-x="-2.025" data-y="-2.7" cx="318.5204755614267" cy="526.0237780713342" - + diff --git a/tests/solvers/TraceCleanupSolver/removeNetSegmentDuplicates.test.ts b/tests/solvers/TraceCleanupSolver/removeNetSegmentDuplicates.test.ts new file mode 100644 index 0000000..94f6e09 --- /dev/null +++ b/tests/solvers/TraceCleanupSolver/removeNetSegmentDuplicates.test.ts @@ -0,0 +1,148 @@ +import { test, expect } from "bun:test" +import { SchematicTracePipelineSolver } from "lib/index" +import overlapFixture from "../../assets/OverlapAvoidanceStepSolver.test.input.json" +import { removeDuplicateConsecutivePoints } from "lib/solvers/TraceCleanupSolver/simplifyPath" +import { removeNetSegmentDuplicates } from "lib/solvers/TraceCleanupSolver/removeNetSegmentDuplicates" + +const EPS = 1e-9 + +const segmentKey = ( + a: { x: number; y: number }, + b: { x: number; y: number }, +) => { + const p1 = `${a.x},${a.y}` + const p2 = `${b.x},${b.y}` + return p1 < p2 ? `${p1}|${p2}` : `${p2}|${p1}` +} + +test("removeDuplicateConsecutivePoints removes zero-length segments from a single path", () => { + const path = [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 0 }, // duplicate + { x: 2, y: 0 }, + { x: 2, y: 0 }, // duplicate + { x: 2, y: 1 }, + ] + const result = removeDuplicateConsecutivePoints(path) + expect(result).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 2, y: 0 }, + { x: 2, y: 1 }, + ]) +}) + +test("removeNetSegmentDuplicates removes cross-trace duplicate segments within same net", () => { + const traceA = { + mspPairId: "A-B", + dcConnNetId: "net1", + globalConnNetId: "net1", + pins: [] as any, + pinIds: ["A", "B"], + mspConnectionPairIds: ["A-B"], + tracePath: [ + { x: 0, y: 0 }, // pin A + { x: -1, y: 0 }, // shared segment + { x: -1, y: -1 }, + { x: 0, y: -1 }, // pin B + ], + } + + const traceB = { + mspPairId: "A-C", + dcConnNetId: "net1", + globalConnNetId: "net1", + pins: [] as any, + pinIds: ["A", "C"], + mspConnectionPairIds: ["A-C"], + tracePath: [ + { x: 1, y: 0 }, // pin C + { x: 0, y: 0 }, // pin A (connected to traceA start) + { x: -1, y: 0 }, // same segment as traceA[0->1] + ], + } + + const result = removeNetSegmentDuplicates([traceA, traceB]) + + // Collect all segments across result traces + const allSegmentKeys = new Set() + let duplicateFound = false + + for (const trace of result) { + for (let i = 0; i < trace.tracePath.length - 1; i++) { + const key = `${trace.globalConnNetId}|${segmentKey(trace.tracePath[i], trace.tracePath[i + 1])}` + if (allSegmentKeys.has(key)) { + duplicateFound = true + } + allSegmentKeys.add(key) + } + } + + expect(duplicateFound).toBe(false) +}) + +test("fix issue #78: no duplicate segments in pipeline output for OverlapAvoidance fixture", () => { + const inputProblem = structuredClone((overlapFixture as any).problem) + + const solver = new SchematicTracePipelineSolver(inputProblem) + solver.solve() + + // Get traces after TraceCleanupSolver (which now deduplicates) + const traces = + (solver as any).traceCleanupSolver?.getOutput().traces ?? + (solver as any).traceLabelOverlapAvoidanceSolver?.getOutput().traces ?? + [] + + // Count cross-trace segment occurrences per net + const netSegCounts = new Map() + for (const trace of traces as any[]) { + for (let i = 0; i < trace.tracePath.length - 1; i++) { + const key = `${trace.globalConnNetId}|${segmentKey(trace.tracePath[i], trace.tracePath[i + 1])}` + netSegCounts.set(key, (netSegCounts.get(key) ?? 0) + 1) + } + } + + const duplicates = [...netSegCounts.entries()].filter(([_, c]) => c > 1) + expect(duplicates.length).toBe(0) +}) + +test("fix issue #78: simplifyPath removes consecutive duplicate points produced by path concatenation", () => { + // Simulate what UntangleTraceSubsolver does when concatenating path segments: + // slice(0, p2Index) + bestRoute + slice(p2Index+1) can produce duplicate points + // at the junctions when bestRoute starts/ends at the same point as slice boundaries + const prefix = [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, // p2 to be replaced + ] + const bestRoute = [ + { x: 1, y: 1 }, // same as prefix end — duplicate! + { x: 2, y: 1 }, + { x: 2, y: 2 }, + { x: 3, y: 2 }, // same as suffix start — duplicate! + ] + const suffix = [ + { x: 3, y: 2 }, // slice(p2Index+1) start + { x: 4, y: 2 }, + ] + + const concatenated = [...prefix.slice(0, 2), ...bestRoute, ...suffix] + // Contains duplicates at junctions + const duplicatesBefore = concatenated.filter( + (p, i) => + i > 0 && + Math.abs(p.x - concatenated[i - 1].x) < EPS && + Math.abs(p.y - concatenated[i - 1].y) < EPS, + ) + expect(duplicatesBefore.length).toBeGreaterThan(0) + + const cleaned = removeDuplicateConsecutivePoints(concatenated) + const duplicatesAfter = cleaned.filter( + (p, i) => + i > 0 && + Math.abs(p.x - cleaned[i - 1].x) < EPS && + Math.abs(p.y - cleaned[i - 1].y) < EPS, + ) + expect(duplicatesAfter.length).toBe(0) +})