partforge 0.41.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 (70) hide show
  1. package/README.md +31 -10
  2. package/bin/cli.js +100 -27
  3. package/docs/AUTHORING-PARTS.md +126 -14
  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 +9 -40
  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 +93 -18
  31. package/src/{testing → framework/oracle}/build.js +1 -1
  32. package/src/{testing → framework/oracle}/bvh.js +1 -1
  33. package/src/{testing → framework/oracle}/measure.js +1 -1
  34. package/src/{testing → framework/oracle}/min-wall.js +1 -1
  35. package/src/{testing → framework/oracle}/verify.js +3 -3
  36. package/src/framework/param-deps.js +1 -1
  37. package/src/framework/part-model.js +48 -0
  38. package/src/framework/pick-request/client.js +11 -3
  39. package/src/framework/pick-request/endpoint.js +60 -0
  40. package/src/framework/pick-request/index.js +6 -0
  41. package/src/framework/pick-request/server.js +222 -34
  42. package/src/framework/pick-request/token-store.js +31 -0
  43. package/src/framework/pose-fast-path.js +12 -1
  44. package/src/framework/pose-probe-core.js +129 -0
  45. package/src/framework/pose-probe.js +7 -123
  46. package/src/framework/regen-loop.js +10 -3
  47. package/src/framework/safe-name.js +26 -0
  48. package/src/framework/verify-metrics.js +4 -4
  49. package/src/framework/view-state.js +25 -21
  50. package/src/framework/view-tabs.js +22 -7
  51. package/src/framework/viewer-controls.js +5 -26
  52. package/src/framework/viewer.js +58 -17
  53. package/src/hinged-box-worker.js +3 -0
  54. package/src/index.js +1 -1
  55. package/src/parts/hinged-box.js +94 -0
  56. package/src/testing/render.js +19 -8
  57. package/src/testing.js +15 -8
  58. package/types/derive.d.ts +14 -0
  59. package/types/geometry.d.ts +117 -0
  60. package/types/index.d.ts +240 -0
  61. package/types/kernel.d.ts +409 -0
  62. package/types/lint.d.ts +85 -0
  63. package/types/part.d.ts +381 -0
  64. package/types/testing.d.ts +362 -0
  65. package/types/worker.d.ts +21 -0
  66. /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
  67. /package/src/{testing → framework/oracle}/cases.js +0 -0
  68. /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
  69. /package/src/{testing → framework/oracle}/gaps.js +0 -0
  70. /package/src/{testing → framework/oracle}/mesh.js +0 -0
