partforge 0.41.0 → 0.44.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.
Files changed (70) hide show
  1. package/README.md +31 -10
  2. package/bin/cli.js +100 -27
  3. package/docs/AUTHORING-PARTS.md +126 -14
  4. package/docs/ERROR-PATTERNS.md +6 -0
  5. package/package.json +48 -7
  6. package/skills/partforge/SKILL.md +17 -3
  7. package/src/app-embed-test.js +1 -1
  8. package/src/app-hinged-box.js +12 -0
  9. package/src/framework/animation-controls.js +243 -0
  10. package/src/framework/animation.js +217 -0
  11. package/src/framework/app.css +32 -0
  12. package/src/framework/assembly.js +1 -1
  13. package/src/framework/backend-select.js +25 -0
  14. package/src/framework/camera-tween.js +58 -0
  15. package/src/framework/chrome.css +16 -0
  16. package/src/framework/controls.js +13 -3
  17. package/src/framework/cutaway-gizmo-scene.js +244 -0
  18. package/src/framework/cutaway-gizmo.js +80 -243
  19. package/src/framework/default-view.js +46 -0
  20. package/src/framework/download.js +7 -2
  21. package/src/framework/export-controller.js +13 -2
  22. package/src/framework/geometry/probe.js +3 -22
  23. package/src/framework/jobs.js +9 -40
  24. package/src/framework/lint/finding.js +4 -0
  25. package/src/framework/lint/index.js +7 -3
  26. package/src/framework/lint/rules-animations.js +404 -0
  27. package/src/framework/lint/rules-place.js +76 -0
  28. package/src/framework/lint/rules-shape.js +12 -0
  29. package/src/framework/lint/rules-verify.js +2 -2
  30. package/src/framework/mount.js +93 -18
  31. package/src/{testing → framework/oracle}/build.js +1 -1
  32. package/src/{testing → framework/oracle}/bvh.js +1 -1
  33. package/src/{testing → framework/oracle}/measure.js +1 -1
  34. package/src/{testing → framework/oracle}/min-wall.js +1 -1
  35. package/src/{testing → framework/oracle}/verify.js +3 -3
  36. package/src/framework/param-deps.js +1 -1
  37. package/src/framework/part-model.js +48 -0
  38. package/src/framework/pick-request/client.js +11 -3
  39. package/src/framework/pick-request/endpoint.js +60 -0
  40. package/src/framework/pick-request/index.js +6 -0
  41. package/src/framework/pick-request/server.js +222 -34
  42. package/src/framework/pick-request/token-store.js +31 -0
  43. package/src/framework/pose-fast-path.js +12 -1
  44. package/src/framework/pose-probe-core.js +129 -0
  45. package/src/framework/pose-probe.js +7 -123
  46. package/src/framework/regen-loop.js +10 -3
  47. package/src/framework/safe-name.js +26 -0
  48. package/src/framework/verify-metrics.js +4 -4
  49. package/src/framework/view-state.js +25 -21
  50. package/src/framework/view-tabs.js +22 -7
  51. package/src/framework/viewer-controls.js +5 -26
  52. package/src/framework/viewer.js +58 -17
  53. package/src/hinged-box-worker.js +3 -0
  54. package/src/index.js +1 -1
  55. package/src/parts/hinged-box.js +94 -0
  56. package/src/testing/render.js +19 -8
  57. package/src/testing.js +15 -8
  58. package/types/derive.d.ts +14 -0
  59. package/types/geometry.d.ts +117 -0
  60. package/types/index.d.ts +240 -0
  61. package/types/kernel.d.ts +409 -0
  62. package/types/lint.d.ts +85 -0
  63. package/types/part.d.ts +381 -0
  64. package/types/testing.d.ts +362 -0
  65. package/types/worker.d.ts +21 -0
  66. /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
  67. /package/src/{testing → framework/oracle}/cases.js +0 -0
  68. /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
  69. /package/src/{testing → framework/oracle}/gaps.js +0 -0
  70. /package/src/{testing → framework/oracle}/mesh.js +0 -0
