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
9 changes: 9 additions & 0 deletions changelog/entries/2026-06-03-pcb-board-solid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"id": "2026-06-03-pcb-board-solid",
"version": "0.9.4",
"date": "2026-06-03",
"category": "feat",
"title": "PCB boards are real FR4 solids",
"summary": "A PCB board now evaluates to a genuine FR4 slab in the live app — a green body visible outside edit focus, STEP-exportable and ray-traceable — instead of a data-only placeholder.",
"features": ["pcb", "ecad", "electronics", "kernel"]
}
22 changes: 18 additions & 4 deletions crates/vcad-app/src/materializer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -990,11 +990,25 @@ fn materialize_feature(
let rotate_id = ctx.alloc();
let translate_id = ctx.alloc();

if let Some(json) = &board {
doc.pcb = serde_json::from_str(json).ok();
}
// Parse the board JSON into a real `PcbBoard` op so the kernel
// evaluator extrudes a genuine FR4 slab (a board is then a true
// body — visible outside edit focus, STEP-exportable, ray-traceable)
// instead of an empty placeholder. Also mirror it into the legacy
// `doc.pcb` field, which `getNodePcb`'s CRDT fallback still reads.
let board_op = match board
.as_ref()
.and_then(|json| serde_json::from_str::<vcad_ir::ecad::Pcb>(json).ok())
{
Some(pcb) => {
doc.pcb = Some(pcb.clone());
CsgOp::PcbBoard {
board: Box::new(pcb),
}
}
None => CsgOp::Empty,
};

insert_node(doc, board_id, &name, CsgOp::Empty);
insert_node(doc, board_id, &name, board_op);
insert_transform_chain(
doc,
ctx,
Expand Down
11 changes: 11 additions & 0 deletions crates/vcad-eval/src/evaluate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,17 @@ fn evaluate_op_timed(
board_solid = Solid::from_mesh(merged);
}

// Center the board on z=0 so its top surface lands at +thickness/2,
// where PcbScene draws the copper (layerZ = thickness/2 + …) and
// where the legacy PcbBoardMesh sat. The outline is extruded from
// z=0, so shift the whole board (slab + components + copper) down by
// thickness/2.
let board_solid = board_solid.apply_transform(&Transform::translation(
0.0,
0.0,
-board.outline.thickness / 2.0,
));

Ok(Some(board_solid))
}

Expand Down
84 changes: 84 additions & 0 deletions crates/vcad-eval/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,90 @@ mod tests {
assert!(!scene.parts[0].mesh.positions.is_empty());
}

#[test]
fn evaluate_pcb_board_is_centered_solid() {
use vcad_ir::ecad::{BoardOutline, DesignRules, LayerStackup, NetClassRules, Pcb};

let thickness = 1.6;
let pcb = Pcb {
outline: BoardOutline {
vertices: vec![
Vec2::new(0.0, 0.0),
Vec2::new(50.0, 0.0),
Vec2::new(50.0, 30.0),
Vec2::new(0.0, 30.0),
],
cutouts: vec![],
thickness,
},
stackup: LayerStackup { layers: vec![] },
nets: vec![],
rules: DesignRules {
default_rules: NetClassRules {
name: "Default".to_string(),
trace_width: 0.25,
clearance: 0.2,
via_diameter: 0.8,
via_drill: 0.4,
diff_pair_gap: None,
diff_pair_width: None,
},
class_rules: vec![],
net_class_assignments: std::collections::HashMap::new(),
edge_clearance: 0.5,
hole_to_hole: 0.5,
min_annular_ring: 0.15,
min_drill: 0.2,
},
footprints: vec![],
traces: vec![],
trace_arcs: vec![],
vias: vec![],
zones: vec![],
keepouts: vec![],
};

let mut doc = Document::new();
doc.nodes.insert(
1,
Node {
id: 1,
name: Some("board".to_string()),
op: CsgOp::PcbBoard {
board: Box::new(pcb),
},
},
);
doc.roots.push(SceneEntry {
root: 1,
material: "default".to_string(),
visible: None,
});

let scene = evaluate_document(&doc, &EvalOptions::default()).unwrap();
assert_eq!(scene.parts.len(), 1);
let pos = &scene.parts[0].mesh.positions;
assert!(!pos.is_empty(), "PcbBoard should evaluate to a real solid");

// The slab is centered on z=0 so its top surface lands at +thickness/2,
// where PcbScene draws the copper (layerZ = thickness/2 + …).
let (mut min_z, mut max_z) = (f32::INFINITY, f32::NEG_INFINITY);
for i in (2..pos.len()).step_by(3) {
min_z = min_z.min(pos[i]);
max_z = max_z.max(pos[i]);
}
let half = (thickness / 2.0) as f32;
assert!(
(min_z + half).abs() < 1e-4,
"board bottom should be at -thickness/2 ({}), got {min_z}",
-half
);
assert!(
(max_z - half).abs() < 1e-4,
"board top should be at +thickness/2 ({half}), got {max_z}"
);
}

#[test]
fn evaluate_sketch_revolve() {
let mut doc = Document::new();
Expand Down
39 changes: 36 additions & 3 deletions crates/vcad-kernel-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3644,9 +3644,42 @@ fn evaluate_node(doc: &vcad_ir::Document, node_id: vcad_ir::NodeId) -> Result<So
"ImportedMesh not supported in VCode evaluation - use evaluateDocument",
)),

