From 43b7514fd16aa3b62eee0cf576d10caa808f754c Mon Sep 17 00:00:00 2001
From: afkjdn-bombadeel <191522573+afkjdn-bombadeel@users.noreply.github.com>
Date: Fri, 22 May 2026 18:21:07 -0400
Subject: [PATCH] fix: merge close same-net trace segments
Adds a TraceCleanupSolver pass that aligns close horizontal or vertical segments belonging to the same net, with focused tests and updated snapshots for affected examples.
Worked on by GPT-5.5 with Codex.
---
.../TraceCleanupSolver/TraceCleanupSolver.ts | 14 +-
.../mergeSameNetTraceSegments.ts | 149 ++++++++++++++++++
.../examples/__snapshots__/example18.snap.svg | 34 ++--
.../examples/__snapshots__/example19.snap.svg | 22 ++-
.../examples/__snapshots__/example29.snap.svg | 148 ++++++-----------
.../mergeSameNetTraceSegments.test.ts | 113 +++++++++++++
6 files changed, 341 insertions(+), 139 deletions(-)
create mode 100644 lib/solvers/TraceCleanupSolver/mergeSameNetTraceSegments.ts
create mode 100644 tests/solvers/TraceCleanupSolver/mergeSameNetTraceSegments.test.ts
diff --git a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts
index e9bac7ca3..5540f2d6f 100644
--- a/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts
+++ b/lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts
@@ -6,6 +6,7 @@ import { BaseSolver } from "lib/solvers/BaseSolver/BaseSolver"
import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"
import { visualizeInputProblem } from "lib/solvers/SchematicTracePipelineSolver/visualizeInputProblem"
import type { NetLabelPlacement } from "../NetLabelPlacementSolver/NetLabelPlacementSolver"
+import { mergeSameNetTraceSegments } from "./mergeSameNetTraceSegments"
/**
* Defines the input structure for the TraceCleanupSolver.
@@ -49,11 +50,12 @@ export class TraceCleanupSolver extends BaseSolver {
constructor(solverInput: TraceCleanupSolverInput) {
super()
this.input = solverInput
- this.outputTraces = [...solverInput.allTraces]
+ this.outputTraces = mergeSameNetTraceSegments({
+ traces: solverInput.allTraces,
+ tolerance: solverInput.paddingBuffer,
+ })
this.tracesMap = new Map(this.outputTraces.map((t) => [t.mspPairId, t]))
- this.traceIdQueue = Array.from(
- solverInput.allTraces.map((e) => e.mspPairId),
- )
+ this.traceIdQueue = Array.from(this.outputTraces.map((e) => e.mspPairId))
}
override _step() {
@@ -97,9 +99,7 @@ export class TraceCleanupSolver extends BaseSolver {
private _runMinimizeTurnsStep() {
if (this.traceIdQueue.length === 0) {
this.pipelineStep = "balancing_l_shapes"
- this.traceIdQueue = Array.from(
- this.input.allTraces.map((e) => e.mspPairId),
- )
+ this.traceIdQueue = Array.from(this.outputTraces.map((e) => e.mspPairId))
return
}
diff --git a/lib/solvers/TraceCleanupSolver/mergeSameNetTraceSegments.ts b/lib/solvers/TraceCleanupSolver/mergeSameNetTraceSegments.ts
new file mode 100644
index 000000000..16f8407bc
--- /dev/null
+++ b/lib/solvers/TraceCleanupSolver/mergeSameNetTraceSegments.ts
@@ -0,0 +1,149 @@
+import type { Point } from "@tscircuit/math-utils"
+import type { SolvedTracePath } from "../SchematicTraceLinesSolver/SchematicTraceLinesSolver"
+import { simplifyPath } from "./simplifyPath"
+
+const EPSILON = 1e-9
+
+type SegmentOrientation = "horizontal" | "vertical"
+
+interface SegmentRef {
+ traceIndex: number
+ startIndex: number
+ endIndex: number
+ netId: string
+ orientation: SegmentOrientation
+ coordinate: number
+ minProjection: number
+ maxProjection: number
+}
+
+const getSegmentRef = (
+ trace: SolvedTracePath,
+ traceIndex: number,
+ startIndex: number,
+): SegmentRef | null => {
+ const p1 = trace.tracePath[startIndex]
+ const p2 = trace.tracePath[startIndex + 1]
+
+ if (!p1 || !p2 || !trace.globalConnNetId) {
+ return null
+ }
+
+ if (Math.abs(p1.y - p2.y) <= EPSILON) {
+ return {
+ traceIndex,
+ startIndex,
+ endIndex: startIndex + 1,
+ netId: trace.globalConnNetId,
+ orientation: "horizontal",
+ coordinate: p1.y,
+ minProjection: Math.min(p1.x, p2.x),
+ maxProjection: Math.max(p1.x, p2.x),
+ }
+ }
+
+ if (Math.abs(p1.x - p2.x) <= EPSILON) {
+ return {
+ traceIndex,
+ startIndex,
+ endIndex: startIndex + 1,
+ netId: trace.globalConnNetId,
+ orientation: "vertical",
+ coordinate: p1.x,
+ minProjection: Math.min(p1.y, p2.y),
+ maxProjection: Math.max(p1.y, p2.y),
+ }
+ }
+
+ return null
+}
+
+const getProjectionGap = (a: SegmentRef, b: SegmentRef): number => {
+ if (a.maxProjection < b.minProjection) {
+ return b.minProjection - a.maxProjection
+ }
+ if (b.maxProjection < a.minProjection) {
+ return a.minProjection - b.maxProjection
+ }
+ return 0
+}
+
+const alignSegment = (
+ path: Point[],
+ segment: SegmentRef,
+ coordinate: number,
+) => {
+ const start = path[segment.startIndex]!
+ const end = path[segment.endIndex]!
+
+ if (segment.orientation === "horizontal") {
+ path[segment.startIndex] = { ...start, y: coordinate }
+ path[segment.endIndex] = { ...end, y: coordinate }
+ } else {
+ path[segment.startIndex] = { ...start, x: coordinate }
+ path[segment.endIndex] = { ...end, x: coordinate }
+ }
+}
+
+const getSegments = (traces: SolvedTracePath[]): SegmentRef[] =>
+ traces.flatMap((trace, traceIndex) =>
+ trace.tracePath
+ .slice(0, -1)
+ .map((_, startIndex) => getSegmentRef(trace, traceIndex, startIndex))
+ .filter((segment): segment is SegmentRef => segment !== null),
+ )
+
+export const mergeSameNetTraceSegments = ({
+ traces,
+ tolerance,
+}: {
+ traces: SolvedTracePath[]
+ tolerance: number
+}): SolvedTracePath[] => {
+ const output = traces.map((trace) => ({
+ ...trace,
+ tracePath: trace.tracePath.map((point) => ({ ...point })),
+ }))
+
+ let changed = true
+ let passes = 0
+
+ while (changed && passes < 4) {
+ changed = false
+ passes++
+ const segments = getSegments(output)
+
+ 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.netId !== b.netId ||
+ a.orientation !== b.orientation ||
+ a.traceIndex === b.traceIndex
+ ) {
+ continue
+ }
+
+ if (Math.abs(a.coordinate - b.coordinate) > tolerance) {
+ continue
+ }
+
+ if (getProjectionGap(a, b) > tolerance) {
+ continue
+ }
+
+ const mergedCoordinate = (a.coordinate + b.coordinate) / 2
+ alignSegment(output[a.traceIndex]!.tracePath, a, mergedCoordinate)
+ alignSegment(output[b.traceIndex]!.tracePath, b, mergedCoordinate)
+ changed = true
+ }
+ }
+ }
+
+ return output.map((trace) => ({
+ ...trace,
+ tracePath: simplifyPath(trace.tracePath),
+ }))
+}
diff --git a/tests/examples/__snapshots__/example18.snap.svg b/tests/examples/__snapshots__/example18.snap.svg
index 8c7f05fc8..419052cdf 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)" />
-
+
-
+
-
+
-
+
-
+
@@ -136,10 +131,10 @@ 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.5895165187499993" data-y="2.7275814000000005" x="469.9865706932519" y="40" width="17.905209437554504" 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" />
diff --git a/tests/examples/__snapshots__/example19.snap.svg b/tests/examples/__snapshots__/example19.snap.svg
index ac5ba82db..a03e43aec 100644
--- a/tests/examples/__snapshots__/example19.snap.svg
+++ b/tests/examples/__snapshots__/example19.snap.svg
@@ -49,20 +49,16 @@ y+" data-x="2.0034928" data-y="-0.8350319000000007" cx="300.34928" cy="422.74112
x-" data-x="1.5541992" data-y="-1.2014628704999997" cx="255.41992000000002" cy="459.38421955" r="3" fill="hsl(247, 100%, 50%, 0.8)" />
-
+
-
+
-
+
-
+
@@ -95,16 +91,16 @@ orientation: y+" data-x="3.3884680250000008" data-y="1.2997267500000007" cx="438
-
+
-
+
-
+
-
+
@@ -141,7 +137,7 @@ globalConnNetId: connectivity_net1" data-x="0.7999316625000001" data-y="0.425000
+globalConnNetId: connectivity_net2" data-x="3.407056687500001" data-y="1.5247267500000008" x="430.7056687500001" y="164.26525749999996" width="20" height="45.00000000000003" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.01" />
diff --git a/tests/examples/__snapshots__/example29.snap.svg b/tests/examples/__snapshots__/example29.snap.svg
index 7f0d46494..50c82d6d7 100644
--- a/tests/examples/__snapshots__/example29.snap.svg
+++ b/tests/examples/__snapshots__/example29.snap.svg
@@ -489,188 +489,142 @@ x+" data-x="-8.4" data-y="-16.6" cx="198.31710258539454" cy="392.52251162760695"
x+" data-x="-8.4" data-y="-17" cx="198.31710258539454" cy="399.68032912258366" r="3" fill="hsl(226, 100%, 50%, 0.8)" />
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -859,7 +813,7 @@ orientation: x+" data-x="-8.4" data-y="-16.2" cx="198.31710258539454" cy="385.36
-
+
@@ -877,7 +831,7 @@ orientation: x+" data-x="-8.4" data-y="-16.2" cx="198.31710258539454" cy="385.36
-
+
@@ -895,7 +849,7 @@ orientation: x+" data-x="-8.4" data-y="-16.2" cx="198.31710258539454" cy="385.36
-
+
@@ -1070,7 +1024,7 @@ globalConnNetId: connectivity_net2" data-x="1.9500000000000006" data-y="1.475000
+globalConnNetId: connectivity_net3" data-x="-6.2125" data-y="-1.7749999999999997" x="235.67196263730466" y="123.2096283791052" width="3.5789087474883843" height="8.052544681848872" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.05588295598214286" />
+globalConnNetId: connectivity_net7" data-x="-6.112500000000001" data-y="-5.775" x="237.46141701104887" y="194.7878033288731" width="3.578908747488356" height="8.052544681848872" fill="hsl(40, 100%, 50%, 0.35)" stroke="black" stroke-width="0.05588295598214286" />
): SolvedTracePath =>
+ ({
+ mspPairId,
+ globalConnNetId,
+ tracePath,
+ mspConnectionPairIds: [mspPairId],
+ pinIds: [],
+ pins: [],
+ }) as any
+
+test("mergeSameNetTraceSegments aligns close horizontal same-net segments", () => {
+ const traces = mergeSameNetTraceSegments({
+ tolerance: 0.1,
+ traces: [
+ makeTrace({
+ mspPairId: "a",
+ globalConnNetId: "net1",
+ tracePath: [
+ { x: 0, y: 0 },
+ { x: 2, y: 0 },
+ ],
+ }),
+ makeTrace({
+ mspPairId: "b",
+ globalConnNetId: "net1",
+ tracePath: [
+ { x: 1, y: 0.08 },
+ { x: 3, y: 0.08 },
+ ],
+ }),
+ ],
+ })
+
+ expect(traces[0]!.tracePath).toEqual([
+ { x: 0, y: 0.04 },
+ { x: 2, y: 0.04 },
+ ])
+ expect(traces[1]!.tracePath).toEqual([
+ { x: 1, y: 0.04 },
+ { x: 3, y: 0.04 },
+ ])
+})
+
+test("mergeSameNetTraceSegments leaves different-net segments alone", () => {
+ const traces = mergeSameNetTraceSegments({
+ tolerance: 0.1,
+ traces: [
+ makeTrace({
+ mspPairId: "a",
+ globalConnNetId: "net1",
+ tracePath: [
+ { x: 0, y: 0 },
+ { x: 2, y: 0 },
+ ],
+ }),
+ makeTrace({
+ mspPairId: "b",
+ globalConnNetId: "net2",
+ tracePath: [
+ { x: 1, y: 0.08 },
+ { x: 3, y: 0.08 },
+ ],
+ }),
+ ],
+ })
+
+ expect(traces[0]!.tracePath[0]!.y).toBe(0)
+ expect(traces[1]!.tracePath[0]!.y).toBe(0.08)
+})
+
+test("mergeSameNetTraceSegments aligns close vertical same-net segments", () => {
+ const traces = mergeSameNetTraceSegments({
+ tolerance: 0.1,
+ traces: [
+ makeTrace({
+ mspPairId: "a",
+ globalConnNetId: "net1",
+ tracePath: [
+ { x: 0, y: 0 },
+ { x: 0, y: 2 },
+ ],
+ }),
+ makeTrace({
+ mspPairId: "b",
+ globalConnNetId: "net1",
+ tracePath: [
+ { x: 0.06, y: 1 },
+ { x: 0.06, y: 3 },
+ ],
+ }),
+ ],
+ })
+
+ expect(traces[0]!.tracePath).toEqual([
+ { x: 0.03, y: 0 },
+ { x: 0.03, y: 2 },
+ ])
+ expect(traces[1]!.tracePath).toEqual([
+ { x: 0.03, y: 1 },
+ { x: 0.03, y: 3 },
+ ])
+})