For developers extending Case Maker. Audience: TypeScript + React + a passing acquaintance with mesh CSG.
+---------------------------------------------------------------+
| UI Layer (React + R3F) |
| Sidebar (params/ports/joints) | Toolbar | <Canvas> (Z-up) |
| │ │ │ |
| └─────────────────┴──────────┴── selectors / actions ──▶|
+----------------------------┬----------------------------------+
▼
| Store (Zustand) — single source of truth |
| project (parametric model) | meshCache | jobState | history |
+----------------------------┬----------------------------------+
│ subscribe(project) → debounce(200ms)
▼
| Engine (main thread, thin) |
| ProjectCompiler → BuildPlan → JobScheduler → SceneSync |
+----------------------------┬----------------------------------+
│ Comlink (transferables)
▼
| Geometry Worker (Manifold WASM) — cancellable via gen counter |
| Export Worker (STL bin / ASCII / 3MF zip) — separate, stateless|
+---------------------------------------------------------------+
Key rule: the parametric Project is the single source of truth. The rendered scene and exported meshes are derived state, recomputed in the geometry worker. Never mutate a mesh — always edit the Project and let the worker rebuild.
- Units: millimeters everywhere.
- Up axis: Z.
THREE.Object3D.DEFAULT_UP.set(0, 0, 1)is enforced on app boot. - Origins:
- World origin (0, 0, 0) is the bottom-front-left corner of the case.
- PCB origin in PCB-local frame is the bottom-left corner of the board (X+ along long edge, Y+ along short edge, Z+ out of the board face).
- PCB-to-world transform:
(wallThickness + internalClearance, wallThickness + internalClearance, floorThickness).
| Module | Exports | Purpose |
|---|---|---|
ProjectCompiler.ts |
compileProject(project) → BuildPlan |
Top-level: turn a Project into a serializable op tree |
buildPlan.ts |
BuildOp, cube, cylinder, translate, rotate, scale, mesh, union, difference, collectMeshTransferables |
Op constructors + transferable-buffer enumeration for Comlink |
caseShell.ts |
buildOuterShell, computeShellDims |
Outer hollow box + cavity dims |
bosses.ts |
computeBossPlacements, buildBossesUnion, resolveInsertSpec, getScrewClearanceDiameter |
Mounting boss geometry + insert variant resolution |
lid.ts |
buildLid, buildFlatLid, buildSnapFitLid, buildSlidingLid, buildScrewDownLid, computeLidDims |
All four joint variants |
slidingRails.ts |
buildSlidingRails |
Two horizontal rails inside the case (sliding joint only) |
ports.ts |
buildPortCutoutOp, buildPortCutoutsForProject |
Per-port wall-piercing cutouts |
portFactory.ts |
autoPortsForBoard |
Populate project.ports from a board's components array |
ventilation.ts |
buildVentilationCutouts |
Slot or hex pattern through the +y wall |
externalAssets.ts |
buildExternalAssetOps |
Convert imported STL/3MF assets into mesh BuildOps with transforms |
ManifoldRuntime.ts exposes buildOp(op, check) which executes a BuildOp against the Manifold WASM kernel. The check callback is called between ops and throws CancelledError when the build's generation has been superseded.
Note: Mesh ops dedupe vertices at 1e-5 mm precision before constructing the Manifold. Without dedup, per-triangle STL vertex copies fail Manifold's 2-manifold check.
| File | Output |
|---|---|
stlBinary.ts |
buildBinaryStl(meshes) → ArrayBuffer |
stlAscii.ts |
buildAsciiStl(meshes, solidName) → string |
threeMf.ts |
buildThreeMf(meshes) → ArrayBuffer (fflate-zipped) |
| Store | Responsibility |
|---|---|
projectStore.ts |
The parametric Project. Wrapped in zundo temporal for undo/redo. |
jobStore.ts |
Latest build status, mesh nodes, mesh stats, last error, last diag. |
viewportStore.ts |
UI-only viewport state (showLid/Grid, selectedPortId). |
settingsStore.ts |
App settings (port, bindToAll). localStorage-persisted. |
Activated by VITE_E2E=1 or MODE=test. The full surface is documented in src/testing/windowApi.ts. Highlights:
getProject(),setProject(p),patchCase(patch),loadBuiltinBoard(id)getMeshStats(node),getSceneGraph(),getLastDiag(),getJobError()triggerExport(format)— triggers a download; intercept withpage.waitForEvent('download')serializeProject()/loadSerializedProject(json)for save/load round-tripsundo()/redo(),cloneBoardForEditing(),addMountingHole(),patchPort(...)getSettings()/setPortSetting(port)/selectPort(id)
Note: Worker
console.logdoes not reliably reach Playwright's page console. For worker-side observability usegetLastDiag()andgetJobError()instead.
Goal: ship a new built-in board (e.g. Teensy 4.1) so users can pick it from the dropdown.
- Create
casemaker-app/src/library/boards/teensy-41.json. Copy the structure fromrpi-4b.jsonand fill in:pcb.sizefrom the manufacturer's mechanical drawing.mountingHoles[]— each{ id, x, y, diameter }in PCB-local mm.components[]— each port withkind, PCB-localposition, AABBsize,facingdirection (+x/-x/+y/-y/+z).defaultStandoffHeight,recommendedZClearance.source— mandatory for built-ins — link to the datasheet PDF.builtin: true.
- Register the JSON in
casemaker-app/src/library/index.ts:import teensy41Raw from './boards/teensy-41.json'; const validated: BoardProfile[] = [..., teensy41Raw].map(...)
- Test:
npm testruns the zod schema validation. The strict schema rejects built-ins missingsource. Add an E2E entry intests/e2e/board-swap.spec.tsto confirm the board loads and produces a non-empty mesh. - Document the addition in
CHANGELOG.mdunder the next phase.
- Add a case to
JointTypeinsrc/types/case.ts. - Add a
case 'your-joint':branch inbuildLid(src/engine/compiler/lid.ts). - (Optional) Add additive geometry in
ProjectCompiler.tsif the joint needs shell-side features (sliding rails, screw posts, etc.). - Add a radio entry in
JOINT_OPTIONSinsrc/components/panels/CasePanel.tsx. - Extend
tests/unit/joints.spec.tswith an op-tree-shape assertion andtests/e2e/joints.spec.tswith a bbox/triangle delta assertion.
The compiler produces a top-level BuildPlan = { nodes: [{ id, op }] }. Each BuildOp is one of:
| Kind | Shape |
|---|---|
cube |
{ size: [x,y,z], center? } |
cylinder |
{ height, radiusLow, radiusHigh?, segments?, center? } |
translate |
{ offset: [x,y,z], child } |
rotate |
{ degrees: [x,y,z], child } |
scale |
{ factor, child } |
mesh |
{ positions: Float32Array, indices: Uint32Array } |
union/difference/intersection |
{ children: [...] } |
mesh ops carry transferable typed-array buffers; the worker client uses collectMeshTransferables to enumerate them so Comlink can pass them zero-copy.
Three workflows in .github/workflows/:
ci.yml— lint + typecheck + Vitest + production build, matrix Node 20/22 on ubuntu-latest. Triggered on every PR + push to main.playwright.yml— E2E suite with chromium + SwiftShader for deterministic WebGL. Uploads HTML report on failure.windows-installer.yml— runs onwindows-latest, installs Rust, generates platform icons, builds Tauri MSI + NSIS bundles, uploads as artifacts.
- Manifold ops are synchronous inside WASM. Cancellation via the generation counter only fires between ops. A single very large boolean still blocks until done.
- Worker count: one geometry worker, one export worker. A pool wouldn't help since rebuilds are latest-wins.
- Bundle: main app code is ~53 KB; Three.js is the bulk at 896 KB (gzip 239 KB). Code-splitting is configured in
vite.config.tsbuild.rolldownOptions.output.manualChunks. - Debounce: slider drags are 200 ms trailing-debounced. Discrete actions (button clicks, board swap, port drop) dispatch immediately.