partforge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # partforge
2
+
3
+ Turn a declarative **part definition** into a full parametric-CAD web app — a 3-D
4
+ viewer, a control panel, geometry workers, and STL / STEP / 3MF export. You write one
5
+ script (geometry build functions + a parameter schema); partforge renders the app.
6
+
7
+ Two geometry backends run in Web Workers: [Manifold](https://github.com/elalish/manifold)
8
+ for fast preview meshes + STL/3MF, and [Replicad](https://replicad.xyz)
9
+ (OpenCASCADE-in-WebAssembly) for exact STEP export. The viewer is [three.js](https://threejs.org).
10
+
11
+ > **Requires a Vite-based app.** partforge is published as plain ESM source and relies
12
+ > on Vite's worker / WASM / CSS import handling.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install partforge
18
+ ```
19
+
20
+ ## Use
21
+
22
+ ```js
23
+ // app.js
24
+ import { mount } from "partforge";
25
+ import part from "./parts/my-part.js";
26
+ mount(part, {
27
+ createWorker: (name) =>
28
+ new Worker(new URL("./part-worker.js", import.meta.url), { type: "module", name }),
29
+ });
30
+
31
+ // part-worker.js
32
+ import { runWorker } from "partforge/worker";
33
+ import part from "./parts/my-part.js";
34
+ runWorker(part);
35
+ ```
36
+
37
+ Test your parts headlessly with `partforge/testing`
38
+ (`createManifoldKernel`, `handle`, `assemblyOverlaps`, `bootOcctKernel`, `meshVolume`, `bboxSize`).
39
+
40
+ **Smoke-test that an app actually boots** (real Chromium, real worker/WASM): `npm run check`
41
+ (or `node scripts/check-app.mjs <entry>.html`) — it loads the app and verifies the kernel
42
+ boots with no errors. Needs Playwright: `npm i -D playwright && npx playwright install chromium`.
43
+
44
+ ## Authoring guide
45
+
46
+ **[docs/AUTHORING-PARTS.md](docs/AUTHORING-PARTS.md)** is the full guide — the part
47
+ contract, the geometry kernel API, the parameter schema, app wiring, testing, and
48
+ gotchas. `src/parts/demo.js` is a minimal worked example; run `npm run dev` and open
49
+ `/demo.html` to see it live.
50
+
51
+ ## License
52
+
53
+ MIT
@@ -0,0 +1,272 @@
1
+ # Authoring parts
2
+
3
+ This app is a small **framework** that turns a declarative **`PartDefinition`** into
4
+ a full parametric-CAD web app: a 3-D viewer, a control panel built from your
5
+ parameter schema, two geometry workers, and STL / STEP / 3MF export. To make a new
6
+ part you write **one script** — geometry build functions + a parameter schema — and
7
+ the framework does the rest.
8
+
9
+ - Reusable framework: `src/framework/` (knows nothing about any specific part).
10
+ - Parts: `src/parts/` — e.g. `drum.js` (full, complex) and `demo.js` (minimal).
11
+ - A part module is **plain data + pure functions**: no DOM, no side effects (it
12
+ loads in both the main thread and a Web Worker).
13
+
14
+ Two worked examples to read alongside this guide: **`src/parts/demo.js`** (a
15
+ parametric spacer — the smallest complete part) and **`src/parts/drum.js`** (the
16
+ capstan drum — every feature in use).
17
+
18
+ ---
19
+
20
+ ## Quickstart
21
+
22
+ 1. Copy `src/parts/demo.js` to `src/parts/<your-part>.js` and edit it.
23
+ 2. Copy the three glue files, repointing them at your part:
24
+ - `demo.html` → `<your-part>.html`
25
+ - `src/app-demo.js` → `src/app-<your-part>.js`
26
+ - `src/demo-worker.js` → `src/<your-part>-worker.js`
27
+ 3. `nvm use && npm install` (Node 24), then `npm run dev` and open
28
+ `http://localhost:5173/<your-part>.html`.
29
+
30
+ That's the whole loop. The chrome (panel, tabs, viewer, export buttons) is shared —
31
+ your HTML is ~30 lines of structural markup and carries no CSS (the framework
32
+ supplies it via `framework/app.css`, imported by `mount`).
33
+
34
+ ---
35
+
36
+ ## The `PartDefinition` contract
37
+
38
+ A part is a default-exported object. Full shape (optional fields marked `?`):
39
+
40
+ ```js
41
+ export default {
42
+ meta: { title, units, background? }, // title string; units e.g. "mm"; background = 0xRRGGBB scene colour
43
+ parameters, // the control-panel schema (array of sections — see below)
44
+ defaults, // flat { paramKey: value } — seeds params + control values
45
+ derive?, // (p) => d optional dependent values computed once per build
46
+ parts: { // named sub-parts; each builds ONE solid
47
+ <name>: {
48
+ label?, // display name (tabs/progress); defaults to the key
49
+ build: (k, p, d, onProgress?) => Solid, // REQUIRED — see kernel API
50
+ place?: (solid, { view, purpose, p, d }) => Solid, // optional reposition; default identity
51
+ views, // string[] — which views show this sub-part
52
+ enabled?: (p) => boolean, // optional — gate a conditional sub-part
53
+ export?: { name }, // filename/object name on export; defaults to the key
54
+ },
55
+ },
56
+ views: { <name>: { label } }, // the view tabs (a view = a set of sub-parts)
57
+ };
58
+ ```
59
+
60
+ **Rules:**
61
+
62
+ - `build(k, p, d, onProgress?)` returns the **canonical** solid (e.g. at the origin).
63
+ It is the only required function per sub-part. `p` is `{ ...defaults, ...userParams }`;
64
+ `d` is `derive(p)` (or `{}`). `onProgress?.("phase")` is optional per-feature progress
65
+ shown during export — call it before expensive steps.
66
+ - `place(solid, ctx)` is an optional escape hatch for parts whose **display pose differs
67
+ from their export pose** (e.g. positioning a sub-part in an assembly). `ctx.purpose` is
68
+ `"display"` or `"export"`; `ctx.view` is the active view. Default is identity, so simple
69
+ parts omit it. **Display placement must not depend on `view`** — display meshes are built
70
+ once per sub-part and cached across views (the viewer re-centres per view).
71
+ - `enabled(p)` gates a conditional sub-part (e.g. only present when a feature is on).
72
+ - A view's sub-parts are derived, never hard-coded: those whose `views` include the view
73
+ and whose `enabled(p)` is true.
74
+
75
+ ---
76
+
77
+ ## Geometry: the kernel / `Solid` API
78
+
79
+ `build` receives a backend-agnostic `kernel` (`k`). It returns and combines `Solid`
80
+ handles. The same code runs on **Manifold** (fast meshes — preview + STL + 3MF) and
81
+ **OCCT/replicad** (exact B-rep — STEP). Contract lives in
82
+ `src/framework/geometry/kernel.js`.
83
+
84
+ **Kernel — make solids:**
85
+
86
+ | Call | Result |
87
+ |---|---|
88
+ | `k.cylinder(rBottom, rTop, h, { center? })` | cylinder/cone along +Z (frustum if radii differ) |
89
+ | `k.box(min, max)` | axis-aligned box from `[x,y,z]` min/max |
90
+ | `k.prism(points2D, h)` | extrude a 2-D polygon (CCW `[[x,y],…]`) from z=0 |
91
+ | `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove) |
92
+ | `k.union(solids[])` | boolean union |
93
+
94
+ 2-D polygon helpers for `prism`: `import { piePolygon, hexPolygon } from "partforge/geometry"`.
95
+ **Import geometry helpers from `partforge/geometry`, never from `partforge`** — the main
96
+ entry pulls in the DOM viewer/controls, and your build functions run in a Web Worker
97
+ (importing the main entry there throws `document is not defined`).
98
+
99
+ **`Solid` — combine / transform / export:**
100
+
101
+ | Call | Result |
102
+ |---|---|
103
+ | `s.cut(tool)` / `s.cutAll(tools[])` | boolean subtract (one / batch) |
104
+ | `s.intersect(other)` | boolean intersection (Manifold; used by collision tests) |
105
+ | `s.translate([x,y,z])` | move |
106
+ | `s.rotate(deg, center, axis)` | rotate `deg` about `axis` through `center` |
107
+ | `s.mirror("XY"\|"XZ"\|"YZ")` | mirror across a plane |
108
+ | `s.volume()` | volume in mm³ (Manifold) |
109
+ | `s.toMesh({ quality })` / `s.toSTL({ quality })` / `s.toIndexedMesh()` | meshes / STL / indexed mesh (3MF) — the framework calls these |
110
+ | `k.toSTEP(named[])` | STEP bytes (OCCT only) — the framework calls this |
111
+
112
+ You normally only call the *make/combine/transform* ops; the framework handles
113
+ `toMesh`/`toSTL`/`toIndexedMesh`/`toSTEP`. Units are millimetres.
114
+
115
+ ---
116
+
117
+ ## Parameters: the control-panel schema
118
+
119
+ `parameters` is an **array of sections**; the framework builds the panel from it and
120
+ binds each control to a key in `defaults`. Two section kinds:
121
+
122
+ **Preset + sliders section:**
123
+
124
+ ```js
125
+ {
126
+ id: "body",
127
+ title: "Body",
128
+ presets: { M3: { od: 8, bore: 3.4, h: 10 }, M5: { od: 12, bore: 5.4, h: 16 } }, // name → param overrides
129
+ advanced: [ // sliders revealed under "Advanced"
130
+ { key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5 },
131
+ { key: "bore", label: "Bore", unit: "mm", min: 1, max: 30, step: 0.1, control: "number" },
132
+ ],
133
+ }
134
+ ```
135
+
136
+ Each slider/feature control shows an **editable number box** beside it — drag the
137
+ slider or type an exact value (finer than `step` is allowed; typed values clamp to
138
+ `[min, max]`). Optional `control` per parameter: omit it (or `"slider"`) for a slider
139
+ + box; `"number"` for a box only (no slider — handy for precise or wide-range values).
140
+
141
+ **Feature-toggle section** (checkbox enables a feature + reveals its sliders; `0` = off):
142
+
143
+ ```js
144
+ {
145
+ id: "flange",
146
+ title: "Flange",
147
+ features: [
148
+ { label: "Base flange", key: "flange_d", on: 16, // checked → set key to `on`; unchecked → 0
149
+ sliders: [{ key: "flange_d", label: "Flange diameter", unit: "mm", min: 8, max: 50, step: 1 }] },
150
+ ],
151
+ }
152
+ ```
153
+
154
+ Every `key` used must exist in `defaults`. (The drum's schema is exported as `SECTIONS`
155
+ in `src/parts/drum/params.js` if you want a large reference.)
156
+
157
+ ---
158
+
159
+ ## Wiring a part into a runnable app
160
+
161
+ Three tiny glue files per part (copy from the demo). The worker statically imports
162
+ your part, so it can't be injected at runtime — hence the per-part entries.
163
+
164
+ `src/app-<part>.js`:
165
+
166
+ ```js
167
+ import part from "./parts/<part>.js";
168
+ import { mount } from "partforge";
169
+ mount(part, {
170
+ // NB: the `new Worker(new URL(...))` MUST stay inline here or Vite won't bundle the worker.
171
+ createWorker: (name) => new Worker(new URL("./<part>-worker.js", import.meta.url), { type: "module", name }),
172
+ });
173
+ ```
174
+
175
+ `src/<part>-worker.js`:
176
+
177
+ ```js
178
+ import part from "./parts/<part>.js";
179
+ import { runWorker } from "partforge/worker";
180
+ runWorker(part);
181
+ ```
182
+
183
+ `<part>.html` — structural markup only (no CSS; `mount` pulls in partforge's
184
+ stylesheet). `mount` looks up these element IDs:
185
+
186
+ | ID | Purpose |
187
+ |---|---|
188
+ | `#app` | viewer canvas mounts here |
189
+ | `#controls` | control panel is built into this |
190
+ | `#part` | view-tab bar: one `<button data-part="<view>">` per view, the active one with `class="on"` |
191
+ | `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
192
+ | `#status`, `#busy`, `#phase` | status line + busy overlay |
193
+ | `#viewbar` with `#pause` / `#reframe` / `#theme` | optional viewer controls (omit any you don't want) |
194
+
195
+ Copy `demo.html` and change the title, the `#part` buttons (one per view), the panel
196
+ heading, and the `<script src>`. Two workers are spawned from your one worker entry
197
+ (`name` = `"manifold"` for preview/STL/3MF, `"occt"` for STEP — handled for you).
198
+
199
+ > Production deploy builds `index.html` only. Extra `*.html` files are **dev-only**
200
+ > (Vite serves any root HTML in `npm run dev`). To also ship one, add it to
201
+ > `build.rollupOptions.input` in `vite.config.js`.
202
+
203
+ ### Developing against a local (linked) partforge
204
+
205
+ A normal `npm install partforge` needs no extra config. But if you `npm link` a local
206
+ partforge checkout (to co-develop the framework), it lives **outside your project root**,
207
+ so Vite refuses to serve its files — including the Manifold/OCCT WASM, which fails with a
208
+ 403 and the kernel never boots. Allow-list it in your `vite.config.js`:
209
+
210
+ ```js
211
+ server: { fs: { allow: ["./", "../partforge"] } } // path to your linked checkout
212
+ ```
213
+
214
+ (Geometry/asset imports are already worker-safe; this is purely Vite's dev-server file
215
+ access. It's harmless to leave in when partforge is a normal install.)
216
+
217
+ ---
218
+
219
+ ## Testing a part
220
+
221
+ Tests run under **Node 24** (`nvm use` first; the default shell Node is too old) via
222
+ `npx vitest run`. Build geometry directly off your part with a Manifold kernel:
223
+
224
+ ```js
225
+ import Module from "manifold-3d";
226
+ import { createManifoldKernel } from "../../src/framework/geometry/manifold-backend.js";
227
+ import part from "../../src/parts/<part>.js";
228
+
229
+ const w = await Module(); w.setup();
230
+ const k = createManifoldKernel(w, { quality: "preview" });
231
+ const solid = part.parts.<name>.build(k, part.defaults, part.derive?.(part.defaults) ?? {});
232
+ expect(solid.toMesh().triangles).toBeGreaterThan(0);
233
+ ```
234
+
235
+ **Collision check (assemblies).** `assemblyOverlaps` builds every sub-part of a view in
236
+ its assembly pose and returns any interpenetrating pair with its overlap volume —
237
+ parts meant to fit (e.g. seated in a pocket) read ~0 and don't trip it:
238
+
239
+ ```js
240
+ import { assemblyOverlaps } from "../../src/framework/assembly.js";
241
+ test("assembly has no interpenetrating parts", () => {
242
+ expect(assemblyOverlaps(k, part, "<view>", {})).toEqual([]); // [{a,b,volume}] on failure
243
+ });
244
+ ```
245
+
246
+ See `test/parts/drum-assembly.test.js` for a real example, and `test/framework/jobs.test.js`
247
+ for exporting through the job loop.
248
+
249
+ **OCCT tests** (STEP / B-rep parity) boot via `bootOcctKernel()` in `test/occt-kernel.js`.
250
+ **OCCT and Manifold must not boot in the same process** — keep OCCT-booting tests in their
251
+ own files (vitest isolates files). For Manifold↔OCCT volume parity, see `test/parity.test.js`
252
+ + the `test/fixtures/occt-volumes.json` fixture (regenerate with
253
+ `node scripts/gen-occt-fixtures.mjs` after a geometry change).
254
+
255
+ ---
256
+
257
+ ## Conventions & gotchas
258
+
259
+ - **replicad (OCCT) transforms consume their input.** `s.translate/.rotate/.mirror/.cut`
260
+ delete the operand and return a new solid; never reuse a solid after transforming it.
261
+ The framework rebuilds each sub-part fresh per job and applies `place` once, which
262
+ avoids this — follow the same pattern in your own code.
263
+ - **Part modules are DOM-free and side-effect-free** — they import into both the main
264
+ thread (schema → controls) and the worker (build → kernel).
265
+ - **Units are millimetres** throughout.
266
+ - **Preview vs print quality.** Manifold bakes segment counts in at primitive creation,
267
+ so the export path uses a separate high-res "print" kernel — your `build` is quality-
268
+ agnostic; just build the geometry.
269
+ - **Display placement is view-independent** (so meshes cache across views); only
270
+ `place(..., { purpose: "export" })` may depend on `view`.
271
+ - Keep geometry backend-agnostic (kernel calls only) so it works in both backends; only
272
+ STEP requires OCCT.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "partforge",
3
+ "version": "0.1.0",
4
+ "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=24"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "docs/AUTHORING-PARTS.md",
13
+ "README.md"
14
+ ],
15
+ "exports": {
16
+ ".": "./src/index.js",
17
+ "./worker": "./src/framework/worker.js",
18
+ "./geometry": "./src/framework/geometry/polygon.js",
19
+ "./testing": "./src/testing.js"
20
+ },
21
+ "scripts": {
22
+ "dev": "vite",
23
+ "build": "vite build",
24
+ "preview": "vite preview",
25
+ "test": "vitest run",
26
+ "test:watch": "vitest",
27
+ "check": "node scripts/check-app.mjs"
28
+ },
29
+ "dependencies": {
30
+ "fflate": "^0.8.3",
31
+ "manifold-3d": "^3.5.1",
32
+ "replicad": "^0.23.1",
33
+ "replicad-opencascadejs": "^0.23.0",
34
+ "three": "^0.184.0"
35
+ },
36
+ "devDependencies": {
37
+ "playwright": "^1.49.0",
38
+ "vite": "^8.0.16",
39
+ "vitest": "^4.1.9"
40
+ }
41
+ }
@@ -0,0 +1,10 @@
1
+ import demoPart from "./parts/demo.js";
2
+ import { mount } from "./framework/index.js";
3
+
4
+ // Dev-only example app for the demo part (a parametric spacer). Identical wiring to
5
+ // app.js — the only thing that differs per part is which definition you import and
6
+ // which worker entry you point at. `npm run dev`, then open /demo.html.
7
+ mount(demoPart, {
8
+ createWorker: (name) =>
9
+ new Worker(new URL("./demo-worker.js", import.meta.url), { type: "module", name }),
10
+ });
@@ -0,0 +1,3 @@
1
+ import part from "./parts/demo.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
@@ -0,0 +1,141 @@
1
+ /* Shared chrome styles for any part app (panel, tabs, viewbar, download row,
2
+ busy overlay) + the light/dark palettes. Imported by framework/mount.js, so
3
+ every part-app gets it; each part's HTML only carries structural markup.
4
+ See docs/AUTHORING-PARTS.md. */
5
+
6
+ :root {
7
+ color-scheme: dark;
8
+ --bg: #15181d; --surface: #1f242c; --surface-2: #20262e; --border: #2c333d;
9
+ --text: #d6dbe2; --text-strong: #e7ebf1; --text-2: #cdd4dd;
10
+ --muted: #7d8794; --muted-2: #aab2bd; --status: #8b94a0; --hint: #6b7480;
11
+ --accent: #3b82f6; --on-accent: #fff; --input-bg: #161a20; --err: #f8746c;
12
+ }
13
+ :root[data-theme="light"] {
14
+ color-scheme: light;
15
+ --bg: #eef1f5; --surface: #ffffff; --surface-2: #f4f6f9; --border: #d4dae2;
16
+ --text: #2b333d; --text-strong: #1a2129; --text-2: #3a434e;
17
+ --muted: #6b7480; --muted-2: #59636f; --status: #6b7480; --hint: #8a93a0;
18
+ --accent: #3b82f6; --on-accent: #fff; --input-bg: #ffffff; --err: #d8453d;
19
+ }
20
+ * { box-sizing: border-box; }
21
+ html, body { margin: 0; height: 100%; overflow: hidden;
22
+ font: 13px/1.4 -apple-system, system-ui, sans-serif; }
23
+ #app { position: fixed; inset: 0; background: var(--bg); }
24
+ canvas { display: block; }
25
+
26
+ #panel {
27
+ position: fixed; top: 12px; left: 12px; width: 252px;
28
+ max-height: calc(100vh - 24px); overflow-y: auto; z-index: 10;
29
+ background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
30
+ padding: 14px; color: var(--text); box-shadow: 0 6px 24px rgba(0,0,0,.35);
31
+ }
32
+ #panel h1 { font-size: 14px; margin: 0 0 2px; }
33
+ #panel .sub { color: var(--muted); font-size: 11px; margin: 0 0 12px; }
34
+
35
+ .seg { display: flex; gap: 4px; margin-bottom: 12px; }
36
+ .seg button {
37
+ flex: 1; padding: 7px 0; border: 1px solid var(--border); border-radius: 7px;
38
+ background: var(--surface-2); color: var(--muted-2); cursor: pointer; font-size: 12px;
39
+ }
40
+ .seg button.on { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
41
+
42
+ .section {
43
+ border: 1px solid var(--border); border-radius: 8px; padding: 9px 10px;
44
+ margin-bottom: 8px; background: var(--surface-2);
45
+ }
46
+ .sec-title { font-weight: 600; color: var(--text-strong); margin-bottom: 7px; }
47
+ select.preset {
48
+ width: 100%; background: var(--input-bg); color: var(--text-2);
49
+ border: 1px solid var(--border); border-radius: 6px; padding: 5px 7px; font-size: 11px;
50
+ }
51
+ .feat { display: flex; align-items: center; gap: 8px; margin: 6px 0;
52
+ color: var(--text-2); cursor: pointer; }
53
+ .feat input { cursor: pointer; }
54
+ .feat-group { margin: 2px 0 8px; padding-left: 9px; border-left: 2px solid var(--border); }
55
+ .feat-group.hidden { display: none; }
56
+ .adv-toggle {
57
+ margin-top: 8px; padding: 3px 0; width: 100%; border: 0; border-radius: 5px;
58
+ background: transparent; color: var(--muted); cursor: pointer; font-size: 11px;
59
+ text-align: left;
60
+ }
61
+ .adv-toggle:hover { color: var(--muted-2); }
62
+ .adv.hidden { display: none; }
63
+ .adv { margin-top: 4px; }
64
+
65
+ .slider { margin: 7px 0; }
66
+ .row { display: flex; justify-content: space-between; align-items: center;
67
+ margin: 0 0 3px; gap: 8px; }
68
+ .row label { color: var(--muted-2); }
69
+ .row .val { display: flex; align-items: baseline; gap: 4px; flex: none; }
70
+ .row .num {
71
+ width: 52px; text-align: right; font: inherit; font-variant-numeric: tabular-nums;
72
+ background: var(--input-bg); color: var(--text-strong);
73
+ border: 1px solid var(--border); border-radius: 5px; padding: 2px 5px;
74
+ }
75
+ .row .num:focus { outline: none; border-color: var(--accent); }
76
+ .row .num::-webkit-outer-spin-button, .row .num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
77
+ .row .num { -moz-appearance: textfield; }
78
+ .row .unit { color: var(--muted); font-size: 11px; }
79
+ input[type="range"] { width: 100%; }
80
+
81
+ button.action {
82
+ width: 100%; margin-top: 8px; padding: 9px; border: 0; border-radius: 7px;
83
+ background: var(--accent); color: var(--on-accent); font-weight: 600; cursor: pointer;
84
+ }
85
+ button.ghost { background: var(--border); color: var(--text-2); font-weight: 500; }
86
+ button.action:disabled { opacity: .5; cursor: default; }
87
+
88
+ .dl { margin-top: 12px; }
89
+ .dl-head { font-weight: 600; color: var(--text-strong); margin-bottom: 6px; }
90
+ .dl-row { display: flex; gap: 6px; }
91
+ .dl-row button {
92
+ flex: 1; padding: 8px 0; border: 1px solid var(--border); border-radius: 7px;
93
+ background: var(--surface-2); color: var(--text-2); font-weight: 600;
94
+ font-size: 12px; cursor: pointer;
95
+ }
96
+ .dl-row button:hover:not(:disabled) { border-color: var(--accent); color: var(--text-strong); }
97
+ .dl-row button:disabled { opacity: .45; cursor: default; }
98
+ #status { margin-top: 10px; min-height: 16px; color: var(--status); font-size: 11px; }
99
+ #status.err { color: var(--err); }
100
+ .hint { margin-top: 8px; color: var(--hint); font-size: 10px; }
101
+
102
+ /* part tabs, floated top-centre over the viewport */
103
+ #topbar {
104
+ position: fixed; top: 12px; left: 50%; transform: translateX(-50%);
105
+ z-index: 15;
106
+ }
107
+ #topbar .seg {
108
+ margin: 0; padding: 4px; background: var(--surface); border: 1px solid var(--border);
109
+ border-radius: 9px; box-shadow: 0 6px 24px rgba(0,0,0,.35);
110
+ }
111
+ #topbar .seg button { min-width: 70px; padding: 7px 10px; }
112
+
113
+ /* viewer controls, top-right */
114
+ #viewbar {
115
+ position: fixed; top: 12px; right: 12px; z-index: 15;
116
+ display: flex; gap: 4px; padding: 4px;
117
+ background: var(--surface); border: 1px solid var(--border);
118
+ border-radius: 9px; box-shadow: 0 6px 24px rgba(0,0,0,.35);
119
+ }
120
+ #viewbar button {
121
+ width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 7px;
122
+ background: var(--surface-2); color: var(--muted-2); cursor: pointer;
123
+ font-size: 15px; line-height: 1;
124
+ display: flex; align-items: center; justify-content: center;
125
+ }
126
+ #viewbar button:hover { color: var(--text); }
127
+ #viewbar button.on { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
128
+
129
+ #busy {
130
+ position: fixed; inset: 0; z-index: 20; pointer-events: none;
131
+ display: none; flex-direction: column; align-items: center;
132
+ justify-content: center; gap: 14px;
133
+ }
134
+ #busy.show { display: flex; }
135
+ #busy .ring {
136
+ width: 46px; height: 46px; border-radius: 50%;
137
+ border: 4px solid var(--border); border-top-color: var(--accent);
138
+ animation: spin 0.9s linear infinite;
139
+ }
140
+ #busy .phase { color: var(--text); font-size: 13px; text-shadow: 0 1px 6px rgba(0,0,0,.6); }
141
+ @keyframes spin { to { transform: rotate(360deg); } }
@@ -0,0 +1,29 @@
1
+ import { viewSubParts } from "./jobs.js";
2
+
3
+ // Collision check for an assembled view: build each sub-part in its display
4
+ // (assembly) pose and return the pairs whose solid-intersection volume exceeds
5
+ // `tolerance` (mm³) — i.e. parts that interpenetrate rather than merely touch.
6
+ // Parts meant to fit together (e.g. a block seated in a pocket void) read ~0 and
7
+ // don't trip it. Manifold-only (needs Solid.intersect + Solid.volume); meant for
8
+ // part tests so an author/LLM editing a part sees collisions fail.
9
+ // → [{ a, b, volume }] for each offending pair (empty = no collisions)
10
+ export function assemblyOverlaps(kernel, part, view, params = {}, { tolerance = 1 } = {}) {
11
+ const p = { ...part.defaults, ...params };
12
+ const d = part.derive ? part.derive(p) : {};
13
+ const posed = viewSubParts(part, view, p).map((name) => {
14
+ const sp = part.parts[name];
15
+ let solid = sp.build(kernel, p, d);
16
+ if (sp.place) solid = sp.place(solid, { view, purpose: "display", p, d });
17
+ return { name, solid };
18
+ });
19
+
20
+ const overlaps = [];
21
+ for (let i = 0; i < posed.length; i++) {
22
+ for (let j = i + 1; j < posed.length; j++) {
23
+ const volume = posed[i].solid.intersect(posed[j].solid).volume();
24
+ if (volume > tolerance) overlaps.push({ a: posed[i].name, b: posed[j].name, volume });
25
+ }
26
+ }
27
+ kernel.cleanup?.(); // free the per-check WASM objects
28
+ return overlaps;
29
+ }