partforge 0.40.0 → 0.44.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 (72) hide show
  1. package/README.md +46 -6
  2. package/bin/cli.js +103 -27
  3. package/docs/AUTHORING-PARTS.md +143 -15
  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 +243 -0
  10. package/src/framework/animation.js +217 -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/chrome.css +16 -0
  16. package/src/framework/controls.js +13 -3
  17. package/src/framework/cutaway-gizmo-scene.js +244 -0
  18. package/src/framework/cutaway-gizmo.js +80 -243
  19. package/src/framework/default-view.js +46 -0
  20. package/src/framework/download.js +7 -2
  21. package/src/framework/export-controller.js +13 -2
  22. package/src/framework/geometry/probe.js +3 -22
  23. package/src/framework/jobs.js +23 -42
  24. package/src/framework/lint/finding.js +4 -0
  25. package/src/framework/lint/index.js +7 -3
  26. package/src/framework/lint/rules-animations.js +404 -0
  27. package/src/framework/lint/rules-place.js +76 -0
  28. package/src/framework/lint/rules-shape.js +12 -0
  29. package/src/framework/lint/rules-verify.js +2 -2
  30. package/src/framework/mount.js +113 -18
  31. package/src/{testing → framework/oracle}/build.js +1 -1
  32. package/src/framework/oracle/bvh.js +463 -0
  33. package/src/{testing → framework/oracle}/gaps.js +6 -3
  34. package/src/{testing → framework/oracle}/measure.js +34 -4
  35. package/src/framework/oracle/min-wall.js +98 -0
  36. package/src/{testing → framework/oracle}/verify.js +61 -5
  37. package/src/framework/param-deps.js +1 -1
  38. package/src/framework/part-model.js +48 -0
  39. package/src/framework/pick-request/client.js +11 -3
  40. package/src/framework/pick-request/endpoint.js +60 -0
  41. package/src/framework/pick-request/index.js +6 -0
  42. package/src/framework/pick-request/server.js +222 -34
  43. package/src/framework/pick-request/token-store.js +31 -0
  44. package/src/framework/pose-fast-path.js +12 -1
  45. package/src/framework/pose-probe-core.js +129 -0
  46. package/src/framework/pose-probe.js +7 -123
  47. package/src/framework/regen-loop.js +10 -3
  48. package/src/framework/safe-name.js +26 -0
  49. package/src/framework/verify-metrics.js +19 -6
  50. package/src/framework/view-state.js +25 -21
  51. package/src/framework/view-tabs.js +22 -7
  52. package/src/framework/viewer-controls.js +5 -26
  53. package/src/framework/viewer.js +126 -16
  54. package/src/hinged-box-worker.js +3 -0
  55. package/src/index.js +1 -1
  56. package/src/parts/hinged-box.js +94 -0
  57. package/src/testing/render.js +19 -8
  58. package/src/testing.js +15 -8
  59. package/types/derive.d.ts +14 -0
  60. package/types/geometry.d.ts +117 -0
  61. package/types/index.d.ts +240 -0
  62. package/types/kernel.d.ts +409 -0
  63. package/types/lint.d.ts +85 -0
  64. package/types/part.d.ts +381 -0
  65. package/types/testing.d.ts +362 -0
  66. package/types/worker.d.ts +21 -0
  67. package/src/testing/bvh.js +0 -273
  68. package/src/testing/min-wall.js +0 -38
  69. /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
  70. /package/src/{testing → framework/oracle}/cases.js +0 -0
  71. /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
  72. /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
@@ -112,15 +112,35 @@ await runtime.ready; // first successful build (rejects on a first-build error
112
112
  runtime.setHostPane("rail"); // narrow layout only: show just the controls
113
113
  // rail ('stage' | 'rail'), suppressing the
114
114
  // built-in tab bar. null hands selection back.
115
+ runtime.setActive(false); // park the viewer: stop the render loop, release the
116
+ // drawing buffer. setActive(true) restores both.
117
+ const off = runtime.onContextLost(() => {}); // WebGL context loss; returns an unsubscribe
115
118
  runtime.dispose(); // stops loops, workers, observers, listeners; frees GPU resources
