From e5e7992e8ca4094ab550189a77c8beb672883a62 Mon Sep 17 00:00:00 2001 From: Mustafa Mulla Date: Fri, 29 May 2026 11:04:56 +0530 Subject: [PATCH 1/8] Partial integration of breakout-point-solver --- lib/components/base-components/Renderable.ts | 1 + .../AutoplacedBreakoutPoint.ts | 9 +- .../primitive-components/BaseBreakoutPoint.ts | 8 +- .../primitive-components/Breakout/Breakout.ts | 33 ++ .../createBreakoutPointSolverInput.ts | 202 +++++++++++ package.json | 3 +- .../autoplaced-breakoutpoints1-pcb.snap.svg | 2 +- ...ader-and-passives-autorouting-srj.snap.svg | 326 ++++++------------ ...fp16-with-header-and-passives-pcb.snap.svg | 2 +- ...ulator-power-rail-autorouting-srj.snap.svg | 188 +++++----- ...ut-sot23-regulator-power-rail-pcb.snap.svg | 2 +- ...sor-to-i2c-header-autorouting-srj.snap.svg | 272 ++++++++------- ...ut-soic8-sensor-to-i2c-header-pcb.snap.svg | 2 +- ...ut-qfp16-with-header-and-passives.test.tsx | 160 ++++----- 14 files changed, 672 insertions(+), 538 deletions(-) create mode 100644 lib/components/primitive-components/Breakout/createBreakoutPointSolverInput.ts diff --git a/lib/components/base-components/Renderable.ts b/lib/components/base-components/Renderable.ts index b83b3b2ec..00176267d 100644 --- a/lib/components/base-components/Renderable.ts +++ b/lib/components/base-components/Renderable.ts @@ -58,6 +58,7 @@ export const orderedRenderPhases = [ "PcbLayout", "PcbBoardAutoSize", "PanelLayout", + "PcbAutoBreakoutPointRender", "PcbTraceHintRender", "PcbManualTraceRender", "PcbTraceRender", diff --git a/lib/components/primitive-components/AutoplacedBreakoutPoint.ts b/lib/components/primitive-components/AutoplacedBreakoutPoint.ts index 6e0cfe1d2..8d9bc1fc9 100644 --- a/lib/components/primitive-components/AutoplacedBreakoutPoint.ts +++ b/lib/components/primitive-components/AutoplacedBreakoutPoint.ts @@ -6,6 +6,11 @@ import { BaseBreakoutPoint, baseBreakoutPointProps } from "./BaseBreakoutPoint" * traces cross the breakout boundary. Unlike user-facing `BreakoutPoint`, * this class does NOT require a `connection` prop — its `matchedPort` * is set programmatically before rendering. + * + * Position is deferred: `doInitialPcbPrimitiveRender` is a no-op because + * the position is unknown until the solver runs in the parent Breakout's + * `doInitialPcbAutoBreakoutPointRender` phase, which calls + * `_renderPcbBreakoutPointAtPosition` directly on each child. */ export class AutoplacedBreakoutPoint extends BaseBreakoutPoint< typeof baseBreakoutPointProps @@ -18,6 +23,8 @@ export class AutoplacedBreakoutPoint extends BaseBreakoutPoint< } doInitialPcbPrimitiveRender(): void { - this._renderPcbBreakoutPoint() + // No-op: position is unknown at this phase. The parent Breakout + // creates the db record via _renderPcbBreakoutPointAtPosition + // during PcbAutoBreakoutPointRender after running the solver. } } diff --git a/lib/components/primitive-components/BaseBreakoutPoint.ts b/lib/components/primitive-components/BaseBreakoutPoint.ts index 73f005497..0d62c26b5 100644 --- a/lib/components/primitive-components/BaseBreakoutPoint.ts +++ b/lib/components/primitive-components/BaseBreakoutPoint.ts @@ -42,11 +42,11 @@ export class BaseBreakoutPoint< return trace?.connected_source_net_ids[0] } - _renderPcbBreakoutPoint(): void { + _renderPcbBreakoutPoint(position?: { x: number; y: number }): void { if (this.pcb_breakout_point_id) return if (this.root?.pcbDisabled) return const { db } = this.root! - const position = this._getGlobalPcbPositionBeforeLayout() + const pos = position ?? this._getGlobalPcbPositionBeforeLayout() const group = this.parent?.getGroup() const subcircuit = this.getSubcircuit() if (!group || !group.pcb_group_id) return @@ -62,8 +62,8 @@ export class BaseBreakoutPoint< : this.matchedPort ? this._getSourceNetIdForPort(this.matchedPort) : undefined, - x: position.x, - y: position.y, + x: pos.x, + y: pos.y, }) this.pcb_breakout_point_id = pcb_breakout_point.pcb_breakout_point_id } diff --git a/lib/components/primitive-components/Breakout/Breakout.ts b/lib/components/primitive-components/Breakout/Breakout.ts index 5bf9fb88d..d1799fa39 100644 --- a/lib/components/primitive-components/Breakout/Breakout.ts +++ b/lib/components/primitive-components/Breakout/Breakout.ts @@ -1,10 +1,12 @@ import { breakoutProps } from "@tscircuit/props" +import { BreakoutPointSolver } from "@tscircuit/breakout-point-solver" import { Group } from "../Group/Group" import { AutoplacedBreakoutPoint } from "../AutoplacedBreakoutPoint" import { BreakoutPoint } from "../BreakoutPoint" import { Trace } from "../Trace/Trace" import type { Port } from "../Port" import type { z } from "zod" +import { createBreakoutPointSolverInput } from "./createBreakoutPointSolverInput" export class Breakout extends Group { constructor(props: z.input) { @@ -72,6 +74,37 @@ export class Breakout extends Group { } } + doInitialPcbAutoBreakoutPointRender(): void { + if (this.root?.pcbDisabled) return + + const props = this._parsedProps as z.infer + if (!props.autorouter) return + + const solverInput = createBreakoutPointSolverInput(this) + if (!solverInput) return + + const solver = new BreakoutPointSolver(solverInput) + solver.solve() + const output = solver.getOutput() + + const autoBreakoutPoints = this.children.filter( + (c) => c instanceof AutoplacedBreakoutPoint, + ) as AutoplacedBreakoutPoint[] + + for (const solvedPoint of output.breakoutPoints) { + const match = autoBreakoutPoints.find( + (child) => + child.matchedPort?.source_port_id === solvedPoint.sourcePortId, + ) + if (match) { + match._renderPcbBreakoutPoint({ + x: solvedPoint.x, + y: solvedPoint.y, + }) + } + } + } + doInitialPcbPrimitiveRender(): void { super.doInitialPcbPrimitiveRender() if (this.root?.pcbDisabled) return diff --git a/lib/components/primitive-components/Breakout/createBreakoutPointSolverInput.ts b/lib/components/primitive-components/Breakout/createBreakoutPointSolverInput.ts new file mode 100644 index 000000000..46bc77fcd --- /dev/null +++ b/lib/components/primitive-components/Breakout/createBreakoutPointSolverInput.ts @@ -0,0 +1,202 @@ +import type { BreakoutPointSolverInput } from "@tscircuit/breakout-point-solver" +import type { CircuitJsonUtilObjects } from "@tscircuit/circuit-json-util" +import type { PcbPort } from "circuit-json" +import type { Breakout } from "./Breakout" + +type BreakoutPcbLayer = "top" | "bottom" + +const toBreakoutPcbLayer = ( + layer: string | undefined, +): BreakoutPcbLayer | undefined => { + if (layer === "top" || layer === "bottom") return layer + return undefined +} + +const getPcbPortPad = (db: CircuitJsonUtilObjects, pcbPortId: string) => { + return ( + db.pcb_smtpad.getWhere({ pcb_port_id: pcbPortId }) ?? + db.pcb_plated_hole.getWhere({ pcb_port_id: pcbPortId }) + ) +} + +const getPortSize = ( + db: CircuitJsonUtilObjects, + pcbPort: PcbPort, +): { width?: number; height?: number; ccwRotationDegrees?: number } => { + const pad = getPcbPortPad(db, pcbPort.pcb_port_id) as any + if (!pad) return {} + if (pad.shape === "circle") { + return { width: pad.radius * 2, height: pad.radius * 2 } + } + if (pad.shape === "pill" || pad.shape === "rotated_pill") { + return { + width: pad.width, + height: pad.height, + ccwRotationDegrees: pad.ccw_rotation, + } + } + if (pad.shape === "rect" || pad.shape === "rotated_rect") { + return { + width: pad.width, + height: pad.height, + ccwRotationDegrees: pad.ccw_rotation, + } + } + return {} +} + +const getPortLabel = (db: CircuitJsonUtilObjects, sourcePortId?: string) => { + if (!sourcePortId) return undefined + const sourcePort = db.source_port.get(sourcePortId) + const sourceComponent = sourcePort?.source_component_id + ? db.source_component.get(sourcePort.source_component_id) + : undefined + if (!sourcePort) return undefined + return sourceComponent?.name + ? `${sourceComponent.name}.${sourcePort.name}` + : sourcePort.name +} + +const toBreakoutPort = (db: CircuitJsonUtilObjects, pcbPort: PcbPort) => ({ + sourcePortId: pcbPort.source_port_id!, + position: { x: pcbPort.x!, y: pcbPort.y! }, + ...getPortSize(db, pcbPort), + layer: toBreakoutPcbLayer(pcbPort.layers?.[0]) ?? "top", + label: getPortLabel(db, pcbPort.source_port_id), +}) + +const getPadDimensions = (pad: any) => { + if (pad.shape === "circle") { + return { + width: pad.radius * 2, + height: pad.radius * 2, + } + } + if (pad.shape === "rect" || pad.shape === "rotated_rect") { + return { + width: pad.width, + height: pad.height, + ccwRotationDegrees: pad.ccw_rotation, + } + } + if (pad.shape === "pill" || pad.shape === "rotated_pill") { + return { + width: pad.width, + height: pad.height, + ccwRotationDegrees: pad.ccw_rotation, + } + } + if (pad.shape === "oval" || pad.shape === "circular_hole_with_rect_pad") { + return { + width: pad.outer_width ?? pad.width ?? pad.outer_diameter, + height: pad.outer_height ?? pad.height ?? pad.outer_diameter, + } + } + return null +} + +export const createBreakoutPointSolverInput = ( + breakout: Breakout, +): BreakoutPointSolverInput | null => { + if (!breakout.root || !breakout.pcb_group_id) return null + + const { db } = breakout.root + const pcbGroup = db.pcb_group.get(breakout.pcb_group_id) + if (!pcbGroup || !pcbGroup.width || !pcbGroup.height) return null + + const sourcePortIdToPcbPort = new Map() + for (const pcbPort of db.pcb_port.list()) { + if (!pcbPort.source_port_id) continue + sourcePortIdToPcbPort.set(pcbPort.source_port_id, pcbPort) + } + + const boundsMinX = pcbGroup.center.x - pcbGroup.width / 2 + const boundsMaxX = pcbGroup.center.x + pcbGroup.width / 2 + const boundsMinY = pcbGroup.center.y - pcbGroup.height / 2 + const boundsMaxY = pcbGroup.center.y + pcbGroup.height / 2 + + const traces: BreakoutPointSolverInput["traces"] = [] + for (const sourceTrace of db.source_trace.list()) { + const pcbPorts = sourceTrace.connected_source_port_ids + .map((sourcePortId) => sourcePortIdToPcbPort.get(sourcePortId)) + .filter((port): port is PcbPort => Boolean(port)) + + const insidePorts = pcbPorts.filter( + (port) => port.pcb_group_id === breakout.pcb_group_id, + ) + // Only include outside ports that are geometrically outside the bounds. + // Components may sit inside the boundary area without belonging to the + // breakout group (different pcb_group_id); passing them to the solver + // as "outside" would produce invalid boundary intersections. + const outsidePorts = pcbPorts.filter( + (port) => + port.pcb_group_id !== breakout.pcb_group_id && + !( + port.x! >= boundsMinX && + port.x! <= boundsMaxX && + port.y! >= boundsMinY && + port.y! <= boundsMaxY + ), + ) + + if (insidePorts.length === 0 || outsidePorts.length === 0) continue + + traces.push({ + sourceTraceId: sourceTrace.source_trace_id, + insidePorts: insidePorts.map((port) => toBreakoutPort(db, port)), + outsidePorts: outsidePorts.map((port) => toBreakoutPort(db, port)), + }) + } + + if (traces.length === 0) return null + + const pads: BreakoutPointSolverInput["pads"] = [] + for (const pad of [ + ...db.pcb_smtpad.list(), + ...db.pcb_plated_hole.list(), + ] as any[]) { + const dimensions = getPadDimensions(pad) + if (!dimensions?.width || !dimensions?.height) continue + const pcbPort = pad.pcb_port_id ? db.pcb_port.get(pad.pcb_port_id) : null + pads.push({ + center: { x: pad.x, y: pad.y }, + width: dimensions.width, + height: dimensions.height, + ccwRotationDegrees: dimensions.ccwRotationDegrees, + layer: toBreakoutPcbLayer(pad.layer) ?? "top", + sourcePortIds: pcbPort?.source_port_id ? [pcbPort.source_port_id] : [], + label: getPortLabel(db, pcbPort?.source_port_id), + }) + } + + const components = db.pcb_component + .list() + .filter((component) => component.width && component.height) + .map((component) => ({ + center: component.center, + width: component.width, + height: component.height, + ccwRotationDegrees: component.rotation, + layer: toBreakoutPcbLayer(component.layer), + label: component.pcb_component_id, + })) + + const usedBoundaryPoints = db.pcb_breakout_point + .list() + .filter((point) => point.pcb_group_id === breakout.pcb_group_id) + .map((point) => ({ x: point.x, y: point.y })) + + return { + bounds: { + minX: boundsMinX, + maxX: boundsMaxX, + minY: boundsMinY, + maxY: boundsMaxY, + }, + boundaryPointSpacing: 0.5, + traces, + pads, + components, + usedBoundaryPoints, + } +} diff --git a/package.json b/package.json index db0a5272b..c0daa5d28 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@biomejs/biome": "^1.8.3", "@resvg/resvg-js": "^2.6.2", "@tscircuit/alphabet": "0.0.25", + "@tscircuit/breakout-point-solver": "github:tscircuit/breakout-point-solver#bac9629", "@tscircuit/capacity-autorouter": "^0.0.529", "@tscircuit/checks": "0.0.133", "@tscircuit/circuit-json-util": "^0.0.94", @@ -74,7 +75,7 @@ "debug": "^4.3.6", "eecircuit-engine": "^1.5.6", "flatbush": "^4.5.0", - "graphics-debug": "^0.0.89", + "graphics-debug": "^0.0.95", "howfat": "^0.3.8", "kicad-to-circuit-json": "^0.0.60", "kicadts": "^0.0.35", diff --git a/tests/breakout/__snapshots__/autoplaced-breakoutpoints1-pcb.snap.svg b/tests/breakout/__snapshots__/autoplaced-breakoutpoints1-pcb.snap.svg index 052c70f7c..ca9816f65 100644 --- a/tests/breakout/__snapshots__/autoplaced-breakoutpoints1-pcb.snap.svg +++ b/tests/breakout/__snapshots__/autoplaced-breakoutpoints1-pcb.snap.svg @@ -1 +1 @@ -R1C1R2 \ No newline at end of file +R1C1R2 \ No newline at end of file diff --git a/tests/breakout/__snapshots__/breakout-qfp16-with-header-and-passives-autorouting-srj.snap.svg b/tests/breakout/__snapshots__/breakout-qfp16-with-header-and-passives-autorouting-srj.snap.svg index 04ed8b0f2..bafce5f07 100644 --- a/tests/breakout/__snapshots__/breakout-qfp16-with-header-and-passives-autorouting-srj.snap.svg +++ b/tests/breakout/__snapshots__/breakout-qfp16-with-header-and-passives-autorouting-srj.snap.svg @@ -1,4 +1,4 @@ - + @@ -10,210 +10,77 @@ font-size="18" font-weight="700" text-anchor="middle" - >FULL ROUTED CIRCUIT: 5 CONNECTIONS, 11 TRACES + >FULL ROUTED CIRCUIT: 6 CONNECTIONS, 2 TRACES - - - - - - - - AUTOROUTING PHASE 2 END: 8 CONNECTIONS, 11 TRACES - - - - + - - + + + + + + + + AUTOROUTING PHASE 2 END: 8 CONNECTIONS, 11 TRACES + + + - + AUTOROUTING PHASE 1 END: 6 CONNECTIONS, 2 TRACES - - +