Skip to content
Merged
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
10 changes: 10 additions & 0 deletions changelog/entries/2026-06-05-pcb-board-resize-fit.json
Original file line number Diff line number Diff line change
@@ -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": []
}
192 changes: 176 additions & 16 deletions packages/app/src/components/electronics/ElectronicsPanelHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
<span className="inline-flex items-center gap-0.5">
<input
aria-label="Board width (mm)"
type="number"
autoFocus
value={editing.w}
onChange={(e) => 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"
/>
<span className="text-text-muted">×</span>
<input
aria-label="Board height (mm)"
type="number"
value={editing.h}
onChange={(e) => 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"
/>
<button
onClick={commit}
className="ml-0.5 px-1 py-0.5 rounded text-accent hover:bg-accent/10"
title="Apply board size"
>
</button>
</span>
);
}

return (
<span className="inline-flex items-center gap-1">
<button
onClick={() => setEditing({ w: String(Math.round(w)), h: String(Math.round(h)) })}
className="text-text-muted hover:text-text underline decoration-dotted underline-offset-2"
title="Edit board size"
>
{Math.round(w)}×{Math.round(h)}mm
</button>
{mechCount > 0 && (
<button
onClick={fitToEnclosure}
className="px-1 py-0.5 rounded border border-border text-text-muted hover:text-text hover:border-accent/60"
title="Resize + position the board to fit the surrounding parts"
>
Fit
</button>
)}
</span>
);
}

export function ElectronicsPanelHeader() {
const focusedPane = useElectronicsStore((s) => s.focusedPane);
const schTool = useElectronicsStore((s) => s.schTool);
Expand All @@ -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;
Expand All @@ -68,9 +228,9 @@ export function ElectronicsPanelHeader() {
<div className="shrink-0 border-b border-border/40 bg-surface/60 px-3 py-2 text-[11px]">
{/* Title + health */}
<div className="flex items-center justify-between gap-2">
<span className="flex items-baseline gap-1.5 min-w-0">
<span className="flex items-center gap-1.5 min-w-0">
<span className="font-medium text-text">Circuit</span>
{boardDims && <span className="truncate text-text-muted">{boardDims}</span>}
{pcb && <BoardSizeControl pcb={pcb} />}
</span>
<div className="flex items-center gap-2 shrink-0">
<span className="inline-flex items-center gap-1" title="DRC errors / warnings">
Expand Down
14 changes: 14 additions & 0 deletions packages/app/src/lib/pcb-interference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/test/pcb-interference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
aabbOfPositions,
aabbsOverlap,
interferingRefs,
mergeAabbs,
type PointTransform,
} from "@/lib/pcb-interference";

Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/stores/document-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
SchematicJunction,
Footprint,
Pcb,
BoardOutline,
EmbroideryDesign,
FillParams,
} from "@vcad/ir";
Expand Down Expand Up @@ -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;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2303,4 +2308,32 @@ export const useDocumentStore = create<DocumentState>((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 });
},
}));