partforge 0.49.0 → 0.51.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.
@@ -5,7 +5,7 @@ import { attachViewerControls } from "./viewer-controls.js";
5
5
  import { attachCutawayControls } from "./cutaway-controls.js";
6
6
  import { attachRail } from "./rail.js";
7
7
  import { attachMobileTabs } from "./mobile-tabs.js";
8
- import { createTooltipPresenter } from "./tooltip.js";
8
+ import { createTooltipPresenter, attachButtonTooltips } from "./tooltip.js";
9
9
  import { loadCamera } from "./view-state.js";
10
10
  import { buildControls } from "./controls.js";
11
11
  import { relevantParamKeys } from "./param-deps.js";
@@ -29,11 +29,17 @@ import { resolveDefaultView } from "./default-view.js";
29
29
 
30
30
  // The mount handle, factored out so its shape is unit-testable without booting
31
31
  // the full mount() pipeline (WASM + workers + DOM).
32
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView }) {
32
+ // The default no-op tooltip binding, so a host can hold on to whatever
33
+ // attachTooltips returned without caring whether this mount resolved one.
34
+ const NOOP_TOOLTIP_BINDING = { sync: () => {}, hide: () => {}, detach: () => {} };
35
+
36
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips }) {
33
37
  return {
34
38
  ready, dispose, setParams,
35
- // Part-declared animation playback (spec 2026-08-02): null when the part
36
- // declares no animations. { play(name?), pause(), seek(t), stop(), state() }.
39
+ // Part-declared animation playback (spec 2026-08-02): animations are
40
+ // VIEW-owned, so this is null only when NO view declares any.
41
+ // { play(name?), pause(), seek(t), stop(), state() } — play(name) resolves
42
+ // within the ACTIVE view's set, and state() reports that view.
37
43
  animation: animation ?? null,
38
44
  // Active view name (never null once mounted). See onViewChange for the push side.
39
45
  getView,
@@ -58,6 +64,12 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
58
64
  // (partforge-cloud does, at the window level). Defaulted to a no-op so the
59
65
  // handle's shape never depends on whether this mount resolved a rail.
60
66
  setHostPane: setHostPane ?? (() => {}),
67
+ // Join host-owned chrome buttons to this mount's shared hover tooltip, so
68
+ // a host's own viewbar/rail-foot buttons match the built-in ones. Entries
69
+ // are [{ element, getLabel? }] (label falls back to the button's
70
+ // title/aria-label); returns { sync, hide, detach }. Same no-op default
71
+ // stance as setHostPane above.
72
+ attachTooltips: attachTooltips ?? (() => NOOP_TOOLTIP_BINDING),
61
73
  };
62
74
  }
63
75
 
