partforge 0.3.3 → 0.5.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 CHANGED
@@ -50,6 +50,11 @@ hide internal params, and keep the interface simple while staying deeply adjusta
50
50
  `src/parts/demo.js` is a minimal worked example; run `npm run dev` and open
51
51
  `/demo.html` to see it live.
52
52
 
53
+ - **Agent clarification (`request-a-pick`):** an external tool can ask the user to click
54
+ geometry and get the `Selection` back — serve with `?pickserver`, drive with
55
+ `partforge pick-serve` + `partforge pick "<prompt>" …`. See
56
+ `skills/partforge/SKILL.md` and the authoring guide.
57
+
53
58
  ## License
54
59
 
55
60
  MIT
package/bin/cli.js CHANGED
@@ -8,54 +8,76 @@ import { detectBackend } from "../src/framework/geometry/probe.js";
8
8
  import { bootOcctKernel } from "../src/testing/occt.js";
9
9
  import { measure } from "../src/testing/measure.js";
10
10
  import { renderViews } from "../src/testing/render.js";
11
+ import { createPickServer, requestPicks, formatPickResult } from "../src/framework/pick-request/server.js";
11
12
 
12
13
  const die = (msg) => { console.error(msg); process.exit(1); };
13
14
  const slug = (s) => String(s).toLowerCase().replace(/\s+/g, "-");
14
15
 
15
- const [, , cmd, partPath, ...rest] = process.argv;
16
+ const [, , cmd, ...args] = process.argv;
16
17
  const flags = {};
17
18
  const positional = [];
