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