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 +5 -0
- package/bin/cli.js +57 -35
- package/docs/AUTHORING-PARTS.md +36 -1
- package/package.json +6 -2
- package/skills/partforge/SKILL.md +58 -0
- package/src/framework/app.css +45 -1
- package/src/framework/debug-overlay.js +40 -0
- package/src/framework/geometry/kernel.js +4 -1
- package/src/framework/geometry/manifold-backend.js +61 -24
- package/src/framework/geometry/occt-backend.js +16 -3
- package/src/framework/geometry/polygon.js +14 -0
- package/src/framework/geometry/probe.js +2 -0
- package/src/framework/geometry/solid-cache.js +39 -0
- package/src/framework/geometry/solid-hash.js +21 -0
- package/src/framework/jobs.js +11 -4
- package/src/framework/mount.js +102 -7
- package/src/framework/param-deps.js +31 -0
- package/src/framework/pick-request/batch.js +42 -0
- package/src/framework/pick-request/client.js +100 -0
- package/src/framework/pick-request/index.js +1 -0
- package/src/framework/pick-request/server.js +158 -0
- package/src/framework/selection/format.js +31 -0
- package/src/framework/selection/index.js +5 -0
- package/src/framework/selection/pick.js +48 -0
- package/src/framework/selection/resolve.js +54 -0
- package/src/framework/viewer.js +15 -2
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,
|
|
16
|
+
const [, , cmd, ...args] = process.argv;
|
|
16
17
|
const flags = {};
|
|
17
18
|
const positional = [];
|
|
18
|
-
for (let i = 0; i <
|
|
19
|
-
if (
|
|
20
|
-
const key =
|
|
21
|
-
flags[key] =
|
|
22
|
-
} else positional.push(
|
|
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
|
-
|
|
27
|
-
if (!partPath) die(`usage: partforge ${cmd} <part-module> [view]`);
|
|
26
|
+
const USAGE = "usage: partforge <measure|render|pick-serve|pick> …";
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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) {
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
|
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
|
+
"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": {
|
|
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.
|
package/src/framework/app.css
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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,
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|