partforge 0.49.0 → 0.50.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.
package/README.md CHANGED
@@ -165,18 +165,20 @@ viewer re-poses the meshes it already has — instantly, with no worker rebuild;
165
165
  `onBuild` does not fire for those pose-only edits. Anything that changes the
166
166
  geometry itself rebuilds as usual.
167
167
 
168
- For parts that declare `animations`, `runtime.animation` exposes the viewer's
169
- playback engine (`null` otherwise):
168
+ When any view declares `animations`, `runtime.animation` exposes the viewer's
169
+ playback engine (`null` otherwise). Animations are **view-owned**
170
+ (`views.<name>.animations`), so the engine is scoped to the active view — call
171
+ `setView` first to reach another view's set:
170
172
 
171
173
  ```js
172
- runtime.animation.play("open"); // switch + play (camera cue and all)
174
+ runtime.animation.play("open"); // switch + play, within the ACTIVE view (camera cue and all)
173
175
  runtime.animation.seek(0.5); // scrub, normalized 0..1 (pauses)
174
176
  runtime.animation.pause();
175
- runtime.animation.stop(); // reset + restore pre-animation params
176
- runtime.animation.state(); // { animation, status, t, stepIndex }
177
+ runtime.animation.stop(); // reset + restore pre-animation params and clear opacity overrides
178
+ runtime.animation.state(); // { view, animation, status, t, stepIndex }
177
179
  ```
178
180
 
179
- A part can mark at most one animation `autoplay: true` to start it
181
+ A view can mark at most one of its animations `autoplay: true` to start it
180
182
  automatically on first show and again on every view switch — until the user
181
183
  touches the transport — or anything writes params (`runtime.setParams`
182
184
  included) or calls a `runtime.animation` method; any of those disarms
package/bin/cli.js CHANGED
@@ -8,7 +8,7 @@ import { pathToFileURL } from "node:url";
8
8
  import { resolve, dirname } from "node:path";
9
9
  import { writeFileSync, mkdirSync } from "node:fs";
10
10
  import { detectBackend } from "../src/framework/backend-select.js";
11
- import { normalizeAnimation, evaluate, cueAt } from "../src/framework/animation.js";
11
+ import { viewAnimations, evaluate, cueAt } from "../src/framework/animation.js";
12
12
  import { bootOcctKernel } from "../src/testing/occt.js";
13
13
  import { bootManifoldKernel } from "../src/testing/manifold.js";
14
14
  import { measure } from "../src/framework/oracle/measure.js";