18
- for (let i = 0; i < rest.length; i++) {
19
- if (rest[i].startsWith("--")) {
20
- const key = rest[i].slice(2);
21
- flags[key] = rest[i + 1] && !rest[i + 1].startsWith("--") ? rest[++i] : true;
22
- } else positional.push(rest[i]);
19
+ for (let i = 0; i < args.length; i++) {
20
+ if (args[i].startsWith("--")) {
21
+ const key = args[i].slice(2);
22
+ flags[key] = args[i + 1] && !args[i + 1].startsWith("--") ? args[++i] : true;
23
+ } else positional.push(args[i]);
23
24
  }
24
- const view = positional[0];
25
25
 
26
- if (!["measure", "render"].includes(cmd)) die("usage: partforge <measure|render> <part-module> [view] [flags]");
27
- if (!partPath) die(`usage: partforge ${cmd} <part-module> [view]`);
26
+ const USAGE = "usage: partforge <measure|render|pick-serve|pick> ";
28
27
 
29
- const mod = await import(pathToFileURL(resolve(process.cwd(), partPath)))
30
- .catch((e) => die(`cannot load part "${partPath}": ${e.message}`));
31
- const part = mod.default;
32
- if (!part?.parts || !part?.views) die(`"${partPath}" has no default-exported PartDefinition`);
33
-
34
- let kernel;
35
- if (detectBackend(part) === "occt") {
36
- kernel = await bootOcctKernel();
37
- } else {
38
- const wasm = await Module(); wasm.setup();
39
- kernel = createManifoldKernel(wasm, { quality: "preview" });
28
+ // --- pick-serve / pick: no part module, no kernel boot --------------------------
29
+ if (cmd === "pick-serve") {
30
+ const port = Number(flags.port) || 4518;
31
+ const timeoutMs = (Number(flags.timeout) || 120) * 1000;
32
+ const { port: bound } = await createPickServer({ port, timeoutMs }).start();
33
+ console.log(`partforge pick-server listening on http://127.0.0.1:${bound}`);
34
+ // keep the process alive serving requests
35
+ } else if (cmd === "pick") {
36
+ if (positional.length === 0) die('usage: partforge pick "<prompt>" ["<prompt>" …] [--port N]');
37
+ const port = Number(flags.port) || 4518;
38
+ const out = await requestPicks({ port, prompts: positional }).catch((e) => die(e.message));
39
+ console.log(formatPickResult(out));
40
+ process.exit(out.status === "done" ? 0 : 1);
41
+ } else if (!["measure", "render"].includes(cmd)) {
42
+ die(USAGE);
40
43
  }
41
44
 
42
- try {
43
- if (cmd === "measure") {
44
- const report = measure(kernel, part, view);
45
- printMeasure(report);
46
- const file = `measure-${slug(report.part)}-${report.view}.json`;
47
- writeFileSync(file, JSON.stringify(report, null, 2));
48
- console.log(`\nwrote ${file}`);
49
- if (flags.json) console.log(JSON.stringify(report, null, 2));
50
- process.exit(report.ok ? 0 : 1);
45
+ const partPath = positional[0];
46
+ const view = positional[1];
47
+ if (["measure", "render"].includes(cmd) && !partPath) die(`usage: partforge ${cmd} <part-module> [view]`);
48
+
49
+ if (["measure", "render"].includes(cmd)) {
50
+ const mod = await import(pathToFileURL(resolve(process.cwd(), partPath)))
51
+ .catch((e) => die(`cannot load part "${partPath}": ${e.message}`));
52
+ const part = mod.default;
53
+ if (!part?.parts || !part?.views) die(`"${partPath}" has no default-exported PartDefinition`);
54
+
55
+ let kernel;
56
+ if (detectBackend(part) === "occt") {
57
+ kernel = await bootOcctKernel();
51
58
  } else {
52
- const views = typeof flags.views === "string" ? flags.views.split(",") : undefined;
53
- const files = await renderViews(kernel, part, view, { views, out: flags.out || "render" });
54
- for (const f of files) console.log(`wrote ${f}`);
55
- process.exit(0);
59
+ const wasm = await Module(); wasm.setup();
60
+ kernel = createManifoldKernel(wasm, { quality: "preview" });
61
+ }
62
+
63
+ try {
64
+ if (cmd === "measure") {
65
+ const report = measure(kernel, part, view);
66
+ printMeasure(report);
67
+ const file = `measure-${slug(report.part)}-${report.view}.json`;
68
+ writeFileSync(file, JSON.stringify(report, null, 2));
69
+ console.log(`\nwrote ${file}`);
70
+ if (flags.json) console.log(JSON.stringify(report, null, 2));
71
+ process.exit(report.ok ? 0 : 1);
72
+ } else {
73
+ const views = typeof flags.views === "string" ? flags.views.split(",") : undefined;
74
+ const files = await renderViews(kernel, part, view, { views, out: flags.out || "render" });
75
+ for (const f of files) console.log(`wrote ${f}`);
76
+ process.exit(0);
77
+ }
78
+ } catch (e) {
79
+ die(`${cmd} failed: ${e.message || e}`);
56
80
  }
57
- } catch (e) {
58
- die(`${cmd} failed: ${e.message || e}`);
59
81
  }
60
82
 
61
83
  function printMeasure(r) {
@@ -88,7 +88,7 @@ handles. The same code runs on **Manifold** (fast meshes — preview + STL + 3MF
88
88
  |---|---|
89
89
  | `k.cylinder(rBottom, rTop, h, { center? })` | cylinder/cone along +Z (frustum if radii differ) |
90
90
  | `k.box(min, max)` | axis-aligned box from `[x,y,z]` min/max |
91
- | `k.prism(points2D, h)` | extrude a 2-D polygon (CCW `[[x,y],…]`) from z=0 |
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) |
92
92
  | `k.sphere(r)` | sphere centred at the origin |
93
93
  | `k.revolve(points2D, { degrees })` | revolve a lathe profile `[[r,z],…]` (r ≥ 0) around the Z axis (full or partial) |
94
94
  | `k.helixSweptTube({ pathR, profileR, pitch, turns, z0, lefthand })` | circle swept along a helix (e.g. a rope groove) |
@@ -108,6 +108,7 @@ entry pulls in the DOM viewer/controls, and your build functions run in a Web Wo
108
108
  | `s.translate([x,y,z])` | move |
109
109
  | `s.rotate(deg, center, axis)` | rotate `deg` about `axis` through `center` |
110
110
  | `s.mirror("XY"\|"XZ"\|"YZ")` | mirror across a plane |
111
+ | `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 |
111
112
  | `s.clone()` | independent copy (replicad consumes solids on transform) |
112
113
  | `s.boundingBox()` | `{ min, max, center, size }` axis-aligned bounds (query) |
113
114
  | `s.volume()` | volume in mm³ (Manifold) |
@@ -117,6 +118,18 @@ entry pulls in the DOM viewer/controls, and your build functions run in a Web Wo
117
118
  You normally only call the *make/combine/transform* ops; the framework handles
118
119
  `toMesh`/`toSTL`/`toIndexedMesh`/`toSTEP`. Units are millimetres.
119
120
 
121
+ ### Caching & determinism
122
+
123
+ The preview kernel memoizes geometry by content hash, so editing a parameter only
124
+ re-runs the operations that parameter actually affects. For this to be sound, a
125
+ `build` must be a **pure function of `(k, p, d)`** — no `Math.random`, no clock, no
126
+ module-level mutable state. An impure build will silently return stale geometry.
127
+
128
+ Cache granularity follows the operations you call. Booleans and heavy primitives are
129
+ cached; cheap transforms are recomputed. To make a multi-step shape into a single
130
+ cache node, use (or add) a **compound op** like `k.boredCylinder({ od, h, bore })` —
131
+ it hashes from its own arguments and never exposes its internals to the cache.
132
+
120
133
  ---
121
134
 
122
135
  ## Parameters: the control-panel schema
@@ -250,6 +263,10 @@ Pure helpers from `partforge/geometry` (no backend dependency):
250
263
  `slotPolygon(length,r)` (overall length = `length + 2r`), `starPolygon(points,outerR,innerR)`,
251
264
  `ringSectorPolygon(innerR,outerR,arcDeg)` (**arcDeg < 360** — a full ring is a contour-with-hole;
252
265
  cut an inner cylinder from an outer one instead).
266
+ `circleProfile(r, center?)` — a circle of radius `r` centered at `[cx,cy]` (default origin).
267
+ Compose it for round solids: `k.prism(circleProfile(r), h)` is a cylinder, and
268
+ **a torus is `k.revolve(circleProfile(minorR, [majorR, 0]))`** (with `majorR > minorR`) —
269
+ partforge has no `torus` primitive because it's just a revolved circle.
253
270
 
254
271
  **Patterns** (return `Solid[]` — feed to `k.union(...)` for features or `s.cutAll(...)` for holes):
255
272
  `linearPattern(solid, count, [dx,dy,dz])`, `circularPattern(solid, count, { center, axis, angle, rotateCopies })`.
@@ -450,3 +467,21 @@ entirely on OCCT, its fillets are exact in the STEP **and** present in the print
450
467
  `place(..., { purpose: "export" })` may depend on `view`.
451
468
  - Keep geometry backend-agnostic (kernel calls only) so it works in both backends; only
452
469
  STEP requires OCCT.
470
+
471
+ ---
472
+
473
+ ## Interactive clarification: request-a-pick
474
+
475
+ An external tool (e.g. an AI agent editing your part) can ask the *user* to click
476
+ geometry and receive the `Selection` back, closing the loop in the other direction
477
+ from `?pick`.
478
+
479
+ - Serve your app with **`?pickserver`** (or `?pickserver=http://host:port`) to enable
480
+ it. While idle nothing changes; when the local pick-server requests a click, a banner
481
+ appears ("🤖 Claude needs you to click …") and the picker arms for one click.
482
+ - The agent side runs `partforge pick-serve` once, then `partforge pick "<prompt>" …`
483
+ for one or more clicks (collected in order, returned together). The CLI blocks until
484
+ the user clicks, then prints the `Selection`(s) as JSON.
485
+
486
+ See the bundled skill `skills/partforge/SKILL.md` for the agent workflow. This is plain
487
+ click-routing — no LLM logic lives in partforge.
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.3.3",
3
+ "version": "0.5.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",
7
- "repository": { "type": "git", "url": "git+https://github.com/scottsykora/partforge.git" },
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/scottsykora/partforge.git"
10
+ },
8
11
  "homepage": "https://github.com/scottsykora/partforge#readme",
9
12
  "bugs": "https://github.com/scottsykora/partforge/issues",
10
13
  "engines": {
@@ -13,6 +16,7 @@
13
16
  "files": [
14
17
  "src",
15
18
  "bin",
19
+ "skills/partforge/SKILL.md",
16
20
  "docs/AUTHORING-PARTS.md",
17
21
  "README.md"
18
22
  ],
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: partforge-request-pick
3
+ description: Use when editing a partforge part for a user who has the live app open and you need them to point at geometry — ask for one or more clicks and get the Selection(s) back.
4
+ ---
5
+
6
+ # partforge: request-a-pick
7
+
8
+ When you're editing a partforge part and you're unsure *which* face, edge, hole, or
9
+ sub-part the user means, don't guess — ask them to click it in the live app. Their
10
+ click comes back to you as a structured `Selection` (sub-part, local CAD point,
11
+ surface normal, the parameters they were viewing).
12
+
13
+ ## When to use
14
+
15
+ - The user's request is geometrically ambiguous ("make this thicker", "fillet that
16
+ edge", "move the hole") and more than one feature could match.
17
+ - You need a concrete location/normal to drive an edit.
18
+
19
+ ## One-time setup (per session)
20
+
21
+ Start the pick-server (it bridges the app and this CLI). The user must have the app
22
+ open with `?pickserver` (e.g. `http://localhost:5173/?pickserver`).
23
+
24
+ ```bash
25
+ partforge pick-serve & # default http://127.0.0.1:4518
26
+ ```
27
+
28
+ ## Requesting clicks
29
+
30
+ Ask for one or many — they're collected in order and returned together:
31
+
32
+ ```bash
33
+ partforge pick "click the face you want filleted"
34
+ partforge pick "click the mounting hole" "click the top edge" "click the boss"
35
+ ```
36
+
37
+ Tell the user out loud to check their browser ("I've put a prompt in your browser —
38
+ click the face you want filleted"). The command **blocks** until they click (or
39
+ timeout), then prints a summary plus JSON:
40
+
41
+ ```json
42
+ { "status": "done", "picks": [ { "prompt": "...", "selection": { "subPart": "...", "point": [...], "normal": [...], "params": {...} } } ] }
43
+ ```
44
+
45
+ Picks come back **in request order**, each echoing its prompt, so you can map them.
46
+
47
+ ## Handling outcomes
48
+
49
+ - `done` — proceed with the returned `selection`(s).
50
+ - `timeout` — the user didn't click in time; `picks` holds any collected so far. Ask
51
+ again or fall back to asking in words.
52
+ - `cancelled` — the user clicked "Can't find it"; reconsider what you're asking for.
53
+ - `busy` (exit non-zero) — a request is already in flight; wait and retry.
54
+
55
+ ## Notes
56
+
57
+ - This only *reads* a click — it never edits files. You make the edits yourself after.
58
+ - The server is localhost-only and holds one request at a time.
@@ -163,4 +163,48 @@ button.action:disabled { opacity: .5; cursor: default; }
163
163
  /* relevance: controls/sections that don't affect the on-screen parts */
164
164
  .irrelevant { opacity: 0.45; }
165
165
  .irrelevant:hover { opacity: 0.7; }
166
- .section-hidden { display: none; }
166
+ .section-hidden { display: none; }
167
+
168
+ /* request-a-pick: agent prompt banner, floated top-centre well below the part tabs,
169
+ laid out like a chat message (avatar + text). Slides/fades in when shown; themed. */
170
+ #pf-pick-banner {
171
+ position: fixed; top: 78px; left: 50%; transform: translateX(-50%);
172
+ z-index: 30; max-width: min(56ch, calc(100vw - 24px));
173
+ padding: 10px 36px 10px 12px;
174
+ background: var(--surface); color: var(--text);
175
+ border: 1px solid var(--border); border-radius: 10px;
176
+ box-shadow: 0 6px 24px rgba(0,0,0,.35);
177
+ font-size: 12px; line-height: 1.45;
178
+ animation: pf-pick-in .18s ease;
179
+ }
180
+ #pf-pick-banner .pf-pick-row { display: flex; align-items: center; gap: 10px; }
181
+ #pf-pick-banner .pf-pick-avatar {
182
+ flex: none; width: 30px; height: 30px; border-radius: 50%; font-size: 17px;
183
+ display: flex; align-items: center; justify-content: center;
184
+ background: var(--surface-2); border: 1px solid var(--border);
185
+ }
186
+ #pf-pick-banner .pf-pick-msg { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
187
+ #pf-pick-banner .pf-pick-label { color: var(--muted); font-size: 11px; }
188
+ #pf-pick-banner .pf-pick-prompt { color: var(--text-strong); font-weight: 600; }
189
+ #pf-pick-close {
190
+ position: absolute; top: 6px; right: 6px; appearance: none;
191
+ width: 20px; height: 20px; padding: 0;
192
+ border: 1px solid var(--border); border-radius: 6px;
193
+ background: var(--surface-2); color: var(--muted);
194
+ cursor: pointer; font-size: 13px; line-height: 1;
195
+ display: flex; align-items: center; justify-content: center;
196
+ }
197
+ #pf-pick-close:hover { color: var(--text-strong); border-color: var(--accent); }
198
+ @keyframes pf-pick-in {
199
+ from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
200
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
201
+ }
202
+ /* dev aid (request-a-pick mode): preview the prompt banner without an agent */
203
+ #pf-pick-test {
204
+ position: fixed; left: 12px; bottom: 12px; z-index: 30; appearance: none;
205
+ padding: 6px 10px; border: 1px solid var(--border); border-radius: 8px;
206
+ background: var(--surface); color: var(--muted-2); cursor: pointer;
207
+ font: 12px/1 -apple-system, system-ui, sans-serif;
208
+ box-shadow: 0 6px 24px rgba(0,0,0,.35);
209
+ }
210
+ #pf-pick-test:hover { color: var(--text); border-color: var(--accent); }
@@ -0,0 +1,40 @@
1
+ // src/framework/debug-overlay.js
2
+ // A dev-only overlay (shown under ?debug) with a caching on/off toggle and a
3
+ // readout of the last build's time + what the cache did. Self-contained: it
4
+ // creates its own DOM and knows nothing about geometry — mount.js wires it in.
5
+ export function createDebugOverlay({ initialCachingOn = true, onToggle } = {}) {
6
+ const box = document.createElement("div");
7
+ box.id = "pf-debug";
8
+ Object.assign(box.style, {
9
+ position: "fixed", bottom: "12px", right: "12px", zIndex: "9999",
10
+ font: "12px ui-monospace, monospace", background: "rgba(0,0,0,0.7)",
11
+ color: "#e6e6e6", padding: "8px 10px", borderRadius: "6px",
12
+ lineHeight: "1.5", whiteSpace: "pre",
13
+ });
14
+
15
+ const label = document.createElement("label");
16
+ label.style.cssText = "display:block;cursor:pointer;margin-bottom:4px";
17
+ const cb = document.createElement("input");
18
+ cb.type = "checkbox";
19
+ cb.checked = initialCachingOn;
20
+ cb.style.marginRight = "6px";
21
+ label.append(cb, document.createTextNode("Caching"));
22
+
23
+ const readout = document.createElement("div");
24
+ readout.textContent = "build: —";
25
+
26
+ box.append(label, readout);
27
+ document.body.appendChild(box);
28
+
29
+ cb.addEventListener("change", () => onToggle?.(cb.checked));
30
+
31
+ return {
32
+ update({ ms, hits = 0, misses = 0, skipped = 0, rebuilt = 0 } = {}) {
33
+ const l2 = cb.checked ? `${hits} hit / ${misses} miss` : "off";
34
+ readout.textContent =
35
+ `build: ${ms != null ? Math.round(ms) + " ms" : "—"}\n` +
36
+ `L2 ops: ${l2}\n` +
37
+ `L1 parts: ${skipped} skipped / ${rebuilt} rebuilt`;
38
+ },
39
+ };
40
+ }
@@ -3,6 +3,7 @@
3
3
 
4
4
  /**
5
5
  * @typedef {Object} Solid An opaque handle to a backend solid.
6
+ * @property {string} _hash content hash (Manifold backend only; drives the worker solid cache)
6
7
  * @property {(tool: Solid) => Solid} cut
7
8
  * @property {(tools: Solid[]) => Solid} cutAll batch subtract (backend-optimized)
8
9
  * @property {(other: Solid) => Solid} intersect boolean intersection (Manifold)
@@ -12,6 +13,7 @@
12
13
  * @property {(v: number[]) => Solid} translate
13
14
  * @property {(deg: number, center: number[], axis: number[]) => Solid} rotate
14
15
  * @property {(plane: "XY"|"XZ"|"YZ") => Solid} mirror
16
+ * @property {(factor:number, center?:number[]) => Solid} scale uniform scale about center (default origin)
15
17
  * @property {() => number} volume solid volume in mm³ (Manifold; used by collision tests)
16
18
  * @property {(opts?: {quality?: "preview"|"print"}) => {positions:Float32Array, normals:Float32Array, indices:Uint32Array, triangles:number}} toMesh
17
19
  * @property {(opts?: {quality?: "preview"|"print"}) => Promise<ArrayBuffer>} toSTL
@@ -19,9 +21,10 @@
19
21
  *
20
22
  * @typedef {Object} GeometryKernel
21
23
  * @property {(rBottom:number, rTop:number, h:number, opts?:{center?:boolean}) => Solid} cylinder
24
+ * @property {(o:{od:number,h:number,bore:number}) => Solid} boredCylinder compound: bored-through cylinder (one cache node)
22
25
  * @property {(r:number) => Solid} sphere sphere centred at the origin
23
26
  * @property {(min:number[], max:number[]) => Solid} box
24
- * @property {(points2D:number[][], h:number) => Solid} prism extrude polygon from z=0
27
+ * @property {(points2D:number[][], h:number, opts?:{twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0 (optional twist° + uniform top taper)
25
28
  * @property {(points2D:number[][], opts?:{degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z
26
29
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
27
30
  * @property {(solids:Solid[]) => Solid} union
@@ -1,5 +1,7 @@
1
1
  import { helixTube } from "./helix-tube.js";
2
2
  import { KernelCapabilityError } from "./errors.js";
3
+ import { h } from "./solid-hash.js";
4
+ import { createSolidCache } from "./solid-cache.js";
3
5
 
4
6
  const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
5
7
  // 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
@@ -22,6 +24,14 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
22
24
  const T = (obj) => { tracked.push(obj); return obj; };
23
25
  const unionRaw = (ms) => ms.reduce((a, b) => T(a.add(b))); // track each reduce step
24
26
 
27
+ const cache = createSolidCache();
28
+ // Boundary ops route through cache.lookup; on a miss `make` runs the WASM op,
29
+ // tracks the result, and returns the triple the cache needs to pin/dispose it.
30
+ const cached = (hash, computeM) => cache.lookup(hash, () => {
31
+ const m = computeM(); // already T()-tracked by the op
32
+ return { value: wrap(m, hash), pin: m, dispose: () => m.delete?.() };
33
+ });
34
+
25
35
  // Copy the mesh out into JS-owned arrays (so it survives cleanup) and free the
26
36
  // transient mesh handle.
27
37
  function meshOut(m, asStl) {
@@ -48,12 +58,14 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
48
58
  return { positions, indices };
49
59
  }
50
60
 
51
- const wrap = (m) => ({
61
+ const wrap = (m, hash) => ({
52
62
  _m: m,
53
- cut: (t) => wrap(T(m.subtract(t._m))),
54
- cutAll: (tools) => wrap(T(m.subtract(unionRaw(tools.map((t) => t._m))))),
55
- intersect: (t) => wrap(T(m.intersect(t._m))),
56
- clone: () => wrap(m),
63
+ _hash: hash,
64
+ cut: (t) => cached(h("cut", hash, t._hash), () => T(m.subtract(t._m))),
65
+ cutAll: (tools) => cached(h("cutAll", hash, tools.map((t) => t._hash)),
66
+ () => T(m.subtract(unionRaw(tools.map((t) => t._m))))),
67
+ intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
68
+ clone: () => wrap(m, hash),
57
69
  boundingBox: () => {
58
70
  const b = m.boundingBox(); // { min: Vec3, max: Vec3 }
59
71
  const min = [...b.min], max = [...b.max];
@@ -66,14 +78,20 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
66
78
  volume: () => m.volume(),
67
79
  genus: () => m.genus(),
68
80
  isEmpty: () => m.isEmpty(),
69
- translate: (v) => wrap(T(m.translate(v))),
81
+ translate: (v) => wrap(T(m.translate(v)), h("translate", hash, v)),
70
82
  rotate: (deg, center, axis) => {
71
83
  const euler = [axis[0] * deg, axis[1] * deg, axis[2] * deg];
72
84
  const a = T(m.translate([-center[0], -center[1], -center[2]]));
73
85
  const b = T(a.rotate(euler));
74
- return wrap(T(b.translate(center)));
86
+ return wrap(T(b.translate(center)), h("rotate", hash, deg, center, axis));
87
+ },
88
+ mirror: (plane) => wrap(T(m.mirror(PLANE_NORMAL[plane])), h("mirror", hash, plane)),
89
+ scale: (factor, center = [0, 0, 0]) => {
90
+ if (!(factor > 0)) throw new Error("scale: factor must be > 0");
91
+ const a = T(m.translate([-center[0], -center[1], -center[2]]));
92
+ const b = T(a.scale([factor, factor, factor]));
93
+ return wrap(T(b.translate(center)), h("scale", hash, factor, center));
75
94
  },
76
- mirror: (plane) => wrap(T(m.mirror(PLANE_NORMAL[plane]))),
77
95
  toMesh: () => meshOut(m, false),
78
96
  toSTL: () => Promise.resolve(meshOut(m, true)),
79
97
  toIndexedMesh: () => indexedMeshOut(m),
@@ -83,26 +101,45 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
83
101
  });
84
102
 
85
103
  return {
86
- cylinder: (rb, rt, h, { center = false } = {}) => wrap(T(Manifold.cylinder(h, rb, rt, segs, center))),
87
- sphere: (r) => wrap(T(Manifold.sphere(r, segs))),
104
+ cylinder: (rb, rt, h2, { center = false } = {}) =>
105
+ wrap(T(Manifold.cylinder(h2, rb, rt, segs, center)), h("cylinder", rb, rt, h2, center, segs)),
106
+ // Compound op: hashed ATOMICALLY from its own args, so it is a single cache
107
+ // node — its internal cylinders/cut are never retained. The template for
108
+ // future compounds: build internals with T(), return the final tracked solid.
109
+ boredCylinder: ({ od, h: height, bore }) => cached(h("boredCylinder", od, height, bore, segs), () => {
110
+ const body = T(Manifold.cylinder(height, od / 2, od / 2, segs, false));
111
+ const tool0 = T(Manifold.cylinder(height + 4, bore / 2, bore / 2, segs, false));
112
+ const tool = T(tool0.translate([0, 0, -2])); // raw ops: track each result
113
+ return T(body.subtract(tool));
114
+ }),
115
+ sphere: (r) => wrap(T(Manifold.sphere(r, segs)), h("sphere", r, segs)),
88
116
  box: (min, max) => {
89
117
  const cube = T(Manifold.cube([max[0] - min[0], max[1] - min[1], max[2] - min[2]]));
90
- return wrap(T(cube.translate(min)));
91
- },
92
- prism: (pts, h) => {
93
- const cs = T(CrossSection.ofPolygons([pts]));
94
- return wrap(T(cs.extrude(h)));
95
- },
96
- helixSweptTube: (o) => wrap(T(helixTube(wasm, { ...o, ...tube }))),
97
- revolve: (pts, { degrees = 360 } = {}) => {
98
- for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
99
- return wrap(T(Manifold.revolve([pts], segs, degrees)));
118
+ return wrap(T(cube.translate(min)), h("box", min, max));
100
119
  },
101
- union: (solids) => wrap(unionRaw(solids.map((s) => s._m))), // unionRaw already tracks its result
120
+ prism: (pts, height, { twist = 0, scaleTop = 1 } = {}) =>
121
+ cached(h("prism", pts, height, twist, scaleTop, segs), () => {
122
+ if (scaleTop < 0) throw new Error("prism: scaleTop must be ≥ 0");
123
+ const cs = T(CrossSection.ofPolygons([pts]));
124
+ if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
125
+ const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
126
+ return T(cs.extrude(height, nDiv, twist, scaleTop));
127
+ }),
128
+ helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
129
+ revolve: (pts, { degrees = 360 } = {}) =>
130
+ cached(h("revolve", pts, degrees, segs), () => {
131
+ for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
132
+ return T(Manifold.revolve([pts], segs, degrees));
133
+ }),
134
+ union: (solids) => cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
102
135
  toSTEP: () => { throw new Error("STEP export not supported by the Manifold backend"); },
103
- // Free every WASM object created since the last cleanup. Call after each job
104
- // once its meshes/buffers have been copied out (meshOut already did).
105
- cleanup: () => { for (const o of tracked) o.delete?.(); tracked.length = 0; },
136
+ beginSubPart: (name) => cache.begin(name),
137
+ endSubPart: () => cache.end(),
138
+ cacheStats: () => cache.stats(),
139
+ resetCacheStats: () => cache.resetStats(),
140
+ // Free every WASM object created since the last cleanup EXCEPT solids the cache
141
+ // still pins (they must survive for the next build to resume from them).
142
+ cleanup: () => { for (const o of tracked) if (!cache.isPinned(o)) o.delete?.(); tracked.length = 0; },
106
143
  };
107
144
  }
108
145
 
@@ -93,6 +93,10 @@ export function createOcctKernel(replicad) {
93
93
  translate: (v) => wrap(shape.translate(v)),
94
94
  rotate: (deg, center, axis) => wrap(shape.rotate(deg, center, axis)),
95
95
  mirror: (plane) => wrap(shape.mirror(plane)),
96
+ scale: (factor, center = [0, 0, 0]) => {
97
+ if (!(factor > 0)) throw new Error("scale: factor must be > 0");
98
+ return wrap(shape.scale(factor, center));
99
+ },
96
100
  toMesh: ({ quality = "preview" } = {}) => {
97
101
  const m = shape.mesh(MESH[quality]);
98
102
  return {
@@ -127,10 +131,16 @@ export function createOcctKernel(replicad) {
127
131
  };
128
132
 
129
133
  // extrude a 2-D polygon from z=0
130
- const prism = (pts, h) => {
134
+ const prism = (pts, h, { twist = 0, scaleTop = 1 } = {}) => {
135
+ if (scaleTop < 0) throw new Error("prism: scaleTop must be ≥ 0");
131
136
  let pen = draw(pts[0]);
132
137
  for (let i = 1; i < pts.length; i++) pen = pen.lineTo(pts[i]);
133
- return wrap(pen.close().sketchOnPlane("XY").extrude(h));
138
+ const sketch = pen.close().sketchOnPlane("XY");
139
+ if (twist === 0 && scaleTop === 1) return wrap(sketch.extrude(h));
140
+ const cfg = {};
141
+ if (twist !== 0) cfg.twistAngle = twist;
142
+ if (scaleTop !== 1) cfg.extrusionProfile = { profile: "linear", endFactor: scaleTop };
143
+ return wrap(sketch.extrude(h, cfg));
134
144
  };
135
145
 
136
146
  // revolve a lathe profile [[r,z],…] around the Z axis (degrees defaults to 360)
@@ -152,7 +162,10 @@ export function createOcctKernel(replicad) {
152
162
  };
153
163
 
154
164
  return {
155
- cylinder, box: (min, max) => wrap(makeBox(min, max)), prism, revolve, helixSweptTube,
165
+ cylinder,
166
+ boredCylinder: ({ od, h, bore }) =>
167
+ cylinder(od / 2, od / 2, h).cut(cylinder(bore / 2, bore / 2, h + 4).translate([0, 0, -2])),
168
+ box: (min, max) => wrap(makeBox(min, max)), prism, revolve, helixSweptTube,
156
169
  sphere: (r) => wrap(makeSphere(r)),
157
170
  union: (solids) => wrap(solids.map((s) => s._s).reduce((a, b) => a.fuse(b))),
158
171
  toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._s }))).arrayBuffer(),
@@ -124,3 +124,17 @@ export function circularPattern(solid, count, { center = [0, 0, 0], axis = "Z",
124
124
  }
125
125
  return out;
126
126
  }
127
+
128
+ // CCW circle of radius r centered at [cx, cy]. A shared 2-D profile primitive:
129
+ // compose with the kernel's profile ops — e.g. revolve(circleProfile(minorR,
130
+ // [majorR, 0])) is a torus, prism(circleProfile(r), h) a cylinder.
131
+ export function circleProfile(r, center = [0, 0], segs = 48) {
132
+ if (!(r > 0)) throw new Error("circleProfile: r must be > 0");
133
+ const [cx, cy] = center;
134
+ const pts = [];
135
+ for (let i = 0; i < segs; i++) {
136
+ const a = (2 * Math.PI * i) / segs;
137
+ pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]);
138
+ }
139
+ return pts;
140
+ }
@@ -15,6 +15,7 @@ export function createProbeKernel() {
15
15
  translate() { note("translate"); return proxy; },
16
16
  rotate() { note("rotate"); return proxy; },
17
17
  mirror() { note("mirror"); return proxy; },
18
+ scale() { note("scale"); return proxy; },
18
19
  fillet() { note("fillet"); return proxy; },
19
20
  chamfer() { note("chamfer"); return proxy; },
20
21
  shell() { note("shell"); return proxy; },
@@ -25,6 +26,7 @@ export function createProbeKernel() {
25
26
  };
26
27
  const kernel = {
27
28
  cylinder() { note("cylinder"); return proxy; },
29
+ boredCylinder() { note("boredCylinder"); return proxy; },
28
30
  sphere() { note("sphere"); return proxy; },
29
31
  box() { note("box"); return proxy; },
30
32
  prism() { note("prism"); return proxy; },
@@ -0,0 +1,39 @@
1
+ // Worker-side cache of boundary-op solids, partitioned per sub-part. Retention is
2
+ // bounded to the CURRENT build's graph: each begin()/end() bracket rebuilds a
3
+ // sub-part's retained set from scratch, disposing any entry not re-used this round.
4
+ // WASM-agnostic — it stores opaque {value, pin, dispose} triples supplied by the
5
+ // caller (the Manifold backend), so it is unit-testable with plain objects.
6
+ export function createSolidCache() {
7
+ const caches = new Map(); // name -> Map(hash -> { value, pin, dispose })
8
+ const pinned = new Set(); // every live `pin` across all sub-parts
9
+ let name = null, active = null, prev = null;
10
+ let hits = 0, misses = 0;
11
+
12
+ return {
13
+ begin(n) { name = n; prev = caches.get(n) ?? new Map(); active = new Map(); },
14
+
15
+ end() {
16
+ if (name == null) return;
17
+ for (const [hash, entry] of prev) {
18
+ if (!active.has(hash)) { pinned.delete(entry.pin); entry.dispose(); } // evict
19
+ }
20
+ caches.set(name, active);
21
+ name = null; active = prev = null;
22
+ },
23
+
24
+ lookup(hash, make) {
25
+ if (name == null) return make().value; // not bracketed → no caching
26
+ if (active.has(hash)) { hits++; return active.get(hash).value; }
27
+ if (prev.has(hash)) { hits++; const e = prev.get(hash); active.set(hash, e); return e.value; }
28
+ misses++;
29
+ const entry = make();
30
+ active.set(hash, entry);
31
+ pinned.add(entry.pin);
32
+ return entry.value;
33
+ },
34
+
35
+ isPinned: (pin) => pinned.has(pin),
36
+ stats: () => ({ hits, misses }),
37
+ resetStats: () => { hits = 0; misses = 0; },
38
+ };
39
+ }