partforge 0.6.1 → 0.7.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.
Files changed (41) hide show
  1. package/README.md +61 -15
  2. package/bin/cli.js +82 -58
  3. package/docs/AUTHORING-PARTS.md +48 -23
  4. package/package.json +1 -1
  5. package/src/framework/app.css +26 -0
  6. package/src/framework/assembly.js +6 -9
  7. package/src/framework/download.js +23 -0
  8. package/src/framework/geometry/feature-attribution.js +102 -0
  9. package/src/framework/geometry/kernel-front.js +31 -0
  10. package/src/framework/geometry/kernel.js +53 -10
  11. package/src/framework/geometry/manifold-backend.js +45 -24
  12. package/src/framework/geometry/occt-backend.js +47 -96
  13. package/src/framework/geometry/occt-repair.js +83 -0
  14. package/src/framework/geometry/probe.js +37 -30
  15. package/src/framework/geometry/solid-sugar.js +32 -5
  16. package/src/framework/geometry-service.js +4 -6
  17. package/src/framework/jobs.js +40 -18
  18. package/src/framework/mesh-cache.js +41 -0
  19. package/src/framework/mount.js +103 -240
  20. package/src/framework/param-deps.js +9 -18
  21. package/src/framework/pick-request/server.js +10 -0
  22. package/src/framework/regen-loop.js +45 -0
  23. package/src/framework/selection/format.js +2 -6
  24. package/src/framework/selection/hover.js +128 -0
  25. package/src/framework/selection/index.js +3 -0
  26. package/src/framework/selection/pick-toggle.js +34 -0
  27. package/src/framework/selection/pick.js +7 -30
  28. package/src/framework/selection/raycast.js +43 -0
  29. package/src/framework/selection/resolve.js +3 -8
  30. package/src/framework/status-ui.js +18 -0
  31. package/src/framework/view-state.js +11 -1
  32. package/src/framework/view-tabs.js +33 -0
  33. package/src/framework/viewer-controls.js +48 -0
  34. package/src/framework/viewer.js +9 -9
  35. package/src/framework/worker.js +12 -20
  36. package/src/parts/filleted-box.js +1 -1
  37. package/src/parts/planter.js +4 -3
  38. package/src/testing/build.js +3 -6
  39. package/src/testing/manifold.js +11 -0
  40. package/src/testing.js +1 -0
  41. package/src/framework/geometry/fuzzy-cut.js +0 -32
package/README.md CHANGED
@@ -1,15 +1,62 @@
1
1
  # partforge