116
119
  ```
117
120
 
121
+ **Park the viewer when you hide it.** A host that hides the canvas with
122
+ `display: none` needs nothing — the container collapses and the ResizeObserver
123
+ shrinks the drawing buffer for free. A host that hides it any other way
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
+ 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
131
+ `setActive(true)` when it comes back.
132
+
133
+ Parking releases both large GPU allocations — the drawing buffer and the cached
134
+ 1024² capture target — and stops the render loop. Offscreen captures
135
+ (`captureCurrent`, `captureViews`) keep working while parked and framing is
136
+ unchanged, so a host can still take a build screenshot of a hidden viewer; the
137
+ first capture after parking just re-allocates its target.
138
+
118
139
  Every `elements` entry defaults to the legacy global ID (`#app`, `#controls`,
119
140
  `#panel` for `rail`, `#status`/`#busy`/`#phase`, `#part`,
120
141
  `#download`/`#download-step`/`#download-3mf`,
121
- `#pause`/`#reframe`/`#theme`/`#rail-toggle`), so a classic host page needs no
122
- changes. The viewer sizes from its container via ResizeObserver — no window
123
- 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.
124
144
 
125
145
  `rail` is the full-height controls rail introduced by the resizable-panel
126
146
  layout (`docs/superpowers/specs/2026-07-26-controls-rail-layout-design.md`);
@@ -145,6 +165,25 @@ viewer re-poses the meshes it already has — instantly, with no worker rebuild;
145
165
  `onBuild` does not fire for those pose-only edits. Anything that changes the
146
166
  geometry itself rebuilds as usual.
147
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
+
148
187
  `onPick` arms click-to-select permanently: `label` is the feature label (falling
149
188
  back to the sub-part label/name) for compact UI, `prompt` is the LLM-ready
150
189
  sentence, `token` the compact form, `selection` the raw object. When `onPick` is
@@ -163,8 +202,9 @@ internal params, and keep the interface simple while staying deeply adjustable.
163
202
  Locally, `npm run dev` then open `/demo.html`, `/planter.html`, or `/filleted-box.html`.
164
203
 
165
204
  - **Agent clarification (`request-a-pick`):** an external tool can ask the user to click
166
- geometry and get the `Selection` back — serve with `?pickserver`, drive with
167
- `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
168
208
  `skills/partforge/SKILL.md` and the authoring guide.
169
209
 
170
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,71 @@ 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
+ const outDir = flags.out || "render";
151
163
  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}`);
164
+ // Usage check BEFORE the kernel: a flag typo shouldn't pay a WASM boot.
165
+ if (!flags.animation && (flags.at || flags.step)) {
166
+ die(`--at/--step require --animation\n${usage}`);
167
+ }
168
+ const kernel = await bootKernel(part);
169
+
170
+ if (!flags.animation) {
171
+ const files = await renderViews(kernel, part, view, { views, out: outDir, params: baseParams });
172
+ for (const f of files) console.log(`wrote ${f}`);
173
+ process.exit(0);
174
+ }
175
+
176
+ const spec = part.animations?.[flags.animation];
177
+ if (!spec) {
178
+ throw new Error(`unknown animation "${flags.animation}" (have: ${Object.keys(part.animations ?? {}).join(", ") || "none"})`);
179
+ }
180
+ const anim = normalizeAnimation(flags.animation, spec);
181
+ // Frames: --step renders one still at the END of that step (its fully
182
+ // applied state); --at takes positions normalized over the animation's
183
+ // TOTAL duration (same t as the viewer scrubber / runtime seek).
184
+ let frames;
185
+ if (flags.step != null) {
186
+ const byLabel = anim.steps.findIndex((s) => s.label === flags.step);
187
+ const idx = byLabel >= 0 ? byLabel : Number(flags.step) - 1;
188
+ if (!(idx >= 0 && idx < anim.steps.length)) {
189
+ throw new Error(`unknown step "${flags.step}" (use 1..${anim.steps.length} or a label: ${anim.steps.map((s) => JSON.stringify(s.label)).join(", ")})`);
190
+ }
191
+ const end = idx + 1 < anim.steps.length ? anim.stepStarts[idx + 1] : 1;
192
+ // Values come from the END of the step (fully applied), but the cue
193
+ // lookup uses the step's own START: cue boundaries belong to the LATER
194
+ // step (see animation.js stepIndexAt/cueAt), and `end` here IS the next
195
+ // step's start, so querying the cue at `end` would resolve to the next
196
+ // step's camera instead of this step's own.
197
+ frames = [{ t: end, cueT: anim.stepStarts[idx], tag: `${flags.animation}-step${idx + 1}` }];
198
+ } else {
199
+ const ts = (flags.at ?? "1").split(",").map(Number);
200
+ if (!ts.length || ts.some((t) => !Number.isFinite(t) || t < 0 || t > 1)) {
201
+ die(`--at takes comma-separated positions in 0..1\n${usage}`);
202
+ }
203
+ frames = ts.map((t) => ({ t, tag: `${flags.animation}-t${String(Math.round(t * 100)).padStart(3, "0")}` }));
204
+ }
205
+ for (const frame of frames) {
206
+ const { values } = evaluate(anim, frame.t);
207
+ const cue = cueAt(anim, frame.cueT ?? frame.t);
208
+ const frameViews = views ?? (cue ? [cue.view] : undefined);
209
+ const files = await renderViews(kernel, part, view, {
210
+ views: frameViews, out: outDir, params: { ...baseParams, ...values }, tag: frame.tag,
211
+ });
212
+ for (const f of files) console.log(`wrote ${f}`);
213
+ }
154
214
  process.exit(0);