@@ -0,0 +1,243 @@
1
+ // Transport bar + playback driver for part-declared animations. The bar is
2
+ // framework-generated DOM appended to the stage (no host markup needed, like
3
+ // the debug overlay); the driver ticks the pure playback state machine
4
+ // (animation.js) from the viewer's frame loop and routes every param write
5
+ // through the mount-supplied applyValues hook — the same path as a slider
6
+ // edit, minus the regen debounce. Returns null when the part declares no
7
+ // (valid) animations, so mount can wire it unconditionally.
8
+ import { normalizeAnimations, createPlayback } from "./animation.js";
9
+ import { createInfoPopover, attachInfo } from "./controls.js";
10
+
11
+ function el(tag, className, text) {
12
+ const node = document.createElement(tag);
13
+ if (className) node.className = className;
14
+ if (text != null) node.textContent = text;
15
+ return node;
16
+ }
17
+ function btn(className, text, label) {
18
+ const b = el("button", className, text);
19
+ b.type = "button";
20
+ b.setAttribute("aria-label", label);
21
+ b.title = label;
22
+ return b;
23
+ }
24
+
25
+ export function attachAnimationControls(viewer, part, { container, applyValues, getParamValues }) {
26
+ // A malformed animations block must degrade to "no transport bar", never a
27
+ // crashed mount — lint reports the specifics; the viewer just goes without.
28
+ let animations;
29
+ try { animations = normalizeAnimations(part); } catch { animations = []; }
30
+ if (!animations.length) return null;
31
+
32
+ const reducedMotion = typeof matchMedia === "function"
33
+ && matchMedia("(prefers-reduced-motion: reduce)").matches;
34
+ const tweenDuration = reducedMotion ? 0 : 0.6; // reduced motion: jump cut, no sweep
35
+
36
+ let current = animations[0];
37
+ let playback = createPlayback(current);
38
+ let snapshot = null; // tracked-param values before this animation first drove them
39
+
40
+ // Autoplay: at most one animation declares it (lint-enforced). Armed until
41
+ // the user manually touches the transport — and never armed at all under
42
+ // prefers-reduced-motion: self-starting motion is exactly what that setting
43
+ // opts out of. The transport still plays everything on request.
44
+ const autoplayAnim = animations.find((a) => a.autoplay) ?? null;
45
+ let autoplayArmed = !!autoplayAnim && !reducedMotion;
46
+ const disarmAutoplay = () => { autoplayArmed = false; };
47
+
48
+ // --- DOM --------------------------------------------------------------------
49
+ const bar = el("div", "pf-anim-bar");
50
+ const info = createInfoPopover();
51
+
52
+ const pick = document.createElement("select");
53
+ pick.className = "pf-anim-pick";
54
+ pick.setAttribute("aria-label", "Choose animation");
55
+ for (const a of animations) {
56
+ const o = document.createElement("option");
57
+ o.value = a.name; o.textContent = a.label;
58
+ pick.append(o);
59
+ }
60
+ const title = el("span", "pf-anim-title", "");
61
+ bar.append(animations.length > 1 ? pick : title);
62
+ const infoSlot = el("span", "pf-anim-info");
63
+ const playBtn = btn("pf-anim-play", "▶", "Play animation");
64
+ const prevBtn = btn("pf-anim-step-btn", "‹", "Previous step");
65
+ const stepLabel = el("span", "pf-anim-step", "");
66
+ const nextBtn = btn("pf-anim-step-btn", "›", "Next step");
67
+ const scrubWrap = el("span", "pf-anim-scrub-wrap");
68
+ const scrub = document.createElement("input");
69
+ scrub.type = "range";
70
+ scrub.min = "0"; scrub.max = "1000"; scrub.step = "1"; scrub.value = "0";
71
+ scrub.className = "pf-anim-scrub";
72
+ scrub.setAttribute("aria-label", "Animation position");
73
+ scrubWrap.append(scrub);
74
+ const resetBtn = btn("pf-anim-reset", "↺", "Reset animation");
75
+ bar.append(infoSlot, playBtn, prevBtn, stepLabel, nextBtn, scrubWrap, resetBtn);
76
+ container.append(bar);
77
+
78
+ // Per-animation chrome: title, ⓘ description, step buttons, scrubber ticks.
79
+ function syncStructure() {
80
+ title.textContent = current.label;
81
+ infoSlot.replaceChildren();
82
+ attachInfo(infoSlot, current.description ?? "", info);
83
+ const stepped = current.steps.length > 1;
84
+ prevBtn.hidden = nextBtn.hidden = stepLabel.hidden = !stepped;
85
+ for (const n of scrubWrap.querySelectorAll(".pf-anim-tick")) n.remove();
86
+ if (stepped) {
87
+ for (const t of current.stepStarts.slice(1)) {
88
+ const tick = el("span", "pf-anim-tick");
89
+ tick.style.left = `${t * 100}%`;
90
+ scrubWrap.append(tick);
91
+ }
92
+ }
93
+ }
94
+
95
+ function syncUi() {
96
+ const { status, t, stepIndex } = playback.state();
97
+ const active = status === "playing" || status === "intro";
98
+ playBtn.textContent = active ? "⏸" : "▶";
99
+ playBtn.setAttribute("aria-label", active ? "Pause animation" : "Play animation");
100
+ playBtn.title = playBtn.getAttribute("aria-label");
101
+ scrub.value = String(Math.round(t * 1000));
102
+ if (current.steps.length > 1) {
103
+ const step = current.steps[stepIndex];
104
+ stepLabel.textContent = `${stepIndex + 1}/${current.steps.length} · ${step.label}`;
105
+ }
106
+ }
107
+
108
+ // --- driver -----------------------------------------------------------------
109
+ // A frame that throws — a malformed cue or track that slipped past lint, a
110
+ // viewer that rejects a view name — must cost that frame, not the render
111
+ // loop: this callback runs from the viewer's frame listeners, and letting it
112
+ // propagate would take the other listeners down with it. Warn once, then
113
+ // stay quiet so a bad frame can't flood the console 60x a second.
114
+ let frameFailureWarned = false;
115
+ function apply(r) {
116
+ if (!r) return;
117
+ try {
118
+ // First write for this run: remember what the user's params were, so Reset
119
+ // can put them back.
120
+ if (snapshot == null && Object.keys(r.values).length) snapshot = getParamValues(current.trackedKeys);
121
+ applyValues(r.values);
122
+ if (r.cue) {
123
+ viewer.tweenCameraTo(r.cue.view, {
124
+ duration: tweenDuration,
125
+ // An intro cue gates playback until the tween settles; mid-timeline
126
+ // cues overlap playback and need no completion signal.
127
+ onComplete: r.status === "intro" ? () => apply(playback.introDone()) : undefined,
128
+ });
129
+ }
130
+ syncUi();
131
+ } catch (err) {
132
+ if (!frameFailureWarned) {
133
+ frameFailureWarned = true;
134
+ console.warn("partforge: animation frame failed", err);
135
+ }
136
+ }
137
+ }
138
+
139
+ function doReset() {
140
+ playback.reset();
141
+ viewer.cancelCameraTween();
142
+ if (snapshot) { applyValues(snapshot); snapshot = null; }
143
+ syncUi();
144
+ }
145
+
146
+ function selectAnimation(name) {
147
+ const next = animations.find((a) => a.name === name);
148
+ if (!next || next === current) return;
149
+ doReset();
150
+ current = next;
151
+ playback = createPlayback(current);
152
+ if (animations.length > 1) pick.value = name;
153
+ syncStructure();
154
+ syncUi();
155
+ }
156
+
157
+ const offFrame = viewer.onFrame((dt) => apply(playback.tick(dt)));
158
+ // User orbit: the viewer has already cancelled any cue tween (its own
159
+ // "start" handler); disarm the remaining cues, and if an intro tween was
160
+ // gating playback, settle the gate — cancel() never fires onComplete, so
161
+ // without this the machine would sit in "intro" forever.
162
+ const offOrbit = viewer.onCameraStart(() => {
163
+ playback.disarmCues();
164
+ if (playback.state().status === "intro") apply(playback.introDone());
165
+ });
166
+
167
+ const onPlayClick = () => {
168
+ disarmAutoplay();
169
+ const active = playback.state().status;
170
+ if (active === "playing" || active === "intro") {
171
+ viewer.cancelCameraTween();
172
+ apply(playback.pause());
173
+ } else {
174
+ apply(playback.play());
175
+ }
176
+ };
177
+ const onScrub = () => { disarmAutoplay(); apply(playback.seek(Number(scrub.value) / 1000)); };
178
+ const onPrev = () => { disarmAutoplay(); apply(playback.stepPrev()); };
179
+ const onNext = () => { disarmAutoplay(); apply(playback.stepNext()); };
180
+ const onPick = () => { disarmAutoplay(); selectAnimation(pick.value); };
181
+ const onResetClick = () => { disarmAutoplay(); doReset(); };
182
+ playBtn.addEventListener("click", onPlayClick);
183
+ scrub.addEventListener("input", onScrub);
184
+ prevBtn.addEventListener("click", onPrev);
185
+ nextBtn.addEventListener("click", onNext);
186
+ pick.addEventListener("change", onPick);
187
+ resetBtn.addEventListener("click", onResetClick);
188
+
189
+ syncStructure();
190
+ syncUi();
191
+
192
+ const runtime = {
193
+ // An unknown name is a host bug, not a request to play whatever happens to
194
+ // be selected — say so and do nothing rather than silently animating
195
+ // something else.
196
+ play(name) {
197
+ disarmAutoplay();
198
+ if (name != null && !animations.some((a) => a.name === name)) {
199
+ console.warn(`partforge: unknown animation "${name}"`);
200
+ return;
201
+ }
202
+ if (name) selectAnimation(name);
203
+ apply(playback.play());
204
+ },
205
+ pause() { disarmAutoplay(); viewer.cancelCameraTween(); apply(playback.pause()); },
206
+ seek(t) { disarmAutoplay(); apply(playback.seek(t)); },
207
+ stop() { disarmAutoplay(); doReset(); },
208
+ state: () => ({ animation: current.name, ...playback.state() }),
209
+ };
210
+
211
+ const handle = {
212
+ runtime,
213
+ // A user edit to any control (or a host setParams) takes over the params:
214
+ // pause playback rather than fight over them.
215
+ notifyUserEdit() {
216
+ disarmAutoplay();
217
+ viewer.cancelCameraTween();
218
+ playback.userEdited();
219
+ syncUi();
220
+ },
221
+ // Mount calls this on first ready and on every view/tab switch.
222
+ autoplayKick() {
223
+ if (!autoplayArmed || !autoplayAnim) return;
224
+ if (current !== autoplayAnim) selectAnimation(autoplayAnim.name);
225
+ const { status } = playback.state();
226
+ if (status !== "playing" && status !== "intro") apply(playback.play());
227
+ },
228
+ detach() {
229
+ offFrame();
230
+ offOrbit();
231
+ playBtn.removeEventListener("click", onPlayClick);
232
+ scrub.removeEventListener("input", onScrub);
233
+ prevBtn.removeEventListener("click", onPrev);
234
+ nextBtn.removeEventListener("click", onNext);
235
+ pick.removeEventListener("change", onPick);
236
+ resetBtn.removeEventListener("click", onResetClick);
237
+ info.dispose();
238
+ bar.remove();
239
+ },
240
+ __viewer: viewer, // test hook only
241
+ };
242
+ return handle;
243
+ }
@@ -0,0 +1,217 @@
1
+ // Timeline model + playback state machine for part-declared animations
2
+ // (spec: docs/superpowers/specs/2026-08-02-model-animation-design.md).
3
+ // Pure and import-free on purpose: no DOM, no clock, no three — the driver
4
+ // (animation-controls.js) owns time and the viewer owns rendering, and both
5
+ // partforge/lint and the Node CLI import this module, so it must satisfy the
6
+ // lint purity guarantee (test/lint-purity.test.js).
7
+
8
+ export const EASINGS = {
9
+ linear: (t) => t,
10
+ "ease-in": (t) => t * t,
11
+ "ease-out": (t) => 1 - (1 - t) * (1 - t),
12
+ "ease-in-out": (t) => t * t * (3 - 2 * t),
13
+ };
14
+ export const DEFAULT_EASING = "ease-in-out";
15
+
16
+ // Normalize one animations-map entry to the canonical shape every consumer
17
+ // (playback, transport UI, lint, CLI) works against: a step list (a bare
18
+ // `tracks` form becomes one anonymous step), normalized step starts, and the
19
+ // camera declaration desugared to a sorted cue list. Assumes lint-valid input;
20
+ // runtime callers guard with try/catch (see attachAnimationControls).
21
+ export function normalizeAnimation(name, spec) {
22
+ const steps = spec.steps
23
+ ? spec.steps.map((s, i) => ({
24
+ label: s.label ?? `Step ${i + 1}`,
25
+ duration: s.duration,
26
+ easing: s.easing ?? spec.easing ?? DEFAULT_EASING,
27
+ tracks: s.tracks ?? {},
28
+ camera: s.camera ?? null,
29
+ }))
30
+ : [{
31
+ label: null, duration: spec.duration,
32
+ easing: spec.easing ?? DEFAULT_EASING,
33
+ tracks: spec.tracks ?? {}, camera: null,
34
+ }];
35
+ const totalDuration = steps.reduce((sum, s) => sum + s.duration, 0) || 1;
36
+ let acc = 0;
37
+ const stepStarts = steps.map((s) => { const t = acc / totalDuration; acc += s.duration; return t; });
38
+ let cues;
39
+ if (typeof spec.camera === "string") cues = [{ t: 0, view: spec.camera }];
40
+ else if (Array.isArray(spec.camera)) cues = spec.camera.map(([t, view]) => ({ t, view }));
41
+ else cues = steps.flatMap((s, i) => (s.camera ? [{ t: stepStarts[i], view: s.camera }] : []));
42
+ const trackedKeys = [...new Set(steps.flatMap((s) => Object.keys(s.tracks)))];
43
+ return {
44
+ name, label: spec.label ?? name, description: spec.description ?? null,
45
+ loop: !!spec.loop, autoplay: !!spec.autoplay, steps, stepStarts, totalDuration, cues, trackedKeys,
46
+ };
47
+ }
48
+
49
+ export function normalizeAnimations(part) {
50
+ return Object.entries(part?.animations ?? {}).map(([name, spec]) => normalizeAnimation(name, spec));
51
+ }
52
+
53
+ // Step containing t. Boundaries belong to the LATER step, and t clamps to [0,1].
54
+ export function stepIndexAt(anim, t) {
55
+ const tc = Math.min(1, Math.max(0, t));
56
+ let idx = 0;
57
+ for (let i = 0; i < anim.stepStarts.length; i++) if (tc >= anim.stepStarts[i]) idx = i;
58
+ return idx;
59
+ }
60
+
61
+ // Most recent cue at or before t, or null. This is both the CLI's default
62
+ // camera for a still and the cue play() honors when starting mid-timeline.
63
+ export function cueAt(anim, t) {
64
+ let g = null;
65
+ for (const c of anim.cues) if (c.t <= t) g = c;
66
+ return g;
67
+ }
68
+
69
+ // The timeline segments (global [start,end] spans) in which `key` is tracked.
70
+ function segmentsFor(anim, key) {
71
+ const out = [];
72
+ anim.steps.forEach((step, i) => {
73
+ const kf = step.tracks[key];
74
+ if (!kf) return;
75
+ const start = anim.stepStarts[i];
76
+ const end = i + 1 < anim.steps.length ? anim.stepStarts[i + 1] : 1;
77
+ out.push({ start, end, keyframes: kf, easing: step.easing });
78
+ });
79
+ return out;
80
+ }
81
+
82
+ // Piecewise-linear keyframe interpolation at (already-eased) local time u.
83
+ function interpKeyframes(kf, u) {
84
+ if (u <= kf[0][0]) return kf[0][1];
85
+ for (let i = 1; i < kf.length; i++) {
86
+ const [t1, v1] = kf[i];
87
+ if (u <= t1) {
88
+ const [t0, v0] = kf[i - 1];
89
+ return t1 === t0 ? v1 : v0 + (v1 - v0) * ((u - t0) / (t1 - t0));
90
+ }
91
+ }
92
+ return kf[kf.length - 1][1];
93
+ }
94
+
95
+ function evaluateTrack(anim, key, t) {
96
+ const segs = segmentsFor(anim, key);
97
+ let prev = null;
98
+ for (const seg of segs) {
99
+ if (t < seg.start) break;
100
+ if (t <= seg.end) {
101
+ const span = seg.end - seg.start || 1;
102
+ const local = (EASINGS[seg.easing] ?? EASINGS[DEFAULT_EASING])((t - seg.start) / span);
103
+ return interpKeyframes(seg.keyframes, local);
104
+ }
105
+ prev = seg;
106
+ }
107
+ // Outside every segment: hold the nearest boundary value, so a param tracked
108
+ // only in step 2 doesn't jump while step 1 plays.
109
+ return prev ? prev.keyframes[prev.keyframes.length - 1][1] : segs[0].keyframes[0][1];
110
+ }
111
+
112
+ // Evaluate the whole animation at normalized position t ∈ [0,1] (over the
113
+ // TOTAL duration — the same t the scrubber, seek(t), and the CLI's --at use).
114
+ export function evaluate(anim, t) {
115
+ const tc = Math.min(1, Math.max(0, t));
116
+ const values = {};
117
+ for (const key of anim.trackedKeys) values[key] = evaluateTrack(anim, key, tc);
118
+ return { stepIndex: stepIndexAt(anim, tc), values };
119
+ }
120
+
121
+ // --- playback state machine --------------------------------------------------
122
+ // Owns WHAT the animation is doing (position, status, cue arming); the driver
123
+ // owns WHEN (it feeds dt from the viewer's frame loop) and WHERE the results
124
+ // go (params + camera tweens). Statuses: idle → intro (a governing camera cue
125
+ // is tweening; params hold) → playing → paused/done. "intro" is entered on any
126
+ // play() with an armed, unfired cue at-or-before the current position — that
127
+ // covers both the t=0 intro and play-from-the-middle honoring the governing
128
+ // cue. Cues crossed DURING playback fire without gating (overlapping tween).
129
+ export function createPlayback(anim) {
130
+ let status = "idle";
131
+ let t = 0;
132
+ let armed = true; // user orbit disarms cues until reset/replay
133
+ let firedCueT = -1; // cues with t <= firedCueT already fired this run
134
+ let stopAt = null; // stepNext/playStep pause playback on reaching this t
135
+
136
+ const snapshot = (cue = null) => ({ t, status, ...evaluate(anim, t), cue });
137
+
138
+ const governingCue = () => {
139
+ if (!armed) return null;
140
+ let g = null;
141
+ for (const c of anim.cues) if (c.t <= t && c.t > firedCueT) g = c;
142
+ return g;
143
+ };
144
+
145
+ function begin() {
146
+ const cue = governingCue();
147
+ if (cue) { firedCueT = Math.max(firedCueT, cue.t); status = "intro"; }
148
+ else status = "playing";
149
+ return snapshot(cue);
150
+ }
151
+
152
+ function play() {
153
+ if (status === "playing" || status === "intro") return snapshot();
154
+ if (t >= 1 && !anim.loop) { t = 0; firedCueT = -1; armed = true; } // replay from start re-arms
155
+ stopAt = null;
156
+ return begin();
157
+ }
158
+ function pause() {
159
+ if (status === "playing" || status === "intro") status = "paused";
160
+ return snapshot();
161
+ }
162
+ function introDone() {
163
+ if (status === "intro") status = "playing";
164
+ return snapshot();
165
+ }
166
+ function seek(v) {
167
+ t = Math.min(1, Math.max(0, v));
168
+ status = "paused";
169
+ stopAt = null;
170
+ firedCueT = -1; // a later play() re-honors the cue governing the new position
171
+ return snapshot();
172
+ }
173
+ function playStep(i) {
174
+ const idx = Math.min(anim.steps.length - 1, Math.max(0, i));
175
+ t = anim.stepStarts[idx];
176
+ stopAt = idx + 1 < anim.steps.length ? anim.stepStarts[idx + 1] : 1;
177
+ firedCueT = -1;
178
+ return begin();
179
+ }
180
+ function stepNext() {
181
+ const cur = stepIndexAt(anim, t);
182
+ return cur + 1 < anim.steps.length ? playStep(cur + 1) : snapshot();
183
+ }
184
+ function stepPrev() {
185
+ return playStep(Math.max(0, stepIndexAt(anim, t) - 1));
186
+ }
187
+ function reset() {
188
+ t = 0; status = "idle"; stopAt = null; firedCueT = -1; armed = true;
189
+ return snapshot();
190
+ }
191
+ function disarmCues() { armed = false; }
192
+ function userEdited() { if (status === "playing" || status === "intro") status = "paused"; }
193
+
194
+ function tick(dt) {
195
+ if (status !== "playing" || !(dt > 0)) return null;
196
+ t += dt / anim.totalDuration;
197
+ if (anim.loop) {
198
+ if (t >= 1) t -= Math.floor(t);
199
+ } else if (stopAt != null && t >= stopAt) {
200
+ t = stopAt; stopAt = null; status = "paused";
201
+ } else if (t >= 1) {
202
+ t = 1; status = "done";
203
+ }
204
+ let cue = null;
205
+ if (armed) {
206
+ for (const c of anim.cues) if (c.t <= t && c.t > firedCueT) cue = c;
207
+ if (cue) firedCueT = cue.t;
208
+ }
209
+ return snapshot(cue);
210
+ }
211
+
212
+ return {
213
+ play, pause, toggle: () => (status === "playing" || status === "intro" ? pause() : play()),
214
+ introDone, seek, stepNext, stepPrev, playStep, reset, disarmCues, userEdited, tick,
215
+ state: () => ({ status, t, stepIndex: stepIndexAt(anim, t) }),
216
+ };
217
+ }
@@ -217,6 +217,38 @@ button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-vi
217
217
  #viewbar .pf-cutaway-actions button { min-width: 44px; padding: 0 6px; }