@@ -114,6 +126,15 @@ function createCleanupStack() {
114
126
  // runtime.setHostPane("rail"); // narrow layout only: show just the controls
115
127
  // // rail ('stage' | 'rail'), suppressing the
116
128
  // // built-in tab bar. null hands selection back.
129
+ // runtime.attachTooltips([{ element: myButton }]); // host chrome buttons join the
130
+ // // mount's shared hover tooltip (the viewbar one).
131
+ // // Label = the button's title (or aria-label), or a
132
+ // // per-entry getLabel(); the title attribute is
133
+ // // absorbed while attached so it can't double up as a
134
+ // // native tooltip, and restored on detach. Returns
135
+ // // { sync, hide, detach } — call sync() after you
136
+ // // toggle a button's disabled state. Detached
137
+ // // automatically on dispose().
117
138
  // runtime.setActive(false); // park the viewer: stop the render loop and release
118
139
  // // both large GPU allocations (the drawing buffer and
119
140
  // // the cached capture target). For a host that hides the
@@ -122,13 +143,20 @@ function createCleanupStack() {
122
143
  // // loop would otherwise render a hidden pane forever.
123
144
  // // Captures still work while parked (they re-allocate).
124
145
  // // setActive(true) restores it. Safe after dispose().
125
- // runtime.animation?.play("open"); // part-declared animation playback: null when the
126
- // // part declares no animations, else
146
+ // runtime.animation?.play("open"); // part-declared animation playback: animations are
147
+ // // VIEW-owned, so this is null only when NO view
148
+ // // declares any, else
127
149
  // // { play(name?), pause(), seek(t), stop(), state() }.
128
- // // play() with an unknown name warns and does nothing;
129
- // // any user/host param edit pauses playback. A part's
130
- // // `autoplay: true` animation self-starts on first show
131
- // // and each view switch until the user touches the
150
+ // // play(name) resolves within the ACTIVE view's set
151
+ // // an unknown name (including one declared by a
152
+ // // different view) warns and does nothing; state()
153
+ // // reports the view it applies to. Switching views
154
+ // // restores the outgoing animation's params, then
155
+ // // presents the incoming view's own set (empty in a
156
+ // // view that declares none). Any user/host param edit
157
+ // // pauses playback. A view's `autoplay: true` animation
158
+ // // self-starts when that view is first shown and on
159
+ // // each switch to it, until the user touches the
132
160
  // // transport — no runtime call needed for that part.
133
161
  // const off = runtime.onContextLost(() => …); // WebGL context loss, i.e. the GPU or the
134
162
  // // OS gave up — surface it rather than showing a dead
@@ -194,7 +222,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
194
222
  cleanup.defer(() => cutawayChrome.detach());
195
223
  // Resizable/collapsible controls rail. No-ops when the host lays out the
196
224
  // framework itself (no #panel / no elements.rail).
197
- const railChrome = attachRail({ rail: els.rail, toggle: els.chrome.railToggle, shell: els.shell });
225
+ const railChrome = attachRail({ rail: els.rail, toggle: els.chrome.railToggle, shell: els.shell, tooltip });
198
226
  cleanup.defer(() => railChrome.detach());
199
227
  // Narrow-layout pane tabs. Below RAIL_NARROW_BREAKPOINT the rail cannot sit
200
228
  // beside the viewer, so exactly one pane shows and this bar picks it. Same
@@ -239,6 +267,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
239
267
  // cached assembly instantly if it's current, else auto-builds what's missing.
240
268
  const tabsCtl = createViewTabs(els.tabs, part, {
241
269
  onChange: (name) => {
270
+ // FIRST: the outgoing animation restores its param snapshot, so the
271
+ // incoming view composes its assembly from un-animated params. Anything
272
+ // that reads params (refreshView / updateRelevance / the loop kick) must
273
+ // run after it. autoplayKick stays LAST — it starts the new view's own.
274
+ animCtl?.viewChanged();
242
275
  pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); animCtl?.autoplayKick();
243
276
  onViewChange?.(name);
244
277
  },
@@ -548,11 +581,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
548
581
  loop.kick();
549
582
  }
550
583
 
551
- // Animation transport + driver (no-op null when the part declares none).
584
+ // Animation transport + driver (null when NO view declares animations).
585
+ // Animations are view-owned: `getView` is how the driver knows which view's
586
+ // set is live, both at attach and after every `viewChanged()`.
552
587
  animCtl = attachAnimationControls(viewer, part, {
553
588
  container: els.viewer,
554
589
  applyValues: applyAnimationValues,
555
590
  getParamValues: (keys) => Object.fromEntries(keys.map((k) => [k, params[k]])),
591
+ getView: view,
556
592
  });
557
593
  if (animCtl) cleanup.defer(() => animCtl.detach());
558
594
 
@@ -622,8 +658,19 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
622
658
  }
623
659
  };
624
660
 
