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
Binary file added docs/pcb-comb/autorouter.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pcb-comb/comb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pcb-comb/straight-line.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { TraceConnectionError } from "lib/errors"
import { getPcbSelectorErrorForTracePort } from "./getPcbSelectorErrorForTracePort"
import { jlcMinTolerances } from "@tscircuit/jlcpcb-manufacturing-specs"
import { getViaSpanLayers } from "lib/utils/getViaSpanLayers"
import {
computeCombWaypoints,
type CombOrientation,
} from "lib/utils/computeCombWaypoints"

const findInflatedPcbViaForPoint = (
vias: PcbVia[] | undefined,
Expand Down Expand Up @@ -47,9 +51,16 @@ export function Trace_doInitialPcbManualTraceRender(trace: Trace) {

const hasPcbPath = props.pcbPath !== undefined
const wantsStraightLine = Boolean(props.pcbStraightLine)
const combOrientation = (props as any).pcbComb as CombOrientation | undefined
const wantsComb = Boolean(combOrientation)
const inflatedPcbTraces = trace._inflatedPcbTraces ?? []

if (!hasPcbPath && !wantsStraightLine && inflatedPcbTraces.length === 0)
if (
!hasPcbPath &&
!wantsStraightLine &&
!wantsComb &&
inflatedPcbTraces.length === 0
)
return

let allPortsFound: boolean
Expand Down Expand Up @@ -298,6 +309,63 @@ export function Trace_doInitialPcbManualTraceRender(trace: Trace) {
return
}

if (wantsComb && !hasPcbPath) {
if (!ports || ports.length < 2) {
trace.renderError("pcbComb requires exactly two connected ports")
return
}

const [startPort, endPort] = ports
const startLayers = startPort.getAvailablePcbLayers()
const endLayers = endPort.getAvailablePcbLayers()
const sharedLayer = startLayers.find((layer) => endLayers.includes(layer))
const layer = (sharedLayer ??
startLayers[0] ??
endLayers[0] ??
"top") as LayerRef

// The two pads are the route endpoints; the comb is computed in global coordinates
// (both positions are known by this phase), so no per-component transform is needed.
const startPos = startPort._getGlobalPcbPositionAfterLayout()
const endPos = endPort._getGlobalPcbPositionAfterLayout()
const bends = computeCombWaypoints(startPos, endPos, combOrientation!)

// A comb whose fixed shape would overshoot (offset exceeds gap) is left unrouted so the
// autorouter handles it, rather than drawing backtracking copper.
if (!bends) {
console.warn(
`[pcbComb] ${trace} (${combOrientation}): fixed comb doesn't fit — leaving for the autorouter`,
)
return
}

const routePoints = [startPos, ...bends, endPos]
const route: PcbTraceRoutePoint[] = routePoints.map((p, index) => ({
route_type: "wire",
x: p.x,
y: p.y,
width,
layer,
...(index === 0 ? { start_pcb_port_id: startPort.pcb_port_id! } : {}),
...(index === routePoints.length - 1
? { end_pcb_port_id: endPort.pcb_port_id! }
: {}),
}))

const traceLength = getTraceLength(route)
const pcb_trace = db.pcb_trace.insert({
route,
source_trace_id: trace.source_trace_id!,
subcircuit_id: subcircuit?.subcircuit_id ?? undefined,
pcb_group_id: trace.getGroup()?.pcb_group_id ?? undefined,
trace_length: traceLength,
})
trace._portsRoutedOnPcb = ports
trace.pcb_trace_id = pcb_trace.pcb_trace_id
trace._insertErrorIfTraceIsOutsideBoard(route, ports)
return
}

if (!props.pcbPath) return

let anchorPort: Port | undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ export function Trace_doInitialPcbTraceRender(trace: Trace) {
return
}

// A placed comb (see PcbManualTraceRender) is already fixed copper; an unfittable comb
// wasn't placed (no pcb_trace_id) and falls through to the autorouter below.
if ((props as any).pcbComb && trace.pcb_trace_id) {
return
}

if (!subcircuit._shouldUseTraceByTraceRouting()) {
return
}
Expand Down
67 changes: 67 additions & 0 deletions lib/utils/computeCombWaypoints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
export type CombOrientation =
| "columnToColumn"
| "rowToColumn"
| "columnToRow"
| "rowToRow"

type Pt = { x: number; y: number }

const monotone = (vals: number[], sgn: number) =>
vals.every((v, i) => i === 0 || sgn * (v - vals[i - 1]) >= -1e-6)

/**
* The two interior bend points of a comb trace between two pads, in board coordinates: a
* perpendicular escape stub off the source, one 45° diagonal covering the offset, then a
* perpendicular landing into the target. The pads are the route's endpoints (added by the
* caller); only these two corners are returned, so the drawn path is straight → 45° → straight.
*
* `orientation` is <sourceLine>To<targetLine>: a COLUMN of pads (stacked, shared x) escapes
* perpendicular in x, a ROW (spread, shared y) in y. The perpendicular escape is what lets a
* bundle clear rectangular SMD lands before it turns, so N of these nest into an even comb.
*
* Returns null when the fixed shape can't reach the target without backtracking — e.g. a
* diverging fan whose perpendicular offset exceeds the gap it has to close. The caller then
* leaves the trace unrouted so the autorouter handles it, rather than drawing overshooting copper.
*/
export function computeCombWaypoints(
s: Pt,
t: Pt,
orientation: CombOrientation,
stub = 1,
): Pt[] | null {
const sgx = Math.sign(t.x - s.x) || 1
const sgy = Math.sign(t.y - s.y) || 1
let p1: Pt
let p2: Pt
if (orientation === "columnToColumn") {
// escape x, land x — diagonal spans y
const x1 = s.x + sgx * stub
p1 = { x: x1, y: s.y }
p2 = { x: x1 + sgx * Math.abs(t.y - s.y), y: t.y }
} else if (orientation === "rowToRow") {
// escape y, land y — diagonal spans x
const y1 = s.y + sgy * stub
p1 = { x: s.x, y: y1 }
p2 = { x: t.x, y: y1 + sgy * Math.abs(t.x - s.x) }
} else if (orientation === "rowToColumn") {
// escape y, land x
const y1 = s.y + sgy * stub
p1 = { x: s.x, y: y1 }
p2 = { x: s.x + sgx * Math.abs(t.y - y1), y: t.y }
} else if (orientation === "columnToRow") {
// escape x, land y
const x1 = s.x + sgx * stub
p1 = { x: x1, y: s.y }
p2 = { x: t.x, y: s.y + sgy * Math.abs(t.x - x1) }
} else {
return null
}
// A comb never backtracks: x and y must each progress monotonically source→target across
// pad → escape → diagonal → land. If not, the fixed shape doesn't fit here.
if (
!monotone([s.x, p1.x, p2.x, t.x], sgx) ||
!monotone([s.y, p1.y, p2.y, t.y], sgy)
)
return null
return [p1, p2]
}
Loading
Loading