155
215
  } catch (e) {
156
216
  crash("render", e, false);
@@ -160,19 +220,31 @@ const commands = {
160
220
  async "pick-serve"(args) {
161
221
  const usage = "usage: partforge pick-serve [--port N] [--timeout <seconds>]";
162
222
  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();
223
+ const port = Number(flags.port) || PICK_SERVER_DEFAULT_PORT;
224
+ const timeoutMs = (Number(flags.timeout) || PICK_SERVER_DEFAULT_TIMEOUT_MS / 1000) * 1000;
225
+ const srv = createPickServer({ port, timeoutMs });
226
+ const { port: bound } = await srv.start();
227
+ // The token is what keeps every other page on the machine out of this server.
228
+ // `partforge pick` runs in a different process, so drop it in a 0600 file for
229
+ // that process to find; the browser gets it through the app URL below.
230
+ savePickToken(bound, srv.token);
231
+ const stop = () => { clearPickToken(bound); process.exit(0); };
232
+ process.on("SIGINT", stop);
233
+ process.on("SIGTERM", stop);
166
234
  console.log(`partforge pick-server listening on http://127.0.0.1:${bound}`);
235
+ console.log(`token: ${srv.token} (also at ${pickTokenPath(bound)})`);
236
+ console.log(`open the app with: ?pickserver=http://127.0.0.1:${bound}&picktoken=${srv.token}`);
167
237
  // no exit — the process stays alive serving requests
168
238
  },
169
239
 
170
240
  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);
241
+ const usage = 'usage: partforge pick "<prompt>" ["<prompt>" …] [--port N] [--token T]';
242
+ const { values: flags, positionals: prompts } = parse(args, { port: { type: "string" }, token: { type: "string" } }, usage);
173
243
  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));
244
+ const port = Number(flags.port) || PICK_SERVER_DEFAULT_PORT;
245
+ const token = flags.token || process.env.PARTFORGE_PICK_TOKEN || loadPickToken(port);
246
+ if (!token) die(`no pick-server token for port ${port} — start one with \`partforge pick-serve\`, or pass --token`);
247
+ const out = await requestPicks({ port, prompts, token }).catch((e) => die(e.message));
176
248
  console.log(formatPickResult(out));
177
249
  process.exit(out.status === "done" ? 0 : 1);
178
250
  },