661
+ // Host chrome buttons joining the mount's shared tooltip (the one the
662
+ // viewbar and cutaway buttons already use). Bindings are detached by
663
+ // dispose() via the cleanup stack; a host detaching earlier is fine —
664
+ // attachButtonTooltips.detach is idempotent.
665
+ const attachHostTooltips = (entries) => {
666
+ const binding = attachButtonTooltips(tooltip, entries);
667
+ cleanup.defer(() => binding.detach());
668
+ return binding;
669
+ };
670
+
625
671
  return makeHandle({
626
672
  ready, dispose, viewer, setParams,
673
+ attachTooltips: attachHostTooltips,
627
674
  setHostPane: paneTabs.setHostPane,
628
675
  getView: view, // () => tabsCtl.current()
629
676
  setView: (name) => tabsCtl.select(name),
@@ -2,6 +2,7 @@ import {
2
2
  RAIL_DEFAULT_WIDTH, RAIL_MIN_WIDTH, RAIL_NARROW_BREAKPOINT,
3
3
  clampRailWidth, railMaxWidth, readRailPref, resolveRailDrag, writeRailPref,
4
4
  } from "./rail-state.js";
5
+ import { attachButtonTooltips } from "./tooltip.js";
5
6
 
6
7
  const KEY_STEP = 16;
7
8
  const KEY_STEP_SHIFT = 64;
@@ -75,7 +76,11 @@ function safeStorage() {
75
76
  //
76
77
  // Everything is optional. With no rail this returns a no-op, so hosts that lay
77
78
  // the framework out themselves (see embed-test.html) are unaffected.
78
- export function attachRail({ rail, toggle, shell = rail?.parentElement, storage = safeStorage() } = {}) {
79
+ //
80
+ // `tooltip` is the mount's shared presenter (tooltip.js). With it, the toggle's
81
+ // Hide/Show-controls label renders as the same anchored pf-hover-tip the
82
+ // viewbar buttons use; without it, the label falls back to a native title.
83
+ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage = safeStorage(), tooltip } = {}) {
79
84
  if (!rail || !shell) {
80
85
  // No rail to resolve in this document: --pf-rail-w still defaults to 288px
81
86
  // from tokens.css, but nothing is reserving that space, so anything that
@@ -107,6 +112,12 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
107
112
  toggle.replaceChildren(svg);
108
113
  toggleChevron = chevron;
109
114
  }
115
+ // Attached BEFORE the first apply() below writes an aria-label, so the
116
+ // binding's original-attribute capture (what its detach() restores) sees the
117
+ // host's own markup, not our first label.
118
+ const tooltipBinding = toggle && tooltip
119
+ ? attachButtonTooltips(tooltip, [{ element: toggle }])
120
+ : null;
110
121
 
111
122
  const seam = document.createElement("div");
112
123
  seam.className = "pf-rail-seam";
@@ -157,8 +168,11 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
157
168
  const label = collapsed ? "Show controls" : "Hide controls";
158
169
  toggle.setAttribute("aria-expanded", String(!collapsed));
159
170
  toggle.setAttribute("aria-label", label);
160
- toggle.title = label;
171
+ // The shared tooltip reads the aria-label at show time, so a native
172
+ // title would double up as a second, competing tooltip.
173
+ if (!tooltipBinding) toggle.title = label;
161
174
  toggle.classList.toggle("on", collapsed);
175
+ tooltipBinding?.sync();
162
176
  }
163
177
  if (persist) writeRailPref(state, storage);
164
178
  }
@@ -324,6 +338,9 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
324
338
  shell.removeAttribute("data-pf-dragging");
325
339
  rail.removeAttribute("inert");
326
340
  root.style.removeProperty("--pf-rail-w");
341
+ // Before the attribute restore below: the binding's own detach rewrites
342
+ // title/aria-label from its capture, and ours must win.
343
+ tooltipBinding?.detach();
327
344
  if (toggle) {
328
345
  toggle.innerHTML = toggleOriginal.html;
329
346
  toggle.title = toggleOriginal.title;
@@ -177,6 +177,115 @@ export function createViewer(container, part) {
177
177
  partsGroup.add(l);
178
178
  }
179
179
 
180
+ // --- animated per-sub-part opacity (display-only) ---------------------------
181
+ // Overrides from the animation driver (spec 2026-08-10-per-view-animations):
182
+ // absent = normal, 0 = fully hidden (mesh AND lines), 0<v<1 = faded on cloned
183
+ // materials. Never touches geometry, params, or exports — this is the display
184
+ // half of "fade a part in, then animate it into place".
185
+ const animOpacity = new Map(); // name -> value in [0, 1)
186
+ const baseMats = Object.fromEntries(names.map((n) => [n, subMesh[n].material]));
187
+ const fadeMats = new Map(); // name -> lazily cloned MeshStandardMaterial
188
+ const fadeLineMats = new Map(); // name -> lazily cloned LineMaterial
189
+ const fadeUnregisters = new Map(); // fade material -> its cutaway unregister fn
190
+ let lastShown = []; // names last passed to showAssembly
191
+
192
+ const effectiveVisible = () => lastShown.filter((n) => (animOpacity.get(n) ?? 1) > 0);
193
+
194
+ // A fade clone is a material the cutaway does not own, so it has to be told
195
+ // about the clipping plane explicitly — otherwise a mid-fade part renders
196
+ // un-sectioned while its stencil caps and cut-face outline keep drawing.
197
+ // registerClippableMaterial syncs immediately, so a clone created while the
198
+ // cutaway is already on picks up the current state.
199
+ //
200
+ // Known cosmetic remainder, accepted: the hatch cap keeps its full-strength
201
+ // opacity while the surface above it fades, because the cap derives its
202
+ // colour/opacity from the base material at refreshSourceMaterial time. A part
203
+ // at opacity 0 drops out of the cutaway's visible set entirely, so the cap
204
+ // only over-reads during the transient middle of a fade; re-deriving cap
205
+ // opacity per frame would cost a material rebuild for a state that lasts
206
+ // under a second.
207
+ function fadeMatFor(name) {
208
+ let m = fadeMats.get(name);
209
+ if (!m) {
210
+ m = baseMats[name].clone();
211
+ m.transparent = true;
212
+ m.depthWrite = false;
213
+ fadeUnregisters.set(m, cutaway.registerClippableMaterial(m));
214
+ fadeMats.set(name, m);
215
+ }
216
+ return m;
217
+ }
218
+ function fadeLineMatFor(name) {
219
+ let m = fadeLineMats.get(name);
220
+ if (!m) {
221
+ m = lineMaterial.clone();
222
+ m.transparent = true;
223
+ m.resolution.copy(lineMaterial.resolution);
224
+ fadeUnregisters.set(m, cutaway.registerClippableMaterial(m));
225
+ fadeLineMats.set(name, m);
226
+ }
227
+ return m;
228
+ }
229
+
230
+ // Re-derive one sub-part's material + visibility from (shown, override).
231
+ function applySubOpacity(name) {
232
+ const mesh = subMesh[name], lines = subLines[name];
233
+ if (!mesh) return;
234
+ const shown = lastShown.includes(name);
235
+ const v = animOpacity.get(name);
236
+ if (v === undefined) {
237
+ // Restore ONLY from our own fade clone. showAssembly runs on every regen
238
+ // (mount.js's refreshView) without disabling the cutaway, and an enabled
239
+ // cutaway has swapped these onto its clipped clones
240
+ // (createSectionRenderSet.setEnabled) — an unconditional write here would
241
+ // silently drop clipping on every sub-part on the next param edit.
242
+ const hadFade = mesh.material === fadeMats.get(name);
243
+ if (hadFade) mesh.material = baseMats[name];
244
+ if (lines.material === fadeLineMats.get(name)) lines.material = lineMaterial;
245
+ // We just took the mesh off our clone, so an enabled cutaway must get the
246
+ // chance to re-claim it onto its clipped clone — the base material we
247
+ // wrote above carries no plane, and nothing else would put it back until
248
+ // the next cutaway toggle or theme change. Guarded on hadFade so the
249
+ // every-regen showAssembly path stays a no-op for un-faded sub-parts.
250
+ if (hadFade) cutaway.resyncSubpart(name);
251
+ mesh.visible = shown;
252
+ lines.visible = shown;
253
+ return;
254
+ }
255
+ if (v <= 0) {
256
+ mesh.visible = false;
257
+ lines.visible = false;
258
+ return;
259
+ }
260
+ const staticOpacity = part.parts[name].display?.opacity ?? 1;
261
+ const fm = fadeMatFor(name);
262
+ fm.opacity = staticOpacity * v;
263
+ mesh.material = fm;
264
+ const flm = fadeLineMatFor(name);
265
+ flm.opacity = v;
266
+ lines.material = flm;
267
+ mesh.visible = shown;
268
+ lines.visible = shown;
269
+ }
270
+
271
+ function setSubPartOpacity(name, value) {
272
+ if (!subMesh[name]) return;
273
+ const wasZero = (animOpacity.get(name) ?? 1) <= 0;
274
+ if (value == null || !(value < 1)) animOpacity.delete(name); // null/undefined/NaN/>=1 clear
275
+ else animOpacity.set(name, Math.max(0, value));
276
+ applySubOpacity(name);
277
+ const isZero = (animOpacity.get(name) ?? 1) <= 0;
278
+ if (wasZero !== isZero) cutaway.setVisible(effectiveVisible());
279
+ }
280
+
281
+ function clearSubPartOpacities() {
282
+ if (!animOpacity.size) return;
283
+ const touched = [...animOpacity.keys()];
284
+ animOpacity.clear();
285
+ for (const n of touched) applySubOpacity(n);
286
+ cutaway.setVisible(effectiveVisible());
287
+ }
288
+
180
289
  // The cutaway plane lives in world space, so its initial/reset bounds must
181
290
  // include the pivot rotation and the per-view recentering transform —
182
291
  // mesh.matrixWorld carries both. Union each visible mesh's own
@@ -347,17 +456,19 @@ export function createViewer(container, part) {
347
456
  // frame the camera to them — done only on the initial show and on view (tab)
348
457
  // changes, NOT on regeneration, so a user's zoom/orbit survives editing params.
349
458
  function showAssembly(visibleNames, { frame = false } = {}) {
350
- for (const [name, mesh] of Object.entries(subMesh)) {
351
- const on = visibleNames.includes(name);
352
- if (on) {
353
- mesh.geometry = subCache[name]; // cached geometries reused, not disposed
459
+ lastShown = [...visibleNames];
460
+ for (const name of names) {
461
+ if (visibleNames.includes(name)) {
462
+ subMesh[name].geometry = subCache[name]; // cached geometries reused, not disposed
354
463
  subLines[name].geometry = subCache[name].userData.edges;
464
+ applySubOpacity(name); // shown, but an active 0-override keeps it hidden
465
+ } else {
466
+ subMesh[name].visible = false;
467
+ subLines[name].visible = false;
355
468
  }
356
- mesh.visible = on;
357
- subLines[name].visible = on;
358
469
  }
359
470
  if (frame) frameTo(visibleNames);
360
- cutaway.setVisible(visibleNames);
471
+ cutaway.setVisible(effectiveVisible());
361
472
  }
362
473
 
363
474
  // Re-frame whatever is currently visible (the reframe button).
@@ -365,8 +476,22 @@ export function createViewer(container, part) {
365
476
  frameTo(names.filter((n) => subMesh[n].visible && subCache[n]));
366
477
  }
367
478
 
479
+ // Call after anything that rewrites sub-part materials out from under us.
480
+ // The cutaway assigns mesh.material itself — the clipped clone on enable, the
481
+ // captured original on disable, and a freshly re-cloned pair on every
482
+ // refreshSourceMaterial (which setTheme drives) — so a live fade has to be
483
+ // re-asserted on top or a PAUSED mid-fade part sticks at full opacity. A
484
+ // playing animation would self-heal on its next frame; a paused one has no
485
+ // next frame. Only the calls that reassign materials need this: flip and
486
+ // reset move the plane and nothing else.
487
+ function reassertLiveFades() {
488
+ for (const n of animOpacity.keys()) applySubOpacity(n);
489
+ }
490
+
368
491
  function setCutawayEnabled(on) {
369
- return cutaway.setEnabled(on);
492
+ const result = cutaway.setEnabled(on);
493
+ reassertLiveFades();
494
+ return result;
370
495
  }
371
496
 
372
497
  // Swap the scene background, grid, and edge-line colors for the given theme.
@@ -378,10 +503,13 @@ export function createViewer(container, part) {
378
503
  grid.position.y = floorY; // keep the floor at the bbox bottom across theme swaps
379
504
  scene.add(grid);
380
505
  lineMaterial.color.set(t.line);
506
+ for (const m of fadeLineMats.values()) m.color.set(t.line); // clones follow the theme
381
507
  cutaway.setTheme(mode, t.line);
508
+ reassertLiveFades(); // setTheme re-clones every section's materials and reassigns them
382
509
  }
383
510
 
384
511
  function hideAssembly() {
512
+ lastShown = [];
385
513
  for (const m of Object.values(subMesh)) m.visible = false;
386
514
  for (const l of Object.values(subLines)) l.visible = false;
387
515
  cutaway.setVisible([]);
@@ -399,6 +527,7 @@ export function createViewer(container, part) {
399
527
  camera.aspect = w / h;
400
528
  camera.updateProjectionMatrix();
401
529
  lineMaterial.resolution.set(w, h); // fat lines need the viewport size for px width
530
+ for (const m of fadeLineMats.values()) m.resolution.set(w, h); // clones need it too
402
531
  cutaway.setViewportSize(w, h, renderer.getPixelRatio());
403
532
  }
404
533
  const ro = new ResizeObserver(resize);
@@ -720,9 +849,22 @@ export function createViewer(container, part) {
720
849
  for (const n of names) {
721
850
  const g = subCache[n];
722
851
  if (g) { g.userData.edges?.dispose(); g.dispose(); subCache[n] = null; }
723
- subMesh[n].material?.dispose();
852
+ // baseMats[n], not subMesh[n].material: an active fade override has swapped
853
+ // the mesh onto a clone, and the base material would otherwise leak.
854
+ baseMats[n]?.dispose();
724
855
  subMesh[n].geometry?.dispose(); // the initial empty BufferGeometry, if never replaced
725
856
  }
857
+ // Hand the fade clones back before freeing them. cutaway.dispose() above has
858
+ // already restored their original clippingPlanes and emptied its registry,
859
+ // so these unregister closures find no entry and return without touching a
860
+ // disposed cutaway. They still earn their place: they release this map's
861
+ // hold on the registry's unregister closures rather than leaving it to GC.
862
+ for (const off of fadeUnregisters.values()) off();
863
+ fadeUnregisters.clear();
864
+ for (const m of fadeMats.values()) m.dispose();
865
+ for (const m of fadeLineMats.values()) m.dispose();
866
+ fadeMats.clear();
867
+ fadeLineMats.clear();
726
868
  material.dispose();
727
869
  lineMaterial.dispose();
728
870
  grid.geometry.dispose();
@@ -737,6 +879,8 @@ export function createViewer(container, part) {
737
879
  hideAssembly,
738
880
  setSubGeometry,
739
881
  setSubPose,
882
+ setSubPartOpacity,
883
+ clearSubPartOpacities,
740
884
  hasSubMesh,
741
885
  subTriangles,
742
886
  frame,
@@ -756,6 +900,8 @@ export function createViewer(container, part) {
756
900
  camera,
757
901
  domElement: renderer.domElement,
758
902
  _subMeshes: subMesh,
903
+ __subMesh: (n) => subMesh[n], // test hooks (cf. attachAnimationControls' __viewer)
904
+ __subLines: (n) => subLines[n],
759
905
  flashPoint,
760
906
  cutawaySupported: () => cutaway.isSupported,
761
907
  cutawayEnabled: () => cutaway.isEnabled,
@@ -1,7 +1,10 @@
1
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.
2
+ // docs/AUTHORING-PARTS.md "Animations": animations are VIEW-OWNED (declared
3
+ // under `views.box.animations`, so the transport bar belongs to that view),
4
+ // they drive pose-only params (lidAngle, lidLift) through place(), and
5
+ // `assemble` opens with an OPACITY fade that brings the lid in from nothing
6
+ // before any motion. Also shown: an intro camera + markdown description on
7
+ // `open`, a looping autoplay `cycle`, and per-step cameras on `assemble`.
5
8
  export default {
6
9
  meta: { title: "Hinged Box", units: "mm" },
7
10
  parameters: [
@@ -58,30 +61,43 @@ export default {
58
61
  : s.rotate(-p.lidAngle, [0, p.depth, p.height], [1, 0, 0]).translate([0, 0, p.lidLift]),
59
62
  },
60
63
  },
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
- ],
64
+ views: {
65
+ box: {
66
+ label: "Box",
67
+ animations: {
68
+ open: {
69
+ label: "Open lid",
70
+ description: "Swings the lid to **110°** about the rear hinge line.\n\nPose-only: playback runs at frame rate with no geometry rebuild.",
71
+ camera: "front",
72
+ duration: 1.2,
73
+ tracks: { lidAngle: [[0, 0], [1, 110]] },
74
+ },
75
+ cycle: {
76
+ label: "Open / close",
77
+ duration: 2.4,
78
+ loop: true,
79
+ easing: "linear",
80
+ autoplay: true,
81
+ tracks: { lidAngle: [[0, 0], [0.5, 110], [1, 0]] },
82
+ },
83
+ assemble: {
84
+ label: "Assemble",
85
+ description: "How the parts come together: the lid fades in above the base, drops on, then swings open to check hinge clearance.",
86
+ steps: [
87
+ // The lidLift hold-track pins the lift at 40 while the lid fades in,
88
+ // so step 2's drop starts from where the fade showed it. Without it
89
+ // the lift would hold step 2's FIRST keyframe — also 40 — but
90
+ // stating it makes the pose explicit and survives a retune of step 2.
91
+ { label: "Lid appears", camera: "iso", duration: 0.8,
92
+ opacity: { lid: [[0, 0], [1, 1]] },
93
+ tracks: { lidLift: [[0, 40], [1, 40]] } },
94
+ { label: "Lower the lid", camera: "left", duration: 1.0,
95
+ tracks: { lidLift: [[0, 40], [1, 0]] } },
96
+ { label: "Open to check clearance", camera: "iso", duration: 1.0,
97
+ tracks: { lidAngle: [[0, 0], [1, 110]] } },
98
+ ],
99
+ },
100
+ },
85
101
  },
86
102
  },
87
103
  verify: {
@@ -32,21 +32,50 @@ const norm = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0]
32
32
  // rasterizer (orthographic, z-buffered, Lambert-shaded, with depth-tested edge
33
33
  // overlays). No native module, no browser. Returns the written file paths.
34
34
  // pngjs is lazy-imported so importing the testing barrel for measure never loads it.
35
+ //
36
+ // `opacity` is a Record<subPartName, number> (an animation's evaluate() output,
37
+ // typically): a sub-part at 0 is skipped entirely — faces AND edges — but it
38
+ // still counts toward the SCENE BOUNDS, so a part crossing 0 cannot silently
39
+ // reframe the still. Framing is a property of the pose, not of what happens to
40
+ // be visible: without that, `--at 0,0.5,1` over a fade drew the same base at
41
+ // three different scales and the sequence read as a zoom.
42
+ // Values in (0,1) fade by PRE-BLENDING that part's shaded base and edge
43
+ // colours toward the background. That is a z-buffered approximation: a faded
44
+ // part still fully occludes whatever is behind it, because real transparency
45
+ // needs back-to-front sorting this rasterizer does not do. Stills only need to
46
+ // read as faded, so the approximation is the contract, not a stopgap.
35
47
  export async function renderViews(kernel, part, view = Object.keys(part.views)[0], {
36
48
  views = ["iso", "front", "top"], out = "render", size = [800, 600], edges = true, params = {}, tag = "",
49
+ opacity = {},
37
50
  } = {}) {
38
51
  const { PNG } = await import("pngjs");
39
52
  const [W, H] = size;
40
- const meshes = buildView(kernel, part, view, params).map((b) => b.mesh); // copied out
53
+ // Sub-part names are kept alongside the meshes: opacity is keyed by name.
54
+ // Own-key lookups only — a part named "constructor" must not inherit a value
55
+ // off Object.prototype and vanish from the render.
56
+ const opacityOf = (name) => {
57
+ if (!Object.hasOwn(opacity ?? {}, name)) return 1;
58
+ const v = Number(opacity[name]);
59
+ return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 1; // a junk value renders solid
60
+ };
61
+ const built = buildView(kernel, part, view, params) // copied out
62
+ .map((b) => ({ name: b.name, mesh: b.mesh }));
41
63
 
42
- // scene bounds over all sub-parts (positions are JS-owned; safe after cleanup)
64
+ // Scene bounds over EVERY built sub-part, visible or not (positions are
65
+ // JS-owned; safe after cleanup). Opacity is deliberately NOT consulted here —
66
+ // see the note above on why a fade must not move the camera.
43
67
  const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
44
- for (const m of meshes) {
68
+ for (const { mesh: m } of built) {
45
69
  const b = bounds(m.positions);
46
70
  for (let i = 0; i < 3; i++) { lo[i] = Math.min(lo[i], b.min[i]); hi[i] = Math.max(hi[i], b.max[i]); }
47
71
  }
72
+ // kernel.cleanup() walks the backend's own tracked list, not this array, so
73
+ // dropping the hidden sub-parts afterwards frees nothing and skips nothing.
48
74
  kernel.cleanup?.();
49
75
 
76
+ // …and only the visible ones are rasterized: faces AND edges below iterate this.
77
+ const meshes = built.filter(({ name }) => opacityOf(name) > 0);
78
+
50
79
  const center = [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2];
51
80
  const radius = Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) / 2 || 5;
52
81
 
@@ -84,7 +113,12 @@ export async function renderViews(kernel, part, view = Object.keys(part.views)[0
84
113
  for (let i = 0; i < W * H; i++) { color[i * 3] = bg[0]; color[i * 3 + 1] = bg[1]; color[i * 3 + 2] = bg[2]; }
85
114
  const zbuf = new Float32Array(W * H).fill(-Infinity); // larger depth = nearer camera
86
115
 
87
- for (const m of meshes) {
116
+ for (const { name, mesh: m } of meshes) {
117
+ // Pre-blend toward the background: the fade is baked into the material
118
+ // colour before shading, so no per-pixel compositing (and no depth sort)
119
+ // is needed. See the note on renderViews for why that is enough here.
120
+ const v = opacityOf(name);
121
+ const faded = v < 1 ? base.map((c, i) => Math.round(c * v + bg[i] * (1 - v))) : base;
88
122
  const P = m.positions, N = m.normals, ind = m.indices;
89
123
  // Manifold meshes are a non-indexed soup (3 consecutive verts/triangle);
90
124
  // OCCT meshes are indexed. Both carry per-vertex normals.
@@ -109,16 +143,18 @@ export async function renderViews(kernel, part, view = Object.keys(part.views)[0
109
143
  const I0 = Math.min(1, ambient + diffuse * Math.abs((nx * light[0] + ny * light[1] + nz * light[2]) / L));
110
144
  inten = [I0, I0, I0];
111
145
  }
112
- rasterTri(sp, inten, base, color, zbuf, W, H);
146
+ rasterTri(sp, inten, faded, color, zbuf, W, H);
113
147
  }
114
148
  }
115
149
 
116
150
  if (edges) {
117
- for (const m of meshes) {
151
+ for (const { name, mesh: m } of meshes) {
118
152
  const E = m.edges;
119
153
  if (!E?.length) continue;
154
+ const v = opacityOf(name);
155
+ const fadedEdge = v < 1 ? edgeColor.map((c, i) => Math.round(c * v + bg[i] * (1 - v))) : edgeColor;
120
156
  for (let i = 0; i < E.length; i += 6)
121
- drawLine(project([E[i], E[i + 1], E[i + 2]]), project([E[i + 3], E[i + 4], E[i + 5]]), edgeColor, color, zbuf, W, H, bias);
157
+ drawLine(project([E[i], E[i + 1], E[i + 2]]), project([E[i + 3], E[i + 4], E[i + 5]]), fadedEdge, color, zbuf, W, H, bias);
122
158
  }
123
159
  }
124
160
 
package/types/index.d.ts CHANGED
@@ -146,8 +146,13 @@ export interface CaptureViewOptions {
146
146
  export type AnimationStatus = "idle" | "intro" | "playing" | "paused";
147
147
 
148
148
  export interface AnimationState {
149
- /** The selected animation's key. */
150
- animation: string;
149
+ /** The active view (tab) name — animations belong to a view. */
150
+ view: string;
151
+ /**
152
+ * The selected animation's key, or `null` while the active view declares no
153
+ * animations. Switching views re-selects that view's first animation.
154
+ */
155
+ animation: string | null;
151
156
  status: AnimationStatus;
152
157
  /** Position on the timeline, 0..1 over the animation's total duration. */
153
158
  t: number;
@@ -158,12 +163,16 @@ export interface AnimationState {
158
163
  /**
159
164
  * Part-declared animation playback — the same engine the viewer's transport bar
160
165
  * drives. Playback writes real params, so exporting while paused exports the
161
- * posed state, and any user or host param edit pauses it.
166
+ * posed state, and any user or host param edit pauses it. An animation's
167
+ * `opacity` tracks are the exception: display-only, never written to params and
168
+ * never visible to export.
162
169
  */
163
170
  export interface AnimationRuntime {
164
171
  /**
165
- * Play, optionally switching to a named animation first. An unknown name
166
- * warns and does nothing rather than playing whatever is selected.
172
+ * Play, optionally switching to a named animation first. The name resolves
173
+ * within the ACTIVE view an animation declared by another view is not
174
+ * playable from here. An unknown name warns and does nothing rather than
175
+ * playing whatever is selected.
167
176
  */
168
177
  play(name?: string): void;
169
178
  pause(): void;
@@ -239,8 +248,9 @@ export interface PartRuntime {
239
248
  */
240
249
  setHostPane(pane: HostPane): void;
241
250
  /**
242
- * Part-declared animation playback, or `null` when the part declares no
243
- * `animations` block.
251
+ * Part-declared animation playback, or `null` when NO view declares an
252
+ * `animations` block. Non-null while any view does — including while the
253
+ * active view has none, where `state().animation` reads `null`.
244
254
  */
245
255
  animation: AnimationRuntime | null;
246
256
  }