218
218
  }
219
219
 
220
+ /* animation transport bar (placement: chrome.css's .pf-anim-bar). APPEARANCE
221
+ is ungated for the same reason as #viewbar above. */
222
+ .pf-anim-bar {
223
+ display: flex; align-items: center; gap: 8px;
224
+ padding: 6px 10px;
225
+ background: var(--pf-surface); border: 1px solid var(--pf-border);
226
+ border-radius: var(--pf-radius-control); box-shadow: var(--pf-shadow-float);
227
+ }
228
+ .pf-anim-bar button {
229
+ border: 0; background: transparent; color: var(--pf-muted);
230
+ cursor: pointer; font-size: 13px; padding: 2px 4px;
231
+ }
232
+ .pf-anim-bar button:hover { color: var(--pf-text-2); }
233
+ .pf-anim-title, .pf-anim-pick {
234
+ font-family: var(--pf-mono); font-size: 11px; color: var(--pf-text-2);
235
+ }
236
+ .pf-anim-pick {
237
+ background: var(--pf-input-bg); border: 1px solid var(--pf-border);
238
+ border-radius: var(--pf-radius-control); padding: 3px 6px;
239
+ }
240
+ .pf-anim-step {
241
+ font-family: var(--pf-mono); font-size: 10px; color: var(--pf-muted-2);
242
+ min-width: 90px; text-align: center; white-space: nowrap;
243
+ overflow: hidden; text-overflow: ellipsis;
244
+ }
245
+ .pf-anim-scrub-wrap { position: relative; display: inline-flex; align-items: center; width: 140px; }
246
+ .pf-anim-scrub { width: 100%; accent-color: var(--pf-accent); }
247
+ .pf-anim-tick {
248
+ position: absolute; top: 50%; width: 2px; height: 8px; margin-top: -4px;
249
+ background: var(--pf-muted); pointer-events: none;
250
+ }
251
+
220
252
  /* Legacy id-only markup only: classed markup's viewbar lives inside .pf-stage
221
253
  (bottom-right, see chrome.css's .pf-float-viewbar) so it never meets the
222
254
  top-left floating #panel card. Legacy markup still floats #viewbar top-right
@@ -1,4 +1,4 @@
1
- import { viewSubParts, resolveParams, buildPosed } from "./jobs.js";
1
+ import { viewSubParts, resolveParams, buildPosed } from "./part-model.js";
2
2
 
3
3
  // Collision check for an assembled view: build each sub-part in its display
4
4
  // (assembly) pose and return the pairs whose solid-intersection volume exceeds
@@ -0,0 +1,25 @@
1
+ // Chooses which geometry backend (manifold vs occt) a part should build against.
2
+ // This is framework-level policy, not geometry-kernel plumbing: it knows the full
3
+ // PartDefinition shape (meta.backend, defaults, parts[name].build), unlike
4
+ // everything in geometry/, which is part-agnostic.
5
+ import { OCCT_ONLY_OPS } from "./geometry/kernel.js";
6
+ import { createProbeKernel } from "./geometry/probe.js";
7
+ import { resolveDerived } from "./derive.js";
8
+
9
+ const OCCT_ONLY = new Set(OCCT_ONLY_OPS);
10
+
11
+ export function detectBackend(part, params = {}) {
12
+ if (part.meta?.backend) return part.meta.backend;
13
+ const p = { ...part.defaults, ...params };
14
+ let d = {};
15
+ // A throwing derive must not escape here — this runs on the main thread mid
16
+ // regen (after the busy spinner goes up). Probe with an empty `d`; the worker
17
+ // build hits the same throw and posts a proper error for the UI.
18
+ try { d = resolveDerived(part, p); } catch { /* fall through with d = {} */ }
19
+ const { kernel, used } = createProbeKernel();
20
+ for (const name of Object.keys(part.parts)) {
21
+ try { part.parts[name].build(kernel, p, d); } catch { /* probe miss → capability backstop covers it */ }
22
+ }
23
+ for (const op of used) if (OCCT_ONLY.has(op)) return "occt";
24
+ return "manifold";
25
+ }
@@ -0,0 +1,58 @@
1
+ import * as THREE from "three";
2
+ import { EASINGS } from "./animation.js";
3
+
4
+ // Retargetable orbit-camera tween for animation camera cues: eased spherical
5
+ // interpolation of {position, target} pairs about the (linearly moving) orbit
6
+ // target, shortest-path in azimuth, clamped off the poles so OrbitControls
7
+ // never gimbal-locks on a "top"/"bottom" cue. Pure math, no clock — the viewer
8
+ // feeds dt seconds into update() each frame and applies the returned pose.
9
+ const POLE_EPS = 0.01;
10
+
11
+ function toSpherical(position, target) {
12
+ const off = new THREE.Vector3().fromArray(position).sub(new THREE.Vector3().fromArray(target));
13
+ const sph = new THREE.Spherical().setFromVector3(off);
14
+ sph.phi = Math.min(Math.PI - POLE_EPS, Math.max(POLE_EPS, sph.phi));
15
+ return sph;
16
+ }
17
+
18
+ export function createCameraTween() {
19
+ let tw = null; // { fromSph, toSph, fromTarget, toTarget, duration, elapsed, onComplete }
20
+
21
+ // `from` is always the CALLER's current pose, which is what makes a restart
22
+ // mid-flight retarget smoothly: the new tween begins wherever the camera is.
23
+ function start(from, to, { duration = 0.6, onComplete } = {}) {
24
+ const fromSph = toSpherical(from.position, from.target);
25
+ const toSph = toSpherical(to.position, to.target);
26
+ const d = toSph.theta - fromSph.theta;
27
+ if (d > Math.PI) toSph.theta -= 2 * Math.PI;
28
+ if (d < -Math.PI) toSph.theta += 2 * Math.PI;
29
+ tw = {
30
+ fromSph, toSph,
31
+ fromTarget: new THREE.Vector3().fromArray(from.target),
32
+ toTarget: new THREE.Vector3().fromArray(to.target),
33
+ duration, elapsed: 0, onComplete,
34
+ };
35
+ }
36
+
37
+ function update(dt) {
38
+ if (!tw) return null;
39
+ tw.elapsed += dt;
40
+ const done = tw.elapsed >= tw.duration;
41
+ const u = done ? 1 : EASINGS["ease-in-out"](tw.elapsed / tw.duration);
42
+ const lerp = (a, b) => a + (b - a) * u;
43
+ const sph = new THREE.Spherical(
44
+ lerp(tw.fromSph.radius, tw.toSph.radius),
45
+ lerp(tw.fromSph.phi, tw.toSph.phi),
46
+ lerp(tw.fromSph.theta, tw.toSph.theta),
47
+ );
48
+ const target = tw.fromTarget.clone().lerp(tw.toTarget, u);
49
+ const position = new THREE.Vector3().setFromSpherical(sph).add(target);
50
+ const onComplete = tw.onComplete;
51
+ if (done) tw = null;
52
+ const out = { position: position.toArray(), target: target.toArray(), done };
53
+ if (done) onComplete?.();
54
+ return out;
55
+ }
56
+
57
+ return { start, update, cancel: () => { tw = null; }, isActive: () => !!tw };
58
+ }
@@ -161,6 +161,14 @@
161
161
  .pf-float-tabs { top: 12px; left: 50%; transform: translateX(-50%); }
