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
@@ -11,26 +11,39 @@ import { buildControls } from "./controls.js";
11
11
  import { relevantParamKeys } from "./param-deps.js";
12
12
  import { createMeshCache } from "./mesh-cache.js";
13
13
  import { createGeometryService } from "./geometry-service.js";
14
- import { viewSubParts } from "./jobs.js";
14
+ import { viewSubParts } from "./part-model.js";
15
15
  import { resolveDerived } from "./derive.js";
16
- import { detectBackend } from "./geometry/probe.js";
16
+ import { detectBackend } from "./backend-select.js";
17
17
  import { createDebugOverlay } from "./debug-overlay.js";
18
18
  import { createRegenLoop } from "./regen-loop.js";
19
19
  import { createPoseFastPath } from "./pose-fast-path.js";
20
20
  import { createStatusUi } from "./status-ui.js";
21
21
  import { createViewTabs } from "./view-tabs.js";
22
22
  import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } from "./selection/index.js";
23
- import { createPickRequestClient } from "./pick-request/index.js";
23
+ import { createPickRequestClient, resolvePickServerUrl, PICK_SERVER_DEFAULT_URL } from "./pick-request/index.js";
24
24
  import { exportablePartNames, partLabel } from "./export-select.js";
25
- import { createExportController } from "./export-controller.js";
25
+ import { createExportController, backendForFormat } from "./export-controller.js";
26
+ import { attachAnimationControls } from "./animation-controls.js";
26
27
 
27
28
  // The mount handle, factored out so its shape is unit-testable without booting
28
29
  // the full mount() pipeline (WASM + workers + DOM).
29
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane }) {
30
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation }) {
30
31
  return {
31
32
  ready, dispose, setParams,
33
+ // Part-declared animation playback (spec 2026-08-02): null when the part
34
+ // declares no animations. { play(name?), pause(), seek(t), stop(), state() }.
35
+ animation: animation ?? null,
32
36
  captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
33
37
  captureCurrent: (opts) => viewer.captureCurrent(opts),
38
+ // Park/unpark the viewer: stops the render loop and frees the drawing
39
+ // buffer and the cached capture target. For an embedder that hides the
40
+ // canvas without unmounting it — `visibility: hidden`, an off-screen tab —
41
+ // where nothing collapses the container and the loop would otherwise run
42
+ // forever. See the setActive comment in viewer.js for what it costs.
43
+ setActive: (active) => viewer.setActive(active),
44
+ // Subscribe to WebGL context loss (returns an unsubscribe), so a host can
45
+ // say "the 3D view ran out of memory" instead of showing a dead canvas.
46
+ onContextLost: (listener) => viewer.onContextLost(listener),
34
47
  listExportableParts,
35
48
  exportParts,
36
49
  // Narrow-layout pane selection, for a host that draws its own tab bar
@@ -69,7 +82,7 @@ function createCleanupStack() {
69
82
  // mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
70
83
  // so Vite can bundle the worker (see geometry-service.js).
71
84
  //
72
- // Embedding contract (0.39.0):
85
+ // Embedding contract (0.44.0):
73
86
  // const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload });
74
87
  // await runtime.ready; // first successful build of the default view
75
88
  // runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
@@ -86,6 +99,25 @@ function createCleanupStack() {
86
99
  // runtime.setHostPane("rail"); // narrow layout only: show just the controls
87
100
  // // rail ('stage' | 'rail'), suppressing the
88
101
  // // built-in tab bar. null hands selection back.
102
+ // runtime.setActive(false); // park the viewer: stop the render loop and release
103
+ // // both large GPU allocations (the drawing buffer and
104
+ // // the cached capture target). For a host that hides the
105
+ // // canvas WITHOUT unmounting it (`visibility: hidden`, an
106
+ // // inactive tab) — nothing else can detect that, and the
107
+ // // loop would otherwise render a hidden pane forever.
108
+ // // Captures still work while parked (they re-allocate).
109
+ // // setActive(true) restores it. Safe after dispose().
110
+ // runtime.animation?.play("open"); // part-declared animation playback: null when the
111
+ // // part declares no animations, else
112
+ // // { play(name?), pause(), seek(t), stop(), state() }.
113
+ // // play() with an unknown name warns and does nothing;
114
+ // // any user/host param edit pauses playback. A part's
115
+ // // `autoplay: true` animation self-starts on first show
116
+ // // and each view switch until the user touches the
117
+ // // transport — no runtime call needed for that part.
118
+ // const off = runtime.onContextLost(() => …); // WebGL context loss, i.e. the GPU or the
119
+ // // OS gave up — surface it rather than showing a dead
120
+ // // canvas. Returns an unsubscribe.
89
121
  // runtime.dispose(); // full teardown
90
122
  // onBuild fires per completed build, so it does NOT fire for a pose-only edit —
91
123
  // those are repaired in the viewer and produce no build at all.
@@ -119,7 +151,6 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
119
151
  threeMf: elements.exports?.threeMf ?? byId("download-3mf"),
120
152
  },
121
153
  chrome: {
122
- pause: elements.chrome?.pause ?? byId("pause"),
123
154
  reframe: elements.chrome?.reframe ?? byId("reframe"),
124
155
  theme: elements.chrome?.theme ?? byId("theme"),
125
156
  cutaway: elements.chrome?.cutaway ?? byId("cutaway"),
@@ -183,7 +214,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
183
214
  // View tabs (generated from part.views) + live params. A tab switch shows the
184
215
  // cached assembly instantly if it's current, else auto-builds what's missing.
185
216
  const tabsCtl = createViewTabs(els.tabs, part, {
186
- onChange: () => { pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); },
217
+ onChange: () => { pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); animCtl?.autoplayKick(); },
187
218
  });
188
219
  cleanup.defer(() => tabsCtl.detach());
189
220
  const view = () => tabsCtl.current();
@@ -220,10 +251,17 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
220
251
  cleanup.defer(() => pickToggle.detach());
221
252
  } else if (qs.has("pickserver")) {
222
253
  // Agent-driven mode: arm the picker only when the local pick-server asks for a
223
- // click. `?pickserver` or `?pickserver=http://host:port`.
224
- const serverUrl = typeof qs.get("pickserver") === "string" && qs.get("pickserver")
225
- ? qs.get("pickserver") : "http://127.0.0.1:4518";
226
- pickClient = createPickRequestClient({ serverUrl, viewer, part, getContext });
254
+ // click. `?pickserver&picktoken=<token>` or `?pickserver=http://host:port&picktoken=…`.
255
+ // The URL is attacker-suppliable (anyone can hand the user a link), so a
256
+ // non-loopback target is refused rather than honoured — otherwise every click,
257
+ // with its live parameter values, would stream to a remote host.
258
+ const serverUrl = resolvePickServerUrl(qs.get("pickserver"), {
259
+ onReject: (raw) => console.warn(
260
+ `partforge: ignoring non-loopback ?pickserver=${raw} — using ${PICK_SERVER_DEFAULT_URL}`,
261
+ ),
262
+ });
263
+ const token = qs.get("picktoken") || "";
264
+ pickClient = createPickRequestClient({ serverUrl, token, viewer, part, getContext });
227
265
  cleanup.defer(() => pickClient.detach());
228
266
  }
229
267
 
@@ -262,9 +300,18 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
262
300
  params, getView: view, getParamsVersion: () => loop.version(),
263
301
  });
