partforge 0.48.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 +8 -6
- package/bin/cli.js +38 -11
- package/docs/AUTHORING-PARTS.md +102 -36
- package/package.json +1 -1
- package/src/framework/animation-controls.js +108 -48
- package/src/framework/animation.js +47 -18
- package/src/framework/app.css +89 -21
- package/src/framework/controls.js +1 -1
- package/src/framework/cutaway.js +14 -0
- package/src/framework/lint/rules-animations.js +177 -92
- package/src/framework/mount.js +36 -11
- package/src/framework/panel/info.js +10 -1
- package/src/framework/panel/render.js +39 -13
- package/src/framework/panel/widgets/checkbox.js +2 -1
- package/src/framework/panel/widgets/numeric.js +3 -1
- package/src/framework/panel/widgets/select.js +4 -2
- package/src/framework/panel/widgets/text.js +2 -1
- package/src/framework/viewer.js +155 -9
- package/src/parts/hinged-box.js +43 -27
- package/src/testing/render.js +43 -7
- package/types/index.d.ts +17 -7
- package/types/part.d.ts +38 -12
- package/types/testing.d.ts +8 -0
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
|
-
|
|
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
|
|
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 {
|
|
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 (
|
|
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,
|
|
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
|
}
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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? } }, //
|
|
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
|
|
135
|
-
params** over time
|
|
136
|
-
|
|
137
|
-
`
|
|
138
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
label: "
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
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
|
|
1529
|
-
`build`: `
|
|
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
|
@@ -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
|
|
7
|
-
// (valid)
|
|
8
|
-
|
|
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
|
|
99
|
-
try {
|
|
100
|
-
if (!
|
|
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
|
-
|
|
107
|
-
|
|
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)
|
|
111
|
-
//
|
|
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
|
|
115
|
-
let autoplayArmed =
|
|
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
|
|
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
|
|
134
|
-
const
|
|
135
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
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
|
|
472
|
-
nextAnimBtn
|
|
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: () => ({
|
|
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
|
|
617
|
+
playback?.userEdited();
|
|
578
618
|
syncUi();
|
|
579
619
|
},
|
|
580
|
-
// Mount calls this
|
|
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
|
|
583
|
-
|
|
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
|
-
|
|
591
|
-
|
|
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);
|