162
162
  .pf-float-viewbar { bottom: 12px; right: 12px; }
163
163
 
164
+ /* --- animation transport bar (generated by animation-controls.js) ----------
165
+ PLACEMENT ONLY, per the rule above; appearance lives in app.css next to
166
+ #viewbar's, so a host that re-anchors this bar still inherits its chrome. */
167
+ .pf-anim-bar {
168
+ position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%);
169
+ z-index: 15; max-width: calc(100% - 24px);
170
+ }
171
+
164
172
  /* ---- the narrow-layout tab bar ------------------------------------------
165
173
  Created by mobile-tabs.js (no host markup declares it). Hidden by default:
166
174
  below the breakpoint the rail sits beside the viewer and needs no tab, so
@@ -229,6 +237,14 @@
229
237
  }
230
238
  .pf-rail-seam { display: none; }
231
239
  .pf-tabbar { display: flex; }
240
+ /* Lift the transport bar clear of the viewbar. On a phone the bar is nearly
241
+ the full stage width (max-width: 100% - 24px) while #viewbar sits at the
242
+ bottom-right of the same stage, at the same z-index — so at bottom: 14px
243
+ the transport bar paints straight over cutaway/reframe/theme and
244
+ makes them unclickable. The viewbar occupies 12px…56px from the bottom
245
+ (12px offset + 4px padding + 34px button + 4px padding + 2px border);
246
+ 64px clears it with an 8px gap. */
247
+ .pf-anim-bar { bottom: 64px; }
232
248
  }