2
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.
3
+ **AI-built parametric CAD for 3D printing.** Describe a part to an AI agent and get a
4
+ *forge* a self-contained web app for that one part: a 3-D viewer, a control panel with
5
+ just the parameters that matter, and STL / STEP / 3MF export. Share the forge and anyone
6
+ can dial in their own version and print it.
6
7
 
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).
8
+ A forge isn't a frozen STL. It's open-source code that renders and regenerates the model
9
+ live, so the part stays **editable** — resize it for a different bearing, screw, or motor
10
+ without relearning a heavy CAD tool and anyone (or their own agent) can read the source
11
+ and extend it.
10
12
 
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
+ partforge is the engine behind the forge: a small npm framework that an LLM tuned for tool
14
+ use can drive to author, test, and measure a part, then ship it as a browser app.
15
+
16
+ ## See it
17
+
18
+ - **Live showcase** — https://scottsykora.github.io/partforge/ — example forges (Faceted
19
+ Planter, Spacer, Filleted Box) you can open, adjust, and export.
20
+ - **A real forge** — https://scottsykora.github.io/Drum-Machine/ — a parametric capstan
21
+ drum built with partforge for a robotics project.
22
+
23
+ ## Build your own forge
24
+
25
+ You don't write CAD by hand. Point a tool-using AI agent (Claude Code, or the Claude /
26
+ ChatGPT desktop apps with file access) at this repo and describe what you want:
27
+
28
+ > Using the partforge framework at https://github.com/scottsykora/partforge, build me a
29
+ > _\<your part\>_ — _\<the dimensions, fits, and features that matter\>_.
30
+
31
+ Expect a few turns. The first attempt is often rough, but you refine it in plain language
32
+ — "make the bore fit an M3", "add a 2 mm fillet", "thinner walls", "taller by 10" — until
33
+ the part is right. You end up with a forge you can host anywhere and hand to anyone.
34
+
35
+ ## How it works
36
+
37
+ You — or an agent — write one **part definition**: geometry *build functions* plus a
38
+ *parameter schema*. partforge renders everything else: the 3-D viewer, the control panel,
39
+ the geometry workers, and the export buttons. Each control can carry a description, a
40
+ preset, or be hidden, so the interface stays simple while the part stays deeply adjustable.
41
+
42
+ Two geometry backends run in Web Workers, and partforge routes each part to whichever it
43
+ needs:
44
+
45
+ - **[Manifold](https://github.com/elalish/manifold)** — fast preview meshes and STL / 3MF.
46
+ - **[Replicad](https://replicad.xyz)** (OpenCASCADE-in-WebAssembly) — exact B-rep for STEP
47
+ export and native fillet / chamfer.
48
+
49
+ The viewer is [three.js](https://threejs.org).
50
+
51
+ Because a part is just code, an agent can build it **and check its own work**: the
52
+ `partforge/testing` helpers measure volume and bounding box, probe geometry, detect
53
+ self-overlap between sub-parts, and render the model so a multimodal agent can *see* what
54
+ it made. A part can also declare a `verify` block — a manufacturing (DFM) profile plus
55
+ design-intent assertions — that flags problems like a too-thin wall or a part that won't
56
+ fit the print bed before anyone hits export.
57
+
58
+ > **Requires a Vite-based app.** partforge is published as plain ESM source and relies on
59
+ > Vite's worker / WASM / CSS import handling.
13
60
 
14
61
  ## Install
15
62
 
@@ -35,7 +82,7 @@ runWorker(part);
35
82
  ```
36
83
 
37
84
  Test your parts headlessly with `partforge/testing`
38
- (`createManifoldKernel`, `handle`, `assemblyOverlaps`, `bootOcctKernel`, `meshVolume`, `bboxSize`).
85
+ (`bootManifoldKernel`, `bootOcctKernel`, `assemblyOverlaps`, `measure`, `verify`, `meshVolume`, `bboxSize`).
39
86
 
40
87
  **Smoke-test that an app actually boots** (real Chromium, real worker/WASM): `npm run check`
41
88
  (or `node scripts/check-app.mjs <entry>.html`) — it loads the app and verifies the kernel
@@ -44,13 +91,12 @@ boots with no errors. Needs Playwright: `npm i -D playwright && npx playwright i
44
91
  ## Authoring guide
45
92
 
46
93
  **[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. See **Designing the control panel** in that guide for how to write descriptions,
49
- hide internal params, and keep the interface simple while staying deeply adjustable.
50
- `src/parts/demo.js` is a minimal worked example.
94
+ contract, the geometry kernel API, the parameter schema, app wiring, testing, and gotchas.
95
+ See **Designing the control panel** in that guide for how to write descriptions, hide
96
+ internal params, and keep the interface simple while staying deeply adjustable.
97
+ `src/parts/demo.js` is a minimal worked example; `src/parts/planter.js` is a richer one
98
+ (facets, taper, twist, even walls, an optional feature, and a `verify` block).
51
99
 
52
- **Live showcase:** https://scottsykora.github.io/partforge/ — the landing gallery links
53
- three live example apps (Faceted Planter, Spacer, Filleted Box), auto-deployed from `main`.
54
100
  Locally, `npm run dev` then open `/demo.html`, `/planter.html`, or `/filleted-box.html`.
55
101
 
56
102
  - **Agent clarification (`request-a-pick`):** an external tool can ask the user to click
package/bin/cli.js CHANGED
@@ -1,93 +1,113 @@
1
1
  #!/usr/bin/env node
2
+ // The partforge CLI — also the agent-facing surface (SKILL.md points here). One
3
+ // async function per command, dispatched from the table at the bottom; flags are
4
+ // parsed strictly per command with util.parseArgs, so a typo'd flag or a missing
5
+ // option value fails loudly instead of being silently ignored.
6
+ import { parseArgs } from "node:util";
2
7
  import { pathToFileURL } from "node:url";
3
- import { resolve } from "node:path";
4
- import { writeFileSync } from "node:fs";
5
- import Module from "manifold-3d";
6
- import { createManifoldKernel } from "../src/framework/geometry/manifold-backend.js";
8
+ import { resolve, dirname } from "node:path";
9
+ import { writeFileSync, mkdirSync } from "node:fs";
7
10
  import { detectBackend } from "../src/framework/geometry/probe.js";
8
11
  import { bootOcctKernel } from "../src/testing/occt.js";
12
+ import { bootManifoldKernel } from "../src/testing/manifold.js";
9
13
  import { measure } from "../src/testing/measure.js";
10
14
  import { verify } from "../src/testing/verify.js";
11
15
  import { renderViews } from "../src/testing/render.js";
12
16
  import { createPickServer, requestPicks, formatPickResult } from "../src/framework/pick-request/server.js";
13
17
 
14
18
  const die = (msg) => { console.error(msg); process.exit(1); };
15
- const slug = (s) => String(s).toLowerCase().replace(/\s+/g, "-");
16
-
17
- const [, , cmd, ...args] = process.argv;
18
- const flags = {};
19
- const positional = [];
20
- for (let i = 0; i < args.length; i++) {
21
- if (args[i].startsWith("--")) {
22
- const key = args[i].slice(2);
23
- flags[key] = args[i + 1] && !args[i + 1].startsWith("--") ? args[++i] : true;
24
- } else positional.push(args[i]);
25
- }
26
-
27
19
  const USAGE = "usage: partforge <measure|render|pick-serve|pick> …";
28
20
 
29
- // --- pick-serve / pick: no part module, no kernel boot --------------------------
30
- if (cmd === "pick-serve") {
31
- const port = Number(flags.port) || 4518;
32
- const timeoutMs = (Number(flags.timeout) || 120) * 1000;
33
- const { port: bound } = await createPickServer({ port, timeoutMs }).start();
34
- console.log(`partforge pick-server listening on http://127.0.0.1:${bound}`);
35
- // keep the process alive serving requests
36
- } else if (cmd === "pick") {
37
- if (positional.length === 0) die('usage: partforge pick "<prompt>" ["<prompt>" …] [--port N]');
38
- const port = Number(flags.port) || 4518;
39
- const out = await requestPicks({ port, prompts: positional }).catch((e) => die(e.message));
40
- console.log(formatPickResult(out));
41
- process.exit(out.status === "done" ? 0 : 1);
42
- } else if (!["measure", "render"].includes(cmd)) {
43
- die(USAGE);
44
- }
45
-
46
- const partPath = positional[0];
47
- const view = positional[1];
48
- if (["measure", "render"].includes(cmd) && !partPath) die(`usage: partforge ${cmd} <part-module> [view]`);
21
+ const parse = (args, options, usage) => {
22
+ try {
23
+ return parseArgs({ args, options, strict: true, allowPositionals: true });
24
+ } catch (e) {
25
+ return die(`${e.message}\n${usage}`);
26
+ }
27
+ };
49
28
 
50
- if (["measure", "render"].includes(cmd)) {
29
+ async function loadPart(partPath, usage) {
30
+ if (!partPath) die(usage);
51
31
  const mod = await import(pathToFileURL(resolve(process.cwd(), partPath)))
52
32
  .catch((e) => die(`cannot load part "${partPath}": ${e.message}`));
53
33
  const part = mod.default;
54
34
  if (!part?.parts || !part?.views) die(`"${partPath}" has no default-exported PartDefinition`);
35
+ return part;
36
+ }
55
37
 
56
- let kernel;
57
- if (detectBackend(part) === "occt") {
58
- kernel = await bootOcctKernel();
59
- } else {
60
- const wasm = await Module(); wasm.setup();
61
- kernel = createManifoldKernel(wasm, { quality: "preview" });
62
- }
38
+ const bootKernel = (part) => (detectBackend(part) === "occt" ? bootOcctKernel() : bootManifoldKernel());
63
39
 
64
- try {
65
- if (cmd === "measure") {
40
+ const commands = {
41
+ async measure(args) {
42
+ const usage = "usage: partforge measure <part-module> [view] [--process <profile>] [--no-verify] [--json] [--out <file>]";
43
+ const { values: flags, positionals: [partPath, view] } = parse(args, {
44
+ process: { type: "string" },
45
+ "no-verify": { type: "boolean" },
46
+ json: { type: "boolean" },
47
+ out: { type: "string" },
48
+ }, usage);
49
+ const part = await loadPart(partPath, usage);
50
+ const kernel = await bootKernel(part);
51
+ try {
66
52
  const report = measure(kernel, part, view);
67
53
  printMeasure(report);
68
54
  let vok = true;
69
- const processFlag = typeof flags.process === "string" ? flags.process : undefined;
70
- if ((part.verify || processFlag) && !flags["no-verify"]) {
71
- const v = verify(kernel, part, { process: processFlag, view });
55
+ if ((part.verify || flags.process) && !flags["no-verify"]) {
56
+ const v = verify(kernel, part, { process: flags.process, view });
72
57
  printVerify(v);
73
58
  report.verify = v;
74
59
  vok = v.ok;
75
60
  }
76
- const file = `measure-${slug(report.part)}-${report.view}.json`;
77
- writeFileSync(file, JSON.stringify(report, null, 2));
78
- console.log(`\nwrote ${file}`);
61
+ if (flags.out) {
62
+ mkdirSync(dirname(resolve(flags.out)), { recursive: true });
63
+ writeFileSync(flags.out, JSON.stringify(report, null, 2));
64
+ console.log(`\nwrote ${flags.out}`);
65
+ }
79
66
  if (flags.json) console.log(JSON.stringify(report, null, 2));
80
67
  process.exit(report.ok && vok ? 0 : 1);
81
- } else {
82
- const views = typeof flags.views === "string" ? flags.views.split(",") : undefined;
68
+ } catch (e) {
69
+ die(`measure failed: ${e.message || e}`);
70
+ }
71
+ },
72
+
73
+ async render(args) {
74
+ const usage = "usage: partforge render <part-module> [view] [--views iso,front] [--out <dir>]";
75
+ const { values: flags, positionals: [partPath, view] } = parse(args, {
76
+ views: { type: "string" },
77
+ out: { type: "string" },
78
+ }, usage);
79
+ const part = await loadPart(partPath, usage);
80
+ const kernel = await bootKernel(part);
81
+ try {
82
+ const views = flags.views ? flags.views.split(",") : undefined;
83
83
  const files = await renderViews(kernel, part, view, { views, out: flags.out || "render" });
84
84
  for (const f of files) console.log(`wrote ${f}`);
85
85
  process.exit(0);
86
+ } catch (e) {
87
+ die(`render failed: ${e.message || e}`);
86
88
  }
87
- } catch (e) {
88
- die(`${cmd} failed: ${e.message || e}`);
89
- }
90
- }
89
+ },
90
+
91
+ async "pick-serve"(args) {
92
+ const usage = "usage: partforge pick-serve [--port N] [--timeout <seconds>]";
93
+ const { values: flags } = parse(args, { port: { type: "string" }, timeout: { type: "string" } }, usage);
94
+ const port = Number(flags.port) || 4518;
95
+ const timeoutMs = (Number(flags.timeout) || 120) * 1000;
96
+ const { port: bound } = await createPickServer({ port, timeoutMs }).start();
97
+ console.log(`partforge pick-server listening on http://127.0.0.1:${bound}`);
98
+ // no exit — the process stays alive serving requests
99
+ },
100
+
101
+ async pick(args) {
102
+ const usage = 'usage: partforge pick "<prompt>" ["<prompt>" …] [--port N]';
103
+ const { values: flags, positionals: prompts } = parse(args, { port: { type: "string" } }, usage);
104
+ if (prompts.length === 0) die(usage);
105
+ const port = Number(flags.port) || 4518;
106
+ const out = await requestPicks({ port, prompts }).catch((e) => die(e.message));
107
+ console.log(formatPickResult(out));
108
+ process.exit(out.status === "done" ? 0 : 1);
109
+ },
110
+ };
91
111
 
92
112
  function printMeasure(r) {
93
113
  console.log(`${r.part} / ${r.view}`);
@@ -115,3 +135,7 @@ function printVerify(v) {
115
135
  const f = v.failures.length, w = v.warnings.length;
116
136
  console.log(` result: ${f ? `${f} gate failure(s)` : "all gates passed"}${w ? `, ${w} warning(s)` : ""}`);
117
137
  }
138
+
139
+ const [, , cmd, ...args] = process.argv;
140
+ if (!commands[cmd]) die(USAGE);
141
+ await commands[cmd](args);
@@ -7,13 +7,15 @@ part you write **one script** — geometry build functions + a parameter schema
7
7
  the framework does the rest.
8
8
 
9
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).
10
+ - Parts: `src/parts/` — e.g. `planter.js` (full, rich) and `demo.js` (minimal).
11
11
  - A part module is **plain data + pure functions**: no DOM, no side effects (it
12
12
  loads in both the main thread and a Web Worker).
13
13
 
14
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 drumevery feature in use).
15
+ parametric spacer — the smallest complete part) and **`src/parts/planter.js`** (a
16
+ faceted planterfacets, taper, twist, even walls, an optional feature, a `derive`,
17
+ and a `verify` block). **`src/parts/filleted-box.js`** is the worked example for the
18
+ OCCT-only fillet/chamfer/shell ops.
17
19
 
18
20
  ---
19
21
 
@@ -114,6 +116,7 @@ entry pulls in the DOM viewer/controls, and your build functions run in a Web Wo
114
116
  | `s.mirror("XY"\|"XZ"\|"YZ")` | mirror across a plane |
115
117
  | `s.scale(factor, center?)` | uniform scale (single factor) about `center` (default origin) — scaling an off-origin part about the origin also moves it; pass a center (e.g. `s.boundingBox().center`) to resize in place |
116
118
  | `s.clone()` | independent copy (replicad consumes solids on transform) |
119
+ | `s.label(name)` | name this solid's surface for hover/pick feature attribution; survives transforms + booleans; same name on several solids merges into one feature |
117
120
  | `s.boundingBox()` | `{ min, max, center, size }` axis-aligned bounds (query) |
118
121
  | `s.volume()` | volume in mm³ (Manifold) |
119
122
  | `s.toMesh({ quality })` / `s.toSTL({ quality })` / `s.toIndexedMesh()` | meshes / STL / indexed mesh (3MF) — the framework calls these |
@@ -157,6 +160,31 @@ magic vectors. Three habits:
157
160
  The bare `rotate(deg, center, axis)` remains available as the low-level primitive for
158
161
  anything `rotateX/Y/Z`/`rotateAbout` can't express, but prefer the vocabulary above.
159
162
 
163
+ ### Naming features (`.label()`)
164
+
165
+ Give build-step solids human-readable names — the viewer's hover tooltip, the
166
+ highlight, and pick selections all use them, so you, the app user, and an agent
167
+ share the same vocabulary ("Make the Drainage hole 10 mm").
168
+
169
+ ```js
170
+ const body = k.prism(d.outerPts, p.height, { scaleTop: p.taper }).label("Faceted wall");
171
+ let s = body.cut(cavity.label("Cavity"));
172
+ if (p.drain > 0) s = s.cut(k.cylinder(d.drainR, d.drainR, p.floor + 4).at([0, 0, -2]).label("Drainage hole"));
173
+ ```
174
+
175
+ - A label names the solid's **surface** wherever it survives into the final part —
176
+ a cutting tool's label lands on the faces it leaves behind (the hole's wall).
177
+ - Label **after** shaping compound tools (e.g. after an `intersect` clip) and
178
+ either before or after transforms — labels ride through `at`/`rotate`/etc.
179
+ - The **same label on several solids merges into one feature** — label a pattern
180
+ of four holes `"Mounting holes"` and they hover/highlight as one.
181
+ - Unlabeled geometry falls back to the sub-part's `label`. Faces created by
182
+ `fillet`/`chamfer`/`shell` are new surfaces, so they use the fallback too.
183
+ - Works on both backends. On OCCT each label keeps a geometry snapshot for
184
+ mesh-time classification — label a handful of features, not hundreds.
185
+ - Names should describe intent ("Drainage hole", not "cylinder2"); keep them
186
+ unique per sub-part unless you want the merge behavior.
187
+
160
188
  ### Caching & determinism
161
189
 
162
190
  The preview kernel memoizes geometry by content hash, so editing a parameter only
@@ -348,13 +376,12 @@ stylesheet). `mount` looks up these element IDs:
348
376
  |---|---|
349
377
  | `#app` | viewer canvas mounts here |
350
378
  | `#controls` | control panel is built into this |
351
- | `#part` | view-tab bar: one `<button data-part="<view>">` per view, the active one with `class="on"` |
379
+ | `#part` | view-tab bar leave the div **empty**; `mount` generates one button per entry in `part.views` |
352
380
  | `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
353
381
  | `#status`, `#busy`, `#phase` | status line + busy overlay |
354
382
  | `#viewbar` with `#pause` / `#reframe` / `#theme` | optional viewer controls (omit any you don't want) |
355
383
 
356
- Copy `demo.html` and change the title, the `#part` buttons (one per view), the panel
357
- heading, and the `<script src>`. Two workers are spawned from your one worker entry
384
+ Copy `demo.html` and change the title, the panel heading, and the `<script src>`. Two workers are spawned from your one worker entry
358
385
  (`name` = `"manifold"` for preview/STL/3MF, `"occt"` for STEP — handled for you).
359
386
 
360
387
  > Production deploy builds `index.html` only. Extra `*.html` files are **dev-only**
@@ -383,12 +410,10 @@ Tests run under **Node 24** (`nvm use` first; the default shell Node is too old)
383
410
  `npx vitest run`. Build geometry directly off your part with a Manifold kernel:
384
411
 
385
412
  ```js
386
- import Module from "manifold-3d";
387
- import { createManifoldKernel } from "../../src/framework/geometry/manifold-backend.js";
388
- import part from "../../src/parts/<part>.js";
413
+ import { bootManifoldKernel } from "partforge/testing";
414
+ import part from "../src/parts/<part>.js";
389
415
 
390
- const w = await Module(); w.setup();
391
- const k = createManifoldKernel(w, { quality: "preview" });
416
+ const k = await bootManifoldKernel();
392
417
  const solid = part.parts.<name>.build(k, part.defaults, part.derive?.(part.defaults) ?? {});
393
418
  expect(solid.toMesh().triangles).toBeGreaterThan(0);
394
419
  ```
@@ -398,20 +423,19 @@ its assembly pose and returns any interpenetrating pair with its overlap volume
398
423
  parts meant to fit (e.g. seated in a pocket) read ~0 and don't trip it:
399
424
 
400
425
  ```js
401
- import { assemblyOverlaps } from "../../src/framework/assembly.js";
426
+ import { assemblyOverlaps } from "partforge/testing";
402
427
  test("assembly has no interpenetrating parts", () => {
403
428
  expect(assemblyOverlaps(k, part, "<view>", {})).toEqual([]); // [{a,b,volume}] on failure
404
429
  });
405
430
  ```
406
431
 
407
- See `test/parts/drum-assembly.test.js` for a real example, and `test/framework/jobs.test.js`
432
+ See `test/framework/assembly.test.js` for a real example, and `test/framework/jobs.test.js`
408
433
  for exporting through the job loop.
409
434
 
410
- **OCCT tests** (STEP / B-rep parity) boot via `bootOcctKernel()` in `test/occt-kernel.js`.
435
+ **OCCT tests** (STEP / B-rep) boot the OCCT kernel with `bootOcctKernel()` from
436
+ `partforge/testing` (in a `beforeAll`) — see `test/occt-backend.test.js`.
411
437
  **OCCT and Manifold must not boot in the same process** — keep OCCT-booting tests in their
412
- own files (vitest isolates files). For Manifold↔OCCT volume parity, see `test/parity.test.js`
413
- + the `test/fixtures/occt-volumes.json` fixture (regenerate with
414
- `node scripts/gen-occt-fixtures.mjs` after a geometry change).
438
+ own files (vitest isolates files).
415
439
 
416
440
  ---
417
441
 
@@ -424,11 +448,12 @@ check it without opening the app:
424
448
  npx partforge measure src/parts/<part>.js [view] # geometric facts
425
449
  npx partforge render src/parts/<part>.js [view] # canonical-angle PNGs
426
450
 
427
- `measure` prints a report and writes `measure-<part>-<view>.json`: per sub-part
428
- and per view it reports bounding box, volume, surface area, triangle count,
429
- whether the solid is watertight, and the number of through-holes (genus), plus an
430
- assembly overlap check. It exits non-zero if any sub-part isn't watertight or any
431
- parts interpenetrate so it doubles as a CI/agent gate. (Manifold output is
451
+ `measure` prints a report: per sub-part and per view it reports bounding box,
452
+ volume, surface area, triangle count, whether the solid is watertight, and the
453
+ number of through-holes (genus), plus an assembly overlap check. It exits non-zero
454
+ if any sub-part isn't watertight or any parts interpenetrate — so it doubles as a
455
+ CI/agent gate. Add `--json` to also dump the report as JSON on stdout, or
456
+ `--out report.json` to write it to a file (nothing is written otherwise). (Manifold output is
432
457
  manifold by construction, so `watertight` is mainly a build-sanity check for
433
458
  empty/degenerate results; `holes` is the informative topology number.)
434
459
 
@@ -536,7 +561,7 @@ See `src/parts/filleted-box.js` for the worked example.
536
561
 
537
562
  **Automatic backend selection.** Before building, the framework runs a geometry-free *probe*
538
563
  of your `build` to see whether it uses a CAD-only op, and routes accordingly — Manifold for
539
- everything else (so sweep-heavy parts like the drum stay fast). Force it with
564
+ everything else (so sweep-heavy parts, e.g. helical grooves, stay fast). Force it with
540
565
  `meta.backend: "occt" | "manifold"` if you ever need to. Because an OCCT part is built
541
566
  entirely on OCCT, its fillets are exact in the STEP **and** present in the printed STL.
542
567
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -204,6 +204,18 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
204
204
  .irrelevant:hover { opacity: 0.7; }
205
205
  .section-hidden { display: none; }
206
206
 
207
+ /* hover feature-label tooltip (selection/hover.js) */
208
+ #pf-hover-tip {
209
+ position: fixed; z-index: 30; pointer-events: none; display: none;
210
+ padding: 4px 9px; border-radius: 6px; max-width: 260px;
211
+ background: var(--surface); border: 1px solid var(--border);
212
+ color: var(--text-strong); font-size: 12px; line-height: 1.35;
213
+ box-shadow: 0 2px 10px rgba(0, 0, 0, .25);
214
+ }
215
+ #pf-hover-tip.show { display: block; }
216
+ #pf-hover-tip .pf-hover-sub { color: var(--muted); font-size: 11px; margin-left: 7px; }
217
+ #pf-hover-tip .pf-hover-sub:empty { display: none; }
218
+
207
219
  /* request-a-pick: agent prompt banner, floated top-centre well below the part tabs,
208
220
  laid out like a chat message (avatar + text). Slides/fades in when shown; themed. */
209
221
  #pf-pick-banner {
@@ -238,3 +250,17 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
238
250
  from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
239
251
  to { opacity: 1; transform: translateX(-50%) translateY(0); }
240
252
  }
253
+
254
+ /* ?pick clipboard mode: a bottom-left toggle button + a transient token toast. */
255
+ #pf-pick {
256
+ position: fixed; left: 12px; bottom: 12px; z-index: 9999;
257
+ font: 12px system-ui, sans-serif; padding: 6px 10px; cursor: pointer;
258
+ }
259
+ #pf-pick.on { outline: 2px solid #ffcc33; }
260
+ #pf-pick-toast {
261
+ position: fixed; left: 12px; bottom: 48px; z-index: 9999; max-width: 60ch;
262
+ font: 12px ui-monospace, monospace; padding: 6px 10px; border-radius: 4px;
263
+ background: rgba(20,24,29,0.92); color: #d8e0ea;
264
+ white-space: pre-wrap; word-break: break-word; display: none;
265
+ }
266
+ #pf-pick-toast.show { display: block; }
@@ -1,4 +1,4 @@
1
- import { viewSubParts } from "./jobs.js";
1
+ import { viewSubParts, resolveParams, buildPosed } from "./jobs.js";
2
2
 
