partforge 0.158.0 → 0.159.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/package.json
CHANGED
|
@@ -23,6 +23,13 @@
|
|
|
23
23
|
// it, so a parked preview is a paused one. `handle.animation` is null once
|
|
24
24
|
// disposed.
|
|
25
25
|
//
|
|
26
|
+
// A playing animation also gets a play/pause/replay button in #viewbar,
|
|
27
|
+
// directly before the view style button (preview-play-button.js): Pause while
|
|
28
|
+
// it plays (or tweens to its intro view), Play while paused, Replay once a
|
|
29
|
+
// one-time animation has finished. No button under reduced motion, for a still
|
|
30
|
+
// file, with `animate: false`, or for a host with no #viewbar; the host can
|
|
31
|
+
// still drive `handle.animation` (state / pause / resume / replay) itself.
|
|
32
|
+
//
|
|
26
33
|
// The decoder loads on demand (it carries the
|
|
27
34
|
// GLTFLoader and meshopt), so a bundle that never mounts a preview never ships it.
|
|
28
35
|
// Hence `ready`. Every handle method is safe before it resolves.
|
|
@@ -36,6 +43,7 @@ import { createTooltipPresenter } from "./tooltip.js";
|
|
|
36
43
|
import { isFaceAlignedState } from "./projection.js";
|
|
37
44
|
import { ENVIRONMENTS } from "./materials/environments.js";
|
|
38
45
|
import { createPreviewPlayer } from "./preview-player.js";
|
|
46
|
+
import { attachPreviewPlayButton } from "./preview-play-button.js";
|
|
39
47
|
|
|
40
48
|
function previewPart({ meshes, view, meta }) {
|
|
41
49
|
return {
|
|
@@ -112,6 +120,10 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
|
|
|
112
120
|
const own = player;
|
|
113
121
|
cleanups.push(() => own.dispose());
|
|
114
122
|
player.start();
|
|
123
|
+
if (!reducedMotion && toolbar) {
|
|
124
|
+
const playButton = attachPreviewPlayButton(own, { toolbar, before: viewStyle.element }, { tooltip });
|
|
125
|
+
cleanups.push(() => playButton.detach());
|
|
126
|
+
}
|
|
115
127
|
}
|
|
116
128
|
});
|
|
117
129
|
ready.catch(() => {}); // the host awaits it; never an unhandled rejection
|
|
@@ -151,7 +163,7 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
|
|
|
151
163
|
getView: () => live?.view ?? null,
|
|
152
164
|
// null before ready, for a still file, with animate: false, and once disposed.
|
|
153
165
|
get animation() {
|
|
154
|
-
return player ? { state: player.state, pause: player.pause, resume: player.resume } : null;
|
|
166
|
+
return player ? { state: player.state, pause: player.pause, resume: player.resume, replay: player.replay } : null;
|
|
155
167
|
},
|
|
156
168
|
getViewerState,
|
|
157
169
|
setViewerState(next) {
|
|
@@ -5,7 +5,9 @@ import { resolveDerivedAttributed } from "./derive.js";
|
|
|
5
5
|
import { createProbeKernel } from "./geometry/probe.js";
|
|
6
6
|
import { inView, placeOf } from "./sub-part-views.js";
|
|
7
7
|
import { byteAwareReplacer } from "./geometry/solid-hash.js";
|
|
8
|
-
import { viewSubParts } from "./part-model.js";
|
|
8
|
+
import { viewSubParts, resolveParamsAttributed, recordedParams } from "./part-model.js";
|
|
9
|
+
import { newReadSink, expandReads } from "./read-recorder.js";
|
|
10
|
+
import { probeSubPartPose } from "./pose-probe-core.js";
|
|
9
11
|
|
|
10
12
|
export const RELEVANT_ALL = Symbol("relevant-all");
|
|
11
13
|
|
|
@@ -77,10 +79,12 @@ export function subPartParamKeys(part, view, params, readsOf, name) {
|
|
|
77
79
|
const rec = readsOf?.(name);
|
|
78
80
|
if (rec) {
|
|
79
81
|
// The worker records the BUILD's reads; the sub-part's own show/hide gate
|
|
80
|
-
//
|
|
82
|
+
// and its place() reads in this view are added here, as the prediction rung
|
|
83
|
+
// does (subPartReadKeys runs enabled() and place()).
|
|
81
84
|
const out = new Set(rec);
|
|
82
85
|
const sp = part.parts[name];
|
|
83
86
|
if (sp?.enabled) sp.enabled(recorder(params, out));
|
|
87
|
+
for (const k of placeReadKeys(part, view, params, name)) out.add(k);
|
|
84
88
|
return [...out].sort();
|
|
85
89
|
}
|
|
86
90
|
const reads = subPartReadKeys(part, view, params);
|
|
@@ -88,16 +92,53 @@ export function subPartParamKeys(part, view, params, readsOf, name) {
|
|
|
88
92
|
return keys.sort();
|
|
89
93
|
}
|
|
90
94
|
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
95
|
+
// The params one sub-part's place() reads in THIS view, derive attribution
|
|
96
|
+
// included. A canonical delivery (jobs.js displayWithReads) is stamped with its
|
|
97
|
+
// BUILD reads alone — the pose is applied as a matrix, and the pose fast path
|
|
98
|
+
// needs a build-only stamp — so a param read only in place() (an explode slider
|
|
99
|
+
// feeding views.cutaway) never reaches the recorded rung. The display side adds
|
|
100
|
+
// it back here, per view: a view whose place() never reads the param leaves it
|
|
101
|
+
// irrelevant. Runs place() on the geometry-free canonical token (no build); the
|
|
102
|
+
// reads count whether or not the probe trusts the pose — they were read either
|
|
103
|
+
// way. Never throws; an unreadable place() contributes nothing (a posed delivery
|
|
104
|
+
// already recorded its place reads in the stamp). `resolved` lets a caller
|
|
105
|
+
// walking many sub-parts run derive() once (a resolveParamsAttributed result).
|
|
106
|
+
export function placeReadKeys(part, view, params, name, resolved) {
|
|
107
|
+
const sp = part.parts[name];
|
|
108
|
+
if (!sp || !placeOf(sp)) return [];
|
|
109
|
+
try {
|
|
110
|
+
resolved ??= resolveParamsAttributed(part, params);
|
|
111
|
+
const sink = newReadSink();
|
|
112
|
+
sink.attribution = resolved.attribution;
|
|
113
|
+
const rec = recordedParams(resolved, sink);
|
|
114
|
+
probeSubPartPose(sp, { view, purpose: "display", p: rec.p, d: rec.d }, { scope: "place" });
|
|
115
|
+
return expandReads(sink);
|
|
116
|
+
} catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Panel relevance from RECORDED reads: the union over on-screen sub-parts, each
|
|
122
|
+
// with its place() reads in this view (a not-yet-built one contributes its
|
|
123
|
+
// prediction — display only, so a wrong guess costs one build's dimming), plus
|
|
124
|
+
// the gate params of every in-view sub-part.
|
|
94
125
|
export function recordedRelevantKeys(part, view, params, readsOf) {
|
|
95
126
|
try {
|
|
96
127
|
const relevant = new Set();
|
|
97
128
|
let predicted = null;
|
|
129
|
+
let resolved = null;
|
|
98
130
|
for (const name of viewSubParts(part, view, params)) {
|
|
99
131
|
const rec = readsOf(name);
|
|
100
|
-
if (rec) {
|
|
132
|
+
if (rec) {
|
|
133
|
+
for (const k of rec) relevant.add(k);
|
|
134
|
+
// derive() once per call, and only if some sub-part has a pose to read.
|
|
135
|
+
// A throwing derive() costs only the place reads, never the recorded ones.
|
|
136
|
+
if (resolved === null && placeOf(part.parts[name])) {
|
|
137
|
+
try { resolved = resolveParamsAttributed(part, params); } catch { resolved = false; }
|
|
138
|
+
}
|
|
139
|
+
if (resolved) for (const k of placeReadKeys(part, view, params, name, resolved)) relevant.add(k);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
101
142
|
predicted ??= subPartReadKeys(part, view, params);
|
|
102
143
|
if (predicted === RELEVANT_ALL) return RELEVANT_ALL;
|
|
103
144
|
for (const k of predicted.get(name) ?? []) relevant.add(k);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The preview's play/pause/replay button (mount-preview.js): lets a visitor stop
|
|
2
|
+
// a cached preview's baked autoplay animation while they read the page. A plain
|
|
3
|
+
// `#viewbar button`, inserted directly before the view style button (#view-style,
|
|
4
|
+
// after its divider), so it inherits the toolbar's sizing and hover styles.
|
|
5
|
+
//
|
|
6
|
+
// Three states, read off the player's onChange notifications (never polled):
|
|
7
|
+
// - Pause while playing or during the intro camera tween;
|
|
8
|
+
// - Play while paused — resumes from the pose it froze at;
|
|
9
|
+
// - Replay once a one-time animation has finished at its end pose — plays it
|
|
10
|
+
// again from the start, camera cues included. A looping animation
|
|
11
|
+
// never gets here.
|
|
12
|
+
// The label (aria-label, and the tooltip, which reads it each time it shows)
|
|
13
|
+
// names what a click does and so carries the state; there is deliberately no
|
|
14
|
+
// aria-pressed (WAI-ARIA: a toggle whose label changes must not also report a
|
|
15
|
+
// pressed state). A label change hides a tooltip that is showing, so it never
|
|
16
|
+
// reads the old action; it comes back with the new text on the next hover.
|
|
17
|
+
//
|
|
18
|
+
// Made only for a player that plays: mount-preview never creates it under
|
|
19
|
+
// reduced motion (the player never starts), for a still file, with
|
|
20
|
+
// animate: false, or for a host with no #viewbar. Nothing is remembered: a
|
|
21
|
+
// remount starts fresh.
|
|
22
|
+
import { attachButtonTooltips } from "./tooltip.js";
|
|
23
|
+
|
|
24
|
+
const svg = (body) => `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
|
25
|
+
// lucide "pause", "play" and "rotate-ccw".
|
|
26
|
+
const STATES = {
|
|
27
|
+
pause: { label: "Pause animation", icon: svg(`<rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/>`) },
|
|
28
|
+
play: { label: "Play animation", icon: svg(`<polygon points="6 3 20 12 6 21 6 3"/>`) },
|
|
29
|
+
replay: { label: "Replay animation", icon: svg(`<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>`) },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// What a click does for a given player status.
|
|
33
|
+
const actionFor = (status) => (status === "paused" ? "play" : status === "done" ? "replay" : "pause");
|
|
34
|
+
|
|
35
|
+
export function attachPreviewPlayButton(player, { toolbar, before = null }, { tooltip = null } = {}) {
|
|
36
|
+
const button = document.createElement("button");
|
|
37
|
+
button.type = "button";
|
|
38
|
+
button.id = "pf-preview-play";
|
|
39
|
+
button.className = "pf-preview-play-button";
|
|
40
|
+
let action = null;
|
|
41
|
+
let tooltipBinding = null;
|
|
42
|
+
|
|
43
|
+
function render(status) {
|
|
44
|
+
const next = actionFor(status);
|
|
45
|
+
if (next === action) return;
|
|
46
|
+
action = next;
|
|
47
|
+
const { label, icon } = STATES[next];
|
|
48
|
+
button.innerHTML = icon;
|
|
49
|
+
button.setAttribute("aria-label", label);
|
|
50
|
+
if (!tooltip) button.title = label;
|
|
51
|
+
tooltipBinding?.hide(); // a showing tooltip still names the old action
|
|
52
|
+
}
|
|
53
|
+
render(player.state().status);
|
|
54
|
+
|
|
55
|
+
const anchor = before && before.parentElement === toolbar ? before : [...toolbar.children].find((c) => c.id === "theme") ?? null;
|
|
56
|
+
toolbar.insertBefore(button, anchor);
|
|
57
|
+
tooltipBinding = tooltip ? attachButtonTooltips(tooltip, [{ element: button }]) : null;
|
|
58
|
+
|
|
59
|
+
const onClick = () => {
|
|
60
|
+
if (action === "pause") player.pause();
|
|
61
|
+
else if (action === "play") player.resume();
|
|
62
|
+
else player.replay();
|
|
63
|
+
};
|
|
64
|
+
button.addEventListener("click", onClick);
|
|
65
|
+
const off = player.onChange((s) => render(s.status));
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
element: button,
|
|
69
|
+
detach() {
|
|
70
|
+
off();
|
|
71
|
+
button.removeEventListener("click", onClick);
|
|
72
|
+
tooltipBinding?.detach();
|
|
73
|
+
button.remove();
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
// opacity, no ground hold, no cue — so the visitor sees exactly what the
|
|
19
19
|
// file draws without a player (the opening pose, every sub-part opaque; an
|
|
20
20
|
// opacity fade-in must not leave a sub-part hidden for good).
|
|
21
|
+
//
|
|
22
|
+
// A host can pause, resume and — once a one-time animation has finished —
|
|
23
|
+
// replay it (the preview's play/pause button, preview-play-button.js). Pause
|
|
24
|
+
// stops any camera move where it is (viewer.cancelCameraTween, as the live
|
|
25
|
+
// driver does): a cancelled tween never fires its onComplete, and createPlayback
|
|
26
|
+
// re-honors an abandoned intro or sweep on the next play(). An orbit while
|
|
27
|
+
// paused stays paused. onChange(cb) reports every status change, so a control
|
|
28
|
+
// never polls.
|
|
21
29
|
import { createPlayback } from "./animation.js";
|
|
22
30
|
import { invertRigid, mulMat4 } from "./geometry/pose.js";
|
|
23
31
|
|
|
@@ -99,6 +107,21 @@ export function createPreviewPlayer(viewer, animation, { reducedMotion = false }
|
|
|
99
107
|
const restInverse = Object.fromEntries(Object.keys(tracks).map((name) => [name, invertRigid(rest[name])]));
|
|
100
108
|
|
|
101
109
|
let offFrame = null, offOrbit = null, started = false, disposed = false;
|
|
110
|
+
const listeners = new Set();
|
|
111
|
+
let lastStatus = playback.state().status;
|
|
112
|
+
|
|
113
|
+
function snapshotState() {
|
|
114
|
+
const { status, t } = playback.state();
|
|
115
|
+
return { status, t };
|
|
116
|
+
}
|
|
117
|
+
function notify() {
|
|
118
|
+
const now = snapshotState();
|
|
119
|
+
if (now.status === lastStatus) return;
|
|
120
|
+
lastStatus = now.status;
|
|
121
|
+
for (const cb of [...listeners]) {
|
|
122
|
+
try { cb(now); } catch (error) { console.warn("partforge: preview animation listener failed", error); }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
102
125
|
|
|
103
126
|
function apply(t) {
|
|
104
127
|
const seconds = t * duration;
|
|
@@ -111,7 +134,7 @@ export function createPreviewPlayer(viewer, animation, { reducedMotion = false }
|
|
|
111
134
|
if (snap.status !== "intro" || !snap.cue) return;
|
|
112
135
|
viewer.tweenCameraTo(snap.cue.view, {
|
|
113
136
|
duration: INTRO_TWEEN_SECONDS,
|
|
114
|
-
onComplete: () => { if (!disposed) playback.introDone(); },
|
|
137
|
+
onComplete: () => { if (!disposed) { playback.introDone(); notify(); } },
|
|
115
138
|
});
|
|
116
139
|
}
|
|
117
140
|
|
|
@@ -120,11 +143,12 @@ export function createPreviewPlayer(viewer, animation, { reducedMotion = false }
|
|
|
120
143
|
if (!snap) return; // not playing: intro, paused, or done — the last poses stand
|
|
121
144
|
apply(snap.t);
|
|
122
145
|
if (snap.cue) viewer.tweenCameraTo(snap.cue.view, { duration: snap.cue.sweep ?? INTRO_TWEEN_SECONDS });
|
|
146
|
+
notify();
|
|
123
147
|
}
|
|
124
148
|
|
|
125
149
|
function onOrbit() {
|
|
126
150
|
playback.disarmCues();
|
|
127
|
-
if (playback.state().status === "intro") playback.introDone();
|
|
151
|
+
if (playback.state().status === "intro") { playback.introDone(); notify(); }
|
|
128
152
|
}
|
|
129
153
|
|
|
130
154
|
return {
|
|
@@ -137,25 +161,44 @@ export function createPreviewPlayer(viewer, animation, { reducedMotion = false }
|
|
|
137
161
|
offFrame = viewer.onFrame(onFrame);
|
|
138
162
|
offOrbit = viewer.onCameraStart(onOrbit);
|
|
139
163
|
honorIntro(playback.play());
|
|
164
|
+
notify();
|
|
140
165
|
},
|
|
141
166
|
pause() {
|
|
142
|
-
if (!
|
|
167
|
+
if (disposed || !offFrame) return;
|
|
168
|
+
const { status } = playback.state();
|
|
169
|
+
if (status !== "playing" && status !== "intro") return;
|
|
170
|
+
viewer.cancelCameraTween(); // an intro tween or a cue's sweep stops where it is
|
|
171
|
+
playback.pause();
|
|
172
|
+
notify();
|
|
143
173
|
},
|
|
144
174
|
resume() {
|
|
145
175
|
if (disposed || playback.state().status !== "paused") return;
|
|
146
176
|
honorIntro(playback.play());
|
|
177
|
+
notify();
|
|
178
|
+
},
|
|
179
|
+
// From `done` only (a one-time animation resting at its end pose): play it
|
|
180
|
+
// again exactly as on arrival — t = 0 pose and opacity first, then the
|
|
181
|
+
// re-armed intro cue (createPlayback's play() from done resets t and cues).
|
|
182
|
+
replay() {
|
|
183
|
+
if (disposed || playback.state().status !== "done") return;
|
|
184
|
+
apply(0);
|
|
185
|
+
honorIntro(playback.play());
|
|
186
|
+
notify();
|
|
187
|
+
},
|
|
188
|
+
onChange(cb) {
|
|
189
|
+
if (disposed) return () => {};
|
|
190
|
+
listeners.add(cb);
|
|
191
|
+
return () => listeners.delete(cb);
|
|
147
192
|
},
|
|
148
193
|
dispose() {
|
|
149
194
|
if (disposed) return;
|
|
150
195
|
disposed = true;
|
|
196
|
+
listeners.clear();
|
|
151
197
|
if (!offFrame) return; // never played (not started, or reduced motion): nothing to undo
|
|
152
198
|
offFrame(); offOrbit();
|
|
153
199
|
offFrame = offOrbit = null;
|
|
154
200
|
viewer.holdGround(false);
|
|
155
201
|
},
|
|
156
|
-
state
|
|
157
|
-
const { status, t } = playback.state();
|
|
158
|
-
return { status, t };
|
|
159
|
-
},
|
|
202
|
+
state: snapshotState,
|
|
160
203
|
};
|
|
161
204
|
}
|
package/types/index.d.ts
CHANGED
|
@@ -809,6 +809,8 @@ export interface PreviewRuntime {
|
|
|
809
809
|
state(): { status: "idle" | "intro" | "playing" | "paused" | "done"; t: number };
|
|
810
810
|
pause(): void;
|
|
811
811
|
resume(): void;
|
|
812
|
+
/** Play a finished one-time animation again from the start (its camera cues included); a no-op unless `state().status` is `"done"`. */
|
|
813
|
+
replay(): void;
|
|
812
814
|
} | null;
|
|
813
815
|
renderMode: {
|
|
814
816
|
get(): RenderMode;
|