@@ -204,6 +276,9 @@ function printVerify(v) {
204
276
  for (const ch of c.checks) {
205
277
  const icon = ch.status === "pass" ? "✓" : ch.status === "fail" ? "✗" : ch.status === "warn" ? "⚠" : "·";
206
278
  console.log(` ${icon} ${ch.subpart ?? "_view"} ${ch.metric} ${ch.expr} (${ch.message})`);
279
+ // A measurement caveat prints whatever the verdict — "passed, but sampled"
280
+ // is precisely the line a reader must not miss.
281
+ if (ch.note) console.log(` note: ${ch.note}`);
207
282
  if (ch.status === "fail" || ch.status === "warn") {
208
283
  if (ch.location) console.log(` at [${ch.location.map((n) => n.toFixed(1)).join(", ")}]`);
209
284
  if (ch.hint) console.log(` hint: ${ch.hint}${ch.pattern ? ` (ERROR-PATTERNS.md#${ch.pattern})` : ""}`);
@@ -215,16 +290,17 @@ function printVerify(v) {
215
290
  }
216
291
 
217
292
  function printLint(r) {
218
- const all = [...r.errors, ...r.warnings];
293
+ const all = [...r.errors, ...r.warnings, ...(r.notes ?? [])];
219
294
  if (all.length === 0) { console.log("lint: clean"); return; }
220
295
  console.log("lint:");
221
296
  for (const f of all) {
222
- console.log(` ${f.severity === "error" ? "✗" : ""} ${f.rule}${f.path ? ` ${f.path}` : ""}`);
297
+ const icon = f.severity === "error" ? "✗" : f.severity === "warning" ? "⚠" : "·";
298
+ console.log(` ${icon} ${f.rule}${f.path ? ` ${f.path}` : ""}`);
223
299
  console.log(` ${f.message}`);
224
300
  console.log(` hint: ${f.hint}${f.pattern ? ` (ERROR-PATTERNS.md#${f.pattern})` : ""}`);
225
301
  }
226
- const e = r.errors.length, w = r.warnings.length;
227
- console.log(` result: ${e ? `${e} error(s)` : "no errors"}${w ? `, ${w} warning(s)` : ""}`);
302
+ const e = r.errors.length, w = r.warnings.length, n = (r.notes ?? []).length;
303
+ console.log(` result: ${e ? `${e} error(s)` : "no errors"}${w ? `, ${w} warning(s)` : ""}${n ? `, ${n} note(s)` : ""}`);
228
304
  }
229
305
 
230
306
  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,76 @@ 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
+ - Couple motions through `derive` (animate one master param; derive the rest),
171
+ not by tracking dependent params separately.
172
+ - `camera` cues use the seven canonical angles (`iso front back top bottom
173
+ left right`). One mechanism per animation: an animation-level name (an intro
174
+ cue at t=0), an animation-level `[[t, angle], …]` list, or per-step names.
175
+ Cues fire during play only — scrubbing never moves the camera — and a user
176
+ orbit disarms the remaining cues for that run.
177
+ - Playback drives params through the real param pipeline: a **pose-only**
178
+ param (feeds only rigid placement — see "Caching & determinism" below) plays
179
+ at frame rate; anything else rebuilds best-effort at worker cadence. `lint`
180
+ prints a note per track that can't take the fast path.
181
+ - Playback pauses when the user edits any control; Reset restores the values
182
+ the animation found. Because animated values are real params, exporting
183
+ while paused exports the posed state — by design.
184
+ - `autoplay: true` (optional, one animation at most) starts that animation on
185
+ first show and again on each view switch, until the user touches the
186
+ transport — or anything writes params (`runtime.setParams` included) or
187
+ calls a `runtime.animation` method; any of those disarms auto-start for the
188
+ session. Lint: `animation-autoplay-invalid`. It is not armed when the
189
+ browser reports `prefers-reduced-motion: reduce` — self-starting motion is
190
+ exactly what that setting asks a page not to do. An autoplay animation that
191
+ declares a `camera` cue will sweep the camera away from the user's
192
+ persisted framing on every page load, so choose cues for autoplay
193
+ deliberately — the shipped example's `cycle` animation has none.
194
+
195
+ Headless: `partforge render <part> --animation open --at 0,0.5,1` renders
196
+ tagged stills (`--at` is normalized over the animation's total duration, like
197
+ the scrubber); `--step <index|label>` renders a step's end state; stills
198
+ default to the governing camera cue's angle.
199
+
200
+ ---
201
+
124
202
  ## Geometry: the kernel / `Solid` API
125
203
 
126
204
  `build` receives a backend-agnostic `kernel` (`k`). It returns and combines `Solid`
@@ -702,12 +780,12 @@ stylesheet). `mount` looks up these element IDs:
702
780
  |---|---|
703
781
  | `#app` | viewer canvas mounts here |
704
782
  | `#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` |
783
+ | `#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
784
  | `#download-step` / `#download` / `#download-3mf` | STEP / STL / 3MF export buttons |
707
785
  | `#status`, `#busy`, `#phase` | status line + busy overlay |
708
- | `#viewbar` with `#pause` / `#reframe` / `#cutaway` / `#theme` | optional viewer controls (omit any you don't want) |
786
+ | `#viewbar` with `#reframe` / `#cutaway` / `#theme` | optional viewer controls (omit any you don't want) |
709
787
  | `#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` |
788
+ | `#rail-toggle` | optional — collapses/restores the rail; resolved the same way as `#reframe`/`#theme` |
711
789
 
712
790
  Copy `demo.html` and change the title, the panel heading, and the `<script src>`. Two
713
791
  workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
@@ -817,7 +895,6 @@ mount(part, {
817
895
  elements: {
818
896
  rail,
819
897
  chrome: {
820
- pause,
821
898
  reframe,
822
899
  cutaway,
823
900
  theme,
@@ -1003,7 +1080,8 @@ previously didn't; that's the fix working as intended, not a regression.
1003
1080
  ### Rule catalog
1004
1081
 
1005
1082
  **Definition shape** — `missing-meta-title`, `missing-defaults`, `no-buildable-parts`,
1006
- `missing-views`, `part-view-unknown` (all errors); `view-unused` (warning).
1083
+ `missing-views`, `part-view-unknown` (all errors); `view-unused`,
1084
+ `default-view-ambiguous` (warnings).
1007
1085
 
1008
1086
  **Parameter schema** — `features-requires-sliders`, `control-key-not-in-defaults`,
1009
1087
  `preset-key-not-in-defaults` (errors); `slider-range-excludes-default`,
@@ -1020,6 +1098,30 @@ previously didn't; that's the fix working as intended, not a regression.
1020
1098
  `contacts` / `clearance` keys, which are not scalar view metrics; they are
1021
1099
  validated by `verify-bad-pair-check`, matching `verify.js`'s own handling.
1022
1100
 
1101
+ **Animations block** — static validation of `animations`, without executing
1102
+ `build`: `animations-not-object`, `animation-tracks-or-steps`,
1103
+ `animation-unknown-param`, `animation-param-not-numeric`,
1104
+ `animation-keyframes-invalid`, `animation-value-out-of-range`,
1105
+ `animation-duration-invalid`, `animation-loop-invalid`,
1106
+ `animation-step-label-duplicate`, `animation-easing-unknown`,
1107
+ `animation-camera-invalid`, `animation-description-invalid`,
1108
+ `animation-autoplay-invalid` (all errors). One
1109
+ more rule does execute `build`, geometry-free: `animation-track-rebuilds` probes
1110
+ each track's endpoint values and emits a **note** when the animated param feeds
1111
+ real geometry (or the probe can't be trusted), because such a track plays
1112
+ best-effort rather than at frame rate. Notes are informational — they never
1113
+ affect `ok`, `measure`, or `--strict`.
1114
+
1115
+ **Place invariants**, found by running the geometry-free pose probe (the same
1116
+ one animation's `animation-track-rebuilds` uses) against each sub-part's
1117
+ `place()` — `view-dependent-display-place` (display placement must not depend
1118
+ on the active view, since display meshes are cached across views) and
1119
+ `place-not-rigid` (display vs. export placement may differ only by a rigid
1120
+ motion — translate/rotate — never a reshape) (both errors). An untrusted probe
1121
+ (a query op or function selector reached during `build`/`place`) proves
1122
+ nothing either way and stays silent, matching `animation-track-rebuilds`'s own
1123
+ trust handling.
1124
+
1023
1125
  A rule that itself throws yields an `internal-rule-error` **warning** and the run
1024
1126
  continues: `lintPart` never throws and never blocks a part because of a linter bug.
1025
1127
 
@@ -1032,6 +1134,9 @@ carries:
1032
1134
  - `hint` — one self-contained corrective sentence (always present),
1033
1135
  - `pattern` — a stable [ERROR-PATTERNS.md](ERROR-PATTERNS.md) entry ID when one
1034
1136
  applies (follow it with `ERROR-PATTERNS.md#<id>`),
1137
+ - `note` — an optional caveat about *how* the value was measured, attached
1138
+ whatever the verdict. Today only `minWall` sets one, when the reading came
1139
+ from a sample rather than every triangle (see below),
1035
1140
  - `location` — `[x, y, z]` in mm where the metric has one: `minWall` (thinnest
1036
1141
  sample point) and `overlaps` (the center of the first offending intersection's
1037
1142
  *bounding box* — a nearby indicator, not an exact point: when a pair overlaps in
@@ -1042,7 +1147,20 @@ carries:
1042
1147
 
1043
1148
  Subpart facts include `minWall` (number or `null` — null exactly when no reading
1044
1149
  exists, e.g. the OCCT backend or min-wall measurement turned off, matching
1045
- `minWallAt`'s null) and `minWallAt` (`[x,y,z]` or `null`); overlap entries are
1150
+ `minWallAt`'s null) and `minWallAt` (`[x,y,z]` or `null`). Min wall casts one ray
1151
+ per triangle, which is unbounded work on a dense mesh, so past 50,000 triangles
1152
+ it casts from a spread, deterministic subset instead — `minWallSampled` (boolean)
1153
+ and `minWallSamples` (`{ sampled, total }` or `null`) say whether that happened.
1154
+ `sampled` is how many triangles the walk *selected*, not how many rays were
1155
+ cast: a degenerate (zero-area) triangle has no normal to cast along and is
1156
+ skipped. A sampled reading is an **upper bound**: it can miss a thin spot, never
1157
+ invent one — and a sampled run that found no wall at all still reports its
1158
+ `minWallSamples`, so a null `minWall` there is "we looked and found nothing",
1159
+ not "nobody looked". Everything in `src/parts/` is far below the budget and
1160
+ reads exactly. The report's top-level `measuredMinWall` says whether this run
1161
+ cast min-wall rays at all — the difference between a null `minWall` that means
1162
+ "no wall found" and one that means "not measured".
1163
+ Overlap entries are
1046
1164
  `{ a, b, volume, location }`. Pair-distance facts are `gaps` (every sub-part
1047
1165
  pair: `{ a, b, distance, at }`, distance 0 = touching or overlapping) and
1048
1166
  `nearMisses` (the pairs with an unintended-looking gap under 0.5 mm).
@@ -1113,8 +1231,8 @@ verify: {
1113
1231
  **What the profile gives you:** a hard **bed-fit** gate (the view bbox must fit `bed`)
1114
1232
  and a **min-wall** warning. **What `expect` gives you:** per-sub-part assertions on the
1115
1233
  facts `measure` already reports — `holes` (through-bores / genus), `volume`,
1116
- `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`, `bounds` (per-sub-part
1117
- and aggregate axis-aligned `{min,max}` corner positions — where the geometry sits, vs
1234
+ `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`, `boundsMin` / `boundsMax`
1235
+ (the axis-aligned `{min,max}` corner positions — where the geometry sits, vs
1118
1236
  `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
1119
1237
  centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
1120
1238
  `volume`, `overlaps`, `centerOfMass`, `boundsMin`, `boundsMax`, plus the pair-wise
@@ -1351,12 +1469,22 @@ An external tool (e.g. an AI agent editing your part) can ask the *user* to clic
1351
1469
  geometry and receive the `Selection` back, closing the loop in the other direction
1352
1470
  from `?pick`.
1353
1471
 
1354
- - Serve your app with **`?pickserver`** (or `?pickserver=http://host:port`) to enable
1355
- it. While idle nothing changes; when the local pick-server requests a click, a banner
1356
- appears ("🤖 Claude needs you to click …") and the picker arms for one click.
1357
- - The agent side runs `partforge pick-serve` once, then `partforge pick "<prompt>" …`
1358
- for one or more clicks (collected in order, returned together). The CLI blocks until
1359
- the user clicks, then prints the `Selection`(s) as JSON.
1472
+ - Serve your app with **`?pickserver&picktoken=<token>`** (or
1473
+ `?pickserver=http://127.0.0.1:4518&picktoken=<token>`) to enable it. While idle
1474
+ nothing changes; when the local pick-server requests a click, a banner appears
1475
+ ("🤖 Claude needs you to click …") and the picker arms for one click.
1476
+ - The agent side runs `partforge pick-serve` once it prints the token and the exact
1477
+ URL to open then `partforge pick "<prompt>" …` for one or more clicks (collected in
1478
+ order, returned together). The CLI blocks until the user clicks, then prints the
1479
+ `Selection`(s) as JSON.
1480
+ - **The token is required.** Every route on the pick-server (including the SSE stream)
1481
+ is gated by a random per-process token, requests from non-loopback origins are
1482
+ refused, and the server never reflects an arbitrary `Origin`. Without that, any site
1483
+ the user browsed to while the server was running could read the agent's prompts,
1484
+ inject text into the agent's output, or harvest the user's live parameter values.
1485
+ A `?pickserver=` pointing anywhere but loopback is ignored with a console warning.
1486
+ `partforge pick` finds the token automatically via `~/.partforge/pick-<port>.token`;
1487
+ `--token` and `PARTFORGE_PICK_TOKEN` override it.
1360
1488
 
1361
1489
  See the bundled skill `skills/partforge/SKILL.md` for the agent workflow. This is plain
1362
1490
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.40.0",
3
+ "version": "0.44.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,22 +16,61 @@
16
16
  "files": [
17
17
  "src",
18
18
  "bin",
19
+ "types",
19
20
  "skills/partforge/SKILL.md",
20
21
  "docs/AUTHORING-PARTS.md",
21
22
  "docs/ERROR-PATTERNS.md",
22
23
  "docs/KERNEL-CONTRACT.md",
23
24
  "README.md"
24
25
  ],
26
+ "types": "./types/index.d.ts",
25
27
  "exports": {
26
- ".": "./src/index.js",
27
- "./worker": "./src/framework/worker.js",
28
- "./geometry": "./src/framework/geometry/polygon.js",
29
- "./lint": "./src/lint.js",
30
- "./derive": "./src/framework/derive.js",
31
- "./testing": "./src/testing.js",
28
+ ".": {
29
+ "types": "./types/index.d.ts",
30
+ "default": "./src/index.js"
31
+ },
32
+ "./worker": {
33
+ "types": "./types/worker.d.ts",
34
+ "default": "./src/framework/worker.js"
35
+ },
36
+ "./geometry": {
37
+ "types": "./types/geometry.d.ts",
38
+ "default": "./src/framework/geometry/polygon.js"
39
+ },
40
+ "./lint": {
41
+ "types": "./types/lint.d.ts",
42
+ "default": "./src/lint.js"
43
+ },
44
+ "./derive": {
45
+ "types": "./types/derive.d.ts",
46
+ "default": "./src/framework/derive.js"
47
+ },
48
+ "./testing": {
49
+ "types": "./types/testing.d.ts",
50
+ "default": "./src/testing.js"
51
+ },
32
52
  "./tokens.css": "./src/framework/tokens.css",
33
53
  "./chrome.css": "./src/framework/chrome.css"
34
54
  },
55
+ "typesVersions": {
56
+ "*": {
57
+ "worker": [
58
+ "./types/worker.d.ts"
59
+ ],
60
+ "geometry": [
61
+ "./types/geometry.d.ts"
62
+ ],
63
+ "lint": [
64
+ "./types/lint.d.ts"
65
+ ],
66
+ "derive": [
67
+ "./types/derive.d.ts"
68
+ ],
69
+ "testing": [
70
+ "./types/testing.d.ts"
71
+ ]
72
+ }
73
+ },
35
74
  "bin": {
36
75
  "partforge": "./bin/cli.js"
37
76
  },
@@ -41,6 +80,7 @@
41
80
  "preview": "vite preview",
42
81
  "test": "vitest run",
43
82
  "test:watch": "vitest",
83
+ "typecheck": "tsc -p tsconfig.json",
44
84
  "check": "node scripts/check-app.mjs"
45
85
  },
46
86
  "dependencies": {
@@ -60,6 +100,7 @@
60
100
  "@fontsource-variable/geist-mono": "^5.3.0",
61
101
  "happy-dom": "^20.10.6",
62
102
  "playwright": "^1.49.0",
103
+ "typescript": "^7.0.2",
63
104
  "vite": "^8.0.16",
64
105
  "vitest": "^4.1.9"
65
106
  }