vcad_ir::CsgOp::PcbBoard { .. } => Err(JsError::new(
"PcbBoard not supported in VCode evaluation - use evaluateDocument",
)),
vcad_ir::CsgOp::PcbBoard { board } => {
// Extrude the board outline into a real FR4 slab, then center it on
// z=0 so the top surface lands at +thickness/2 — where PcbScene
// draws the copper (layerZ = thickness/2 + …) and where the legacy
// PcbBoardMesh sat. The kernel extrudes from z=0 along +z, so we
// shift down by thickness/2. Cutouts (`outline.cutouts`) are a TODO:
// the slab uses the outer outline only, matching the TS path.
let outline = &board.outline;
let verts = &outline.vertices;
if verts.len() < 3 {
return Ok(Solid::empty());
}
let t = outline.thickness;
let segments: Vec<WasmSketchSegment> = verts
.iter()
.enumerate()
.map(|(i, v)| {
let next = &verts[(i + 1) % verts.len()];
WasmSketchSegment::Line {
start: [v.x, v.y],
end: [next.x, next.y],
}
})
.collect();
let profile = WasmSketchProfile {
origin: [0.0, 0.0, 0.0],
x_dir: [1.0, 0.0, 0.0],
y_dir: [0.0, 1.0, 0.0],
segments,
};
let profile_json = serde_json::to_string(&profile).map_err(|e| {
JsError::new(&format!("Board profile serialization failed: {}", e))
})?;
let slab = Solid::extrude(profile_json, vec![0.0, 0.0, t])?;
Ok(slab.translate(0.0, 0.0, -t / 2.0))
}

