diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index e9bac7ca3..551ea88c8 100644 --- a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts +++ b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts @@ -1,11 +1,12 @@ -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 { alignSameNetTraceSegments } from "./alignSameNetTraceSegments" +import { balanceZShapes } from "./balanceZShapes" +import { minimizeTurnsWithFilteredLabels } from "./minimizeTurnsWithFilteredLabels" /** * Defines the input structure for the TraceCleanupSolver. @@ -18,13 +19,14 @@ interface TraceCleanupSolverInput { paddingBuffer: number } -import { UntangleTraceSubsolver } from "./sub-solver/UntangleTraceSubsolver" 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,23 @@ export class TraceCleanupSolver extends BaseSolver { }) } + private _runAlignSameNetSegmentsStep() { + const result = alignSameNetTraceSegments({ + traces: Array.from(this.tracesMap.values()), + inputProblem: this.input.inputProblem, + allLabelPlacements: this.input.allLabelPlacements, + mergedLabelNetIdMap: this.input.mergedLabelNetIdMap, + paddingBuffer: this.input.paddingBuffer, + }) + + 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/alignSameNetTraceSegments.ts b/lib/solvers/TraceCleanupSolver/alignSameNetTraceSegments.ts new file mode 100644 index 000000000..c7a136a11 --- /dev/null +++ b/lib/solvers/TraceCleanupSolver/alignSameNetTraceSegments.ts @@ -0,0 +1,408 @@ +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 + netId: string +} + +interface RectObstacle { + chipId: string + minX: number + minY: number + maxX: number + maxY: number +} + +interface AlignSameNetTraceSegmentsOptions { + inputProblem?: InputProblem + allLabelPlacements?: NetLabelPlacement[] + mergedLabelNetIdMap?: Record> + paddingBuffer?: number + maxAlignmentDistance?: number + maxEndpointGap?: number + maxIterations?: number +} + +interface AlignSameNetTraceSegmentsParams + extends AlignSameNetTraceSegmentsOptions { + traces: SolvedTracePath[] +} + +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((point) => ({ ...point })), +}) + +const normalizePath = (path: Point[]): Point[] => { + const withoutDuplicatePoints: Point[] = [] + + for (const point of path) { + const previous = withoutDuplicatePoints[withoutDuplicatePoints.length - 1] + if ( + previous && + Math.abs(previous.x - point.x) < EPS && + Math.abs(previous.y - point.y) < EPS + ) { + continue + } + withoutDuplicatePoints.push(point) + } + + return simplifyPath(withoutDuplicatePoints) +} + +const collectInteriorSegments = ( + traces: SolvedTracePath[], + traceIndex: number, +): SegmentRef[] => { + const trace = traces[traceIndex]! + const segments: SegmentRef[] = [] + + for ( + let segmentIndex = 1; + segmentIndex < trace.tracePath.length - 2; + segmentIndex++ + ) { + const start = trace.tracePath[segmentIndex]! + const end = trace.tracePath[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, + netId: trace.globalConnNetId, + }) + } + } 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, + netId: trace.globalConnNetId, + }) + } + } + } + + return segments +} + +const areEquivalentNetIds = ( + leftNetId: string, + rightNetId: string, + mergedLabelNetIdMap: Record> = {}, +) => { + if (leftNetId === rightNetId) return true + return ( + mergedLabelNetIdMap[leftNetId]?.has(rightNetId) === true || + mergedLabelNetIdMap[rightNetId]?.has(leftNetId) === true + ) +} + +const rangesAreNear = ( + left: SegmentRef, + right: SegmentRef, + maxEndpointGap: number, +) => + Math.max(left.min, right.min) - Math.min(left.max, right.max) <= + maxEndpointGap + +const segmentsAreClusterable = ( + left: SegmentRef, + right: SegmentRef, + params: Required< + Pick< + AlignSameNetTraceSegmentsParams, + "maxAlignmentDistance" | "maxEndpointGap" + > + > & { + mergedLabelNetIdMap?: Record> + }, +) => + left.traceIndex !== right.traceIndex && + left.orientation === right.orientation && + areEquivalentNetIds(left.netId, right.netId, params.mergedLabelNetIdMap) && + Math.abs(left.fixedCoord - right.fixedCoord) <= params.maxAlignmentDistance && + rangesAreNear(left, right, params.maxEndpointGap) + +const getSegmentKey = (segment: SegmentRef) => + `${segment.traceIndex}:${segment.segmentIndex}` + +const getCandidateClusters = ( + segments: SegmentRef[], + params: Required< + Pick< + AlignSameNetTraceSegmentsParams, + "maxAlignmentDistance" | "maxEndpointGap" + > + > & { + mergedLabelNetIdMap?: Record> + }, +) => { + const visited = new Set() + const clusters: SegmentRef[][] = [] + + for (const segment of segments) { + const segmentKey = getSegmentKey(segment) + if (visited.has(segmentKey)) continue + + const cluster: SegmentRef[] = [] + const queue = [segment] + visited.add(segmentKey) + + while (queue.length > 0) { + const current = queue.shift()! + cluster.push(current) + + for (const candidate of segments) { + const candidateKey = getSegmentKey(candidate) + if (visited.has(candidateKey)) continue + if (!segmentsAreClusterable(current, candidate, params)) continue + + visited.add(candidateKey) + queue.push(candidate) + } + } + + if (cluster.length > 1) clusters.push(cluster) + } + + return clusters.sort((left, right) => { + const rightLength = right.reduce((sum, segment) => sum + segment.length, 0) + const leftLength = left.reduce((sum, segment) => sum + segment.length, 0) + return right.length - left.length || rightLength - leftLength + }) +} + +const getAnchorSegment = (cluster: SegmentRef[]) => + cluster.reduce((best, segment) => { + if (segment.length > best.length + EPS) return segment + if ( + Math.abs(segment.length - best.length) < EPS && + segment.traceIndex < best.traceIndex + ) { + return segment + } + return best + }, cluster[0]!) + +const rectFromSegment = ( + start: Point, + end: Point, + chipId: string, +): RectObstacle => ({ + chipId, + minX: Math.min(start.x, end.x) - TRACE_WIDTH / 2, + minY: Math.min(start.y, end.y) - TRACE_WIDTH / 2, + maxX: Math.max(start.x, end.x) + TRACE_WIDTH / 2, + maxY: Math.max(start.y, end.y) + TRACE_WIDTH / 2, +}) + +const getBlockingRectsForTrace = ( + traces: SolvedTracePath[], + traceIndex: number, + params: AlignSameNetTraceSegmentsOptions, +): RectObstacle[] => { + const targetTrace = traces[traceIndex]! + + const staticObstacles = + params.inputProblem?.chips === undefined + ? [] + : getObstacleRects(params.inputProblem).map((obstacle) => ({ + ...obstacle, + minX: obstacle.minX - STATIC_OBSTACLE_PADDING, + minY: obstacle.minY - STATIC_OBSTACLE_PADDING, + maxX: obstacle.maxX + STATIC_OBSTACLE_PADDING, + maxY: obstacle.maxY + STATIC_OBSTACLE_PADDING, + })) + + const traceObstacles = traces.flatMap((trace, otherTraceIndex) => { + if (otherTraceIndex === traceIndex) return [] + if ( + areEquivalentNetIds( + trace.globalConnNetId, + targetTrace.globalConnNetId, + params.mergedLabelNetIdMap, + ) + ) { + return [] + } + + return trace.tracePath.slice(0, -1).map((start, segmentIndex) => { + const end = trace.tracePath[segmentIndex + 1]! + return rectFromSegment( + start, + end, + `trace-${otherTraceIndex}-${segmentIndex}`, + ) + }) + }) + + const labelObstacles = (params.allLabelPlacements ?? []) + .filter( + (label) => + !areEquivalentNetIds( + label.globalConnNetId, + targetTrace.globalConnNetId, + params.mergedLabelNetIdMap, + ), + ) + .map((label) => ({ + chipId: `label-${label.globalConnNetId}`, + minX: label.center.x - label.width / 2 - (params.paddingBuffer ?? 0), + minY: label.center.y - label.height / 2 - (params.paddingBuffer ?? 0), + maxX: label.center.x + label.width / 2 + (params.paddingBuffer ?? 0), + maxY: label.center.y + label.height / 2 + (params.paddingBuffer ?? 0), + })) + + return [...staticObstacles, ...traceObstacles, ...labelObstacles] +} + +const countIntersections = (path: Point[], obstacles: RectObstacle[]) => { + let intersections = 0 + + for (let i = 0; i < path.length - 1; i++) { + const start = path[i]! + const end = path[i + 1]! + for (const obstacle of obstacles) { + if (segmentIntersectsRect(start, end, obstacle)) { + intersections++ + } + } + } + + return intersections +} + +const getMovedPath = ( + trace: SolvedTracePath, + segment: SegmentRef, + targetFixedCoord: number, +) => { + const nextPath = trace.tracePath.map((point) => ({ ...point })) + const start = nextPath[segment.segmentIndex]! + const end = nextPath[segment.segmentIndex + 1]! + + if (segment.orientation === "horizontal") { + start.y = targetFixedCoord + end.y = targetFixedCoord + } else { + start.x = targetFixedCoord + end.x = targetFixedCoord + } + + return normalizePath(nextPath) +} + +const canMoveSegment = ( + traces: SolvedTracePath[], + segment: SegmentRef, + targetFixedCoord: number, + params: AlignSameNetTraceSegmentsOptions, +) => { + const trace = traces[segment.traceIndex]! + const movedPath = getMovedPath(trace, segment, targetFixedCoord) + const blockingRects = getBlockingRectsForTrace( + traces, + segment.traceIndex, + params, + ) + + return ( + countIntersections(movedPath, blockingRects) <= + countIntersections(trace.tracePath, blockingRects) + ) +} + +const applySegmentMove = ( + traces: SolvedTracePath[], + segment: SegmentRef, + targetFixedCoord: number, +) => { + const trace = traces[segment.traceIndex]! + traces[segment.traceIndex] = { + ...trace, + tracePath: getMovedPath(trace, segment, targetFixedCoord), + } +} + +export const alignSameNetTraceSegments = ({ + traces, + maxAlignmentDistance = 0.12, + maxEndpointGap = 0.05, + maxIterations = 25, + ...params +}: AlignSameNetTraceSegmentsParams) => { + const outputTraces = traces.map(cloneTrace) + let changed = false + + for (let iteration = 0; iteration < maxIterations; iteration++) { + const segments = outputTraces.flatMap((_, traceIndex) => + collectInteriorSegments(outputTraces, traceIndex), + ) + const clusters = getCandidateClusters(segments, { + maxAlignmentDistance, + maxEndpointGap, + mergedLabelNetIdMap: params.mergedLabelNetIdMap, + }) + + let changedThisIteration = false + + for (const cluster of clusters) { + const anchor = getAnchorSegment(cluster) + for (const segment of cluster) { + if (getSegmentKey(segment) === getSegmentKey(anchor)) continue + if (Math.abs(segment.fixedCoord - anchor.fixedCoord) < EPS) continue + if (!canMoveSegment(outputTraces, segment, anchor.fixedCoord, params)) { + continue + } + + applySegmentMove(outputTraces, segment, anchor.fixedCoord) + changed = true + changedThisIteration = true + } + + if (changedThisIteration) break + } + + if (!changedThisIteration) break + } + + 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-same-net-trace-segments.test.ts b/tests/solvers/TraceCleanupSolver/align-same-net-trace-segments.test.ts new file mode 100644 index 000000000..a94ceefeb --- /dev/null +++ b/tests/solvers/TraceCleanupSolver/align-same-net-trace-segments.test.ts @@ -0,0 +1,183 @@ +import { expect, test } from "bun:test" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { alignSameNetTraceSegments } from "lib/solvers/TraceCleanupSolver/alignSameNetTraceSegments" +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("alignSameNetTraceSegments aligns a close same-net segment group to the longest segment", () => { + const longest = makeTrace("pair-a", "NET1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 0 }, + ]) + const middle = makeTrace("pair-b", "NET1", [ + { x: 0, y: 0.06 }, + { x: 1.2, y: 0.06 }, + { x: 1.2, y: 1.06 }, + { x: 3.2, y: 1.06 }, + { x: 3.2, y: 0.06 }, + ]) + const shortest = makeTrace("pair-c", "NET1", [ + { x: 0, y: 0.11 }, + { x: 1.4, y: 0.11 }, + { x: 1.4, y: 1.11 }, + { x: 2.8, y: 1.11 }, + { x: 2.8, y: 0.11 }, + ]) + + const result = alignSameNetTraceSegments({ + traces: [middle, shortest, longest], + }) + + expect(result.changed).toBe(true) + expect(result.traces[0]!.tracePath[2]!.y).toBe(1) + expect(result.traces[0]!.tracePath[3]!.y).toBe(1) + expect(result.traces[1]!.tracePath[2]!.y).toBe(1) + expect(result.traces[1]!.tracePath[3]!.y).toBe(1) + expect(result.traces[2]!.tracePath).toEqual(longest.tracePath) +}) + +test("alignSameNetTraceSegments aligns close interior vertical segments", () => { + const left = makeTrace("pair-a", "NET1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 3 }, + { x: 0, y: 3 }, + ]) + const right = 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 = alignSameNetTraceSegments({ traces: [left, right] }) + + expect(result.changed).toBe(true) + 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("alignSameNetTraceSegments does not align different nets", () => { + const first = 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 second = 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 = alignSameNetTraceSegments({ traces: [first, second] }) + + expect(result.changed).toBe(false) + expect(result.traces.map((trace) => trace.tracePath)).toEqual([ + first.tracePath, + second.tracePath, + ]) +}) + +test("alignSameNetTraceSegments leaves endpoint-only segments anchored", () => { + const first = makeTrace("pair-a", "NET1", [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 2, y: 1 }, + ]) + const second = makeTrace("pair-b", "NET1", [ + { x: 0, y: 0.08 }, + { x: 2, y: 0.08 }, + { x: 2, y: 1 }, + ]) + + const result = alignSameNetTraceSegments({ traces: [first, second] }) + + expect(result.changed).toBe(false) + expect(result.traces.map((trace) => trace.tracePath)).toEqual([ + first.tracePath, + second.tracePath, + ]) +}) + +test("alignSameNetTraceSegments does not align close segments within the same trace", () => { + const trace = makeTrace("pair-a", "NET1", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + { x: 3, y: 1 }, + { x: 3, y: 1.08 }, + { x: 1, y: 1.08 }, + { x: 1, y: 2 }, + ]) + + const result = alignSameNetTraceSegments({ traces: [trace] }) + + expect(result.changed).toBe(false) + expect(result.traces[0]!.tracePath).toEqual(trace.tracePath) +}) + +test("alignSameNetTraceSegments rejects moves that add chip intersections", () => { + const keep = 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 move = 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 = alignSameNetTraceSegments({ + traces: [keep, move], + inputProblem, + }) + + expect(result.changed).toBe(false) + expect(result.traces.map((trace) => trace.tracePath)).toEqual([ + keep.tracePath, + move.tracePath, + ]) +})