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..3e666c73 --- /dev/null +++ b/lib/phases/combine-close-same-net-segments.ts @@ -0,0 +1,129 @@ +/** + * combine-close-same-net-segments.ts + * + * 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. + * + * Related to issue #29. + */ +import type { SchematicTrace } from "@tscircuit/props" + +const CLOSE_THRESHOLD = 0.1 + +interface Segment { + x1: number + y1: number + x2: number + y2: number + net_name?: string + [key: string]: unknown +} + +function isClose(a: number, b: number): boolean { + return Math.abs(a - b) <= CLOSE_THRESHOLD +} + +function isHorizontal(seg: Segment): boolean { + return Math.abs(seg.y1 - seg.y2) < 1e-9 +} + +function isVertical(seg: Segment): boolean { + return Math.abs(seg.x1 - seg.x2) < 1e-9 +} + +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 +} + +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 +} + +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 { + ...a, + x1: Math.min(...allX), + y1: avgY, + x2: Math.max(...allX), + y2: avgY, + } +} + +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 { + ...a, + x1: avgX, + y1: Math.min(...allY), + x2: avgX, + y2: Math.max(...allY), + } +} + +/** + * 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. + */ +export function combineCloseSameNetSegments(segments: Segment[]): Segment[] { + let current = [...segments] + let changed = true + + while (changed) { + changed = false + const used = new Set() + const result: Segment[] = [] + + 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) + + for (let j = i + 1; j < current.length; j++) { + if (used.has(j)) continue + const other = current[j] + + // Only merge segments on the same net + if (base.net_name !== other.net_name) continue + + 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 + } + } + + result.push(base) + used.add(i) + } + + current = result + } + + return current +} 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..c9aedfa5 --- /dev/null +++ b/lib/phases/combine-close-same-net-trace-segments.ts @@ -0,0 +1,248 @@ +import type { SchematicTrace } from "@tscircuit/props" + +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 Edge { + from: Point + to: Point + [key: string]: unknown +} + +/** + * 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 +} + +/** + * 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 +} + +/** + * 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 +} + +/** + * 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 +} + +/** + * 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 +} + +/** + * 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 +} + +/** + * 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 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 }, + } +} + +/** + * 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 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 }, + } +} + +/** + * 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 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 + } + } + + merged.push(base) + used.add(i) + } + + current = merged + } + + return current +} + +/** + * Phase: combineCloseSameNetTraceSegments + * + * 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[], +): SchematicTrace[] { + 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 }) + } + } + + // 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) + } + } + + // Filter out traces that have no edges after merging + return result.filter((trace) => { + const edges = (trace as any).edges ?? [] + return edges.length > 0 + }) +} diff --git a/lib/phases/index.ts b/lib/phases/index.ts new file mode 100644 index 00000000..7208d9ca --- /dev/null +++ b/lib/phases/index.ts @@ -0,0 +1 @@ +export { mergeCloseSameNetTraces } from "./merge-close-same-net-traces" 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 +} 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..e2d02059 --- /dev/null +++ b/tests/combine-close-same-net-segments.test.ts @@ -0,0 +1,111 @@ +/** + * 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", () => { + 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(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) + }) + + 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(segments) + expect(result).toHaveLength(1) + expect(result[0].x1).toBeCloseTo(0) + expect(result[0].x2).toBeCloseTo(2) + }) + + 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(segments) + expect(result).toHaveLength(2) + }) + + 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(segments) + expect(result).toHaveLength(2) + }) + + 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(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) + }) + + 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(segments) + expect(result).toHaveLength(1) + expect(result[0].x1).toBeCloseTo(0) + expect(result[0].x2).toBeCloseTo(4) + }) + + it("handles an empty segment list", () => { + expect(combineCloseSameNetSegments([])).toHaveLength(0) + }) + + 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(segments) + expect(result).toHaveLength(2) + }) + + 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") + }) +}) 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..9c1bb670 --- /dev/null +++ b/tests/phases/combine-close-same-net-trace-segments.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from "vitest" +import { combineCloseSameNetTraceSegments } from "../../lib/phases/combine-close-same-net-trace-segments" +import type { SchematicTrace } from "@tscircuit/props" + +/** + * Helper to create a minimal SchematicTrace-compatible object for testing. + */ +function makeTrace( + net: string, + edges: Array<{ from: Record; to: Record }>, +): SchematicTrace { + return { + connection_name: net, + edges, + } as unknown as SchematicTrace +} + +describe("combineCloseSameNetTraceSegments", () => { + it("merges two overlapping horizontal edges from separate traces on the same net", () => { + const traces = [ + 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).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 vertical edges from separate traces on the same net", () => { + const traces = [ + 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).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("does NOT merge edges from different nets", () => { + const traces = [ + 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).toHaveLength(2) + }) + + it("does NOT merge horizontal edges that are far apart in X", () => { + const traces = [ + 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) + const edges = (result[0] as any).edges + expect(edges).toHaveLength(2) + }) + + it("does NOT merge horizontal edges that are far apart in Y", () => { + const traces = [ + 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) + const edges = (result[0] as any).edges + expect(edges).toHaveLength(2) + }) + + 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", [ + { + 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) + 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 edges within a single trace", () => { + const traces = [ + 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) + 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 an empty trace list", () => { + expect(combineCloseSameNetTraceSegments([])).toHaveLength(0) + }) + + it("handles traces with no edges", () => { + const traces = [makeTrace("net1", [])] + // Empty traces are filtered out + const result = combineCloseSameNetTraceSegments(traces) + expect(result).toHaveLength(0) + }) + + 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", [{ 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) + 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) + }) +}) 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) + }) +})