vcad_ir::CsgOp::EmbroideryPattern { .. } => Err(JsError::new(
"EmbroideryPattern not supported in VCode evaluation - use evaluateDocument",
Expand Down
16 changes: 9 additions & 7 deletions packages/app/src/components/ViewportContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1695,14 +1695,15 @@ export function ViewportContent({
)}

{/* ═══════════════════════════════════════════════════════════════════
PCB EDIT FOCUS: render the board (FR4 slab + copper/footprints/
ratsnest) in the main scene, alongside (not instead of) the
mechanical assembly. PcbScene draws the slab itself — the PcbBoard
kernel op evaluates to Solid.empty(), so there is no duplicate body.
PCB EDIT FOCUS: render the focused board's copper/footprints/ratsnest
in the main scene, alongside (not instead of) the mechanical assembly.
The FR4 slab is suppressed here (showBoard={false}) because the
PcbBoard kernel op now evaluates to a real extruded slab that renders
as an ordinary part via SceneMesh — drawing it again would z-fight.
═══════════════════════════════════════════════════════════════════ */}
{pcbEditFocus && (
<group rotation={[-Math.PI / 2, 0, 0]}>
<PcbScene />
<PcbScene showBoard={false} />
</group>
)}

Expand All @@ -1726,8 +1727,9 @@ export function ViewportContent({
{/* Plane gizmo at origin - inside rotation group so kernel planes display correctly */}
<PlaneGizmo />

{/* PCB board slabs — visible as bodies even when not being edited
(the focused board is drawn by PcbScene instead). */}
{/* Legacy fallback only: real PcbBoard nodes now extrude a kernel
slab that renders as a part. This covers the doc.pcb-only path
where there is no PcbBoard node for the kernel to evaluate. */}
<PcbBoardBodies excludeNodeId={activeBoardNodeId} />

{/* KILL-SWITCH: was `<SilhouetteTarget enabled={silhouetteEnabled}>`
Expand Down
42 changes: 17 additions & 25 deletions packages/app/src/components/electronics/pcb3d/PcbBoardBodies.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,30 @@
/**
* Renders the FR4 slab for every PcbBoard in the document so a board is
* visible as a body in the main scene even when it is NOT being edited.
* Legacy fallback for rendering a board's FR4 slab in the main scene.
*
* The board's geometry comes straight from its PCB data (via the proven
* PcbBoardMesh), independent of the kernel evaluator — the app's live WASM
* evaluator still returns an empty solid for PcbBoard, so without this a board
* would be invisible outside edit focus.
* Normal boards are real `PcbBoard` nodes whose kernel op now evaluates to a
* genuine extruded slab (see crates/vcad-kernel-wasm `evaluate_node` +
* vcad-app `materializer`). Those render as ordinary parts via `SceneMesh`, so
* they are deliberately NOT drawn here — doing so would z-fight / double the
* body.
*
* The board with edit focus is skipped: PcbScene already draws its slab.
* The only case left for this component is the legacy CRDT dual-path: a board
* that lives solely in `doc.pcb` with no `PcbBoard` node id, so the kernel has
* nothing to extrude. The focused board is drawn by `PcbScene`, so we only show
* the slab here when nothing has edit focus.
*/

import { useDocumentStore, getPcbNodeIds, getNodePcb } from "@vcad/core";
import { useDocumentStore, getPcbNodeIds } from "@vcad/core";
import type { NodeId } from "@vcad/ir";
import { PcbBoardMesh } from "./PcbBoardMesh";

export function PcbBoardBodies({ excludeNodeId }: { excludeNodeId: NodeId | null }) {
const doc = useDocumentStore((s) => s.document);
const boardIds = getPcbNodeIds(doc);

if (boardIds.length === 0) {
// CRDT dual-path: the board lives in doc.pcb with no PcbBoard node id.
// When a board has edit focus (excludeNodeId set) PcbScene draws it.
return doc.pcb && excludeNodeId == null ? (
<PcbBoardMesh pcb={doc.pcb} explosion={0} />
) : null;
}
// Real PcbBoard nodes are extruded by the kernel and rendered as parts.
if (getPcbNodeIds(doc).length > 0) return null;

return (
<>
{boardIds.map((id) => {
if (id === excludeNodeId) return null;
const pcb = getNodePcb(doc, id);
return pcb ? <PcbBoardMesh key={String(id)} pcb={pcb} explosion={0} /> : null;
})}
</>
);
// Legacy dual-path: board only in doc.pcb with no node.
return doc.pcb && excludeNodeId == null ? (
<PcbBoardMesh pcb={doc.pcb} explosion={0} />
) : null;
}
19 changes: 19 additions & 0 deletions packages/app/src/data/materials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,8 +405,27 @@ export function getMaterialsByCategory(category: MaterialCategory): MaterialPres
return MATERIAL_PRESETS.filter((m) => m.category === category);
}

/**
* Internal material key for a PCB's FR4 substrate. Assigned to PcbBoard parts
* in the document store; resolved here (not in MATERIAL_PRESETS) so the FR4
* green renders on the board solid without exposing it in the material picker.
*/
export const FR4_MATERIAL_KEY = "__pcb_fr4__";

const FR4_PRESET: MaterialPreset = {
key: FR4_MATERIAL_KEY,
name: "FR4",
category: "composite",
// PcbBoardMesh's FR4 green (#0d5a2d).
color: [0.051, 0.353, 0.176],
metallic: 0,
roughness: 0.8,
density: 1850,
};

/** Find a material by key */
export function getMaterialByKey(key: string): MaterialPreset | undefined {
if (key === FR4_MATERIAL_KEY) return FR4_PRESET;
return MATERIAL_PRESETS.find((m) => m.key === key);
}

Expand Down
10 changes: 8 additions & 2 deletions packages/engine/src/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -887,12 +887,18 @@ function evaluateOp(
return { type: "Line" as const, start: [v.x, v.y], end: [next.x, next.y] };
});
const profile = {
origin: [0, 0, -t / 2],
origin: [0, 0, 0],
x_dir: [1, 0, 0],
y_dir: [0, 1, 0],
segments,
};
return Solid.extrude(JSON.stringify(profile), new Float64Array([0, 0, t]));
// The kernel extrudes from z=0; shift down by t/2 to center the slab on
// z=0 so its top surface lands at +t/2, where the copper (layerZ) sits.
return Solid.extrude(JSON.stringify(profile), new Float64Array([0, 0, t])).translate(
0,
0,
-t / 2,
);
}

case "EmbroideryPattern":
Expand Down
Loading