Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { mergeCollinearTraces } from "./mergeCollinearTraces"

/**
* Defines the input structure for the TraceCleanupSolver.
Expand All @@ -28,13 +29,15 @@ type PipelineStep =
| "minimizing_turns"
| "balancing_l_shapes"
| "untangling_traces"
| "merging_collinear_traces"

/**
* The TraceCleanupSolver is responsible for improving the aesthetics and readability of schematic traces.
* 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. **Merging Collinear Traces**: Finally, it merges same-net traces that run parallel and close together.
* The solver processes traces one by one, applying these cleanup steps sequentially to refine the overall trace layout.
*/
export class TraceCleanupSolver extends BaseSolver {
Expand Down Expand Up @@ -84,6 +87,9 @@ export class TraceCleanupSolver extends BaseSolver {
case "balancing_l_shapes":
this._runBalanceLShapesStep()
break
case "merging_collinear_traces":
this._runMergeCollinearTracesStep()
break
}
}

Expand All @@ -108,13 +114,19 @@ export class TraceCleanupSolver extends BaseSolver {

private _runBalanceLShapesStep() {
if (this.traceIdQueue.length === 0) {
this.solved = true
this.pipelineStep = "merging_collinear_traces"
return
}

this._processTrace("balancing_l_shapes")
}

private _runMergeCollinearTracesStep() {
this.outputTraces = mergeCollinearTraces(this.outputTraces)
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
Expand Down
214 changes: 214 additions & 0 deletions lib/solvers/TraceCleanupSolver/mergeCollinearTraces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import type { SolvedTracePath } from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceLinesSolver"

/**
* Threshold for considering two parallel trace segments as "close enough" to merge.
* Segments within this distance on the perpendicular axis will be aligned.
*/
const MERGE_THRESHOLD = 0.15

/**
* Minimum overlap required along the parallel axis for segments to be merge candidates.
*/
const MIN_OVERLAP = 0.01

interface Segment {
orientation: "horizontal" | "vertical"
/** Fixed coordinate (y for horizontal, x for vertical) */
fixed: number
/** Start of the range along the varying axis */
start: number
/** End of the range along the varying axis */
end: number
/** Index of this segment's first point in the parent trace's tracePath */
pointIndex: number
/** Reference to the parent trace */
traceRef: SolvedTracePath
}

function extractSegments(trace: SolvedTracePath): Segment[] {
const segments: Segment[] = []
const path = trace.tracePath

for (let i = 0; i < path.length - 1; i++) {
const p1 = path[i]!
const p2 = path[i + 1]!

const dx = Math.abs(p1.x - p2.x)
const dy = Math.abs(p1.y - p2.y)

if (dy < 1e-6 && dx > 1e-6) {
// Horizontal segment
segments.push({
orientation: "horizontal",
fixed: p1.y,
start: Math.min(p1.x, p2.x),
end: Math.max(p1.x, p2.x),
pointIndex: i,
traceRef: trace,
})
} else if (dx < 1e-6 && dy > 1e-6) {
// Vertical segment
segments.push({
orientation: "vertical",
fixed: p1.x,
start: Math.min(p1.y, p2.y),
end: Math.max(p1.y, p2.y),
pointIndex: i,
traceRef: trace,
})
}
}

return segments
}

/**
* Check if two ranges [s1, e1] and [s2, e2] overlap by at least MIN_OVERLAP.
*/
function rangesOverlap(
s1: number,
e1: number,
s2: number,
e2: number,
): boolean {
const overlapStart = Math.max(s1, s2)
const overlapEnd = Math.min(e1, e2)
return overlapEnd - overlapStart > MIN_OVERLAP
}

/**
* Check if a point is a pin endpoint (first or last point in the trace path).
* Endpoints should not be moved as they connect to chip pins.
*/
function isEndpoint(pointIndex: number, tracePathLength: number): boolean {
return pointIndex === 0 || pointIndex >= tracePathLength - 2
}

/**
* Merges collinear same-net trace segments that run parallel and close together.
*
* When two traces belong to the same net (same globalConnNetId) and have
* parallel segments within MERGE_THRESHOLD distance, this function aligns
* them to the average coordinate, producing cleaner schematics.
*
* Only non-endpoint segments are adjusted to preserve pin connections.
*/
export function mergeCollinearTraces(
traces: SolvedTracePath[],
): SolvedTracePath[] {
// Group traces by globalConnNetId
const tracesByNet: Record<string, SolvedTracePath[]> = {}
for (const trace of traces) {
const netId = trace.globalConnNetId
if (!tracesByNet[netId]) {
tracesByNet[netId] = []
}
tracesByNet[netId].push(trace)
}

// Process each net group
for (const netId of Object.keys(tracesByNet)) {
const netTraces = tracesByNet[netId]!
if (netTraces.length < 2) continue

// Extract all segments from all traces in this net
const allSegments: Segment[] = []
for (const trace of netTraces) {
allSegments.push(...extractSegments(trace))
}

// Group segments by orientation
const horizontalSegments = allSegments.filter(
(s) => s.orientation === "horizontal",
)
const verticalSegments = allSegments.filter(
(s) => s.orientation === "vertical",
)

// Align close horizontal segments
alignCloseSegments(horizontalSegments)

// Align close vertical segments
alignCloseSegments(verticalSegments)
}

return traces
}

/**
* Find groups of segments that are close together on the fixed axis and
* have overlapping ranges, then align them to their average coordinate.
*/
function alignCloseSegments(segments: Segment[]): void {
if (segments.length < 2) return

// Sort by fixed coordinate for efficient grouping
segments.sort((a, b) => a.fixed - b.fixed)

const aligned = new Set<number>()

for (let i = 0; i < segments.length; i++) {
if (aligned.has(i)) continue

const cluster: number[] = [i]

for (let j = i + 1; j < segments.length; j++) {
if (aligned.has(j)) continue

const si = segments[i]!
const sj = segments[j]!

// Must be from different traces to be worth merging
if (si.traceRef === sj.traceRef) continue

// Check if close enough on the fixed axis
if (Math.abs(si.fixed - sj.fixed) > MERGE_THRESHOLD) break

// Check if they overlap on the varying axis
if (rangesOverlap(si.start, si.end, sj.start, sj.end)) {
cluster.push(j)
}
}

if (cluster.length < 2) continue

// Filter out endpoint segments -- we don't move those
const movableIndices = cluster.filter((idx) => {
const seg = segments[idx]!
return !isEndpoint(seg.pointIndex, seg.traceRef.tracePath.length)
})

if (movableIndices.length < 1) continue

// Compute average fixed coordinate across all cluster members
let sum = 0
for (const idx of cluster) {
sum += segments[idx]!.fixed
}
const avgFixed = sum / cluster.length

// Apply the alignment to movable segments
for (const idx of movableIndices) {
const seg = segments[idx]!
const path = seg.traceRef.tracePath
const p1 = path[seg.pointIndex]!
const p2 = path[seg.pointIndex + 1]!

if (seg.orientation === "horizontal") {
p1.y = avgFixed
p2.y = avgFixed
} else {
p1.x = avgFixed
p2.x = avgFixed
}

seg.fixed = avgFixed
aligned.add(idx)
}

// Mark all cluster members as processed
for (const idx of cluster) {
aligned.add(idx)
}
}
}
75 changes: 75 additions & 0 deletions site/examples/example31-merge-same-net-traces.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { InputProblem } from "lib/types/InputProblem"
import { PipelineDebugger } from "site/components/PipelineDebugger"

/**
* Example demonstrating same-net trace merging (Issue #34).
*
* Two VCC traces connect from opposite sides of U1 to R1 and R2.
* They may have parallel segments that run close together. After
* the merge phase in TraceCleanupSolver, these should be aligned.
*/
export const inputProblem: InputProblem = {
chips: [
{
chipId: "U1",
center: { x: 0, y: 0 },
width: 1.6,
height: 0.6,
pins: [
{ pinId: "U1.1", x: -0.8, y: 0.2 },
{ pinId: "U1.2", x: -0.8, y: -0.2 },
{ pinId: "U1.3", x: 0.8, y: 0.2 },
{ pinId: "U1.4", x: 0.8, y: -0.2 },
],
},
{
chipId: "R1",
center: { x: -3, y: 1 },
width: 0.5,
height: 1,
pins: [
{ pinId: "R1.1", x: -3, y: 1.5 },
{ pinId: "R1.2", x: -3, y: 0.5 },
],
},
{
chipId: "R2",
center: { x: 3, y: 1 },
width: 0.5,
height: 1,
pins: [
{ pinId: "R2.1", x: 3, y: 1.5 },
{ pinId: "R2.2", x: 3, y: 0.5 },
],
},
{
chipId: "C1",
center: { x: -3, y: -1 },
width: 0.5,
height: 1,
pins: [
{ pinId: "C1.1", x: -3, y: -0.5 },
{ pinId: "C1.2", x: -3, y: -1.5 },
],
},
],
directConnections: [
{ pinIds: ["U1.1", "R1.2"], netId: "VCC" },
{ pinIds: ["U1.3", "R2.2"], netId: "VCC" },
{ pinIds: ["U1.2", "C1.1"], netId: "GND" },
],
netConnections: [
{
pinIds: ["R1.1", "R2.1"],
netId: "VOUT",
},
],
availableNetLabelOrientations: {
VCC: ["y+"],
GND: ["y-"],
VOUT: ["y+", "y-"],
},
maxMspPairDistance: 8,
}

export default () => <PipelineDebugger inputProblem={inputProblem} />
19 changes: 19 additions & 0 deletions tests/examples/example31.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { test, expect } from "bun:test"
import { SchematicTracePipelineSolver } from "lib/solvers/SchematicTracePipelineSolver/SchematicTracePipelineSolver"
import { inputProblem } from "site/examples/example31-merge-same-net-traces.page"
import "tests/fixtures/matcher"

/**
* Test for Issue #34: Merge same-net trace lines that are close together.
*
* This circuit has two VCC traces connecting from opposite sides of U1
* to R1 and R2. These traces may produce parallel segments that run
* close together. After the merge step, they should be aligned.
*/
test("example31: merge same-net trace lines close together", () => {
const solver = new SchematicTracePipelineSolver(inputProblem)

solver.solve()

expect(solver).toMatchSolverSnapshot(import.meta.path)
})
Loading