diff --git a/lib/solvers/MergeParallelTracesSolver/MergeParallelTracesSolver.ts b/lib/solvers/MergeParallelTracesSolver/MergeParallelTracesSolver.ts new file mode 100644 index 00000000..047112d5 --- /dev/null +++ b/lib/solvers/MergeParallelTracesSolver/MergeParallelTracesSolver.ts @@ -0,0 +1,77 @@ +import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import type { MspConnectionPairId } from "lib/solvers/MspConnectionPairSolver/MspConnectionPairSolver" +import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem" +import type { InputProblem } from "lib/types/InputProblem" +import { + DEFAULT_MERGE_DISTANCE, + mergeParallelTraceSegments, +} from "./mergeParallelTraceSegments" + +export class MergeParallelTracesSolver extends BaseSolver { + inputProblem: InputProblem + inputTracePaths: SolvedTracePath[] + mergeDistance: number + + correctedTraceMap: Record = {} + + constructor(params: { + inputProblem: InputProblem + inputTracePaths: SolvedTracePath[] + mergeDistance?: number + }) { + super() + this.inputProblem = params.inputProblem + this.inputTracePaths = params.inputTracePaths + this.mergeDistance = params.mergeDistance ?? DEFAULT_MERGE_DISTANCE + + for (const tracePath of this.inputTracePaths) { + this.correctedTraceMap[tracePath.mspPairId] = { + ...tracePath, + tracePath: tracePath.tracePath.map((point) => ({ ...point })), + mspConnectionPairIds: [...tracePath.mspConnectionPairIds], + pinIds: [...tracePath.pinIds], + } + } + } + + override getConstructorParams(): ConstructorParameters< + typeof MergeParallelTracesSolver + >[0] { + return { + inputProblem: this.inputProblem, + inputTracePaths: this.inputTracePaths, + mergeDistance: this.mergeDistance, + } + } + + override _step() { + const merged = mergeParallelTraceSegments( + this.inputTracePaths, + this.mergeDistance, + ) + + this.correctedTraceMap = Object.fromEntries( + merged.map((trace) => [trace.mspPairId, trace]), + ) + this.solved = true + } + + getOutput(): { traces: SolvedTracePath[] } { + return { traces: Object.values(this.correctedTraceMap) } + } + + override visualize() { + const graphics = visualizeInputProblem(this.inputProblem) + graphics.lines = graphics.lines || [] + + for (const trace of Object.values(this.correctedTraceMap)) { + graphics.lines.push({ + points: trace.tracePath, + strokeColor: "teal", + }) + } + + return graphics + } +} diff --git a/lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts b/lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts new file mode 100644 index 00000000..6b6a9482 --- /dev/null +++ b/lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.ts @@ -0,0 +1,319 @@ +import type { Point } from "@tscircuit/math-utils" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { simplifyPath } from "lib/solvers/TraceCleanupSolver/simplifyPath" + +const EPS = 1e-6 +export const DEFAULT_MERGE_DISTANCE = 0.15 + +type Orientation = "horizontal" | "vertical" + +interface SegmentRef { + traceIndex: number + segmentIndex: number + orientation: Orientation + fixedCoordinate: number + rangeStart: number + rangeEnd: number +} + +const getSegmentRef = ( + traceIndex: number, + segmentIndex: number, + start: Point, + end: Point, +): SegmentRef | null => { + if (Math.abs(start.y - end.y) < EPS) { + return { + traceIndex, + segmentIndex, + orientation: "horizontal", + fixedCoordinate: start.y, + rangeStart: Math.min(start.x, end.x), + rangeEnd: Math.max(start.x, end.x), + } + } + + if (Math.abs(start.x - end.x) < EPS) { + return { + traceIndex, + segmentIndex, + orientation: "vertical", + fixedCoordinate: start.x, + rangeStart: Math.min(start.y, end.y), + rangeEnd: Math.max(start.y, end.y), + } + } + + return null +} + +const getSegments = ( + traces: SolvedTracePath[], + traceIndex: number, + options: { includeTerminals: boolean }, +): SegmentRef[] => { + const trace = traces[traceIndex]! + const refs: SegmentRef[] = [] + const startIndex = options.includeTerminals ? 0 : 1 + const endIndex = options.includeTerminals + ? trace.tracePath.length - 1 + : trace.tracePath.length - 2 + + for (let segmentIndex = startIndex; segmentIndex < endIndex; segmentIndex++) { + const start = trace.tracePath[segmentIndex]! + const end = trace.tracePath[segmentIndex + 1]! + const ref = getSegmentRef(traceIndex, segmentIndex, start, end) + if (ref) refs.push(ref) + } + + return refs +} + +const rangesOverlap = (a: SegmentRef, b: SegmentRef) => + Math.min(a.rangeEnd, b.rangeEnd) - Math.max(a.rangeStart, b.rangeStart) > EPS + +const wouldOverlapDifferentNet = ( + traces: SolvedTracePath[], + source: SegmentRef, + fixedCoordinate: number, +) => { + for (let traceIndex = 0; traceIndex < traces.length; traceIndex++) { + const trace = traces[traceIndex]! + if (trace.globalConnNetId === traces[source.traceIndex]!.globalConnNetId) { + continue + } + + for ( + let segmentIndex = 0; + segmentIndex < trace.tracePath.length - 1; + segmentIndex++ + ) { + const ref = getSegmentRef( + traceIndex, + segmentIndex, + trace.tracePath[segmentIndex]!, + trace.tracePath[segmentIndex + 1]!, + ) + if (!ref) continue + if (ref.orientation !== source.orientation) continue + if (Math.abs(ref.fixedCoordinate - fixedCoordinate) > EPS) continue + if (rangesOverlap(source, ref)) return true + } + } + + return false +} + +const findConsolidatableSegmentPair = ( + traces: SolvedTracePath[], + indexA: number, + indexB: number, + mergeDistance: number, +): SegmentRef | null => { + const traceA = traces[indexA]! + const traceB = traces[indexB]! + if (traceA.tracePath.length !== 2 || traceB.tracePath.length !== 2) { + return null + } + + const segmentA = getSegmentRef( + indexA, + 0, + traceA.tracePath[0]!, + traceA.tracePath[1]!, + ) + const segmentB = getSegmentRef( + indexB, + 0, + traceB.tracePath[0]!, + traceB.tracePath[1]!, + ) + if (!segmentA || !segmentB) return null + if (segmentA.orientation !== segmentB.orientation) return null + if ( + Math.abs(segmentA.fixedCoordinate - segmentB.fixedCoordinate) > + mergeDistance + ) { + return null + } + if (!rangesOverlap(segmentA, segmentB)) return null + + return segmentA +} + +const mergeTracePair = ( + kept: SolvedTracePath, + removed: SolvedTracePath, + canonical: SegmentRef, +): SolvedTracePath => { + const tracePath = kept.tracePath.map((point) => { + const p = { ...point } + if (canonical.orientation === "horizontal") { + p.y = canonical.fixedCoordinate + } else { + p.x = canonical.fixedCoordinate + } + return p + }) + + return { + ...kept, + tracePath: simplifyPath(tracePath), + mspConnectionPairIds: [ + ...new Set([ + ...kept.mspConnectionPairIds, + ...removed.mspConnectionPairIds, + ]), + ], + pinIds: [...new Set([...kept.pinIds, ...removed.pinIds])], + } +} + +const consolidateRedundantParallelTraces = ( + traces: SolvedTracePath[], + mergeDistance: number, +): SolvedTracePath[] => { + const result = traces.map((trace) => ({ + ...trace, + tracePath: trace.tracePath.map((point) => ({ ...point })), + })) + + let changed = true + while (changed) { + changed = false + + outer: for (let indexA = 0; indexA < result.length; indexA++) { + for (let indexB = indexA + 1; indexB < result.length; indexB++) { + if ( + result[indexA]!.globalConnNetId !== result[indexB]!.globalConnNetId + ) { + continue + } + + const canonical = findConsolidatableSegmentPair( + result, + indexA, + indexB, + mergeDistance, + ) + if (!canonical) continue + if ( + wouldOverlapDifferentNet(result, canonical, canonical.fixedCoordinate) + ) { + continue + } + + result[indexA] = mergeTracePair( + result[indexA]!, + result[indexB]!, + canonical, + ) + result.splice(indexB, 1) + changed = true + break outer + } + } + } + + return result +} + +const snapSegmentFixedCoordinate = ( + trace: SolvedTracePath, + segmentIndex: number, + orientation: Orientation, + fixedCoordinate: number, +) => { + const tracePath = trace.tracePath.map((point) => ({ ...point })) + const start = tracePath[segmentIndex]! + const end = tracePath[segmentIndex + 1]! + + if (orientation === "horizontal") { + start.y = fixedCoordinate + end.y = fixedCoordinate + } else { + start.x = fixedCoordinate + end.x = fixedCoordinate + } + + return { + ...trace, + tracePath: simplifyPath(tracePath), + } +} + +/** + * Snaps nearby parallel same-net trace segments onto a shared X or Y axis. + * Internal segments align to overlapping segments on sibling traces in the net. + */ +export const mergeParallelTraceSegments = ( + traces: SolvedTracePath[], + mergeDistance = DEFAULT_MERGE_DISTANCE, +): SolvedTracePath[] => { + const mergedTraces = traces.map((trace) => ({ + ...trace, + tracePath: trace.tracePath.map((point) => ({ ...point })), + })) + + const traceIndexesByNet = new Map() + for (let traceIndex = 0; traceIndex < mergedTraces.length; traceIndex++) { + const netId = mergedTraces[traceIndex]!.globalConnNetId + const traceIndexes = traceIndexesByNet.get(netId) ?? [] + traceIndexes.push(traceIndex) + traceIndexesByNet.set(netId, traceIndexes) + } + + for (const traceIndexes of traceIndexesByNet.values()) { + if (traceIndexes.length < 2) continue + + let changed = true + while (changed) { + changed = false + + for (const traceIndex of traceIndexes.slice(1)) { + const candidates = getSegments(mergedTraces, traceIndex, { + includeTerminals: false, + }) + + for (const candidate of candidates) { + const target = traceIndexes + .filter((targetTraceIndex) => targetTraceIndex !== traceIndex) + .flatMap((targetTraceIndex) => + getSegments(mergedTraces, targetTraceIndex, { + includeTerminals: true, + }), + ) + .find( + (other) => + other.orientation === candidate.orientation && + Math.abs(other.fixedCoordinate - candidate.fixedCoordinate) <= + mergeDistance && + Math.abs(other.fixedCoordinate - candidate.fixedCoordinate) > + EPS && + rangesOverlap(candidate, other) && + !wouldOverlapDifferentNet( + mergedTraces, + candidate, + other.fixedCoordinate, + ), + ) + + if (!target) continue + + mergedTraces[traceIndex] = snapSegmentFixedCoordinate( + mergedTraces[traceIndex]!, + candidate.segmentIndex, + candidate.orientation, + target.fixedCoordinate, + ) + changed = true + break + } + + if (changed) break + } + } + } + + return consolidateRedundantParallelTraces(mergedTraces, mergeDistance) +} diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index 59821f0c..176f9105 100644 --- a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts +++ b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts @@ -12,6 +12,7 @@ import { type SolvedTracePath, } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver" import { TraceOverlapShiftSolver } from "../TraceOverlapShiftSolver/TraceOverlapShiftSolver" +import { MergeParallelTracesSolver } from "../MergeParallelTracesSolver/MergeParallelTracesSolver" import { NetLabelPlacementSolver } from "../NetLabelPlacementSolver/NetLabelPlacementSolver" import { colorAvailableNetOrientationLabels } from "./colorAvailableNetOrientationLabels" import { visualizeInputProblem } from "./visualizeInputProblem" @@ -71,6 +72,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { schematicTraceLinesSolver?: SchematicTraceLinesSolver longDistancePairSolver?: LongDistancePairSolver traceOverlapShiftSolver?: TraceOverlapShiftSolver + mergeParallelTracesSolver?: MergeParallelTracesSolver netLabelPlacementSolver?: NetLabelPlacementSolver labelMergingSolver?: MergedNetLabelObstacleSolver traceLabelOverlapAvoidanceSolver?: TraceLabelOverlapAvoidanceSolver @@ -154,19 +156,25 @@ export class SchematicTracePipelineSolver extends BaseSolver { onSolved: (_solver) => {}, }, ), + definePipelineStep( + "mergeParallelTracesSolver", + MergeParallelTracesSolver, + () => [ + { + inputProblem: this.inputProblem, + inputTracePaths: Object.values( + this.traceOverlapShiftSolver!.correctedTraceMap, + ), + }, + ], + ), definePipelineStep( "netLabelPlacementSolver", NetLabelPlacementSolver, () => [ { inputProblem: this.inputProblem, - inputTraceMap: - this.traceOverlapShiftSolver?.correctedTraceMap ?? - Object.fromEntries( - this.longDistancePairSolver!.getOutput().allTracesMerged.map( - (p) => [p.mspPairId, p], - ), - ), + inputTraceMap: this.getRoutedTraceMap(), }, ], { @@ -179,13 +187,7 @@ export class SchematicTracePipelineSolver extends BaseSolver { "traceLabelOverlapAvoidanceSolver", TraceLabelOverlapAvoidanceSolver, (instance) => { - const traceMap = - instance.traceOverlapShiftSolver?.correctedTraceMap ?? - Object.fromEntries( - instance - .longDistancePairSolver!.getOutput() - .allTracesMerged.map((p) => [p.mspPairId, p]), - ) + const traceMap = instance.getRoutedTraceMap() const traces = Object.values(traceMap) const netLabelPlacements = instance.netLabelPlacementSolver!.netLabelPlacements @@ -320,6 +322,21 @@ export class SchematicTracePipelineSolver extends BaseSolver { currentPipelineStepIndex = 0 + getRoutedTraceMap(): Record { + if (this.mergeParallelTracesSolver) { + return this.mergeParallelTracesSolver.correctedTraceMap + } + if (this.traceOverlapShiftSolver) { + return this.traceOverlapShiftSolver.correctedTraceMap + } + return Object.fromEntries( + this.longDistancePairSolver!.getOutput().allTracesMerged.map((p) => [ + p.mspPairId, + p, + ]), + ) + } + private cloneAndCorrectInputProblem(original: InputProblem): InputProblem { const cloned: InputProblem = structuredClone({ ...original, diff --git a/site/examples/example35.page.tsx b/site/examples/example35.page.tsx new file mode 100644 index 00000000..055ab187 --- /dev/null +++ b/site/examples/example35.page.tsx @@ -0,0 +1,6 @@ +import { PipelineDebugger } from "site/components/PipelineDebugger" +import inputProblem from "../../tests/assets/example35.json" + +export { inputProblem } + +export default () => diff --git a/tests/assets/example35-pre-merge-traces.json b/tests/assets/example35-pre-merge-traces.json new file mode 100644 index 00000000..4c365230 --- /dev/null +++ b/tests/assets/example35-pre-merge-traces.json @@ -0,0 +1,28 @@ +[ + { + "mspPairId": "trace-a", + "dcConnNetId": "V3_3", + "globalConnNetId": "V3_3", + "tracePath": [ + { "x": 0, "y": 0 }, + { "x": 0, "y": 1 }, + { "x": 4, "y": 1 }, + { "x": 4, "y": 0 } + ], + "mspConnectionPairIds": ["trace-a"], + "pinIds": ["C14.1", "C15.1"] + }, + { + "mspPairId": "trace-b", + "dcConnNetId": "V3_3", + "globalConnNetId": "V3_3", + "tracePath": [ + { "x": 0, "y": 0.12 }, + { "x": 0, "y": 1.12 }, + { "x": 4, "y": 1.12 }, + { "x": 4, "y": 0.12 } + ], + "mspConnectionPairIds": ["trace-b"], + "pinIds": ["C14.2", "C15.2"] + } +] diff --git a/tests/assets/example35.json b/tests/assets/example35.json new file mode 100644 index 00000000..e3dc6eee --- /dev/null +++ b/tests/assets/example35.json @@ -0,0 +1,35 @@ +{ + "chips": [ + { + "chipId": "C14", + "center": { "x": 0, "y": 0.5 }, + "width": 0.4, + "height": 1.2, + "pins": [ + { "pinId": "C14.1", "x": 0, "y": 0 }, + { "pinId": "C14.2", "x": 0, "y": 0.12 } + ] + }, + { + "chipId": "C15", + "center": { "x": 4, "y": 0.5 }, + "width": 0.4, + "height": 1.2, + "pins": [ + { "pinId": "C15.1", "x": 4, "y": 0 }, + { "pinId": "C15.2", "x": 4, "y": 0.12 } + ] + } + ], + "directConnections": [], + "netConnections": [ + { + "pinIds": ["C14.1", "C14.2", "C15.1", "C15.2"], + "netId": "V3_3" + } + ], + "availableNetLabelOrientations": { + "V3_3": ["x+", "y+"] + }, + "maxMspPairDistance": 10 +} diff --git a/tests/examples/__snapshots__/after-mergeParallelTraces.snap.svg b/tests/examples/__snapshots__/after-mergeParallelTraces.snap.svg new file mode 100644 index 00000000..c4513b6c --- /dev/null +++ b/tests/examples/__snapshots__/after-mergeParallelTraces.snap.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/examples/__snapshots__/before-mergeParallelTraces.snap.svg b/tests/examples/__snapshots__/before-mergeParallelTraces.snap.svg new file mode 100644 index 00000000..8891c74e --- /dev/null +++ b/tests/examples/__snapshots__/before-mergeParallelTraces.snap.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/examples/__snapshots__/example35.snap.svg b/tests/examples/__snapshots__/example35.snap.svg new file mode 100644 index 00000000..03508af9 --- /dev/null +++ b/tests/examples/__snapshots__/example35.snap.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/examples/example35.test.ts b/tests/examples/example35.test.ts new file mode 100644 index 00000000..44181d91 --- /dev/null +++ b/tests/examples/example35.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "bun:test" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { MergeParallelTracesSolver } from "lib/solvers/MergeParallelTracesSolver/MergeParallelTracesSolver" +import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver" +import inputProblem from "../assets/example35.json" +import preMergeTraces from "../assets/example35-pre-merge-traces.json" +import "tests/fixtures/matcher" + +const cloneInputTracePaths = (traces: SolvedTracePath[]): SolvedTracePath[] => + traces.map((trace) => ({ + ...trace, + tracePath: trace.tracePath.map((point) => ({ ...point })), + mspConnectionPairIds: [...trace.mspConnectionPairIds], + pinIds: [...trace.pinIds], + })) + +/** + * Issue #34: same-net trace segments that are almost on the same Y (or X) get + * snapped onto a shared axis — removes the small jog between parallel runs. + * Repro matches the bypass-cap style case from the issue screenshot (C14/C15). + */ +test("example35 before/after mergeParallelTracesSolver", () => { + const inputTracePaths = cloneInputTracePaths( + preMergeTraces as SolvedTracePath[], + ) + + const mergeSolver = new MergeParallelTracesSolver({ + inputProblem: inputProblem as any, + inputTracePaths, + }) + + expect(mergeSolver.solved).toBe(false) + expect(Object.keys(mergeSolver.correctedTraceMap)).toHaveLength(2) + + expect(mergeSolver).toMatchSolverSnapshot( + import.meta.path, + "before-mergeParallelTraces", + ) + + mergeSolver.solve() + + expect(mergeSolver.solved).toBe(true) + expect(Object.keys(mergeSolver.correctedTraceMap)).toHaveLength(2) + + const traceA = mergeSolver.correctedTraceMap["trace-a"]! + const traceB = mergeSolver.correctedTraceMap["trace-b"]! + + // Internal horizontal runs align to y=1 (same Y), pin legs keep their offset + expect(traceA.tracePath[1]!.y).toBe(1) + expect(traceA.tracePath[2]!.y).toBe(1) + expect(traceB.tracePath[1]!.y).toBe(1) + expect(traceB.tracePath[2]!.y).toBe(1) + expect(traceB.tracePath[0]!.y).toBe(0.12) + expect(traceB.tracePath[3]!.y).toBe(0.12) + + expect(mergeSolver).toMatchSolverSnapshot( + import.meta.path, + "after-mergeParallelTraces", + ) +}) + +test("example35 full pipeline", () => { + const solver = new SchematicTracePipelineSolver(inputProblem as any) + solver.solve() + expect(solver).toMatchSolverSnapshot(import.meta.path) +}) diff --git a/tests/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.test.ts b/tests/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.test.ts new file mode 100644 index 00000000..7b1c6e12 --- /dev/null +++ b/tests/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments.test.ts @@ -0,0 +1,141 @@ +import { expect, test } from "bun:test" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { mergeParallelTraceSegments } from "lib/solvers/MergeParallelTracesSolver/mergeParallelTraceSegments" + +const makeTrace = ( + mspPairId: string, + globalConnNetId: string, + tracePath: Array<{ x: number; y: number }>, +): SolvedTracePath => + ({ + mspPairId, + dcConnNetId: globalConnNetId, + globalConnNetId, + pins: [ + { pinId: `${mspPairId}-a`, chipId: "U1", ...tracePath[0]! }, + { + pinId: `${mspPairId}-b`, + chipId: "U1", + ...tracePath[tracePath.length - 1]!, + }, + ], + tracePath, + mspConnectionPairIds: [mspPairId], + pinIds: [`${mspPairId}-a`, `${mspPairId}-b`], + }) as SolvedTracePath + +test("snaps nearby internal same-net horizontal segments onto the same y", () => { + const [first, second] = mergeParallelTraceSegments([ + makeTrace("trace-a", "net-1", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 0 }, + ]), + makeTrace("trace-b", "net-1", [ + { x: 0, y: 0.12 }, + { x: 0, y: 1.12 }, + { x: 4, y: 1.12 }, + { x: 4, y: 0.12 }, + ]), + ]) + + expect(first!.tracePath[1]!.y).toBe(1) + expect(first!.tracePath[2]!.y).toBe(1) + expect(second!.tracePath[1]!.y).toBe(1) + expect(second!.tracePath[2]!.y).toBe(1) + expect(second!.tracePath[0]!.y).toBe(0.12) + expect(second!.tracePath[3]!.y).toBe(0.12) +}) + +test("snaps nearby internal same-net vertical segments onto the same x", () => { + const [first, second] = mergeParallelTraceSegments([ + makeTrace("trace-a", "net-1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 4 }, + { x: 0, y: 4 }, + ]), + makeTrace("trace-b", "net-1", [ + { x: 0.12, y: 0 }, + { x: 1.12, y: 0 }, + { x: 1.12, y: 4 }, + { x: 0.12, y: 4 }, + ]), + ]) + + expect(first!.tracePath[1]!.x).toBe(1) + expect(first!.tracePath[2]!.x).toBe(1) + expect(second!.tracePath[1]!.x).toBe(1) + expect(second!.tracePath[2]!.x).toBe(1) + expect(second!.tracePath[0]!.x).toBe(0.12) + expect(second!.tracePath[3]!.x).toBe(0.12) +}) + +test("merges two close parallel same-net traces into one", () => { + const result = mergeParallelTraceSegments([ + makeTrace("trace-a", "net-1", [ + { x: 0, y: 1 }, + { x: 4, y: 1 }, + ]), + makeTrace("trace-b", "net-1", [ + { x: 0, y: 1.1 }, + { x: 4, y: 1.1 }, + ]), + ]) + + expect(result).toHaveLength(1) + expect(result[0]!.tracePath).toEqual([ + { x: 0, y: 1 }, + { x: 4, y: 1 }, + ]) + expect(result[0]!.globalConnNetId).toBe("net-1") + expect(result[0]!.mspConnectionPairIds).toEqual(["trace-a", "trace-b"]) + expect(result[0]!.pinIds).toEqual([ + "trace-a-a", + "trace-a-b", + "trace-b-a", + "trace-b-b", + ]) +}) + +test("merges two close parallel vertical same-net traces into one", () => { + const result = mergeParallelTraceSegments([ + makeTrace("trace-a", "net-1", [ + { x: 2, y: 0 }, + { x: 2, y: 5 }, + ]), + makeTrace("trace-b", "net-1", [ + { x: 2.12, y: 0 }, + { x: 2.12, y: 5 }, + ]), + ]) + + expect(result).toHaveLength(1) + expect(result[0]!.tracePath).toEqual([ + { x: 2, y: 0 }, + { x: 2, y: 5 }, + ]) +}) + +test("does not snap nearby segments from different nets", () => { + const [first, second] = mergeParallelTraceSegments([ + makeTrace("trace-a", "net-1", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 0 }, + ]), + makeTrace("trace-b", "net-2", [ + { x: 0, y: 0.12 }, + { x: 0, y: 1.12 }, + { x: 4, y: 1.12 }, + { x: 4, y: 0.12 }, + ]), + ]) + + expect(first!.tracePath[1]!.y).toBe(1) + expect(first!.tracePath[2]!.y).toBe(1) + expect(second!.tracePath[1]!.y).toBe(1.12) + expect(second!.tracePath[2]!.y).toBe(1.12) +})