partforge 0.44.0 → 0.45.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/bin/cli.js +43 -5
- package/docs/AUTHORING-PARTS.md +38 -3
- package/package.json +2 -2
- package/src/framework/animation-controls.js +27 -16
- package/src/framework/animation.js +67 -13
- package/src/framework/capture-build.js +59 -0
- package/src/framework/jobs.js +21 -0
- package/src/framework/lint/rules-animations.js +54 -17
- package/src/framework/lint/rules-schema.js +22 -0
- package/src/framework/mount.js +57 -5
- package/src/framework/view-tabs.js +13 -0
- package/src/framework/viewer-lighting.js +8 -1
- package/src/framework/viewer.js +82 -4
- package/src/framework/worker.js +5 -1
- package/types/index.d.ts +23 -4
- package/types/part.d.ts +44 -16
package/bin/cli.js
CHANGED
|
@@ -159,15 +159,35 @@ const commands = {
|
|
|
159
159
|
try {
|
|
160
160
|
const part = await loadPart(partPath, usage);
|
|
161
161
|
const baseParams = flags.params ? JSON.parse(flags.params) : {};
|
|
162
|
+
// `--params '[1,2]'` or '42' parses fine and then merges into nothing, so the
|
|
163
|
+
// flag silently does nothing. Only an object can carry param overrides.
|
|
164
|
+
if (baseParams === null || typeof baseParams !== "object" || Array.isArray(baseParams)) {
|
|
165
|
+
die(`--params takes a JSON object of param overrides\n${usage}`);
|
|
166
|
+
}
|
|
162
167
|
const outDir = flags.out || "render";
|
|
163
168
|
const views = flags.views ? flags.views.split(",") : undefined;
|
|
164
|
-
// Usage
|
|
165
|
-
|
|
169
|
+
// Usage checks BEFORE the kernel: a flag typo shouldn't pay a WASM boot.
|
|
170
|
+
// Test `=== undefined`, not falsiness: `--animation ""` is a flag the user
|
|
171
|
+
// passed and got wrong (an unset shell variable, typically), not one they
|
|
172
|
+
// omitted, and silently rendering a non-animation still hides the mistake.
|
|
173
|
+
if (flags.animation !== undefined && flags.animation.trim() === "") {
|
|
174
|
+
die(`--animation needs an animation name\n${usage}`);
|
|
175
|
+
}
|
|
176
|
+
if (flags.animation === undefined && (flags.at || flags.step)) {
|
|
166
177
|
die(`--at/--step require --animation\n${usage}`);
|
|
167
178
|
}
|
|
179
|
+
if (flags.at != null && flags.step != null) {
|
|
180
|
+
die(`--at and --step are alternatives — pass one, not both\n${usage}`);
|
|
181
|
+
}
|
|
182
|
+
// Own-key test: `part.views?.["constructor"]` resolves through
|
|
183
|
+
// Object.prototype and would sail past a plain lookup, straight back into
|
|
184
|
+
// the background-only render this guard exists to stop.
|
|
185
|
+
if (view !== undefined && !Object.hasOwn(part.views ?? {}, view)) {
|
|
186
|
+
die(`unknown view "${view}" (have: ${Object.keys(part.views ?? {}).join(", ") || "none"})\n${usage}`);
|
|
187
|
+
}
|
|
168
188
|
const kernel = await bootKernel(part);
|
|
169
189
|
|
|
170
|
-
if (
|
|
190
|
+
if (flags.animation === undefined) {
|
|
171
191
|
const files = await renderViews(kernel, part, view, { views, out: outDir, params: baseParams });
|
|
172
192
|
for (const f of files) console.log(`wrote ${f}`);
|
|
173
193
|
process.exit(0);
|
|
@@ -196,11 +216,29 @@ const commands = {
|
|
|
196
216
|
// step's camera instead of this step's own.
|
|
197
217
|
frames = [{ t: end, cueT: anim.stepStarts[idx], tag: `${flags.animation}-step${idx + 1}` }];
|
|
198
218
|
} else {
|
|
199
|
-
|
|
219
|
+
// Split first and reject blanks: Number("") is 0, so "0.2,,0.8" would
|
|
220
|
+
// otherwise slip a silent extra frame at t=0 past the range check.
|
|
221
|
+
const raw = (flags.at ?? "1").split(",");
|
|
222
|
+
const ts = raw.map((s) => (s.trim() === "" ? Number.NaN : Number(s)));
|
|
200
223
|
if (!ts.length || ts.some((t) => !Number.isFinite(t) || t < 0 || t > 1)) {
|
|
201
224
|
die(`--at takes comma-separated positions in 0..1\n${usage}`);
|
|
202
225
|
}
|
|
203
|
-
|
|
226
|
+
// The tag is the only thing distinguishing one frame's file from another.
|
|
227
|
+
// Two decimals suits the usual `--at 0,0.5,1`, but a dense request like
|
|
228
|
+
// 0.001,0.004 collides and the later render would silently overwrite the
|
|
229
|
+
// earlier — one file for two frames asked for. Widen the tag just enough
|
|
230
|
+
// for THIS request instead of refusing it: ordinary runs keep their
|
|
231
|
+
// familiar t000/t050/t100 names, dense ones get one file each.
|
|
232
|
+
const tagsAt = (decimals) =>
|
|
233
|
+
ts.map((t) => String(Math.round(t * 10 ** decimals)).padStart(decimals + 1, "0"));
|
|
234
|
+
let decimals = 2;
|
|
235
|
+
while (decimals < 6 && new Set(tagsAt(decimals)).size !== ts.length) decimals++;
|
|
236
|
+
const tags = tagsAt(decimals);
|
|
237
|
+
if (new Set(tags).size !== ts.length) {
|
|
238
|
+
// No precision separates them: the same position was listed twice.
|
|
239
|
+
die(`--at lists the same position more than once\n${usage}`);
|
|
240
|
+
}
|
|
241
|
+
frames = ts.map((t, i) => ({ t, tag: `${flags.animation}-t${tags[i]}` }));
|
|
204
242
|
}
|
|
205
243
|
for (const frame of frames) {
|
|
206
244
|
const { values } = evaluate(anim, frame.t);
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -167,6 +167,13 @@ Rules (all lint-enforced):
|
|
|
167
167
|
inside the owning control's min/max (the engine applies them unclamped).
|
|
168
168
|
- Params not tracked anywhere keep their current values; a param tracked in
|
|
169
169
|
one step holds its nearest keyframe value while other steps play.
|
|
170
|
+
- A step may declare a `camera` and **no** `tracks` — an establishing shot that
|
|
171
|
+
swings the view while the model holds still. At least one step still has to
|
|
172
|
+
carry tracks, or the animation animates nothing. Note the holding value is the
|
|
173
|
+
nearest keyframe, not whatever the user last set: a leading camera-only step
|
|
174
|
+
shows the animation's opening pose, the same one `t = 0` would show.
|
|
175
|
+
- `loop` and `autoplay` must be literal booleans. Anything else is reported by
|
|
176
|
+
lint and treated as `false` at runtime, so `loop: "false"` never means "loop".
|
|
170
177
|
- Couple motions through `derive` (animate one master param; derive the rest),
|
|
171
178
|
not by tracking dependent params separately.
|
|
172
179
|
- `camera` cues use the seven canonical angles (`iso front back top bottom
|
|
@@ -484,6 +491,12 @@ choosing a preset updates both numeric and text fields.
|
|
|
484
491
|
Every `key` used must exist in `defaults`. `src/parts/demo.js` is the worked example for
|
|
485
492
|
everything below.
|
|
486
493
|
|
|
494
|
+
A feature's `on` is **required and must be greater than 0** — it is the real value the
|
|
495
|
+
parameter takes when the box is ticked (a diameter, a count), and the panel reads
|
|
496
|
+
`> 0` as "enabled", so there is nothing sensible to fall back to. `partforge lint`
|
|
497
|
+
reports a missing or non-positive one as `features-requires-on`. A `toggles` entry is
|
|
498
|
+
the exception: its `on` is just a flag and defaults to 1.
|
|
499
|
+
|
|
487
500
|
**Standalone toggles** (a plain on/off checkbox, no accompanying sliders): add a
|
|
488
501
|
`toggles` array to a preset section — shown below the preset picker, outside the
|
|
489
502
|
Advanced fold, so it stays visible:
|
|
@@ -791,6 +804,27 @@ Copy `demo.html` and change the title, the panel heading, and the `<script src>`
|
|
|
791
804
|
workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
|
|
792
805
|
`"occt"` for STEP — handled for you).
|
|
793
806
|
|
|
807
|
+
**View control (the mount handle).** For an embedder driving the view tabs from its own UI
|
|
808
|
+
instead of (or in addition to) the built-in `#part` bar:
|
|
809
|
+
|
|
810
|
+
- `runtime.getView() → string` — the active view name; never null once the runtime is ready
|
|
811
|
+
(mount resolves a default before first build — see "Which view the viewer opens on" above).
|
|
812
|
+
- `runtime.setView(name) → boolean` — switch tabs programmatically, the same path as clicking
|
|
813
|
+
a tab. Returns `false` (and leaves the active tab untouched) for a name the part doesn't
|
|
814
|
+
declare in `views`; `true` otherwise, including when `name` is already active.
|
|
815
|
+
- `await runtime.captureView(viewName?, opts?) → Promise<string | null>` — a JPEG data URL of
|
|
816
|
+
`viewName` rendered offscreen (falling back to the resolved default view — see
|
|
817
|
+
`resolveDefaultView` / `default-view.js` — when `viewName` is omitted or names a view the
|
|
818
|
+
part doesn't declare). Never disturbs the active tab, the live camera, or the on-screen
|
|
819
|
+
scene; `opts` forwards to the underlying render (size, quality, angle). Resolves `null` on
|
|
820
|
+
failure rather than throwing (a build error, a part with no sub-parts in that view, a
|
|
821
|
+
disposed runtime).
|
|
822
|
+
|
|
823
|
+
Pass `onViewChange(name)` to `mount()` to be told the active view: it fires once
|
|
824
|
+
synchronously during mount with the initial resolved view (before `runtime.ready` settles),
|
|
825
|
+
then again on every subsequent change — a tab click or a `setView` call — always with the
|
|
826
|
+
new view name.
|
|
827
|
+
|
|
794
828
|
**Headless export (the mount handle).** The `#download*` buttons above are the built-in,
|
|
795
829
|
view-bound export UI. An embedder that wants its own export UI (e.g. a "pick which parts,
|
|
796
830
|
pick a format" modal) can skip those buttons and drive export off the handle `mount()`
|
|
@@ -1083,9 +1117,10 @@ previously didn't; that's the fix working as intended, not a regression.
|
|
|
1083
1117
|
`missing-views`, `part-view-unknown` (all errors); `view-unused`,
|
|
1084
1118
|
`default-view-ambiguous` (warnings).
|
|
1085
1119
|
|
|
1086
|
-
**Parameter schema** — `features-requires-sliders`, `
|
|
1087
|
-
`preset-key-not-in-defaults` (errors);
|
|
1088
|
-
`unknown-control-field`, `duplicate-control-key`,
|
|
1120
|
+
**Parameter schema** — `features-requires-sliders`, `features-requires-on`,
|
|
1121
|
+
`control-key-not-in-defaults`, `preset-key-not-in-defaults` (errors);
|
|
1122
|
+
`slider-range-excludes-default`, `unknown-control-field`, `duplicate-control-key`,
|
|
1123
|
+
`default-not-exposed` (warnings).
|
|
1089
1124
|
|
|
1090
1125
|
**Kernel API**, found by executing `build()` against a geometry-free probe —
|
|
1091
1126
|
`unknown-kernel-op`, `unknown-solid-op`, `invalid-op-options`, `build-throws`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.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",
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
"@fontsource-variable/geist-mono": "^5.3.0",
|
|
101
101
|
"happy-dom": "^20.10.6",
|
|
102
102
|
"playwright": "^1.49.0",
|
|
103
|
-
"typescript": "^
|
|
103
|
+
"typescript": "^5.9.3",
|
|
104
104
|
"vite": "^8.0.16",
|
|
105
105
|
"vitest": "^4.1.9"
|
|
106
106
|
}
|
|
@@ -112,6 +112,11 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
112
112
|
// propagate would take the other listeners down with it. Warn once, then
|
|
113
113
|
// stay quiet so a bad frame can't flood the console 60x a second.
|
|
114
114
|
let frameFailureWarned = false;
|
|
115
|
+
function warnFrameFailure(err) {
|
|
116
|
+
if (frameFailureWarned) return;
|
|
117
|
+
frameFailureWarned = true;
|
|
118
|
+
console.warn("partforge: animation frame failed", err);
|
|
119
|
+
}
|
|
115
120
|
function apply(r) {
|
|
116
121
|
if (!r) return;
|
|
117
122
|
try {
|
|
@@ -124,18 +129,24 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
124
129
|
duration: tweenDuration,
|
|
125
130
|
// An intro cue gates playback until the tween settles; mid-timeline
|
|
126
131
|
// cues overlap playback and need no completion signal.
|
|
127
|
-
onComplete: r.status === "intro" ? () =>
|
|
132
|
+
onComplete: r.status === "intro" ? () => guarded(() => playback.introDone()) : undefined,
|
|
128
133
|
});
|
|
129
134
|
}
|
|
130
135
|
syncUi();
|
|
131
136
|
} catch (err) {
|
|
132
|
-
|
|
133
|
-
frameFailureWarned = true;
|
|
134
|
-
console.warn("partforge: animation frame failed", err);
|
|
135
|
-
}
|
|
137
|
+
warnFrameFailure(err);
|
|
136
138
|
}
|
|
137
139
|
}
|
|
138
140
|
|
|
141
|
+
// Every transport entry point goes through here so the STATE-MACHINE call is
|
|
142
|
+
// inside the guard too, not just apply(). playback.tick() is evaluated in the
|
|
143
|
+
// render loop, and three re-arms requestAnimationFrame only after the frame
|
|
144
|
+
// callback returns — a throw escaping from there stops the rAF chain and
|
|
145
|
+
// freezes the viewer permanently instead of costing one frame.
|
|
146
|
+
function guarded(produce) {
|
|
147
|
+
try { apply(produce()); } catch (err) { warnFrameFailure(err); }
|
|
148
|
+
}
|
|
149
|
+
|
|
139
150
|
function doReset() {
|
|
140
151
|
playback.reset();
|
|
141
152
|
viewer.cancelCameraTween();
|
|
@@ -154,14 +165,14 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
154
165
|
syncUi();
|
|
155
166
|
}
|
|
156
167
|
|
|
157
|
-
const offFrame = viewer.onFrame((dt) =>
|
|
168
|
+
const offFrame = viewer.onFrame((dt) => guarded(() => playback.tick(dt)));
|
|
158
169
|
// User orbit: the viewer has already cancelled any cue tween (its own
|
|
159
170
|
// "start" handler); disarm the remaining cues, and if an intro tween was
|
|
160
171
|
// gating playback, settle the gate — cancel() never fires onComplete, so
|
|
161
172
|
// without this the machine would sit in "intro" forever.
|
|
162
173
|
const offOrbit = viewer.onCameraStart(() => {
|
|
163
174
|
playback.disarmCues();
|
|
164
|
-
if (playback.state().status === "intro")
|
|
175
|
+
if (playback.state().status === "intro") guarded(() => playback.introDone());
|
|
165
176
|
});
|
|
166
177
|
|
|
167
178
|
const onPlayClick = () => {
|
|
@@ -169,14 +180,14 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
169
180
|
const active = playback.state().status;
|
|
170
181
|
if (active === "playing" || active === "intro") {
|
|
171
182
|
viewer.cancelCameraTween();
|
|
172
|
-
|
|
183
|
+
guarded(() => playback.pause());
|
|
173
184
|
} else {
|
|
174
|
-
|
|
185
|
+
guarded(() => playback.play());
|
|
175
186
|
}
|
|
176
187
|
};
|
|
177
|
-
const onScrub = () => { disarmAutoplay();
|
|
178
|
-
const onPrev = () => { disarmAutoplay();
|
|
179
|
-
const onNext = () => { disarmAutoplay();
|
|
188
|
+
const onScrub = () => { disarmAutoplay(); guarded(() => playback.seek(Number(scrub.value) / 1000)); };
|
|
189
|
+
const onPrev = () => { disarmAutoplay(); guarded(() => playback.stepPrev()); };
|
|
190
|
+
const onNext = () => { disarmAutoplay(); guarded(() => playback.stepNext()); };
|
|
180
191
|
const onPick = () => { disarmAutoplay(); selectAnimation(pick.value); };
|
|
181
192
|
const onResetClick = () => { disarmAutoplay(); doReset(); };
|
|
182
193
|
playBtn.addEventListener("click", onPlayClick);
|
|
@@ -200,10 +211,10 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
200
211
|
return;
|
|
201
212
|
}
|
|
202
213
|
if (name) selectAnimation(name);
|
|
203
|
-
|
|
214
|
+
guarded(() => playback.play());
|
|
204
215
|
},
|
|
205
|
-
pause() { disarmAutoplay(); viewer.cancelCameraTween();
|
|
206
|
-
seek(t) { disarmAutoplay();
|
|
216
|
+
pause() { disarmAutoplay(); viewer.cancelCameraTween(); guarded(() => playback.pause()); },
|
|
217
|
+
seek(t) { disarmAutoplay(); guarded(() => playback.seek(t)); },
|
|
207
218
|
stop() { disarmAutoplay(); doReset(); },
|
|
208
219
|
state: () => ({ animation: current.name, ...playback.state() }),
|
|
209
220
|
};
|
|
@@ -223,7 +234,7 @@ export function attachAnimationControls(viewer, part, { container, applyValues,
|
|
|
223
234
|
if (!autoplayArmed || !autoplayAnim) return;
|
|
224
235
|
if (current !== autoplayAnim) selectAnimation(autoplayAnim.name);
|
|
225
236
|
const { status } = playback.state();
|
|
226
|
-
if (status !== "playing" && status !== "intro")
|
|
237
|
+
if (status !== "playing" && status !== "intro") guarded(() => playback.play());
|
|
227
238
|
},
|
|
228
239
|
detach() {
|
|
229
240
|
offFrame();
|
|
@@ -13,6 +13,32 @@ export const EASINGS = {
|
|
|
13
13
|
};
|
|
14
14
|
export const DEFAULT_EASING = "ease-in-out";
|
|
15
15
|
|
|
16
|
+
// Look easings up by OWN key only. `EASINGS[name]` / `name in EASINGS` would walk
|
|
17
|
+
// the prototype chain, so "toString" resolves to a function that silently returns
|
|
18
|
+
// garbage and "__proto__" resolves to a non-function that throws — mid-frame, from
|
|
19
|
+
// inside the render loop. Lint applies the same test, so an unknown easing is an
|
|
20
|
+
// authoring error there and a quiet fall back to the default here.
|
|
21
|
+
export const easingFor = (name) =>
|
|
22
|
+
(Object.hasOwn(EASINGS, name) ? EASINGS[name] : EASINGS[DEFAULT_EASING]);
|
|
23
|
+
|
|
24
|
+
// A track value has to be a non-empty keyframe array to be evaluable. Lint reports
|
|
25
|
+
// anything else as `animation-keyframes-invalid`; this predicate is what keeps the
|
|
26
|
+
// runtime total when a part reaches it unlinted, and it must stay the single rule
|
|
27
|
+
// both segmentsFor and trackedKeys agree on — if they disagree, evaluate() is asked
|
|
28
|
+
// for a key that has no segment and throws.
|
|
29
|
+
const usableKeyframes = (kf) => Array.isArray(kf) && kf.length > 0;
|
|
30
|
+
|
|
31
|
+
// t is clamped to [0,1]. Only an unorderable t (NaN, or anything that coerces to
|
|
32
|
+
// it, such as a host calling seek() with no argument) folds to 0 — ±Infinity is
|
|
33
|
+
// ordered and clamps normally. NaN has to be caught rather than clamped because
|
|
34
|
+
// it fails every comparison: `Math.min(1, Math.max(0, NaN))` is still NaN, and an
|
|
35
|
+
// unclamped NaN leaves `t >= 1` permanently false, so playback could never reach
|
|
36
|
+
// `done` and every later cue test would silently fail.
|
|
37
|
+
const clampT = (t) => {
|
|
38
|
+
const n = Number(t);
|
|
39
|
+
return Number.isNaN(n) ? 0 : Math.min(1, Math.max(0, n));
|
|
40
|
+
};
|
|
41
|
+
|
|
16
42
|
// Normalize one animations-map entry to the canonical shape every consumer
|
|
17
43
|
// (playback, transport UI, lint, CLI) works against: a step list (a bare
|
|
18
44
|
// `tracks` form becomes one anonymous step), normalized step starts, and the
|
|
@@ -39,10 +65,18 @@ export function normalizeAnimation(name, spec) {
|
|
|
39
65
|
if (typeof spec.camera === "string") cues = [{ t: 0, view: spec.camera }];
|
|
40
66
|
else if (Array.isArray(spec.camera)) cues = spec.camera.map(([t, view]) => ({ t, view }));
|
|
41
67
|
else cues = steps.flatMap((s, i) => (s.camera ? [{ t: stepStarts[i], view: s.camera }] : []));
|
|
42
|
-
const trackedKeys = [...new Set(steps.flatMap((s) =>
|
|
68
|
+
const trackedKeys = [...new Set(steps.flatMap((s) =>
|
|
69
|
+
Object.entries(s.tracks).filter(([, kf]) => usableKeyframes(kf)).map(([key]) => key)))];
|
|
43
70
|
return {
|
|
44
71
|
name, label: spec.label ?? name, description: spec.description ?? null,
|
|
45
|
-
|
|
72
|
+
// Fail CLOSED on both flags: only a literal `true` turns them on. Coercing
|
|
73
|
+
// with `!!` reads `loop: "false"` as "loop forever", which is the worst
|
|
74
|
+
// available reading of that typo, and nothing downstream would catch it —
|
|
75
|
+
// lint reports non-booleans, but a part can mount in a browser without ever
|
|
76
|
+
// having been linted. An invalid flag therefore does the quiet thing here and
|
|
77
|
+
// is reported there.
|
|
78
|
+
loop: spec.loop === true, autoplay: spec.autoplay === true,
|
|
79
|
+
steps, stepStarts, totalDuration, cues, trackedKeys,
|
|
46
80
|
};
|
|
47
81
|
}
|
|
48
82
|
|
|
@@ -52,7 +86,7 @@ export function normalizeAnimations(part) {
|
|
|
52
86
|
|
|
53
87
|
// Step containing t. Boundaries belong to the LATER step, and t clamps to [0,1].
|
|
54
88
|
export function stepIndexAt(anim, t) {
|
|
55
|
-
const tc =
|
|
89
|
+
const tc = clampT(t);
|
|
56
90
|
let idx = 0;
|
|
57
91
|
for (let i = 0; i < anim.stepStarts.length; i++) if (tc >= anim.stepStarts[i]) idx = i;
|
|
58
92
|
return idx;
|
|
@@ -71,7 +105,7 @@ function segmentsFor(anim, key) {
|
|
|
71
105
|
const out = [];
|
|
72
106
|
anim.steps.forEach((step, i) => {
|
|
73
107
|
const kf = step.tracks[key];
|
|
74
|
-
if (!kf) return;
|
|
108
|
+
if (!usableKeyframes(kf)) return;
|
|
75
109
|
const start = anim.stepStarts[i];
|
|
76
110
|
const end = i + 1 < anim.steps.length ? anim.stepStarts[i + 1] : 1;
|
|
77
111
|
out.push({ start, end, keyframes: kf, easing: step.easing });
|
|
@@ -94,12 +128,16 @@ function interpKeyframes(kf, u) {
|
|
|
94
128
|
|
|
95
129
|
function evaluateTrack(anim, key, t) {
|
|
96
130
|
const segs = segmentsFor(anim, key);
|
|
131
|
+
// trackedKeys and segmentsFor share usableKeyframes, so a tracked key always
|
|
132
|
+
// has a segment. Guard anyway: this runs inside the render loop, where a throw
|
|
133
|
+
// costs the whole viewer, not just the frame.
|
|
134
|
+
if (!segs.length) return undefined;
|
|
97
135
|
let prev = null;
|
|
98
136
|
for (const seg of segs) {
|
|
99
137
|
if (t < seg.start) break;
|
|
100
138
|
if (t <= seg.end) {
|
|
101
139
|
const span = seg.end - seg.start || 1;
|
|
102
|
-
const local = (
|
|
140
|
+
const local = easingFor(seg.easing)((t - seg.start) / span);
|
|
103
141
|
return interpKeyframes(seg.keyframes, local);
|
|
104
142
|
}
|
|
105
143
|
prev = seg;
|
|
@@ -112,7 +150,7 @@ function evaluateTrack(anim, key, t) {
|
|
|
112
150
|
// Evaluate the whole animation at normalized position t ∈ [0,1] (over the
|
|
113
151
|
// TOTAL duration — the same t the scrubber, seek(t), and the CLI's --at use).
|
|
114
152
|
export function evaluate(anim, t) {
|
|
115
|
-
const tc =
|
|
153
|
+
const tc = clampT(t);
|
|
116
154
|
const values = {};
|
|
117
155
|
for (const key of anim.trackedKeys) values[key] = evaluateTrack(anim, key, tc);
|
|
118
156
|
return { stepIndex: stepIndexAt(anim, tc), values };
|
|
@@ -131,6 +169,7 @@ export function createPlayback(anim) {
|
|
|
131
169
|
let t = 0;
|
|
132
170
|
let armed = true; // user orbit disarms cues until reset/replay
|
|
133
171
|
let firedCueT = -1; // cues with t <= firedCueT already fired this run
|
|
172
|
+
let pendingCueT = null; // cue handed to an in-flight intro tween, not yet settled
|
|
134
173
|
let stopAt = null; // stepNext/playStep pause playback on reaching this t
|
|
135
174
|
|
|
136
175
|
const snapshot = (cue = null) => ({ t, status, ...evaluate(anim, t), cue });
|
|
@@ -144,7 +183,11 @@ export function createPlayback(anim) {
|
|
|
144
183
|
|
|
145
184
|
function begin() {
|
|
146
185
|
const cue = governingCue();
|
|
147
|
-
|
|
186
|
+
// The cue is NOT counted as fired yet — only introDone() retires it. Pausing
|
|
187
|
+
// mid-intro cancels the tween and drops its completion callback, so a cue
|
|
188
|
+
// retired here would never be re-issued on resume and the camera would stay
|
|
189
|
+
// stranded wherever the cancelled sweep left it.
|
|
190
|
+
if (cue) { pendingCueT = cue.t; status = "intro"; }
|
|
148
191
|
else status = "playing";
|
|
149
192
|
return snapshot(cue);
|
|
150
193
|
}
|
|
@@ -157,17 +200,23 @@ export function createPlayback(anim) {
|
|
|
157
200
|
}
|
|
158
201
|
function pause() {
|
|
159
202
|
if (status === "playing" || status === "intro") status = "paused";
|
|
203
|
+
pendingCueT = null; // an unsettled intro cue is abandoned, so resume re-issues it
|
|
160
204
|
return snapshot();
|
|
161
205
|
}
|
|
162
206
|
function introDone() {
|
|
163
|
-
if (status === "intro")
|
|
207
|
+
if (status === "intro") {
|
|
208
|
+
if (pendingCueT != null) firedCueT = Math.max(firedCueT, pendingCueT);
|
|
209
|
+
status = "playing";
|
|
210
|
+
}
|
|
211
|
+
pendingCueT = null;
|
|
164
212
|
return snapshot();
|
|
165
213
|
}
|
|
166
214
|
function seek(v) {
|
|
167
|
-
t =
|
|
215
|
+
t = clampT(v);
|
|
168
216
|
status = "paused";
|
|
169
217
|
stopAt = null;
|
|
170
218
|
firedCueT = -1; // a later play() re-honors the cue governing the new position
|
|
219
|
+
pendingCueT = null;
|
|
171
220
|
return snapshot();
|
|
172
221
|
}
|
|
173
222
|
function playStep(i) {
|
|
@@ -175,6 +224,7 @@ export function createPlayback(anim) {
|
|
|
175
224
|
t = anim.stepStarts[idx];
|
|
176
225
|
stopAt = idx + 1 < anim.steps.length ? anim.stepStarts[idx + 1] : 1;
|
|
177
226
|
firedCueT = -1;
|
|
227
|
+
pendingCueT = null;
|
|
178
228
|
return begin();
|
|
179
229
|
}
|
|
180
230
|
function stepNext() {
|
|
@@ -185,7 +235,7 @@ export function createPlayback(anim) {
|
|
|
185
235
|
return playStep(Math.max(0, stepIndexAt(anim, t) - 1));
|
|
186
236
|
}
|
|
187
237
|
function reset() {
|
|
188
|
-
t = 0; status = "idle"; stopAt = null; firedCueT = -1; armed = true;
|
|
238
|
+
t = 0; status = "idle"; stopAt = null; firedCueT = -1; pendingCueT = null; armed = true;
|
|
189
239
|
return snapshot();
|
|
190
240
|
}
|
|
191
241
|
function disarmCues() { armed = false; }
|
|
@@ -194,10 +244,14 @@ export function createPlayback(anim) {
|
|
|
194
244
|
function tick(dt) {
|
|
195
245
|
if (status !== "playing" || !(dt > 0)) return null;
|
|
196
246
|
t += dt / anim.totalDuration;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
247
|
+
// A pending step boundary outranks looping. Lint rejects loop on a stepped
|
|
248
|
+
// animation, so the two rarely coexist — but when they do, an explicit
|
|
249
|
+
// "play this step" must still stop where it was told to, rather than being
|
|
250
|
+
// swallowed by the wrap and running forever.
|
|
251
|
+
if (stopAt != null && t >= stopAt) {
|
|
200
252
|
t = stopAt; stopAt = null; status = "paused";
|
|
253
|
+
} else if (anim.loop) {
|
|
254
|
+
if (t >= 1) t -= Math.floor(t);
|
|
201
255
|
} else if (t >= 1) {
|
|
202
256
|
t = 1; status = "done";
|
|
203
257
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Correlated one-shot geometry builds for captureView — a private channel that
|
|
2
|
+
// does NOT go through the regen loop. Same shape as export-controller's pending
|
|
3
|
+
// Map (export-controller.js): allocate a jobId, resolve when the matching
|
|
4
|
+
// reply arrives. Pure — no DOM, no worker; `send` is injected.
|
|
5
|
+
export function createCaptureBuild({ send }) {
|
|
6
|
+
let nextId = 1;
|
|
7
|
+
let disposed = false;
|
|
8
|
+
const pending = new Map(); // jobId -> resolve
|
|
9
|
+
|
|
10
|
+
function request({ subparts, view, params, backend }) {
|
|
11
|
+
// After teardown the workers are gone, so a fresh send would post to a terminated
|
|
12
|
+
// worker (a silent no-op) and its promise would hang forever. Resolve null instead
|
|
13
|
+
// — captureView's documented "disposed runtime resolves null" contract.
|
|
14
|
+
if (disposed) return Promise.resolve(null);
|
|
15
|
+
// String-namespaced ("cap-N") so a capture jobId can never collide with
|
|
16
|
+
// export-controller's numeric jobIds — both share the same worker message
|
|
17
|
+
// space, and exportCtl.handleMessage does a raw pending.get(m.jobId) before
|
|
18
|
+
// checking type, so a colliding id could otherwise settle the wrong promise.
|
|
19
|
+
const jobId = `cap-${nextId++}`;
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
pending.set(jobId, resolve);
|
|
22
|
+
// cache:true so the worker reuses its per-sub-part geometry memo (the
|
|
23
|
+
// expensive CSG); only the per-view place() + meshing re-run.
|
|
24
|
+
send({ type: "capture-generate", jobId, subparts, view, params, cache: true }, backend);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Returns true iff this message was a reply this controller owns (so the
|
|
29
|
+
// caller — mount.js's onWorkerMessage — can skip it entirely). Keyed on
|
|
30
|
+
// membership in `pending` first: the namespaced jobId guarantees another
|
|
31
|
+
// channel's message never matches, so a hit here is always ours. A failed
|
|
32
|
+
// build (the worker's shared catch posts a generic error/needs-occt, jobId
|
|
33
|
+
// intact) resolves to null rather than leaving the caller hanging forever —
|
|
34
|
+
// captureView treats null as "capture failed, skip".
|
|
35
|
+
function handleMessage(data) {
|
|
36
|
+
const jobId = data?.jobId;
|
|
37
|
+
if (jobId == null || !pending.has(jobId)) return false;
|
|
38
|
+
if (data.type === "capture-meshes") {
|
|
39
|
+
pending.get(jobId)(data.meshes);
|
|
40
|
+
} else if (data.type === "error" || data.type === "needs-occt") {
|
|
41
|
+
pending.get(jobId)(null);
|
|
42
|
+
} else {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
pending.delete(jobId);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Teardown / worker death: settle every in-flight request to null instead
|
|
50
|
+
// of leaving its promise permanently pending (a caller awaiting captureView
|
|
51
|
+
// across a viewer dispose must still get an answer, even a negative one).
|
|
52
|
+
function dispose() {
|
|
53
|
+
disposed = true;
|
|
54
|
+
for (const resolve of pending.values()) resolve(null);
|
|
55
|
+
pending.clear();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { request, handleMessage, dispose };
|
|
59
|
+
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -80,6 +80,27 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
80
80
|
const transfer = meshes.flatMap((m) =>
|
|
81
81
|
[m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
|
|
82
82
|
post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.() }, transfer);
|
|
83
|
+
} else if (msg.type === "capture-generate") {
|
|
84
|
+
// A private, job-correlated one-shot channel for captureView — builds a
|
|
85
|
+
// (possibly non-active) view's meshes off the regen loop, so it can never
|
|
86
|
+
// race or clobber live state. Same per-sub-part build+cache-round as
|
|
87
|
+
// `generate` above (cache:true reuses the worker's geometry memo), but no
|
|
88
|
+
// isStale/superseded polling — there's nothing to supersede a one-shot.
|
|
89
|
+
const useCache = msg.cache !== false;
|
|
90
|
+
const meshes = [];
|
|
91
|
+
for (const name of msg.subparts) {
|
|
92
|
+
if (useCache) kernel.beginSubPart?.(name);
|
|
93
|
+
try {
|
|
94
|
+
const m = posed(name, "display").toMesh({ quality: "preview" });
|
|
95
|
+
meshes.push({ name, positions: m.positions, normals: m.normals, indices: m.indices, triangles: m.triangles, edges: m.edges, featureIds: m.featureIds, features: m.features });
|
|
96
|
+
} finally {
|
|
97
|
+
if (useCache) kernel.endSubPart?.();
|
|
98
|
+
kernel.cleanup?.();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const captureTransfer = meshes.flatMap((m) =>
|
|
102
|
+
[m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
|
|
103
|
+
post({ type: "capture-meshes", jobId: msg.jobId, meshes }, captureTransfer);
|
|
83
104
|
} else if (msg.type === "export-stl") {
|
|
84
105
|
const names = selected();
|
|
85
106
|
if (names.length === 0) throw new Error("no exportable parts selected");
|
|
@@ -84,14 +84,26 @@ export const ANIMATION_RULES = [
|
|
|
84
84
|
`animations.${name}.steps`));
|
|
85
85
|
continue;
|
|
86
86
|
}
|
|
87
|
-
rawSteps(a)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
87
|
+
const steps = rawSteps(a);
|
|
88
|
+
const trackful = (s) => isPlainObject(s.tracks) && Object.keys(s.tracks).length > 0;
|
|
89
|
+
if (!steps.some(trackful)) {
|
|
90
|
+
out.push(err("animation-tracks-or-steps",
|
|
91
|
+
`animation "${name}" animates nothing`,
|
|
92
|
+
"At least one step needs a non-empty `tracks` object mapping a param key to keyframes.",
|
|
93
|
+
hasSteps ? `animations.${name}.steps` : `animations.${name}.tracks`));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
steps.forEach((s, i) => {
|
|
97
|
+
if (trackful(s)) return;
|
|
98
|
+
// A camera-only step is legal: it holds the pose and just moves the
|
|
99
|
+
// camera — an establishing shot before the motion starts. The runtime
|
|
100
|
+
// emits its cue and evaluate() holds the surrounding values, so lint
|
|
101
|
+
// must not reject what plays correctly.
|
|
102
|
+
if (hasSteps && s.camera != null) return;
|
|
103
|
+
out.push(err("animation-tracks-or-steps",
|
|
104
|
+
`animation "${name}"${hasSteps ? ` step ${i}` : ""} has no tracks`,
|
|
105
|
+
"Every step needs a non-empty `tracks` object mapping a param key to keyframes — or, for a step that only moves the camera, a `camera` angle.",
|
|
106
|
+
hasSteps ? `animations.${name}.steps[${i}].tracks` : `animations.${name}.tracks`));
|
|
95
107
|
});
|
|
96
108
|
}
|
|
97
109
|
return out;
|
|
@@ -201,12 +213,31 @@ export const ANIMATION_RULES = [
|
|
|
201
213
|
},
|
|
202
214
|
{
|
|
203
215
|
id: "animation-loop-invalid",
|
|
204
|
-
run: ({ part }) =>
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
`
|
|
216
|
+
run: ({ part }) => {
|
|
217
|
+
const out = [];
|
|
218
|
+
for (const [name, a] of animEntries(part)) {
|
|
219
|
+
if (a.loop === undefined) continue;
|
|
220
|
+
// Type first, like `autoplay`. The runtime fails closed (normalizeAnimation
|
|
221
|
+
// reads `spec.loop === true`), so a truthy non-boolean does NOT loop — it
|
|
222
|
+
// silently means `false`. That is the safe default but not an obvious one,
|
|
223
|
+
// so the author has to hear about it here rather than wonder why `loop: 1`
|
|
224
|
+
// does nothing.
|
|
225
|
+
if (typeof a.loop !== "boolean") {
|
|
226
|
+
out.push(err("animation-loop-invalid",
|
|
227
|
+
`animation "${name}" has a non-boolean \`loop\``,
|
|
228
|
+
"`loop` must be `true` or `false`. Any other truthy value still loops at runtime, so it cannot be left to mean something else.",
|
|
229
|
+
`animations.${name}.loop`));
|
|
230
|
+
continue; // one error per field: the check below assumes a real boolean
|
|
231
|
+
}
|
|
232
|
+
if (a.loop && Array.isArray(a.steps) && a.steps.length > 1) {
|
|
233
|
+
out.push(err("animation-loop-invalid",
|
|
234
|
+
`animation "${name}" sets \`loop: true\` on a multi-step animation`,
|
|
235
|
+
"Loop is for continuous single-phase motion (gears). A stepped sequence replays via the transport instead — drop `loop` or collapse to one step.",
|
|
236
|
+
`animations.${name}.loop`));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
},
|
|
210
241
|
},
|
|
211
242
|
{
|
|
212
243
|
id: "animation-step-label-duplicate",
|
|
@@ -235,7 +266,11 @@ export const ANIMATION_RULES = [
|
|
|
235
266
|
run: ({ part }) => {
|
|
236
267
|
const out = [];
|
|
237
268
|
const check = (easing, path) => {
|
|
238
|
-
|
|
269
|
+
// Own-key test, not `in`: `"toString" in EASINGS` is true, so `in` would
|
|
270
|
+
// wave through every Object.prototype member. The runtime (easingFor)
|
|
271
|
+
// applies the same test and falls back to the default, so these names are
|
|
272
|
+
// caught here rather than silently mis-animating or throwing mid-frame.
|
|
273
|
+
if (easing !== undefined && !Object.hasOwn(EASINGS, easing)) {
|
|
239
274
|
out.push(err("animation-easing-unknown",
|
|
240
275
|
`unknown easing "${easing}"`,
|
|
241
276
|
`Use one of: ${Object.keys(EASINGS).join(", ")}.`,
|
|
@@ -258,7 +293,7 @@ export const ANIMATION_RULES = [
|
|
|
258
293
|
const stepCameras = Array.isArray(a.steps)
|
|
259
294
|
? a.steps.map((s, i) => [s?.camera, i]).filter(([c]) => c !== undefined && c !== null)
|
|
260
295
|
: [];
|
|
261
|
-
if (a.camera
|
|
296
|
+
if (a.camera != null && stepCameras.length) {
|
|
262
297
|
out.push(err("animation-camera-invalid",
|
|
263
298
|
`animation "${name}" mixes an animation-level \`camera\` with per-step cameras`,
|
|
264
299
|
"One camera mechanism per animation: either the animation-level name/cue-list, or per-step names — not both.",
|
|
@@ -272,7 +307,9 @@ export const ANIMATION_RULES = [
|
|
|
272
307
|
`animations.${name}.steps[${i}].camera`));
|
|
273
308
|
}
|
|
274
309
|
}
|
|
275
|
-
|
|
310
|
+
// An explicit `camera: null` is "no camera", which is how
|
|
311
|
+
// normalizeAnimation reads it — not a malformed value to report.
|
|
312
|
+
if (a.camera == null) continue;
|
|
276
313
|
if (typeof a.camera === "string") {
|
|
277
314
|
if (badName(a.camera)) {
|
|
278
315
|
out.push(err("animation-camera-invalid",
|
|
@@ -83,6 +83,28 @@ export const SCHEMA_RULES = [
|
|
|
83
83
|
return out;
|
|
84
84
|
},
|
|
85
85
|
},
|
|
86
|
+
{
|
|
87
|
+
id: "features-requires-on",
|
|
88
|
+
run: ({ part }) => {
|
|
89
|
+
const out = [];
|
|
90
|
+
sections(part).forEach((sec, si) => {
|
|
91
|
+
if (!sectionRenders(sec)) return;
|
|
92
|
+
arr(sec?.features).forEach((f, i) => {
|
|
93
|
+
// The panel treats "enabled" as `params[key] > 0`, so `on` has to be a
|
|
94
|
+
// positive number: a missing one writes undefined (NaN in the build),
|
|
95
|
+
// and 0 or a negative writes a value the panel reads straight back as
|
|
96
|
+
// "still off", leaving a checkbox that won't stay ticked.
|
|
97
|
+
if (f && !f.hidden && !(typeof f.on === "number" && f.on > 0)) {
|
|
98
|
+
out.push(err("features-requires-on",
|
|
99
|
+
`section "${sec.id ?? si}" feature ${i}${f.key ? ` ("${f.key}")` : ""} has no positive numeric \`on\` value`,
|
|
100
|
+
"Ticking a feature's checkbox writes `on` into the feature's own parameter, so a missing one writes `undefined` and the build reads it as NaN. Unlike a `toggles` entry — a plain flag that falls back to 1 — a feature's `on` is the real value the parameter takes when enabled (a diameter, a count), so there is no safe default to guess. It must be greater than 0, because the panel reads `> 0` as \"enabled\". Give the feature the value it should switch on to.",
|
|
101
|
+
`parameters[${si}].features[${i}]`));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
return out;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
86
108
|
{
|
|
87
109
|
id: "control-key-not-in-defaults",
|
|
88
110
|
run: ({ part }) => {
|
package/src/framework/mount.js
CHANGED
|
@@ -23,16 +23,24 @@ import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } fr
|
|
|
23
23
|
import { createPickRequestClient, resolvePickServerUrl, PICK_SERVER_DEFAULT_URL } from "./pick-request/index.js";
|
|
24
24
|
import { exportablePartNames, partLabel } from "./export-select.js";
|
|
25
25
|
import { createExportController, backendForFormat } from "./export-controller.js";
|
|
26
|
+
import { createCaptureBuild } from "./capture-build.js";
|
|
26
27
|
import { attachAnimationControls } from "./animation-controls.js";
|
|
28
|
+
import { resolveDefaultView } from "./default-view.js";
|
|
27
29
|
|
|
28
30
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
29
31
|
// the full mount() pipeline (WASM + workers + DOM).
|
|
30
|
-
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation }) {
|
|
32
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView }) {
|
|
31
33
|
return {
|
|
32
34
|
ready, dispose, setParams,
|
|
33
35
|
// Part-declared animation playback (spec 2026-08-02): null when the part
|
|
34
36
|
// declares no animations. { play(name?), pause(), seek(t), stop(), state() }.
|
|
35
37
|
animation: animation ?? null,
|
|
38
|
+
// Active view name (never null once mounted). See onViewChange for the push side.
|
|
39
|
+
getView,
|
|
40
|
+
// Programmatic tab switch; false for a name the part doesn't declare.
|
|
41
|
+
setView,
|
|
42
|
+
// Offscreen render of a named view (default when omitted, or on an unknown name).
|
|
43
|
+
captureView,
|
|
36
44
|
captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
|
|
37
45
|
captureCurrent: (opts) => viewer.captureCurrent(opts),
|
|
38
46
|
// Park/unpark the viewer: stops the render loop and frees the drawing
|
|
@@ -82,10 +90,17 @@ function createCleanupStack() {
|
|
|
82
90
|
// mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
|
|
83
91
|
// so Vite can bundle the worker (see geometry-service.js).
|
|
84
92
|
//
|
|
85
|
-
// Embedding contract (0.
|
|
86
|
-
// const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload });
|
|
93
|
+
// Embedding contract (0.45.0):
|
|
94
|
+
// const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload, onViewChange });
|
|
87
95
|
// await runtime.ready; // first successful build of the default view
|
|
88
96
|
// runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
|
|
97
|
+
// runtime.getView(); // active view name (string), never null once mounted
|
|
98
|
+
// runtime.setView("lid"); // switch tab programmatically; returns false (and leaves the
|
|
99
|
+
// // active tab untouched) for a name the part doesn't declare
|
|
100
|
+
// await runtime.captureView(); // JPEG data URL of the DEFAULT view rendered offscreen (pass
|
|
101
|
+
// // a name for a specific view, falling back to the default for
|
|
102
|
+
// // an unknown one), never disturbing the active tab or the live
|
|
103
|
+
// // scene; null on failure (never throws)
|
|
89
104
|
// runtime.captureCurrent({ size: 2048 }); // one offscreen render of the user's current
|
|
90
105
|
// // framing (live camera pose + viewport aspect) at the
|
|
91
106
|
// // given long-edge resolution → JPEG data URL, or null
|
|
@@ -121,10 +136,13 @@ function createCleanupStack() {
|
|
|
121
136
|
// runtime.dispose(); // full teardown
|
|
122
137
|
// onBuild fires per completed build, so it does NOT fire for a pose-only edit —
|
|
123
138
|
// those are repaired in the viewer and produce no build at all.
|
|
139
|
+
// onViewChange fires once synchronously during mount with the initial resolved
|
|
140
|
+
// view (before ready), then again on every subsequent view change (user click
|
|
141
|
+
// or a programmatic setView) — always the new view name.
|
|
124
142
|
// Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
|
|
125
143
|
// exactly once here — submodules take element refs and never query the document.
|
|
126
144
|
// `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
|
|
127
|
-
export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload,
|
|
145
|
+
export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange,
|
|
128
146
|
container: legacyContainer, controls: legacyControls } = {}) {
|
|
129
147
|
// --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
|
|
130
148
|
const byId = (id) => document.getElementById(id);
|
|
@@ -214,10 +232,16 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
214
232
|
// View tabs (generated from part.views) + live params. A tab switch shows the
|
|
215
233
|
// cached assembly instantly if it's current, else auto-builds what's missing.
|
|
216
234
|
const tabsCtl = createViewTabs(els.tabs, part, {
|
|
217
|
-
onChange: () => {
|
|
235
|
+
onChange: (name) => {
|
|
236
|
+
pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); animCtl?.autoplayKick();
|
|
237
|
+
onViewChange?.(name);
|
|
238
|
+
},
|
|
218
239
|
});
|
|
219
240
|
cleanup.defer(() => tabsCtl.detach());
|
|
220
241
|
const view = () => tabsCtl.current();
|
|
242
|
+
// Tell the embedder the starting tab exactly once, synchronously, so a host
|
|
243
|
+
// (partforge-cloud) never has to poll getView() to learn where we opened.
|
|
244
|
+
onViewChange?.(tabsCtl.current());
|
|
221
245
|
const params = { ...part.defaults };
|
|
222
246
|
|
|
223
247
|
// Current selection context for the pickers: the active view + live params +
|
|
@@ -364,6 +388,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
364
388
|
function onWorkerMessage({ data }) {
|
|
365
389
|
// Headless exportParts() correlation: consume its own replies first.
|
|
366
390
|
if (exportCtl.handleMessage(data, onDownload)) return;
|
|
391
|
+
// captureView's off-loop build channel: consume its replies before the
|
|
392
|
+
// live `meshes` case — capture-meshes must never touch live cache/display.
|
|
393
|
+
if (captureBuild.handleMessage(data)) return;
|
|
367
394
|
switch (data.type) {
|
|
368
395
|
case "ready":
|
|
369
396
|
loop.ready(); // auto-build the default view (keeps the busy spinner up)
|
|
@@ -444,6 +471,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
444
471
|
const service = createGeometryService({ createWorker, onMessage: onWorkerMessage });
|
|
445
472
|
cleanup.defer(() => service.terminate());
|
|
446
473
|
|
|
474
|
+
const captureBuild = createCaptureBuild({ send: (msg, backend) => service.send(msg, backend) });
|
|
475
|
+
cleanup.defer(() => captureBuild.dispose());
|
|
476
|
+
|
|
447
477
|
const exportCtl = createExportController({
|
|
448
478
|
send: (msg, backend) => service.send(msg, backend),
|
|
449
479
|
currentView: () => view(),
|
|
@@ -558,9 +588,31 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
558
588
|
cleanup.dispose();
|
|
559
589
|
}
|
|
560
590
|
|
|
591
|
+
// Off-loop offscreen thumbnail: builds `viewName` (or the part's resolved
|
|
592
|
+
// default when omitted/unknown) via captureBuild's correlated channel, then
|
|
593
|
+
// renders it in a throwaway scene via viewer.renderMeshPayloads. Never
|
|
594
|
+
// touches the active tab, getView(), or the live scene — best-effort: any
|
|
595
|
+
// failure, including a resolved-null from a worker build failure (4A
|
|
596
|
+
// settles rather than throwing), returns null.
|
|
597
|
+
const captureView = async (viewName, opts = {}) => {
|
|
598
|
+
try {
|
|
599
|
+
const target = (viewName && part.views?.[viewName]) ? viewName : resolveDefaultView(part);
|
|
600
|
+
const subparts = viewSubParts(part, target, params);
|
|
601
|
+
if (!subparts.length) return null;
|
|
602
|
+
const meshes = await captureBuild.request({ subparts, view: target, params, backend: backendFor() });
|
|
603
|
+
if (!meshes || !meshes.length) return null; // 4A resolves null on a worker build failure
|
|
604
|
+
return viewer.renderMeshPayloads(meshes, { size: 640, quality: 0.8, angle: "iso", ...opts });
|
|
605
|
+
} catch {
|
|
606
|
+
return null; // best-effort: a failed thumbnail never breaks the caller
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
|
|
561
610
|
return makeHandle({
|
|
562
611
|
ready, dispose, viewer, setParams,
|
|
563
612
|
setHostPane: paneTabs.setHostPane,
|
|
613
|
+
getView: view, // () => tabsCtl.current()
|
|
614
|
+
setView: (name) => tabsCtl.select(name),
|
|
615
|
+
captureView,
|
|
564
616
|
listExportableParts: () =>
|
|
565
617
|
exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
|
|
566
618
|
exportParts: (opts) => exportCtl.exportParts(opts),
|
|
@@ -48,6 +48,19 @@ export function createViewTabs(el, part, { onChange }) {
|
|
|
48
48
|
|
|
49
49
|
return {
|
|
50
50
|
current: () => view,
|
|
51
|
+
// Programmatic switch — the click path without the click. Used by an
|
|
52
|
+
// embedder (mount's handle.setView) to change tabs from outside the DOM.
|
|
53
|
+
// Returns false for a name that isn't a tab so callers can validate.
|
|
54
|
+
select: (name) => {
|
|
55
|
+
if (name === view) return true; // already active — nothing to do
|
|
56
|
+
const btn = [...el.querySelectorAll("button[data-part]")].find((b) => b.dataset.part === name);
|
|
57
|
+
if (!btn) return false;
|
|
58
|
+
view = name;
|
|
59
|
+
saveView(partKey, view);
|
|
60
|
+
setActive(btn);
|
|
61
|
+
onChange(view);
|
|
62
|
+
return true;
|
|
63
|
+
},
|
|
51
64
|
detach: () => {
|
|
52
65
|
el.removeEventListener("click", onClick);
|
|
53
66
|
if (generated) el.innerHTML = ""; // we generated these buttons; hand-written markup stays
|
|
@@ -3,8 +3,15 @@ import * as THREE from "three";
|
|
|
3
3
|
const KEY_COLOR = 0xffffff, KEY_INTENSITY = 1.45;
|
|
4
4
|
const FILL_COLOR = 0xe5efff, FILL_INTENSITY = 0.65;
|
|
5
5
|
|
|
6
|
+
// The persistent sky/ground ambient. Shared with the offscreen thumbnail path
|
|
7
|
+
// (viewer.js renderMeshPayloads), which builds its own throwaway scene and so needs
|
|
8
|
+
// the same hemisphere fill to avoid rendering faces outside the key/fill cones near-black.
|
|
9
|
+
export function createHemisphereLight() {
|
|
10
|
+
return new THREE.HemisphereLight(0xdce9ff, 0x687586, 1.35);
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
export function addViewerLights(scene) {
|
|
7
|
-
const hemisphere =
|
|
14
|
+
const hemisphere = createHemisphereLight();
|
|
8
15
|
const key = new THREE.DirectionalLight(KEY_COLOR, KEY_INTENSITY);
|
|
9
16
|
key.position.set(8, 14, 10);
|
|
10
17
|
const fill = new THREE.DirectionalLight(FILL_COLOR, FILL_INTENSITY);
|
package/src/framework/viewer.js
CHANGED
|
@@ -6,7 +6,7 @@ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js
|
|
|
6
6
|
import { LineMaterial } from "three/addons/lines/LineMaterial.js";
|
|
7
7
|
import { createCutaway } from "./cutaway.js";
|
|
8
8
|
import { createCameraTween } from "./camera-tween.js";
|
|
9
|
-
import { addViewerLights, captureLightPoses, createCaptureLights } from "./viewer-lighting.js";
|
|
9
|
+
import { addViewerLights, captureLightPoses, createCaptureLights, createHemisphereLight } from "./viewer-lighting.js";
|
|
10
10
|
import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
|
|
11
11
|
|
|
12
12
|
// three renders into a render target in the LINEAR working colour space: as of r184
|
|
@@ -427,7 +427,8 @@ export function createViewer(container, part) {
|
|
|
427
427
|
// no error, live view unaffected, wrong only in the capture.
|
|
428
428
|
const RT_OPTIONS = { samples: 4, stencilBuffer: true };
|
|
429
429
|
function renderOffscreen({ position, up, target },
|
|
430
|
-
{ width = _rtSize, height = _rtSize, fov = 45, quality = 0.9 } = {}
|
|
430
|
+
{ width = _rtSize, height = _rtSize, fov = 45, quality = 0.9 } = {},
|
|
431
|
+
renderScene = scene) {
|
|
431
432
|
const cachedSize = width === _rtSize && height === _rtSize;
|
|
432
433
|
const rt = cachedSize
|
|
433
434
|
? (_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize, RT_OPTIONS))
|
|
@@ -451,7 +452,7 @@ export function createViewer(container, part) {
|
|
|
451
452
|
scene.add(capKey, capKey.target, capFill, capFill.target);
|
|
452
453
|
try {
|
|
453
454
|
renderer.setRenderTarget(rt);
|
|
454
|
-
renderer.render(
|
|
455
|
+
renderer.render(renderScene, cam);
|
|
455
456
|
// render() resolves the multisample renderbuffer into the target texture, so this
|
|
456
457
|
// reads antialiased pixels.
|
|
457
458
|
renderer.readRenderTargetPixels(rt, 0, 0, width, height, buf);
|
|
@@ -510,6 +511,76 @@ export function createViewer(container, part) {
|
|
|
510
511
|
});
|
|
511
512
|
}
|
|
512
513
|
|
|
514
|
+
// Offscreen render of an arbitrary mesh set (a non-active view), for thumbnails.
|
|
515
|
+
// Assembles a THROWAWAY scene mirroring the live pivot convention, frames it from a
|
|
516
|
+
// canonical angle, renders through the parameterized renderOffscreen, and disposes
|
|
517
|
+
// everything. Never touches the live scene, camera, subMesh, or subCache. `payloads`
|
|
518
|
+
// is the worker's [{name, positions, normals, indices, …}] array — placement is
|
|
519
|
+
// already baked into shared-frame coords, so meshes are NOT recentred.
|
|
520
|
+
function renderMeshPayloads(payloads, { angle = "iso", size = 640, quality = 0.8 } = {}) {
|
|
521
|
+
if (disposed) return null; // same guard as captureCurrent/captureCanonicalViews — never touch a torn-down renderer
|
|
522
|
+
const tmpScene = new THREE.Scene();
|
|
523
|
+
const tmpPivot = new THREE.Group();
|
|
524
|
+
tmpPivot.rotation.x = -Math.PI / 2; // model Z (CAD up) -> vertical, same as live pivot
|
|
525
|
+
tmpScene.add(tmpPivot);
|
|
526
|
+
|
|
527
|
+
const built = [];
|
|
528
|
+
for (const payload of payloads) {
|
|
529
|
+
const geo = buildGeometry(payload); // shared-frame coords, NOT recentred
|
|
530
|
+
const mesh = new THREE.Mesh(geo, materialFor(payload.name));
|
|
531
|
+
tmpPivot.add(mesh);
|
|
532
|
+
built.push(mesh);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Frame in WORLD space, AFTER the pivot rotation. The meshes are built in model
|
|
536
|
+
// coords but rendered rotated by tmpPivot, so a model-space bbox centre would aim
|
|
537
|
+
// the camera at the wrong point — an off-origin part would render off-centre or blank.
|
|
538
|
+
tmpPivot.updateMatrixWorld(true);
|
|
539
|
+
const box = new THREE.Box3().setFromObject(tmpPivot);
|
|
540
|
+
const center = box.getCenter(new THREE.Vector3()).toArray();
|
|
541
|
+
const radius = box.getSize(new THREE.Vector3()).length() / 2 || 1;
|
|
542
|
+
const pose = cameraPoseForView(angle, { center, radius });
|
|
543
|
+
|
|
544
|
+
// Light the throwaway scene ourselves: renderOffscreen's own key/fill (and the
|
|
545
|
+
// persistent hemisphere) live in the LIVE scene, which is never rendered here — so
|
|
546
|
+
// without our own ambient + camera-relative key/fill it comes back near-black.
|
|
547
|
+
const hemi = createHemisphereLight();
|
|
548
|
+
const capLights = createCaptureLights();
|
|
549
|
+
const poses = captureLightPoses(pose);
|
|
550
|
+
capLights.key.position.set(poses.key[0], poses.key[1], poses.key[2]);
|
|
551
|
+
capLights.fill.position.set(poses.fill[0], poses.fill[1], poses.fill[2]);
|
|
552
|
+
for (const light of [capLights.key, capLights.fill]) {
|
|
553
|
+
light.target.position.set(pose.target[0], pose.target[1], pose.target[2]);
|
|
554
|
+
}
|
|
555
|
+
tmpScene.add(hemi, capLights.key, capLights.key.target, capLights.fill, capLights.fill.target);
|
|
556
|
+
|
|
557
|
+
// Feature-edge lines, so the thumbnail carries the same hole/seam/chamfer outlines the
|
|
558
|
+
// live viewer shows. A dedicated LineMaterial at the render resolution (the live one is
|
|
559
|
+
// sized to the on-screen canvas); added after framing so it can't perturb the bbox.
|
|
560
|
+
const lineMat = new LineMaterial({ color: THEME.dark.line, linewidth: 1.0 });
|
|
561
|
+
lineMat.resolution.set(size, size);
|
|
562
|
+
for (const mesh of built) {
|
|
563
|
+
const edges = mesh.geometry.userData.edges;
|
|
564
|
+
if (edges) tmpPivot.add(new LineSegments2(edges, lineMat));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
// fov matches the live camera (and captureViews/captureCurrent) — cameraPoseForView's
|
|
569
|
+
// distance is tuned to it, so a narrower fov would crop long, thin parts.
|
|
570
|
+
return renderOffscreen(pose, { width: size, height: size, fov: camera.fov, quality }, tmpScene);
|
|
571
|
+
} finally {
|
|
572
|
+
for (const mesh of built) {
|
|
573
|
+
mesh.geometry.userData.edges?.dispose();
|
|
574
|
+
mesh.geometry.dispose();
|
|
575
|
+
if (mesh.material !== material) mesh.material.dispose(); // clone only — never the shared singleton
|
|
576
|
+
}
|
|
577
|
+
lineMat.dispose();
|
|
578
|
+
hemi.dispose?.();
|
|
579
|
+
capLights.key.dispose?.();
|
|
580
|
+
capLights.fill.dispose?.();
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
513
584
|
// --- render loop ----------------------------------------------------------
|
|
514
585
|
// The tween is applied after controls.update() so the cue wins the frame, and
|
|
515
586
|
// the frame listeners run before render so a playback frame draws its own pose.
|
|
@@ -523,7 +594,13 @@ export function createViewer(container, part) {
|
|
|
523
594
|
camera.position.fromArray(tw.position);
|
|
524
595
|
controls.target.fromArray(tw.target);
|
|
525
596
|
}
|
|
526
|
-
|
|
597
|
+
// Per-listener guard, because three re-arms requestAnimationFrame only AFTER
|
|
598
|
+
// this callback returns (WebGLAnimation.onAnimationFrame): a listener that
|
|
599
|
+
// throws would stop the rAF chain outright and freeze the viewer for good, not
|
|
600
|
+
// just skip a frame. Containment belongs here rather than in every subscriber.
|
|
601
|
+
for (const cb of [...frameListeners]) {
|
|
602
|
+
try { cb(dt); } catch (e) { console.warn("partforge: frame listener failed", e); }
|
|
603
|
+
}
|
|
527
604
|
if (cutaway.isEnabled) cutaway.updateForCamera();
|
|
528
605
|
renderer.render(scene, camera);
|
|
529
606
|
cutaway.renderOverlay(renderer, camera);
|
|
@@ -662,6 +739,7 @@ export function createViewer(container, part) {
|
|
|
662
739
|
frame,
|
|
663
740
|
captureCanonicalViews,
|
|
664
741
|
captureCurrent,
|
|
742
|
+
renderMeshPayloads,
|
|
665
743
|
onFrame,
|
|
666
744
|
tweenCameraTo,
|
|
667
745
|
cancelCameraTween,
|
package/src/framework/worker.js
CHANGED
|
@@ -110,7 +110,11 @@ export function runWorker(part) {
|
|
|
110
110
|
await handle(kernel, job.part, job.data, gated, { isStale });
|
|
111
111
|
} catch (err) {
|
|
112
112
|
// Same shape jobs.js posts for a failed build, so hosts need no new branch.
|
|
113
|
-
|
|
113
|
+
// Carry the job's jobId when it has one (capture/export are correlated by it):
|
|
114
|
+
// a boot failure hitting kernelFor here must reach the right controller, or a
|
|
115
|
+
// correlated caller (captureView, exportParts) would hang instead of settling.
|
|
116
|
+
const jobId = job.data?.jobId;
|
|
117
|
+
postMessage({ type: "error", message: String(err?.message || err), ...(jobId != null ? { jobId } : {}) });
|
|
114
118
|
}
|
|
115
119
|
}
|
|
116
120
|
} finally {
|
package/types/index.d.ts
CHANGED
|
@@ -5,14 +5,11 @@
|
|
|
5
5
|
// their geometry helpers from "partforge/geometry".
|
|
6
6
|
|
|
7
7
|
import type { BackendName } from "./kernel.js";
|
|
8
|
-
import type { ParamValue, PartDefinition } from "./part.js";
|
|
8
|
+
import type { CanonicalView, ParamValue, PartDefinition } from "./part.js";
|
|
9
9
|
|
|
10
10
|
export * from "./kernel.js";
|
|
11
11
|
export * from "./part.js";
|
|
12
12
|
|
|
13
|
-
/** A canonical capture angle. */
|
|
14
|
-
export type CanonicalView = "iso" | "front" | "back" | "left" | "right" | "top" | "bottom";
|
|
15
|
-
|
|
16
13
|
/** Which pane a narrow layout shows. `null` hands selection back to partforge. */
|
|
17
14
|
export type HostPane = "stage" | "rail" | null;
|
|
18
15
|
|
|
@@ -110,6 +107,8 @@ export interface MountOptions {
|
|
|
110
107
|
onPick?: (event: PickEvent) => void;
|
|
111
108
|
/** Receive exported bytes instead of partforge's own DOM download. */
|
|
112
109
|
onDownload?: (file: DownloadPayload) => void;
|
|
110
|
+
/** The active view (tab) name — emitted once on mount, then on every change. */
|
|
111
|
+
onViewChange?: (view: string) => void;
|
|
113
112
|
/** @deprecated alias for `elements.viewer`. */
|
|
114
113
|
container?: HTMLElement | null;
|
|
115
114
|
/** @deprecated alias for `elements.controls`. */
|
|
@@ -134,6 +133,15 @@ export interface CaptureCurrentOptions {
|
|
|
134
133
|
quality?: number;
|
|
135
134
|
}
|
|
136
135
|
|
|
136
|
+
export interface CaptureViewOptions {
|
|
137
|
+
/** Square render resolution in px. Default 640. */
|
|
138
|
+
size?: number;
|
|
139
|
+
/** JPEG quality, 0..1. Default 0.8. */
|
|
140
|
+
quality?: number;
|
|
141
|
+
/** Canonical angle to render from. Default `"iso"`. */
|
|
142
|
+
angle?: CanonicalView | string;
|
|
143
|
+
}
|
|
144
|
+
|
|
137
145
|
/** Where playback is: idle, swinging the camera to an intro cue, playing, or paused. */
|
|
138
146
|
export type AnimationStatus = "idle" | "intro" | "playing" | "paused";
|
|
139
147
|
|
|
@@ -190,6 +198,17 @@ export interface PartRuntime {
|
|
|
190
198
|
* when disposed or nothing is built yet. Never throws.
|
|
191
199
|
*/
|
|
192
200
|
captureCurrent(opts?: CaptureCurrentOptions): string | null;
|
|
201
|
+
/** The active view (tab) name. Never null once mounted. */
|
|
202
|
+
getView(): string;
|
|
203
|
+
/** Switch the active view; `false` if the part declares no such view. Persists per part for the session. */
|
|
204
|
+
setView(name: string): boolean;
|
|
205
|
+
/**
|
|
206
|
+
* Render a named view OFFSCREEN → a `data:image/jpeg;base64,…` string, or `null` on
|
|
207
|
+
* failure (a build error, a view with no sub-parts, or a disposed runtime). Omit
|
|
208
|
+
* `viewName` — or pass an unknown one — to render the part's DEFAULT view. Never
|
|
209
|
+
* disturbs the active tab, the live camera, or the on-screen scene.
|
|
210
|
+
*/
|
|
211
|
+
captureView(viewName?: string, opts?: CaptureViewOptions): Promise<string | null>;
|
|
193
212
|
/**
|
|
194
213
|
* Park/unpark the viewer: stops the render loop and releases the drawing
|
|
195
214
|
* buffer and cached capture target. For a host that hides the canvas without
|
package/types/part.d.ts
CHANGED
|
@@ -297,11 +297,18 @@ export type Easing = "linear" | "ease-in" | "ease-out" | "ease-in-out";
|
|
|
297
297
|
export type Keyframes = Array<[number, number]>;
|
|
298
298
|
|
|
299
299
|
/**
|
|
300
|
-
*
|
|
301
|
-
* app entry
|
|
302
|
-
*
|
|
300
|
+
* The seven angles the viewer can frame a part from. Defined here rather than in
|
|
301
|
+
* the app entry so `CameraCue` can be the real union without an import cycle —
|
|
302
|
+
* the app entry re-exports it under its own name.
|
|
303
303
|
*/
|
|
304
|
-
export type
|
|
304
|
+
export type CanonicalView = "iso" | "front" | "back" | "left" | "right" | "top" | "bottom";
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* A camera cue angle. `partforge lint` rejects anything outside the canonical
|
|
308
|
+
* seven (`animation-camera-invalid`), so the type says so too. Cues fire during
|
|
309
|
+
* play only; scrubbing never moves the camera.
|
|
310
|
+
*/
|
|
311
|
+
export type CameraCue = CanonicalView;
|
|
305
312
|
|
|
306
313
|
/** One step of a multi-step animation. Steps play in order; prev/next navigate them. */
|
|
307
314
|
export interface AnimationStep {
|
|
@@ -310,31 +317,31 @@ export interface AnimationStep {
|
|
|
310
317
|
/** Seconds. Step durations are relative — they set each step's share of the timeline. */
|
|
311
318
|
duration: number;
|
|
312
319
|
easing?: Easing;
|
|
313
|
-
/**
|
|
314
|
-
|
|
320
|
+
/**
|
|
321
|
+
* Param key -> keyframes. A param tracked nowhere keeps its current value.
|
|
322
|
+
*
|
|
323
|
+
* Optional so a step can move only the camera — an establishing shot that
|
|
324
|
+
* holds the pose while the view swings round. `partforge lint` still requires
|
|
325
|
+
* that at least one step in the animation carries tracks, which is a
|
|
326
|
+
* whole-animation rule the type system can't express per step.
|
|
327
|
+
*/
|
|
328
|
+
tracks?: Record<string, Keyframes>;
|
|
315
329
|
/** Swing the camera to this angle when the step begins. */
|
|
316
330
|
camera?: CameraCue;
|
|
317
331
|
}
|
|
318
332
|
|
|
319
333
|
/**
|
|
320
|
-
*
|
|
321
|
-
* `
|
|
322
|
-
* docs/AUTHORING-PARTS.md "Animations".
|
|
334
|
+
* The fields both animation forms share. Exported so a host can extend it —
|
|
335
|
+
* `AnimationSpec` itself is a union and cannot be `extends`-ed.
|
|
323
336
|
*/
|
|
324
|
-
export interface
|
|
337
|
+
export interface AnimationSpecCommon {
|
|
325
338
|
/** Shown in the transport bar's picker. Defaults to the animation's key. */
|
|
326
339
|
label?: string;
|
|
327
340
|
/** CommonMark, shown behind the ⓘ glyph. */
|
|
328
341
|
description?: string;
|
|
329
|
-
/** Seconds. Required in the single-step (`tracks`) form. */
|
|
330
|
-
duration?: number;
|
|
331
342
|
easing?: Easing;
|
|
332
343
|
/** Wrap continuously. Single-step animations only. */
|
|
333
344
|
loop?: boolean;
|
|
334
|
-
/** The single-step form: param key -> keyframes. */
|
|
335
|
-
tracks?: Record<string, Keyframes>;
|
|
336
|
-
/** The multi-step form. */
|
|
337
|
-
steps?: AnimationStep[];
|
|
338
345
|
/**
|
|
339
346
|
* One mechanism per animation: an angle (an intro cue at t=0), a
|
|
340
347
|
* `[[t, angle], …]` cue list, or per-step `camera` names.
|
|
@@ -350,6 +357,27 @@ export interface AnimationSpec {
|
|
|
350
357
|
autoplay?: boolean;
|
|
351
358
|
}
|
|
352
359
|
|
|
360
|
+
/**
|
|
361
|
+
* An animation is EITHER single-phase (`tracks` + `duration`) OR stepped
|
|
362
|
+
* (`steps`) — never both, never neither. `partforge lint` enforces that
|
|
363
|
+
* (`animation-tracks-or-steps`), and the union says the same thing, so a block
|
|
364
|
+
* carrying both is rejected before it ever reaches lint.
|
|
365
|
+
*/
|
|
366
|
+
export type AnimationSpec =
|
|
367
|
+
| (AnimationSpecCommon & {
|
|
368
|
+
/** Seconds — the whole animation's duration in the single-phase form. */
|
|
369
|
+
duration: number;
|
|
370
|
+
/** Param key -> keyframes. */
|
|
371
|
+
tracks: Record<string, Keyframes>;
|
|
372
|
+
steps?: never;
|
|
373
|
+
})
|
|
374
|
+
| (AnimationSpecCommon & {
|
|
375
|
+
/** The multi-step form; each step carries its own relative `duration`. */
|
|
376
|
+
steps: AnimationStep[];
|
|
377
|
+
tracks?: never;
|
|
378
|
+
duration?: never;
|
|
379
|
+
});
|
|
380
|
+
|
|
353
381
|
// --- the part itself --------------------------------------------------------
|
|
354
382
|
|
|
355
383
|
/**
|