@@ -185,19 +185,46 @@ const commands = {
185
185
  if (view !== undefined && !Object.hasOwn(part.views ?? {}, view)) {
186
186
  die(`unknown view "${view}" (have: ${Object.keys(part.views ?? {}).join(", ") || "none"})\n${usage}`);
187
187
  }
188
+ // Resolve --animation across views, still BEFORE the kernel boots (the
189
+ // header's rule: a flag typo shouldn't pay a WASM boot, and this depends
190
+ // only on `part`). A unique name implies its owning view, overriding the
191
+ // default-view rule; an ambiguous one needs the positional view argument.
192
+ // NOT a --view flag: --views already means camera angles.
193
+ let anim = null, animView;
194
+ if (flags.animation !== undefined) {
195
+ const byView = viewAnimations(part);
196
+ // Deduped: two views declaring the same name would otherwise report
197
+ // "(declared: shared, shared)".
198
+ const declared = () =>
199
+ [...new Set([...byView.values()].flat().map((x) => x.name))].join(", ") || "none";
200
+ const owners = [...byView.entries()]
201
+ .filter(([, anims]) => anims.some((x) => x.name === flags.animation))
202
+ .map(([v]) => v);
203
+ if (view !== undefined) {
204
+ if (!owners.includes(view)) {
205
+ throw new Error(owners.length
206
+ ? `animation "${flags.animation}" is not in view "${view}" — it lives in view ${owners.map((v) => `"${v}"`).join(", ")}`
207
+ : `unknown animation "${flags.animation}" (declared: ${declared()})`);
208
+ }
209
+ animView = view;
210
+ } else if (owners.length === 1) {
211
+ animView = owners[0];
212
+ } else if (owners.length > 1) {
213
+ die(`--animation "${flags.animation}" is ambiguous (views ${owners.map((v) => `"${v}"`).join(", ")}) — pass the positional view argument\n${usage}`);
214
+ } else {
215
+ throw new Error(`unknown animation "${flags.animation}" (declared: ${declared()})`);
216
+ }
217
+ // Already normalized by viewAnimations — no normalizeAnimation call here.
218
+ anim = byView.get(animView).find((x) => x.name === flags.animation);
219
+ }
220
+
188
221
  const kernel = await bootKernel(part);
189
222
 
190
- if (flags.animation === undefined) {
223
+ if (anim === null) {
191
224
  const files = await renderViews(kernel, part, view, { views, out: outDir, params: baseParams });
192
225
  for (const f of files) console.log(`wrote ${f}`);
193
226
  process.exit(0);
194
227
  }
195
-
196
- const spec = part.animations?.[flags.animation];
197
- if (!spec) {
198
- throw new Error(`unknown animation "${flags.animation}" (have: ${Object.keys(part.animations ?? {}).join(", ") || "none"})`);
199
- }
200
- const anim = normalizeAnimation(flags.animation, spec);
201
228
  // Frames: --step renders one still at the END of that step (its fully
202
229
  // applied state); --at takes positions normalized over the animation's
203
230
  // TOTAL duration (same t as the viewer scrubber / runtime seek).
@@ -241,11 +268,11 @@ const commands = {
241
268
  frames = ts.map((t, i) => ({ t, tag: `${flags.animation}-t${tags[i]}` }));
242
269
  }
243
270
  for (const frame of frames) {
244
- const { values } = evaluate(anim, frame.t);
271
+ const { values, opacity } = evaluate(anim, frame.t);
245
272
  const cue = cueAt(anim, frame.cueT ?? frame.t);
246
273
  const frameViews = views ?? (cue ? [cue.view] : undefined);
247
- const files = await renderViews(kernel, part, view, {
248
- views: frameViews, out: outDir, params: { ...baseParams, ...values }, tag: frame.tag,
274
+ const files = await renderViews(kernel, part, animView, {
275
+ views: frameViews, out: outDir, params: { ...baseParams, ...values }, tag: frame.tag, opacity,
249
276
  });
250
277
  for (const f of files) console.log(`wrote ${f}`);
251
278
  }
@@ -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, default? } }, // the view tabs (a view = a set of sub-parts)
88
+ views: { <name>: { label, default?, animations? } }, // view tabs; a view may own animations (below)
89
89
  };
90
90
  ```
91
91
 
@@ -131,11 +131,23 @@ export default {
131
131
 
132
132
  ## Animations
133
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, with ‹ ›
136
- pagers between animations); hosts drive the same engine via
137
- `runtime.animation`; `partforge render` can render stills at any position. The
138
- reference part is `src/parts/hinged-box.js`.
134
+ A **view** may declare named animations — pure keyframe data that drives
135
+ **existing params** over time and fades the view's own sub-parts in and out.
136
+ Animations belong to the view that declares them: they live under
137
+ `views.<name>.animations`, never at the top level of the part (a top-level
138
+ `animations` key is a lint error — `animation-not-in-view` — and is ignored at
139
+ runtime: no bar, no crash).
140
+
141
+ The viewer shows a transport bar (play/scrub, with ‹ › pagers between
142
+ animations) **only while the active view declares animations**, listing exactly
143
+ that view's set; views without animations render no bar at all. Switching views
144
+ resets playback: the running animation stops, its param snapshot is restored,
145
+ all opacity overrides are cleared, and the incoming view's transport starts
146
+ fresh at its first animation, position 0 — no animation state survives a view
147
+ switch. Hosts drive the same engine via `runtime.animation`, scoped to the
148
+ active view (call `setView` first to reach another view's animations);
149
+ `partforge render` can render stills at any position. The reference part is
150
+ `src/parts/hinged-box.js`.
139
151
 
140
152
  Step labels surface on the scrubber rather than in a readout: hovering or
141
153
  dragging along the timeline names the chapter under the pointer, and with the
@@ -144,42 +156,87 @@ matching the key's native slider direction). Screen readers get the same
144
156
  information from the scrubber's `aria-valuetext`, which reads
145
157
  `"<step label> — <percent>"`.
146
158
 
159
+ This is the shipped reference part's own block — `views.box` owns all three
160
+ animations, and `assemble` opens with a fade rather than a motion:
161
+
147
162
  ```js
148
- animations: {
149
- open: {
150
- label: "Open lid",
151
- description: "Optional **CommonMark**, shown behind the ⓘ glyph.",
152
- camera: "front", // optional: intro angle, cue list, or per-step (below)
153
- duration: 1.2, // seconds
154
- loop: false, // true = wraps continuously (single-step only)
155
- easing: "ease-in-out", // linear | ease-in | ease-out | ease-in-out
156
- tracks: { lidAngle: [[0, 0], [1, 110]] }, // param -> [t, value] keyframes
157
- },
158
- assemble: {
159
- label: "Assemble",
160
- steps: [ // steps play in order; named on the scrubber as you hover/drag
161
- { label: "Lower the lid", camera: "left", duration: 1.0,
162
- tracks: { lidLift: [[0, 40], [1, 0]] } },
163
- { label: "Open", camera: "iso", duration: 1.0,
164
- tracks: { lidAngle: [[0, 0], [1, 110]] } },
165
- ],
163
+ views: {
164
+ box: {
165
+ label: "Box",
166
+ animations: {
167
+ open: {
168
+ label: "Open lid",
169
+ description: "Swings the lid to **110°** about the rear hinge line.\n\nPose-only: playback runs at frame rate with no geometry rebuild.",
170
+ camera: "front", // optional: intro angle, cue list, or per-step (below)
171
+ duration: 1.2, // seconds
172
+ tracks: { lidAngle: [[0, 0], [1, 110]] }, // param -> [t, value] keyframes
173
+ },
174
+ cycle: {
175
+ label: "Open / close",
176
+ duration: 2.4,
177
+ loop: true, // wraps continuously (single-step only)
178
+ easing: "linear", // linear | ease-in | ease-out | ease-in-out
179
+ autoplay: true, // at most one per view
180
+ tracks: { lidAngle: [[0, 0], [0.5, 110], [1, 0]] },
181
+ },
182
+ assemble: {
183
+ label: "Assemble",
184
+ description: "How the parts come together: the lid fades in above the base, drops on, then swings open to check hinge clearance.",
185
+ steps: [ // steps play in order; named on the scrubber as you hover/drag
186
+ { label: "Lid appears", camera: "iso", duration: 0.8,
187
+ opacity: { lid: [[0, 0], [1, 1]] }, // sub-part -> [t, 0..1] keyframes
188
+ tracks: { lidLift: [[0, 40], [1, 40]] } }, // hold the lift while it fades in
189
+ { label: "Lower the lid", camera: "left", duration: 1.0,
190
+ tracks: { lidLift: [[0, 40], [1, 0]] } },
191
+ { label: "Open to check clearance", camera: "iso", duration: 1.0,
192
+ tracks: { lidAngle: [[0, 0], [1, 110]] } },
193
+ ],
194
+ },
195
+ },
166
196
  },
167
- }
197
+ },
168
198
  ```
169
199
 
170
200
  Rules (all lint-enforced):
171
201
 
172
- - An animation has **either** `tracks` (a single anonymous step) **or** `steps`.
202
+ - Animations are declared under `views.<name>.animations` one map per view,
203
+ each name unique within its view. Two views may reuse a name; each owns its
204
+ own animation.
205
+ - An animation has **either** `tracks`/`opacity` (a single anonymous step)
206
+ **or** `steps`. Never both forms, never neither.
173
207
  - Tracks reference numeric params from `defaults`. Keyframe `t` is normalized
174
208
  per step, strictly ascending from exactly 0 to exactly 1; values must sit
175
209
  inside the owning control's min/max (the engine applies them unclamped).
176
210
  - Params not tracked anywhere keep their current values; a param tracked in
177
211
  one step holds its nearest keyframe value while other steps play.
178
- - A step may declare a `camera` and **no** `tracks` an establishing shot that
179
- swings the view while the model holds still. At least one step still has to
180
- carry tracks, or the animation animates nothing. Note the holding value is the
181
- nearest keyframe, not whatever the user last set: a leading camera-only step
182
- shows the animation's opening pose, the same one `t = 0` would show.
212
+ - `opacity` sits beside `tracks` and fades sub-parts instead of moving them.
213
+ It is keyed by **sub-part name**, and the sub-part must belong to the owning
214
+ view (`animation-opacity-unknown-part` otherwise); values run 0 (hidden) to 1
215
+ (normal) and are lint-checked against that range
216
+ (`animation-opacity-range`). Keyframes follow exactly the same rules as param
217
+ tracks — per-step normalized `t`, strictly ascending from 0 to 1 — including
218
+ the hold rule: a sub-part faded in step 3 holds its step-3 opening value
219
+ (0, hidden) through steps 1–2, so "absent until its moment" needs no extra
220
+ declaration. Sub-parts never mentioned render normally.
221
+ - Opacity 0 hides the mesh **and its edge lines** entirely — it is absence, not
222
+ a ghost. Values in between multiply any static `display.opacity`: a ghost
223
+ part at `display.opacity: 0.5` faded to 1 shows at 0.5.
224
+ - **Opacity is display-only, always** — it never touches params, export,
225
+ `measure`, or `verify`, and Reset restores normal visibility. This is a
226
+ deliberate asymmetry with param `tracks`, where exporting while paused
227
+ exports the posed state (below): a pose is real param state, a fade is not.
228
+ Because it bypasses the param pipeline, a fade runs at frame rate even when
229
+ param tracks force worker-cadence rebuilds.
230
+ - Fades compose with the cutaway: a half-faded surface is still sectioned by the
231
+ cut plane, though its hatch cap keeps full-strength opacity for the moment the
232
+ part is mid-fade.
233
+ - A step may declare a `camera` and **no** `tracks`/`opacity` — an establishing
234
+ shot that swings the view while the model holds still. At least one step still
235
+ has to carry `tracks` or `opacity`, or the animation animates nothing; a
236
+ **pure-fade** animation, carrying only `opacity`, is perfectly legal. Note the
237
+ holding value is the nearest keyframe, not whatever the user last set: a
238
+ leading camera-only step shows the animation's opening pose, the same one
239
+ `t = 0` would show.
183
240
  - `loop` and `autoplay` must be literal booleans. Anything else is reported by
184
241
  lint and treated as `false` at runtime, so `loop: "false"` never means "loop".
185
242
  - Couple motions through `derive` (animate one master param; derive the rest),
@@ -196,8 +253,8 @@ Rules (all lint-enforced):
196
253
  - Playback pauses when the user edits any control; Reset restores the values
197
254
  the animation found. Because animated values are real params, exporting
198
255
  while paused exports the posed state — by design.
199
- - `autoplay: true` (optional, one animation at most) starts that animation on
200
- first show and again on each view switch, until the user touches the
256
+ - `autoplay: true` (optional, at most one animation **per view**) starts that
257
+ animation on first show and again on each view switch, until the user touches the
201
258
  transport — or anything writes params (`runtime.setParams` included) or
202
259
  calls a `runtime.animation` method; any of those disarms auto-start for the
203
260
  session. Lint: `animation-autoplay-invalid`. It is not armed when the
@@ -210,7 +267,13 @@ Rules (all lint-enforced):
210
267
  Headless: `partforge render <part> --animation open --at 0,0.5,1` renders
211
268
  tagged stills (`--at` is normalized over the animation's total duration, like
212
269
  the scrubber); `--step <index|label>` renders a step's end state; stills
213
- default to the governing camera cue's angle.
270
+ default to the governing camera cue's angle, and apply opacity at the rendered
271
+ `t`, so a faded frame renders faded. `--animation` searches every view: a name
272
+ unique across the part implies its owning view and renders there, overriding
273
+ the usual first-view default. If two views declare the same name the CLI stops
274
+ and asks for the existing positional view argument
275
+ (`partforge render <part> <view> --animation shared`) — there is no compound
276
+ "view/name" syntax, and `--views` already means camera angles.
214
277
 
215
278
  ---
216
279
 
@@ -1525,10 +1588,13 @@ bring a section back under budget.
1525
1588
  `contacts` / `clearance` keys, which are not scalar view metrics; they are
1526
1589
  validated by `verify-bad-pair-check`, matching `verify.js`'s own handling.
1527
1590
 
1528
- **Animations block** — static validation of `animations`, without executing
1529
- `build`: `animations-not-object`, `animation-tracks-or-steps`,
1591
+ **Animations block** — static validation of each view's `animations` block,
1592
+ without executing `build`: `animation-not-in-view` (a top-level `animations`
1593
+ key, which the runtime ignores), `animations-not-object`,
1594
+ `animation-tracks-or-steps`,
1530
1595
  `animation-unknown-param`, `animation-param-not-numeric`,
1531
1596
  `animation-keyframes-invalid`, `animation-value-out-of-range`,
1597
+ `animation-opacity-unknown-part`, `animation-opacity-range`,
1532
1598
  `animation-duration-invalid`, `animation-loop-invalid`,
1533
1599
  `animation-step-label-duplicate`, `animation-easing-unknown`,
1534
1600
  `animation-camera-invalid`, `animation-description-invalid`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.49.0",
3
+ "version": "0.50.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",
@@ -3,9 +3,14 @@
3
3
  // the debug overlay); the driver ticks the pure playback state machine
4
4
  // (animation.js) from the viewer's frame loop and routes every param write
5
5
  // through the mount-supplied applyValues hook — the same path as a slider
6
- // edit, minus the regen debounce. Returns null when the part declares no
7
- // (valid) animations, so mount can wire it unconditionally.
8
- import { normalizeAnimations, createPlayback, stepIndexAt } from "./animation.js";
6
+ // edit, minus the regen debounce. Returns null when NO view declares a
7
+ // (valid) animation, so mount can wire it unconditionally.
8
+ //
9
+ // Animations belong to a VIEW: the bar shows the active view's set and hides
10
+ // itself in a view that declares none, and every view switch resets the
11
+ // outgoing animation first (see viewChanged). The host owns which view is
12
+ // active — getView() is the driver's only window onto it.
13
+ import { viewAnimations, createPlayback, stepIndexAt } from "./animation.js";
9
14
  import { createInfoPopover, attachInfo } from "./controls.js";
10
15
 
11
16
  function el(tag, className, text) {
@@ -92,49 +97,51 @@ function textSetter(element) {
92
97
  return (value) => { if (node.data !== value) node.data = value; };
93
98
  }
94
99
 
95
- export function attachAnimationControls(viewer, part, { container, applyValues, getParamValues }) {
100
+ export function attachAnimationControls(viewer, part, { container, applyValues, getParamValues, getView }) {
96
101
  // A malformed animations block must degrade to "no transport bar", never a
97
102
  // crashed mount — lint reports the specifics; the viewer just goes without.
98
- let animations;
99
- try { animations = normalizeAnimations(part); } catch { animations = []; }
100
- if (!animations.length) return null;
103
+ let byView;
104
+ try { byView = viewAnimations(part); } catch { byView = new Map(); }
105
+ if (![...byView.values()].some((a) => a.length)) return null;
101
106
 
102
107
  const reducedMotion = typeof matchMedia === "function"
103
108
  && matchMedia("(prefers-reduced-motion: reduce)").matches;
104
109
  const tweenDuration = reducedMotion ? 0 : 0.6; // reduced motion: jump cut, no sweep
105
110
 
106
- let current = animations[0];
107
- let playback = createPlayback(current);
111
+ // `animations` is the ACTIVE view's set; it is re-pointed by viewChanged, and
112
+ // is empty (with a null current/playback) in a view that declares none.
113
+ const animsFor = (view) => byView.get(view) ?? [];
114
+ let animations = animsFor(getView());
115
+ let current = animations[0] ?? null;
116
+ let playback = current ? createPlayback(current) : null;
108
117
  let snapshot = null; // tracked-param values before this animation first drove them
109
118
 
110
- // Autoplay: at most one animation declares it (lint-enforced). Armed until
111
- // the user manually touches the transport and never armed at all under
119
+ // Autoplay: at most one animation PER VIEW declares it (lint-enforced), and
120
+ // each view's own gets its kick when that view becomes active. Armed until
121
+ // the user manually touches the transport — one touch disarms it for the
122
+ // whole session, every view included — and never armed at all under
112
123
  // prefers-reduced-motion: self-starting motion is exactly what that setting
113
124
  // opts out of. The transport still plays everything on request.
114
- const autoplayAnim = animations.find((a) => a.autoplay) ?? null;
115
- let autoplayArmed = !!autoplayAnim && !reducedMotion;
125
+ const autoplayFor = (view) => animsFor(view).find((a) => a.autoplay) ?? null;
126
+ let autoplayArmed = [...byView.values()].some((set) => set.some((a) => a.autoplay)) && !reducedMotion;
116
127
  const disarmAutoplay = () => { autoplayArmed = false; };
117
128
 
118
129
  // --- DOM --------------------------------------------------------------------
119
130
  const bar = el("div", "pf-anim-bar");
120
131
  const info = createInfoPopover();
121
132
 
133
+ // The bar's DOM is built ONCE and re-dressed per view: which of the picker,
134
+ // the title and the pagers actually shows is syncStructure's call, because a
135
+ // view switch can move between a one-animation set and a many-animation one.
122
136
  const pick = document.createElement("select");
123
137
  pick.className = "pf-anim-pick";
124
138
  pick.setAttribute("aria-label", "Choose animation");
125
- for (const a of animations) {
126
- const o = document.createElement("option");
127
- o.value = a.name; o.textContent = a.label;
128
- pick.append(o);
129
- }
130
139
  const title = el("span", "pf-anim-title", "");
131
- // Multi-animation parts page with ‹ › at the card's outer edges — whole
140
+ // Multi-animation views page with ‹ › at the card's outer edges — whole
132
141
  // animations only, never chapters (chapters are the bubble + PageUp/Down).
133
- const paged = animations.length > 1;
134
- const prevAnimBtn = paged ? btn("pf-anim-page", "", "Previous animation") : null;
135
- const nextAnimBtn = paged ? btn("pf-anim-page", "›", "Next animation") : null;
136
- if (prevAnimBtn) bar.append(prevAnimBtn);
137
- bar.append(paged ? pick : title);
142
+ const prevAnimBtn = btn("pf-anim-page", "‹", "Previous animation");
143
+ const nextAnimBtn = btn("pf-anim-page", "", "Next animation");
144
+ bar.append(prevAnimBtn, pick, title);
138
145
  const infoSlot = el("span", "pf-anim-info");
139
146
  const playBtn = btn("pf-anim-play", "▶", "Play animation");
140
147
  const scrubWrap = el("span", "pf-anim-scrub-wrap");
@@ -145,8 +152,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
145
152
  scrub.setAttribute("aria-label", "Animation position");
146
153
  scrubWrap.append(scrub);
147
154
  const resetBtn = btn("pf-anim-reset", "↺", "Reset animation");
148
- bar.append(infoSlot, playBtn, scrubWrap, resetBtn);
149
- if (nextAnimBtn) bar.append(nextAnimBtn);
155
+ bar.append(infoSlot, playBtn, scrubWrap, resetBtn, nextAnimBtn);
150
156
  container.append(bar);
151
157
  // Chapter bubble: floats above the scrubber naming the chapter under the
152
158
  // pointer (hover) or playhead (scrub).
@@ -188,7 +194,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
188
194
  let bubbleLabel = null;
189
195
  let bubbleWidth = 0;
190
196
  function showChapterBubble(fraction, { transient = false } = {}) {
191
- if (current.steps.length <= 1) return;
197
+ if (!current || current.steps.length <= 1) return;
192
198
  const f = Math.min(1, Math.max(0, fraction));
193
199
  const label = current.steps[stepIndexAt(current, f)].label;
194
200
  if (label !== bubbleLabel) {
@@ -224,7 +230,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
224
230
  chapterBubble.classList.remove("pf-show");
225
231
  }
226
232
  const onWrapPointerMove = (e) => {
227
- if (current.steps.length <= 1) return; // no chapters: no bubble, and no layout read
233
+ if (!current || current.steps.length <= 1) return; // no chapters: no bubble, and no layout read
228
234
  const rect = scrubWrap.getBoundingClientRect();
229
235
  if (!rect.width) return;
230
236
  hoverInside = true;
@@ -243,10 +249,25 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
243
249
  scrubWrap.addEventListener("pointerleave", onWrapPointerLeave);
244
250
  scrubWrap.addEventListener("pointercancel", onWrapPointerLeave);
245
251
 
246
- // Per-animation chrome: title, description, pager labels, scrubber ticks.
252
+ // Per-view + per-animation chrome: which chooser shows, the picker's options,
253
+ // title, ⓘ description, pager labels, scrubber ticks. A view with no
254
+ // animations hides the whole bar rather than showing an empty transport.
247
255
  function syncStructure() {
248
- title.textContent = current.label;
256
+ bar.style.display = current ? "" : "none";
249
257
  hideChapterBubble();
258
+ if (!current) return;
259
+ const paged = animations.length > 1;
260
+ pick.style.display = paged ? "" : "none";
261
+ title.style.display = paged ? "none" : "";
262
+ prevAnimBtn.style.display = paged ? "" : "none";
263
+ nextAnimBtn.style.display = paged ? "" : "none";
264
+ pick.replaceChildren(...animations.map((a) => {
265
+ const o = document.createElement("option");
266
+ o.value = a.name; o.textContent = a.label;
267
+ return o;
268
+ }));
269
+ pick.value = current.name;
270
+ title.textContent = current.label;
250
271
  if (paged) {
251
272
  // Name the destination. Activating a pager keeps focus on it and leaves
252
273
  // its glyph unchanged, so without this a screen reader re-announces the
@@ -321,6 +342,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
321
342
  }
322
343
 
323
344
  function syncUi() {
345
+ if (!current) return; // a view with no animations has nothing to draw
324
346
  const { status, t, stepIndex } = playback.state();
325
347
  const playing = status === "playing" || status === "intro";
326
348
  scrub.value = String(Math.round(t * SCRUB_STEPS));
@@ -350,12 +372,20 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
350
372
  console.warn("partforge: animation frame failed", err);
351
373
  }
352
374
  function apply(r) {
353
- if (!r) return;
375
+ if (!r || !current) return;
354
376
  try {
355
- // First write for this run: remember what the user's params were, so Reset
356
- // can put them back.
357
- if (snapshot == null && Object.keys(r.values).length) snapshot = getParamValues(current.trackedKeys);
358
- applyValues(r.values);
377
+ // Params go through applyValues (the slider path) only when the animation
378
+ // actually drives some: an opacity-only frame must neither snapshot nor
379
+ // write params, or it would dirty the regen loop for a display-only fade.
380
+ // First write for this run also remembers what the user's params were, so
381
+ // Reset can put them back.
382
+ if (Object.keys(r.values).length) {
383
+ if (snapshot == null) snapshot = getParamValues(current.trackedKeys);
384
+ applyValues(r.values);
385
+ }
386
+ // Opacity is display-only and lives entirely in the viewer — no param, no
387
+ // rebuild, nothing to snapshot; doReset drops the overrides wholesale.
388
+ for (const [n, v] of Object.entries(r.opacity ?? {})) viewer.setSubPartOpacity?.(n, v);
359
389
  if (r.cue) {
360
390
  viewer.tweenCameraTo(r.cue.view, {
361
391
  duration: tweenDuration,
@@ -376,12 +406,14 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
376
406
  // callback returns — a throw escaping from there stops the rAF chain and
377
407
  // freezes the viewer permanently instead of costing one frame.
378
408
  function guarded(produce) {
409
+ if (!playback) return; // active view has no animations: nothing to drive
379
410
  try { apply(produce()); } catch (err) { warnFrameFailure(err); }
380
411
  }
381
412
 
382
413
  function doReset() {
383
- playback.reset();
414
+ playback?.reset();
384
415
  viewer.cancelCameraTween();
416
+ viewer.clearSubPartOpacities?.();
385
417
  if (snapshot) { applyValues(snapshot); snapshot = null; }
386
418
  syncUi();
387
419
  }
@@ -392,7 +424,6 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
392
424
  doReset();
393
425
  current = next;
394
426
  playback = createPlayback(current);
395
- if (animations.length > 1) pick.value = name;
396
427
  syncStructure();
397
428
  invalidateUi();
398
429
  syncUi();
@@ -404,11 +435,13 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
404
435
  // gating playback, settle the gate — cancel() never fires onComplete, so
405
436
  // without this the machine would sit in "intro" forever.
406
437
  const offOrbit = viewer.onCameraStart(() => {
438
+ if (!playback) return;
407
439
  playback.disarmCues();
408
440
  if (playback.state().status === "intro") guarded(() => playback.introDone());
409
441
  });
410
442
 
411
443
  const onPlayClick = () => {
444
+ if (!current) return;
412
445
  disarmAutoplay();
413
446
  const active = playback.state().status;
414
447
  if (active === "playing" || active === "intro") {
@@ -419,6 +452,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
419
452
  }
420
453
  };
421
454
  const onScrub = () => {
455
+ if (!current) return;
422
456
  disarmAutoplay();
423
457
  const f = Number(scrub.value) / SCRUB_STEPS;
424
458
  showChapterBubble(f, { transient: true });
@@ -441,7 +475,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
441
475
  // "inside" the chapter, and PageDown would restart it forever instead of
442
476
  // walking back.
443
477
  const onScrubKeydown = (e) => {
444
- if (current.steps.length <= 1) return;
478
+ if (!current || current.steps.length <= 1) return;
445
479
  if (e.key !== "PageUp" && e.key !== "PageDown") return;
446
480
  e.preventDefault();
447
481
  disarmAutoplay();
@@ -460,6 +494,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
460
494
  guarded(() => playback.seek(target));
461
495
  };
462
496
  const cycleAnimation = (dir) => {
497
+ if (!current) return;
463
498
  disarmAutoplay();
464
499
  const i = animations.indexOf(current);
465
500
  selectAnimation(animations[(i + dir + animations.length) % animations.length].name);
@@ -468,8 +503,8 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
468
503
  const onNextAnim = () => cycleAnimation(1);
469
504
  const onPick = () => { disarmAutoplay(); selectAnimation(pick.value); };
470
505
  const onResetClick = () => { disarmAutoplay(); doReset(); };
471
- prevAnimBtn?.addEventListener("click", onPrevAnim);
472
- nextAnimBtn?.addEventListener("click", onNextAnim);
506
+ prevAnimBtn.addEventListener("click", onPrevAnim);
507
+ nextAnimBtn.addEventListener("click", onNextAnim);
473
508
  playBtn.addEventListener("click", onPlayClick);
474
509
  scrub.addEventListener("input", onScrub);
475
510
  scrub.addEventListener("keydown", onScrubKeydown);
@@ -551,7 +586,8 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
551
586
  const runtime = {
552
587
  // An unknown name is a host bug, not a request to play whatever happens to
553
588
  // be selected — say so and do nothing rather than silently animating
554
- // something else.
589
+ // something else. "Unknown" is judged against the ACTIVE view's set: an
590
+ // animation that exists in another view is not playable from here.
555
591
  play(name) {
556
592
  disarmAutoplay();
557
593
  if (name != null && !animations.some((a) => a.name === name)) {
@@ -564,7 +600,11 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
564
600
  pause() { disarmAutoplay(); viewer.cancelCameraTween(); guarded(() => playback.pause()); },
565
601
  seek(t) { disarmAutoplay(); guarded(() => playback.seek(t)); },
566
602
  stop() { disarmAutoplay(); doReset(); },
567
- state: () => ({ animation: current.name, ...playback.state() }),
603
+ state: () => ({
604
+ view: getView(),
605
+ animation: current?.name ?? null,
606
+ ...(playback ? playback.state() : { status: "idle", t: 0, stepIndex: 0 }),
607
+ }),
568
608
  };
569
609
 
570
610
  const handle = {
@@ -574,21 +614,41 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
574
614
  notifyUserEdit() {
575
615
  disarmAutoplay();
576
616
  viewer.cancelCameraTween();
577
- playback.userEdited();
617
+ playback?.userEdited();
578
618
  syncUi();
579
619
  },
580
- // Mount calls this on first ready and on every view/tab switch.
620
+ // Mount calls this from the view-tab onChange, BEFORE it refreshes the
621
+ // view: the outgoing animation's params and opacity overrides must be
622
+ // restored before the incoming view composes its assembly.
623
+ viewChanged() {
624
+ doReset();
625
+ animations = animsFor(getView());
626
+ current = animations[0] ?? null;
627
+ playback = current ? createPlayback(current) : null;
628
+ syncStructure();
629
+ invalidateUi();
630
+ if (current) syncUi();
631
+ },
632
+ // Mount calls this on first ready and on every view/tab switch — it plays
633
+ // the ACTIVE view's autoplay animation, if that view declares one.
581
634
  autoplayKick() {
582
- if (!autoplayArmed || !autoplayAnim) return;
583
- if (current !== autoplayAnim) selectAnimation(autoplayAnim.name);
635
+ if (!autoplayArmed) return;
636
+ const target = autoplayFor(getView());
637
+ if (!target) return;
638
+ // selectAnimation resolves within the ACTIVE view's set, so a kick that
639
+ // arrives before viewChanged has re-pointed it finds nothing to select —
640
+ // degrade to a no-op rather than driving the outgoing view's playback.
641
+ if (current !== target) selectAnimation(target.name);
642
+ if (current !== target || !playback) return;
584
643
  const { status } = playback.state();
585
644
  if (status !== "playing" && status !== "intro") guarded(() => playback.play());
586
645
  },
587
646
  detach() {
588
647
  offFrame();
589
648
  offOrbit();
590
- prevAnimBtn?.removeEventListener("click", onPrevAnim);
591
- nextAnimBtn?.removeEventListener("click", onNextAnim);
649
+ viewer.clearSubPartOpacities?.();
650
+ prevAnimBtn.removeEventListener("click", onPrevAnim);
651
+ nextAnimBtn.removeEventListener("click", onNextAnim);
592
652
  playBtn.removeEventListener("click", onPlayClick);
593
653
  scrub.removeEventListener("input", onScrub);
594
654
  scrub.removeEventListener("keydown", onScrubKeydown);