3
3
  // Collision check for an assembled view: build each sub-part in its display
4
4
  // (assembly) pose and return the pairs whose solid-intersection volume exceeds
@@ -8,14 +8,11 @@ import { viewSubParts } from "./jobs.js";
8
8
  // part tests so an author/LLM editing a part sees collisions fail.
9
9
  // → [{ a, b, volume }] for each offending pair (empty = no collisions)
10
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
- });
11
+ const { p, d } = resolveParams(part, params);
12
+ const posed = viewSubParts(part, view, p).map((name) => ({
13
+ name,
14
+ solid: buildPosed(kernel, part, name, { purpose: "display", view, p, d }),
15
+ }));
19
16
 
20
17
  const overlaps = [];
21
18
  for (let i = 0; i < posed.length; i++) {
@@ -0,0 +1,23 @@
1
+ import { zipSync } from "fflate";
2
+
3
+ // Browser file-download helpers. Pure DOM/Blob utilities with no app state — the
4
+ // worker produces the bytes; these just hand them to the browser as a download.
5
+
6
+ // Trigger a download of one binary blob under `filename`.
7
+ export function triggerDownload(data, filename, mime) {
8
+ const url = URL.createObjectURL(new Blob([data], { type: mime }));
9
+ const a = document.createElement("a");
10
+ a.href = url;
11
+ a.download = filename;
12
+ a.click();
13
+ URL.revokeObjectURL(url);
14
+ }
15
+
16
+ // Download a set of built parts: a single part downloads directly; multiple parts
17
+ // are bundled into one flat, store-only (level 0) zip named `zipName`.
18
+ export function downloadParts({ parts, ext, mime }, zipName) {
19
+ if (parts.length === 1) return triggerDownload(parts[0].data, `${parts[0].name}.${ext}`, mime);
20
+ const entries = {};
21
+ for (const p of parts) entries[`${p.name}.${ext}`] = new Uint8Array(p.data);
22
+ triggerDownload(zipSync(entries, { level: 0 }), zipName, "application/zip");
23
+ }
@@ -0,0 +1,102 @@
1
+ // Pure classification math for OCCT feature labels. The OCCT backend meshes each
2
+ // labeled solid snapshot into a triangle soup; a face of the RESULT mesh belongs to
3
+ // a label when its sampled triangle centroids all lie on that soup's surface (a cut
4
+ // face lies exactly on its tool's surface, up to the two meshes' tolerances).
5
+ // No OCCT, no three.js — unit-testable with hand-built soups.
6
+
7
+ // Result mesh (preview) tolerance 0.1 + snapshot mesh tolerance 0.1 + slack.
8
+ const DEFAULT_TOL = 0.35; // mm — surfaces closer than this to another labeled surface can misattribute
9
+ const SAMPLES_PER_FACE = 4;
10
+
11
+ // Distance from point p to triangle (a,b,c) — the classic region-based projection.
12
+ export function pointTriDist(p, a, b, c) {
13
+ const sub = (u, v) => [u[0] - v[0], u[1] - v[1], u[2] - v[2]];
14
+ const dot = (u, v) => u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
15
+ const ab = sub(b, a), ac = sub(c, a), ap = sub(p, a);
16
+ const d1 = dot(ab, ap), d2 = dot(ac, ap);
17
+ if (d1 <= 0 && d2 <= 0) return Math.hypot(...ap); // vertex a
18
+ const bp = sub(p, b);
19
+ const d3 = dot(ab, bp), d4 = dot(ac, bp);
20
+ if (d3 >= 0 && d4 <= d3) return Math.hypot(...bp); // vertex b
21
+ const vc = d1 * d4 - d3 * d2;
22
+ if (vc <= 0 && d1 >= 0 && d3 <= 0) { // edge ab
23
+ const t = d1 / (d1 - d3);
24
+ return Math.hypot(...sub(p, [a[0] + ab[0] * t, a[1] + ab[1] * t, a[2] + ab[2] * t]));
25
+ }
26
+ const cp = sub(p, c);
27
+ const d5 = dot(ab, cp), d6 = dot(ac, cp);
28
+ if (d6 >= 0 && d5 <= d6) return Math.hypot(...cp); // vertex c
29
+ const vb = d5 * d2 - d1 * d6;
30
+ if (vb <= 0 && d2 >= 0 && d6 <= 0) { // edge ac
31
+ const t = d2 / (d2 - d6);
32
+ return Math.hypot(...sub(p, [a[0] + ac[0] * t, a[1] + ac[1] * t, a[2] + ac[2] * t]));
33
+ }
34
+ const va = d3 * d6 - d5 * d4;
35
+ if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) { // edge bc
36
+ const t = (d4 - d3) / (d4 - d3 + (d5 - d6));
37
+ const bc = sub(c, b);
38
+ return Math.hypot(...sub(p, [b[0] + bc[0] * t, b[1] + bc[1] * t, b[2] + bc[2] * t]));
39
+ }
40
+ const denom = 1 / (va + vb + vc); // interior
41
+ const v = vb * denom, w = vc * denom;
42
+ return Math.hypot(...sub(p, [a[0] + ab[0] * v + ac[0] * w, a[1] + ab[1] * v + ac[1] * w, a[2] + ab[2] * v + ac[2] * w]));
43
+ }
44
+
45
+ const centroid = (V, T, t) => {
46
+ const i = T[t * 3] * 3, j = T[t * 3 + 1] * 3, k = T[t * 3 + 2] * 3;
47
+ return [(V[i] + V[j] + V[k]) / 3, (V[i + 1] + V[j + 1] + V[k + 1]) / 3, (V[i + 2] + V[j + 2] + V[k + 2]) / 3];
48
+ };
49
+
50
+ function distToSoup(p, soup) {
51
+ const V = soup.vertices, T = soup.triangles;
52
+ let best = Infinity;
53
+ for (let t = 0; t < T.length / 3; t++) {
54
+ const a = [V[T[t * 3] * 3], V[T[t * 3] * 3 + 1], V[T[t * 3] * 3 + 2]];
55
+ const b = [V[T[t * 3 + 1] * 3], V[T[t * 3 + 1] * 3 + 1], V[T[t * 3 + 1] * 3 + 2]];
56
+ const c = [V[T[t * 3 + 2] * 3], V[T[t * 3 + 2] * 3 + 1], V[T[t * 3 + 2] * 3 + 2]];
57
+ const d = pointTriDist(p, a, b, c);
58
+ if (d < best) best = d;
59
+ }
60
+ return best;
61
+ }
62
+
63
+ // resultMesh: replicad ShapeMesh {vertices, triangles, faceGroups}; soups: labeled
64
+ // snapshots meshed by the caller. Returns {} when attribution isn't possible.
65
+ export function classifyFaceGroups(resultMesh, soups, tol = DEFAULT_TOL) {
66
+ const groups = resultMesh.faceGroups;
67
+ const nTri = resultMesh.triangles.length / 3;
68
+ if (!groups?.length || !soups.length) return {};
69
+
70
+ // faceGroups start/count units differ across replicad versions: triangle counts
71
+ // sum to nTri, index counts to nTri*3. Detect which this is.
72
+ const total = groups.reduce((s, g) => s + g.count, 0);
73
+ const div = total === nTri * 3 ? 3 : 1;
74
+
75
+ const indexOf = new Map(); // label -> 1-based feature index (same-label merge)
76
+ const features = [];
77
+ const featureIds = new Uint16Array(nTri);
78
+
79
+ for (const g of groups) {
80
+ const start = g.start / div, count = g.count / div;
81
+ if (count <= 0) continue; // degenerate group: no triangles, nothing to attribute
82
+ // sample a few spread triangles of the face
83
+ const picks = [];
84
+ const denom = Math.max(1, Math.min(SAMPLES_PER_FACE, count) - 1);
85
+ for (let s = 0; s < Math.min(SAMPLES_PER_FACE, count); s++) {
86
+ picks.push(start + Math.floor((s * (count - 1)) / denom));
87
+ }
88
+ // last matching soup wins (most recently applied label)
89
+ let winner = null;
90
+ for (const soup of soups) {
91
+ const onSurface = picks.every(
92
+ (t) => distToSoup(centroid(resultMesh.vertices, resultMesh.triangles, t), soup) <= tol
93
+ );
94
+ if (onSurface) winner = soup.label;
95
+ }
96
+ if (winner == null) continue;
97
+ let fi = indexOf.get(winner);
98
+ if (fi === undefined) { features.push(winner); fi = features.length; indexOf.set(winner, fi); }
99
+ for (let t = start; t < start + count; t++) featureIds[t] = fi;
100
+ }
101
+ return features.length ? { featureIds, features } : {};
102
+ }