264
302
 
303
+ // paramsVersion of the most recent animation-frame apply. It is what lets
304
+ // the meshes handler tell "stale because playback moved on" (show it — that
305
+ // IS best-effort playback) from "stale because the user edited" (discard).
306
+ let lastAnimApplyVersion = -1;
307
+
265
308
  // First-build readiness: resolves on the first accepted meshes result, rejects on
266
309
  // a first-build error. Guarded against unhandled rejection when never awaited.
267
310
  let readySettled = false;
311
+ // First-show autoplay latch: separate from `readySettled`, which the error
312
+ // branch also settles — a part whose first build fails but whose retry
313
+ // succeeds still deserves its autoplay.
314
+ let autoplayKicked = false;
268
315
  let resolveReady, rejectReady;
269
316
  const ready = new Promise((res, rej) => { resolveReady = res; rejectReady = rej; });
270
317
  ready.catch(() => {});
@@ -326,7 +373,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
326
373
  ui.setStatus(`${data.phase}…`);
327
374
  break;
328
375
  case "meshes": {
329
- if (loop.buildDone()) { // stale results (params changed mid-build) are discarded
376
+ const fresh = loop.buildDone();
377
+ if (fresh) { // stale results (params changed mid-build) are discarded
330
378
  for (const m of data.meshes) {
331
379
  viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
332
380
  cache.record(m.name);
@@ -343,6 +391,26 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
343
391
  dbg?.update({ ms: data.ms, hits: data.cache?.hits ?? 0, misses: data.cache?.misses ?? 0, skipped: lastGen.skipped, rebuilt: lastGen.rebuilt, posed: lastGen.posed });
344
392
  onBuild?.({ status: "success", ms: data.ms });
345
393
  if (!readySettled) { readySettled = true; resolveReady(); }
394
+ // First-show autoplay: latched separately from `ready`, which the
395
+ // error branch also settles — a part whose first build fails but
396
+ // whose retry succeeds still deserves its autoplay.
397
+ if (!autoplayKicked) { autoplayKicked = true; animCtl?.autoplayKick(); }
398
+ } else if (lastAnimApplyVersion === loop.version()) {
399
+ // Stale ONLY because animation frames kept bumping the version:
400
+ // show the delivered meshes anyway — that IS best-effort playback —
401
+ // but record NOTHING. Cache and fast-path stamps must describe
402
+ // geometry built at the live params, and this delivery wasn't; the
403
+ // fast-path stamp is dropped too, so a later pose-only repair can
404
+ // never re-pose this newer geometry off an older delivery's stamp.
405
+ // A user edit mid-play pauses playback and bumps the version WITHOUT
406
+ // touching lastAnimApplyVersion, so a genuinely user-stale result
407
+ // fails this test and is discarded exactly as before.
408
+ for (const m of data.meshes) {
409
+ viewer.setSubGeometry(m.name, m);
410
+ fastPath.forget(m.name);
411
+ }
412
+ ui.hideBusy();
413
+ refreshView();
346
414
  }
347
415
  loop.kick(); // stale → rebuild; fresh → the view may still need parts (tab switched mid-build)
348
416
  break;
@@ -385,7 +453,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
385
453
  });
386
454
  cleanup.defer(() => exportCtl.dispose("viewer disposed"));
387
455
 
388
- const panel = buildControls(els.controls, part.parameters, params, onParamChange);
456
+ let animCtl = null; // assigned below; panel edits must pause active playback
457
+ const panel = buildControls(els.controls, part.parameters, params, () => {
458
+ animCtl?.notifyUserEdit();
459
+ onParamChange();
460
+ });
389
461
  cleanup.defer(() => panel.dispose());
390
462
  const updateRelevance = () => panel.applyRelevance(relevantParamKeys(part, view(), params));
391
463
  updateRelevance(); // initial view
@@ -394,8 +466,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
394
466
  // forceRegen path: forceRegen() forgets cache stamps WITHOUT bumping the params
395
467
  // version, so a repair there would re-stamp everything current off the memoized
396
468
  // probe and the forced rebuild would silently no-op.
397
- function onParamChange() {
398
- loop.markDirty(); // bump the version first: refreshView below must see the parts as stale
469
+ function onParamChange({ debounce = true } = {}) {
470
+ loop.markDirty({ debounce }); // bump the version first: refreshView below must see the parts as stale
399
471
  // Pose-only edits: re-posed + re-stamped current, no job. Skipped entirely
400
472
  // when caching is off — ?debug&nocache is there to measure true uncached
401
473
  // rebuilds, which the fast path would otherwise hide.
@@ -412,11 +484,33 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
412
484
  // path as a slider edit: pose-only changes repair synchronously (no worker
413
485
  // job, no debounce); geometry changes fall through to the regen loop.
414
486
  function setParams(partial) {
487
+ animCtl?.notifyUserEdit();
415
488
  Object.assign(params, partial);
416
489
  panel.syncValues(Object.keys(partial));
417
490
  onParamChange();
418
491
  }
419
492
 
493
+ // Animation-frame param entry point: same change path as setParams, minus
494
+ // the regen debounce. The explicit kick after repair is what makes playback
495
+ // best-effort — a pose-only frame finds nothing missing (repair re-stamped
496
+ // it) and sends no job; a geometry frame dispatches immediately when the
497
+ // worker is idle and is otherwise absorbed until buildDone re-kicks.
498
+ function applyAnimationValues(values) {
499
+ Object.assign(params, values);
500
+ panel.syncValues(Object.keys(values));
501
+ onParamChange({ debounce: false });
502
+ lastAnimApplyVersion = loop.version(); // this version came from playback, not a user edit
503
+ loop.kick();
504
+ }
505
+
506
+ // Animation transport + driver (no-op null when the part declares none).
507
+ animCtl = attachAnimationControls(viewer, part, {
508
+ container: els.viewer,
509
+ applyValues: applyAnimationValues,
510
+ getParamValues: (keys) => Object.fromEntries(keys.map((k) => [k, params[k]])),
511
+ });
512
+ if (animCtl) cleanup.defer(() => animCtl.detach());
513
+
420
514
  // Re-run the active view under the current caching setting, so toggling the
421
515
  // ?debug switch updates the readout for the same design without a param change.
422
516
  function forceRegen() {
@@ -437,7 +531,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
437
531
 
438
532
  const onStepClick = () => {
439
533
  ui.showBusy("exporting STEP");
440
- service.send({ type: "export-step", view: view(), params }, "occt"); // STEP is always OCCT
534
+ service.send({ type: "export-step", view: view(), params }, backendForFormat("step", backendFor));
441
535
  };
442
536
  if (els.exports.step) {
443
537
  els.exports.step.addEventListener("click", onStepClick);
@@ -453,7 +547,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
453
547
  cleanup.defer(() => els.exports.threeMf.removeEventListener("click", on3mfClick));
454
548
  }
455
549
 
456
- // Optional host-page viewer chrome (pause / reframe / theme) + camera persistence.
550
+ // Optional host-page viewer chrome (reframe / theme) + camera persistence.
457
551
  const chrome = attachViewerControls(viewer, els.chrome, { tooltip });
458
552
  cleanup.defer(() => chrome.detach());
459
553
 
@@ -470,6 +564,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
470
564
  listExportableParts: () =>
471
565
  exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
472
566
  exportParts: (opts) => exportCtl.exportParts(opts),
567
+ animation: animCtl?.runtime ?? null,
473
568
  });
474
569
  } catch (error) {
475
570
  try {
@@ -1,4 +1,4 @@
1
- import { viewSubParts, resolveParams, buildPosed } from "../framework/jobs.js";
1
+ import { viewSubParts, resolveParams, buildPosed } from "../part-model.js";
2
2
 
3
3
  // Build every sub-part of a view in its display (assembly) pose with the given
4
4
  // Manifold kernel, returning live solids + copied-out meshes. Mirrors the