@@ -1,116 +1,9 @@
1
- // Geometry-free pose probe: run a subpart's build()+place() against a stub kernel
2
- // whose token solids carry (a) a content-hash chain built with the shared h() and
3
- // (b) pending rigid pose steps, mirroring the backends' pose-lazy bookkeeping.
4
- // The fast path compares probe results ACROSS PARAM CHANGES ONLY — probe hashes
5
- // are never compared to backend hashes, so they only need to be stable and to
6
- // fold every geometry-affecting argument.
7
- //
8
- // Trust model: any query op (boundingBox/volume/…) during a build marks that
9
- // subpart untrusted — a query result could feed geometry OR pose, and the probe
10
- // returns dummies, so neither hash stability nor pose values can be believed.
11
- // A FUNCTION passed as (or nested inside) an op argument is untrusted for the
12
- // same reason the OCCT backend refuses to hash function selectors (see `selKey`
13
- // in occt-backend.js): a closure like `(e) => e.inDirection([0,0,p.z])` has the
14
- // same source text at every value of `p.z`, so hashing it would hold baseHash
15
- // stable while the real geometry changed — precisely the false-positive the fast
16
- // path must never make. Untrusted subparts simply take the normal regen path.
17
- import { h } from "./geometry/solid-hash.js";
18
- import { addSugar } from "./geometry/solid-sugar.js";
19
- import { SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS, OCCT_ONLY_OPS } from "./geometry/kernel.js";
20
- import { MAX_PROBE_OPS, ProbeRunawayError } from "./geometry/probe.js";
21
- import { viewSubParts, resolveParams } from "./jobs.js";
22
-
23
- const NAN3 = () => [NaN, NaN, NaN];
24
-
25
- function makeProbeSession() {
26
- const state = { count: 0, queried: false, unhashable: false };
27
- const tick = () => { if (++state.count > MAX_PROBE_OPS) throw new ProbeRunawayError(`pose probe exceeded ${MAX_PROBE_OPS} ops`); };
28
-
29
- // Queries return dummies AND poison trust (see module comment).
30
- const QUERY_DUMMIES = {
31
- boundingBox: () => ({ min: NAN3(), max: NAN3() }), // addSugar derives center/size
32
- volume: () => NaN,
33
- genus: () => NaN,
34
- isEmpty: () => false,
35
- area: () => NaN,
36
- toRegions: () => [],
37
- simple: () => ({ outer: [[NaN, NaN]], holes: [] }),
38
- toMesh: () => ({ positions: new Float32Array(9), normals: new Float32Array(0), triangles: 1, edges: new Float32Array(0) }),
39
- toSTL: () => new ArrayBuffer(0),
40
- toIndexedMesh: () => ({ positions: new Float32Array(9), indices: new Uint32Array(3) }),
41
- };
42
-
43
- // Operand tokens fold into a hash key by their own (pose-folded) hash; plain
44
- // data canonicalizes via h(). Functions can't be hashed at all (see the module
45
- // comment), so they poison trust AND get a per-call unique key — belt and
46
- // braces, so the hash can't collide even before the trust check is consulted.
47
- //
48
- // The walk mirrors h()'s `canon` exactly (array → elements, other object → own
49
- // enumerable values), because a function nested inside an options object —
50
- // `fillet({ r, edges: (e) => … })`, the normal calling convention — is reached
51
- // by canon, not by the top-level argument check.
52
- let unhashable = 0;
53
- const argKey = (a) => {
54
- if (a && a.__poseToken) return a.__folded();
55
- if (typeof a === "function") { state.unhashable = true; return `fn#${unhashable++}`; }
56
- if (Array.isArray(a)) return a.map(argKey);
57
- if (a && typeof a === "object") return Object.fromEntries(Object.keys(a).map((k) => [k, argKey(a[k])]));
58
- return a;
59
- };
60
-
61
- function token(hash, pose) {
62
- const folded = () => (pose.length ? h("posed", hash, pose) : hash);
63
- const foldOp = (op) => (...args) => { tick(); return token(h(op, folded(), ...args.map(argKey)), []); };
64
- const t = {
65
- __poseToken: true,
66
- __folded: folded,
67
- _hash: hash,
68
- _pose: pose,
69
- // The rigid vocabulary stays out of the hash: recorded as pending steps,
70
- // exactly like the OCCT backend's pose-lazy wrap. All transform sugar
71
- // (rotateAbout/along/at/rotateX…) composes onto these via addSugar.
72
- translate: (v) => { tick(); return token(hash, [...pose, { t: "translate", v }]); },
73
- rotate: (deg, center, axis) => { tick(); return token(hash, [...pose, { t: "rotate", deg, center, axis }]); },
74
- clone: () => t, // tokens are immutable — sharing is safe
75
- // regions() is a data-returning query in disguise: on a real backend the
76
- // scission ARRAY LENGTH is param-dependent data, so a build branching on
77
- // `regions().length` could hold baseHash stable while geometry changed.
78
- // It therefore poisons trust like any other query; the single token is
79
- // still returned so op chains on regions()[0] don't crash mid-probe.
80
- regions: () => { tick(); state.queried = true; return [token(h("regions", folded()), [])]; },
81
- };
82
- for (const [op, dummy] of Object.entries(QUERY_DUMMIES))
83
- t[op] = (...a) => { tick(); state.queried = true; return dummy(...a); };
84
- // Every other contract op folds pose + args into a fresh hash. Generated from
85
- // the kernel-contract lists so new ops can never silently drift out of the probe.
86
- for (const op of [...SOLID_OPS, ...SOLID_OPTIONAL_OPS, ...SHAPE2D_OPS, ...OCCT_ONLY_OPS])
87
- t[op] ??= foldOp(op);
88
- return addSugar(t);
89
- }
90
-
91
- // Kernel: catch-all factory — any op makes a fresh token hashed from its args.
92
- const kernelQueries = {
93
- toSTEP: () => Promise.resolve(new ArrayBuffer(0)),
94
- cleanup: () => {}, beginSubPart: () => {}, endSubPart: () => {},
95
- cacheStats: () => ({ hits: 0, misses: 0 }), resetCacheStats: () => {},
96
- };
97
- const ignore = (key) => typeof key !== "string" || key === "then" || key === "toJSON" || key[0] === "_";
98
- const kernel = new Proxy({}, {
99
- get(_t, key) {
100
- if (ignore(key)) return undefined;
101
- if (key in kernelQueries) return kernelQueries[key];
102
- return (...args) => { tick(); return token(h(key, ...args.map(argKey)), []); };
103
- },
104
- });
105
-
106
- return { kernel, state };
107
- }
108
-
109
- const finiteVec = (v) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite);
110
- const stepsFinite = (steps) => steps.every((st) =>
111
- st.t === "translate"
112
- ? finiteVec(st.v)
113
- : Number.isFinite(st.deg) && finiteVec(st.center) && finiteVec(st.axis));
1
+ // Geometry-free pose probe over a view see pose-probe-core.js for the probe
2
+ // session itself and the trust model. This wrapper resolves params/derive and
3
+ // walks the view's sub-parts; it stays separate so lint can import the core
4
+ // without dragging in the part-model/jobs layer (purity).
5
+ import { probeSubPartPose } from "./pose-probe-core.js";
6
+ import { viewSubParts, resolveParams } from "./part-model.js";
114
7
 
