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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.158.0",
3
+ "version": "0.159.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",
@@ -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
- // is added here, as the prediction rung does (subPartReadKeys runs enabled()).
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
- // Panel relevance from RECORDED reads: the union over on-screen sub-parts (a
92
- // not-yet-built one contributes its prediction — display only, so a wrong guess
93
- // costs one build's dimming), plus the gate params of every in-view sub-part.
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) { for (const k of rec) relevant.add(k); continue; }
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 (!disposed) playback.pause();
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;