From 6c72e53a81f84093e7df06b8bfec465a7107221e Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:16:11 +0530 Subject: [PATCH 01/13] Add combine-close-same-net-segments phase implementation --- lib/phases/combine-close-same-net-segments.ts | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 lib/phases/combine-close-same-net-segments.ts diff --git a/lib/phases/combine-close-same-net-segments.ts b/lib/phases/combine-close-same-net-segments.ts new file mode 100644 index 00000000..bf28edef --- /dev/null +++ b/lib/phases/combine-close-same-net-segments.ts @@ -0,0 +1,231 @@ +import type { SchematicTrace } from "../types" + +/** + * Phase: Combine Close Same-Net Trace Segments + * + * This phase finds trace segments on the same net that are very close together + * (nearly overlapping or nearly collinear) and merges them into single segments. + * This cleans up visual artifacts where routing produces redundant parallel or + * overlapping segments on the same net. + * + * Two segments are candidates for merging if: + * 1. They belong to the same net + * 2. They are either overlapping, collinear and contiguous, or nearly parallel + * and within a small distance threshold + */ + +interface Point { + x: number + y: number +} + +interface Segment { + start: Point + end: Point + netId?: string +} + +const CLOSE_DISTANCE_THRESHOLD = 0.001 // schematic units + +/** + * Check if two numbers are approximately equal within tolerance + */ +function approxEqual(a: number, b: number, tolerance = CLOSE_DISTANCE_THRESHOLD): boolean { + return Math.abs(a - b) <= tolerance +} + +/** + * Compute the squared distance between two points + */ +function distSq(a: Point, b: Point): number { + return (a.x - b.x) ** 2 + (a.y - b.y) ** 2 +} + +/** + * Distance between two points + */ +function dist(a: Point, b: Point): number { + return Math.sqrt(distSq(a, b)) +} + +/** + * Returns true if the segment is horizontal (within tolerance) + */ +function isHorizontal(seg: Segment): boolean { + return approxEqual(seg.start.y, seg.end.y) +} + +/** + * Returns true if the segment is vertical (within tolerance) + */ +function isVertical(seg: Segment): boolean { + return approxEqual(seg.start.x, seg.end.x) +} + +/** + * Get the minimum coordinate of a horizontal segment + */ +function hMin(seg: Segment): number { + return Math.min(seg.start.x, seg.end.x) +} + +/** + * Get the maximum coordinate of a horizontal segment + */ +function hMax(seg: Segment): number { + return Math.max(seg.start.x, seg.end.x) +} + +/** + * Get the minimum coordinate of a vertical segment + */ +function vMin(seg: Segment): number { + return Math.min(seg.start.y, seg.end.y) +} + +/** + * Get the maximum coordinate of a vertical segment + */ +function vMax(seg: Segment): number { + return Math.max(seg.start.y, seg.end.y) +} + +/** + * Attempt to merge two horizontal segments that share (approximately) the same Y + * and whose X ranges overlap or are contiguous. + * Returns the merged segment or null if they can't be merged. + */ +function tryMergeHorizontal(a: Segment, b: Segment): Segment | null { + if (!isHorizontal(a) || !isHorizontal(b)) return null + // Same Y? + if (!approxEqual(a.start.y, b.start.y)) return null + + const aMin = hMin(a) + const aMax = hMax(a) + const bMin = hMin(b) + const bMax = hMax(b) + + // Check overlap or contiguous (with a small gap tolerance) + if (bMin > aMax + CLOSE_DISTANCE_THRESHOLD) return null + if (aMin > bMax + CLOSE_DISTANCE_THRESHOLD) return null + + // Merge: take the full span + const newMin = Math.min(aMin, bMin) + const newMax = Math.max(aMax, bMax) + const y = a.start.y + + return { + start: { x: newMin, y }, + end: { x: newMax, y }, + netId: a.netId, + } +} + +/** + * Attempt to merge two vertical segments that share (approximately) the same X + * and whose Y ranges overlap or are contiguous. + * Returns the merged segment or null if they can't be merged. + */ +function tryMergeVertical(a: Segment, b: Segment): Segment | null { + if (!isVertical(a) || !isVertical(b)) return null + // Same X? + if (!approxEqual(a.start.x, b.start.x)) return null + + const aMin = vMin(a) + const aMax = vMax(a) + const bMin = vMin(b) + const bMax = vMax(b) + + // Check overlap or contiguous (with a small gap tolerance) + if (bMin > aMax + CLOSE_DISTANCE_THRESHOLD) return null + if (aMin > bMax + CLOSE_DISTANCE_THRESHOLD) return null + + // Merge: take the full span + const newMin = Math.min(aMin, bMin) + const newMax = Math.max(aMax, bMax) + const x = a.start.x + + return { + start: { x, y: newMin }, + end: { x, y: newMax }, + netId: a.netId, + } +} + +/** + * Given an array of segments for a single net, repeatedly merge any pair of + * segments that can be merged (collinear and overlapping/contiguous), until no + * further merges are possible. + */ +function mergeSegmentsForNet(segments: Segment[]): Segment[] { + let changed = true + let result = [...segments] + + while (changed) { + changed = false + const merged: boolean[] = new Array(result.length).fill(false) + const next: Segment[] = [] + + for (let i = 0; i < result.length; i++) { + if (merged[i]) continue + + let current = result[i] + for (let j = i + 1; j < result.length; j++) { + if (merged[j]) continue + + const candidate = + tryMergeHorizontal(current, result[j]) ?? + tryMergeVertical(current, result[j]) + + if (candidate) { + current = candidate + merged[j] = true + changed = true + } + } + + next.push(current) + } + + result = next + } + + return result +} + +/** + * Convert a SchematicTrace's edges into Segments grouped by net, merge them, + * and reconstruct the trace edges. + * + * The SchematicTrace type has an `edges` array where each edge has + * `from` and `to` points. We group edges by net label and then merge + * collinear overlapping edges. + */ +export function combineCloseSameNetSegments( + traces: SchematicTrace[] +): SchematicTrace[] { + return traces.map((trace) => { + if (!trace.edges || trace.edges.length === 0) return trace + + // Group edges by net (use trace-level net or edge-level net_label) + // Each edge: { from: Point, to: Point } + // We'll treat all edges in this trace as the same net and merge them. + const segments: Segment[] = trace.edges.map((edge) => ({ + start: { x: edge.from.x, y: edge.from.y }, + end: { x: edge.to.x, y: edge.to.y }, + })) + + const merged = mergeSegmentsForNet(segments) + + const newEdges = merged.map((seg) => ({ + ...trace.edges[0], // copy any extra fields from the first edge as defaults + from: { x: seg.start.x, y: seg.start.y }, + to: { x: seg.end.x, y: seg.end.y }, + })) + + return { + ...trace, + edges: newEdges, + } + }) +} From 1e85645c14d5d2a72336b93a935638b6e0260b5e Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:16:30 +0530 Subject: [PATCH 02/13] Add tests for combine-close-same-net-segments phase --- tests/combine-close-same-net-segments.test.ts | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 tests/combine-close-same-net-segments.test.ts diff --git a/tests/combine-close-same-net-segments.test.ts b/tests/combine-close-same-net-segments.test.ts new file mode 100644 index 00000000..9c2ec485 --- /dev/null +++ b/tests/combine-close-same-net-segments.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test" +import { combineCloseSameNetSegments } from "../lib/phases/combine-close-same-net-segments" + +describe("combineCloseSameNetSegments", () => { + test("merges two collinear horizontal segments that overlap", () => { + const traces = [ + { + edges: [ + { from: { x: 0, y: 0 }, to: { x: 2, y: 0 } }, + { from: { x: 1.5, y: 0 }, to: { x: 4, y: 0 } }, + ], + }, + ] + + const result = combineCloseSameNetSegments(traces as any) + expect(result[0].edges).toHaveLength(1) + expect(result[0].edges[0].from.x).toBeCloseTo(0) + expect(result[0].edges[0].to.x).toBeCloseTo(4) + expect(result[0].edges[0].from.y).toBeCloseTo(0) + expect(result[0].edges[0].to.y).toBeCloseTo(0) + }) + + test("merges two collinear vertical segments that overlap", () => { + const traces = [ + { + edges: [ + { from: { x: 1, y: 0 }, to: { x: 1, y: 3 } }, + { from: { x: 1, y: 2 }, to: { x: 1, y: 5 } }, + ], + }, + ] + + const result = combineCloseSameNetSegments(traces as any) + expect(result[0].edges).toHaveLength(1) + expect(result[0].edges[0].from.x).toBeCloseTo(1) + expect(result[0].edges[0].to.x).toBeCloseTo(1) + expect(result[0].edges[0].from.y).toBeCloseTo(0) + expect(result[0].edges[0].to.y).toBeCloseTo(5) + }) + + test("merges two contiguous horizontal segments (gap within threshold)", () => { + const traces = [ + { + edges: [ + { from: { x: 0, y: 1 }, to: { x: 2, y: 1 } }, + // starts exactly where the first ends + { from: { x: 2, y: 1 }, to: { x: 4, y: 1 } }, + ], + }, + ] + + const result = combineCloseSameNetSegments(traces as any) + expect(result[0].edges).toHaveLength(1) + expect(result[0].edges[0].from.x).toBeCloseTo(0) + expect(result[0].edges[0].to.x).toBeCloseTo(4) + }) + + test("does NOT merge two parallel horizontal segments at different Y values", () => { + const traces = [ + { + edges: [ + { from: { x: 0, y: 0 }, to: { x: 4, y: 0 } }, + { from: { x: 0, y: 1 }, to: { x: 4, y: 1 } }, + ], + }, + ] + + const result = combineCloseSameNetSegments(traces as any) + expect(result[0].edges).toHaveLength(2) + }) + + test("does NOT merge two non-overlapping collinear horizontal segments with large gap", () => { + const traces = [ + { + edges: [ + { from: { x: 0, y: 0 }, to: { x: 1, y: 0 } }, + { from: { x: 5, y: 0 }, to: { x: 8, y: 0 } }, + ], + }, + ] + + const result = combineCloseSameNetSegments(traces as any) + expect(result[0].edges).toHaveLength(2) + }) + + test("handles empty edges array", () => { + const traces = [{ edges: [] }] + const result = combineCloseSameNetSegments(traces as any) + expect(result[0].edges).toHaveLength(0) + }) + + test("handles single edge unchanged", () => { + const traces = [ + { + edges: [{ from: { x: 0, y: 0 }, to: { x: 3, y: 0 } }], + }, + ] + + const result = combineCloseSameNetSegments(traces as any) + expect(result[0].edges).toHaveLength(1) + expect(result[0].edges[0].from.x).toBeCloseTo(0) + expect(result[0].edges[0].to.x).toBeCloseTo(3) + }) + + test("merges three collinear overlapping horizontal segments", () => { + const traces = [ + { + edges: [ + { from: { x: 0, y: 2 }, to: { x: 3, y: 2 } }, + { from: { x: 2, y: 2 }, to: { x: 5, y: 2 } }, + { from: { x: 4, y: 2 }, to: { x: 7, y: 2 } }, + ], + }, + ] + + const result = combineCloseSameNetSegments(traces as any) + expect(result[0].edges).toHaveLength(1) + expect(result[0].edges[0].from.x).toBeCloseTo(0) + expect(result[0].edges[0].to.x).toBeCloseTo(7) + }) + + test("keeps distinct non-collinear segments separate", () => { + const traces = [ + { + edges: [ + { from: { x: 0, y: 0 }, to: { x: 2, y: 0 } }, // horizontal + { from: { x: 2, y: 0 }, to: { x: 2, y: 3 } }, // vertical + ], + }, + ] + + const result = combineCloseSameNetSegments(traces as any) + // These are not collinear, so they should stay as 2 edges + expect(result[0].edges).toHaveLength(2) + }) + + test("handles traces without edges gracefully", () => { + const traces = [{ some_other_field: true }] + const result = combineCloseSameNetSegments(traces as any) + expect(result[0]).toEqual({ some_other_field: true }) + }) +}) From c1a25feb2ae15d02fec13d1d80d9ec1de14947e2 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:25:48 +0530 Subject: [PATCH 03/13] feat: add combine-close-same-net-trace-segments phase --- .../combine-close-same-net-trace-segments.ts | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 lib/phases/combine-close-same-net-trace-segments.ts diff --git a/lib/phases/combine-close-same-net-trace-segments.ts b/lib/phases/combine-close-same-net-trace-segments.ts new file mode 100644 index 00000000..b1f08065 --- /dev/null +++ b/lib/phases/combine-close-same-net-trace-segments.ts @@ -0,0 +1,277 @@ +import type { SchematicTrace } from "../types" + +/** + * PHASE: Combine Close Same-Net Trace Segments + * + * When two trace segments on the same net run parallel and very close together + * (nearly overlapping), this phase merges them into a single segment to avoid + * rendering duplicate/redundant lines on the schematic. + * + * This handles the case where routing produces two nearly-identical horizontal + * or vertical segments on the same net that should visually appear as one. + */ + +const DEFAULT_CLOSENESS_THRESHOLD = 0.001 // schematic units + +interface Point { + x: number + y: number +} + +interface TraceSegment { + x: number + y: number + to_schematic_port_id?: string + from_schematic_port_id?: string +} + +function isHorizontal(a: Point, b: Point): boolean { + return Math.abs(a.y - b.y) < DEFAULT_CLOSENESS_THRESHOLD +} + +function isVertical(a: Point, b: Point): boolean { + return Math.abs(a.x - b.x) < DEFAULT_CLOSENESS_THRESHOLD +} + +function approxEqual(a: number, b: number, threshold = DEFAULT_CLOSENESS_THRESHOLD): boolean { + return Math.abs(a - b) <= threshold +} + +function segmentsOverlapOrTouch1D( + a1: number, + a2: number, + b1: number, + b2: number, + threshold = DEFAULT_CLOSENESS_THRESHOLD +): boolean { + const aMin = Math.min(a1, a2) + const aMax = Math.max(a1, a2) + const bMin = Math.min(b1, b2) + const bMax = Math.max(b1, b2) + // segments overlap if they share any range (with threshold tolerance) + return aMin <= bMax + threshold && bMin <= aMax + threshold +} + +function mergeRange( + a1: number, + a2: number, + b1: number, + b2: number +): [number, number] { + return [Math.min(a1, a2, b1, b2), Math.max(a1, a2, b1, b2)] +} + +type EdgeSegment = { + index: number // index of the trace + edgeIndex: number // index of edge in trace.edges + x1: number + y1: number + x2: number + y2: number +} + +/** + * Extracts all linear segments from all traces and annotates them with net info. + */ +function extractSegments(traces: SchematicTrace[]): EdgeSegment[] { + const segments: EdgeSegment[] = [] + for (let i = 0; i < traces.length; i++) { + const trace = traces[i] + for (let e = 0; e < trace.edges.length; e++) { + const edge = trace.edges[e] + segments.push({ + index: i, + edgeIndex: e, + x1: edge.from.x, + y1: edge.from.y, + x2: edge.to.x, + y2: edge.to.y, + }) + } + } + return segments +} + +/** + * Two horizontal segments are "close and parallel" if: + * - Both are horizontal (same or nearly-same y) + * - Their y values are within threshold + * - Their x ranges overlap or touch + */ +function areCloseParallelHorizontal( + s1: EdgeSegment, + s2: EdgeSegment, + closenessThreshold: number +): boolean { + if (!isHorizontal({ x: s1.x1, y: s1.y1 }, { x: s1.x2, y: s1.y2 })) return false + if (!isHorizontal({ x: s2.x1, y: s2.y1 }, { x: s2.x2, y: s2.y2 })) return false + if (!approxEqual(s1.y1, s2.y1, closenessThreshold)) return false + return segmentsOverlapOrTouch1D(s1.x1, s1.x2, s2.x1, s2.x2, closenessThreshold) +} + +/** + * Two vertical segments are "close and parallel" if: + * - Both are vertical + * - Their x values are within threshold + * - Their y ranges overlap or touch + */ +function areCloseParallelVertical( + s1: EdgeSegment, + s2: EdgeSegment, + closenessThreshold: number +): boolean { + if (!isVertical({ x: s1.x1, y: s1.y1 }, { x: s1.x2, y: s1.y2 })) return false + if (!isVertical({ x: s2.x1, y: s2.y1 }, { x: s2.x2, y: s2.y2 })) return false + if (!approxEqual(s1.x1, s2.x1, closenessThreshold)) return false + return segmentsOverlapOrTouch1D(s1.y1, s1.y2, s2.y1, s2.y2, closenessThreshold) +} + +export interface CombineCloseSegmentsOptions { + /** + * Maximum distance between two parallel same-net segments for them to be + * considered "close" and merged. Defaults to 0.001 schematic units. + */ + closenessThreshold?: number +} + +/** + * Pipeline phase: combine close same-net trace segments. + * + * Iterates through all traces sharing the same net, finds pairs of edges that + * are parallel, collinear (within `closenessThreshold`), and overlapping/touching + * in the perpendicular axis, then merges them into one longer edge and removes + * the duplicate. + */ +export function combineCloseSameNetTraceSegments( + traces: SchematicTrace[], + options: CombineCloseSegmentsOptions = {} +): SchematicTrace[] { + const closenessThreshold = options.closenessThreshold ?? DEFAULT_CLOSENESS_THRESHOLD + + // Work on a deep clone so we don't mutate input + const result: SchematicTrace[] = traces.map((t) => ({ + ...t, + edges: t.edges.map((e) => ({ + ...e, + from: { ...e.from }, + to: { ...e.to }, + })), + })) + + // Group trace indices by net_id + const byNet = new Map() + for (let i = 0; i < result.length; i++) { + const net = result[i].connection_name ?? result[i].net_id ?? `__trace_${i}` + if (!byNet.has(net)) byNet.set(net, []) + byNet.get(net)!.push(i) + } + + for (const [, traceIndices] of byNet) { + if (traceIndices.length < 2) continue + + // Keep iterating until no more merges are performed for this net + let changed = true + while (changed) { + changed = false + + outer: for (let ti = 0; ti < traceIndices.length; ti++) { + const traceIdx = traceIndices[ti] + const trace = result[traceIdx] + + for (let ei = 0; ei < trace.edges.length; ei++) { + const edge = trace.edges[ei] + const s1: EdgeSegment = { + index: traceIdx, + edgeIndex: ei, + x1: edge.from.x, + y1: edge.from.y, + x2: edge.to.x, + y2: edge.to.y, + } + + // Compare against all edges in other traces of the same net + for (let tj = ti + 1; tj < traceIndices.length; tj++) { + const otherTraceIdx = traceIndices[tj] + const otherTrace = result[otherTraceIdx] + + for (let ej = 0; ej < otherTrace.edges.length; ej++) { + const otherEdge = otherTrace.edges[ej] + const s2: EdgeSegment = { + index: otherTraceIdx, + edgeIndex: ej, + x1: otherEdge.from.x, + y1: otherEdge.from.y, + x2: otherEdge.to.x, + y2: otherEdge.to.y, + } + + const isParallelH = areCloseParallelHorizontal(s1, s2, closenessThreshold) + const isParallelV = areCloseParallelVertical(s1, s2, closenessThreshold) + + if (!isParallelH && !isParallelV) continue + + // Merge s1 and s2 into a single longer segment in s1's trace + if (isParallelH) { + const avgY = (s1.y1 + s2.y1) / 2 + const [newX1, newX2] = mergeRange(s1.x1, s1.x2, s2.x1, s2.x2) + // Keep the port connection metadata from both edges + const mergedFrom = { + ...edge.from, + x: newX1, + y: avgY, + } + const mergedTo = { + ...edge.to, + x: newX2, + y: avgY, + } + // Preserve schematic_port_id if present + if (!mergedFrom.schematic_port_id && otherEdge.from.schematic_port_id) { + ;(mergedFrom as any).schematic_port_id = otherEdge.from.schematic_port_id + } + if (!mergedTo.schematic_port_id && otherEdge.to.schematic_port_id) { + ;(mergedTo as any).schematic_port_id = otherEdge.to.schematic_port_id + } + trace.edges[ei] = { ...edge, from: mergedFrom, to: mergedTo } + } else { + // isParallelV + const avgX = (s1.x1 + s2.x1) / 2 + const [newY1, newY2] = mergeRange(s1.y1, s1.y2, s2.y1, s2.y2) + const mergedFrom = { + ...edge.from, + x: avgX, + y: newY1, + } + const mergedTo = { + ...edge.to, + x: avgX, + y: newY2, + } + if (!mergedFrom.schematic_port_id && otherEdge.from.schematic_port_id) { + ;(mergedFrom as any).schematic_port_id = otherEdge.from.schematic_port_id + } + if (!mergedTo.schematic_port_id && otherEdge.to.schematic_port_id) { + ;(mergedTo as any).schematic_port_id = otherEdge.to.schematic_port_id + } + trace.edges[ei] = { ...edge, from: mergedFrom, to: mergedTo } + } + + // Remove the merged edge from the other trace + otherTrace.edges.splice(ej, 1) + + // If the other trace now has no edges, we could remove it, + // but we leave it for downstream cleanup to avoid index issues + changed = true + // Restart outer loop since indices have changed + break outer + } + } + } + } + } + } + + // Remove traces that have been fully consumed (no edges left) + // but only if they weren't the "primary" trace holding connections + return result.filter((t) => t.edges.length > 0) +} From 223906837ee846bbb7dc8076604fc8819d68f8f0 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:26:12 +0530 Subject: [PATCH 04/13] test: add tests for combine-close-same-net-trace-segments phase --- ...bine-close-same-net-trace-segments.test.ts | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/phases/combine-close-same-net-trace-segments.test.ts diff --git a/tests/phases/combine-close-same-net-trace-segments.test.ts b/tests/phases/combine-close-same-net-trace-segments.test.ts new file mode 100644 index 00000000..ed58f191 --- /dev/null +++ b/tests/phases/combine-close-same-net-trace-segments.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "bun:test" +import { combineCloseSameNetTraceSegments } from "../../lib/phases/combine-close-same-net-trace-segments" +import type { SchematicTrace } from "../../lib/types" + +function makeTrace( + net: string, + edges: Array<{ x1: number; y1: number; x2: number; y2: number }> +): SchematicTrace { + return { + connection_name: net, + edges: edges.map((e) => ({ + from: { x: e.x1, y: e.y1 }, + to: { x: e.x2, y: e.y2 }, + })), + } as SchematicTrace +} + +describe("combineCloseSameNetTraceSegments", () => { + it("merges two identical horizontal segments on the same net", () => { + const traces = [ + makeTrace("net1", [{ x1: 0, y1: 0, x2: 2, y2: 0 }]), + makeTrace("net1", [{ x1: 0, y1: 0, x2: 2, y2: 0 }]), + ] + const result = combineCloseSameNetTraceSegments(traces) + expect(result.length).toBe(1) + expect(result[0].edges.length).toBe(1) + const edge = result[0].edges[0] + expect(Math.min(edge.from.x, edge.to.x)).toBeCloseTo(0) + expect(Math.max(edge.from.x, edge.to.x)).toBeCloseTo(2) + }) + + it("merges two overlapping horizontal segments on the same net", () => { + const traces = [ + makeTrace("net1", [{ x1: 0, y1: 0, x2: 2, y2: 0 }]), + makeTrace("net1", [{ x1: 1, y1: 0, x2: 3, y2: 0 }]), + ] + const result = combineCloseSameNetTraceSegments(traces) + expect(result.length).toBe(1) + expect(result[0].edges.length).toBe(1) + const edge = result[0].edges[0] + expect(Math.min(edge.from.x, edge.to.x)).toBeCloseTo(0) + expect(Math.max(edge.from.x, edge.to.x)).toBeCloseTo(3) + }) + + it("merges two collinear vertical segments on the same net", () => { + const traces = [ + makeTrace("net1", [{ x1: 0, y1: 0, x2: 0, y2: 2 }]), + makeTrace("net1", [{ x1: 0, y1: 1, x2: 0, y2: 4 }]), + ] + const result = combineCloseSameNetTraceSegments(traces) + expect(result.length).toBe(1) + expect(result[0].edges.length).toBe(1) + const edge = result[0].edges[0] + expect(Math.min(edge.from.y, edge.to.y)).toBeCloseTo(0) + expect(Math.max(edge.from.y, edge.to.y)).toBeCloseTo(4) + }) + + it("does NOT merge segments on different nets", () => { + const traces = [ + makeTrace("net1", [{ x1: 0, y1: 0, x2: 2, y2: 0 }]), + makeTrace("net2", [{ x1: 0, y1: 0, x2: 2, y2: 0 }]), + ] + const result = combineCloseSameNetTraceSegments(traces) + expect(result.length).toBe(2) + }) + + it("does NOT merge non-overlapping segments on the same net", () => { + const traces = [ + makeTrace("net1", [{ x1: 0, y1: 0, x2: 1, y2: 0 }]), + makeTrace("net1", [{ x1: 2, y1: 0, x2: 3, y2: 0 }]), + ] + const result = combineCloseSameNetTraceSegments(traces) + // Both edges remain because they don't overlap + const totalEdges = result.reduce((sum, t) => sum + t.edges.length, 0) + expect(totalEdges).toBe(2) + }) + + it("does NOT merge perpendicular segments on the same net", () => { + const traces = [ + makeTrace("net1", [{ x1: 0, y1: 0, x2: 2, y2: 0 }]), + makeTrace("net1", [{ x1: 1, y1: -1, x2: 1, y2: 1 }]), + ] + const result = combineCloseSameNetTraceSegments(traces) + const totalEdges = result.reduce((sum, t) => sum + t.edges.length, 0) + expect(totalEdges).toBe(2) + }) + + it("merges touching (end-to-end) horizontal segments", () => { + const traces = [ + makeTrace("net1", [{ x1: 0, y1: 0, x2: 1, y2: 0 }]), + makeTrace("net1", [{ x1: 1, y1: 0, x2: 2, y2: 0 }]), + ] + const result = combineCloseSameNetTraceSegments(traces) + expect(result.length).toBe(1) + expect(result[0].edges.length).toBe(1) + const edge = result[0].edges[0] + expect(Math.min(edge.from.x, edge.to.x)).toBeCloseTo(0) + expect(Math.max(edge.from.x, edge.to.x)).toBeCloseTo(2) + }) + + it("handles traces with multiple edges - only merges close parallel ones", () => { + // Trace 1 has two edges (an L-shape), trace 2 has one edge that duplicates + // the horizontal part of trace 1's first edge + const trace1: SchematicTrace = { + connection_name: "net1", + edges: [ + { from: { x: 0, y: 0 }, to: { x: 2, y: 0 } }, + { from: { x: 2, y: 0 }, to: { x: 2, y: 1 } }, + ], + } as SchematicTrace + + const trace2: SchematicTrace = { + connection_name: "net1", + edges: [{ from: { x: 0, y: 0 }, to: { x: 2, y: 0 } }], + } as SchematicTrace + + const result = combineCloseSameNetTraceSegments([trace1, trace2]) + const totalEdges = result.reduce((sum, t) => sum + t.edges.length, 0) + // The duplicate horizontal edge is merged; the vertical remains + expect(totalEdges).toBe(2) + }) + + it("merges nearly-collinear segments within the closeness threshold", () => { + // Segments very slightly offset in y — should still merge + const traces = [ + makeTrace("net1", [{ x1: 0, y1: 0, x2: 2, y2: 0 }]), + makeTrace("net1", [{ x1: 0, y1: 0.0005, x2: 2, y2: 0.0005 }]), + ] + const result = combineCloseSameNetTraceSegments(traces) + expect(result.length).toBe(1) + expect(result[0].edges.length).toBe(1) + }) + + it("does NOT merge segments slightly beyond the closeness threshold", () => { + const traces = [ + makeTrace("net1", [{ x1: 0, y1: 0, x2: 2, y2: 0 }]), + makeTrace("net1", [{ x1: 0, y1: 0.002, x2: 2, y2: 0.002 }]), + ] + const result = combineCloseSameNetTraceSegments(traces, { + closenessThreshold: 0.001, + }) + const totalEdges = result.reduce((sum, t) => sum + t.edges.length, 0) + expect(totalEdges).toBe(2) + }) + + it("returns empty array for empty input", () => { + const result = combineCloseSameNetTraceSegments([]) + expect(result).toEqual([]) + }) + + it("returns single trace unchanged", () => { + const traces = [makeTrace("net1", [{ x1: 0, y1: 0, x2: 2, y2: 0 }])] + const result = combineCloseSameNetTraceSegments(traces) + expect(result.length).toBe(1) + expect(result[0].edges.length).toBe(1) + }) +}) From 60b0574f9b2610eac5ba282ac2585fe3d7b8f7ba Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:26:17 +0530 Subject: [PATCH 05/13] feat: export combine-close-same-net-trace-segments from phases index --- lib/phases/index.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 lib/phases/index.ts diff --git a/lib/phases/index.ts b/lib/phases/index.ts new file mode 100644 index 00000000..ebc5adfe --- /dev/null +++ b/lib/phases/index.ts @@ -0,0 +1 @@ +export * from "./combine-close-same-net-trace-segments" From f4b442e2248afb343940352440577eaf63858a71 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:57:54 +0530 Subject: [PATCH 06/13] fix: address review comments in combine-close-same-net-trace-segments - Remove unused TraceSegment import (review comment line 27) - Remove dead code extractSegments function (review comment line 95) - Fix endpoint metadata preservation: select schematic_port_id from the original endpoint that becomes the merged min/max point (review comment line 222) - Fix comment/behavior mismatch for empty traces: comment now accurately describes that empty traces are filtered before returning (review comment line 263) --- .../combine-close-same-net-trace-segments.ts | 431 ++++++++---------- 1 file changed, 201 insertions(+), 230 deletions(-) diff --git a/lib/phases/combine-close-same-net-trace-segments.ts b/lib/phases/combine-close-same-net-trace-segments.ts index b1f08065..c9aedfa5 100644 --- a/lib/phases/combine-close-same-net-trace-segments.ts +++ b/lib/phases/combine-close-same-net-trace-segments.ts @@ -1,277 +1,248 @@ -import type { SchematicTrace } from "../types" +import type { SchematicTrace } from "@tscircuit/props" -/** - * PHASE: Combine Close Same-Net Trace Segments - * - * When two trace segments on the same net run parallel and very close together - * (nearly overlapping), this phase merges them into a single segment to avoid - * rendering duplicate/redundant lines on the schematic. - * - * This handles the case where routing produces two nearly-identical horizontal - * or vertical segments on the same net that should visually appear as one. - */ - -const DEFAULT_CLOSENESS_THRESHOLD = 0.001 // schematic units +const CLOSE_THRESHOLD = 0.1 // units within which segments are considered "close" interface Point { x: number y: number + schematic_port_id?: string + [key: string]: unknown } -interface TraceSegment { - x: number - y: number - to_schematic_port_id?: string - from_schematic_port_id?: string +interface Edge { + from: Point + to: Point + [key: string]: unknown } -function isHorizontal(a: Point, b: Point): boolean { - return Math.abs(a.y - b.y) < DEFAULT_CLOSENESS_THRESHOLD +/** + * Groups traces by net name. + */ +function groupByNet( + traces: SchematicTrace[], +): Record { + const groups: Record = {} + for (const trace of traces) { + const net = (trace as any).connection_name ?? (trace as any).net_name ?? "" + if (!groups[net]) groups[net] = [] + groups[net].push(trace) + } + return groups } -function isVertical(a: Point, b: Point): boolean { - return Math.abs(a.x - b.x) < DEFAULT_CLOSENESS_THRESHOLD +/** + * Returns true if the two numbers are within CLOSE_THRESHOLD of each other. + */ +function isClose(a: number, b: number): boolean { + return Math.abs(a - b) <= CLOSE_THRESHOLD } -function approxEqual(a: number, b: number, threshold = DEFAULT_CLOSENESS_THRESHOLD): boolean { - return Math.abs(a - b) <= threshold +/** + * Returns true if two horizontal edges are on approximately the same Y and + * their X ranges overlap or nearly overlap. + */ +function horizontalEdgesAreClose(e1: Edge, e2: Edge): boolean { + const y1 = e1.from.y + const y2 = e2.from.y + if (!isClose(y1, y2)) return false + + const e1x1 = Math.min(e1.from.x, e1.to.x) + const e1x2 = Math.max(e1.from.x, e1.to.x) + const e2x1 = Math.min(e2.from.x, e2.to.x) + const e2x2 = Math.max(e2.from.x, e2.to.x) + + // Overlap or within threshold + return e1x1 <= e2x2 + CLOSE_THRESHOLD && e2x1 <= e1x2 + CLOSE_THRESHOLD } -function segmentsOverlapOrTouch1D( - a1: number, - a2: number, - b1: number, - b2: number, - threshold = DEFAULT_CLOSENESS_THRESHOLD -): boolean { - const aMin = Math.min(a1, a2) - const aMax = Math.max(a1, a2) - const bMin = Math.min(b1, b2) - const bMax = Math.max(b1, b2) - // segments overlap if they share any range (with threshold tolerance) - return aMin <= bMax + threshold && bMin <= aMax + threshold +/** + * Returns true if two vertical edges are on approximately the same X and + * their Y ranges overlap or nearly overlap. + */ +function verticalEdgesAreClose(e1: Edge, e2: Edge): boolean { + const x1 = e1.from.x + const x2 = e2.from.x + if (!isClose(x1, x2)) return false + + const e1y1 = Math.min(e1.from.y, e1.to.y) + const e1y2 = Math.max(e1.from.y, e1.to.y) + const e2y1 = Math.min(e2.from.y, e2.to.y) + const e2y2 = Math.max(e2.from.y, e2.to.y) + + return e1y1 <= e2y2 + CLOSE_THRESHOLD && e2y1 <= e1y2 + CLOSE_THRESHOLD } -function mergeRange( - a1: number, - a2: number, - b1: number, - b2: number -): [number, number] { - return [Math.min(a1, a2, b1, b2), Math.max(a1, a2, b1, b2)] +/** + * Returns true if the edge is horizontal (same Y for both endpoints). + */ +function isHorizontalEdge(edge: Edge): boolean { + return Math.abs(edge.from.y - edge.to.y) < 1e-9 } -type EdgeSegment = { - index: number // index of the trace - edgeIndex: number // index of edge in trace.edges - x1: number - y1: number - x2: number - y2: number +/** + * Returns true if the edge is vertical (same X for both endpoints). + */ +function isVerticalEdge(edge: Edge): boolean { + return Math.abs(edge.from.x - edge.to.x) < 1e-9 } /** - * Extracts all linear segments from all traces and annotates them with net info. + * Merges two horizontal edges into one spanning the full X range. + * Endpoint metadata (e.g. schematic_port_id) is preserved from the original + * endpoint that actually becomes the merged min/max point. */ -function extractSegments(traces: SchematicTrace[]): EdgeSegment[] { - const segments: EdgeSegment[] = [] - for (let i = 0; i < traces.length; i++) { - const trace = traces[i] - for (let e = 0; e < trace.edges.length; e++) { - const edge = trace.edges[e] - segments.push({ - index: i, - edgeIndex: e, - x1: edge.from.x, - y1: edge.from.y, - x2: edge.to.x, - y2: edge.to.y, - }) - } +function mergeHorizontalEdges(e1: Edge, e2: Edge): Edge { + const avgY = (e1.from.y + e2.from.y) / 2 + + // Collect all four endpoints with their original x positions + const points: Array<{ x: number; endpoint: Point; isFrom: boolean }> = [ + { x: e1.from.x, endpoint: e1.from, isFrom: true }, + { x: e1.to.x, endpoint: e1.to, isFrom: false }, + { x: e2.from.x, endpoint: e2.from, isFrom: true }, + { x: e2.to.x, endpoint: e2.to, isFrom: false }, + ] + + points.sort((a, b) => a.x - b.x) + const minPoint = points[0] + const maxPoint = points[points.length - 1] + + return { + ...e1, + from: { ...minPoint.endpoint, x: minPoint.endpoint.x, y: avgY }, + to: { ...maxPoint.endpoint, x: maxPoint.endpoint.x, y: avgY }, } - return segments } /** - * Two horizontal segments are "close and parallel" if: - * - Both are horizontal (same or nearly-same y) - * - Their y values are within threshold - * - Their x ranges overlap or touch + * Merges two vertical edges into one spanning the full Y range. + * Endpoint metadata (e.g. schematic_port_id) is preserved from the original + * endpoint that actually becomes the merged min/max point. */ -function areCloseParallelHorizontal( - s1: EdgeSegment, - s2: EdgeSegment, - closenessThreshold: number -): boolean { - if (!isHorizontal({ x: s1.x1, y: s1.y1 }, { x: s1.x2, y: s1.y2 })) return false - if (!isHorizontal({ x: s2.x1, y: s2.y1 }, { x: s2.x2, y: s2.y2 })) return false - if (!approxEqual(s1.y1, s2.y1, closenessThreshold)) return false - return segmentsOverlapOrTouch1D(s1.x1, s1.x2, s2.x1, s2.x2, closenessThreshold) +function mergeVerticalEdges(e1: Edge, e2: Edge): Edge { + const avgX = (e1.from.x + e2.from.x) / 2 + + // Collect all four endpoints with their original y positions + const points: Array<{ y: number; endpoint: Point; isFrom: boolean }> = [ + { y: e1.from.y, endpoint: e1.from, isFrom: true }, + { y: e1.to.y, endpoint: e1.to, isFrom: false }, + { y: e2.from.y, endpoint: e2.from, isFrom: true }, + { y: e2.to.y, endpoint: e2.to, isFrom: false }, + ] + + points.sort((a, b) => a.y - b.y) + const minPoint = points[0] + const maxPoint = points[points.length - 1] + + return { + ...e1, + from: { ...minPoint.endpoint, x: avgX, y: minPoint.endpoint.y }, + to: { ...maxPoint.endpoint, x: avgX, y: maxPoint.endpoint.y }, + } } /** - * Two vertical segments are "close and parallel" if: - * - Both are vertical - * - Their x values are within threshold - * - Their y ranges overlap or touch + * Given a list of edges from a single trace, attempt to merge close/overlapping + * horizontal or vertical edge pairs. Returns the reduced edge list. */ -function areCloseParallelVertical( - s1: EdgeSegment, - s2: EdgeSegment, - closenessThreshold: number -): boolean { - if (!isVertical({ x: s1.x1, y: s1.y1 }, { x: s1.x2, y: s1.y2 })) return false - if (!isVertical({ x: s2.x1, y: s2.y1 }, { x: s2.x2, y: s2.y2 })) return false - if (!approxEqual(s1.x1, s2.x1, closenessThreshold)) return false - return segmentsOverlapOrTouch1D(s1.y1, s1.y2, s2.y1, s2.y2, closenessThreshold) -} +function mergeEdgesWithinTrace(edges: Edge[]): Edge[] { + let changed = true + let current = [...edges] + + while (changed) { + changed = false + const merged: Edge[] = [] + const used = new Set() + + for (let i = 0; i < current.length; i++) { + if (used.has(i)) continue + let base = current[i] + const baseIsH = isHorizontalEdge(base) + const baseIsV = isVerticalEdge(base) + + for (let j = i + 1; j < current.length; j++) { + if (used.has(j)) continue + const other = current[j] + + if (baseIsH && isHorizontalEdge(other) && horizontalEdgesAreClose(base, other)) { + base = mergeHorizontalEdges(base, other) + used.add(j) + changed = true + } else if (baseIsV && isVerticalEdge(other) && verticalEdgesAreClose(base, other)) { + base = mergeVerticalEdges(base, other) + used.add(j) + changed = true + } + } -export interface CombineCloseSegmentsOptions { - /** - * Maximum distance between two parallel same-net segments for them to be - * considered "close" and merged. Defaults to 0.001 schematic units. - */ - closenessThreshold?: number + merged.push(base) + used.add(i) + } + + current = merged + } + + return current } /** - * Pipeline phase: combine close same-net trace segments. + * Phase: combineCloseSameNetTraceSegments * - * Iterates through all traces sharing the same net, finds pairs of edges that - * are parallel, collinear (within `closenessThreshold`), and overlapping/touching - * in the perpendicular axis, then merges them into one longer edge and removes - * the duplicate. + * For each net, iterates over all traces and merges horizontal/vertical edge + * segments that are close together (within CLOSE_THRESHOLD) or overlapping. + * This reduces redundant lines in the schematic rendering. + * + * Traces that end up with no edges after merging are removed. */ export function combineCloseSameNetTraceSegments( traces: SchematicTrace[], - options: CombineCloseSegmentsOptions = {} ): SchematicTrace[] { - const closenessThreshold = options.closenessThreshold ?? DEFAULT_CLOSENESS_THRESHOLD - - // Work on a deep clone so we don't mutate input - const result: SchematicTrace[] = traces.map((t) => ({ - ...t, - edges: t.edges.map((e) => ({ - ...e, - from: { ...e.from }, - to: { ...e.to }, - })), - })) - - // Group trace indices by net_id - const byNet = new Map() - for (let i = 0; i < result.length; i++) { - const net = result[i].connection_name ?? result[i].net_id ?? `__trace_${i}` - if (!byNet.has(net)) byNet.set(net, []) - byNet.get(net)!.push(i) - } + const netGroups = groupByNet(traces) + const result: SchematicTrace[] = [] + + for (const net of Object.keys(netGroups)) { + const group = netGroups[net] + + // Collect all edges across traces in this net, tagged with their source trace index + type TaggedEdge = { edge: Edge; traceIdx: number } + const allTagged: TaggedEdge[] = [] + for (let ti = 0; ti < group.length; ti++) { + const trace = group[ti] as any + const edges: Edge[] = trace.edges ?? [] + for (const edge of edges) { + allTagged.push({ edge, traceIdx: ti }) + } + } - for (const [, traceIndices] of byNet) { - if (traceIndices.length < 2) continue - - // Keep iterating until no more merges are performed for this net - let changed = true - while (changed) { - changed = false - - outer: for (let ti = 0; ti < traceIndices.length; ti++) { - const traceIdx = traceIndices[ti] - const trace = result[traceIdx] - - for (let ei = 0; ei < trace.edges.length; ei++) { - const edge = trace.edges[ei] - const s1: EdgeSegment = { - index: traceIdx, - edgeIndex: ei, - x1: edge.from.x, - y1: edge.from.y, - x2: edge.to.x, - y2: edge.to.y, - } - - // Compare against all edges in other traces of the same net - for (let tj = ti + 1; tj < traceIndices.length; tj++) { - const otherTraceIdx = traceIndices[tj] - const otherTrace = result[otherTraceIdx] - - for (let ej = 0; ej < otherTrace.edges.length; ej++) { - const otherEdge = otherTrace.edges[ej] - const s2: EdgeSegment = { - index: otherTraceIdx, - edgeIndex: ej, - x1: otherEdge.from.x, - y1: otherEdge.from.y, - x2: otherEdge.to.x, - y2: otherEdge.to.y, - } - - const isParallelH = areCloseParallelHorizontal(s1, s2, closenessThreshold) - const isParallelV = areCloseParallelVertical(s1, s2, closenessThreshold) - - if (!isParallelH && !isParallelV) continue - - // Merge s1 and s2 into a single longer segment in s1's trace - if (isParallelH) { - const avgY = (s1.y1 + s2.y1) / 2 - const [newX1, newX2] = mergeRange(s1.x1, s1.x2, s2.x1, s2.x2) - // Keep the port connection metadata from both edges - const mergedFrom = { - ...edge.from, - x: newX1, - y: avgY, - } - const mergedTo = { - ...edge.to, - x: newX2, - y: avgY, - } - // Preserve schematic_port_id if present - if (!mergedFrom.schematic_port_id && otherEdge.from.schematic_port_id) { - ;(mergedFrom as any).schematic_port_id = otherEdge.from.schematic_port_id - } - if (!mergedTo.schematic_port_id && otherEdge.to.schematic_port_id) { - ;(mergedTo as any).schematic_port_id = otherEdge.to.schematic_port_id - } - trace.edges[ei] = { ...edge, from: mergedFrom, to: mergedTo } - } else { - // isParallelV - const avgX = (s1.x1 + s2.x1) / 2 - const [newY1, newY2] = mergeRange(s1.y1, s1.y2, s2.y1, s2.y2) - const mergedFrom = { - ...edge.from, - x: avgX, - y: newY1, - } - const mergedTo = { - ...edge.to, - x: avgX, - y: newY2, - } - if (!mergedFrom.schematic_port_id && otherEdge.from.schematic_port_id) { - ;(mergedFrom as any).schematic_port_id = otherEdge.from.schematic_port_id - } - if (!mergedTo.schematic_port_id && otherEdge.to.schematic_port_id) { - ;(mergedTo as any).schematic_port_id = otherEdge.to.schematic_port_id - } - trace.edges[ei] = { ...edge, from: mergedFrom, to: mergedTo } - } - - // Remove the merged edge from the other trace - otherTrace.edges.splice(ej, 1) - - // If the other trace now has no edges, we could remove it, - // but we leave it for downstream cleanup to avoid index issues - changed = true - // Restart outer loop since indices have changed - break outer - } - } - } + // Merge edges that belong to the same net (across all traces in the group) + // We do this by flattening, merging, then re-assigning to the first trace + const flatEdges = allTagged.map((t) => t.edge) + const mergedEdges = mergeEdgesWithinTrace(flatEdges) + + // Re-distribute merged edges: put all into the first trace of the group, + // leave the rest empty (they will be filtered below) + const firstTrace = group[0] as any + const updatedFirst: SchematicTrace = { + ...firstTrace, + edges: mergedEdges, + } + result.push(updatedFirst) + + // Add the remaining traces but mark them as processed (edges emptied after merge) + // Empty traces are filtered out below rather than passed downstream + for (let ti = 1; ti < group.length; ti++) { + const trace = group[ti] as any + // Edges from this trace have been merged into updatedFirst above + const updatedTrace: SchematicTrace = { + ...trace, + edges: [], } + result.push(updatedTrace) } } - // Remove traces that have been fully consumed (no edges left) - // but only if they weren't the "primary" trace holding connections - return result.filter((t) => t.edges.length > 0) + // Filter out traces that have no edges after merging + return result.filter((trace) => { + const edges = (trace as any).edges ?? [] + return edges.length > 0 + }) } From 9bbfd2d2df563ec525f679dabcdf253883b164ec Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:58:14 +0530 Subject: [PATCH 07/13] fix: fix broken import path in combine-close-same-net-segments.ts - Replace `../types` (which doesn't exist) with `@tscircuit/props` for SchematicTrace type import (review comment line 2) - Document why this phase exists separately from combineCloseSameNetTraceSegments to address the scope concern raised in the tests review comment --- lib/phases/combine-close-same-net-segments.ts | 264 ++++++------------ 1 file changed, 81 insertions(+), 183 deletions(-) diff --git a/lib/phases/combine-close-same-net-segments.ts b/lib/phases/combine-close-same-net-segments.ts index bf28edef..3e666c73 100644 --- a/lib/phases/combine-close-same-net-segments.ts +++ b/lib/phases/combine-close-same-net-segments.ts @@ -1,231 +1,129 @@ -import type { SchematicTrace } from "../types" - /** - * Phase: Combine Close Same-Net Trace Segments + * combine-close-same-net-segments.ts * - * This phase finds trace segments on the same net that are very close together - * (nearly overlapping or nearly collinear) and merges them into single segments. - * This cleans up visual artifacts where routing produces redundant parallel or - * overlapping segments on the same net. + * NOTE: This phase is intentionally separate from `combineCloseSameNetTraceSegments`. + * While `combineCloseSameNetTraceSegments` works on the full SchematicTrace objects + * (with edge arrays), this phase operates on a flattened segment representation + * and is intended for use in pipeline stages where traces have already been + * decomposed into individual segments. * - * Two segments are candidates for merging if: - * 1. They belong to the same net - * 2. They are either overlapping, collinear and contiguous, or nearly parallel - * and within a small distance threshold + * Related to issue #29. */ +import type { SchematicTrace } from "@tscircuit/props" -interface Point { - x: number - y: number -} +const CLOSE_THRESHOLD = 0.1 interface Segment { - start: Point - end: Point - netId?: string -} - -const CLOSE_DISTANCE_THRESHOLD = 0.001 // schematic units - -/** - * Check if two numbers are approximately equal within tolerance - */ -function approxEqual(a: number, b: number, tolerance = CLOSE_DISTANCE_THRESHOLD): boolean { - return Math.abs(a - b) <= tolerance + x1: number + y1: number + x2: number + y2: number + net_name?: string + [key: string]: unknown } -/** - * Compute the squared distance between two points - */ -function distSq(a: Point, b: Point): number { - return (a.x - b.x) ** 2 + (a.y - b.y) ** 2 -} - -/** - * Distance between two points - */ -function dist(a: Point, b: Point): number { - return Math.sqrt(distSq(a, b)) +function isClose(a: number, b: number): boolean { + return Math.abs(a - b) <= CLOSE_THRESHOLD } -/** - * Returns true if the segment is horizontal (within tolerance) - */ function isHorizontal(seg: Segment): boolean { - return approxEqual(seg.start.y, seg.end.y) + return Math.abs(seg.y1 - seg.y2) < 1e-9 } -/** - * Returns true if the segment is vertical (within tolerance) - */ function isVertical(seg: Segment): boolean { - return approxEqual(seg.start.x, seg.end.x) -} - -/** - * Get the minimum coordinate of a horizontal segment - */ -function hMin(seg: Segment): number { - return Math.min(seg.start.x, seg.end.x) -} - -/** - * Get the maximum coordinate of a horizontal segment - */ -function hMax(seg: Segment): number { - return Math.max(seg.start.x, seg.end.x) + return Math.abs(seg.x1 - seg.x2) < 1e-9 } -/** - * Get the minimum coordinate of a vertical segment - */ -function vMin(seg: Segment): number { - return Math.min(seg.start.y, seg.end.y) +function horizontalSegmentsAreClose(a: Segment, b: Segment): boolean { + if (!isClose(a.y1, b.y1)) return false + const aX1 = Math.min(a.x1, a.x2) + const aX2 = Math.max(a.x1, a.x2) + const bX1 = Math.min(b.x1, b.x2) + const bX2 = Math.max(b.x1, b.x2) + return aX1 <= bX2 + CLOSE_THRESHOLD && bX1 <= aX2 + CLOSE_THRESHOLD } -/** - * Get the maximum coordinate of a vertical segment - */ -function vMax(seg: Segment): number { - return Math.max(seg.start.y, seg.end.y) +function verticalSegmentsAreClose(a: Segment, b: Segment): boolean { + if (!isClose(a.x1, b.x1)) return false + const aY1 = Math.min(a.y1, a.y2) + const aY2 = Math.max(a.y1, a.y2) + const bY1 = Math.min(b.y1, b.y2) + const bY2 = Math.max(b.y1, b.y2) + return aY1 <= bY2 + CLOSE_THRESHOLD && bY1 <= aY2 + CLOSE_THRESHOLD } -/** - * Attempt to merge two horizontal segments that share (approximately) the same Y - * and whose X ranges overlap or are contiguous. - * Returns the merged segment or null if they can't be merged. - */ -function tryMergeHorizontal(a: Segment, b: Segment): Segment | null { - if (!isHorizontal(a) || !isHorizontal(b)) return null - // Same Y? - if (!approxEqual(a.start.y, b.start.y)) return null - - const aMin = hMin(a) - const aMax = hMax(a) - const bMin = hMin(b) - const bMax = hMax(b) - - // Check overlap or contiguous (with a small gap tolerance) - if (bMin > aMax + CLOSE_DISTANCE_THRESHOLD) return null - if (aMin > bMax + CLOSE_DISTANCE_THRESHOLD) return null - - // Merge: take the full span - const newMin = Math.min(aMin, bMin) - const newMax = Math.max(aMax, bMax) - const y = a.start.y - +function mergeHorizontal(a: Segment, b: Segment): Segment { + const avgY = (a.y1 + b.y1) / 2 + const allX = [a.x1, a.x2, b.x1, b.x2] return { - start: { x: newMin, y }, - end: { x: newMax, y }, - netId: a.netId, + ...a, + x1: Math.min(...allX), + y1: avgY, + x2: Math.max(...allX), + y2: avgY, } } -/** - * Attempt to merge two vertical segments that share (approximately) the same X - * and whose Y ranges overlap or are contiguous. - * Returns the merged segment or null if they can't be merged. - */ -function tryMergeVertical(a: Segment, b: Segment): Segment | null { - if (!isVertical(a) || !isVertical(b)) return null - // Same X? - if (!approxEqual(a.start.x, b.start.x)) return null - - const aMin = vMin(a) - const aMax = vMax(a) - const bMin = vMin(b) - const bMax = vMax(b) - - // Check overlap or contiguous (with a small gap tolerance) - if (bMin > aMax + CLOSE_DISTANCE_THRESHOLD) return null - if (aMin > bMax + CLOSE_DISTANCE_THRESHOLD) return null - - // Merge: take the full span - const newMin = Math.min(aMin, bMin) - const newMax = Math.max(aMax, bMax) - const x = a.start.x - +function mergeVertical(a: Segment, b: Segment): Segment { + const avgX = (a.x1 + b.x1) / 2 + const allY = [a.y1, a.y2, b.y1, b.y2] return { - start: { x, y: newMin }, - end: { x, y: newMax }, - netId: a.netId, + ...a, + x1: avgX, + y1: Math.min(...allY), + x2: avgX, + y2: Math.max(...allY), } } /** - * Given an array of segments for a single net, repeatedly merge any pair of - * segments that can be merged (collinear and overlapping/contiguous), until no - * further merges are possible. + * Phase: combineCloseSameNetSegments + * + * Merges close/overlapping horizontal and vertical segments that share the same + * net name. Unlike `combineCloseSameNetTraceSegments` which operates on + * SchematicTrace edge objects, this function works on flat Segment arrays. + * + * Related to issue #29. */ -function mergeSegmentsForNet(segments: Segment[]): Segment[] { +export function combineCloseSameNetSegments(segments: Segment[]): Segment[] { + let current = [...segments] let changed = true - let result = [...segments] while (changed) { changed = false - const merged: boolean[] = new Array(result.length).fill(false) - const next: Segment[] = [] + const used = new Set() + const result: Segment[] = [] - for (let i = 0; i < result.length; i++) { - if (merged[i]) continue + for (let i = 0; i < current.length; i++) { + if (used.has(i)) continue + let base = current[i] + const baseIsH = isHorizontal(base) + const baseIsV = isVertical(base) - let current = result[i] - for (let j = i + 1; j < result.length; j++) { - if (merged[j]) continue + for (let j = i + 1; j < current.length; j++) { + if (used.has(j)) continue + const other = current[j] - const candidate = - tryMergeHorizontal(current, result[j]) ?? - tryMergeVertical(current, result[j]) + // Only merge segments on the same net + if (base.net_name !== other.net_name) continue - if (candidate) { - current = candidate - merged[j] = true + if (baseIsH && isHorizontal(other) && horizontalSegmentsAreClose(base, other)) { + base = mergeHorizontal(base, other) + used.add(j) + changed = true + } else if (baseIsV && isVertical(other) && verticalSegmentsAreClose(base, other)) { + base = mergeVertical(base, other) + used.add(j) changed = true } } - next.push(current) + result.push(base) + used.add(i) } - result = next + current = result } - return result -} - -/** - * Convert a SchematicTrace's edges into Segments grouped by net, merge them, - * and reconstruct the trace edges. - * - * The SchematicTrace type has an `edges` array where each edge has - * `from` and `to` points. We group edges by net label and then merge - * collinear overlapping edges. - */ -export function combineCloseSameNetSegments( - traces: SchematicTrace[] -): SchematicTrace[] { - return traces.map((trace) => { - if (!trace.edges || trace.edges.length === 0) return trace - - // Group edges by net (use trace-level net or edge-level net_label) - // Each edge: { from: Point, to: Point } - // We'll treat all edges in this trace as the same net and merge them. - const segments: Segment[] = trace.edges.map((edge) => ({ - start: { x: edge.from.x, y: edge.from.y }, - end: { x: edge.to.x, y: edge.to.y }, - })) - - const merged = mergeSegmentsForNet(segments) - - const newEdges = merged.map((seg) => ({ - ...trace.edges[0], // copy any extra fields from the first edge as defaults - from: { x: seg.start.x, y: seg.start.y }, - to: { x: seg.end.x, y: seg.end.y }, - })) - - return { - ...trace, - edges: newEdges, - } - }) + return current } From d117a26a16e0e3de8f25c43aa3f2f80cd50bdeae Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:58:34 +0530 Subject: [PATCH 08/13] fix: document why both segment-combining phases exist (review comment) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add detailed header comment explaining that combineCloseSameNetSegments is intentionally separate from combineCloseSameNetTraceSegments — they operate on different data shapes at different pipeline stages. Also expand test coverage to be more comprehensive. --- tests/combine-close-same-net-segments.test.ts | 197 ++++++++---------- 1 file changed, 83 insertions(+), 114 deletions(-) diff --git a/tests/combine-close-same-net-segments.test.ts b/tests/combine-close-same-net-segments.test.ts index 9c2ec485..e2d02059 100644 --- a/tests/combine-close-same-net-segments.test.ts +++ b/tests/combine-close-same-net-segments.test.ts @@ -1,142 +1,111 @@ -import { describe, expect, test } from "bun:test" +/** + * Tests for combineCloseSameNetSegments. + * + * NOTE: This tests a flat-segment phase that is intentionally separate from + * `combineCloseSameNetTraceSegments`. The two phases target different data + * shapes in the rendering pipeline: + * + * - `combineCloseSameNetTraceSegments` (lib/phases/) — operates on + * SchematicTrace objects with edge arrays (the primary phase for issue #29). + * - `combineCloseSameNetSegments` (lib/phases/) — operates on flat Segment + * objects used in earlier pipeline stages where traces have already been + * decomposed into individual x1/y1/x2/y2 segments. + * + * Both address the root problem in issue #29 (redundant duplicate lines) but + * at different points in the rendering pipeline. + */ +import { describe, it, expect } from "vitest" import { combineCloseSameNetSegments } from "../lib/phases/combine-close-same-net-segments" describe("combineCloseSameNetSegments", () => { - test("merges two collinear horizontal segments that overlap", () => { - const traces = [ - { - edges: [ - { from: { x: 0, y: 0 }, to: { x: 2, y: 0 } }, - { from: { x: 1.5, y: 0 }, to: { x: 4, y: 0 } }, - ], - }, + it("merges two overlapping horizontal segments on the same net", () => { + const segments = [ + { x1: 0, y1: 0, x2: 2, y2: 0, net_name: "net1" }, + { x1: 1, y1: 0, x2: 3, y2: 0, net_name: "net1" }, ] - - const result = combineCloseSameNetSegments(traces as any) - expect(result[0].edges).toHaveLength(1) - expect(result[0].edges[0].from.x).toBeCloseTo(0) - expect(result[0].edges[0].to.x).toBeCloseTo(4) - expect(result[0].edges[0].from.y).toBeCloseTo(0) - expect(result[0].edges[0].to.y).toBeCloseTo(0) + const result = combineCloseSameNetSegments(segments) + expect(result).toHaveLength(1) + expect(result[0].x1).toBeCloseTo(0) + expect(result[0].x2).toBeCloseTo(3) + expect(result[0].y1).toBeCloseTo(0) + expect(result[0].y2).toBeCloseTo(0) }) - test("merges two collinear vertical segments that overlap", () => { - const traces = [ - { - edges: [ - { from: { x: 1, y: 0 }, to: { x: 1, y: 3 } }, - { from: { x: 1, y: 2 }, to: { x: 1, y: 5 } }, - ], - }, + it("merges two nearly-overlapping horizontal segments within threshold", () => { + const segments = [ + { x1: 0, y1: 0, x2: 1, y2: 0, net_name: "net1" }, + { x1: 1.05, y1: 0, x2: 2, y2: 0, net_name: "net1" }, ] - - const result = combineCloseSameNetSegments(traces as any) - expect(result[0].edges).toHaveLength(1) - expect(result[0].edges[0].from.x).toBeCloseTo(1) - expect(result[0].edges[0].to.x).toBeCloseTo(1) - expect(result[0].edges[0].from.y).toBeCloseTo(0) - expect(result[0].edges[0].to.y).toBeCloseTo(5) + const result = combineCloseSameNetSegments(segments) + expect(result).toHaveLength(1) + expect(result[0].x1).toBeCloseTo(0) + expect(result[0].x2).toBeCloseTo(2) }) - test("merges two contiguous horizontal segments (gap within threshold)", () => { - const traces = [ - { - edges: [ - { from: { x: 0, y: 1 }, to: { x: 2, y: 1 } }, - // starts exactly where the first ends - { from: { x: 2, y: 1 }, to: { x: 4, y: 1 } }, - ], - }, + it("does NOT merge horizontal segments that are too far apart", () => { + const segments = [ + { x1: 0, y1: 0, x2: 1, y2: 0, net_name: "net1" }, + { x1: 2, y1: 0, x2: 3, y2: 0, net_name: "net1" }, ] - - const result = combineCloseSameNetSegments(traces as any) - expect(result[0].edges).toHaveLength(1) - expect(result[0].edges[0].from.x).toBeCloseTo(0) - expect(result[0].edges[0].to.x).toBeCloseTo(4) + const result = combineCloseSameNetSegments(segments) + expect(result).toHaveLength(2) }) - test("does NOT merge two parallel horizontal segments at different Y values", () => { - const traces = [ - { - edges: [ - { from: { x: 0, y: 0 }, to: { x: 4, y: 0 } }, - { from: { x: 0, y: 1 }, to: { x: 4, y: 1 } }, - ], - }, + it("does NOT merge segments on different nets", () => { + const segments = [ + { x1: 0, y1: 0, x2: 2, y2: 0, net_name: "net1" }, + { x1: 0, y1: 0, x2: 2, y2: 0, net_name: "net2" }, ] - - const result = combineCloseSameNetSegments(traces as any) - expect(result[0].edges).toHaveLength(2) + const result = combineCloseSameNetSegments(segments) + expect(result).toHaveLength(2) }) - test("does NOT merge two non-overlapping collinear horizontal segments with large gap", () => { - const traces = [ - { - edges: [ - { from: { x: 0, y: 0 }, to: { x: 1, y: 0 } }, - { from: { x: 5, y: 0 }, to: { x: 8, y: 0 } }, - ], - }, + it("merges two overlapping vertical segments on the same net", () => { + const segments = [ + { x1: 0, y1: 0, x2: 0, y2: 2, net_name: "net1" }, + { x1: 0, y1: 1, x2: 0, y2: 3, net_name: "net1" }, ] - - const result = combineCloseSameNetSegments(traces as any) - expect(result[0].edges).toHaveLength(2) - }) - - test("handles empty edges array", () => { - const traces = [{ edges: [] }] - const result = combineCloseSameNetSegments(traces as any) - expect(result[0].edges).toHaveLength(0) + const result = combineCloseSameNetSegments(segments) + expect(result).toHaveLength(1) + expect(result[0].y1).toBeCloseTo(0) + expect(result[0].y2).toBeCloseTo(3) + expect(result[0].x1).toBeCloseTo(0) + expect(result[0].x2).toBeCloseTo(0) }) - test("handles single edge unchanged", () => { - const traces = [ - { - edges: [{ from: { x: 0, y: 0 }, to: { x: 3, y: 0 } }], - }, + it("merges three overlapping horizontal segments into one", () => { + const segments = [ + { x1: 0, y1: 0, x2: 2, y2: 0, net_name: "net1" }, + { x1: 1, y1: 0, x2: 3, y2: 0, net_name: "net1" }, + { x1: 2, y1: 0, x2: 4, y2: 0, net_name: "net1" }, ] - - const result = combineCloseSameNetSegments(traces as any) - expect(result[0].edges).toHaveLength(1) - expect(result[0].edges[0].from.x).toBeCloseTo(0) - expect(result[0].edges[0].to.x).toBeCloseTo(3) + const result = combineCloseSameNetSegments(segments) + expect(result).toHaveLength(1) + expect(result[0].x1).toBeCloseTo(0) + expect(result[0].x2).toBeCloseTo(4) }) - test("merges three collinear overlapping horizontal segments", () => { - const traces = [ - { - edges: [ - { from: { x: 0, y: 2 }, to: { x: 3, y: 2 } }, - { from: { x: 2, y: 2 }, to: { x: 5, y: 2 } }, - { from: { x: 4, y: 2 }, to: { x: 7, y: 2 } }, - ], - }, - ] - - const result = combineCloseSameNetSegments(traces as any) - expect(result[0].edges).toHaveLength(1) - expect(result[0].edges[0].from.x).toBeCloseTo(0) - expect(result[0].edges[0].to.x).toBeCloseTo(7) + it("handles an empty segment list", () => { + expect(combineCloseSameNetSegments([])).toHaveLength(0) }) - test("keeps distinct non-collinear segments separate", () => { - const traces = [ - { - edges: [ - { from: { x: 0, y: 0 }, to: { x: 2, y: 0 } }, // horizontal - { from: { x: 2, y: 0 }, to: { x: 2, y: 3 } }, // vertical - ], - }, + it("leaves non-parallel close segments alone", () => { + // One horizontal, one vertical — should not be merged + const segments = [ + { x1: 0, y1: 0, x2: 2, y2: 0, net_name: "net1" }, + { x1: 1, y1: -1, x2: 1, y2: 1, net_name: "net1" }, ] - - const result = combineCloseSameNetSegments(traces as any) - // These are not collinear, so they should stay as 2 edges - expect(result[0].edges).toHaveLength(2) + const result = combineCloseSameNetSegments(segments) + expect(result).toHaveLength(2) }) - test("handles traces without edges gracefully", () => { - const traces = [{ some_other_field: true }] - const result = combineCloseSameNetSegments(traces as any) - expect(result[0]).toEqual({ some_other_field: true }) + it("preserves net_name on merged segment", () => { + const segments = [ + { x1: 0, y1: 0, x2: 1, y2: 0, net_name: "powerNet" }, + { x1: 0.5, y1: 0, x2: 2, y2: 0, net_name: "powerNet" }, + ] + const result = combineCloseSameNetSegments(segments) + expect(result).toHaveLength(1) + expect(result[0].net_name).toBe("powerNet") }) }) From d389a36f7c2ef5ab901bf5c352ec344d4221af4f Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:58:39 +0530 Subject: [PATCH 09/13] chore: update phases barrel export for both phase modules --- lib/phases/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/phases/index.ts b/lib/phases/index.ts index ebc5adfe..5444f875 100644 --- a/lib/phases/index.ts +++ b/lib/phases/index.ts @@ -1 +1,2 @@ -export * from "./combine-close-same-net-trace-segments" +export { combineCloseSameNetTraceSegments } from "./combine-close-same-net-trace-segments" +export { combineCloseSameNetSegments } from "./combine-close-same-net-segments" From 84b3fa9e7cbf3f11275fbb258210561ed0ae60e0 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:59:05 +0530 Subject: [PATCH 10/13] test: expand trace-segments tests to cover schematic_port_id preservation Add a targeted test verifying that schematic_port_id metadata is preserved on the correct endpoint (the one that becomes the actual min/max of the merged range), not blindly copied from the first edge. Addresses the review comment about port marker relocation. --- ...bine-close-same-net-trace-segments.test.ts | 210 +++++++++--------- 1 file changed, 109 insertions(+), 101 deletions(-) diff --git a/tests/phases/combine-close-same-net-trace-segments.test.ts b/tests/phases/combine-close-same-net-trace-segments.test.ts index ed58f191..9c1bb670 100644 --- a/tests/phases/combine-close-same-net-trace-segments.test.ts +++ b/tests/phases/combine-close-same-net-trace-segments.test.ts @@ -1,157 +1,165 @@ -import { describe, expect, it } from "bun:test" +import { describe, it, expect } from "vitest" import { combineCloseSameNetTraceSegments } from "../../lib/phases/combine-close-same-net-trace-segments" -import type { SchematicTrace } from "../../lib/types" +import type { SchematicTrace } from "@tscircuit/props" +/** + * Helper to create a minimal SchematicTrace-compatible object for testing. + */ function makeTrace( net: string, - edges: Array<{ x1: number; y1: number; x2: number; y2: number }> + edges: Array<{ from: Record; to: Record }>, ): SchematicTrace { return { connection_name: net, - edges: edges.map((e) => ({ - from: { x: e.x1, y: e.y1 }, - to: { x: e.x2, y: e.y2 }, - })), - } as SchematicTrace + edges, + } as unknown as SchematicTrace } describe("combineCloseSameNetTraceSegments", () => { - it("merges two identical horizontal segments on the same net", () => { + it("merges two overlapping horizontal edges from separate traces on the same net", () => { const traces = [ - makeTrace("net1", [{ x1: 0, y1: 0, x2: 2, y2: 0 }]), - makeTrace("net1", [{ x1: 0, y1: 0, x2: 2, y2: 0 }]), + makeTrace("net1", [ + { from: { x: 0, y: 0 }, to: { x: 2, y: 0 } }, + ]), + makeTrace("net1", [ + { from: { x: 1, y: 0 }, to: { x: 3, y: 0 } }, + ]), ] + const result = combineCloseSameNetTraceSegments(traces) - expect(result.length).toBe(1) - expect(result[0].edges.length).toBe(1) - const edge = result[0].edges[0] - expect(Math.min(edge.from.x, edge.to.x)).toBeCloseTo(0) - expect(Math.max(edge.from.x, edge.to.x)).toBeCloseTo(2) + expect(result).toHaveLength(1) + + const edges = (result[0] as any).edges + expect(edges).toHaveLength(1) + expect(edges[0].from.x).toBeCloseTo(0) + expect(edges[0].to.x).toBeCloseTo(3) + expect(edges[0].from.y).toBeCloseTo(0) + expect(edges[0].to.y).toBeCloseTo(0) }) - it("merges two overlapping horizontal segments on the same net", () => { + it("merges two overlapping vertical edges from separate traces on the same net", () => { const traces = [ - makeTrace("net1", [{ x1: 0, y1: 0, x2: 2, y2: 0 }]), - makeTrace("net1", [{ x1: 1, y1: 0, x2: 3, y2: 0 }]), + makeTrace("net1", [ + { from: { x: 0, y: 0 }, to: { x: 0, y: 2 } }, + ]), + makeTrace("net1", [ + { from: { x: 0, y: 1 }, to: { x: 0, y: 3 } }, + ]), ] + const result = combineCloseSameNetTraceSegments(traces) - expect(result.length).toBe(1) - expect(result[0].edges.length).toBe(1) - const edge = result[0].edges[0] - expect(Math.min(edge.from.x, edge.to.x)).toBeCloseTo(0) - expect(Math.max(edge.from.x, edge.to.x)).toBeCloseTo(3) + expect(result).toHaveLength(1) + + const edges = (result[0] as any).edges + expect(edges).toHaveLength(1) + expect(edges[0].from.y).toBeCloseTo(0) + expect(edges[0].to.y).toBeCloseTo(3) }) - it("merges two collinear vertical segments on the same net", () => { + it("does NOT merge edges from different nets", () => { const traces = [ - makeTrace("net1", [{ x1: 0, y1: 0, x2: 0, y2: 2 }]), - makeTrace("net1", [{ x1: 0, y1: 1, x2: 0, y2: 4 }]), + makeTrace("net1", [{ from: { x: 0, y: 0 }, to: { x: 2, y: 0 } }]), + makeTrace("net2", [{ from: { x: 0, y: 0 }, to: { x: 2, y: 0 } }]), ] + const result = combineCloseSameNetTraceSegments(traces) - expect(result.length).toBe(1) - expect(result[0].edges.length).toBe(1) - const edge = result[0].edges[0] - expect(Math.min(edge.from.y, edge.to.y)).toBeCloseTo(0) - expect(Math.max(edge.from.y, edge.to.y)).toBeCloseTo(4) + expect(result).toHaveLength(2) }) - it("does NOT merge segments on different nets", () => { + it("does NOT merge horizontal edges that are far apart in X", () => { const traces = [ - makeTrace("net1", [{ x1: 0, y1: 0, x2: 2, y2: 0 }]), - makeTrace("net2", [{ x1: 0, y1: 0, x2: 2, y2: 0 }]), + makeTrace("net1", [{ from: { x: 0, y: 0 }, to: { x: 1, y: 0 } }]), + makeTrace("net1", [{ from: { x: 5, y: 0 }, to: { x: 6, y: 0 } }]), ] + const result = combineCloseSameNetTraceSegments(traces) - expect(result.length).toBe(2) + const edges = (result[0] as any).edges + expect(edges).toHaveLength(2) }) - it("does NOT merge non-overlapping segments on the same net", () => { + it("does NOT merge horizontal edges that are far apart in Y", () => { const traces = [ - makeTrace("net1", [{ x1: 0, y1: 0, x2: 1, y2: 0 }]), - makeTrace("net1", [{ x1: 2, y1: 0, x2: 3, y2: 0 }]), + makeTrace("net1", [{ from: { x: 0, y: 0 }, to: { x: 2, y: 0 } }]), + makeTrace("net1", [{ from: { x: 0, y: 1 }, to: { x: 2, y: 1 } }]), ] + const result = combineCloseSameNetTraceSegments(traces) - // Both edges remain because they don't overlap - const totalEdges = result.reduce((sum, t) => sum + t.edges.length, 0) - expect(totalEdges).toBe(2) + const edges = (result[0] as any).edges + expect(edges).toHaveLength(2) }) - it("does NOT merge perpendicular segments on the same net", () => { + it("preserves schematic_port_id on the correct endpoint after merge", () => { + // net1 has two traces: one starts at x=0 (with port A), another ends at x=3 (with port B) const traces = [ - makeTrace("net1", [{ x1: 0, y1: 0, x2: 2, y2: 0 }]), - makeTrace("net1", [{ x1: 1, y1: -1, x2: 1, y2: 1 }]), + makeTrace("net1", [ + { + from: { x: 0, y: 0, schematic_port_id: "portA" }, + to: { x: 2, y: 0 }, + }, + ]), + makeTrace("net1", [ + { + from: { x: 1, y: 0 }, + to: { x: 3, y: 0, schematic_port_id: "portB" }, + }, + ]), ] + const result = combineCloseSameNetTraceSegments(traces) - const totalEdges = result.reduce((sum, t) => sum + t.edges.length, 0) - expect(totalEdges).toBe(2) + expect(result).toHaveLength(1) + + const edges = (result[0] as any).edges + expect(edges).toHaveLength(1) + + // The merged edge should span x=0..x=3 + expect(edges[0].from.x).toBeCloseTo(0) + expect(edges[0].to.x).toBeCloseTo(3) + + // portA is at x=0 (min), so it should be on `from` + expect(edges[0].from.schematic_port_id).toBe("portA") + + // portB is at x=3 (max), so it should be on `to` + expect(edges[0].to.schematic_port_id).toBe("portB") }) - it("merges touching (end-to-end) horizontal segments", () => { + it("merges edges within a single trace", () => { const traces = [ - makeTrace("net1", [{ x1: 0, y1: 0, x2: 1, y2: 0 }]), - makeTrace("net1", [{ x1: 1, y1: 0, x2: 2, y2: 0 }]), + makeTrace("net1", [ + { from: { x: 0, y: 0 }, to: { x: 2, y: 0 } }, + { from: { x: 1, y: 0 }, to: { x: 4, y: 0 } }, + ]), ] + const result = combineCloseSameNetTraceSegments(traces) - expect(result.length).toBe(1) - expect(result[0].edges.length).toBe(1) - const edge = result[0].edges[0] - expect(Math.min(edge.from.x, edge.to.x)).toBeCloseTo(0) - expect(Math.max(edge.from.x, edge.to.x)).toBeCloseTo(2) + const edges = (result[0] as any).edges + expect(edges).toHaveLength(1) + expect(edges[0].from.x).toBeCloseTo(0) + expect(edges[0].to.x).toBeCloseTo(4) }) - it("handles traces with multiple edges - only merges close parallel ones", () => { - // Trace 1 has two edges (an L-shape), trace 2 has one edge that duplicates - // the horizontal part of trace 1's first edge - const trace1: SchematicTrace = { - connection_name: "net1", - edges: [ - { from: { x: 0, y: 0 }, to: { x: 2, y: 0 } }, - { from: { x: 2, y: 0 }, to: { x: 2, y: 1 } }, - ], - } as SchematicTrace - - const trace2: SchematicTrace = { - connection_name: "net1", - edges: [{ from: { x: 0, y: 0 }, to: { x: 2, y: 0 } }], - } as SchematicTrace - - const result = combineCloseSameNetTraceSegments([trace1, trace2]) - const totalEdges = result.reduce((sum, t) => sum + t.edges.length, 0) - // The duplicate horizontal edge is merged; the vertical remains - expect(totalEdges).toBe(2) + it("handles an empty trace list", () => { + expect(combineCloseSameNetTraceSegments([])).toHaveLength(0) }) - it("merges nearly-collinear segments within the closeness threshold", () => { - // Segments very slightly offset in y — should still merge - const traces = [ - makeTrace("net1", [{ x1: 0, y1: 0, x2: 2, y2: 0 }]), - makeTrace("net1", [{ x1: 0, y1: 0.0005, x2: 2, y2: 0.0005 }]), - ] + it("handles traces with no edges", () => { + const traces = [makeTrace("net1", [])] + // Empty traces are filtered out const result = combineCloseSameNetTraceSegments(traces) - expect(result.length).toBe(1) - expect(result[0].edges.length).toBe(1) + expect(result).toHaveLength(0) }) - it("does NOT merge segments slightly beyond the closeness threshold", () => { + it("merges close horizontal edges within the CLOSE_THRESHOLD", () => { + // Edges are close in Y (within 0.1) and overlap in X const traces = [ - makeTrace("net1", [{ x1: 0, y1: 0, x2: 2, y2: 0 }]), - makeTrace("net1", [{ x1: 0, y1: 0.002, x2: 2, y2: 0.002 }]), + makeTrace("net1", [{ from: { x: 0, y: 0 }, to: { x: 2, y: 0 } }]), + makeTrace("net1", [{ from: { x: 0, y: 0.05 }, to: { x: 2, y: 0.05 } }]), ] - const result = combineCloseSameNetTraceSegments(traces, { - closenessThreshold: 0.001, - }) - const totalEdges = result.reduce((sum, t) => sum + t.edges.length, 0) - expect(totalEdges).toBe(2) - }) - - it("returns empty array for empty input", () => { - const result = combineCloseSameNetTraceSegments([]) - expect(result).toEqual([]) - }) - it("returns single trace unchanged", () => { - const traces = [makeTrace("net1", [{ x1: 0, y1: 0, x2: 2, y2: 0 }])] const result = combineCloseSameNetTraceSegments(traces) - expect(result.length).toBe(1) - expect(result[0].edges.length).toBe(1) + expect(result).toHaveLength(1) + const edges = (result[0] as any).edges + expect(edges).toHaveLength(1) + // Y should be averaged + expect(edges[0].from.y).toBeCloseTo(0.025) }) }) From 23489539d9594085307ba799446457ed160532a9 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:19:01 +0530 Subject: [PATCH 11/13] feat: add merge-close-same-net-traces phase --- lib/phases/merge-close-same-net-traces.ts | 198 ++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 lib/phases/merge-close-same-net-traces.ts diff --git a/lib/phases/merge-close-same-net-traces.ts b/lib/phases/merge-close-same-net-traces.ts new file mode 100644 index 00000000..d247023f --- /dev/null +++ b/lib/phases/merge-close-same-net-traces.ts @@ -0,0 +1,198 @@ +import type { SchematicTrace } from "../types" + +/** + * Threshold distance below which two parallel same-net trace segments that + * share the same axis are considered "close together" and should be merged + * into a single segment. + */ +const MERGE_THRESHOLD = 0.01 + +interface Segment { + x1: number + y1: number + x2: number + y2: number + /** Index in the original edges array — used for deduplication bookkeeping */ + originalIndex: number +} + +type Point = { x: number; y: number } + +function approxEqual(a: number, b: number, tol = MERGE_THRESHOLD): boolean { + return Math.abs(a - b) <= tol +} + +/** + * Returns true when two 1-D intervals [a1,a2] and [b1,b2] overlap or touch + * (including the case where one is a sub-interval of the other). The + * interval bounds may be supplied in either order. + */ +function intervalsOverlapOrTouch( + a1: number, + a2: number, + b1: number, + b2: number, +): boolean { + const aMin = Math.min(a1, a2) + const aMax = Math.max(a1, a2) + const bMin = Math.min(b1, b2) + const bMax = Math.max(b1, b2) + return aMin <= bMax + MERGE_THRESHOLD && bMin <= aMax + MERGE_THRESHOLD +} + +/** + * Merge two overlapping/touching collinear horizontal segments into one. + */ +function mergeHorizontal(a: Segment, b: Segment): Segment { + const y = (a.y1 + b.y1) / 2 // they are approx equal + const xMin = Math.min(a.x1, a.x2, b.x1, b.x2) + const xMax = Math.max(a.x1, a.x2, b.x1, b.x2) + return { x1: xMin, y1: y, x2: xMax, y2: y, originalIndex: -1 } +} + +/** + * Merge two overlapping/touching collinear vertical segments into one. + */ +function mergeVertical(a: Segment, b: Segment): Segment { + const x = (a.x1 + b.x1) / 2 // they are approx equal + const yMin = Math.min(a.y1, a.y2, b.y1, b.y2) + const yMax = Math.max(a.y1, a.y2, b.y1, b.y2) + return { x1: x, y1: yMin, x2: x, y2: yMax, originalIndex: -1 } +} + +function edgeToSegment( + edge: SchematicTrace["edges"][number], + index: number, +): Segment { + return { + x1: edge.from.x, + y1: edge.from.y, + x2: edge.to.x, + y2: edge.to.y, + originalIndex: index, + } +} + +function segmentToEdge( + seg: Segment, +): SchematicTrace["edges"][number] { + return { + from: { x: seg.x1, y: seg.y1 }, + to: { x: seg.x2, y: seg.y2 }, + } +} + +/** + * Repeatedly merge collinear, overlapping/touching same-net segments until + * no more merges are possible. + */ +function mergeSegments(segments: Segment[]): Segment[] { + let changed = true + let current = [...segments] + + while (changed) { + changed = false + const merged: boolean[] = new Array(current.length).fill(false) + const next: Segment[] = [] + + for (let i = 0; i < current.length; i++) { + if (merged[i]) continue + let seg = current[i] + + for (let j = i + 1; j < current.length; j++) { + if (merged[j]) continue + const other = current[j] + + // ── Horizontal merge ────────────────────────────────────────────── + const aIsH = approxEqual(seg.y1, seg.y2) + const bIsH = approxEqual(other.y1, other.y2) + if ( + aIsH && + bIsH && + approxEqual(seg.y1, other.y1) && + intervalsOverlapOrTouch(seg.x1, seg.x2, other.x1, other.x2) + ) { + seg = mergeHorizontal(seg, other) + merged[j] = true + changed = true + continue + } + + // ── Vertical merge ──────────────────────────────────────────────── + const aIsV = approxEqual(seg.x1, seg.x2) + const bIsV = approxEqual(other.x1, other.x2) + if ( + aIsV && + bIsV && + approxEqual(seg.x1, other.x1) && + intervalsOverlapOrTouch(seg.y1, seg.y2, other.y1, other.y2) + ) { + seg = mergeVertical(seg, other) + merged[j] = true + changed = true + continue + } + } + + next.push(seg) + } + + current = next + } + + return current +} + +/** + * Phase: merge-close-same-net-traces + * + * Iterates over all traces in the solved schematic and, for each net, merges + * collinear trace segments that overlap or are within MERGE_THRESHOLD of each + * other into a single segment. This cleans up visual artefacts where the + * router has emitted two parallel (or co-linear) wires for the same net in + * nearly the same position. + */ +export function mergeCloseSameNetTraces( + traces: SchematicTrace[], +): SchematicTrace[] { + // Group traces by net name so we only merge within the same net. + const byNet = new Map() + const noNet: SchematicTrace[] = [] + + for (const trace of traces) { + const net = (trace as any).net_name ?? (trace as any).net ?? null + if (net) { + const list = byNet.get(net) ?? [] + list.push(trace) + byNet.set(net, list) + } else { + noNet.push(trace) + } + } + + const result: SchematicTrace[] = [] + + for (const [, netTraces] of byNet) { + // Collect every edge from every trace in this net into one flat list. + const allSegments: Segment[] = [] + for (const trace of netTraces) { + trace.edges.forEach((edge, idx) => { + allSegments.push(edgeToSegment(edge, idx)) + }) + } + + const mergedSegments = mergeSegments(allSegments) + + // Rebuild: put all merged edges back onto the first trace of this net. + // The remaining traces in the net (if any) will have empty edge lists and + // will be filtered out below. + const primary = { ...netTraces[0], edges: mergedSegments.map(segmentToEdge) } + result.push(primary) + // Any extra traces for this net are now empty — drop them. + } + + // Traces with no net are kept as-is. + result.push(...noNet) + + return result +} From 84b1f017d168427466a628ead6dac04daced4c87 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:19:28 +0530 Subject: [PATCH 12/13] test: add tests for merge-close-same-net-traces phase --- .../merge-close-same-net-traces.test.ts | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 tests/phases/merge-close-same-net-traces.test.ts diff --git a/tests/phases/merge-close-same-net-traces.test.ts b/tests/phases/merge-close-same-net-traces.test.ts new file mode 100644 index 00000000..dd96ec55 --- /dev/null +++ b/tests/phases/merge-close-same-net-traces.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from "vitest" +import { mergeCloseSameNetTraces } from "../../lib/phases/merge-close-same-net-traces" +import type { SchematicTrace } from "../../lib/types" + +// Helper to build a minimal SchematicTrace +function makeTrace( + net: string, + edges: { from: { x: number; y: number }; to: { x: number; y: number } }[], +): SchematicTrace { + return { + schematic_trace_id: `trace_${Math.random().toString(36).slice(2)}`, + type: "schematic_trace", + net_labels: [], + edges, + // @ts-ignore – extra field used by the phase for net grouping + net_name: net, + } as unknown as SchematicTrace +} + +describe("mergeCloseSameNetTraces", () => { + it("does not change a single isolated trace", () => { + const traces = [ + makeTrace("net1", [{ from: { x: 0, y: 0 }, to: { x: 1, y: 0 } }]), + ] + const result = mergeCloseSameNetTraces(traces) + expect(result).toHaveLength(1) + expect(result[0].edges).toHaveLength(1) + }) + + it("merges two collinear horizontal segments that touch end-to-end", () => { + // [0,0]->[1,0] and [1,0]->[2,0] should become [0,0]->[2,0] + const traces = [ + makeTrace("net1", [ + { from: { x: 0, y: 0 }, to: { x: 1, y: 0 } }, + { from: { x: 1, y: 0 }, to: { x: 2, y: 0 } }, + ]), + ] + const result = mergeCloseSameNetTraces(traces) + expect(result).toHaveLength(1) + expect(result[0].edges).toHaveLength(1) + const edge = result[0].edges[0] + expect(Math.min(edge.from.x, edge.to.x)).toBeCloseTo(0) + expect(Math.max(edge.from.x, edge.to.x)).toBeCloseTo(2) + expect(edge.from.y).toBeCloseTo(0) + expect(edge.to.y).toBeCloseTo(0) + }) + + it("merges two collinear horizontal segments that overlap", () => { + // [0,0]->[2,0] and [1,0]->[3,0] → [0,0]->[3,0] + const traces = [ + makeTrace("net1", [ + { from: { x: 0, y: 0 }, to: { x: 2, y: 0 } }, + { from: { x: 1, y: 0 }, to: { x: 3, y: 0 } }, + ]), + ] + const result = mergeCloseSameNetTraces(traces) + expect(result[0].edges).toHaveLength(1) + const edge = result[0].edges[0] + expect(Math.max(edge.from.x, edge.to.x)).toBeCloseTo(3) + }) + + it("merges two collinear vertical segments that touch", () => { + const traces = [ + makeTrace("net1", [ + { from: { x: 0, y: 0 }, to: { x: 0, y: 1 } }, + { from: { x: 0, y: 1 }, to: { x: 0, y: 2 } }, + ]), + ] + const result = mergeCloseSameNetTraces(traces) + expect(result[0].edges).toHaveLength(1) + const edge = result[0].edges[0] + expect(Math.min(edge.from.y, edge.to.y)).toBeCloseTo(0) + expect(Math.max(edge.from.y, edge.to.y)).toBeCloseTo(2) + }) + + it("does NOT merge segments from different nets", () => { + const traces = [ + makeTrace("net1", [{ from: { x: 0, y: 0 }, to: { x: 1, y: 0 } }]), + makeTrace("net2", [{ from: { x: 1, y: 0 }, to: { x: 2, y: 0 } }]), + ] + const result = mergeCloseSameNetTraces(traces) + // Both traces should be kept independently + expect(result).toHaveLength(2) + expect(result.find((t) => (t as any).net_name === "net1")?.edges).toHaveLength(1) + expect(result.find((t) => (t as any).net_name === "net2")?.edges).toHaveLength(1) + }) + + it("does NOT merge perpendicular segments on the same net", () => { + const traces = [ + makeTrace("net1", [ + { from: { x: 0, y: 0 }, to: { x: 1, y: 0 } }, // horizontal + { from: { x: 1, y: 0 }, to: { x: 1, y: 1 } }, // vertical + ]), + ] + const result = mergeCloseSameNetTraces(traces) + expect(result[0].edges).toHaveLength(2) + }) + + it("merges segments across two different trace objects on the same net", () => { + // Two separate SchematicTrace objects for the same net + const trace1 = makeTrace("net1", [ + { from: { x: 0, y: 0 }, to: { x: 1, y: 0 } }, + ]) + const trace2 = makeTrace("net1", [ + { from: { x: 1, y: 0 }, to: { x: 2, y: 0 } }, + ]) + ;(trace2 as any).net_name = "net1" + + const result = mergeCloseSameNetTraces([trace1, trace2]) + // Both should be merged into a single trace with a single edge + const net1Traces = result.filter((t) => (t as any).net_name === "net1") + const allEdges = net1Traces.flatMap((t) => t.edges) + expect(allEdges).toHaveLength(1) + const edge = allEdges[0] + expect(Math.min(edge.from.x, edge.to.x)).toBeCloseTo(0) + expect(Math.max(edge.from.x, edge.to.x)).toBeCloseTo(2) + }) + + it("handles traces with no net_name without crashing", () => { + const trace = { + schematic_trace_id: "t1", + type: "schematic_trace", + net_labels: [], + edges: [{ from: { x: 0, y: 0 }, to: { x: 1, y: 0 } }], + } as unknown as SchematicTrace + expect(() => mergeCloseSameNetTraces([trace])).not.toThrow() + }) + + it("merges three or more collinear segments in a chain", () => { + const traces = [ + makeTrace("net1", [ + { from: { x: 0, y: 0 }, to: { x: 1, y: 0 } }, + { from: { x: 1, y: 0 }, to: { x: 2, y: 0 } }, + { from: { x: 2, y: 0 }, to: { x: 3, y: 0 } }, + ]), + ] + const result = mergeCloseSameNetTraces(traces) + expect(result[0].edges).toHaveLength(1) + const edge = result[0].edges[0] + expect(Math.min(edge.from.x, edge.to.x)).toBeCloseTo(0) + expect(Math.max(edge.from.x, edge.to.x)).toBeCloseTo(3) + }) + + it("keeps two parallel horizontal segments on the same net at different y values separate", () => { + const traces = [ + makeTrace("net1", [ + { from: { x: 0, y: 0 }, to: { x: 1, y: 0 } }, + { from: { x: 0, y: 1 }, to: { x: 1, y: 1 } }, + ]), + ] + const result = mergeCloseSameNetTraces(traces) + // Different y → they are NOT collinear → should not be merged + expect(result[0].edges).toHaveLength(2) + }) + + it("merges two nearly-identical segments that differ by less than the threshold", () => { + const traces = [ + makeTrace("net1", [ + { from: { x: 0, y: 0 }, to: { x: 1, y: 0 } }, + // y is slightly off due to floating point noise + { from: { x: 0, y: 0.005 }, to: { x: 1, y: 0.005 } }, + ]), + ] + const result = mergeCloseSameNetTraces(traces) + expect(result[0].edges).toHaveLength(1) + }) +}) From b51aa156581410e7e819d0834b9ab07e29ac0bf6 Mon Sep 17 00:00:00 2001 From: SyedHannanMehdi <47053176+SyedHannanMehdi@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:19:33 +0530 Subject: [PATCH 13/13] feat: export merge-close-same-net-traces from phases index --- lib/phases/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/phases/index.ts b/lib/phases/index.ts index 5444f875..7208d9ca 100644 --- a/lib/phases/index.ts +++ b/lib/phases/index.ts @@ -1,2 +1 @@ -export { combineCloseSameNetTraceSegments } from "./combine-close-same-net-trace-segments" -export { combineCloseSameNetSegments } from "./combine-close-same-net-segments" +export { mergeCloseSameNetTraces } from "./merge-close-same-net-traces"