partforge 0.41.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -10
- package/bin/cli.js +138 -27
- package/docs/AUTHORING-PARTS.md +164 -17
- 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 +254 -0
- package/src/framework/animation.js +271 -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/capture-build.js +59 -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 +30 -40
- package/src/framework/lint/finding.js +4 -0
- package/src/framework/lint/index.js +7 -3
- package/src/framework/lint/rules-animations.js +441 -0
- package/src/framework/lint/rules-place.js +76 -0
- package/src/framework/lint/rules-schema.js +22 -0
- package/src/framework/lint/rules-shape.js +12 -0
- package/src/framework/lint/rules-verify.js +2 -2
- package/src/framework/mount.js +147 -20
- 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 +35 -7
- package/src/framework/viewer-controls.js +5 -26
- package/src/framework/viewer-lighting.js +8 -1
- package/src/framework/viewer.js +139 -20
- package/src/framework/worker.js +5 -1
- 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 +259 -0
- package/types/kernel.d.ts +409 -0
- package/types/lint.d.ts +85 -0
- package/types/part.d.ts +409 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.0",
|
|
4
4
|
"description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,22 +16,61 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"src",
|
|
18
18
|
"bin",
|
|
19
|
+
"types",
|
|
19
20
|
"skills/partforge/SKILL.md",
|
|
20
21
|
"docs/AUTHORING-PARTS.md",
|
|
21
22
|
"docs/ERROR-PATTERNS.md",
|
|
22
23
|
"docs/KERNEL-CONTRACT.md",
|
|
23
24
|
"README.md"
|
|
24
25
|
],
|
|
26
|
+
"types": "./types/index.d.ts",
|
|
25
27
|
"exports": {
|
|
26
|
-
".":
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"./
|
|
31
|
-
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./types/index.d.ts",
|
|
30
|
+
"default": "./src/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./worker": {
|
|
33
|
+
"types": "./types/worker.d.ts",
|
|
34
|
+
"default": "./src/framework/worker.js"
|
|
35
|
+
},
|
|
36
|
+
"./geometry": {
|
|
37
|
+
"types": "./types/geometry.d.ts",
|
|
38
|
+
"default": "./src/framework/geometry/polygon.js"
|
|
39
|
+
},
|
|
40
|
+
"./lint": {
|
|
41
|
+
"types": "./types/lint.d.ts",
|
|
42
|
+
"default": "./src/lint.js"
|
|
43
|
+
},
|
|
44
|
+
"./derive": {
|
|
45
|
+
"types": "./types/derive.d.ts",
|
|
46
|
+
"default": "./src/framework/derive.js"
|
|
47
|
+
},
|
|
48
|
+
"./testing": {
|
|
49
|
+
"types": "./types/testing.d.ts",
|
|
50
|
+
"default": "./src/testing.js"
|
|
51
|
+
},
|
|
32
52
|
"./tokens.css": "./src/framework/tokens.css",
|
|
33
53
|
"./chrome.css": "./src/framework/chrome.css"
|
|
34
54
|
},
|
|
55
|
+
"typesVersions": {
|
|
56
|
+
"*": {
|
|
57
|
+
"worker": [
|
|
58
|
+
"./types/worker.d.ts"
|
|
59
|
+
],
|
|
60
|
+
"geometry": [
|
|
61
|
+
"./types/geometry.d.ts"
|
|
62
|
+
],
|
|
63
|
+
"lint": [
|
|
64
|
+
"./types/lint.d.ts"
|
|
65
|
+
],
|
|
66
|
+
"derive": [
|
|
67
|
+
"./types/derive.d.ts"
|
|
68
|
+
],
|
|
69
|
+
"testing": [
|
|
70
|
+
"./types/testing.d.ts"
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
},
|
|
35
74
|
"bin": {
|
|
36
75
|
"partforge": "./bin/cli.js"
|
|
37
76
|
},
|
|
@@ -41,6 +80,7 @@
|
|
|
41
80
|
"preview": "vite preview",
|
|
42
81
|
"test": "vitest run",
|
|
43
82
|
"test:watch": "vitest",
|
|
83
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
44
84
|
"check": "node scripts/check-app.mjs"
|
|
45
85
|
},
|
|
46
86
|
"dependencies": {
|
|
@@ -60,6 +100,7 @@
|
|
|
60
100
|
"@fontsource-variable/geist-mono": "^5.3.0",
|
|
61
101
|
"happy-dom": "^20.10.6",
|
|
62
102
|
"playwright": "^1.49.0",
|
|
103
|
+
"typescript": "^5.9.3",
|
|
63
104
|
"vite": "^8.0.16",
|
|
64
105
|
"vitest": "^4.1.9"
|
|
65
106
|
}
|
|
@@ -18,13 +18,24 @@ surface normal, the parameters they were viewing).
|
|
|
18
18
|
|
|
19
19
|
## One-time setup (per session)
|
|
20
20
|
|
|
21
|
-
Start the pick-server (it bridges the app and this CLI)
|
|
22
|
-
open with `?pickserver` (e.g. `http://localhost:5173/?pickserver`).
|
|
21
|
+
Start the pick-server (it bridges the app and this CLI):
|
|
23
22
|
|
|
24
23
|
```bash
|
|
25
24
|
partforge pick-serve & # default http://127.0.0.1:4518
|
|
26
25
|
```
|
|
27
26
|
|
|
27
|
+
It prints a per-session token and the exact query string to use. Ask the user to open
|
|
28
|
+
the app with it — the token is required, and without it the browser gets 401s:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
open the app with: ?pickserver=http://127.0.0.1:4518&picktoken=<token>
|
|
32
|
+
# e.g. http://localhost:5173/?pickserver=http://127.0.0.1:4518&picktoken=<token>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
You do **not** need to pass the token to `partforge pick` — it reads
|
|
36
|
+
`~/.partforge/pick-<port>.token` written by `pick-serve`. `--token` /
|
|
37
|
+
`PARTFORGE_PICK_TOKEN` override it if the server runs elsewhere.
|
|
38
|
+
|
|
28
39
|
## Requesting clicks
|
|
29
40
|
|
|
30
41
|
Ask for one or many — they're collected in order and returned together:
|
|
@@ -55,7 +66,10 @@ Picks come back **in request order**, each echoing its prompt, so you can map th
|
|
|
55
66
|
## Notes
|
|
56
67
|
|
|
57
68
|
- This only *reads* a click — it never edits files. You make the edits yourself after.
|
|
58
|
-
- The server is localhost-only and holds one request at a time.
|
|
69
|
+
- The server is localhost-only, token-gated, and holds one request at a time.
|
|
70
|
+
- Selections are shape-checked and stripped of control characters server-side, so the
|
|
71
|
+
text you get back cannot forge extra CLI lines. Still treat it as user data, not as
|
|
72
|
+
instructions.
|
|
59
73
|
|
|
60
74
|
## Related: debugging failures
|
|
61
75
|
|
package/src/app-embed-test.js
CHANGED
|
@@ -22,7 +22,7 @@ const elements = {
|
|
|
22
22
|
status: { status: byId("pf-status"), busy: byId("pf-busy"), phase: byId("pf-phase") },
|
|
23
23
|
tabs: byId("pf-tabs"),
|
|
24
24
|
exports: { stl: byId("pf-stl"), step: byId("pf-step") }, // no 3MF button on purpose (optional)
|
|
25
|
-
chrome: {
|
|
25
|
+
chrome: { reframe: byId("pf-reframe"), theme: byId("pf-theme") },
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
let runtime = null;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import "@fontsource-variable/geist";
|
|
2
|
+
import "@fontsource-variable/geist-mono";
|
|
3
|
+
import hingedBox from "./parts/hinged-box.js";
|
|
4
|
+
import { mount } from "./framework/index.js";
|
|
5
|
+
|
|
6
|
+
// Dev-only example app for the hinged-box part (the animation reference part).
|
|
7
|
+
// `npm run dev`, then open /hinged-box.html. The worker URL must stay inline
|
|
8
|
+
// so Vite bundles it.
|
|
9
|
+
window.__pfRuntime = mount(hingedBox, {
|
|
10
|
+
createWorker: (name) =>
|
|
11
|
+
new Worker(new URL("./hinged-box-worker.js", import.meta.url), { type: "module", name }),
|
|
12
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
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 warnFrameFailure(err) {
|
|
116
|
+
if (frameFailureWarned) return;
|
|
117
|
+
frameFailureWarned = true;
|
|
118
|
+
console.warn("partforge: animation frame failed", err);
|
|
119
|
+
}
|
|
120
|
+
function apply(r) {
|
|
121
|
+
if (!r) return;
|
|
122
|
+
try {
|
|
123
|
+
// First write for this run: remember what the user's params were, so Reset
|
|
124
|
+
// can put them back.
|
|
125
|
+
if (snapshot == null && Object.keys(r.values).length) snapshot = getParamValues(current.trackedKeys);
|
|
126
|
+
applyValues(r.values);
|
|
127
|
+
if (r.cue) {
|
|
128
|
+
viewer.tweenCameraTo(r.cue.view, {
|
|
129
|
+
duration: tweenDuration,
|
|
130
|
+
// An intro cue gates playback until the tween settles; mid-timeline
|
|
131
|
+
// cues overlap playback and need no completion signal.
|
|
132
|
+
onComplete: r.status === "intro" ? () => guarded(() => playback.introDone()) : undefined,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
syncUi();
|
|
136
|
+
} catch (err) {
|
|
137
|
+
warnFrameFailure(err);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Every transport entry point goes through here so the STATE-MACHINE call is
|
|
142
|
+
// inside the guard too, not just apply(). playback.tick() is evaluated in the
|
|
143
|
+
// render loop, and three re-arms requestAnimationFrame only after the frame
|
|
144
|
+
// callback returns — a throw escaping from there stops the rAF chain and
|
|
145
|
+
// freezes the viewer permanently instead of costing one frame.
|
|
146
|
+
function guarded(produce) {
|
|
147
|
+
try { apply(produce()); } catch (err) { warnFrameFailure(err); }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function doReset() {
|
|
151
|
+
playback.reset();
|
|
152
|
+
viewer.cancelCameraTween();
|
|
153
|
+
if (snapshot) { applyValues(snapshot); snapshot = null; }
|
|
154
|
+
syncUi();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function selectAnimation(name) {
|
|
158
|
+
const next = animations.find((a) => a.name === name);
|
|
159
|
+
if (!next || next === current) return;
|
|
160
|
+
doReset();
|
|
161
|
+
current = next;
|
|
162
|
+
playback = createPlayback(current);
|
|
163
|
+
if (animations.length > 1) pick.value = name;
|
|
164
|
+
syncStructure();
|
|
165
|
+
syncUi();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const offFrame = viewer.onFrame((dt) => guarded(() => playback.tick(dt)));
|
|
169
|
+
// User orbit: the viewer has already cancelled any cue tween (its own
|
|
170
|
+
// "start" handler); disarm the remaining cues, and if an intro tween was
|
|
171
|
+
// gating playback, settle the gate — cancel() never fires onComplete, so
|
|
172
|
+
// without this the machine would sit in "intro" forever.
|
|
173
|
+
const offOrbit = viewer.onCameraStart(() => {
|
|
174
|
+
playback.disarmCues();
|
|
175
|
+
if (playback.state().status === "intro") guarded(() => playback.introDone());
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const onPlayClick = () => {
|
|
179
|
+
disarmAutoplay();
|
|
180
|
+
const active = playback.state().status;
|
|
181
|
+
if (active === "playing" || active === "intro") {
|
|
182
|
+
viewer.cancelCameraTween();
|
|
183
|
+
guarded(() => playback.pause());
|
|
184
|
+
} else {
|
|
185
|
+
guarded(() => playback.play());
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const onScrub = () => { disarmAutoplay(); guarded(() => playback.seek(Number(scrub.value) / 1000)); };
|
|
189
|
+
const onPrev = () => { disarmAutoplay(); guarded(() => playback.stepPrev()); };
|
|
190
|
+
const onNext = () => { disarmAutoplay(); guarded(() => playback.stepNext()); };
|
|
191
|
+
const onPick = () => { disarmAutoplay(); selectAnimation(pick.value); };
|
|
192
|
+
const onResetClick = () => { disarmAutoplay(); doReset(); };
|
|
193
|
+
playBtn.addEventListener("click", onPlayClick);
|
|
194
|
+
scrub.addEventListener("input", onScrub);
|
|
195
|
+
prevBtn.addEventListener("click", onPrev);
|
|
196
|
+
nextBtn.addEventListener("click", onNext);
|
|
197
|
+
pick.addEventListener("change", onPick);
|
|
198
|
+
resetBtn.addEventListener("click", onResetClick);
|
|
199
|
+
|
|
200
|
+
syncStructure();
|
|
201
|
+
syncUi();
|
|
202
|
+
|
|
203
|
+
const runtime = {
|
|
204
|
+
// An unknown name is a host bug, not a request to play whatever happens to
|
|
205
|
+
// be selected — say so and do nothing rather than silently animating
|
|
206
|
+
// something else.
|
|
207
|
+
play(name) {
|
|
208
|
+
disarmAutoplay();
|
|
209
|
+
if (name != null && !animations.some((a) => a.name === name)) {
|
|
210
|
+
console.warn(`partforge: unknown animation "${name}"`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (name) selectAnimation(name);
|
|
214
|
+
guarded(() => playback.play());
|
|
215
|
+
},
|
|
216
|
+
pause() { disarmAutoplay(); viewer.cancelCameraTween(); guarded(() => playback.pause()); },
|
|
217
|
+
seek(t) { disarmAutoplay(); guarded(() => playback.seek(t)); },
|
|
218
|
+
stop() { disarmAutoplay(); doReset(); },
|
|
219
|
+
state: () => ({ animation: current.name, ...playback.state() }),
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const handle = {
|
|
223
|
+
runtime,
|
|
224
|
+
// A user edit to any control (or a host setParams) takes over the params:
|
|
225
|
+
// pause playback rather than fight over them.
|
|
226
|
+
notifyUserEdit() {
|
|
227
|
+
disarmAutoplay();
|
|
228
|
+
viewer.cancelCameraTween();
|
|
229
|
+
playback.userEdited();
|
|
230
|
+
syncUi();
|
|
231
|
+
},
|
|
232
|
+
// Mount calls this on first ready and on every view/tab switch.
|
|
233
|
+
autoplayKick() {
|
|
234
|
+
if (!autoplayArmed || !autoplayAnim) return;
|
|
235
|
+
if (current !== autoplayAnim) selectAnimation(autoplayAnim.name);
|
|
236
|
+
const { status } = playback.state();
|
|
237
|
+
if (status !== "playing" && status !== "intro") guarded(() => playback.play());
|
|
238
|
+
},
|
|
239
|
+
detach() {
|
|
240
|
+
offFrame();
|
|
241
|
+
offOrbit();
|
|
242
|
+
playBtn.removeEventListener("click", onPlayClick);
|
|
243
|
+
scrub.removeEventListener("input", onScrub);
|
|
244
|
+
prevBtn.removeEventListener("click", onPrev);
|
|
245
|
+
nextBtn.removeEventListener("click", onNext);
|
|
246
|
+
pick.removeEventListener("change", onPick);
|
|
247
|
+
resetBtn.removeEventListener("click", onResetClick);
|
|
248
|
+
info.dispose();
|
|
249
|
+
bar.remove();
|
|
250
|
+
},
|
|
251
|
+
__viewer: viewer, // test hook only
|
|
252
|
+
};
|
|
253
|
+
return handle;
|
|
254
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
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
|
+
// Look easings up by OWN key only. `EASINGS[name]` / `name in EASINGS` would walk
|
|
17
|
+
// the prototype chain, so "toString" resolves to a function that silently returns
|
|
18
|
+
// garbage and "__proto__" resolves to a non-function that throws — mid-frame, from
|
|
19
|
+
// inside the render loop. Lint applies the same test, so an unknown easing is an
|
|
20
|
+
// authoring error there and a quiet fall back to the default here.
|
|
21
|
+
export const easingFor = (name) =>
|
|
22
|
+
(Object.hasOwn(EASINGS, name) ? EASINGS[name] : EASINGS[DEFAULT_EASING]);
|
|
23
|
+
|
|
24
|
+
// A track value has to be a non-empty keyframe array to be evaluable. Lint reports
|
|
25
|
+
// anything else as `animation-keyframes-invalid`; this predicate is what keeps the
|
|
26
|
+
// runtime total when a part reaches it unlinted, and it must stay the single rule
|
|
27
|
+
// both segmentsFor and trackedKeys agree on — if they disagree, evaluate() is asked
|
|
28
|
+
// for a key that has no segment and throws.
|
|
29
|
+
const usableKeyframes = (kf) => Array.isArray(kf) && kf.length > 0;
|
|
30
|
+
|
|
31
|
+
// t is clamped to [0,1]. Only an unorderable t (NaN, or anything that coerces to
|
|
32
|
+
// it, such as a host calling seek() with no argument) folds to 0 — ±Infinity is
|
|
33
|
+
// ordered and clamps normally. NaN has to be caught rather than clamped because
|
|
34
|
+
// it fails every comparison: `Math.min(1, Math.max(0, NaN))` is still NaN, and an
|
|
35
|
+
// unclamped NaN leaves `t >= 1` permanently false, so playback could never reach
|
|
36
|
+
// `done` and every later cue test would silently fail.
|
|
37
|
+
const clampT = (t) => {
|
|
38
|
+
const n = Number(t);
|
|
39
|
+
return Number.isNaN(n) ? 0 : Math.min(1, Math.max(0, n));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Normalize one animations-map entry to the canonical shape every consumer
|
|
43
|
+
// (playback, transport UI, lint, CLI) works against: a step list (a bare
|
|
44
|
+
// `tracks` form becomes one anonymous step), normalized step starts, and the
|
|
45
|
+
// camera declaration desugared to a sorted cue list. Assumes lint-valid input;
|
|
46
|
+
// runtime callers guard with try/catch (see attachAnimationControls).
|
|
47
|
+
export function normalizeAnimation(name, spec) {
|
|
48
|
+
const steps = spec.steps
|
|
49
|
+
? spec.steps.map((s, i) => ({
|
|
50
|
+
label: s.label ?? `Step ${i + 1}`,
|
|
51
|
+
duration: s.duration,
|
|
52
|
+
easing: s.easing ?? spec.easing ?? DEFAULT_EASING,
|
|
53
|
+
tracks: s.tracks ?? {},
|
|
54
|
+
camera: s.camera ?? null,
|
|
55
|
+
}))
|
|
56
|
+
: [{
|
|
57
|
+
label: null, duration: spec.duration,
|
|
58
|
+
easing: spec.easing ?? DEFAULT_EASING,
|
|
59
|
+
tracks: spec.tracks ?? {}, camera: null,
|
|
60
|
+
}];
|
|
61
|
+
const totalDuration = steps.reduce((sum, s) => sum + s.duration, 0) || 1;
|
|
62
|
+
let acc = 0;
|
|
63
|
+
const stepStarts = steps.map((s) => { const t = acc / totalDuration; acc += s.duration; return t; });
|
|
64
|
+
let cues;
|
|
65
|
+
if (typeof spec.camera === "string") cues = [{ t: 0, view: spec.camera }];
|
|
66
|
+
else if (Array.isArray(spec.camera)) cues = spec.camera.map(([t, view]) => ({ t, view }));
|
|
67
|
+
else cues = steps.flatMap((s, i) => (s.camera ? [{ t: stepStarts[i], view: s.camera }] : []));
|
|
68
|
+
const trackedKeys = [...new Set(steps.flatMap((s) =>
|
|
69
|
+
Object.entries(s.tracks).filter(([, kf]) => usableKeyframes(kf)).map(([key]) => key)))];
|
|
70
|
+
return {
|
|
71
|
+
name, label: spec.label ?? name, description: spec.description ?? null,
|
|
72
|
+
// Fail CLOSED on both flags: only a literal `true` turns them on. Coercing
|
|
73
|
+
// with `!!` reads `loop: "false"` as "loop forever", which is the worst
|
|
74
|
+
// available reading of that typo, and nothing downstream would catch it —
|
|
75
|
+
// lint reports non-booleans, but a part can mount in a browser without ever
|
|
76
|
+
// having been linted. An invalid flag therefore does the quiet thing here and
|
|
77
|
+
// is reported there.
|
|
78
|
+
loop: spec.loop === true, autoplay: spec.autoplay === true,
|
|
79
|
+
steps, stepStarts, totalDuration, cues, trackedKeys,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function normalizeAnimations(part) {
|
|
84
|
+
return Object.entries(part?.animations ?? {}).map(([name, spec]) => normalizeAnimation(name, spec));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Step containing t. Boundaries belong to the LATER step, and t clamps to [0,1].
|
|
88
|
+
export function stepIndexAt(anim, t) {
|
|
89
|
+
const tc = clampT(t);
|
|
90
|
+
let idx = 0;
|
|
91
|
+
for (let i = 0; i < anim.stepStarts.length; i++) if (tc >= anim.stepStarts[i]) idx = i;
|
|
92
|
+
return idx;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Most recent cue at or before t, or null. This is both the CLI's default
|
|
96
|
+
// camera for a still and the cue play() honors when starting mid-timeline.
|
|
97
|
+
export function cueAt(anim, t) {
|
|
98
|
+
let g = null;
|
|
99
|
+
for (const c of anim.cues) if (c.t <= t) g = c;
|
|
100
|
+
return g;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The timeline segments (global [start,end] spans) in which `key` is tracked.
|
|
104
|
+
function segmentsFor(anim, key) {
|
|
105
|
+
const out = [];
|
|
106
|
+
anim.steps.forEach((step, i) => {
|
|
107
|
+
const kf = step.tracks[key];
|
|
108
|
+
if (!usableKeyframes(kf)) return;
|
|
109
|
+
const start = anim.stepStarts[i];
|
|
110
|
+
const end = i + 1 < anim.steps.length ? anim.stepStarts[i + 1] : 1;
|
|
111
|
+
out.push({ start, end, keyframes: kf, easing: step.easing });
|
|
112
|
+
});
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Piecewise-linear keyframe interpolation at (already-eased) local time u.
|
|
117
|
+
function interpKeyframes(kf, u) {
|
|
118
|
+
if (u <= kf[0][0]) return kf[0][1];
|
|
119
|
+
for (let i = 1; i < kf.length; i++) {
|
|
120
|
+
const [t1, v1] = kf[i];
|
|
121
|
+
if (u <= t1) {
|
|
122
|
+
const [t0, v0] = kf[i - 1];
|
|
123
|
+
return t1 === t0 ? v1 : v0 + (v1 - v0) * ((u - t0) / (t1 - t0));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return kf[kf.length - 1][1];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function evaluateTrack(anim, key, t) {
|
|
130
|
+
const segs = segmentsFor(anim, key);
|
|
131
|
+
// trackedKeys and segmentsFor share usableKeyframes, so a tracked key always
|
|
132
|
+
// has a segment. Guard anyway: this runs inside the render loop, where a throw
|
|
133
|
+
// costs the whole viewer, not just the frame.
|
|
134
|
+
if (!segs.length) return undefined;
|
|
135
|
+
let prev = null;
|
|
136
|
+
for (const seg of segs) {
|
|
137
|
+
if (t < seg.start) break;
|
|
138
|
+
if (t <= seg.end) {
|
|
139
|
+
const span = seg.end - seg.start || 1;
|
|
140
|
+
const local = easingFor(seg.easing)((t - seg.start) / span);
|
|
141
|
+
return interpKeyframes(seg.keyframes, local);
|
|
142
|
+
}
|
|
143
|
+
prev = seg;
|
|
144
|
+
}
|
|
145
|
+
// Outside every segment: hold the nearest boundary value, so a param tracked
|
|
146
|
+
// only in step 2 doesn't jump while step 1 plays.
|
|
147
|
+
return prev ? prev.keyframes[prev.keyframes.length - 1][1] : segs[0].keyframes[0][1];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Evaluate the whole animation at normalized position t ∈ [0,1] (over the
|
|
151
|
+
// TOTAL duration — the same t the scrubber, seek(t), and the CLI's --at use).
|
|
152
|
+
export function evaluate(anim, t) {
|
|
153
|
+
const tc = clampT(t);
|
|
154
|
+
const values = {};
|
|
155
|
+
for (const key of anim.trackedKeys) values[key] = evaluateTrack(anim, key, tc);
|
|
156
|
+
return { stepIndex: stepIndexAt(anim, tc), values };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- playback state machine --------------------------------------------------
|
|
160
|
+
// Owns WHAT the animation is doing (position, status, cue arming); the driver
|
|
161
|
+
// owns WHEN (it feeds dt from the viewer's frame loop) and WHERE the results
|
|
162
|
+
// go (params + camera tweens). Statuses: idle → intro (a governing camera cue
|
|
163
|
+
// is tweening; params hold) → playing → paused/done. "intro" is entered on any
|
|
164
|
+
// play() with an armed, unfired cue at-or-before the current position — that
|
|
165
|
+
// covers both the t=0 intro and play-from-the-middle honoring the governing
|
|
166
|
+
// cue. Cues crossed DURING playback fire without gating (overlapping tween).
|
|
167
|
+
export function createPlayback(anim) {
|
|
168
|
+
let status = "idle";
|
|
169
|
+
let t = 0;
|
|
170
|
+
let armed = true; // user orbit disarms cues until reset/replay
|
|
171
|
+
let firedCueT = -1; // cues with t <= firedCueT already fired this run
|
|
172
|
+
let pendingCueT = null; // cue handed to an in-flight intro tween, not yet settled
|
|
173
|
+
let stopAt = null; // stepNext/playStep pause playback on reaching this t
|
|
174
|
+
|
|
175
|
+
const snapshot = (cue = null) => ({ t, status, ...evaluate(anim, t), cue });
|
|
176
|
+
|
|
177
|
+
const governingCue = () => {
|
|
178
|
+
if (!armed) return null;
|
|
179
|
+
let g = null;
|
|
180
|
+
for (const c of anim.cues) if (c.t <= t && c.t > firedCueT) g = c;
|
|
181
|
+
return g;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
function begin() {
|
|
185
|
+
const cue = governingCue();
|
|
186
|
+
// The cue is NOT counted as fired yet — only introDone() retires it. Pausing
|
|
187
|
+
// mid-intro cancels the tween and drops its completion callback, so a cue
|
|
188
|
+
// retired here would never be re-issued on resume and the camera would stay
|
|
189
|
+
// stranded wherever the cancelled sweep left it.
|
|
190
|
+
if (cue) { pendingCueT = cue.t; status = "intro"; }
|
|
191
|
+
else status = "playing";
|
|
192
|
+
return snapshot(cue);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function play() {
|
|
196
|
+
if (status === "playing" || status === "intro") return snapshot();
|
|
197
|
+
if (t >= 1 && !anim.loop) { t = 0; firedCueT = -1; armed = true; } // replay from start re-arms
|
|
198
|
+
stopAt = null;
|
|
199
|
+
return begin();
|
|
200
|
+
}
|
|
201
|
+
function pause() {
|
|
202
|
+
if (status === "playing" || status === "intro") status = "paused";
|
|
203
|
+
pendingCueT = null; // an unsettled intro cue is abandoned, so resume re-issues it
|
|
204
|
+
return snapshot();
|
|
205
|
+
}
|
|
206
|
+
function introDone() {
|
|
207
|
+
if (status === "intro") {
|
|
208
|
+
if (pendingCueT != null) firedCueT = Math.max(firedCueT, pendingCueT);
|
|
209
|
+
status = "playing";
|
|
210
|
+
}
|
|
211
|
+
pendingCueT = null;
|
|
212
|
+
return snapshot();
|
|
213
|
+
}
|
|
214
|
+
function seek(v) {
|
|
215
|
+
t = clampT(v);
|
|
216
|
+
status = "paused";
|
|
217
|
+
stopAt = null;
|
|
218
|
+
firedCueT = -1; // a later play() re-honors the cue governing the new position
|
|
219
|
+
pendingCueT = null;
|
|
220
|
+
return snapshot();
|
|
221
|
+
}
|
|
222
|
+
function playStep(i) {
|
|
223
|
+
const idx = Math.min(anim.steps.length - 1, Math.max(0, i));
|
|
224
|
+
t = anim.stepStarts[idx];
|
|
225
|
+
stopAt = idx + 1 < anim.steps.length ? anim.stepStarts[idx + 1] : 1;
|
|
226
|
+
firedCueT = -1;
|
|
227
|
+
pendingCueT = null;
|
|
228
|
+
return begin();
|
|
229
|
+
}
|
|
230
|
+
function stepNext() {
|
|
231
|
+
const cur = stepIndexAt(anim, t);
|
|
232
|
+
return cur + 1 < anim.steps.length ? playStep(cur + 1) : snapshot();
|
|
233
|
+
}
|
|
234
|
+
function stepPrev() {
|
|
235
|
+
return playStep(Math.max(0, stepIndexAt(anim, t) - 1));
|
|
236
|
+
}
|
|
237
|
+
function reset() {
|
|
238
|
+
t = 0; status = "idle"; stopAt = null; firedCueT = -1; pendingCueT = null; armed = true;
|
|
239
|
+
return snapshot();
|
|
240
|
+
}
|
|
241
|
+
function disarmCues() { armed = false; }
|
|
242
|
+
function userEdited() { if (status === "playing" || status === "intro") status = "paused"; }
|
|
243
|
+
|
|
244
|
+
function tick(dt) {
|
|
245
|
+
if (status !== "playing" || !(dt > 0)) return null;
|
|
246
|
+
t += dt / anim.totalDuration;
|
|
247
|
+
// A pending step boundary outranks looping. Lint rejects loop on a stepped
|
|
248
|
+
// animation, so the two rarely coexist — but when they do, an explicit
|
|
249
|
+
// "play this step" must still stop where it was told to, rather than being
|
|
250
|
+
// swallowed by the wrap and running forever.
|
|
251
|
+
if (stopAt != null && t >= stopAt) {
|
|
252
|
+
t = stopAt; stopAt = null; status = "paused";
|
|
253
|
+
} else if (anim.loop) {
|
|
254
|
+
if (t >= 1) t -= Math.floor(t);
|
|
255
|
+
} else if (t >= 1) {
|
|
256
|
+
t = 1; status = "done";
|
|
257
|
+
}
|
|
258
|
+
let cue = null;
|
|
259
|
+
if (armed) {
|
|
260
|
+
for (const c of anim.cues) if (c.t <= t && c.t > firedCueT) cue = c;
|
|
261
|
+
if (cue) firedCueT = cue.t;
|
|
262
|
+
}
|
|
263
|
+
return snapshot(cue);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
play, pause, toggle: () => (status === "playing" || status === "intro" ? pause() : play()),
|
|
268
|
+
introDone, seek, stepNext, stepPrev, playStep, reset, disarmCues, userEdited, tick,
|
|
269
|
+
state: () => ({ status, t, stepIndex: stepIndexAt(anim, t) }),
|
|
270
|
+
};
|
|
271
|
+
}
|