partforge 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -15
- package/bin/cli.js +82 -58
- package/docs/AUTHORING-PARTS.md +48 -23
- package/package.json +1 -1
- package/src/framework/app.css +26 -0
- package/src/framework/assembly.js +6 -9
- package/src/framework/download.js +23 -0
- package/src/framework/geometry/feature-attribution.js +102 -0
- package/src/framework/geometry/kernel-front.js +31 -0
- package/src/framework/geometry/kernel.js +53 -10
- package/src/framework/geometry/manifold-backend.js +45 -24
- package/src/framework/geometry/occt-backend.js +47 -96
- package/src/framework/geometry/occt-repair.js +83 -0
- package/src/framework/geometry/probe.js +37 -30
- package/src/framework/geometry/solid-sugar.js +32 -5
- package/src/framework/geometry-service.js +4 -6
- package/src/framework/jobs.js +40 -18
- package/src/framework/mesh-cache.js +41 -0
- package/src/framework/mount.js +103 -240
- package/src/framework/param-deps.js +9 -18
- package/src/framework/pick-request/server.js +10 -0
- package/src/framework/regen-loop.js +45 -0
- package/src/framework/selection/format.js +2 -6
- package/src/framework/selection/hover.js +128 -0
- package/src/framework/selection/index.js +3 -0
- package/src/framework/selection/pick-toggle.js +34 -0
- package/src/framework/selection/pick.js +7 -30
- package/src/framework/selection/raycast.js +43 -0
- package/src/framework/selection/resolve.js +3 -8
- package/src/framework/status-ui.js +18 -0
- package/src/framework/view-state.js +11 -1
- package/src/framework/view-tabs.js +33 -0
- package/src/framework/viewer-controls.js +48 -0
- package/src/framework/viewer.js +9 -9
- package/src/framework/worker.js +12 -20
- package/src/parts/filleted-box.js +1 -1
- package/src/parts/planter.js +4 -3
- package/src/testing/build.js +3 -6
- package/src/testing/manifold.js +11 -0
- package/src/testing.js +1 -0
- package/src/framework/geometry/fuzzy-cut.js +0 -32
package/README.md
CHANGED
|
@@ -1,15 +1,62 @@
|
|
|
1
1
|
# partforge
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
(`
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
}
|
|
82
|
-
|
|
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
|
-
}
|
|
88
|
-
|
|
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);
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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. `
|
|
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/
|
|
16
|
-
|
|
15
|
+
parametric spacer — the smallest complete part) and **`src/parts/planter.js`** (a
|
|
16
|
+
faceted planter — facets, taper, twist, even walls, an optional feature, a `derive`,
|
|
17
|
+
and a `verify` block). **`src/parts/filleted-box.js`** is the worked example for the
|
|
18
|
+
OCCT-only fillet/chamfer/shell ops.
|
|
17
19
|
|
|
18
20
|
---
|
|
19
21
|
|
|
@@ -114,6 +116,7 @@ entry pulls in the DOM viewer/controls, and your build functions run in a Web Wo
|
|
|
114
116
|
| `s.mirror("XY"\|"XZ"\|"YZ")` | mirror across a plane |
|
|
115
117
|
| `s.scale(factor, center?)` | uniform scale (single factor) about `center` (default origin) — scaling an off-origin part about the origin also moves it; pass a center (e.g. `s.boundingBox().center`) to resize in place |
|
|
116
118
|
| `s.clone()` | independent copy (replicad consumes solids on transform) |
|
|
119
|
+
| `s.label(name)` | name this solid's surface for hover/pick feature attribution; survives transforms + booleans; same name on several solids merges into one feature |
|
|
117
120
|
| `s.boundingBox()` | `{ min, max, center, size }` axis-aligned bounds (query) |
|
|
118
121
|
| `s.volume()` | volume in mm³ (Manifold) |
|
|
119
122
|
| `s.toMesh({ quality })` / `s.toSTL({ quality })` / `s.toIndexedMesh()` | meshes / STL / indexed mesh (3MF) — the framework calls these |
|
|
@@ -157,6 +160,31 @@ magic vectors. Three habits:
|
|
|
157
160
|
The bare `rotate(deg, center, axis)` remains available as the low-level primitive for
|
|
158
161
|
anything `rotateX/Y/Z`/`rotateAbout` can't express, but prefer the vocabulary above.
|
|
159
162
|
|
|
163
|
+
### Naming features (`.label()`)
|
|
164
|
+
|
|
165
|
+
Give build-step solids human-readable names — the viewer's hover tooltip, the
|
|
166
|
+
highlight, and pick selections all use them, so you, the app user, and an agent
|
|
167
|
+
share the same vocabulary ("Make the Drainage hole 10 mm").
|
|
168
|
+
|
|
169
|
+
```js
|
|
170
|
+
const body = k.prism(d.outerPts, p.height, { scaleTop: p.taper }).label("Faceted wall");
|
|
171
|
+
let s = body.cut(cavity.label("Cavity"));
|
|
172
|
+
if (p.drain > 0) s = s.cut(k.cylinder(d.drainR, d.drainR, p.floor + 4).at([0, 0, -2]).label("Drainage hole"));
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
- A label names the solid's **surface** wherever it survives into the final part —
|
|
176
|
+
a cutting tool's label lands on the faces it leaves behind (the hole's wall).
|
|
177
|
+
- Label **after** shaping compound tools (e.g. after an `intersect` clip) and
|
|
178
|
+
either before or after transforms — labels ride through `at`/`rotate`/etc.
|
|
179
|
+
- The **same label on several solids merges into one feature** — label a pattern
|
|
180
|
+
of four holes `"Mounting holes"` and they hover/highlight as one.
|
|
181
|
+
- Unlabeled geometry falls back to the sub-part's `label`. Faces created by
|
|
182
|
+
`fillet`/`chamfer`/`shell` are new surfaces, so they use the fallback too.
|
|
183
|
+
- Works on both backends. On OCCT each label keeps a geometry snapshot for
|
|
184
|
+
mesh-time classification — label a handful of features, not hundreds.
|
|
185
|
+
- Names should describe intent ("Drainage hole", not "cylinder2"); keep them
|
|
186
|
+
unique per sub-part unless you want the merge behavior.
|
|
187
|
+
|
|
160
188
|
### Caching & determinism
|
|
161
189
|
|
|
162
190
|
The preview kernel memoizes geometry by content hash, so editing a parameter only
|
|
@@ -348,13 +376,12 @@ stylesheet). `mount` looks up these element IDs:
|
|
|
348
376
|
|---|---|
|
|
349
377
|
| `#app` | viewer canvas mounts here |
|
|
350
378
|
| `#controls` | control panel is built into this |
|
|
351
|
-
| `#part` | view-tab bar
|
|
379
|
+
| `#part` | view-tab bar — leave the div **empty**; `mount` generates one button per entry in `part.views` |
|
|
352
380
|
| `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
|
|
353
381
|
| `#status`, `#busy`, `#phase` | status line + busy overlay |
|
|
354
382
|
| `#viewbar` with `#pause` / `#reframe` / `#theme` | optional viewer controls (omit any you don't want) |
|
|
355
383
|
|
|
356
|
-
Copy `demo.html` and change the title, the
|
|
357
|
-
heading, and the `<script src>`. Two workers are spawned from your one worker entry
|
|
384
|
+
Copy `demo.html` and change the title, the panel heading, and the `<script src>`. Two workers are spawned from your one worker entry
|
|
358
385
|
(`name` = `"manifold"` for preview/STL/3MF, `"occt"` for STEP — handled for you).
|
|
359
386
|
|
|
360
387
|
> Production deploy builds `index.html` only. Extra `*.html` files are **dev-only**
|
|
@@ -383,12 +410,10 @@ Tests run under **Node 24** (`nvm use` first; the default shell Node is too old)
|
|
|
383
410
|
`npx vitest run`. Build geometry directly off your part with a Manifold kernel:
|
|
384
411
|
|
|
385
412
|
```js
|
|
386
|
-
import
|
|
387
|
-
import
|
|
388
|
-
import part from "../../src/parts/<part>.js";
|
|
413
|
+
import { bootManifoldKernel } from "partforge/testing";
|
|
414
|
+
import part from "../src/parts/<part>.js";
|
|
389
415
|
|
|
390
|
-
const
|
|
391
|
-
const k = createManifoldKernel(w, { quality: "preview" });
|
|
416
|
+
const k = await bootManifoldKernel();
|
|
392
417
|
const solid = part.parts.<name>.build(k, part.defaults, part.derive?.(part.defaults) ?? {});
|
|
393
418
|
expect(solid.toMesh().triangles).toBeGreaterThan(0);
|
|
394
419
|
```
|
|
@@ -398,20 +423,19 @@ its assembly pose and returns any interpenetrating pair with its overlap volume
|
|
|
398
423
|
parts meant to fit (e.g. seated in a pocket) read ~0 and don't trip it:
|
|
399
424
|
|
|
400
425
|
```js
|
|
401
|
-
import { assemblyOverlaps } from "
|
|
426
|
+
import { assemblyOverlaps } from "partforge/testing";
|
|
402
427
|
test("assembly has no interpenetrating parts", () => {
|
|
403
428
|
expect(assemblyOverlaps(k, part, "<view>", {})).toEqual([]); // [{a,b,volume}] on failure
|
|
404
429
|
});
|
|
405
430
|
```
|
|
406
431
|
|
|
407
|
-
See `test/
|
|
432
|
+
See `test/framework/assembly.test.js` for a real example, and `test/framework/jobs.test.js`
|
|
408
433
|
for exporting through the job loop.
|
|
409
434
|
|
|
410
|
-
**OCCT tests** (STEP / B-rep
|
|
435
|
+
**OCCT tests** (STEP / B-rep) boot the OCCT kernel with `bootOcctKernel()` from
|
|
436
|
+
`partforge/testing` (in a `beforeAll`) — see `test/occt-backend.test.js`.
|
|
411
437
|
**OCCT and Manifold must not boot in the same process** — keep OCCT-booting tests in their
|
|
412
|
-
own files (vitest isolates files).
|
|
413
|
-
+ the `test/fixtures/occt-volumes.json` fixture (regenerate with
|
|
414
|
-
`node scripts/gen-occt-fixtures.mjs` after a geometry change).
|
|
438
|
+
own files (vitest isolates files).
|
|
415
439
|
|
|
416
440
|
---
|
|
417
441
|
|
|
@@ -424,11 +448,12 @@ check it without opening the app:
|
|
|
424
448
|
npx partforge measure src/parts/<part>.js [view] # geometric facts
|
|
425
449
|
npx partforge render src/parts/<part>.js [view] # canonical-angle PNGs
|
|
426
450
|
|
|
427
|
-
`measure` prints a report and
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
451
|
+
`measure` prints a report: per sub-part and per view it reports bounding box,
|
|
452
|
+
volume, surface area, triangle count, whether the solid is watertight, and the
|
|
453
|
+
number of through-holes (genus), plus an assembly overlap check. It exits non-zero
|
|
454
|
+
if any sub-part isn't watertight or any parts interpenetrate — so it doubles as a
|
|
455
|
+
CI/agent gate. Add `--json` to also dump the report as JSON on stdout, or
|
|
456
|
+
`--out report.json` to write it to a file (nothing is written otherwise). (Manifold output is
|
|
432
457
|
manifold by construction, so `watertight` is mainly a build-sanity check for
|
|
433
458
|
empty/degenerate results; `holes` is the informative topology number.)
|
|
434
459
|
|
|
@@ -536,7 +561,7 @@ See `src/parts/filleted-box.js` for the worked example.
|
|
|
536
561
|
|
|
537
562
|
**Automatic backend selection.** Before building, the framework runs a geometry-free *probe*
|
|
538
563
|
of your `build` to see whether it uses a CAD-only op, and routes accordingly — Manifold for
|
|
539
|
-
everything else (so sweep-heavy parts
|
|
564
|
+
everything else (so sweep-heavy parts, e.g. helical grooves, stay fast). Force it with
|
|
540
565
|
`meta.backend: "occt" | "manifold"` if you ever need to. Because an OCCT part is built
|
|
541
566
|
entirely on OCCT, its fillets are exact in the STEP **and** present in the printed STL.
|
|
542
567
|
|
package/package.json
CHANGED
package/src/framework/app.css
CHANGED
|
@@ -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 =
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (sp.place) solid = sp.place(solid, { view, purpose: "display", p, d });
|
|
17
|
-
return { name, solid };
|
|
18
|
-
});
|
|
11
|
+
const { p, d } = resolveParams(part, params);
|
|
12
|
+
const posed = viewSubParts(part, view, p).map((name) => ({
|
|
13
|
+
name,
|
|
14
|
+
solid: buildPosed(kernel, part, name, { purpose: "display", view, p, d }),
|
|
15
|
+
}));
|
|
19
16
|
|
|
20
17
|
const overlaps = [];
|
|
21
18
|
for (let i = 0; i < posed.length; i++) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { zipSync } from "fflate";
|
|
2
|
+
|
|
3
|
+
// Browser file-download helpers. Pure DOM/Blob utilities with no app state — the
|
|
4
|
+
// worker produces the bytes; these just hand them to the browser as a download.
|
|
5
|
+
|
|
6
|
+
// Trigger a download of one binary blob under `filename`.
|
|
7
|
+
export function triggerDownload(data, filename, mime) {
|
|
8
|
+
const url = URL.createObjectURL(new Blob([data], { type: mime }));
|
|
9
|
+
const a = document.createElement("a");
|
|
10
|
+
a.href = url;
|
|
11
|
+
a.download = filename;
|
|
12
|
+
a.click();
|
|
13
|
+
URL.revokeObjectURL(url);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Download a set of built parts: a single part downloads directly; multiple parts
|
|
17
|
+
// are bundled into one flat, store-only (level 0) zip named `zipName`.
|
|
18
|
+
export function downloadParts({ parts, ext, mime }, zipName) {
|
|
19
|
+
if (parts.length === 1) return triggerDownload(parts[0].data, `${parts[0].name}.${ext}`, mime);
|
|
20
|
+
const entries = {};
|
|
21
|
+
for (const p of parts) entries[`${p.name}.${ext}`] = new Uint8Array(p.data);
|
|
22
|
+
triggerDownload(zipSync(entries, { level: 0 }), zipName, "application/zip");
|
|
23
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Pure classification math for OCCT feature labels. The OCCT backend meshes each
|
|
2
|
+
// labeled solid snapshot into a triangle soup; a face of the RESULT mesh belongs to
|
|
3
|
+
// a label when its sampled triangle centroids all lie on that soup's surface (a cut
|
|
4
|
+
// face lies exactly on its tool's surface, up to the two meshes' tolerances).
|
|
5
|
+
// No OCCT, no three.js — unit-testable with hand-built soups.
|
|
6
|
+
|
|
7
|
+
// Result mesh (preview) tolerance 0.1 + snapshot mesh tolerance 0.1 + slack.
|
|
8
|
+
const DEFAULT_TOL = 0.35; // mm — surfaces closer than this to another labeled surface can misattribute
|
|
9
|
+
const SAMPLES_PER_FACE = 4;
|
|
10
|
+
|
|
11
|
+
// Distance from point p to triangle (a,b,c) — the classic region-based projection.
|
|
12
|
+
export function pointTriDist(p, a, b, c) {
|
|
13
|
+
const sub = (u, v) => [u[0] - v[0], u[1] - v[1], u[2] - v[2]];
|
|
14
|
+
const dot = (u, v) => u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
|
|
15
|
+
const ab = sub(b, a), ac = sub(c, a), ap = sub(p, a);
|
|
16
|
+
const d1 = dot(ab, ap), d2 = dot(ac, ap);
|
|
17
|
+
if (d1 <= 0 && d2 <= 0) return Math.hypot(...ap); // vertex a
|
|
18
|
+
const bp = sub(p, b);
|
|
19
|
+
const d3 = dot(ab, bp), d4 = dot(ac, bp);
|
|
20
|
+
if (d3 >= 0 && d4 <= d3) return Math.hypot(...bp); // vertex b
|
|
21
|
+
const vc = d1 * d4 - d3 * d2;
|
|
22
|
+
if (vc <= 0 && d1 >= 0 && d3 <= 0) { // edge ab
|
|
23
|
+
const t = d1 / (d1 - d3);
|
|
24
|
+
return Math.hypot(...sub(p, [a[0] + ab[0] * t, a[1] + ab[1] * t, a[2] + ab[2] * t]));
|
|
25
|
+
}
|
|
26
|
+
const cp = sub(p, c);
|
|
27
|
+
const d5 = dot(ab, cp), d6 = dot(ac, cp);
|
|
28
|
+
if (d6 >= 0 && d5 <= d6) return Math.hypot(...cp); // vertex c
|
|
29
|
+
const vb = d5 * d2 - d1 * d6;
|
|
30
|
+
if (vb <= 0 && d2 >= 0 && d6 <= 0) { // edge ac
|
|
31
|
+
const t = d2 / (d2 - d6);
|
|
32
|
+
return Math.hypot(...sub(p, [a[0] + ac[0] * t, a[1] + ac[1] * t, a[2] + ac[2] * t]));
|
|
33
|
+
}
|
|
34
|
+
const va = d3 * d6 - d5 * d4;
|
|
35
|
+
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) { // edge bc
|
|
36
|
+
const t = (d4 - d3) / (d4 - d3 + (d5 - d6));
|
|
37
|
+
const bc = sub(c, b);
|
|
38
|
+
return Math.hypot(...sub(p, [b[0] + bc[0] * t, b[1] + bc[1] * t, b[2] + bc[2] * t]));
|
|
39
|
+
}
|
|
40
|
+
const denom = 1 / (va + vb + vc); // interior
|
|
41
|
+
const v = vb * denom, w = vc * denom;
|
|
42
|
+
return Math.hypot(...sub(p, [a[0] + ab[0] * v + ac[0] * w, a[1] + ab[1] * v + ac[1] * w, a[2] + ab[2] * v + ac[2] * w]));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const centroid = (V, T, t) => {
|
|
46
|
+
const i = T[t * 3] * 3, j = T[t * 3 + 1] * 3, k = T[t * 3 + 2] * 3;
|
|
47
|
+
return [(V[i] + V[j] + V[k]) / 3, (V[i + 1] + V[j + 1] + V[k + 1]) / 3, (V[i + 2] + V[j + 2] + V[k + 2]) / 3];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function distToSoup(p, soup) {
|
|
51
|
+
const V = soup.vertices, T = soup.triangles;
|
|
52
|
+
let best = Infinity;
|
|
53
|
+
for (let t = 0; t < T.length / 3; t++) {
|
|
54
|
+
const a = [V[T[t * 3] * 3], V[T[t * 3] * 3 + 1], V[T[t * 3] * 3 + 2]];
|
|
55
|
+
const b = [V[T[t * 3 + 1] * 3], V[T[t * 3 + 1] * 3 + 1], V[T[t * 3 + 1] * 3 + 2]];
|
|
56
|
+
const c = [V[T[t * 3 + 2] * 3], V[T[t * 3 + 2] * 3 + 1], V[T[t * 3 + 2] * 3 + 2]];
|
|
57
|
+
const d = pointTriDist(p, a, b, c);
|
|
58
|
+
if (d < best) best = d;
|
|
59
|
+
}
|
|
60
|
+
return best;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// resultMesh: replicad ShapeMesh {vertices, triangles, faceGroups}; soups: labeled
|
|
64
|
+
// snapshots meshed by the caller. Returns {} when attribution isn't possible.
|
|
65
|
+
export function classifyFaceGroups(resultMesh, soups, tol = DEFAULT_TOL) {
|
|
66
|
+
const groups = resultMesh.faceGroups;
|
|
67
|
+
const nTri = resultMesh.triangles.length / 3;
|
|
68
|
+
if (!groups?.length || !soups.length) return {};
|
|
69
|
+
|
|
70
|
+
// faceGroups start/count units differ across replicad versions: triangle counts
|
|
71
|
+
// sum to nTri, index counts to nTri*3. Detect which this is.
|
|
72
|
+
const total = groups.reduce((s, g) => s + g.count, 0);
|
|
73
|
+
const div = total === nTri * 3 ? 3 : 1;
|
|
74
|
+
|
|
75
|
+
const indexOf = new Map(); // label -> 1-based feature index (same-label merge)
|
|
76
|
+
const features = [];
|
|
77
|
+
const featureIds = new Uint16Array(nTri);
|
|
78
|
+
|
|
79
|
+
for (const g of groups) {
|
|
80
|
+
const start = g.start / div, count = g.count / div;
|
|
81
|
+
if (count <= 0) continue; // degenerate group: no triangles, nothing to attribute
|
|
82
|
+
// sample a few spread triangles of the face
|
|
83
|
+
const picks = [];
|
|
84
|
+
const denom = Math.max(1, Math.min(SAMPLES_PER_FACE, count) - 1);
|
|
85
|
+
for (let s = 0; s < Math.min(SAMPLES_PER_FACE, count); s++) {
|
|
86
|
+
picks.push(start + Math.floor((s * (count - 1)) / denom));
|
|
87
|
+
}
|
|
88
|
+
// last matching soup wins (most recently applied label)
|
|
89
|
+
let winner = null;
|
|
90
|
+
for (const soup of soups) {
|
|
91
|
+
const onSurface = picks.every(
|
|
92
|
+
(t) => distToSoup(centroid(resultMesh.vertices, resultMesh.triangles, t), soup) <= tol
|
|
93
|
+
);
|
|
94
|
+
if (onSurface) winner = soup.label;
|
|
95
|
+
}
|
|
96
|
+
if (winner == null) continue;
|
|
97
|
+
let fi = indexOf.get(winner);
|
|
98
|
+
if (fi === undefined) { features.push(winner); fi = features.length; indexOf.set(winner, fi); }
|
|
99
|
+
for (let t = start; t < start + count; t++) featureIds[t] = fi;
|
|
100
|
+
}
|
|
101
|
+
return features.length ? { featureIds, features } : {};
|
|
102
|
+
}
|