diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index e9bac7ca3..e328d4d34 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 { alignNearbySameNetTraceSegments } from "./alignNearbySameNetTraceSegments" import { is4PointRectangle } from "./is4PointRectangle" +import { UntangleTraceSubsolver } from "./sub-solver/UntangleTraceSubsolver" /** * Represents the different stages or steps within the trace cleanup pipeline. */ type PipelineStep = + | "aligning_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 = "aligning_same_net_segments" } else if (this.activeSubSolver.failed) { this.activeSubSolver = null - this.pipelineStep = "minimizing_turns" + this.pipelineStep = "aligning_same_net_segments" } return } @@ -78,6 +80,9 @@ export class TraceCleanupSolver extends BaseSolver { case "untangling_traces": this._runUntangleTracesStep() break + case "aligning_same_net_segments": + this._runAlignSameNetSegmentsStep() + break case "minimizing_turns": this._runMinimizeTurnsStep() break @@ -94,6 +99,20 @@ export class TraceCleanupSolver extends BaseSolver { }) } + private _runAlignSameNetSegmentsStep() { + const result = alignNearbySameNetTraceSegments( + Array.from(this.tracesMap.values()), + this.input, + ) + + if (result.changed) { + this.outputTraces = result.traces + this.tracesMap = new Map(this.outputTraces.map((t) => [t.mspPairId, t])) + } + + this.pipelineStep = "minimizing_turns" + } + private _runMinimizeTurnsStep() { if (this.traceIdQueue.length === 0) { this.pipelineStep = "balancing_l_shapes" diff --git a/lib/solvers/TraceCleanupSolver/alignNearbySameNetTraceSegments.ts b/lib/solvers/TraceCleanupSolver/alignNearbySameNetTraceSegments.ts new file mode 100644 index 000000000..a355df54c --- /dev/null +++ b/lib/solvers/TraceCleanupSolver/alignNearbySameNetTraceSegments.ts @@ -0,0 +1,336 @@ +import type { Point } from "@tscircuit/math-utils" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { segmentIntersectsRect } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/collisions" +import { getObstacleRects } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/rect" +import type { InputProblem } from "lib/types/InputProblem" +import type { NetLabelPlacement } from "../NetLabelPlacementSolver/NetLabelPlacementSolver" +import { simplifyPath } from "./simplifyPath" + +type Orientation = "horizontal" | "vertical" + +interface SegmentRef { + traceIndex: number + segmentIndex: number + orientation: Orientation + fixedCoord: number + min: number + max: number + length: number +} + +export interface SameNetAlignmentOptions { + maxAlignmentDistance?: number + maxEndpointGap?: number + maxIterations?: number + inputProblem?: InputProblem + allLabelPlacements?: NetLabelPlacement[] + mergedLabelNetIdMap?: Record> + paddingBuffer?: number +} + +const EPS = 1e-6 +const TRACE_WIDTH = 0.01 +const STATIC_OBSTACLE_PADDING = 0.01 + +const cloneTrace = (trace: SolvedTracePath): SolvedTracePath => ({ + ...trace, + tracePath: trace.tracePath.map((p) => ({ x: p.x, y: p.y })), +}) + +const normalizePath = (path: Point[]): Point[] => { + const deduped: Point[] = [] + for (const point of path) { + const previous = deduped[deduped.length - 1] + if ( + previous && + Math.abs(previous.x - point.x) < EPS && + Math.abs(previous.y - point.y) < EPS + ) { + continue + } + deduped.push(point) + } + return simplifyPath(deduped) +} + +const collectInteriorSegments = ( + traces: SolvedTracePath[], + traceIndex: number, +): SegmentRef[] => { + const path = traces[traceIndex]!.tracePath + const segments: SegmentRef[] = [] + + for (let segmentIndex = 1; segmentIndex < path.length - 2; segmentIndex++) { + const start = path[segmentIndex]! + const end = path[segmentIndex + 1]! + + if (Math.abs(start.y - end.y) < EPS) { + const min = Math.min(start.x, end.x) + const max = Math.max(start.x, end.x) + if (max - min > EPS) { + segments.push({ + traceIndex, + segmentIndex, + orientation: "horizontal", + fixedCoord: start.y, + min, + max, + length: max - min, + }) + } + } else if (Math.abs(start.x - end.x) < EPS) { + const min = Math.min(start.y, end.y) + const max = Math.max(start.y, end.y) + if (max - min > EPS) { + segments.push({ + traceIndex, + segmentIndex, + orientation: "vertical", + fixedCoord: start.x, + min, + max, + length: max - min, + }) + } + } + } + + return segments +} + +const rangesAreClose = (a: SegmentRef, b: SegmentRef, maxEndpointGap: number) => + Math.max(a.min, b.min) - Math.min(a.max, b.max) <= maxEndpointGap + +const netIdsMatch = ( + a: string, + b: string, + mergedLabelNetIdMap: Record> = {}, +) => { + if (a === b) return true + return ( + mergedLabelNetIdMap[a]?.has(b) === true || + mergedLabelNetIdMap[b]?.has(a) === true + ) +} + +const rectFromSegment = (p1: Point, p2: Point, chipId: string) => ({ + chipId, + minX: Math.min(p1.x, p2.x) - TRACE_WIDTH / 2, + minY: Math.min(p1.y, p2.y) - TRACE_WIDTH / 2, + maxX: Math.max(p1.x, p2.x) + TRACE_WIDTH / 2, + maxY: Math.max(p1.y, p2.y) + TRACE_WIDTH / 2, +}) + +const getBlockingRectsForTrace = ( + traces: SolvedTracePath[], + traceIndex: number, + { + inputProblem, + allLabelPlacements = [], + mergedLabelNetIdMap = {}, + paddingBuffer = 0, + }: SameNetAlignmentOptions, +) => { + const targetTrace = traces[traceIndex]! + const staticObstacles = inputProblem + ? getObstacleRects(inputProblem).map((obs) => ({ + ...obs, + minX: obs.minX - STATIC_OBSTACLE_PADDING, + minY: obs.minY - STATIC_OBSTACLE_PADDING, + maxX: obs.maxX + STATIC_OBSTACLE_PADDING, + maxY: obs.maxY + STATIC_OBSTACLE_PADDING, + })) + : [] + + const traceObstacles = traces.flatMap((trace, otherTraceIndex) => { + if (otherTraceIndex === traceIndex) return [] + if ( + netIdsMatch( + trace.globalConnNetId, + targetTrace.globalConnNetId, + mergedLabelNetIdMap, + ) + ) { + return [] + } + + return trace.tracePath.slice(0, -1).map((p1, segmentIndex) => { + const p2 = trace.tracePath[segmentIndex + 1]! + return rectFromSegment( + p1, + p2, + `trace-obstacle-${otherTraceIndex}-${segmentIndex}`, + ) + }) + }) + + const labelBounds = allLabelPlacements + .filter( + (label) => + !netIdsMatch( + label.globalConnNetId, + targetTrace.globalConnNetId, + mergedLabelNetIdMap, + ), + ) + .map((label) => ({ + chipId: `label-obstacle-${label.globalConnNetId}`, + minX: label.center.x - label.width / 2 - paddingBuffer, + maxX: label.center.x + label.width / 2 + paddingBuffer, + minY: label.center.y - label.height / 2 - paddingBuffer, + maxY: label.center.y + label.height / 2 + paddingBuffer, + })) + + return [...staticObstacles, ...traceObstacles, ...labelBounds] +} + +const countPathRectIntersections = ( + path: Point[], + rects: ReturnType, +) => { + let count = 0 + for (let i = 0; i < path.length - 1; i++) { + const start = path[i]! + const end = path[i + 1]! + for (const rect of rects) { + if (segmentIntersectsRect(start, end, rect)) count++ + } + } + return count +} + +const getMovedPath = ( + traces: SolvedTracePath[], + segment: SegmentRef, + targetCoord: number, +) => { + const trace = traces[segment.traceIndex]! + const nextPath = trace.tracePath.map((p) => ({ x: p.x, y: p.y })) + const start = nextPath[segment.segmentIndex]! + const end = nextPath[segment.segmentIndex + 1]! + + if (segment.orientation === "horizontal") { + start.y = targetCoord + end.y = targetCoord + } else { + start.x = targetCoord + end.x = targetCoord + } + + return normalizePath(nextPath) +} + +const canMoveSegmentToCoord = ( + traces: SolvedTracePath[], + segment: SegmentRef, + targetCoord: number, + options: SameNetAlignmentOptions, +) => { + if (!options.inputProblem && !options.allLabelPlacements?.length) return true + + const originalPath = traces[segment.traceIndex]!.tracePath + const movedPath = getMovedPath(traces, segment, targetCoord) + const blockingRects = getBlockingRectsForTrace( + traces, + segment.traceIndex, + options, + ) + + return ( + countPathRectIntersections(movedPath, blockingRects) <= + countPathRectIntersections(originalPath, blockingRects) + ) +} + +const findNextAlignment = ( + traces: SolvedTracePath[], + maxAlignmentDistance: number, + maxEndpointGap: number, + options: SameNetAlignmentOptions, +) => { + for (let traceIndex = 0; traceIndex < traces.length; traceIndex++) { + const trace = traces[traceIndex]! + const sameNetTraces = traces + .map((candidate, candidateIndex) => ({ candidate, candidateIndex })) + .filter( + ({ candidate, candidateIndex }) => + candidateIndex > traceIndex && + candidate.globalConnNetId === trace.globalConnNetId, + ) + + const leftSegments = collectInteriorSegments(traces, traceIndex) + for (const { candidateIndex } of sameNetTraces) { + const rightSegments = collectInteriorSegments(traces, candidateIndex) + + for (const left of leftSegments) { + for (const right of rightSegments) { + if (left.orientation !== right.orientation) continue + if (!rangesAreClose(left, right, maxEndpointGap)) continue + if ( + Math.abs(left.fixedCoord - right.fixedCoord) > maxAlignmentDistance + ) { + continue + } + + const keep = left.length >= right.length ? left : right + const move = keep === left ? right : left + if (Math.abs(move.fixedCoord - keep.fixedCoord) < EPS) continue + if (!canMoveSegmentToCoord(traces, move, keep.fixedCoord, options)) { + continue + } + + return { + targetCoord: keep.fixedCoord, + segment: move, + } + } + } + } + } + + return null +} + +const moveSegmentToCoord = ( + traces: SolvedTracePath[], + segment: SegmentRef, + targetCoord: number, +) => { + const trace = traces[segment.traceIndex]! + + traces[segment.traceIndex] = { + ...trace, + tracePath: getMovedPath(traces, segment, targetCoord), + } +} + +export const alignNearbySameNetTraceSegments = ( + traces: SolvedTracePath[], + options: SameNetAlignmentOptions = {}, +) => { + const { + maxAlignmentDistance = 0.12, + maxEndpointGap = 0.05, + maxIterations = 100, + } = options + const outputTraces = traces.map(cloneTrace) + let changed = false + + for (let iteration = 0; iteration < maxIterations; iteration++) { + const alignment = findNextAlignment( + outputTraces, + maxAlignmentDistance, + maxEndpointGap, + options, + ) + if (!alignment) break + + moveSegmentToCoord(outputTraces, alignment.segment, alignment.targetCoord) + changed = true + } + + return { + changed, + traces: outputTraces, + } +} diff --git a/tests/examples/__snapshots__/example18.snap.svg b/tests/examples/__snapshots__/example18.snap.svg index 8c7f05fc8..bc8149cf2 100644 --- a/tests/examples/__snapshots__/example18.snap.svg +++ b/tests/examples/__snapshots__/example18.snap.svg @@ -65,24 +65,19 @@ y-" data-x="1.7580660749999977" data-y="-3.3025814000000002" cx="494.02875093834 y+" data-x="1.757519574999999" data-y="-2.2" cx="493.97982495355666" cy="501.29024555523955" r="3" fill="hsl(248, 100%, 50%, 0.8)" /> - + - + - + - + - + @@ -139,7 +134,7 @@ orientation: x+" data-x="1.757519574999999" data-y="-2" cx="493.97982495355666" - + @@ -167,28 +162,23 @@ orientation: x+" data-x="1.757519574999999" data-y="-2" cx="493.97982495355666" +globalConnNetId: connectivity_net0" data-x="-1.8574283249999997" data-y="0.9762093000000004" x="161.39522395803996" y="196.79342126794842" width="17.905209437554532" height="40.28672123449769" fill="#ef444466" stroke="#ef4444" stroke-width="0.011169933571428573" /> +globalConnNetId: connectivity_net0" data-x="1.5790330374999988" data-y="2.7275814000000005" x="469.0480260561722" y="40" width="17.90520943755456" height="40.28672123449769" fill="#ef444466" stroke="#ef4444" stroke-width="0.011169933571428573" /> +globalConnNetId: connectivity_net1" data-x="-2.31430995" data-y="-0.9762093000000004" x="120.4924180390637" y="371.58574098183345" width="17.905209437554532" height="40.28672123449769" fill="#00000066" stroke="#000000" stroke-width="0.011169933571428573" /> +globalConnNetId: connectivity_net2" data-x="1.982519574999999" data-y="0.85" x="493.97982495355666" y="219.2831969137558" width="40.28672123449769" height="17.905209437554532" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.011169933571428573" /> +globalConnNetId: connectivity_net3" data-x="1.982519574999999" data-y="-2" x="493.97982495355666" y="474.43243139890774" width="40.28672123449769" height="17.90520943755456" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.011169933571428573" /> - + - + - + - + @@ -101,7 +97,7 @@ orientation: y+" data-x="3.3884680250000008" data-y="1.2997267500000007" cx="438 - + diff --git a/tests/solvers/TraceCleanupSolver/align-nearby-same-net-trace-segments.test.ts b/tests/solvers/TraceCleanupSolver/align-nearby-same-net-trace-segments.test.ts new file mode 100644 index 000000000..cbdafe7b7 --- /dev/null +++ b/tests/solvers/TraceCleanupSolver/align-nearby-same-net-trace-segments.test.ts @@ -0,0 +1,160 @@ +import { expect, test } from "bun:test" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { alignNearbySameNetTraceSegments } from "lib/solvers/TraceCleanupSolver/alignNearbySameNetTraceSegments" +import type { InputProblem } from "lib/types/InputProblem" + +const makeTrace = ( + mspPairId: string, + globalConnNetId: string, + tracePath: Array<{ x: number; y: number }>, +): SolvedTracePath => + ({ + mspPairId, + globalConnNetId, + tracePath, + mspConnectionPairIds: [mspPairId], + pinIds: [`${mspPairId}.1`, `${mspPairId}.2`], + pins: [], + }) as unknown as SolvedTracePath + +test("alignNearbySameNetTraceSegments aligns close interior horizontal segments on the same net", () => { + const longTrace = makeTrace("pair-a", "NET1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 3, y: 1 }, + { x: 3, y: 0 }, + ]) + const shortTrace = makeTrace("pair-b", "NET1", [ + { x: 0, y: 0.08 }, + { x: 1.2, y: 0.08 }, + { x: 1.2, y: 1.08 }, + { x: 2.8, y: 1.08 }, + { x: 2.8, y: 0.08 }, + ]) + + const result = alignNearbySameNetTraceSegments([longTrace, shortTrace]) + + expect(result.changed).toBe(true) + expect(result.traces[0]!.tracePath).toEqual(longTrace.tracePath) + expect(result.traces[1]!.tracePath).toEqual([ + { x: 0, y: 0.08 }, + { x: 1.2, y: 0.08 }, + { x: 1.2, y: 1 }, + { x: 2.8, y: 1 }, + { x: 2.8, y: 0.08 }, + ]) +}) + +test("alignNearbySameNetTraceSegments aligns close interior vertical segments on the same net", () => { + const leftTrace = makeTrace("pair-a", "NET1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 3 }, + { x: 0, y: 3 }, + ]) + const rightTrace = makeTrace("pair-b", "NET1", [ + { x: 0.08, y: 0 }, + { x: 1.08, y: 0 }, + { x: 1.08, y: 3 }, + { x: 0.08, y: 3 }, + ]) + + const result = alignNearbySameNetTraceSegments([leftTrace, rightTrace]) + + expect(result.changed).toBe(true) + expect(result.traces[0]!.tracePath).toEqual(leftTrace.tracePath) + expect(result.traces[1]!.tracePath).toEqual([ + { x: 0.08, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 3 }, + { x: 0.08, y: 3 }, + ]) +}) + +test("alignNearbySameNetTraceSegments leaves different nets unchanged", () => { + const firstTrace = makeTrace("pair-a", "NET1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 3, y: 1 }, + { x: 3, y: 0 }, + ]) + const secondTrace = makeTrace("pair-b", "NET2", [ + { x: 0, y: 0.08 }, + { x: 1.2, y: 0.08 }, + { x: 1.2, y: 1.08 }, + { x: 2.8, y: 1.08 }, + { x: 2.8, y: 0.08 }, + ]) + + const result = alignNearbySameNetTraceSegments([firstTrace, secondTrace]) + + expect(result.changed).toBe(false) + expect(result.traces.map((trace) => trace.tracePath)).toEqual([ + firstTrace.tracePath, + secondTrace.tracePath, + ]) +}) + +test("alignNearbySameNetTraceSegments does not move endpoint segments", () => { + const firstTrace = makeTrace("pair-a", "NET1", [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 2, y: 1 }, + ]) + const secondTrace = makeTrace("pair-b", "NET1", [ + { x: 0, y: 0.08 }, + { x: 2, y: 0.08 }, + { x: 2, y: 1 }, + ]) + + const result = alignNearbySameNetTraceSegments([firstTrace, secondTrace]) + + expect(result.changed).toBe(false) + expect(result.traces.map((trace) => trace.tracePath)).toEqual([ + firstTrace.tracePath, + secondTrace.tracePath, + ]) +}) + +test("alignNearbySameNetTraceSegments does not introduce obstacle intersections", () => { + const keepTrace = makeTrace("pair-a", "NET1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 3, y: 1 }, + { x: 3, y: 0 }, + ]) + const moveTrace = makeTrace("pair-b", "NET1", [ + { x: 0, y: 0.08 }, + { x: 1.2, y: 0.08 }, + { x: 1.2, y: 1.08 }, + { x: 2.8, y: 1.08 }, + { x: 2.8, y: 0.08 }, + ]) + const inputProblem: InputProblem = { + chips: [ + { + chipId: "obstacle", + center: { x: 2, y: 1 }, + width: 1, + height: 0.08, + pins: [], + }, + ], + directConnections: [], + netConnections: [], + availableNetLabelOrientations: {}, + } + + const result = alignNearbySameNetTraceSegments([keepTrace, moveTrace], { + inputProblem, + }) + + expect(result.changed).toBe(false) + expect(result.traces.map((trace) => trace.tracePath)).toEqual([ + keepTrace.tracePath, + moveTrace.tracePath, + ]) +})