115
8
  // Probe every subpart the view shows. Never throws; a failing/queried/weird
116
9
  // subpart yields { trusted: false } and the others still probe.
@@ -124,16 +17,7 @@ export function probePoses(part, view, params) {
124
17
  }
125
18
  const { p, d } = resolved;
126
19
  for (const name of viewSubParts(part, view, params)) {
127
- try {
128
- const { kernel, state } = makeProbeSession(); // fresh op budget + trust per subpart
129
- const sp = part.parts[name];
130
- let s = sp.build(kernel, p, d);
131
- if (sp.place) s = sp.place(s, { view, purpose: "display", p, d });
132
- const ok = s && s.__poseToken && !state.queried && !state.unhashable && stepsFinite(s._pose);
133
- out.set(name, ok ? { baseHash: s._hash, pose: s._pose, trusted: true } : { trusted: false });
134
- } catch {
135
- out.set(name, { trusted: false });
136
- }
20
+ out.set(name, probeSubPartPose(part.parts[name], { view, purpose: "display", p, d }));
137
21
  }
138
22
  return out;
139
23
  }
@@ -9,7 +9,9 @@
9
9
  // - markDirty() bumps the params version and debounces a kick, so dragging a
10
10
  // slider queues one build per pause, not one per pixel;
11
11
  // - a build that a mid-flight edit outdated is reported stale by buildDone()