233
249
 
234
250
 
@@ -48,10 +48,20 @@ export function clampToRange(raw, min, max) {
48
48
  }
49
49
 
50
50
  // --- info glyph + per-panel popover -----------------------------------------
51
+ // Popover top edge: below the glyph when it fits, flipped above when the
52
+ // viewport bottom would clip it (e.g. the animation transport bar's ⓘ, which
53
+ // sits at the bottom of the stage). Pure, for direct unit testing — happy-dom
54
+ // reports zero layout metrics, so the flip can't be exercised via the DOM.
55
+ export function popoverTop({ glyphTop, glyphBottom, popHeight, viewportHeight }) {
56
+ const below = glyphBottom + 6;
57
+ if (below + popHeight <= viewportHeight - 8) return below;
58
+ return Math.max(8, glyphTop - 6 - popHeight);
59
+ }
60
+
51
61
  // One popover element per panel, shared by all its glyphs (only one open at a
52
62
  // time). Document-level dismiss listeners are registered per panel and removed
53
63
  // by panel.dispose().
54
- function createInfoPopover() {
64
+ export function createInfoPopover() {
55
65
  const pop = el("div", "popover");
56
66
  pop.hidden = true;
57
67
  document.body.append(pop);
@@ -78,7 +88,7 @@ function createInfoPopover() {
78
88
  owner = glyph;
79
89
  glyph.setAttribute("aria-expanded", "true");
80
90
  const r = glyph.getBoundingClientRect();
81
- pop.style.top = `${r.bottom + 6}px`;
91
+ pop.style.top = `${popoverTop({ glyphTop: r.top, glyphBottom: r.bottom, popHeight: pop.offsetHeight, viewportHeight: window.innerHeight })}px`;
82
92
  pop.style.left = `${Math.max(8, r.left - 8)}px`;
83
93
  },
84
94
  dispose() {
@@ -91,7 +101,7 @@ function createInfoPopover() {
91
101
 
92
102
  // Append a focusable ⓘ glyph to `container` that toggles the panel's shared
93
103
  // popover with `description` (Markdown). No-op when description is empty.
94
- function attachInfo(container, description, info) {
104
+ export function attachInfo(container, description, info) {
95
105
  if (typeof description !== "string" || !description.trim()) return;
96
106
  const glyph = document.createElement("button");
97
107
  glyph.type = "button";