diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts index e9bac7ca3..ca42f3412 100644 --- a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts +++ b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts @@ -2,6 +2,7 @@ import type { InputProblem } from "lib/types/InputProblem" import type { GraphicsObject, Line } from "graphics-debug" import { minimizeTurnsWithFilteredLabels } from "./minimizeTurnsWithFilteredLabels" import { balanceZShapes } from "./balanceZShapes" +import { alignCloseSameNetSegments } from "./alignCloseSameNetSegments" import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem" @@ -27,6 +28,7 @@ import { is4PointRectangle } from "./is4PointRectangle" type PipelineStep = | "minimizing_turns" | "balancing_l_shapes" + | "aligning_same_net_segments" | "untangling_traces" /** @@ -34,7 +36,8 @@ type PipelineStep = * It operates in a multi-step pipeline: * 1. **Untangling Traces**: It first attempts to untangle any overlapping or highly convoluted traces using a sub-solver. * 2. **Minimizing Turns**: After untangling, it iterates through each trace to minimize the number of turns, simplifying their paths. - * 3. **Balancing L-Shapes**: Finally, it balances L-shaped trace segments to create more visually appealing and consistent layouts. + * 3. **Balancing L-Shapes**: It balances L-shaped trace segments to create more visually appealing and consistent layouts. + * 4. **Aligning Same-Net Segments**: Finally, it aligns close same-net segments onto a shared axis when doing so is collision-free. * The solver processes traces one by one, applying these cleanup steps sequentially to refine the overall trace layout. */ export class TraceCleanupSolver extends BaseSolver { @@ -84,6 +87,9 @@ export class TraceCleanupSolver extends BaseSolver { case "balancing_l_shapes": this._runBalanceLShapesStep() break + case "aligning_same_net_segments": + this._runAlignSameNetSegmentsStep() + break } } @@ -108,13 +114,25 @@ export class TraceCleanupSolver extends BaseSolver { private _runBalanceLShapesStep() { if (this.traceIdQueue.length === 0) { - this.solved = true + this.pipelineStep = "aligning_same_net_segments" return } this._processTrace("balancing_l_shapes") } + private _runAlignSameNetSegmentsStep() { + this.outputTraces = alignCloseSameNetSegments({ + traces: Array.from(this.tracesMap.values()), + inputProblem: this.input.inputProblem, + allLabelPlacements: this.input.allLabelPlacements, + mergedLabelNetIdMap: this.input.mergedLabelNetIdMap, + paddingBuffer: this.input.paddingBuffer, + }) + this.tracesMap = new Map(this.outputTraces.map((t) => [t.mspPairId, t])) + this.solved = true + } + private _processTrace(step: "minimizing_turns" | "balancing_l_shapes") { const targetMspConnectionPairId = this.traceIdQueue.shift()! this.activeTraceId = targetMspConnectionPairId diff --git a/lib/solvers/TraceCleanupSolver/alignCloseSameNetSegments.ts b/lib/solvers/TraceCleanupSolver/alignCloseSameNetSegments.ts new file mode 100644 index 000000000..35c0ef4a7 --- /dev/null +++ b/lib/solvers/TraceCleanupSolver/alignCloseSameNetSegments.ts @@ -0,0 +1,458 @@ +import type { Point } from "@tscircuit/math-utils" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { + isHorizontal, + isVertical, + 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 SegmentOrientation = "horizontal" | "vertical" + +interface TraceSegment { + traceIndex: number + trace: SolvedTracePath + segmentIndex: number + p1: Point + p2: Point + orientation: SegmentOrientation + axis: number + min: number + max: number + length: number +} + +interface AlignmentMove { + moving: TraceSegment + anchor: TraceSegment + axisDistance: number +} + +export interface AlignCloseSameNetSegmentsInput { + traces: SolvedTracePath[] + inputProblem?: InputProblem + allLabelPlacements?: NetLabelPlacement[] + mergedLabelNetIdMap?: Record> + paddingBuffer?: number + maxAlignmentDistance?: number +} + +const EPS = 1e-6 +const DEFAULT_MAX_ALIGNMENT_DISTANCE = 0.15 +const MAX_ALIGNMENT_PASSES = 20 + +const getOrientation = (p1: Point, p2: Point): SegmentOrientation | null => { + if (isHorizontal(p1, p2, EPS)) return "horizontal" + if (isVertical(p1, p2, EPS)) return "vertical" + return null +} + +const isZeroLength = (p1: Point, p2: Point) => + Math.abs(p1.x - p2.x) < EPS && Math.abs(p1.y - p2.y) < EPS + +const getSegmentLength = ( + p1: Point, + p2: Point, + orientation: SegmentOrientation, +) => + orientation === "horizontal" ? Math.abs(p2.x - p1.x) : Math.abs(p2.y - p1.y) + +const getSegments = ( + traces: SolvedTracePath[], + { + internalOnly, + }: { + internalOnly: boolean + }, +): TraceSegment[] => { + const segments: TraceSegment[] = [] + + for (let traceIndex = 0; traceIndex < traces.length; traceIndex++) { + const trace = traces[traceIndex]! + const path = trace.tracePath + + for (let segmentIndex = 0; segmentIndex < path.length - 1; segmentIndex++) { + if ( + internalOnly && + (segmentIndex === 0 || segmentIndex >= path.length - 2) + ) { + continue + } + + const p1 = path[segmentIndex]! + const p2 = path[segmentIndex + 1]! + const orientation = getOrientation(p1, p2) + + if (!orientation || isZeroLength(p1, p2)) continue + + const min = + orientation === "horizontal" + ? Math.min(p1.x, p2.x) + : Math.min(p1.y, p2.y) + const max = + orientation === "horizontal" + ? Math.max(p1.x, p2.x) + : Math.max(p1.y, p2.y) + const axis = orientation === "horizontal" ? p1.y : p1.x + const length = getSegmentLength(p1, p2, orientation) + + if (length <= EPS) continue + + segments.push({ + traceIndex, + trace, + segmentIndex, + p1, + p2, + orientation, + axis, + min, + max, + length, + }) + } + } + + return segments +} + +const projectionOverlap = (a: TraceSegment, b: TraceSegment) => + Math.min(a.max, b.max) - Math.max(a.min, b.min) + +const areNetsEquivalent = ( + netA: string, + netB: string, + mergedLabelNetIdMap?: Record>, +) => { + if (netA === netB) return true + + if (!mergedLabelNetIdMap) return false + + if (mergedLabelNetIdMap[netA]?.has(netB)) return true + if (mergedLabelNetIdMap[netB]?.has(netA)) return true + + return Object.values(mergedLabelNetIdMap).some( + (mergedNetIds) => mergedNetIds.has(netA) && mergedNetIds.has(netB), + ) +} + +const chooseMoveDirection = ( + a: TraceSegment, + b: TraceSegment, +): { moving: TraceSegment; anchor: TraceSegment } => { + if (Math.abs(a.length - b.length) > EPS) { + return a.length < b.length + ? { moving: a, anchor: b } + : { moving: b, anchor: a } + } + + if (a.traceIndex !== b.traceIndex) { + return a.traceIndex > b.traceIndex + ? { moving: a, anchor: b } + : { moving: b, anchor: a } + } + + return a.segmentIndex > b.segmentIndex + ? { moving: a, anchor: b } + : { moving: b, anchor: a } +} + +const getAlignmentMoves = ({ + traces, + mergedLabelNetIdMap, + maxAlignmentDistance, +}: { + traces: SolvedTracePath[] + mergedLabelNetIdMap?: Record> + maxAlignmentDistance: number +}): AlignmentMove[] => { + const segments = getSegments(traces, { internalOnly: true }) + const moves: AlignmentMove[] = [] + + for (let i = 0; i < segments.length; i++) { + const a = segments[i]! + + for (let j = i + 1; j < segments.length; j++) { + const b = segments[j]! + + if (a.traceIndex === b.traceIndex) continue + if (a.orientation !== b.orientation) continue + if ( + !areNetsEquivalent( + a.trace.globalConnNetId, + b.trace.globalConnNetId, + mergedLabelNetIdMap, + ) + ) { + continue + } + + const axisDistance = Math.abs(a.axis - b.axis) + if (axisDistance <= EPS || axisDistance > maxAlignmentDistance) continue + if (projectionOverlap(a, b) <= EPS) continue + + const { moving, anchor } = chooseMoveDirection(a, b) + moves.push({ moving, anchor, axisDistance }) + } + } + + return moves.sort((a, b) => { + if (Math.abs(a.axisDistance - b.axisDistance) > EPS) { + return a.axisDistance - b.axisDistance + } + if (Math.abs(a.moving.length - b.moving.length) > EPS) { + return a.moving.length - b.moving.length + } + if (a.moving.traceIndex !== b.moving.traceIndex) { + return a.moving.traceIndex - b.moving.traceIndex + } + return a.moving.segmentIndex - b.moving.segmentIndex + }) +} + +const moveSegmentToAxis = ( + path: Point[], + segment: TraceSegment, + targetAxis: number, +): { nextPath: Point[]; changedSegments: Array<[Point, Point]> } | null => { + const nextPath = path.map((point) => ({ ...point })) + const start = { ...nextPath[segment.segmentIndex]! } + const end = { ...nextPath[segment.segmentIndex + 1]! } + + if (segment.orientation === "horizontal") { + start.y = targetAxis + end.y = targetAxis + } else { + start.x = targetAxis + end.x = targetAxis + } + + nextPath[segment.segmentIndex] = start + nextPath[segment.segmentIndex + 1] = end + + const changedSegments: Array<[Point, Point]> = [] + + if (segment.segmentIndex > 0) { + changedSegments.push([nextPath[segment.segmentIndex - 1]!, start]) + } + + changedSegments.push([start, end]) + + if (segment.segmentIndex + 2 < nextPath.length) { + changedSegments.push([end, nextPath[segment.segmentIndex + 2]!]) + } + + const hasNonOrthogonalSegment = changedSegments.some(([p1, p2]) => { + if (isZeroLength(p1, p2)) return false + return !getOrientation(p1, p2) + }) + + if (hasNonOrthogonalSegment) return null + + return { nextPath, changedSegments } +} + +const pointWithin = (value: number, min: number, max: number) => + value >= min - EPS && value <= max + EPS + +const segmentsIntersect = ( + a1: Point, + a2: Point, + b1: Point, + b2: Point, +): boolean => { + if (isZeroLength(a1, a2) || isZeroLength(b1, b2)) return false + + const aOrientation = getOrientation(a1, a2) + const bOrientation = getOrientation(b1, b2) + + if (!aOrientation || !bOrientation) return false + + if (aOrientation === bOrientation) { + if (aOrientation === "horizontal") { + if (Math.abs(a1.y - b1.y) > EPS) return false + return ( + Math.min(Math.max(a1.x, a2.x), Math.max(b1.x, b2.x)) - + Math.max(Math.min(a1.x, a2.x), Math.min(b1.x, b2.x)) > + EPS + ) + } + + if (Math.abs(a1.x - b1.x) > EPS) return false + return ( + Math.min(Math.max(a1.y, a2.y), Math.max(b1.y, b2.y)) - + Math.max(Math.min(a1.y, a2.y), Math.min(b1.y, b2.y)) > + EPS + ) + } + + const horizontal = + aOrientation === "horizontal" ? { p1: a1, p2: a2 } : { p1: b1, p2: b2 } + const vertical = + aOrientation === "vertical" ? { p1: a1, p2: a2 } : { p1: b1, p2: b2 } + + return ( + pointWithin( + vertical.p1.x, + Math.min(horizontal.p1.x, horizontal.p2.x), + Math.max(horizontal.p1.x, horizontal.p2.x), + ) && + pointWithin( + horizontal.p1.y, + Math.min(vertical.p1.y, vertical.p2.y), + Math.max(vertical.p1.y, vertical.p2.y), + ) + ) +} + +const segmentIntersectsAnyRect = ( + p1: Point, + p2: Point, + rects: Array<{ minX: number; minY: number; maxX: number; maxY: number }>, +) => { + for (const rect of rects) { + if (segmentIntersectsRect(p1, p2, { ...rect, chipId: "alignment-rect" })) { + return true + } + } + + return false +} + +const getDifferentNetLabelBounds = ({ + allLabelPlacements, + movingNetId, + mergedLabelNetIdMap, +}: { + allLabelPlacements: NetLabelPlacement[] + movingNetId: string + mergedLabelNetIdMap?: Record> +}) => + allLabelPlacements + .filter( + (label) => + !areNetsEquivalent( + label.globalConnNetId, + movingNetId, + mergedLabelNetIdMap, + ), + ) + .map((label) => ({ + minX: label.center.x - label.width / 2 + EPS, + maxX: label.center.x + label.width / 2 - EPS, + minY: label.center.y - label.height / 2 + EPS, + maxY: label.center.y + label.height / 2 - EPS, + })) + +const isMoveSafe = ({ + changedSegments, + movingTrace, + traces, + inputProblem, + allLabelPlacements, + mergedLabelNetIdMap, +}: { + changedSegments: Array<[Point, Point]> + movingTrace: SolvedTracePath + traces: SolvedTracePath[] + inputProblem?: InputProblem + allLabelPlacements: NetLabelPlacement[] + mergedLabelNetIdMap?: Record> +}) => { + const differentNetSegments = getSegments(traces, { + internalOnly: false, + }).filter( + (segment) => + segment.trace.mspPairId !== movingTrace.mspPairId && + !areNetsEquivalent( + segment.trace.globalConnNetId, + movingTrace.globalConnNetId, + mergedLabelNetIdMap, + ), + ) + + const staticObstacles = inputProblem ? getObstacleRects(inputProblem) : [] + const labelBounds = getDifferentNetLabelBounds({ + allLabelPlacements, + movingNetId: movingTrace.globalConnNetId, + mergedLabelNetIdMap, + }) + + for (const [p1, p2] of changedSegments) { + if (isZeroLength(p1, p2)) continue + if (!getOrientation(p1, p2)) return false + + if (segmentIntersectsAnyRect(p1, p2, staticObstacles)) return false + if (segmentIntersectsAnyRect(p1, p2, labelBounds)) return false + + for (const otherSegment of differentNetSegments) { + if (segmentsIntersect(p1, p2, otherSegment.p1, otherSegment.p2)) { + return false + } + } + } + + return true +} + +export const alignCloseSameNetSegments = ({ + traces, + inputProblem, + allLabelPlacements = [], + mergedLabelNetIdMap, + maxAlignmentDistance = DEFAULT_MAX_ALIGNMENT_DISTANCE, +}: AlignCloseSameNetSegmentsInput): SolvedTracePath[] => { + let outputTraces = traces.map((trace) => ({ + ...trace, + tracePath: trace.tracePath.map((point) => ({ ...point })), + })) + + for (let pass = 0; pass < MAX_ALIGNMENT_PASSES; pass++) { + const moves = getAlignmentMoves({ + traces: outputTraces, + mergedLabelNetIdMap, + maxAlignmentDistance, + }) + + let appliedMove = false + + for (const move of moves) { + const movingTrace = outputTraces[move.moving.traceIndex]! + const movedPath = moveSegmentToAxis( + movingTrace.tracePath, + move.moving, + move.anchor.axis, + ) + + if (!movedPath) continue + + if ( + !isMoveSafe({ + changedSegments: movedPath.changedSegments, + movingTrace, + traces: outputTraces, + inputProblem, + allLabelPlacements, + mergedLabelNetIdMap, + }) + ) { + continue + } + + outputTraces = outputTraces.map((trace, traceIndex) => + traceIndex === move.moving.traceIndex + ? { ...trace, tracePath: simplifyPath(movedPath.nextPath) } + : trace, + ) + appliedMove = true + break + } + + if (!appliedMove) break + } + + return outputTraces +} diff --git a/tests/solvers/TraceCleanupSolver/alignCloseSameNetSegments.test.ts b/tests/solvers/TraceCleanupSolver/alignCloseSameNetSegments.test.ts new file mode 100644 index 000000000..e83852071 --- /dev/null +++ b/tests/solvers/TraceCleanupSolver/alignCloseSameNetSegments.test.ts @@ -0,0 +1,187 @@ +import { expect, test } from "bun:test" +import type { Point } from "@tscircuit/math-utils" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import { alignCloseSameNetSegments } from "lib/solvers/TraceCleanupSolver/alignCloseSameNetSegments" + +const makeTrace = ( + id: string, + netId: string, + tracePath: Point[], +): SolvedTracePath => + ({ + mspPairId: id, + dcConnNetId: netId, + globalConnNetId: netId, + pins: [ + { pinId: `${id}-pin-a`, chipId: `${id}-chip-a`, ...tracePath[0]! }, + { + pinId: `${id}-pin-b`, + chipId: `${id}-chip-b`, + ...tracePath[tracePath.length - 1]!, + }, + ], + tracePath, + mspConnectionPairIds: [id], + pinIds: [`${id}-pin-a`, `${id}-pin-b`], + }) as SolvedTracePath + +test("aligns close horizontal same-net internal segments", () => { + const traces = [ + makeTrace("anchor", "net-a", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 0 }, + ]), + makeTrace("moving", "net-a", [ + { x: 1, y: 0.2 }, + { x: 1, y: 1.08 }, + { x: 3, y: 1.08 }, + { x: 3, y: 0.2 }, + ]), + ] + + const [anchor, moving] = alignCloseSameNetSegments({ traces }) + + expect(anchor!.tracePath).toEqual(traces[0]!.tracePath) + expect(moving!.tracePath).toEqual([ + { x: 1, y: 0.2 }, + { x: 1, y: 1 }, + { x: 3, y: 1 }, + { x: 3, y: 0.2 }, + ]) +}) + +test("aligns close vertical same-net internal segments", () => { + const traces = [ + makeTrace("anchor", "net-a", [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 4 }, + { x: 0, y: 4 }, + ]), + makeTrace("moving", "net-a", [ + { x: 0.2, y: 1 }, + { x: 1.09, y: 1 }, + { x: 1.09, y: 3 }, + { x: 0.2, y: 3 }, + ]), + ] + + const [, moving] = alignCloseSameNetSegments({ traces }) + + expect(moving!.tracePath).toEqual([ + { x: 0.2, y: 1 }, + { x: 1, y: 1 }, + { x: 1, y: 3 }, + { x: 0.2, y: 3 }, + ]) +}) + +test("does not align close segments on different nets", () => { + const traces = [ + makeTrace("trace-a", "net-a", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 0 }, + ]), + makeTrace("trace-b", "net-b", [ + { x: 1, y: 0.2 }, + { x: 1, y: 1.08 }, + { x: 3, y: 1.08 }, + { x: 3, y: 0.2 }, + ]), + ] + + expect(alignCloseSameNetSegments({ traces })).toEqual(traces) +}) + +test("does not move terminal-only trace segments", () => { + const traces = [ + makeTrace("trace-a", "net-a", [ + { x: 0, y: 1 }, + { x: 4, y: 1 }, + ]), + makeTrace("trace-b", "net-a", [ + { x: 0, y: 1.08 }, + { x: 4, y: 1.08 }, + ]), + ] + + expect(alignCloseSameNetSegments({ traces })).toEqual(traces) +}) + +test("rejects same-net alignment that would cross a different net", () => { + const traces = [ + makeTrace("anchor", "net-a", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 0 }, + ]), + makeTrace("moving", "net-a", [ + { x: 1, y: 0.2 }, + { x: 1, y: 1.08 }, + { x: 3, y: 1.08 }, + { x: 3, y: 0.2 }, + ]), + makeTrace("blocker", "net-b", [ + { x: 2, y: 0.8 }, + { x: 2, y: 1.02 }, + { x: 2.5, y: 1.02 }, + { x: 2.5, y: 0.8 }, + ]), + ] + + const [, moving] = alignCloseSameNetSegments({ traces }) + + expect(moving!.tracePath).toEqual(traces[1]!.tracePath) +}) + +test("still applies later safe alignments when the closest candidate is blocked", () => { + const traces = [ + makeTrace("blocked-anchor", "net-a", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 0 }, + ]), + makeTrace("blocked-moving", "net-a", [ + { x: 1, y: 0.2 }, + { x: 1, y: 1.04 }, + { x: 3, y: 1.04 }, + { x: 3, y: 0.2 }, + ]), + makeTrace("blocker", "net-b", [ + { x: 2, y: 0.8 }, + { x: 2, y: 1.02 }, + { x: 2.5, y: 1.02 }, + { x: 2.5, y: 0.8 }, + ]), + makeTrace("safe-anchor", "net-a", [ + { x: 5, y: 1 }, + { x: 5, y: 2 }, + { x: 9, y: 2 }, + { x: 9, y: 1 }, + ]), + makeTrace("safe-moving", "net-a", [ + { x: 6, y: 1.2 }, + { x: 6, y: 2.1 }, + { x: 8, y: 2.1 }, + { x: 8, y: 1.2 }, + ]), + ] + + const [, blockedMoving, , , safeMoving] = alignCloseSameNetSegments({ + traces, + }) + + expect(blockedMoving!.tracePath).toEqual(traces[1]!.tracePath) + expect(safeMoving!.tracePath).toEqual([ + { x: 6, y: 1.2 }, + { x: 6, y: 2 }, + { x: 8, y: 2 }, + { x: 8, y: 1.2 }, + ]) +})