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.
Files changed (74) hide show
  1. package/README.md +31 -10
  2. package/bin/cli.js +138 -27
  3. package/docs/AUTHORING-PARTS.md +164 -17
  4. package/docs/ERROR-PATTERNS.md +6 -0
  5. package/package.json +48 -7
  6. package/skills/partforge/SKILL.md +17 -3
  7. package/src/app-embed-test.js +1 -1
  8. package/src/app-hinged-box.js +12 -0
  9. package/src/framework/animation-controls.js +254 -0
  10. package/src/framework/animation.js +271 -0
  11. package/src/framework/app.css +32 -0
  12. package/src/framework/assembly.js +1 -1
  13. package/src/framework/backend-select.js +25 -0
  14. package/src/framework/camera-tween.js +58 -0
  15. package/src/framework/capture-build.js +59 -0
  16. package/src/framework/chrome.css +16 -0
  17. package/src/framework/controls.js +13 -3
  18. package/src/framework/cutaway-gizmo-scene.js +244 -0
  19. package/src/framework/cutaway-gizmo.js +80 -243
  20. package/src/framework/default-view.js +46 -0
  21. package/src/framework/download.js +7 -2
  22. package/src/framework/export-controller.js +13 -2
  23. package/src/framework/geometry/probe.js +3 -22
  24. package/src/framework/jobs.js +30 -40
  25. package/src/framework/lint/finding.js +4 -0
  26. package/src/framework/lint/index.js +7 -3
  27. package/src/framework/lint/rules-animations.js +441 -0
  28. package/src/framework/lint/rules-place.js +76 -0
  29. package/src/framework/lint/rules-schema.js +22 -0
  30. package/src/framework/lint/rules-shape.js +12 -0
  31. package/src/framework/lint/rules-verify.js +2 -2
  32. package/src/framework/mount.js +147 -20
  33. package/src/{testing → framework/oracle}/build.js +1 -1
  34. package/src/{testing → framework/oracle}/bvh.js +1 -1
  35. package/src/{testing → framework/oracle}/measure.js +1 -1
  36. package/src/{testing → framework/oracle}/min-wall.js +1 -1
  37. package/src/{testing → framework/oracle}/verify.js +3 -3
  38. package/src/framework/param-deps.js +1 -1
  39. package/src/framework/part-model.js +48 -0
  40. package/src/framework/pick-request/client.js +11 -3
  41. package/src/framework/pick-request/endpoint.js +60 -0
  42. package/src/framework/pick-request/index.js +6 -0
  43. package/src/framework/pick-request/server.js +222 -34
  44. package/src/framework/pick-request/token-store.js +31 -0
  45. package/src/framework/pose-fast-path.js +12 -1
  46. package/src/framework/pose-probe-core.js +129 -0
  47. package/src/framework/pose-probe.js +7 -123
  48. package/src/framework/regen-loop.js +10 -3
  49. package/src/framework/safe-name.js +26 -0
  50. package/src/framework/verify-metrics.js +4 -4
  51. package/src/framework/view-state.js +25 -21
  52. package/src/framework/view-tabs.js +35 -7
  53. package/src/framework/viewer-controls.js +5 -26
  54. package/src/framework/viewer-lighting.js +8 -1
  55. package/src/framework/viewer.js +139 -20
  56. package/src/framework/worker.js +5 -1
  57. package/src/hinged-box-worker.js +3 -0
  58. package/src/index.js +1 -1
  59. package/src/parts/hinged-box.js +94 -0
  60. package/src/testing/render.js +19 -8
  61. package/src/testing.js +15 -8
  62. package/types/derive.d.ts +14 -0
  63. package/types/geometry.d.ts +117 -0
  64. package/types/index.d.ts +259 -0
  65. package/types/kernel.d.ts +409 -0
  66. package/types/lint.d.ts +85 -0
  67. package/types/part.d.ts +409 -0
  68. package/types/testing.d.ts +362 -0
  69. package/types/worker.d.ts +21 -0
  70. /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
  71. /package/src/{testing → framework/oracle}/cases.js +0 -0
  72. /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
  73. /package/src/{testing → framework/oracle}/gaps.js +0 -0
  74. /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: { pause, reframe, theme, railToggle }, // viewer buttons + rail collapse/restore
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 drawing
126
- an auto-rotating scene at 60fps that nobody can see. On a phone that is tens of
127
- megabytes plus continuous GPU work, and it has been enough on its own to get a
128
- tab killed. Call `runtime.setActive(false)` when the viewer goes off-screen and
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
- `#pause`/`#reframe`/`#theme`/`#rail-toggle`), so a classic host page needs no
141
- changes. The viewer sizes from its container via ResizeObserver — no window
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`, drive with
186
- `partforge pick-serve` + `partforge pick "<prompt>" …`. See
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/geometry/probe.js";
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/testing/measure.js";
14
- import { verify } from "../src/testing/verify.js";
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 { createPickServer, requestPicks, formatPickResult } from "../src/framework/pick-request/server.js";
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: crash
26
- // JSON is the only thing on stdout for errors thrown before any report printing
27
- // (load/boot/measure). But verify() runs after printMeasure and can throw (an
28
- // unknown metric in verify.expect, or a per-case build crash), so a throw after
29
- // printing appends the JSON after the human lines it is not pure. Consumers
30
- // should prefer --out for robust machine parsing.
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 kernel = await bootKernel(part);
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
- const files = await renderViews(kernel, part, view, { views, out: flags.out || "render" });
153
- for (const f of files) console.log(`wrote ${f}`);
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) || 4518;
164
- const timeoutMs = (Number(flags.timeout) || 120) * 1000;
165
- const { port: bound } = await createPickServer({ port, timeoutMs }).start();
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) || 4518;
175
- const out = await requestPicks({ port, prompts }).catch((e) => die(e.message));
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
- console.log(` ${f.severity === "error" ? "✗" : ""} ${f.rule}${f.path ? ` ${f.path}` : ""}`);
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;
@@ -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 } }, // the view tabs (a view = a set of sub-parts)
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 `#pause` / `#reframe` / `#cutaway` / `#theme` | optional viewer controls (omit any you don't want) |
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 `#pause`/`#theme` |
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` (warning).
1117
+ `missing-views`, `part-view-unknown` (all errors); `view-unused`,
1118
+ `default-view-ambiguous` (warnings).
1007
1119
 
1008
- **Parameter schema** — `features-requires-sliders`, `control-key-not-in-defaults`,
1009
- `preset-key-not-in-defaults` (errors); `slider-range-excludes-default`,
1010
- `unknown-control-field`, `duplicate-control-key`, `default-not-exposed` (warnings).
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`, `bounds` (per-sub-part
1133
- and aggregate axis-aligned `{min,max}` corner positions — where the geometry sits, vs
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`** (or `?pickserver=http://host:port`) to enable
1371
- it. While idle nothing changes; when the local pick-server requests a click, a banner
1372
- appears ("🤖 Claude needs you to click …") and the picker arms for one click.
1373
- - The agent side runs `partforge pick-serve` once, then `partforge pick "<prompt>" …`
1374
- for one or more clicks (collected in order, returned together). The CLI blocks until
1375
- the user clicks, then prints the `Selection`(s) as JSON.
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.
@@ -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.