From 29560767bbdb3ea6c780d9f019d126432cc20c23 Mon Sep 17 00:00:00 2001 From: zidhannnn Date: Sun, 24 May 2026 15:03:41 +0700 Subject: [PATCH] feat: combine close same-net trace segments --- lib/index.ts | 1 + .../SchematicTracePipelineSolver.ts | 30 ++- .../TraceCombineSolver/TraceCombineSolver.ts | 227 ++++++++++++++++++ tests/solvers/TraceCombineSolver.test.ts | 83 +++++++ 4 files changed, 334 insertions(+), 7 deletions(-) create mode 100644 lib/solvers/TraceCombineSolver/TraceCombineSolver.ts create mode 100644 tests/solvers/TraceCombineSolver.test.ts diff --git a/lib/index.ts b/lib/index.ts index 3985b32ac..a3ee46d99 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,3 +1,4 @@ export * from "./solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver" +export * from "./solvers/TraceCombineSolver/TraceCombineSolver" export * from "./types/InputProblem" export { SchematicTraceSingleLineSolver2 } from "./solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/SchematicTraceSingleLineSolver2" diff --git a/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts b/lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver.ts index 59821f0c1..561093952 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 { TraceCombineSolver } from "../TraceCombineSolver/TraceCombineSolver" 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 + traceCombineSolver?: TraceCombineSolver netLabelPlacementSolver?: NetLabelPlacementSolver labelMergingSolver?: MergedNetLabelObstacleSolver traceLabelOverlapAvoidanceSolver?: TraceLabelOverlapAvoidanceSolver @@ -154,19 +156,33 @@ export class SchematicTracePipelineSolver extends BaseSolver { onSolved: (_solver) => {}, }, ), + definePipelineStep( + "traceCombineSolver", + TraceCombineSolver, + (instance) => [ + { + inputProblem: instance.inputProblem, + inputTraces: Object.values( + instance.traceOverlapShiftSolver!.correctedTraceMap, + ), + }, + ], + { + onSolved: (_solver) => {}, + }, + ), definePipelineStep( "netLabelPlacementSolver", NetLabelPlacementSolver, () => [ { inputProblem: this.inputProblem, - inputTraceMap: - this.traceOverlapShiftSolver?.correctedTraceMap ?? - Object.fromEntries( - this.longDistancePairSolver!.getOutput().allTracesMerged.map( - (p) => [p.mspPairId, p], - ), - ), + inputTraceMap: Object.fromEntries( + this.traceCombineSolver!.getOutput().traces.map((p) => [ + p.mspPairId, + p, + ]), + ), }, ], { diff --git a/lib/solvers/TraceCombineSolver/TraceCombineSolver.ts b/lib/solvers/TraceCombineSolver/TraceCombineSolver.ts new file mode 100644 index 000000000..0fe2911c4 --- /dev/null +++ b/lib/solvers/TraceCombineSolver/TraceCombineSolver.ts @@ -0,0 +1,227 @@ +import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver" +import type { InputProblem } from "lib/types/InputProblem" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import type { GraphicsObject } from "graphics-debug" +import type { Point } from "@tscircuit/math-utils" +import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem" +import { simplifyPath } from "lib/solvers/TraceCleanupSolver/simplifyPath" + +type Orientation = "horizontal" | "vertical" + +interface TraceCombineSolverParams { + inputProblem: InputProblem + inputTraces: SolvedTracePath[] + distanceThreshold?: number +} + +interface SegmentRef { + traceIndex: number + segmentIndex: number + orientation: Orientation + coord: number + min: number + max: number + length: number +} + +const EPS = 1e-6 + +const cloneTrace = (trace: SolvedTracePath): SolvedTracePath => ({ + ...trace, + tracePath: trace.tracePath.map((p) => ({ ...p })), +}) + +const getSegmentRef = ( + trace: SolvedTracePath, + traceIndex: number, + segmentIndex: number, +): SegmentRef | null => { + const start = trace.tracePath[segmentIndex] + const end = trace.tracePath[segmentIndex + 1] + if (!start || !end) return null + + if (Math.abs(start.y - end.y) < EPS) { + return { + traceIndex, + segmentIndex, + orientation: "horizontal", + coord: start.y, + min: Math.min(start.x, end.x), + max: Math.max(start.x, end.x), + length: Math.abs(start.x - end.x), + } + } + + if (Math.abs(start.x - end.x) < EPS) { + return { + traceIndex, + segmentIndex, + orientation: "vertical", + coord: start.x, + min: Math.min(start.y, end.y), + max: Math.max(start.y, end.y), + length: Math.abs(start.y - end.y), + } + } + + return null +} + +const isInternalSegment = (path: Point[], segmentIndex: number) => + segmentIndex > 0 && segmentIndex < path.length - 2 + +const rangesTouchOrOverlap = ( + a: SegmentRef, + b: SegmentRef, + threshold: number, +) => Math.max(a.min, b.min) <= Math.min(a.max, b.max) + threshold + +const areSegmentsCombinable = ( + a: SegmentRef, + b: SegmentRef, + threshold: number, +) => + a.orientation === b.orientation && + Math.abs(a.coord - b.coord) <= threshold && + rangesTouchOrOverlap(a, b, threshold) + +const targetCoordFor = (a: SegmentRef, b: SegmentRef) => { + if (a.length !== b.length) return a.length > b.length ? a.coord : b.coord + return Math.min(a.coord, b.coord) +} + +const snapSegmentCoord = ( + path: Point[], + segment: SegmentRef, + coord: number, +) => { + const start = path[segment.segmentIndex]! + const end = path[segment.segmentIndex + 1]! + + if (segment.orientation === "horizontal") { + start.y = coord + end.y = coord + } else { + start.x = coord + end.x = coord + } +} + +/** + * Combines close same-net trace trunks by snapping internal parallel segments + * onto a shared coordinate. Terminal segments are left untouched so pin + * endpoints remain fixed. + */ +export class TraceCombineSolver extends BaseSolver { + inputProblem: InputProblem + inputTraces: SolvedTracePath[] + distanceThreshold: number + + outputTraces: SolvedTracePath[] + combinedSegmentCount = 0 + + constructor(params: TraceCombineSolverParams) { + super() + this.inputProblem = params.inputProblem + this.inputTraces = params.inputTraces + this.distanceThreshold = params.distanceThreshold ?? 0.1 + this.outputTraces = this.inputTraces.map(cloneTrace) + } + + override getConstructorParams(): ConstructorParameters< + typeof TraceCombineSolver + >[0] { + return { + inputProblem: this.inputProblem, + inputTraces: this.inputTraces, + distanceThreshold: this.distanceThreshold, + } + } + + override _step() { + const tracesByNet = new Map() + for (let i = 0; i < this.outputTraces.length; i++) { + const netId = this.outputTraces[i]!.globalConnNetId + if (!tracesByNet.has(netId)) tracesByNet.set(netId, []) + tracesByNet.get(netId)!.push(i) + } + + for (const traceIndexes of tracesByNet.values()) { + this.combineTraceGroup(traceIndexes) + } + + for (const trace of this.outputTraces) { + trace.tracePath = simplifyPath(trace.tracePath) + } + + this.stats.combinedSegmentCount = this.combinedSegmentCount + this.solved = true + } + + private combineTraceGroup(traceIndexes: number[]) { + const segments: SegmentRef[] = [] + + for (const traceIndex of traceIndexes) { + const trace = this.outputTraces[traceIndex]! + for ( + let segmentIndex = 0; + segmentIndex < trace.tracePath.length - 1; + segmentIndex++ + ) { + if (!isInternalSegment(trace.tracePath, segmentIndex)) continue + const segment = getSegmentRef(trace, traceIndex, segmentIndex) + if (segment && segment.length > EPS) segments.push(segment) + } + } + + for (let i = 0; i < segments.length; i++) { + for (let j = i + 1; j < segments.length; j++) { + const a = segments[i]! + const b = segments[j]! + if (a.traceIndex === b.traceIndex) continue + if (!areSegmentsCombinable(a, b, this.distanceThreshold)) continue + + const targetCoord = targetCoordFor(a, b) + const traceA = this.outputTraces[a.traceIndex]! + const traceB = this.outputTraces[b.traceIndex]! + let changed = false + + if (Math.abs(a.coord - targetCoord) > EPS) { + snapSegmentCoord(traceA.tracePath, a, targetCoord) + a.coord = targetCoord + changed = true + } + + if (Math.abs(b.coord - targetCoord) > EPS) { + snapSegmentCoord(traceB.tracePath, b, targetCoord) + b.coord = targetCoord + changed = true + } + + if (changed) this.combinedSegmentCount++ + } + } + } + + getOutput() { + return { + traces: this.outputTraces, + } + } + + override visualize(): GraphicsObject { + const graphics = visualizeInputProblem(this.inputProblem, { + chipAlpha: 0.1, + connectionAlpha: 0.1, + }) + + for (const trace of this.outputTraces) { + graphics.lines!.push({ + points: trace.tracePath, + strokeColor: "purple", + }) + } + + return graphics + } +} diff --git a/tests/solvers/TraceCombineSolver.test.ts b/tests/solvers/TraceCombineSolver.test.ts new file mode 100644 index 000000000..2d616d35c --- /dev/null +++ b/tests/solvers/TraceCombineSolver.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from "bun:test" +import { TraceCombineSolver } from "lib/solvers/TraceCombineSolver/TraceCombineSolver" +import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver" +import type { InputProblem } from "lib/types/InputProblem" + +const inputProblem: InputProblem = { + chips: [], + directConnections: [], + netConnections: [], + availableNetLabelOrientations: {}, +} + +const trace = ( + mspPairId: string, + globalConnNetId: string, + tracePath: Array<{ x: number; y: number }>, +): SolvedTracePath => + ({ + mspPairId, + dcConnNetId: globalConnNetId, + globalConnNetId, + userNetId: globalConnNetId, + pins: [], + mspConnectionPairIds: [mspPairId], + pinIds: [], + tracePath, + }) as unknown as SolvedTracePath + +test("TraceCombineSolver snaps close same-net internal segments together", () => { + const solver = new TraceCombineSolver({ + inputProblem, + inputTraces: [ + trace("a", "net1", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 0 }, + ]), + trace("b", "net1", [ + { x: 0, y: 0.08 }, + { x: 0, y: 1.08 }, + { x: 4, y: 1.08 }, + { x: 4, y: 0.08 }, + ]), + ], + }) + + solver.solve() + + const [first, second] = solver.getOutput().traces + expect(solver.stats.combinedSegmentCount).toBe(1) + 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) +}) + +test("TraceCombineSolver does not snap close segments from different nets", () => { + const solver = new TraceCombineSolver({ + inputProblem, + inputTraces: [ + trace("a", "net1", [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + { x: 4, y: 1 }, + { x: 4, y: 0 }, + ]), + trace("b", "net2", [ + { x: 0, y: 0.08 }, + { x: 0, y: 1.08 }, + { x: 4, y: 1.08 }, + { x: 4, y: 0.08 }, + ]), + ], + }) + + solver.solve() + + const second = solver.getOutput().traces[1]! + expect(solver.stats.combinedSegmentCount).toBe(0) + expect(second.tracePath[1]!.y).toBe(1.08) + expect(second.tracePath[2]!.y).toBe(1.08) +})