partforge 0.6.1 → 0.8.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 (50) hide show
  1. package/README.md +61 -15
  2. package/bin/cli.js +82 -58
  3. package/docs/AUTHORING-PARTS.md +98 -26
  4. package/package.json +1 -1
  5. package/src/app-faceted-vase.js +10 -0
  6. package/src/faceted-vase-worker.js +3 -0
  7. package/src/framework/app.css +26 -0
  8. package/src/framework/assembly.js +6 -9
  9. package/src/framework/download.js +23 -0
  10. package/src/framework/geometry/feature-attribution.js +102 -0
  11. package/src/framework/geometry/helix-tube.js +10 -20
  12. package/src/framework/geometry/kernel-front.js +37 -0
  13. package/src/framework/geometry/kernel.js +56 -10
  14. package/src/framework/geometry/loft.js +79 -0
  15. package/src/framework/geometry/manifold-backend.js +67 -25
  16. package/src/framework/geometry/mesh-build.js +53 -0
  17. package/src/framework/geometry/occt-backend.js +117 -106
  18. package/src/framework/geometry/occt-repair.js +83 -0
  19. package/src/framework/geometry/polygon.js +89 -0
  20. package/src/framework/geometry/probe.js +37 -30
  21. package/src/framework/geometry/profile.js +96 -0
  22. package/src/framework/geometry/solid-sugar.js +32 -5
  23. package/src/framework/geometry/sweep.js +151 -0
  24. package/src/framework/geometry-service.js +4 -6
  25. package/src/framework/jobs.js +40 -18
  26. package/src/framework/mesh-cache.js +41 -0
  27. package/src/framework/mount.js +103 -240
  28. package/src/framework/param-deps.js +9 -18
  29. package/src/framework/pick-request/server.js +10 -0
  30. package/src/framework/regen-loop.js +45 -0
  31. package/src/framework/selection/format.js +2 -6
  32. package/src/framework/selection/hover.js +128 -0
  33. package/src/framework/selection/index.js +3 -0
  34. package/src/framework/selection/pick-toggle.js +34 -0
  35. package/src/framework/selection/pick.js +7 -30
  36. package/src/framework/selection/raycast.js +43 -0
  37. package/src/framework/selection/resolve.js +3 -8
  38. package/src/framework/status-ui.js +18 -0
  39. package/src/framework/view-state.js +11 -1
  40. package/src/framework/view-tabs.js +33 -0
  41. package/src/framework/viewer-controls.js +48 -0
  42. package/src/framework/viewer.js +9 -9
  43. package/src/framework/worker.js +12 -20
  44. package/src/parts/faceted-vase.js +75 -0
  45. package/src/parts/filleted-box.js +1 -1
  46. package/src/parts/planter.js +4 -3
  47. package/src/testing/build.js +3 -6
  48. package/src/testing/manifold.js +11 -0
  49. package/src/testing.js +1 -0
  50. 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
 
