partforge 0.41.0 → 0.45.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 +31 -10
- package/bin/cli.js +138 -27
- package/docs/AUTHORING-PARTS.md +164 -17
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +48 -7
- package/skills/partforge/SKILL.md +17 -3
- package/src/app-embed-test.js +1 -1
- package/src/app-hinged-box.js +12 -0
- package/src/framework/animation-controls.js +254 -0
- package/src/framework/animation.js +271 -0
- package/src/framework/app.css +32 -0
- package/src/framework/assembly.js +1 -1
- package/src/framework/backend-select.js +25 -0
- package/src/framework/camera-tween.js +58 -0
- package/src/framework/capture-build.js +59 -0
- package/src/framework/chrome.css +16 -0
- package/src/framework/controls.js +13 -3
- package/src/framework/cutaway-gizmo-scene.js +244 -0
- package/src/framework/cutaway-gizmo.js +80 -243
- package/src/framework/default-view.js +46 -0
- package/src/framework/download.js +7 -2
- package/src/framework/export-controller.js +13 -2
- package/src/framework/geometry/probe.js +3 -22
- package/src/framework/jobs.js +30 -40
- package/src/framework/lint/finding.js +4 -0
- package/src/framework/lint/index.js +7 -3
- package/src/framework/lint/rules-animations.js +441 -0
- package/src/framework/lint/rules-place.js +76 -0
- package/src/framework/lint/rules-schema.js +22 -0
- package/src/framework/lint/rules-shape.js +12 -0
- package/src/framework/lint/rules-verify.js +2 -2
- package/src/framework/mount.js +147 -20
- package/src/{testing → framework/oracle}/build.js +1 -1
- package/src/{testing → framework/oracle}/bvh.js +1 -1
- package/src/{testing → framework/oracle}/measure.js +1 -1
- package/src/{testing → framework/oracle}/min-wall.js +1 -1
- package/src/{testing → framework/oracle}/verify.js +3 -3
- package/src/framework/param-deps.js +1 -1
- package/src/framework/part-model.js +48 -0
- package/src/framework/pick-request/client.js +11 -3
- package/src/framework/pick-request/endpoint.js +60 -0
- package/src/framework/pick-request/index.js +6 -0
- package/src/framework/pick-request/server.js +222 -34
- package/src/framework/pick-request/token-store.js +31 -0
- package/src/framework/pose-fast-path.js +12 -1
- package/src/framework/pose-probe-core.js +129 -0
- package/src/framework/pose-probe.js +7 -123
- package/src/framework/regen-loop.js +10 -3
- package/src/framework/safe-name.js +26 -0
- package/src/framework/verify-metrics.js +4 -4
- package/src/framework/view-state.js +25 -21
- package/src/framework/view-tabs.js +35 -7
- package/src/framework/viewer-controls.js +5 -26
- package/src/framework/viewer-lighting.js +8 -1
- package/src/framework/viewer.js +139 -20
- package/src/framework/worker.js +5 -1
- package/src/hinged-box-worker.js +3 -0
- package/src/index.js +1 -1
- package/src/parts/hinged-box.js +94 -0
- package/src/testing/render.js +19 -8
- package/src/testing.js +15 -8
- package/types/derive.d.ts +14 -0
- package/types/geometry.d.ts +117 -0
- package/types/index.d.ts +259 -0
- package/types/kernel.d.ts +409 -0
- package/types/lint.d.ts +85 -0
- package/types/part.d.ts +409 -0
- package/types/testing.d.ts +362 -0
- package/types/worker.d.ts +21 -0
- /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
- /package/src/{testing → framework/oracle}/cases.js +0 -0
- /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
- /package/src/{testing → framework/oracle}/gaps.js +0 -0
- /package/src/{testing → framework/oracle}/mesh.js +0 -0
package/README.md
CHANGED
|
@@ -103,7 +103,7 @@ const runtime = mount(part, {
|
|
|
103
103
|
status: { status, busy, phase }, // status chrome
|
|
104
104
|
tabs, // view-tab segmented control
|
|
105
105
|
exports: { stl, step, threeMf }, // export buttons
|
|
106
|
-
chrome: {
|
|
106
|
+
chrome: { reframe, theme, railToggle }, // viewer buttons + rail collapse/restore
|
|
107
107
|
},
|
|
108
108
|
onBuild: ({ status, ms, error }) => {}, // per accepted build: "success" | "error"
|
|
109
109
|
onPick: ({ selection, label, prompt, token }) => {}, // programmatic click-to-select
|
|
@@ -122,10 +122,12 @@ runtime.dispose(); // stops loops, workers, observers, listeners; frees GPU
|
|
|
122
122
|
`display: none` needs nothing — the container collapses and the ResizeObserver
|
|
123
123
|
shrinks the drawing buffer for free. A host that hides it any other way
|
|
124
124
|
(`visibility: hidden`, an inactive tab, an off-screen pane) gets no such signal:
|
|
125
|
-
the full-resolution MSAA buffer stays resident and the render loop keeps
|
|
126
|
-
an
|
|
127
|
-
|
|
128
|
-
|
|
125
|
+
the full-resolution MSAA buffer stays resident and the render loop keeps
|
|
126
|
+
redrawing an unchanging scene at 60fps that nobody can see (a part with an
|
|
127
|
+
`autoplay` animation running is the same story, just with something actually
|
|
128
|
+
moving off-screen). On a phone that is tens of megabytes plus continuous GPU
|
|
129
|
+
work, and it has been enough on its own to get a tab killed. Call
|
|
130
|
+
`runtime.setActive(false)` when the viewer goes off-screen and
|
|
129
131
|
`setActive(true)` when it comes back.
|
|
130
132
|
|
|
131
133
|
Parking releases both large GPU allocations — the drawing buffer and the cached
|
|
@@ -137,9 +139,8 @@ first capture after parking just re-allocates its target.
|
|
|
137
139
|
Every `elements` entry defaults to the legacy global ID (`#app`, `#controls`,
|
|
138
140
|
`#panel` for `rail`, `#status`/`#busy`/`#phase`, `#part`,
|
|
139
141
|
`#download`/`#download-step`/`#download-3mf`,
|
|
140
|
-
`#
|
|
141
|
-
|
|
142
|
-
coupling.
|
|
142
|
+
`#reframe`/`#theme`/`#rail-toggle`), so a classic host page needs no changes.
|
|
143
|
+
The viewer sizes from its container via ResizeObserver — no window coupling.
|
|
143
144
|
|
|
144
145
|
`rail` is the full-height controls rail introduced by the resizable-panel
|
|
145
146
|
layout (`docs/superpowers/specs/2026-07-26-controls-rail-layout-design.md`);
|
|
@@ -164,6 +165,25 @@ viewer re-poses the meshes it already has — instantly, with no worker rebuild;
|
|
|
164
165
|
`onBuild` does not fire for those pose-only edits. Anything that changes the
|
|
165
166
|
geometry itself rebuilds as usual.
|
|
166
167
|
|
|
168
|
+
For parts that declare `animations`, `runtime.animation` exposes the viewer's
|
|
169
|
+
playback engine (`null` otherwise):
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
runtime.animation.play("open"); // switch + play (camera cue and all)
|
|
173
|
+
runtime.animation.seek(0.5); // scrub, normalized 0..1 (pauses)
|
|
174
|
+
runtime.animation.pause();
|
|
175
|
+
runtime.animation.stop(); // reset + restore pre-animation params
|
|
176
|
+
runtime.animation.state(); // { animation, status, t, stepIndex }
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
A part can mark at most one animation `autoplay: true` to start it
|
|
180
|
+
automatically on first show and again on every view switch — until the user
|
|
181
|
+
touches the transport — or anything writes params (`runtime.setParams`
|
|
182
|
+
included) or calls a `runtime.animation` method; any of those disarms
|
|
183
|
+
auto-start for the session. This replaces the old idle turntable as the
|
|
184
|
+
"something is moving" cue for a part-app; see the authoring guide for the
|
|
185
|
+
full contract.
|
|
186
|
+
|
|
167
187
|
`onPick` arms click-to-select permanently: `label` is the feature label (falling
|
|
168
188
|
back to the sub-part label/name) for compact UI, `prompt` is the LLM-ready
|
|
169
189
|
sentence, `token` the compact form, `selection` the raw object. When `onPick` is
|
|
@@ -182,8 +202,9 @@ internal params, and keep the interface simple while staying deeply adjustable.
|
|
|
182
202
|
Locally, `npm run dev` then open `/demo.html`, `/planter.html`, or `/filleted-box.html`.
|
|
183
203
|
|
|
184
204
|
- **Agent clarification (`request-a-pick`):** an external tool can ask the user to click
|
|
185
|
-
geometry and get the `Selection` back — serve with `?pickserver
|
|
186
|
-
`partforge pick-serve` + `partforge pick "<prompt>" …`.
|
|
205
|
+
geometry and get the `Selection` back — serve with `?pickserver&picktoken=<token>`,
|
|
206
|
+
drive with `partforge pick-serve` (it prints the token) + `partforge pick "<prompt>" …`.
|
|
207
|
+
The server is loopback-only and token-gated on every route. See
|
|
187
208
|
`skills/partforge/SKILL.md` and the authoring guide.
|
|
188
209
|
|
|
189
210
|
## License
|
package/bin/cli.js
CHANGED
|
@@ -7,13 +7,18 @@ import { parseArgs } from "node:util";
|
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
8
|
import { resolve, dirname } from "node:path";
|
|
9
9
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
10
|
-
import { detectBackend } from "../src/framework/
|
|
10
|
+
import { detectBackend } from "../src/framework/backend-select.js";
|
|
11
|
+
import { normalizeAnimation, evaluate, cueAt } from "../src/framework/animation.js";
|
|
11
12
|
import { bootOcctKernel } from "../src/testing/occt.js";
|
|
12
13
|
import { bootManifoldKernel } from "../src/testing/manifold.js";
|
|
13
|
-
import { measure } from "../src/
|
|
14
|
-
import { verify } from "../src/
|
|
14
|
+
import { measure } from "../src/framework/oracle/measure.js";
|
|
15
|
+
import { verify } from "../src/framework/oracle/verify.js";
|
|
15
16
|
import { renderViews } from "../src/testing/render.js";
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
createPickServer, requestPicks, formatPickResult,
|
|
19
|
+
PICK_SERVER_DEFAULT_PORT, PICK_SERVER_DEFAULT_TIMEOUT_MS,
|
|
20
|
+
} from "../src/framework/pick-request/server.js";
|
|
21
|
+
import { savePickToken, loadPickToken, clearPickToken, pickTokenPath } from "../src/framework/pick-request/token-store.js";
|
|
17
22
|
import { matchPattern } from "../src/testing/error-patterns.js";
|
|
18
23
|
import { lintPart } from "../src/lint.js";
|
|
19
24
|
|
|
@@ -22,12 +27,13 @@ const USAGE = "usage: partforge <lint|measure|render|pick-serve|pick> …";
|
|
|
22
27
|
|
|
23
28
|
// Crash contract (issue #27): with --json, a thrown error becomes structured
|
|
24
29
|
// stdout JSON; either way the message is matched against ERROR-PATTERNS.md and
|
|
25
|
-
// the pattern's fix is surfaced. Exit 1 always. NOTE on stdout purity:
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// unknown metric in verify.expect,
|
|
29
|
-
//
|
|
30
|
-
//
|
|
30
|
+
// the pattern's fix is surfaced. Exit 1 always. NOTE on stdout purity: in --json
|
|
31
|
+
// mode every human-readable printer (printLint/printMeasure/printVerify) is
|
|
32
|
+
// gated behind `!flags.json`, so crash JSON is the only thing that ever reaches
|
|
33
|
+
// stdout — including when verify() throws (an unknown metric in verify.expect,
|
|
34
|
+
// or a per-case build crash) after measure's report has already been computed.
|
|
35
|
+
// Without --json there is no purity contract: human lines print as each stage
|
|
36
|
+
// completes, so a later crash's message lands after them, not instead of them.
|
|
31
37
|
function crash(cmd, e, jsonMode) {
|
|
32
38
|
const message = e?.message || String(e);
|
|
33
39
|
const m = matchPattern(message);
|
|
@@ -113,7 +119,7 @@ const commands = {
|
|
|
113
119
|
}
|
|
114
120
|
const kernel = await bootKernel(part);
|
|
115
121
|
const report = measure(kernel, part, view);
|
|
116
|
-
printMeasure(report);
|
|
122
|
+
if (!flags.json) printMeasure(report);
|
|
117
123
|
// Write --out right after measure succeeds, then re-write once verify has
|
|
118
124
|
// attached report.verify. If verify throws (unknown metric, per-case build
|
|
119
125
|
// crash) the file already holds the measure half (no `verify` key) — matching
|
|
@@ -126,7 +132,7 @@ const commands = {
|
|
|
126
132
|
let vok = true;
|
|
127
133
|
if ((part.verify || flags.process) && !flags["no-verify"]) {
|
|
128
134
|
const v = verify(kernel, part, { process: flags.process, view });
|
|
129
|
-
printVerify(v);
|
|
135
|
+
if (!flags.json) printVerify(v);
|
|
130
136
|
report.verify = v;
|
|
131
137
|
vok = v.ok;
|
|
132
138
|
if (flags.out) writeOut();
|
|
@@ -140,17 +146,109 @@ const commands = {
|
|
|
140
146
|
},
|
|
141
147
|
|
|
142
148
|
async render(args) {
|
|
143
|
-
const usage = "usage: partforge render <part-module> [view] [--views iso,front] [--out <dir>]"
|
|
149
|
+
const usage = "usage: partforge render <part-module> [view] [--views iso,front] [--out <dir>] " +
|
|
150
|
+
"[--params <json>] [--animation <name>] [--at <t[,t…]>] [--step <index|label>]";
|
|
144
151
|
const { values: flags, positionals: [partPath, view] } = parse(args, {
|
|
145
152
|
views: { type: "string" },
|
|
146
153
|
out: { type: "string" },
|
|
154
|
+
params: { type: "string" },
|
|
155
|
+
animation: { type: "string" },
|
|
156
|
+
at: { type: "string" },
|
|
157
|
+
step: { type: "string" },
|
|
147
158
|
}, usage);
|
|
148
159
|
try {
|
|
149
160
|
const part = await loadPart(partPath, usage);
|
|
150
|
-
const
|
|
161
|
+
const baseParams = flags.params ? JSON.parse(flags.params) : {};
|
|
162
|
+
// `--params '[1,2]'` or '42' parses fine and then merges into nothing, so the
|
|
163
|
+
// flag silently does nothing. Only an object can carry param overrides.
|
|
164
|
+
if (baseParams === null || typeof baseParams !== "object" || Array.isArray(baseParams)) {
|
|
165
|
+
die(`--params takes a JSON object of param overrides\n${usage}`);
|
|
166
|
+
}
|
|
167
|
+
const outDir = flags.out || "render";
|
|
151
168
|
const views = flags.views ? flags.views.split(",") : undefined;
|
|
152
|
-
|
|
153
|
-
|
|
169
|
+
// Usage checks BEFORE the kernel: a flag typo shouldn't pay a WASM boot.
|
|
170
|
+
// Test `=== undefined`, not falsiness: `--animation ""` is a flag the user
|
|
171
|
+
// passed and got wrong (an unset shell variable, typically), not one they
|
|
172
|
+
// omitted, and silently rendering a non-animation still hides the mistake.
|
|
173
|
+
if (flags.animation !== undefined && flags.animation.trim() === "") {
|
|
174
|
+
die(`--animation needs an animation name\n${usage}`);
|
|
175
|
+
}
|
|
176
|
+
if (flags.animation === undefined && (flags.at || flags.step)) {
|
|
177
|
+
die(`--at/--step require --animation\n${usage}`);
|
|
178
|
+
}
|
|
179
|
+
if (flags.at != null && flags.step != null) {
|
|
180
|
+
die(`--at and --step are alternatives — pass one, not both\n${usage}`);
|
|
181
|
+
}
|
|
182
|
+
// Own-key test: `part.views?.["constructor"]` resolves through
|
|
183
|
+
// Object.prototype and would sail past a plain lookup, straight back into
|
|
184
|
+
// the background-only render this guard exists to stop.
|
|
185
|
+
if (view !== undefined && !Object.hasOwn(part.views ?? {}, view)) {
|
|
186
|
+
die(`unknown view "${view}" (have: ${Object.keys(part.views ?? {}).join(", ") || "none"})\n${usage}`);
|
|
187
|
+
}
|
|
188
|
+
const kernel = await bootKernel(part);
|
|
189
|
+
|
|
190
|
+
if (flags.animation === undefined) {
|
|
191
|
+
const files = await renderViews(kernel, part, view, { views, out: outDir, params: baseParams });
|
|
192
|
+
for (const f of files) console.log(`wrote ${f}`);
|
|
193
|
+
process.exit(0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const spec = part.animations?.[flags.animation];
|
|
197
|
+
if (!spec) {
|
|
198
|
+
throw new Error(`unknown animation "${flags.animation}" (have: ${Object.keys(part.animations ?? {}).join(", ") || "none"})`);
|
|
199
|
+
}
|
|
200
|
+
const anim = normalizeAnimation(flags.animation, spec);
|
|
201
|
+
// Frames: --step renders one still at the END of that step (its fully
|
|
202
|
+
// applied state); --at takes positions normalized over the animation's
|
|
203
|
+
// TOTAL duration (same t as the viewer scrubber / runtime seek).
|
|
204
|
+
let frames;
|
|
205
|
+
if (flags.step != null) {
|
|
206
|
+
const byLabel = anim.steps.findIndex((s) => s.label === flags.step);
|
|
207
|
+
const idx = byLabel >= 0 ? byLabel : Number(flags.step) - 1;
|
|
208
|
+
if (!(idx >= 0 && idx < anim.steps.length)) {
|
|
209
|
+
throw new Error(`unknown step "${flags.step}" (use 1..${anim.steps.length} or a label: ${anim.steps.map((s) => JSON.stringify(s.label)).join(", ")})`);
|
|
210
|
+
}
|
|
211
|
+
const end = idx + 1 < anim.steps.length ? anim.stepStarts[idx + 1] : 1;
|
|
212
|
+
// Values come from the END of the step (fully applied), but the cue
|
|
213
|
+
// lookup uses the step's own START: cue boundaries belong to the LATER
|
|
214
|
+
// step (see animation.js stepIndexAt/cueAt), and `end` here IS the next
|
|
215
|
+
// step's start, so querying the cue at `end` would resolve to the next
|
|
216
|
+
// step's camera instead of this step's own.
|
|
217
|
+
frames = [{ t: end, cueT: anim.stepStarts[idx], tag: `${flags.animation}-step${idx + 1}` }];
|
|
218
|
+
} else {
|
|
219
|
+
// Split first and reject blanks: Number("") is 0, so "0.2,,0.8" would
|
|
220
|
+
// otherwise slip a silent extra frame at t=0 past the range check.
|
|
221
|
+
const raw = (flags.at ?? "1").split(",");
|
|
222
|
+
const ts = raw.map((s) => (s.trim() === "" ? Number.NaN : Number(s)));
|
|
223
|
+
if (!ts.length || ts.some((t) => !Number.isFinite(t) || t < 0 || t > 1)) {
|
|
224
|
+
die(`--at takes comma-separated positions in 0..1\n${usage}`);
|
|
225
|
+
}
|
|
226
|
+
// The tag is the only thing distinguishing one frame's file from another.
|
|
227
|
+
// Two decimals suits the usual `--at 0,0.5,1`, but a dense request like
|
|
228
|
+
// 0.001,0.004 collides and the later render would silently overwrite the
|
|
229
|
+
// earlier — one file for two frames asked for. Widen the tag just enough
|
|
230
|
+
// for THIS request instead of refusing it: ordinary runs keep their
|
|
231
|
+
// familiar t000/t050/t100 names, dense ones get one file each.
|
|
232
|
+
const tagsAt = (decimals) =>
|
|
233
|
+
ts.map((t) => String(Math.round(t * 10 ** decimals)).padStart(decimals + 1, "0"));
|
|
234
|
+
let decimals = 2;
|
|
235
|
+
while (decimals < 6 && new Set(tagsAt(decimals)).size !== ts.length) decimals++;
|
|
236
|
+
const tags = tagsAt(decimals);
|
|
237
|
+
if (new Set(tags).size !== ts.length) {
|
|
238
|
+
// No precision separates them: the same position was listed twice.
|
|
239
|
+
die(`--at lists the same position more than once\n${usage}`);
|
|
240
|
+
}
|
|
241
|
+
frames = ts.map((t, i) => ({ t, tag: `${flags.animation}-t${tags[i]}` }));
|
|
242
|
+
}
|
|
243
|
+
for (const frame of frames) {
|
|
244
|
+
const { values } = evaluate(anim, frame.t);
|
|
245
|
+
const cue = cueAt(anim, frame.cueT ?? frame.t);
|
|
246
|
+
const frameViews = views ?? (cue ? [cue.view] : undefined);
|
|
247
|
+
const files = await renderViews(kernel, part, view, {
|
|
248
|
+
views: frameViews, out: outDir, params: { ...baseParams, ...values }, tag: frame.tag,
|
|
249
|
+
});
|
|
250
|
+
for (const f of files) console.log(`wrote ${f}`);
|
|
251
|
+
}
|
|
154
252
|
process.exit(0);
|
|
155
253
|
} catch (e) {
|
|
156
254
|
crash("render", e, false);
|
|
@@ -160,19 +258,31 @@ const commands = {
|
|
|
160
258
|
async "pick-serve"(args) {
|
|
161
259
|
const usage = "usage: partforge pick-serve [--port N] [--timeout <seconds>]";
|
|
162
260
|
const { values: flags } = parse(args, { port: { type: "string" }, timeout: { type: "string" } }, usage);
|
|
163
|
-
const port = Number(flags.port) ||
|
|
164
|
-
const timeoutMs = (Number(flags.timeout) ||
|
|
165
|
-
const
|
|
261
|
+
const port = Number(flags.port) || PICK_SERVER_DEFAULT_PORT;
|
|
262
|
+
const timeoutMs = (Number(flags.timeout) || PICK_SERVER_DEFAULT_TIMEOUT_MS / 1000) * 1000;
|
|
263
|
+
const srv = createPickServer({ port, timeoutMs });
|
|
264
|
+
const { port: bound } = await srv.start();
|
|
265
|
+
// The token is what keeps every other page on the machine out of this server.
|
|
266
|
+
// `partforge pick` runs in a different process, so drop it in a 0600 file for
|
|
267
|
+
// that process to find; the browser gets it through the app URL below.
|
|
268
|
+
savePickToken(bound, srv.token);
|
|
269
|
+
const stop = () => { clearPickToken(bound); process.exit(0); };
|
|
270
|
+
process.on("SIGINT", stop);
|
|
271
|
+
process.on("SIGTERM", stop);
|
|
166
272
|
console.log(`partforge pick-server listening on http://127.0.0.1:${bound}`);
|
|
273
|
+
console.log(`token: ${srv.token} (also at ${pickTokenPath(bound)})`);
|
|
274
|
+
console.log(`open the app with: ?pickserver=http://127.0.0.1:${bound}&picktoken=${srv.token}`);
|
|
167
275
|
// no exit — the process stays alive serving requests
|
|
168
276
|
},
|
|
169
277
|
|
|
170
278
|
async pick(args) {
|
|
171
|
-
const usage = 'usage: partforge pick "<prompt>" ["<prompt>" …] [--port N]';
|
|
172
|
-
const { values: flags, positionals: prompts } = parse(args, { port: { type: "string" } }, usage);
|
|
279
|
+
const usage = 'usage: partforge pick "<prompt>" ["<prompt>" …] [--port N] [--token T]';
|
|
280
|
+
const { values: flags, positionals: prompts } = parse(args, { port: { type: "string" }, token: { type: "string" } }, usage);
|
|
173
281
|
if (prompts.length === 0) die(usage);
|
|
174
|
-
const port = Number(flags.port) ||
|
|
175
|
-
const
|
|
282
|
+
const port = Number(flags.port) || PICK_SERVER_DEFAULT_PORT;
|
|
283
|
+
const token = flags.token || process.env.PARTFORGE_PICK_TOKEN || loadPickToken(port);
|
|
284
|
+
if (!token) die(`no pick-server token for port ${port} — start one with \`partforge pick-serve\`, or pass --token`);
|
|
285
|
+
const out = await requestPicks({ port, prompts, token }).catch((e) => die(e.message));
|
|
176
286
|
console.log(formatPickResult(out));
|
|
177
287
|
process.exit(out.status === "done" ? 0 : 1);
|
|
178
288
|
},
|
|
@@ -218,16 +328,17 @@ function printVerify(v) {
|
|
|
218
328
|
}
|
|
219
329
|
|
|
220
330
|
function printLint(r) {
|
|
221
|
-
const all = [...r.errors, ...r.warnings];
|
|
331
|
+
const all = [...r.errors, ...r.warnings, ...(r.notes ?? [])];
|
|
222
332
|
if (all.length === 0) { console.log("lint: clean"); return; }
|
|
223
333
|
console.log("lint:");
|
|
224
334
|
for (const f of all) {
|
|
225
|
-
|
|
335
|
+
const icon = f.severity === "error" ? "✗" : f.severity === "warning" ? "⚠" : "·";
|
|
336
|
+
console.log(` ${icon} ${f.rule}${f.path ? ` ${f.path}` : ""}`);
|
|
226
337
|
console.log(` ${f.message}`);
|
|
227
338
|
console.log(` hint: ${f.hint}${f.pattern ? ` (ERROR-PATTERNS.md#${f.pattern})` : ""}`);
|
|
228
339
|
}
|
|
229
|
-
const e = r.errors.length, w = r.warnings.length;
|
|
230
|
-
console.log(` result: ${e ? `${e} error(s)` : "no errors"}${w ? `, ${w} warning(s)` : ""}`);
|
|
340
|
+
const e = r.errors.length, w = r.warnings.length, n = (r.notes ?? []).length;
|
|
341
|
+
console.log(` result: ${e ? `${e} error(s)` : "no errors"}${w ? `, ${w} warning(s)` : ""}${n ? `, ${n} note(s)` : ""}`);
|
|
231
342
|
}
|
|
232
343
|
|
|
233
344
|
const [, , cmd, ...args] = process.argv;
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -85,7 +85,7 @@ export default {
|
|
|
85
85
|
export?: { name }, // filename/object name on export; defaults to the key
|
|
86
86
|
},
|
|
87
87
|
},
|
|
88
|
-
views: { <name>: { label } },
|
|
88
|
+
views: { <name>: { label, default? } }, // the view tabs (a view = a set of sub-parts)
|
|
89
89
|
};
|
|
90
90
|
```
|
|
91
91
|
|
|
@@ -111,6 +111,14 @@ export default {
|
|
|
111
111
|
- `enabled(p)` gates a conditional sub-part (e.g. only present when a feature is on).
|
|
112
112
|
- A view's sub-parts are derived, never hard-coded: those whose `views` include the view
|
|
113
113
|
and whose `enabled(p)` is true.
|
|
114
|
+
- **Which view the viewer opens on** is resolved in this order: the first view flagged
|
|
115
|
+
`default: true`; else the view placing the most sub-parts at `defaults` (counting
|
|
116
|
+
`enabled(defaults)`), which for a multi-view part is normally the assembly; else the
|
|
117
|
+
first key in `views`. So flag the assembly view `default: true` when you want it to
|
|
118
|
+
open but sit last in the tab bar. The chosen tab then persists per part for the rest
|
|
119
|
+
of the browser session. The headless tools are deliberately different: `measure`,
|
|
120
|
+
`verify` and `render` all default to the **first key** in `views`, ignoring
|
|
121
|
+
`default: true`, so a CI gate can't move because a sub-part was added to a view.
|
|
114
122
|
- `fonts` declares the outline fonts a part's `k.text2d()` calls need, as `{ name: source
|
|
115
123
|
}` — a source is inline bytes, a URL string, or a thunk (e.g. a Vite `import('./x.ttf')`,
|
|
116
124
|
which resolves to `{ default: url }`). The framework resolves and parses these into
|
|
@@ -121,6 +129,83 @@ export default {
|
|
|
121
129
|
|
|
122
130
|
---
|
|
123
131
|
|
|
132
|
+
## Animations
|
|
133
|
+
|
|
134
|
+
A part may declare named animations — pure keyframe data that drives **existing
|
|
135
|
+
params** over time. The viewer shows a transport bar (play/scrub/step); hosts
|
|
136
|
+
drive the same engine via `runtime.animation`; `partforge render` can render
|
|
137
|
+
stills at any position. The reference part is `src/parts/hinged-box.js`.
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
animations: {
|
|
141
|
+
open: {
|
|
142
|
+
label: "Open lid",
|
|
143
|
+
description: "Optional **CommonMark**, shown behind the ⓘ glyph.",
|
|
144
|
+
camera: "front", // optional: intro angle, cue list, or per-step (below)
|
|
145
|
+
duration: 1.2, // seconds
|
|
146
|
+
loop: false, // true = wraps continuously (single-step only)
|
|
147
|
+
easing: "ease-in-out", // linear | ease-in | ease-out | ease-in-out
|
|
148
|
+
tracks: { lidAngle: [[0, 0], [1, 110]] }, // param -> [t, value] keyframes
|
|
149
|
+
},
|
|
150
|
+
assemble: {
|
|
151
|
+
label: "Assemble",
|
|
152
|
+
steps: [ // steps play in order; prev/next navigate them
|
|
153
|
+
{ label: "Lower the lid", camera: "left", duration: 1.0,
|
|
154
|
+
tracks: { lidLift: [[0, 40], [1, 0]] } },
|
|
155
|
+
{ label: "Open", camera: "iso", duration: 1.0,
|
|
156
|
+
tracks: { lidAngle: [[0, 0], [1, 110]] } },
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Rules (all lint-enforced):
|
|
163
|
+
|
|
164
|
+
- An animation has **either** `tracks` (a single anonymous step) **or** `steps`.
|
|
165
|
+
- Tracks reference numeric params from `defaults`. Keyframe `t` is normalized
|
|
166
|
+
per step, strictly ascending from exactly 0 to exactly 1; values must sit
|
|
167
|
+
inside the owning control's min/max (the engine applies them unclamped).
|
|
168
|
+
- Params not tracked anywhere keep their current values; a param tracked in
|
|
169
|
+
one step holds its nearest keyframe value while other steps play.
|
|
170
|
+
- A step may declare a `camera` and **no** `tracks` — an establishing shot that
|
|
171
|
+
swings the view while the model holds still. At least one step still has to
|
|
172
|
+
carry tracks, or the animation animates nothing. Note the holding value is the
|
|
173
|
+
nearest keyframe, not whatever the user last set: a leading camera-only step
|
|
174
|
+
shows the animation's opening pose, the same one `t = 0` would show.
|
|
175
|
+
- `loop` and `autoplay` must be literal booleans. Anything else is reported by
|
|
176
|
+
lint and treated as `false` at runtime, so `loop: "false"` never means "loop".
|
|
177
|
+
- Couple motions through `derive` (animate one master param; derive the rest),
|
|
178
|
+
not by tracking dependent params separately.
|
|
179
|
+
- `camera` cues use the seven canonical angles (`iso front back top bottom
|
|
180
|
+
left right`). One mechanism per animation: an animation-level name (an intro
|
|
181
|
+
cue at t=0), an animation-level `[[t, angle], …]` list, or per-step names.
|
|
182
|
+
Cues fire during play only — scrubbing never moves the camera — and a user
|
|
183
|
+
orbit disarms the remaining cues for that run.
|
|
184
|
+
- Playback drives params through the real param pipeline: a **pose-only**
|
|
185
|
+
param (feeds only rigid placement — see "Caching & determinism" below) plays
|
|
186
|
+
at frame rate; anything else rebuilds best-effort at worker cadence. `lint`
|
|
187
|
+
prints a note per track that can't take the fast path.
|
|
188
|
+
- Playback pauses when the user edits any control; Reset restores the values
|
|
189
|
+
the animation found. Because animated values are real params, exporting
|
|
190
|
+
while paused exports the posed state — by design.
|
|
191
|
+
- `autoplay: true` (optional, one animation at most) starts that animation on
|
|
192
|
+
first show and again on each view switch, until the user touches the
|
|
193
|
+
transport — or anything writes params (`runtime.setParams` included) or
|
|
194
|
+
calls a `runtime.animation` method; any of those disarms auto-start for the
|
|
195
|
+
session. Lint: `animation-autoplay-invalid`. It is not armed when the
|
|
196
|
+
browser reports `prefers-reduced-motion: reduce` — self-starting motion is
|
|
197
|
+
exactly what that setting asks a page not to do. An autoplay animation that
|
|
198
|
+
declares a `camera` cue will sweep the camera away from the user's
|
|
199
|
+
persisted framing on every page load, so choose cues for autoplay
|
|
200
|
+
deliberately — the shipped example's `cycle` animation has none.
|
|
201
|
+
|
|
202
|
+
Headless: `partforge render <part> --animation open --at 0,0.5,1` renders
|
|
203
|
+
tagged stills (`--at` is normalized over the animation's total duration, like
|
|
204
|
+
the scrubber); `--step <index|label>` renders a step's end state; stills
|
|
205
|
+
default to the governing camera cue's angle.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
124
209
|
## Geometry: the kernel / `Solid` API
|
|
125
210
|
|
|
126
211
|
`build` receives a backend-agnostic `kernel` (`k`). It returns and combines `Solid`
|
|
@@ -406,6 +491,12 @@ choosing a preset updates both numeric and text fields.
|
|
|
406
491
|
Every `key` used must exist in `defaults`. `src/parts/demo.js` is the worked example for
|
|
407
492
|
everything below.
|
|
408
493
|
|
|
494
|
+
A feature's `on` is **required and must be greater than 0** — it is the real value the
|
|
495
|
+
parameter takes when the box is ticked (a diameter, a count), and the panel reads
|
|
496
|
+
`> 0` as "enabled", so there is nothing sensible to fall back to. `partforge lint`
|
|
497
|
+
reports a missing or non-positive one as `features-requires-on`. A `toggles` entry is
|
|
498
|
+
the exception: its `on` is just a flag and defaults to 1.
|
|
499
|
+
|
|
409
500
|
**Standalone toggles** (a plain on/off checkbox, no accompanying sliders): add a
|
|
410
501
|
`toggles` array to a preset section — shown below the preset picker, outside the
|
|
411
502
|
Advanced fold, so it stays visible:
|
|
@@ -702,17 +793,38 @@ stylesheet). `mount` looks up these element IDs:
|
|
|
702
793
|
|---|---|
|
|
703
794
|
| `#app` | viewer canvas mounts here |
|
|
704
795
|
| `#controls` | control panel is built into this |
|
|
705
|
-
| `#part` | view-tab bar — leave the div **empty**; `mount` generates one button per entry in `part.views` |
|
|
796
|
+
| `#part` | view-tab bar — leave the div **empty**; `mount` generates one button per entry in `part.views` and opens the resolved default (see the "Which view the viewer opens on" rule above) |
|
|
706
797
|
| `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
|
|
707
798
|
| `#status`, `#busy`, `#phase` | status line + busy overlay |
|
|
708
|
-
| `#viewbar` with `#
|
|
799
|
+
| `#viewbar` with `#reframe` / `#cutaway` / `#theme` | optional viewer controls (omit any you don't want) |
|
|
709
800
|
| `#panel` | the full-height controls rail (`class="pf-rail"`); programmatic hosts pass `elements.rail` instead |
|
|
710
|
-
| `#rail-toggle` | optional — collapses/restores the rail; resolved the same way as `#
|
|
801
|
+
| `#rail-toggle` | optional — collapses/restores the rail; resolved the same way as `#reframe`/`#theme` |
|
|
711
802
|
|
|
712
803
|
Copy `demo.html` and change the title, the panel heading, and the `<script src>`. Two
|
|
713
804
|
workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
|
|
714
805
|
`"occt"` for STEP — handled for you).
|
|
715
806
|
|
|
807
|
+
**View control (the mount handle).** For an embedder driving the view tabs from its own UI
|
|
808
|
+
instead of (or in addition to) the built-in `#part` bar:
|
|
809
|
+
|
|
810
|
+
- `runtime.getView() → string` — the active view name; never null once the runtime is ready
|
|
811
|
+
(mount resolves a default before first build — see "Which view the viewer opens on" above).
|
|
812
|
+
- `runtime.setView(name) → boolean` — switch tabs programmatically, the same path as clicking
|
|
813
|
+
a tab. Returns `false` (and leaves the active tab untouched) for a name the part doesn't
|
|
814
|
+
declare in `views`; `true` otherwise, including when `name` is already active.
|
|
815
|
+
- `await runtime.captureView(viewName?, opts?) → Promise<string | null>` — a JPEG data URL of
|
|
816
|
+
`viewName` rendered offscreen (falling back to the resolved default view — see
|
|
817
|
+
`resolveDefaultView` / `default-view.js` — when `viewName` is omitted or names a view the
|
|
818
|
+
part doesn't declare). Never disturbs the active tab, the live camera, or the on-screen
|
|
819
|
+
scene; `opts` forwards to the underlying render (size, quality, angle). Resolves `null` on
|
|
820
|
+
failure rather than throwing (a build error, a part with no sub-parts in that view, a
|
|
821
|
+
disposed runtime).
|
|
822
|
+
|
|
823
|
+
Pass `onViewChange(name)` to `mount()` to be told the active view: it fires once
|
|
824
|
+
synchronously during mount with the initial resolved view (before `runtime.ready` settles),
|
|
825
|
+
then again on every subsequent change — a tab click or a `setView` call — always with the
|
|
826
|
+
new view name.
|
|
827
|
+
|
|
716
828
|
**Headless export (the mount handle).** The `#download*` buttons above are the built-in,
|
|
717
829
|
view-bound export UI. An embedder that wants its own export UI (e.g. a "pick which parts,
|
|
718
830
|
pick a format" modal) can skip those buttons and drive export off the handle `mount()`
|
|
@@ -817,7 +929,6 @@ mount(part, {
|
|
|
817
929
|
elements: {
|
|
818
930
|
rail,
|
|
819
931
|
chrome: {
|
|
820
|
-
pause,
|
|
821
932
|
reframe,
|
|
822
933
|
cutaway,
|
|
823
934
|
theme,
|
|
@@ -1003,11 +1114,13 @@ previously didn't; that's the fix working as intended, not a regression.
|
|
|
1003
1114
|
### Rule catalog
|
|
1004
1115
|
|
|
1005
1116
|
**Definition shape** — `missing-meta-title`, `missing-defaults`, `no-buildable-parts`,
|
|
1006
|
-
`missing-views`, `part-view-unknown` (all errors); `view-unused
|
|
1117
|
+
`missing-views`, `part-view-unknown` (all errors); `view-unused`,
|
|
1118
|
+
`default-view-ambiguous` (warnings).
|
|
1007
1119
|
|
|
1008
|
-
**Parameter schema** — `features-requires-sliders`, `
|
|
1009
|
-
`preset-key-not-in-defaults` (errors);
|
|
1010
|
-
`unknown-control-field`, `duplicate-control-key`,
|
|
1120
|
+
**Parameter schema** — `features-requires-sliders`, `features-requires-on`,
|
|
1121
|
+
`control-key-not-in-defaults`, `preset-key-not-in-defaults` (errors);
|
|
1122
|
+
`slider-range-excludes-default`, `unknown-control-field`, `duplicate-control-key`,
|
|
1123
|
+
`default-not-exposed` (warnings).
|
|
1011
1124
|
|
|
1012
1125
|
**Kernel API**, found by executing `build()` against a geometry-free probe —
|
|
1013
1126
|
`unknown-kernel-op`, `unknown-solid-op`, `invalid-op-options`, `build-throws`,
|
|
@@ -1020,6 +1133,30 @@ previously didn't; that's the fix working as intended, not a regression.
|
|
|
1020
1133
|
`contacts` / `clearance` keys, which are not scalar view metrics; they are
|
|
1021
1134
|
validated by `verify-bad-pair-check`, matching `verify.js`'s own handling.
|
|
1022
1135
|
|
|
1136
|
+
**Animations block** — static validation of `animations`, without executing
|
|
1137
|
+
`build`: `animations-not-object`, `animation-tracks-or-steps`,
|
|
1138
|
+
`animation-unknown-param`, `animation-param-not-numeric`,
|
|
1139
|
+
`animation-keyframes-invalid`, `animation-value-out-of-range`,
|
|
1140
|
+
`animation-duration-invalid`, `animation-loop-invalid`,
|
|
1141
|
+
`animation-step-label-duplicate`, `animation-easing-unknown`,
|
|
1142
|
+
`animation-camera-invalid`, `animation-description-invalid`,
|
|
1143
|
+
`animation-autoplay-invalid` (all errors). One
|
|
1144
|
+
more rule does execute `build`, geometry-free: `animation-track-rebuilds` probes
|
|
1145
|
+
each track's endpoint values and emits a **note** when the animated param feeds
|
|
1146
|
+
real geometry (or the probe can't be trusted), because such a track plays
|
|
1147
|
+
best-effort rather than at frame rate. Notes are informational — they never
|
|
1148
|
+
affect `ok`, `measure`, or `--strict`.
|
|
1149
|
+
|
|
1150
|
+
**Place invariants**, found by running the geometry-free pose probe (the same
|
|
1151
|
+
one animation's `animation-track-rebuilds` uses) against each sub-part's
|
|
1152
|
+
`place()` — `view-dependent-display-place` (display placement must not depend
|
|
1153
|
+
on the active view, since display meshes are cached across views) and
|
|
1154
|
+
`place-not-rigid` (display vs. export placement may differ only by a rigid
|
|
1155
|
+
motion — translate/rotate — never a reshape) (both errors). An untrusted probe
|
|
1156
|
+
(a query op or function selector reached during `build`/`place`) proves
|
|
1157
|
+
nothing either way and stays silent, matching `animation-track-rebuilds`'s own
|
|
1158
|
+
trust handling.
|
|
1159
|
+
|
|
1023
1160
|
A rule that itself throws yields an `internal-rule-error` **warning** and the run
|
|
1024
1161
|
continues: `lintPart` never throws and never blocks a part because of a linter bug.
|
|
1025
1162
|
|
|
@@ -1129,8 +1266,8 @@ verify: {
|
|
|
1129
1266
|
**What the profile gives you:** a hard **bed-fit** gate (the view bbox must fit `bed`)
|
|
1130
1267
|
and a **min-wall** warning. **What `expect` gives you:** per-sub-part assertions on the
|
|
1131
1268
|
facts `measure` already reports — `holes` (through-bores / genus), `volume`,
|
|
1132
|
-
`surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`, `
|
|
1133
|
-
|
|
1269
|
+
`surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`, `boundsMin` / `boundsMax`
|
|
1270
|
+
(the axis-aligned `{min,max}` corner positions — where the geometry sits, vs
|
|
1134
1271
|
`bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
|
|
1135
1272
|
centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
|
|
1136
1273
|
`volume`, `overlaps`, `centerOfMass`, `boundsMin`, `boundsMax`, plus the pair-wise
|
|
@@ -1367,12 +1504,22 @@ An external tool (e.g. an AI agent editing your part) can ask the *user* to clic
|
|
|
1367
1504
|
geometry and receive the `Selection` back, closing the loop in the other direction
|
|
1368
1505
|
from `?pick`.
|
|
1369
1506
|
|
|
1370
|
-
- Serve your app with **`?pickserver
|
|
1371
|
-
it. While idle
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1507
|
+
- Serve your app with **`?pickserver&picktoken=<token>`** (or
|
|
1508
|
+
`?pickserver=http://127.0.0.1:4518&picktoken=<token>`) to enable it. While idle
|
|
1509
|
+
nothing changes; when the local pick-server requests a click, a banner appears
|
|
1510
|
+
("🤖 Claude needs you to click …") and the picker arms for one click.
|
|
1511
|
+
- The agent side runs `partforge pick-serve` once — it prints the token and the exact
|
|
1512
|
+
URL to open — then `partforge pick "<prompt>" …` for one or more clicks (collected in
|
|
1513
|
+
order, returned together). The CLI blocks until the user clicks, then prints the
|
|
1514
|
+
`Selection`(s) as JSON.
|
|
1515
|
+
- **The token is required.** Every route on the pick-server (including the SSE stream)
|
|
1516
|
+
is gated by a random per-process token, requests from non-loopback origins are
|
|
1517
|
+
refused, and the server never reflects an arbitrary `Origin`. Without that, any site
|
|
1518
|
+
the user browsed to while the server was running could read the agent's prompts,
|
|
1519
|
+
inject text into the agent's output, or harvest the user's live parameter values.
|
|
1520
|
+
A `?pickserver=` pointing anywhere but loopback is ignored with a console warning.
|
|
1521
|
+
`partforge pick` finds the token automatically via `~/.partforge/pick-<port>.token`;
|
|
1522
|
+
`--token` and `PARTFORGE_PICK_TOKEN` override it.
|
|
1376
1523
|
|
|
1377
1524
|
See the bundled skill `skills/partforge/SKILL.md` for the agent workflow. This is plain
|
|
1378
1525
|
click-routing — no LLM logic lives in partforge.
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -312,6 +312,12 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
|
|
|
312
312
|
- **Cause:** paper.js returned an unexpected or numerically degenerate path topology for the supplied font outline; the resolver refuses to attach the hole to an arbitrary outer.
|
|
313
313
|
- **Fix:** reduce or normalize degenerate font contours, confirm the correct CFF/TrueType fill rule was selected, and add the glyph as a focused `curve-fill.test.js` regression before changing resolver tolerances.
|
|
314
314
|
|
|
315
|
+
## animation-plays-choppy
|
|
316
|
+
|
|
317
|
+
- **Symptom:** An animation stutters or updates a few times a second instead of smoothly; `?debug` shows `rebuilt` counts climbing during playback.
|
|
318
|
+
- **Cause:** A track drives a param that feeds real geometry (or a build the pose probe can't trust — a query op or function selector), so every frame is a worker rebuild instead of a pose repair.
|
|
319
|
+
- **Fix:** Run `npx partforge lint <part>` — the `animation-track-rebuilds` note names the track. Restructure so the param only feeds rigid placement (`place()` or a trailing translate/rotate in `build`), or accept best-effort playback if geometry morphing is the intent. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Animations".
|
|
320
|
+
|
|
315
321
|
# Hardware library
|
|
316
322
|
|
|
317
323
|
Reserved for `hardware-*` patterns (issue #30). No entries yet.
|