From b7d198a2ab72835e652d918292bb289bdb07480e Mon Sep 17 00:00:00 2001 From: Cam Pedersen Date: Fri, 5 Jun 2026 14:28:59 -0400 Subject: [PATCH] feat(pcb): editable board size + fit to enclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of PCB co-design — make the board outline parametric and link it to the surrounding mechanical geometry. - document-store: `resizeBoard(w, h)` and `setBoardOutline(outline)` actions. Both re-extrude the FR4 slab via setCrdtPcb → kernel re-eval. - ElectronicsPanelHeader: the board-dims readout is now an editable W×H chip (click → number inputs → commit re-extrudes), plus a "Fit" button that sizes *and* positions the board to the union of the non-board mechanical parts' world AABB minus a 2mm clearance (reuses the Phase 3 interference gather + the board's world transform, dividing out board scale). The button only shows once a non-board part with mesh geometry exists. - pcb-interference: `mergeAabbs()` helper (union of AABBs) + tests. Verified live: edit 50×30 → 72×48 re-extrudes the slab; a 20mm cube fits the board to 16×16 (20 − 2×2mm) and repositions it. Core (100) + interference (8) tests green; tsc -b clean. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-05-pcb-board-resize-fit.json | 10 + .../electronics/ElectronicsPanelHeader.tsx | 192 ++++++++++++++++-- packages/app/src/lib/pcb-interference.ts | 14 ++ .../app/src/test/pcb-interference.test.ts | 9 + packages/core/src/stores/document-store.ts | 33 +++ 5 files changed, 242 insertions(+), 16 deletions(-) create mode 100644 changelog/entries/2026-06-05-pcb-board-resize-fit.json diff --git a/changelog/entries/2026-06-05-pcb-board-resize-fit.json b/changelog/entries/2026-06-05-pcb-board-resize-fit.json new file mode 100644 index 00000000..8e624c9b --- /dev/null +++ b/changelog/entries/2026-06-05-pcb-board-resize-fit.json @@ -0,0 +1,10 @@ +{ + "id": "2026-06-05-pcb-board-resize-fit", + "version": "0.9.4", + "date": "2026-06-05", + "category": "feat", + "title": "Editable PCB board size + fit to enclosure", + "summary": "Resize the board inline from the Circuit panel, or fit it to the surrounding mechanical parts with one click.", + "features": ["pcb", "ecad", "co-design"], + "mcpTools": [] +} diff --git a/packages/app/src/components/electronics/ElectronicsPanelHeader.tsx b/packages/app/src/components/electronics/ElectronicsPanelHeader.tsx index 85eb4ec3..2e17217c 100644 --- a/packages/app/src/components/electronics/ElectronicsPanelHeader.tsx +++ b/packages/app/src/components/electronics/ElectronicsPanelHeader.tsx @@ -8,8 +8,37 @@ * transform when nothing is selected). Shown whenever electronics is active. */ -import { useDocumentStore, useCoreElectronicsStore, getNodePcb } from "@vcad/core"; +import { useState } from "react"; +import { + useDocumentStore, + useCoreElectronicsStore, + useEngineStore, + getNodePcb, + isPcbBoardPart, + findPcbBoardPart, + getPcbBoardTransform, +} from "@vcad/core"; +import type { Pcb } from "@vcad/ir"; import { useElectronicsStore } from "@/stores/electronics-store"; +import { useNotificationStore } from "@/stores/notification-store"; +import { aabbOfPositions, mergeAabbs, type Aabb } from "@/lib/pcb-interference"; + +/** Clearance (mm) inset between the board edge and the enclosure walls on a fit. */ +const ENCLOSURE_CLEARANCE = 2; + +/** W×H (mm) of a board outline's bounding box. */ +function outlineWH(pcb: Pcb): { w: number; h: number } { + const verts = pcb.outline.vertices; + if (verts.length < 3) return { w: 0, h: 0 }; + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const v of verts) { + if (v.x < minX) minX = v.x; + if (v.x > maxX) maxX = v.x; + if (v.y < minY) minY = v.y; + if (v.y > maxY) maxY = v.y; + } + return { w: maxX - minX, h: maxY - minY }; +} function Stat({ label, @@ -30,6 +59,150 @@ function Stat({ ); } +/** + * Editable board-size chip: shows W×H, click to edit, plus a "Fit" button that + * sizes + positions the board to the surrounding mechanical parts (the ECAD↔MCAD + * co-design link). Resizing re-extrudes the FR4 slab via the kernel. + */ +function BoardSizeControl({ pcb }: { pcb: Pcb }) { + const activeBoardNodeId = useCoreElectronicsStore((s) => s.activeBoardNodeId); + const document = useDocumentStore((s) => s.document); + const parts = useDocumentStore((s) => s.parts); + const scene = useEngineStore((s) => s.scene); + const resizeBoard = useDocumentStore((s) => s.resizeBoard); + const setTranslation = useDocumentStore((s) => s.setTranslation); + const addToast = useNotificationStore((s) => s.addToast); + + const [editing, setEditing] = useState<{ w: string; h: string } | null>(null); + + const { w, h } = outlineWH(pcb); + + // Count non-board mechanical parts that actually carry a mesh, so "Fit" only + // shows once there's geometry to fit to (gating on part identity alone races + // the mesh eval — the button would appear a frame before fitting can work). + let mechCount = 0; + scene?.parts.forEach((ep, idx) => { + const pi = parts[idx]; + if (pi && !isPcbBoardPart(pi) && (ep.mesh?.positions?.length ?? 0) > 0) mechCount++; + }); + + const commit = () => { + if (!editing) return; + const nw = parseFloat(editing.w); + const nh = parseFloat(editing.h); + if (isFinite(nw) && isFinite(nh) && nw > 0 && nh > 0) { + resizeBoard(nw, nh); + } + setEditing(null); + }; + + const fitToEnclosure = () => { + // Gather world-space AABBs of every non-board part, union into the + // enclosure box, then size + place the board to fill its XY footprint. + const mech: Aabb[] = []; + scene?.parts.forEach((ep, idx) => { + const pi = parts[idx]; + if (pi && isPcbBoardPart(pi)) return; + const bb = aabbOfPositions(ep.mesh?.positions); + if (bb) mech.push(bb); + }); + const enc = mergeAabbs(mech); + if (!enc) { + addToast("No surrounding parts to fit the board to", "info"); + return; + } + const clr = ENCLOSURE_CLEARANCE; + const targetW = enc.max[0] - enc.min[0] - 2 * clr; + const targetH = enc.max[1] - enc.min[1] - 2 * clr; + if (targetW < 1 || targetH < 1) { + addToast("Surrounding parts are too small to fit a board", "info"); + return; + } + + // Outline is board-local; divide out the board's scale so the *world* + // footprint matches the enclosure (rotation is assumed axis-aligned). + const boardPart = + activeBoardNodeId != null ? findPcbBoardPart(parts, activeBoardNodeId) : null; + const xf = boardPart ? getPcbBoardTransform(document, boardPart) : null; + const sx = xf && xf.scale.x !== 0 ? xf.scale.x : 1; + const sy = xf && xf.scale.y !== 0 ? xf.scale.y : 1; + resizeBoard(targetW / sx, targetH / sy); + + // Position the board's local origin at the enclosure's min corner + clearance, + // preserving its current Z so vertical placement is untouched. + if (boardPart) { + setTranslation(boardPart.id, { + x: enc.min[0] + clr, + y: enc.min[1] + clr, + z: xf?.position.z ?? 0, + }); + } + addToast( + `Board fit to enclosure · ${Math.round(targetW)}×${Math.round(targetH)}mm`, + "success", + ); + }; + + if (editing) { + return ( + + setEditing({ ...editing, w: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") setEditing(null); + }} + className="w-10 bg-surface border border-border rounded px-1 py-0.5 text-text text-[11px] tabular-nums" + /> + × + setEditing({ ...editing, h: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") setEditing(null); + }} + className="w-10 bg-surface border border-border rounded px-1 py-0.5 text-text text-[11px] tabular-nums" + /> + + + ); + } + + return ( + + + {mechCount > 0 && ( + + )} + + ); +} + export function ElectronicsPanelHeader() { const focusedPane = useElectronicsStore((s) => s.focusedPane); const schTool = useElectronicsStore((s) => s.schTool); @@ -46,19 +219,6 @@ export function ElectronicsPanelHeader() { const currentTool = focusedPane === "pcb" ? pcbTool : schTool; const toolLabel = currentTool.charAt(0).toUpperCase() + currentTool.slice(1); - // Compact board-size readout (W×H from the outline bbox), shown by the title. - let boardDims: string | null = null; - const verts = pcb?.outline.vertices; - if (verts && verts.length >= 3) { - let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; - for (const v of verts) { - if (v.x < minX) minX = v.x; - if (v.x > maxX) maxX = v.x; - if (v.y < minY) minY = v.y; - if (v.y > maxY) maxY = v.y; - } - boardDims = `${Math.round(maxX - minX)}×${Math.round(maxY - minY)}mm`; - } const drcErrors = drcViolations.filter((v) => v.severity === "Error").length; const drcWarnings = drcViolations.length - drcErrors; const ercCount = ercViolations.length; @@ -68,9 +228,9 @@ export function ElectronicsPanelHeader() {
{/* Title + health */}
- + Circuit - {boardDims && {boardDims}} + {pcb && }
diff --git a/packages/app/src/lib/pcb-interference.ts b/packages/app/src/lib/pcb-interference.ts index 60350a4a..29144b3e 100644 --- a/packages/app/src/lib/pcb-interference.ts +++ b/packages/app/src/lib/pcb-interference.ts @@ -52,6 +52,20 @@ export function aabbOfPositions( return { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] }; } +/** Union a list of AABBs into one enclosing box. null if the list is empty. */ +export function mergeAabbs(boxes: Aabb[]): Aabb | null { + if (boxes.length === 0) return null; + const min: [number, number, number] = [Infinity, Infinity, Infinity]; + const max: [number, number, number] = [-Infinity, -Infinity, -Infinity]; + for (const b of boxes) { + for (let i = 0; i < 3; i++) { + if (b.min[i]! < min[i]!) min[i] = b.min[i]!; + if (b.max[i]! > max[i]!) max[i] = b.max[i]!; + } + } + return { min, max }; +} + /** True when two AABBs overlap (optionally expanded by a clearance margin). */ export function aabbsOverlap(a: Aabb, b: Aabb, margin = 0): boolean { return ( diff --git a/packages/app/src/test/pcb-interference.test.ts b/packages/app/src/test/pcb-interference.test.ts index 98e237d1..ea667cef 100644 --- a/packages/app/src/test/pcb-interference.test.ts +++ b/packages/app/src/test/pcb-interference.test.ts @@ -4,6 +4,7 @@ import { aabbOfPositions, aabbsOverlap, interferingRefs, + mergeAabbs, type PointTransform, } from "@/lib/pcb-interference"; @@ -64,6 +65,14 @@ describe("pcb-interference", () => { expect(interferingRefs(parts, mech)).toEqual(["R1"]); }); + it("merges AABBs into the enclosing box (the 'fit to enclosure' bound)", () => { + expect(mergeAabbs([])).toBeNull(); + const a = aabbOfPositions(boxPositions(10, 0, 0, 0))!; // [0,10]^3 + const b = aabbOfPositions(boxPositions(5, 20, 2, -3))!; // [20,25]×[2,7]×[-3,2] + expect(mergeAabbs([a])).toEqual(a); + expect(mergeAabbs([a, b])).toEqual({ min: [0, 0, -3], max: [25, 10, 10] }); + }); + it("maps board-local bodies into world via boardToWorld", () => { const mech = [aabbOfPositions(boxPositions(10))!]; // world [0,10]^3 const c = comp("R1", boxPositions(2, 55, 1, 1)); // local x∈[55,57] → no clash diff --git a/packages/core/src/stores/document-store.ts b/packages/core/src/stores/document-store.ts index 52822a94..bc981b70 100644 --- a/packages/core/src/stores/document-store.ts +++ b/packages/core/src/stores/document-store.ts @@ -24,6 +24,7 @@ import type { SchematicJunction, Footprint, Pcb, + BoardOutline, EmbroideryDesign, FillParams, } from "@vcad/ir"; @@ -471,6 +472,10 @@ export interface DocumentState { // PCB editing mutations addFootprint: (nodeId: NodeId, fp: Footprint) => void; removeFootprint: (nodeId: NodeId, idx: number) => void; + /** Replace the board outline (vertices, cutouts, thickness). Re-extrudes the slab. */ + setBoardOutline: (outline: BoardOutline) => void; + /** Resize the board to a W×H rectangle (origin corner at [0,0]), preserving thickness + cutouts. */ + resizeBoard: (width: number, height: number) => void; } // --------------------------------------------------------------------------- @@ -2303,4 +2308,32 @@ export const useDocumentStore = create((set, get) => ({ pcb.footprints.splice(idx, 1); set({ ...setCrdtPcb(state, pcb), isDirty: true }); }, + + setBoardOutline: (outline) => { + const state = get(); + if (!state.document.pcb) return; + const pcb = structuredClone(state.document.pcb); + pcb.outline = structuredClone(outline); + set({ ...setCrdtPcb(state, pcb), isDirty: true }); + }, + + resizeBoard: (width, height) => { + const state = get(); + if (!state.document.pcb) return; + const w = Math.max(1, width); + const h = Math.max(1, height); + const pcb = structuredClone(state.document.pcb); + // Rectangular resize: origin corner at [0,0] (matches initPcb), keep + // thickness + cutouts. Custom (non-rect) outlines are rectangularized. + pcb.outline = { + ...pcb.outline, + vertices: [ + { x: 0, y: 0 }, + { x: w, y: 0 }, + { x: w, y: h }, + { x: 0, y: h }, + ], + }; + set({ ...setCrdtPcb(state, pcb), isDirty: true }); + }, }));