12
- // (return false → the caller discards the meshes and kicks a rebuild).
12
+ // (return false → the caller discards the meshes and kicks a rebuild);
13
+ // - markDirty({debounce:false}) bumps without arming the timer (the animation
14
+ // fast-apply path kicks explicitly).
13
15
  export function createRegenLoop({ missingParts, send, debounceMs = 180 }) {
14
16
  let kernelReady = false;
15
17
  let generating = false;
@@ -30,10 +32,15 @@ export function createRegenLoop({ missingParts, send, debounceMs = 180 }) {
30
32
  return {
31
33
  kick,
32
34
  ready() { if (disposed) return; kernelReady = true; kick(); },
33
- markDirty() {
35
+ // `debounce: false` is the animation driver's mode: the version still bumps
36
+ // (stale in-flight builds are still detected), but no timer is armed — the
37
+ // driver kicks explicitly after the pose fast path has repaired, so a
38
+ // pose-only frame sends no job and a geometry frame dispatches immediately
39
+ // whenever the worker is idle (best-effort at worker cadence, clock-free).
40
+ markDirty({ debounce = true } = {}) {
34
41
  paramsVersion++;
35
42
  clearTimeout(timer);
36
- timer = setTimeout(kick, debounceMs);
43
+ if (debounce) timer = setTimeout(kick, debounceMs);
37
44
  },
38
45
  // The build finished (meshes / needs-occt / error). Returns whether its result
39
46
  // is still current; the caller applies the meshes only on true, then kicks.
@@ -0,0 +1,26 @@
1
+ // One hardened slug for every part-derived string that ends up as a filename, a
2
+ // path segment, or a zip entry name.
3
+ //
4
+ // A PartDefinition is DATA, not trusted developer source: downstream hosts
5
+ // (partforge-cloud) run LLM-generated and user-supplied parts, so `meta.title`,
6
+ // `views[k].label` and `parts[x].export.name` are untrusted input. A title of
7
+ // "../../.ssh/authorized" must not steer a render write out of its output
8
+ // directory, and a zip entry must not carry a separator that a naive extractor
9
+ // would honour (zip-slip).
10
+ //
11
+ // Deliberately DOM-free and free of `node:` builtins: the geometry worker, the
12
+ // browser shell and the headless testing tree all import this.
13
+
14
+ // Lowercase, reduce to [a-z0-9._-], collapse runs, and refuse to start with a
15
+ // dot or dash — that is what kills ".." and dotfiles at the same time. Anything
16
+ // that sanitizes away to nothing (a title that is entirely CJK or emoji, or the
17
+ // empty string) yields `fallback`, so callers never build a name-less path.
18
+ export function safeName(s, fallback = "part") {
19
+ const out = String(s ?? "")
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9._-]+/g, "-")
22
+ .replace(/-{2,}/g, "-")
23
+ .replace(/^[.-]+/, "")
24
+ .replace(/[.-]+$/, "");
25
+ return out || fallback;
26
+ }
@@ -6,11 +6,11 @@
6
6
  // attached whatever the status — a passing-but-sampled reading is exactly the
7
7
  // case a reader needs told about). `manifoldOnly` facts are null on OCCT parts.
8
8
  //
9
- // This lives in framework/ rather than testing/ deliberately: the set of legal
9
+ // This lives one level above framework/oracle/ deliberately: the set of legal
10
10
  // `verify.expect` metrics is part of the PartDefinition CONTRACT, which both the
11
- // verify runner (testing) and the linter (partforge/lint) must agree on. Keeping
12
- // it here lets the linter import the vocabulary without reaching measure.js or
13
- // jobs.js, which pull in the geometry kernels. This module must stay import-free.
11
+ // verify runner (oracle/verify.js) and the linter (partforge/lint) must agree on.
12
+ // Keeping it here lets the linter import the vocabulary without reaching
13
+ // measure.js, which pulls in the geometry kernels. Must stay import-free.
14
14
  export const SUBPART_METRICS = {
15
15
  holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
16
16
  hint: "genus is wrong — an unintended tunnel exists or an intended bore is blocked; make cut tools pierce fully (overcut past the faces)" },
@@ -1,36 +1,35 @@
1
1
  // Persist a little viewer UI state across browser reloads (notably Vite dev
2
- // auto-refresh) in localStorage. All keys are global. Reads/writes are guarded:
3
- // if localStorage is unavailable (private mode, disabled) or a value is corrupt,
4
- // reads return the documented default and writes are no-ops persistence never
5
- // throws.
2
+ // auto-refresh). `camera` and `theme` live in localStorage under global
3
+ // keys they're viewer preferences, not part state. The active view is different:
4
+ // it's scoped to one part and stored in sessionStorage, so a hot reload keeps your
5
+ // tab but the name can't bleed into another part that happens to share a view name,
6
+ // and a fresh session opens on the part's own default (see default-view.js).
7
+ // Reads/writes are guarded: if storage is unavailable (private mode, disabled) or a
8
+ // value is corrupt, reads return the documented default and writes are no-ops —
9
+ // persistence never throws.
6
10
 
7
11
  const KEY = {
8
- rotating: "partforge:rotating",
9
12
  camera: "partforge:camera",
10
- view: "partforge:view",
11
13
  theme: "partforge:theme",
12
14
  };
13
15
 
16
+ const viewKey = (partKey) => `partforge:view:${partKey}`;
17
+
14
18
  function read(key) {
15
19
  try { return localStorage.getItem(key); } catch { return null; }
16
20
  }
17
21
  function write(key, value) {
18
22
  try { localStorage.setItem(key, value); } catch { /* storage unavailable — no-op */ }
19
23
  }
20
-
21
- const isVec3 = (v) => Array.isArray(v) && v.length === 3 && v.every((n) => Number.isFinite(n));
22
-
23
- export function loadRotating() {
24
- const raw = read(KEY.rotating);
25
- if (raw === "false") return false;
26
- if (raw === "true") return true;
27
- return true; // default: auto-rotate on (matches the viewer's default)
24
+ function readSession(key) {
25
+ try { return sessionStorage.getItem(key); } catch { return null; }
28
26
  }
29
-
30
- export function saveRotating(on) {
31
- write(KEY.rotating, on ? "true" : "false");
27
+ function writeSession(key, value) {
28
+ try { sessionStorage.setItem(key, value); } catch { /* storage unavailable — no-op */ }
32
29
  }
33
30
 
31
+ const isVec3 = (v) => Array.isArray(v) && v.length === 3 && v.every((n) => Number.isFinite(n));
32
+
34
33
  export function loadCamera() {
35
34
  const raw = read(KEY.camera);
36
35
  if (!raw) return null;
@@ -56,10 +55,15 @@ export function saveTheme(mode) {
56
55
  if (mode === "light" || mode === "dark") write(KEY.theme, mode);
57
56
  }
58
57
 
59
- export function loadView() {
60
- return read(KEY.view); // raw string or null; caller validates against available tabs
58
+ // `partKey` identifies the part — createViewTabs passes `meta.title`. Without one
59
+ // there is nothing safe to key on, so both calls no-op rather than falling back to a
60
+ // shared key (the cross-part bleed this scoping exists to remove).
61
+ export function loadView(partKey) {
62
+ if (typeof partKey !== "string" || !partKey) return null;
63
+ return readSession(viewKey(partKey)); // raw string or null; caller validates against available tabs
61
64
  }
62
65
 
63
- export function saveView(name) {
64
- if (typeof name === "string" && name) write(KEY.view, name);
66
+ export function saveView(partKey, name) {
67
+ if (typeof partKey !== "string" || !partKey) return;
68
+ if (typeof name === "string" && name) writeSession(viewKey(partKey), name);
65
69
  }
@@ -1,22 +1,37 @@
1
+ import { resolveDefaultView } from "./default-view.js";
1
2
  import { loadView, saveView } from "./view-state.js";
2
3
 
3
4
  // The view-tab segmented control. When the part declares `views`, the buttons are
4
5
  // generated from it (part.views is the single source of truth — host pages leave
5
6
  // the #part div empty); a part without `views` keeps whatever buttons the page
6
- // hand-wrote. The active view persists across reloads via view-state.
7
+ // hand-wrote. Which tab opens is resolveDefaultView's call, not key order. The
8
+ // active tab then persists per part for the rest of the browser session, so a
9
+ // Vite dev reload doesn't throw you back mid-edit.
7
10
  export function createViewTabs(el, part, { onChange }) {
8
11
  const generated = !!(el && part.views);
12
+ const partKey = part?.meta?.title ?? "";
13
+ const resolved = resolveDefaultView(part);
9
14
  if (generated) {
10
- el.innerHTML = Object.entries(part.views)
11
- .map(([key, v], i) => `<button data-part="${key}"${i === 0 ? ' class="on"' : ""}>${v?.label ?? key}</button>`)
12
- .join("");
15
+ // Built node-by-node with textContent/dataset rather than an innerHTML
16
+ // template view keys and labels come from the part, which is untrusted
17
+ // data (hosts run LLM-generated and user-supplied parts), so a label of
18
+ // `<img src=x onerror=...>` must land as text, not as markup.
19
+ el.replaceChildren(...Object.entries(part.views).map(([key, v]) => {
20
+ const btn = document.createElement("button");
21
+ btn.dataset.part = key;
22
+ btn.textContent = v?.label ?? key;
23
+ if (key === resolved) btn.classList.add("on");
24
+ return btn;
25
+ }));
13
26
  }
14
27
 
15
28
  const setActive = (btn) => { for (const b of el.children) b.classList.toggle("on", b === btn); };
16
29
 
17
- // Initial view: the saved one if it still matches a tab, else the active (first) tab.
30
+ // Initial view: the session-saved one if it still matches a tab, else the active
31
+ // button — the resolved default for a generated bar, or whatever the page's own
32
+ // markup marked `on` for a hand-written one.
18
33
  const defaultView = el.querySelector("button.on")?.dataset.part ?? el.querySelector("button")?.dataset.part;
19
- const saved = loadView();
34
+ const saved = loadView(partKey);
20
35
  const savedBtn = saved ? [...el.querySelectorAll("button[data-part]")].find((b) => b.dataset.part === saved) : null;
21
36
  let view = savedBtn ? saved : defaultView;
22
37
  if (savedBtn) setActive(savedBtn);
@@ -25,7 +40,7 @@ export function createViewTabs(el, part, { onChange }) {
25
40
  const btn = e.target.closest("button[data-part]");
26
41
  if (!btn) return;
27
42
  view = btn.dataset.part;
28
- saveView(view);
43
+ saveView(partKey, view);
29
44
  setActive(btn);
30
45
  onChange(view);
31
46
  };
@@ -1,17 +1,17 @@
1
- import { loadRotating, saveRotating, saveCamera, loadTheme, saveTheme } from "./view-state.js";
1
+ import { saveCamera, loadTheme, saveTheme } from "./view-state.js";
2
2
  import { attachButtonTooltips } from "./tooltip.js";
3
3
 
4
- // Wire the optional viewer-chrome buttons (pause / reframe / theme) to the viewer,
4
+ // Wire the optional viewer-chrome buttons (reframe / theme) to the viewer,
5
5
  // plus persist the camera pose. Element refs in (mount resolves defaults); each
6
6
  // button is optional — pass nothing and its behavior is simply absent. Returns
7
7
  // { detach } removing every listener this attached.
8
8
  export function attachViewerControls(
9
9
  viewer,
10
- { pause: pauseBtn, reframe: reframeBtn, theme: themeBtn } = {},
10
+ { reframe: reframeBtn, theme: themeBtn } = {},
11
11
  { tooltip } = {},
12
12
  ) {
13
13
  const tooltipBinding = tooltip
14
- ? attachButtonTooltips(tooltip, [pauseBtn, reframeBtn, themeBtn].map((element) => ({ element })))
14
+ ? attachButtonTooltips(tooltip, [reframeBtn, themeBtn].map((element) => ({ element })))
15
15
  : null;
16
16
 
17
17
  // Theme: toggle the page chrome (CSS vars keyed off <html data-theme>) and the
@@ -34,26 +34,6 @@ export function attachViewerControls(
34
34
  const onThemeClick = () => applyTheme(theme === "light" ? "dark" : "light");
35
35
  themeBtn?.addEventListener("click", onThemeClick);
36
36
 
37
- // Pause/resume the idle auto-rotation.
38
- let rotating = loadRotating();
39
- viewer.setAutoRotate(rotating);
40
- const syncPause = () => {
41
- if (!pauseBtn) return;
42
- pauseBtn.textContent = rotating ? "⏸" : "▶";
43
- const label = rotating ? "Pause rotation" : "Resume rotation";
44
- pauseBtn.setAttribute("aria-label", label);
45
- if (!tooltip) pauseBtn.title = label;
46
- tooltipBinding?.sync();
47
- };
48
- syncPause();
49
- const onPauseClick = () => {
50
- rotating = !rotating;
51
- viewer.setAutoRotate(rotating);
52
- syncPause();
53
- saveRotating(rotating);
54
- };
55
- pauseBtn?.addEventListener("click", onPauseClick);
56
-
57
37
  // Re-fit the camera to the current view.
58
38
  if (reframeBtn) {
59
39
  reframeBtn.setAttribute("aria-label", "Re-frame model");
@@ -63,7 +43,7 @@ export function attachViewerControls(
63
43
  reframeBtn?.addEventListener("click", onReframeClick);
64
44
 
65
45
  // Persist the camera when the user finishes an orbit/zoom, and right before a
66
- // reload (captures the latest pose, including auto-rotation drift).
46
+ // reload (captures the latest pose).
67
47
  viewer.onCameraEnd(() => saveCamera(viewer.getCameraState()));
68
48
  const onPageHide = () => saveCamera(viewer.getCameraState());
69
49
  window.addEventListener("pagehide", onPageHide);
@@ -71,7 +51,6 @@ export function attachViewerControls(
71
51
  return {
72
52
  detach: () => {
73
53
  themeBtn?.removeEventListener("click", onThemeClick);
74
- pauseBtn?.removeEventListener("click", onPauseClick);
75
54
  reframeBtn?.removeEventListener("click", onReframeClick);
76
55
  window.removeEventListener("pagehide", onPageHide);
77
56
  tooltipBinding?.detach();
@@ -5,6 +5,7 @@ import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
5
5
  import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
6
6
  import { LineMaterial } from "three/addons/lines/LineMaterial.js";
7
7
  import { createCutaway } from "./cutaway.js";
8
+ import { createCameraTween } from "./camera-tween.js";
8
9
  import { addViewerLights, captureLightPoses, createCaptureLights } from "./viewer-lighting.js";
9
10
  import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
10
11
 
@@ -113,8 +114,6 @@ export function createViewer(container, part) {
113
114
 
114
115
  const controls = new OrbitControls(camera, renderer.domElement);
115
116
  controls.enableDamping = true;
116
- controls.autoRotate = true;
117
- controls.autoRotateSpeed = 1.6;
118
117
 
119
118
  // --- lights + grid --------------------------------------------------------
120
119
  const liveLights = addViewerLights(scene);
@@ -214,6 +213,39 @@ export function createViewer(container, part) {
214
213
  cutaway.setSubpart(name, subMesh[name], subLines[name]);
215
214
  }
216
215
 
216
+ // --- animation hooks --------------------------------------------------------
217
+ // Frame listeners get dt (seconds, clamped so a background-tab return doesn't
218
+ // fast-forward playback) inside the render loop — so a parked viewer
219
+ // (setActive(false)) automatically halts playback too: no loop, no ticks.
220
+ const frameListeners = new Set();
221
+ function onFrame(cb) { frameListeners.add(cb); return () => frameListeners.delete(cb); }
222
+
223
+ const camTween = createCameraTween();
224
+ // Tween the orbit camera to a canonical angle, framed on what's visible now.
225
+ // Presentational only; a caller passing duration 0 gets a jump cut.
226
+ function tweenCameraTo(viewName, { duration = 0.6, onComplete } = {}) {
227
+ const box = getVisibleWorldBounds();
228
+ if (!box || box.isEmpty()) { onComplete?.(); return; }
229
+ const center = box.getCenter(new THREE.Vector3()).toArray();
230
+ const size = box.getSize(new THREE.Vector3());
231
+ // radius = full max extent (not half), matching frameTo's framing distance so a
232
+ // live camera cue doesn't land twice as close as the reframe button and crop the part.
233
+ const pose = cameraPoseForView(viewName, { center, radius: Math.max(size.x, size.y, size.z) || 12 });
234
+ camTween.start(
235
+ { position: camera.position.toArray(), target: controls.target.toArray() },
236
+ { position: pose.position, target: pose.target },
237
+ { duration, onComplete },
238
+ );
239
+ }
240
+ const cancelCameraTween = () => camTween.cancel();
241
+
242
+ // User grabbing the orbit cancels any cue tween (the user owns the camera) and
243
+ // tells subscribers (the animation driver disarms remaining cues).
244
+ const cameraStartListeners = new Set();
245
+ const onControlsStart = () => { camTween.cancel(); for (const cb of [...cameraStartListeners]) cb(); };
246
+ controls.addEventListener("start", onControlsStart);
247
+ function onCameraStart(cb) { cameraStartListeners.add(cb); return () => cameraStartListeners.delete(cb); }
248
+
217
249
  // Smooth shading within CREASE_ANGLE of a shared edge, hard edge past it — so the
218
250
  // round body and helical groove read smooth while bore rims, drum faces, and
219
251
  // groove walls stay crisp. Lower = more hard edges; raise toward Math.PI/3 for
@@ -330,18 +362,8 @@ export function createViewer(container, part) {
330
362
  frameTo(names.filter((n) => subMesh[n].visible && subCache[n]));
331
363
  }
332
364
 
333
- let autoRotateRequested = true;
334
- function syncAutoRotate() {
335
- controls.autoRotate = autoRotateRequested && !cutaway.isEnabled;
336
- }
337
- function setAutoRotate(on) {
338
- autoRotateRequested = !!on;
339
- syncAutoRotate();
340
- }
341
365
  function setCutawayEnabled(on) {
342
- const changed = cutaway.setEnabled(on);
343
- syncAutoRotate();
344
- return changed;
366
+ return cutaway.setEnabled(on);
345
367
  }
346
368
 
347
369
  // Swap the scene background, grid, and edge-line colors for the given theme.
@@ -489,8 +511,19 @@ export function createViewer(container, part) {
489
511
  }
490
512
 
491
513
  // --- render loop ----------------------------------------------------------
492
- function renderFrame() {
514
+ // The tween is applied after controls.update() so the cue wins the frame, and
515
+ // the frame listeners run before render so a playback frame draws its own pose.
516
+ let lastFrameTime = null;
517
+ function renderFrame(time) {
518
+ const dt = lastFrameTime == null ? 0 : Math.min(0.1, (time - lastFrameTime) / 1000);
519
+ lastFrameTime = time;
493
520
  controls.update();
521
+ const tw = camTween.update(dt);
522
+ if (tw) {
523
+ camera.position.fromArray(tw.position);
524
+ controls.target.fromArray(tw.target);
525
+ }
526
+ for (const cb of [...frameListeners]) cb(dt);
494
527
  if (cutaway.isEnabled) cutaway.updateForCamera();
495
528
  renderer.render(scene, camera);
496
529
  cutaway.renderOverlay(renderer, camera);
@@ -504,8 +537,8 @@ export function createViewer(container, part) {
504
537
  // that cannot do that — partforge-cloud's phone tab bar uses
505
538
  // `visibility: hidden`, because the canvas has to keep its size for build
506
539
  // screenshots — gets no such signal: the full-resolution MSAA drawing buffer
507
- // stays resident and this loop keeps rendering an auto-rotating scene at
508
- // 60fps behind an invisible pane. On an iPhone that is tens of megabytes and
540
+ // stays resident and this loop keeps rendering the scene at 60fps behind an
541
+ // invisible pane. On an iPhone that is tens of megabytes and
509
542
  // continuous GPU work nobody can see, so the host has to say so explicitly.
510
543
  //
511
544
  // Parking stops the loop and releases the drawing buffer. `setSize(1, 1,
@@ -530,6 +563,7 @@ export function createViewer(container, part) {
530
563
  return;
531
564
  }
532
565
  resize(); // rebuild the buffer at whatever size the container is now
566
+ lastFrameTime = null; // parked time is not elapsed time — no dt jump on unpark
533
567
  renderer.setAnimationLoop(renderFrame);
534
568
  }
535
569
 
@@ -595,6 +629,10 @@ export function createViewer(container, part) {
595
629
  // closure (and whatever it captured) alive.
596
630
  renderer.domElement.removeEventListener("webglcontextlost", onContextLostEvent);
597
631
  contextLostListeners.clear();
632
+ controls.removeEventListener("start", onControlsStart);
633
+ cameraStartListeners.clear();
634
+ frameListeners.clear();
635
+ camTween.cancel();
598
636
  controls.dispose();
599
637
  for (const t of flashTimers) clearTimeout(t);
600
638
  flashTimers.clear();
@@ -624,7 +662,10 @@ export function createViewer(container, part) {
624
662
  frame,
625
663
  captureCanonicalViews,
626
664
  captureCurrent,
627
- setAutoRotate,
665
+ onFrame,
666
+ tweenCameraTo,
667
+ cancelCameraTween,
668
+ onCameraStart,
628
669
  setActive,
629
670
  onContextLost,
630
671
  setTheme,
@@ -0,0 +1,3 @@
1
+ import part from "./parts/hinged-box.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
package/src/index.js CHANGED
@@ -3,4 +3,4 @@
3
3
  // must NOT be imported from a part's build functions — those run in a Web Worker.
4
4
  // Part build functions import geometry helpers from "partforge/geometry" instead.
5
5
  export { mount } from "./framework/index.js";
6
- export { viewSubParts } from "./framework/jobs.js";
6
+ export { viewSubParts } from "./framework/part-model.js";
@@ -0,0 +1,94 @@
1
+ // Animation reference part — a box with a hinged lid. Worked example for
2
+ // docs/AUTHORING-PARTS.md "Animations": pose-only animated params (lidAngle,
3
+ // lidLift) driven through place(), an intro camera + markdown description on
4
+ // `open`, a looping `cycle`, and a stepped `assemble` with per-step cameras.
5
+ export default {
6
+ meta: { title: "Hinged Box", units: "mm" },
7
+ parameters: [
8
+ {
9
+ id: "box",
10
+ title: "Box",
11
+ description: "Outer dimensions of the base. The lid is a flat plate of the same wall thickness.",
12
+ advanced: [
13
+ { key: "width", label: "Width", unit: "mm", min: 20, max: 120, step: 1,
14
+ description: "Outer width (X)." },
15
+ { key: "depth", label: "Depth", unit: "mm", min: 20, max: 120, step: 1,
16
+ description: "Outer depth (Y). The hinge runs along the rear edge." },
17
+ { key: "height", label: "Height", unit: "mm", min: 10, max: 80, step: 1,
18
+ description: "Outer height of the base (Z)." },
19
+ { key: "wall", label: "Wall", unit: "mm", min: 1.2, max: 5, step: 0.2,
20
+ description: "Wall and lid thickness." },
21
+ ],
22
+ },
23
+ {
24
+ id: "pose",
25
+ title: "Pose",
26
+ description: "Presentation pose. The **Open lid** and **Assemble** animations drive these — both are pose-only, so animating them never rebuilds geometry.",
27
+ advanced: [
28
+ { key: "lidAngle", label: "Lid angle", unit: "°", min: 0, max: 110, step: 1,
29
+ description: "Hinge opening angle about the rear top edge." },
30
+ { key: "lidLift", label: "Lid lift", unit: "mm", min: 0, max: 60, step: 1,
31
+ description: "Assembly explode offset: raises the lid straight up off the hinge." },
32
+ ],
33
+ },
34
+ ],
35
+ defaults: { width: 60, depth: 40, height: 24, wall: 2, lidAngle: 0, lidLift: 0 },
36
+ parts: {
37
+ base: {
38
+ label: "Base",
39
+ views: ["box"],
40
+ export: { name: "base" },
41
+ build: (k, p) =>
42
+ k.box({ min: [0, 0, 0], max: [p.width, p.depth, p.height] })
43
+ .cut(k.box({ min: [p.wall, p.wall, p.wall], max: [p.width - p.wall, p.depth - p.wall, p.height + 1] })),
44
+ },
45
+ lid: {
46
+ label: "Lid",
47
+ views: ["box"],
48
+ export: { name: "lid" },
49
+ build: (k, p) => k.box({ min: [0, 0, p.height], max: [p.width, p.depth, p.height + p.wall] }),
50
+ // Display: swing about the hinge line (rear top edge, axis +X through
51
+ // [0, depth, height]; negative angle opens upward), then the assembly
52
+ // lift. Export: the lid prints flat beside the base. Both poses are
53
+ // rigid motions of the same solid, and neither reads `view` — the two
54
+ // invariants lint's place rules hold every part to.
55
+ place: (s, { purpose, p }) =>
56
+ purpose === "export"
57
+ ? s.translate([p.width + 10, 0, -p.height])
58
+ : s.rotate(-p.lidAngle, [0, p.depth, p.height], [1, 0, 0]).translate([0, 0, p.lidLift]),
59
+ },
60
+ },
61
+ views: { box: { label: "Box" } },
62
+ animations: {
63
+ open: {
64
+ label: "Open lid",
65
+ description: "Swings the lid to **110°** about the rear hinge line.\n\nPose-only: playback runs at frame rate with no geometry rebuild.",
66
+ camera: "front",
67
+ duration: 1.2,
68
+ tracks: { lidAngle: [[0, 0], [1, 110]] },
69
+ },
70
+ cycle: {
71
+ label: "Open / close",
72
+ duration: 2.4,
73
+ loop: true,
74
+ easing: "linear",
75
+ autoplay: true,
76
+ tracks: { lidAngle: [[0, 0], [0.5, 110], [1, 0]] },
77
+ },
78
+ assemble: {
79
+ label: "Assemble",
80
+ description: "How the parts come together: the lid drops onto the base, then swings open to check hinge clearance.",
81
+ steps: [
82
+ { label: "Lower the lid", camera: "left", duration: 1.0, tracks: { lidLift: [[0, 40], [1, 0]] } },
83
+ { label: "Open to check clearance", camera: "iso", duration: 1.0, tracks: { lidAngle: [[0, 0], [1, 110]] } },
84
+ ],
85
+ },
86
+ },
87
+ verify: {
88
+ process: "fdm-pla",
89
+ expect: {
90
+ base: { bbox: "<=[200,200,200]" },
91
+ _view: { overlaps: 0 },
92
+ },
93
+ },
94
+ };