From 17cd13cd88bc044f612d9dcd4b3831f29c108d7b Mon Sep 17 00:00:00 2001 From: Makaia Taye Childress Date: Tue, 17 Feb 2026 17:40:07 -0600 Subject: [PATCH] fix: remove extra trace lines from post-processing step (#78) Add duplicate consecutive point removal to UntangleTraceSubsolver._applyBestRoute() and enhance simplifyPath with duplicate point preprocessing. When _applyBestRoute() concatenates path segments after rerouting L-shaped corners, the junction points can produce duplicate consecutive coordinates that render as zero-length extra trace segments in the schematic output. Changes: - UntangleTraceSubsolver: remove duplicate consecutive points after path concatenation - simplifyPath: add removeDuplicateConsecutivePoints preprocessing before collinear removal - Add 9 unit tests for simplifyPath covering duplicates, collinear, and combined cases - Add NE555+J1 connector integration test verifying clean trace paths All 59 existing tests pass with zero snapshot regressions. /claim #78 Co-Authored-By: Claude Opus 4.6 --- .../TraceCleanupSolver/simplifyPath.ts | 35 ++- .../sub-solver/UntangleTraceSubsolver.ts | 25 +- tests/assets/example_issue78.json | 98 ++++++ .../__snapshots__/example_issue78.snap.svg | 286 ++++++++++++++++++ tests/examples/example_issue78.test.ts | 50 +++ tests/functions/simplifyPath.test.ts | 121 ++++++++ 6 files changed, 607 insertions(+), 8 deletions(-) create mode 100644 tests/assets/example_issue78.json create mode 100644 tests/examples/__snapshots__/example_issue78.snap.svg create mode 100644 tests/examples/example_issue78.test.ts create mode 100644 tests/functions/simplifyPath.test.ts diff --git a/lib/solvers/TraceCleanupSolver/simplifyPath.ts b/lib/solvers/TraceCleanupSolver/simplifyPath.ts index e17bfb5..0b3881c 100644 --- a/lib/solvers/TraceCleanupSolver/simplifyPath.ts +++ b/lib/solvers/TraceCleanupSolver/simplifyPath.ts @@ -4,13 +4,36 @@ import { isVertical, } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/collisions" +const EPS = 1e-9 + +const isDuplicate = (a: Point, b: Point): boolean => + Math.abs(a.x - b.x) < EPS && Math.abs(a.y - b.y) < EPS + +/** + * Remove duplicate consecutive points from a path. + * These can appear after path concatenation in _applyBestRoute. + */ +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 (!isDuplicate(result[result.length - 1], path[i])) { + result.push(path[i]) + } + } + return result +} + export const simplifyPath = (path: Point[]): Point[] => { - if (path.length < 3) return path - const newPath: Point[] = [path[0]] - for (let i = 1; i < path.length - 1; i++) { + // First remove any duplicate consecutive points + const deduped = removeDuplicateConsecutivePoints(path) + + if (deduped.length < 3) return deduped + const newPath: Point[] = [deduped[0]] + for (let i = 1; i < deduped.length - 1; i++) { const p1 = newPath[newPath.length - 1] - const p2 = path[i] - const p3 = path[i + 1] + const p2 = deduped[i] + const p3 = deduped[i + 1] if ( (isVertical(p1, p2) && isVertical(p2, p3)) || (isHorizontal(p1, p2) && isHorizontal(p2, p3)) @@ -19,7 +42,7 @@ export const simplifyPath = (path: Point[]): Point[] => { } newPath.push(p2) } - newPath.push(path[path.length - 1]) + newPath.push(deduped[deduped.length - 1]) if (newPath.length < 3) return newPath const finalPath: Point[] = [newPath[0]] diff --git a/lib/solvers/TraceCleanupSolver/sub-solver/UntangleTraceSubsolver.ts b/lib/solvers/TraceCleanupSolver/sub-solver/UntangleTraceSubsolver.ts index 3519aa9..89301fe 100644 --- a/lib/solvers/TraceCleanupSolver/sub-solver/UntangleTraceSubsolver.ts +++ b/lib/solvers/TraceCleanupSolver/sub-solver/UntangleTraceSubsolver.ts @@ -23,6 +23,27 @@ import { visualizeTightRectangle } from "../visualizeTightRectangle" import { visualizeCandidates } from "./visualizeCandidates" import { mergeGraphicsObjects } from "../mergeGraphicsObjects" import { visualizeCollision } from "./visualizeCollision" +const EPS = 1e-9 + +/** + * Remove duplicate consecutive points from a path. + * Path concatenation in _applyBestRoute can produce duplicate points + * at the junctions, which render as zero-length extra trace segments. + */ +const removeDuplicateConsecutivePoints = ( + path: Array<{ x: number; y: number }>, +) => { + if (path.length < 2) return path + const result = [path[0]] + for (let i = 1; i < path.length; i++) { + const prev = result[result.length - 1] + const curr = path[i] + if (Math.abs(prev.x - curr.x) >= EPS || Math.abs(prev.y - curr.y) >= EPS) { + result.push(curr) + } + } + return result +} /** * Defines the input structure for the UntangleTraceSubsolver. @@ -258,11 +279,11 @@ export class UntangleTraceSubsolver extends BaseSolver { p.x === this.currentLShape!.p2.x && p.y === this.currentLShape!.p2.y, ) if (p2Index !== -1) { - const newTracePath = [ + const newTracePath = removeDuplicateConsecutivePoints([ ...originalTrace.tracePath.slice(0, p2Index), ...bestRoute, ...originalTrace.tracePath.slice(p2Index + 1), - ] + ]) this.input.allTraces[traceIndex] = { ...originalTrace, tracePath: newTracePath, diff --git a/tests/assets/example_issue78.json b/tests/assets/example_issue78.json new file mode 100644 index 0000000..444a697 --- /dev/null +++ b/tests/assets/example_issue78.json @@ -0,0 +1,98 @@ +{ + "chips": [ + { + "chipId": "schematic_component_0", + "center": { "x": 0, "y": 0 }, + "width": 2.4000000000000004, + "height": 1, + "pins": [ + { "pinId": "U1.1", "x": 1.2000000000000002, "y": -0.30000000000000004 }, + { + "pinId": "U1.2", + "x": -1.2000000000000002, + "y": -0.30000000000000004 + }, + { "pinId": "U1.3", "x": 1.2000000000000002, "y": 0.09999999999999998 }, + { "pinId": "U1.4", "x": -1.2000000000000002, "y": 0.30000000000000004 }, + { "pinId": "U1.5", "x": -1.2000000000000002, "y": 0.10000000000000003 }, + { + "pinId": "U1.6", + "x": -1.2000000000000002, + "y": -0.09999999999999998 + }, + { "pinId": "U1.7", "x": 1.2000000000000002, "y": -0.10000000000000003 }, + { "pinId": "U1.8", "x": 1.2000000000000002, "y": 0.30000000000000004 } + ] + }, + { + "chipId": "schematic_component_1", + "center": { "x": 2.45, "y": 1.0 }, + "width": 1.0999999999999996, + "height": 0.84, + "pins": [ + { "pinId": "R1.1", "x": 2.45, "y": 1.42 }, + { "pinId": "R1.2", "x": 2.45, "y": 0.58 } + ] + }, + { + "chipId": "schematic_component_2", + "center": { "x": 3.0, "y": -0.10000000000000009 }, + "width": 1.1, + "height": 0.388910699999999, + "pins": [ + { "pinId": "R2.1", "x": 3.55, "y": -0.10000000000000002 }, + { "pinId": "R2.2", "x": 2.45, "y": -0.10000000000000016 } + ] + }, + { + "chipId": "schematic_component_3", + "center": { "x": -1.5, "y": -3.0 }, + "width": 1.06, + "height": 1.1, + "pins": [ + { "pinId": "C1.1", "x": -1.5, "y": -2.45 }, + { "pinId": "C1.2", "x": -1.5, "y": -3.55 } + ] + }, + { + "chipId": "schematic_component_4", + "center": { "x": -2.75, "y": 0 }, + "width": 1.06, + "height": 1.1, + "pins": [ + { "pinId": "C2.1", "x": -2.75, "y": 0.55 }, + { "pinId": "C2.2", "x": -2.75, "y": -0.55 } + ] + }, + { + "chipId": "schematic_component_5", + "center": { "x": 1.5, "y": -3.2 }, + "width": 1.2, + "height": 0.8, + "pins": [ + { "pinId": "J1.1", "x": 0.9, "y": -2.9 }, + { "pinId": "J1.2", "x": 0.9, "y": -3.2 }, + { "pinId": "J1.3", "x": 0.9, "y": -3.5 } + ] + } + ], + "directConnections": [ + { "pinIds": ["U1.5", "C2.1"], "netId": "U1.CTRL to C2.pin1" }, + { "pinIds": ["U1.6", "U1.2"], "netId": "U1.THRES to U1.TRIG" }, + { "pinIds": ["R1.2", "U1.7"], "netId": "R1.pin2 to U1.DISCH" }, + { "pinIds": ["U1.7", "R2.2"], "netId": "U1.DISCH to R2.pin2" }, + { "pinIds": ["R2.1", "U1.6"], "netId": "R2.pin1 to U1.THRES" }, + { "pinIds": ["U1.6", "C1.1"], "netId": "U1.THRES to C1.pin1" }, + { "pinIds": ["U1.3", "J1.2"], "netId": "U1.OUT to J1.OUT" }, + { "pinIds": ["R1.2", "U1.8"], "netId": "R1.pin2 to U1.VCC" } + ], + "netConnections": [ + { "netId": "GND", "pinIds": ["U1.1", "C1.2", "C2.2", "J1.3"] }, + { "netId": "VCC", "pinIds": ["U1.4", "U1.8", "R1.1", "J1.1"] } + ], + "availableNetLabelOrientations": { + "VCC": ["y+"], + "GND": ["y-"] + }, + "maxMspPairDistance": 6 +} diff --git a/tests/examples/__snapshots__/example_issue78.snap.svg b/tests/examples/__snapshots__/example_issue78.snap.svg new file mode 100644 index 0000000..8e7ce6a --- /dev/null +++ b/tests/examples/__snapshots__/example_issue78.snap.svg @@ -0,0 +1,286 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/examples/example_issue78.test.ts b/tests/examples/example_issue78.test.ts new file mode 100644 index 0000000..7acb52c --- /dev/null +++ b/tests/examples/example_issue78.test.ts @@ -0,0 +1,50 @@ +import { test, expect } from "bun:test" +import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver" +import "tests/fixtures/matcher" +import inputProblem from "../assets/example_issue78.json" + +/** + * Reproduction for issue #78: "Fix extra trace lines in post-processing step" + * + * NE555 timer circuit with a J1 connector and maxMspPairDistance=6. + * The UntangleTraceSubsolver reroutes L-shaped corners and the path + * concatenation in _applyBestRoute() can produce duplicate consecutive + * points or redundant collinear intermediate points, which render as + * extra short line segments in the schematic output. + * + * This test verifies that all trace paths are clean after solving: + * no duplicate consecutive points and no collinear intermediate points. + */ +test("example_issue78: NE555 with J1 connector should have clean trace paths", () => { + const solver = new SchematicTracePipelineSolver(inputProblem as any) + + solver.solve() + + const traces = solver.traceCleanupSolver!.getOutput().traces + + for (const trace of traces) { + const path = trace.tracePath + + // No duplicate consecutive points (would render as zero-length segments) + for (let i = 0; i < path.length - 1; i++) { + const p1 = path[i] + const p2 = path[i + 1] + const dist = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2) + expect(dist).toBeGreaterThan(1e-9) + } + + // No redundant collinear intermediate points (would render as extra segments) + for (let i = 0; i < path.length - 2; i++) { + const p1 = path[i] + const p2 = path[i + 1] + const p3 = path[i + 2] + const isCollinearH = + Math.abs(p1.y - p2.y) < 1e-9 && Math.abs(p2.y - p3.y) < 1e-9 + const isCollinearV = + Math.abs(p1.x - p2.x) < 1e-9 && Math.abs(p2.x - p3.x) < 1e-9 + expect(isCollinearH || isCollinearV).toBe(false) + } + } + + expect(solver).toMatchSolverSnapshot(import.meta.path) +}) diff --git a/tests/functions/simplifyPath.test.ts b/tests/functions/simplifyPath.test.ts new file mode 100644 index 0000000..ade4ae5 --- /dev/null +++ b/tests/functions/simplifyPath.test.ts @@ -0,0 +1,121 @@ +import { test, expect } from "bun:test" +import { simplifyPath } from "lib/solvers/TraceCleanupSolver/simplifyPath" + +test("simplifyPath removes duplicate consecutive points", () => { + const path = [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 0 }, // duplicate + { x: 1, y: 1 }, + ] + const result = simplifyPath(path) + expect(result).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + ]) +}) + +test("simplifyPath removes collinear horizontal points", () => { + const path = [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 2, y: 0 }, // collinear with neighbors + { x: 3, y: 0 }, + ] + const result = simplifyPath(path) + expect(result).toEqual([ + { x: 0, y: 0 }, + { x: 3, y: 0 }, + ]) +}) + +test("simplifyPath removes collinear vertical points", () => { + const path = [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: 2 }, // collinear with neighbors + { x: 0, y: 3 }, + ] + const result = simplifyPath(path) + expect(result).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 3 }, + ]) +}) + +test("simplifyPath preserves L-shaped turns", () => { + const path = [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, // turn point - should be preserved + { x: 2, y: 1 }, + ] + const result = simplifyPath(path) + expect(result).toEqual(path) +}) + +test("simplifyPath handles path with only two points", () => { + const path = [ + { x: 0, y: 0 }, + { x: 1, y: 1 }, + ] + const result = simplifyPath(path) + expect(result).toEqual(path) +}) + +test("simplifyPath handles empty and single-point paths", () => { + expect(simplifyPath([])).toEqual([]) + expect(simplifyPath([{ x: 0, y: 0 }])).toEqual([{ x: 0, y: 0 }]) +}) + +test("simplifyPath handles duplicate at start of path", () => { + const path = [ + { x: 0, y: 0 }, + { x: 0, y: 0 }, // duplicate at start + { x: 1, y: 0 }, + { x: 1, y: 1 }, + ] + const result = simplifyPath(path) + expect(result).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + ]) +}) + +test("simplifyPath handles duplicate at end of path", () => { + const path = [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 1, y: 1 }, // duplicate at end + ] + const result = simplifyPath(path) + expect(result).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + ]) +}) + +test("simplifyPath handles combined duplicates and collinear points", () => { + // Simulates what _applyBestRoute can produce: path concatenation with + // both duplicate consecutive points and collinear intermediate points + const path = [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 0 }, // duplicate from concatenation + { x: 1, y: 1 }, + { x: 2, y: 1 }, + { x: 3, y: 1 }, // collinear + { x: 4, y: 1 }, + ] + const result = simplifyPath(path) + expect(result).toEqual([ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 4, y: 1 }, + ]) +})