diff --git a/demos/pr-392-same-net-trace-cleanup-demo.mp4 b/demos/pr-392-same-net-trace-cleanup-demo.mp4 new file mode 100644 index 000000000..cc884a27a Binary files /dev/null and b/demos/pr-392-same-net-trace-cleanup-demo.mp4 differ diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index e9bac7ca3..712cf9087 100644 --- a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts +++ b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts @@ -1,11 +1,11 @@ -import type { InputProblem } from "lib/types/InputProblem" import type { GraphicsObject, Line } from "graphics-debug" -import { minimizeTurnsWithFilteredLabels } from "./minimizeTurnsWithFilteredLabels" -import { balanceZShapes } from "./balanceZShapes" import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem" +import type { InputProblem } from "lib/types/InputProblem" import type { NetLabelPlacement } from "../NetLabelPlacementSolver/NetLabelPlacementSolver" +import { balanceZShapes } from "./balanceZShapes" +import { minimizeTurnsWithFilteredLabels } from "./minimizeTurnsWithFilteredLabels" /** * Defines the input structure for the TraceCleanupSolver. @@ -18,13 +18,15 @@ interface TraceCleanupSolverInput { paddingBuffer: number } -import { UntangleTraceSubsolver } from "./sub-solver/UntangleTraceSubsolver" +import { combineCloseSameNetSegments } from "./combineCloseSameNetSegments" import { is4PointRectangle } from "./is4PointRectangle" +import { UntangleTraceSubsolver } from "./sub-solver/UntangleTraceSubsolver" /** * Represents the different stages or steps within the trace cleanup pipeline. */ type PipelineStep = + | "combining_same_net_segments" | "minimizing_turns" | "balancing_l_shapes" | "untangling_traces" @@ -66,10 +68,10 @@ export class TraceCleanupSolver extends BaseSolver { this.outputTraces = output.traces this.tracesMap = new Map(this.outputTraces.map((t) => [t.mspPairId, t])) this.activeSubSolver = null - this.pipelineStep = "minimizing_turns" + this.pipelineStep = "combining_same_net_segments" } else if (this.activeSubSolver.failed) { this.activeSubSolver = null - this.pipelineStep = "minimizing_turns" + this.pipelineStep = "combining_same_net_segments" } return } @@ -78,6 +80,9 @@ export class TraceCleanupSolver extends BaseSolver { case "untangling_traces": this._runUntangleTracesStep() break + case "combining_same_net_segments": + this._runCombineCloseSameNetSegmentsStep() + break case "minimizing_turns": this._runMinimizeTurnsStep() break @@ -94,6 +99,16 @@ export class TraceCleanupSolver extends BaseSolver { }) } + private _runCombineCloseSameNetSegmentsStep() { + this.outputTraces = combineCloseSameNetSegments( + Array.from(this.tracesMap.values()), + this.input.paddingBuffer, + ) + this.tracesMap = new Map(this.outputTraces.map((t) => [t.mspPairId, t])) + this.traceIdQueue = Array.from(this.outputTraces.map((e) => e.mspPairId)) + this.pipelineStep = "minimizing_turns" + } + private _runMinimizeTurnsStep() { if (this.traceIdQueue.length === 0) { this.pipelineStep = "balancing_l_shapes" diff --git a/lib/solvers/TraceCleanupSolver/combineCloseSameNetSegments.ts b/lib/solvers/TraceCleanupSolver/combineCloseSameNetSegments.ts new file mode 100644 index 000000000..696aab30c --- /dev/null +++ b/lib/solvers/TraceCleanupSolver/combineCloseSameNetSegments.ts @@ -0,0 +1,194 @@ +import type { Point } from "@tscircuit/math-utils" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { simplifyPath } from "./simplifyPath" + +const EPSILON = 1e-9 +const MAX_PASSES = 20 + +type SegmentOrientation = "horizontal" | "vertical" + +interface TraceSegment { + traceIndex: number + pointIndex: number + orientation: SegmentOrientation + fixedCoord: number + minCoord: number + maxCoord: number + length: number + isTerminal: boolean +} + +const getTraceNetKey = (trace: SolvedTracePath): string => + trace.userNetId ?? trace.globalConnNetId ?? trace.dcConnNetId + +const getSegmentsForTrace = ( + trace: SolvedTracePath, + traceIndex: number, +): TraceSegment[] => { + const segments: TraceSegment[] = [] + + for ( + let pointIndex = 0; + pointIndex < trace.tracePath.length - 1; + pointIndex++ + ) { + const start = trace.tracePath[pointIndex]! + const end = trace.tracePath[pointIndex + 1]! + + if (Math.abs(start.y - end.y) <= EPSILON) { + const minCoord = Math.min(start.x, end.x) + const maxCoord = Math.max(start.x, end.x) + const length = maxCoord - minCoord + if (length <= EPSILON) continue + segments.push({ + traceIndex, + pointIndex, + orientation: "horizontal", + fixedCoord: start.y, + minCoord, + maxCoord, + length, + isTerminal: + pointIndex === 0 || pointIndex === trace.tracePath.length - 2, + }) + } else if (Math.abs(start.x - end.x) <= EPSILON) { + const minCoord = Math.min(start.y, end.y) + const maxCoord = Math.max(start.y, end.y) + const length = maxCoord - minCoord + if (length <= EPSILON) continue + segments.push({ + traceIndex, + pointIndex, + orientation: "vertical", + fixedCoord: start.x, + minCoord, + maxCoord, + length, + isTerminal: + pointIndex === 0 || pointIndex === trace.tracePath.length - 2, + }) + } + } + + return segments +} + +const hasProjectionOverlap = (a: TraceSegment, b: TraceSegment): boolean => { + const overlap = + Math.min(a.maxCoord, b.maxCoord) - Math.max(a.minCoord, b.minCoord) + return overlap > EPSILON +} + +const shouldCombineSegments = ( + a: TraceSegment, + b: TraceSegment, + maxDistance: number, +): boolean => { + if (a.traceIndex === b.traceIndex) return false + if (a.orientation !== b.orientation) return false + if (a.isTerminal || b.isTerminal) return false + if (!hasProjectionOverlap(a, b)) return false + + const distance = Math.abs(a.fixedCoord - b.fixedCoord) + return distance > EPSILON && distance <= maxDistance +} + +const wouldOverlapDifferentNetSegment = ( + traces: SolvedTracePath[], + target: TraceSegment, + targetNetKey: string, + fixedCoord: number, +): boolean => { + for (let traceIndex = 0; traceIndex < traces.length; traceIndex++) { + const trace = traces[traceIndex]! + if (getTraceNetKey(trace) === targetNetKey) continue + + for (const segment of getSegmentsForTrace(trace, traceIndex)) { + if (segment.orientation !== target.orientation) continue + if (Math.abs(segment.fixedCoord - fixedCoord) > EPSILON) continue + if (hasProjectionOverlap(segment, target)) return true + } + } + + return false +} + +const snapSegmentFixedCoord = ( + trace: SolvedTracePath, + segment: TraceSegment, + fixedCoord: number, +): SolvedTracePath => { + const tracePath = trace.tracePath.map((point) => ({ ...point })) + const start = tracePath[segment.pointIndex]! + const end = tracePath[segment.pointIndex + 1]! + + if (segment.orientation === "horizontal") { + start.y = fixedCoord + end.y = fixedCoord + } else { + start.x = fixedCoord + end.x = fixedCoord + } + + return { + ...trace, + tracePath: simplifyPath(tracePath as Point[]), + } +} + +export const combineCloseSameNetSegments = ( + traces: SolvedTracePath[], + maxDistance = 0.1, +): SolvedTracePath[] => { + const outputTraces = traces.map((trace) => ({ + ...trace, + tracePath: trace.tracePath.map((point) => ({ ...point })), + })) + + for (let pass = 0; pass < MAX_PASSES; pass++) { + let changed = false + + const segments = outputTraces.flatMap((trace, traceIndex) => + getSegmentsForTrace(trace, traceIndex), + ) + + for (let i = 0; i < segments.length; i++) { + const a = segments[i]! + const traceA = outputTraces[a.traceIndex]! + const netA = getTraceNetKey(traceA) + + for (let j = i + 1; j < segments.length; j++) { + const b = segments[j]! + const traceB = outputTraces[b.traceIndex]! + + if (netA !== getTraceNetKey(traceB)) continue + if (!shouldCombineSegments(a, b, maxDistance)) continue + + const [anchor, target] = a.length >= b.length ? [a, b] : [b, a] + if ( + wouldOverlapDifferentNetSegment( + outputTraces, + target, + netA, + anchor.fixedCoord, + ) + ) { + continue + } + outputTraces[target.traceIndex] = snapSegmentFixedCoord( + outputTraces[target.traceIndex]!, + target, + anchor.fixedCoord, + ) + changed = true + break + } + + if (changed) break + } + + if (!changed) break + } + + return outputTraces +} diff --git a/tests/functions/combine-close-same-net-segments.test.ts b/tests/functions/combine-close-same-net-segments.test.ts new file mode 100644 index 000000000..4e4fdbec8 --- /dev/null +++ b/tests/functions/combine-close-same-net-segments.test.ts @@ -0,0 +1,120 @@ +import { expect, test } from "bun:test" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { combineCloseSameNetSegments } from "lib/solvers/TraceCleanupSolver/combineCloseSameNetSegments" + +const baseTrace = ( + id: string, + netId: string, + tracePath: Array<{ x: number; y: number }>, +): SolvedTracePath => + ({ + mspPairId: id, + dcConnNetId: netId, + globalConnNetId: netId, + pins: [] as any, + pinIds: [], + mspConnectionPairIds: [id], + tracePath, + }) as SolvedTracePath + +test("snaps close parallel same-net segments onto the longer segment", () => { + const traces = [ + baseTrace("a", "N1", [ + { x: -1, y: -1 }, + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 3 }, + ]), + baseTrace("b", "N1", [ + { x: -1, y: 2 }, + { x: 1, y: 0.05 }, + { x: 9, y: 0.05 }, + { x: 9, y: 2 }, + ]), + ] + + const combined = combineCloseSameNetSegments(traces, 0.1) + + expect(combined[1]!.tracePath[1]).toEqual({ x: 1, y: 0 }) + expect(combined[1]!.tracePath[2]).toEqual({ x: 9, y: 0 }) +}) + +test("does not snap nearby segments from different nets", () => { + const traces = [ + baseTrace("a", "N1", [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ]), + baseTrace("b", "N2", [ + { x: 1, y: 0.05 }, + { x: 9, y: 0.05 }, + ]), + ] + + const combined = combineCloseSameNetSegments(traces, 0.1) + + expect(combined[1]!.tracePath).toEqual(traces[1]!.tracePath) +}) + +test("does not snap same-net segments outside the distance threshold", () => { + const traces = [ + baseTrace("a", "N1", [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ]), + baseTrace("b", "N1", [ + { x: 1, y: 0.5 }, + { x: 9, y: 0.5 }, + ]), + ] + + const combined = combineCloseSameNetSegments(traces, 0.1) + + expect(combined[1]!.tracePath).toEqual(traces[1]!.tracePath) +}) + +test("preserves terminal pin legs", () => { + const traces = [ + baseTrace("a", "N1", [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + ]), + baseTrace("b", "N1", [ + { x: 1, y: 0.05 }, + { x: 9, y: 0.05 }, + ]), + ] + + const combined = combineCloseSameNetSegments(traces, 0.1) + + expect(combined[0]!.tracePath).toEqual(traces[0]!.tracePath) + expect(combined[1]!.tracePath).toEqual(traces[1]!.tracePath) +}) + +test("rejects snaps that would overlap a different-net segment", () => { + const traces = [ + baseTrace("a", "N1", [ + { x: -1, y: -1 }, + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 11, y: 1 }, + ]), + baseTrace("b", "N1", [ + { x: -1, y: 2 }, + { x: 1, y: 0.05 }, + { x: 9, y: 0.05 }, + { x: 11, y: 2 }, + ]), + baseTrace("c", "N2", [ + { x: -1, y: 3 }, + { x: 2, y: 0 }, + { x: 8, y: 0 }, + { x: 11, y: 3 }, + ]), + ] + + const combined = combineCloseSameNetSegments(traces, 0.1) + + expect(combined[1]!.tracePath[1]).toEqual({ x: 1, y: 0.05 }) + expect(combined[1]!.tracePath[2]).toEqual({ x: 9, y: 0.05 }) +})