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
50 changes: 39 additions & 11 deletions lib/solvers/TraceCleanupSolver/TraceCleanupSolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ 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 { UntangleTraceSubsolver } from "./sub-solver/UntangleTraceSubsolver"
import { is4PointRectangle } from "./is4PointRectangle"
import { simplifyPath } from "./simplifyPath"

/**
* Defines the input structure for the TraceCleanupSolver.
Expand All @@ -18,24 +21,23 @@ interface TraceCleanupSolverInput {
paddingBuffer: number
}

import { UntangleTraceSubsolver } from "./sub-solver/UntangleTraceSubsolver"
import { is4PointRectangle } from "./is4PointRectangle"

/**
* Represents the different stages or steps within the trace cleanup pipeline.
* Represents the different stages within the cleanup pipeline.
* added merging_collinear for the bounty #34 logic.
*/
type PipelineStep =
| "minimizing_turns"
| "balancing_l_shapes"
| "untangling_traces"
| "merging_collinear"

/**
* 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.
* The solver processes traces one by one, applying these cleanup steps sequentially to refine the overall trace layout.
* Orchestrates aesthetic and readability improvements for schematic traces.
* logic:
* 1. Untangle - fix convolutions.
* 2. Minimize Turns - simplify paths.
* 3. Balance L-Shapes - visual consistency.
* 4. Merging Collinear - snap to same X/Y and purge redundant points (Bounty #34).
*/
export class TraceCleanupSolver extends BaseSolver {
private input: TraceCleanupSolverInput
Expand Down Expand Up @@ -84,6 +86,9 @@ export class TraceCleanupSolver extends BaseSolver {
case "balancing_l_shapes":
this._runBalanceLShapesStep()
break
case "merging_collinear":
this._runMergingCollinearStep()
break
}
}

Expand All @@ -108,13 +113,36 @@ export class TraceCleanupSolver extends BaseSolver {

private _runBalanceLShapesStep() {
if (this.traceIdQueue.length === 0) {
this.solved = true
// transitioning to the new snapping/merging phase
this.pipelineStep = "merging_collinear"
this.traceIdQueue = Array.from(
this.input.allTraces.map((e) => e.mspPairId),
)
return
}

this._processTrace("balancing_l_shapes")
}

private _runMergingCollinearStep() {
if (this.traceIdQueue.length === 0) {
this.solved = true
return
}

const targetMspConnectionPairId = this.traceIdQueue.shift()!
const originalTrace = this.tracesMap.get(targetMspConnectionPairId)!

// purging redundant segments and snapping nearly-aligned points
const updatedPath = simplifyPath(originalTrace.tracePath)

this.tracesMap.set(targetMspConnectionPairId, {
...originalTrace,
tracePath: updatedPath,
})
this.outputTraces = Array.from(this.tracesMap.values())
}

private _processTrace(step: "minimizing_turns" | "balancing_l_shapes") {
const targetMspConnectionPairId = this.traceIdQueue.shift()!
this.activeTraceId = targetMspConnectionPairId
Expand Down
111 changes: 82 additions & 29 deletions lib/solvers/TraceCleanupSolver/simplifyPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,91 @@ import {
isVertical,
} from "lib/solvers/SchematicTraceLinesSolver/SchematicTraceSingleLineSolver2/collisions"

export const simplifyPath = (path: Point[]): Point[] => {
if (path.length < 3) return path
const newPath: Point[] = [path[0]]
for (let i = 1; i < path.length - 1; i++) {
const p1 = newPath[newPath.length - 1]
const p2 = path[i]
const p3 = path[i + 1]
if (
(isVertical(p1, p2) && isVertical(p2, p3)) ||
(isHorizontal(p1, p2) && isHorizontal(p2, p3))
) {
continue
/**
* Robustly simplifies a PCB trace path to ensure all lines are perfectly
* horizontal or vertical, avoiding "crinks" while preserving component connections.
* * This version uses the epsilon/threshold method but adds absolute protection
* for the final segment to ensure vertical/horizontal entries are never lost.
*/
export const simplifyPath = (path: Point[], threshold = 0.1): Point[] => {
// If the path is just a direct connection, keep it as is
if (path.length <= 2) return [...path]

// Pass 1: Snapping logic (The Epsilon Method)
// We snap points to match the previous point's axes if they are within threshold.
const snappedPath: Point[] = [path[0]]
const lastIdx = path.length - 1

for (let i = 1; i < path.length; i++) {
const prev = snappedPath[snappedPath.length - 1]
const current = { ...path[i] }

// CRITICAL: If we are at the very last point, do NOT snap it to the previous point.
// Instead, if the segment is slightly off, snap the PREVIOUS point to match the LAST point's axis.
// This ensures the connection line exists and is vertical/horizontal.
const isLastPoint = i === lastIdx

if (!isLastPoint) {
if (Math.abs(current.x - prev.x) < threshold) current.x = prev.x
if (Math.abs(current.y - prev.y) < threshold) current.y = prev.y
} else {
// For the last point, if the segment is "almost" vertical/horizontal,
// we modify the previous point in the snapped list to align with the destination.
if (Math.abs(current.x - prev.x) < threshold) prev.x = current.x
if (Math.abs(current.y - prev.y) < threshold) prev.y = current.y
}

// Only add if it's a unique coordinate or the final mandatory endpoint
if (current.x !== prev.x || current.y !== prev.y || isLastPoint) {
snappedPath.push(current)
}
newPath.push(p2)
}
newPath.push(path[path.length - 1])

if (newPath.length < 3) return newPath
const finalPath: Point[] = [newPath[0]]
for (let i = 1; i < newPath.length - 1; i++) {
const p1 = finalPath[finalPath.length - 1]
const p2 = newPath[i]
const p3 = newPath[i + 1]
if (
(isVertical(p1, p2) && isVertical(p2, p3)) ||
(isHorizontal(p1, p2) && isHorizontal(p2, p3))
) {
continue

// Pass 2: Vertical/Horizontal Force
// Ensure every single segment in the snapped path is strictly orthogonal.
for (let i = 0; i < snappedPath.length - 1; i++) {
const p1 = snappedPath[i]
const p2 = snappedPath[i + 1]

// If a segment is slanted, snap it to the dominant axis
if (p1.x !== p2.x && p1.y !== p2.y) {
if (Math.abs(p2.x - p1.x) < Math.abs(p2.y - p1.y)) {
p2.x = p1.x
} else {
p2.y = p1.y
}
}
finalPath.push(p2)
}
finalPath.push(newPath[newPath.length - 1])

return finalPath
// Pass 3: Collinear Point Removal (Pruning)
// Remove points that lie on a straight line between two others.
const result: Point[] = [snappedPath[0]]

for (let i = 1; i < snappedPath.length - 1; i++) {
const p1 = result[result.length - 1]
const p2 = snappedPath[i]
const p3 = snappedPath[i + 1]

// A turn is only necessary if the direction changes
const isRedundant =
(isHorizontal(p1, p2) && isHorizontal(p2, p3)) ||
(isVertical(p1, p2) && isVertical(p2, p3))

if (!isRedundant) {
// Ensure we don't accidentally merge distinct points
if (p2.x !== p1.x || p2.y !== p1.y) {
result.push(p2)
}
}
}

// Final safeguard: Always append the destination
const finalDest = snappedPath[snappedPath.length - 1]
const currentEnd = result[result.length - 1]

if (finalDest.x !== currentEnd.x || finalDest.y !== currentEnd.y) {
result.push(finalDest)
}

return result
}
Loading
Loading