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.
- package/README.md +31 -10
- package/bin/cli.js +100 -27
- package/docs/AUTHORING-PARTS.md +126 -14
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +48 -7
- package/skills/partforge/SKILL.md +17 -3
- package/src/app-embed-test.js +1 -1
- package/src/app-hinged-box.js +12 -0
- package/src/framework/animation-controls.js +243 -0
- package/src/framework/animation.js +217 -0
- package/src/framework/app.css +32 -0
- package/src/framework/assembly.js +1 -1
- package/src/framework/backend-select.js +25 -0
- package/src/framework/camera-tween.js +58 -0
- package/src/framework/chrome.css +16 -0
- package/src/framework/controls.js +13 -3
- package/src/framework/cutaway-gizmo-scene.js +244 -0
- package/src/framework/cutaway-gizmo.js +80 -243
- package/src/framework/default-view.js +46 -0
- package/src/framework/download.js +7 -2
- package/src/framework/export-controller.js +13 -2
- package/src/framework/geometry/probe.js +3 -22
- package/src/framework/jobs.js +9 -40
- package/src/framework/lint/finding.js +4 -0
- package/src/framework/lint/index.js +7 -3
- package/src/framework/lint/rules-animations.js +404 -0
- package/src/framework/lint/rules-place.js +76 -0
- package/src/framework/lint/rules-shape.js +12 -0
- package/src/framework/lint/rules-verify.js +2 -2
- package/src/framework/mount.js +93 -18
- package/src/{testing → framework/oracle}/build.js +1 -1
- package/src/{testing → framework/oracle}/bvh.js +1 -1
- package/src/{testing → framework/oracle}/measure.js +1 -1
- package/src/{testing → framework/oracle}/min-wall.js +1 -1
- package/src/{testing → framework/oracle}/verify.js +3 -3
- package/src/framework/param-deps.js +1 -1
- package/src/framework/part-model.js +48 -0
- package/src/framework/pick-request/client.js +11 -3
- package/src/framework/pick-request/endpoint.js +60 -0
- package/src/framework/pick-request/index.js +6 -0
- package/src/framework/pick-request/server.js +222 -34
- package/src/framework/pick-request/token-store.js +31 -0
- package/src/framework/pose-fast-path.js +12 -1
- package/src/framework/pose-probe-core.js +129 -0
- package/src/framework/pose-probe.js +7 -123
- package/src/framework/regen-loop.js +10 -3
- package/src/framework/safe-name.js +26 -0
- package/src/framework/verify-metrics.js +4 -4
- package/src/framework/view-state.js +25 -21
- package/src/framework/view-tabs.js +22 -7
- package/src/framework/viewer-controls.js +5 -26
- package/src/framework/viewer.js +58 -17
- package/src/hinged-box-worker.js +3 -0
- package/src/index.js +1 -1
- package/src/parts/hinged-box.js +94 -0
- package/src/testing/render.js +19 -8
- package/src/testing.js +15 -8
- package/types/derive.d.ts +14 -0
- package/types/geometry.d.ts +117 -0
- package/types/index.d.ts +240 -0
- package/types/kernel.d.ts +409 -0
- package/types/lint.d.ts +85 -0
- package/types/part.d.ts +381 -0
- package/types/testing.d.ts +362 -0
- package/types/worker.d.ts +21 -0
- /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
- /package/src/{testing → framework/oracle}/cases.js +0 -0
- /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
- /package/src/{testing → framework/oracle}/gaps.js +0 -0
- /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
|
+
}
|
package/src/framework/app.css
CHANGED
|
@@ -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 "./
|
|
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
|
+
}
|
package/src/framework/chrome.css
CHANGED
|
@@ -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
|
|
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";
|