@@ -88,13 +90,55 @@ handles. The same code runs on **Manifold** (fast meshes — preview + STL + 3MF
88
90
  |---|---|
89
91
  | `k.cylinder(rBottom, rTop, h, { center? })` | cylinder/cone along +Z (frustum if radii differ) |
90
92
  | `k.box(min, max)` | axis-aligned box from `[x,y,z]` min/max |
91
- | `k.prism(points2D, h, { twist?, scaleTop? })` | extrude a 2-D polygon from z=0; optional `twist` (degrees over the height) and `scaleTop` (uniform top taper: 1 straight, <1 taper in, 0 → point/cone) |
93
+ | `k.prism(points2D, h, { twist?, scaleTop? })` | extrude a 2-D polygon (or an **arc profile** from `roundedProfile`) from z=0; optional `twist` (degrees over the height) and `scaleTop` (uniform top taper: 1 straight, <1 taper in, 0 → point/cone) |
94
+ | `k.extrude(profile, h, { twist?, scaleTop? })` | extrude a **polygon-with-holes** region from z=0 in one op — `profile` is `{ outer, holes? }` where each contour is a points array **or an arc profile** (`roundedProfile`, for true STEP fillets), or a bare points array / arc profile for outer-only; same `twist`/`scaleTop` as `prism` (both backends) |
95
+ | `k.loft(rings, { ruled?, closed? })` | stack polygon cross-sections into a solid — ruled walls between consecutive rings, capped ends (both backends; `closed:true` capless loops are Manifold-only). `ruled:false` (smooth C2 blend) is honoured only by OCCT/STEP export; the Manifold preview always shows faceted straight walls |
96
+ | `k.sweep(profile2D, path3D, { cornerRadius?, closed?, ruled?, smooth? })` | sweep a fixed 2-D profile along a 3-D polyline path — sharp mitered corners (or `cornerRadius` fillets), capped ends (both backends). `closed:true` capless loops and `smooth:true` (OCCT-native swept B-rep, STEP-exact / preview-faceted) are backend-specific, like loft's `closed`/`ruled:false`. `closed:true` loops must be **planar** — RMF frame-transport holonomy can seam-twist a non-planar closed loop where the last station rejoins the first, so only planar closed loops are supported/tested |
92
97
  | `k.sphere(r)` | sphere centred at the origin |
93
98
  | `k.revolve(points2D, { degrees })` | revolve a lathe profile `[[r,z],…]` (r ≥ 0) around the Z axis (full or partial) |
94
99
  | `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove) |
95
100
  | `k.union(solids[])` | boolean union |
96
101
 
97
- 2-D polygon helpers for `prism`: `import { piePolygon, hexPolygon } from "partforge/geometry"`.
102
+ **`loft` rings** each ring is `{ polygon:[[x,y],…] | sides+radius, z, rotate?, scale? }`
103
+ (all rings must share the same vertex count; `rotate` is degrees about Z, `scale` is a
104
+ number or `[sx,sy]`). Author rings CCW and ordered by ascending `z` (the `regularPolygon`
105
+ / `polygon.js` helpers are already CCW); loft self-corrects a fully-inverted result so
106
+ CW-wound or descending-z rings still export a valid outward solid. (Arc profiles from
107
+ `roundedProfile` are **not** accepted as loft rings yet — a ring must be a point array;
108
+ use `prism`/`extrude` for true-arc STEP export.) **`sweep`** takes the same CCW
109
+ `polygon.js` outline as its `profile2D` and a plain `[[x,y,z],…]` point list as its
110
+ `path3D`; the profile stays perpendicular to the path (a rotation-minimizing frame), with
111
+ sharp mitered corners by default or `cornerRadius` fillets. Worked snippets:
112
+
113
+ ```js
114
+ // a square tube (extrude a region with a hole) — one op, no boolean cut
115
+ k.extrude({ outer: roundedRectPolygon(40, 30, 4), holes: [circleProfile(6)] }, 10);
116
+
117
+ // a tapered, twisting faceted vase wall (see src/parts/faceted-vase.js)
118
+ const rings = [];
119
+ for (let i = 0; i <= 24; i++) { const t = i / 24;
120
+ rings.push({ sides: 6, radius: 30 - 8 * t, z: 120 * t, rotate: 90 * t }); }
121
+ k.loft(rings); // ruled walls, capped ends
122
+
123
+ // a cable/hose: sweep a circle along a 3-D polyline, with rounded bends
124
+ k.sweep(circleProfile(3), [[0, 0, 0], [0, 0, 20], [15, 0, 20]], { cornerRadius: 5 });
125
+
126
+ // round every corner of any CCW outline, then extrude/loft/prism it
127
+ k.prism(filletPolygon(bracketOutline, 3), 4); // tessellated corners (faceted in STEP)
128
+ k.prism(roundedProfile(bracketOutline, 3), 4); // true CIRCLE corners in STEP export
129
+ ```
130
+
131
+ 2-D polygon helpers for `prism`/`extrude`/`loft`: `import { piePolygon, hexPolygon,
132
+ regularPolygon, roundedRectPolygon, starPolygon, circleProfile, filletPolygon,
133
+ roundedProfile } from "partforge/geometry"`. `filletPolygon(points, r, { segs? })` rounds
134
+ every corner of a CCW polygon (per-corner radius clamped so neighbouring arcs never overlap)
135
+ and returns points usable by `prism`/`extrude`/`loft` on both backends — but it **bakes each
136
+ corner into line facets**, so STEP corners are faceted. `roundedProfile(points, r | r[])`
137
+ rounds corners the same way but keeps them **mathematically true** — it carries the arc
138
+ symbolically so STEP export gets real circular edges. Use it for `prism`/`extrude` (not yet
139
+ `loft` — arc rings are rejected there in v1). A scalar `r` rounds every corner; a per-corner
140
+ `r[]` (length = points) rounds selectively (a `0`, a zero-length edge, or a straight/180°
141
+ corner stays sharp).
98
142
  **Import geometry helpers from `partforge/geometry`, never from `partforge`** — the main
99
143
  entry pulls in the DOM viewer/controls, and your build functions run in a Web Worker
100
144
  (importing the main entry there throws `document is not defined`).
@@ -114,6 +158,7 @@ entry pulls in the DOM viewer/controls, and your build functions run in a Web Wo
114
158
  | `s.mirror("XY"\|"XZ"\|"YZ")` | mirror across a plane |
115
159
  | `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
160
  | `s.clone()` | independent copy (replicad consumes solids on transform) |
161
+ | `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
162
  | `s.boundingBox()` | `{ min, max, center, size }` axis-aligned bounds (query) |
118
163
  | `s.volume()` | volume in mm³ (Manifold) |
119
164
  | `s.toMesh({ quality })` / `s.toSTL({ quality })` / `s.toIndexedMesh()` | meshes / STL / indexed mesh (3MF) — the framework calls these |
@@ -157,6 +202,31 @@ magic vectors. Three habits:
157
202
  The bare `rotate(deg, center, axis)` remains available as the low-level primitive for
158
203
  anything `rotateX/Y/Z`/`rotateAbout` can't express, but prefer the vocabulary above.
159
204
 
205
+ ### Naming features (`.label()`)
206
+
207
+ Give build-step solids human-readable names — the viewer's hover tooltip, the
208
+ highlight, and pick selections all use them, so you, the app user, and an agent
209
+ share the same vocabulary ("Make the Drainage hole 10 mm").
210
+
211
+ ```js
212
+ const body = k.prism(d.outerPts, p.height, { scaleTop: p.taper }).label("Faceted wall");
213
+ let s = body.cut(cavity.label("Cavity"));
214
+ if (p.drain > 0) s = s.cut(k.cylinder(d.drainR, d.drainR, p.floor + 4).at([0, 0, -2]).label("Drainage hole"));
215
+ ```
216
+
217
+ - A label names the solid's **surface** wherever it survives into the final part —
218
+ a cutting tool's label lands on the faces it leaves behind (the hole's wall).
219
+ - Label **after** shaping compound tools (e.g. after an `intersect` clip) and
220
+ either before or after transforms — labels ride through `at`/`rotate`/etc.
221
+ - The **same label on several solids merges into one feature** — label a pattern
222
+ of four holes `"Mounting holes"` and they hover/highlight as one.
223
+ - Unlabeled geometry falls back to the sub-part's `label`. Faces created by
224
+ `fillet`/`chamfer`/`shell` are new surfaces, so they use the fallback too.
225
+ - Works on both backends. On OCCT each label keeps a geometry snapshot for
226
+ mesh-time classification — label a handful of features, not hundreds.
227
+ - Names should describe intent ("Drainage hole", not "cylinder2"); keep them
228
+ unique per sub-part unless you want the merge behavior.
229
+
160
230
  ### Caching & determinism
161
231
 
162
232
  The preview kernel memoizes geometry by content hash, so editing a parameter only
@@ -167,7 +237,12 @@ module-level mutable state. An impure build will silently return stale geometry.
167
237
  Cache granularity follows the operations you call. Booleans and heavy primitives are
168
238
  cached; cheap transforms are recomputed. To make a multi-step shape into a single
169
239
  cache node, use (or add) a **compound op** like `k.boredCylinder({ od, h, bore })` —
170
- it hashes from its own arguments and never exposes its internals to the cache.
240
+ it hashes from its own arguments and never exposes its internals to the cache. The heavy
241
+ primitives `loft`, `sweep`, `extrude`, `prism`, and `revolve` are cached this way too:
242
+ their hash folds every shape-affecting argument (each `loft` ring's points/`z`/`rotate`/`scale`,
243
+ `sweep`'s profile points/path points/`cornerRadius`/`closed`, `extrude`'s holes, an arc
244
+ profile's segment specs from `roundedProfile`, and the tessellation from `twist`), so
245
+ changing any of them is a fresh cache node while an identical rebuild is a hit.
171
246
 
172
247
  ---
173
248
 
@@ -348,13 +423,12 @@ stylesheet). `mount` looks up these element IDs:
348
423
  |---|---|
349
424
  | `#app` | viewer canvas mounts here |
350
425
  | `#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"` |
426
+ | `#part` | view-tab bar leave the div **empty**; `mount` generates one button per entry in `part.views` |
352
427
  | `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
353
428
  | `#status`, `#busy`, `#phase` | status line + busy overlay |
354
429
  | `#viewbar` with `#pause` / `#reframe` / `#theme` | optional viewer controls (omit any you don't want) |
355
430
 
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
431
+ Copy `demo.html` and change the title, the panel heading, and the `<script src>`. Two workers are spawned from your one worker entry
358
432
  (`name` = `"manifold"` for preview/STL/3MF, `"occt"` for STEP — handled for you).
359
433
 
360
434
  > Production deploy builds `index.html` only. Extra `*.html` files are **dev-only**
@@ -383,12 +457,10 @@ Tests run under **Node 24** (`nvm use` first; the default shell Node is too old)
383
457
  `npx vitest run`. Build geometry directly off your part with a Manifold kernel:
384
458
 
385
459
  ```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";
460
+ import { bootManifoldKernel } from "partforge/testing";
461
+ import part from "../src/parts/<part>.js";
389
462
 
390
- const w = await Module(); w.setup();
391
- const k = createManifoldKernel(w, { quality: "preview" });
463
+ const k = await bootManifoldKernel();
392
464
  const solid = part.parts.<name>.build(k, part.defaults, part.derive?.(part.defaults) ?? {});
393
465
  expect(solid.toMesh().triangles).toBeGreaterThan(0);
394
466
  ```
@@ -398,20 +470,19 @@ its assembly pose and returns any interpenetrating pair with its overlap volume
398
470
  parts meant to fit (e.g. seated in a pocket) read ~0 and don't trip it:
399
471
 
400
472
  ```js
401
- import { assemblyOverlaps } from "../../src/framework/assembly.js";
473
+ import { assemblyOverlaps } from "partforge/testing";
402
474
  test("assembly has no interpenetrating parts", () => {
403
475
  expect(assemblyOverlaps(k, part, "<view>", {})).toEqual([]); // [{a,b,volume}] on failure
404
476
  });
405
477
  ```
406
478
 
407
- See `test/parts/drum-assembly.test.js` for a real example, and `test/framework/jobs.test.js`
479
+ See `test/framework/assembly.test.js` for a real example, and `test/framework/jobs.test.js`
408
480
  for exporting through the job loop.
409
481
 
410
- **OCCT tests** (STEP / B-rep parity) boot via `bootOcctKernel()` in `test/occt-kernel.js`.
482
+ **OCCT tests** (STEP / B-rep) boot the OCCT kernel with `bootOcctKernel()` from
483
+ `partforge/testing` (in a `beforeAll`) — see `test/occt-backend.test.js`.
411
484
  **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).
485
+ own files (vitest isolates files).
415
486
 
416
487
  ---
417
488
 
@@ -424,11 +495,12 @@ check it without opening the app:
424
495
  npx partforge measure src/parts/<part>.js [view] # geometric facts
425
496
  npx partforge render src/parts/<part>.js [view] # canonical-angle PNGs
426
497
 
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
498
+ `measure` prints a report: per sub-part and per view it reports bounding box,
499
+ volume, surface area, triangle count, whether the solid is watertight, and the
500
+ number of through-holes (genus), plus an assembly overlap check. It exits non-zero
501
+ if any sub-part isn't watertight or any parts interpenetrate — so it doubles as a
502
+ CI/agent gate. Add `--json` to also dump the report as JSON on stdout, or
503
+ `--out report.json` to write it to a file (nothing is written otherwise). (Manifold output is
432
504
  manifold by construction, so `watertight` is mainly a build-sanity check for
433
505
  empty/degenerate results; `holes` is the informative topology number.)
434
506
 
@@ -536,7 +608,7 @@ See `src/parts/filleted-box.js` for the worked example.
536
608
 
537
609
  **Automatic backend selection.** Before building, the framework runs a geometry-free *probe*
538
610
  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
611
+ everything else (so sweep-heavy parts, e.g. helical grooves, stay fast). Force it with
540
612
  `meta.backend: "occt" | "manifold"` if you ever need to. Because an OCCT part is built
541
613
  entirely on OCCT, its fillets are exact in the STEP **and** present in the printed STL.
542
614
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.6.1",
3
+ "version": "0.8.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",
@@ -0,0 +1,10 @@
1
+ import vasePart from "./parts/faceted-vase.js";
2
+ import { mount } from "./framework/index.js";
3
+
4
+ // Dev-only example app for the faceted vase part. Identical wiring to app-planter.js —
5
+ // only the imported definition and the worker entry differ per part. `npm run dev`,
6
+ // then open /faceted-vase.html.
7
+ mount(vasePart, {
8
+ createWorker: (name) =>
9
+ new Worker(new URL("./faceted-vase-worker.js", import.meta.url), { type: "module", name }),
10
+ });
@@ -0,0 +1,3 @@
1
+ import part from "./parts/faceted-vase.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
@@ -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
+ }