partforge 0.49.0 → 0.51.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 +12 -6
- package/bin/cli.js +38 -11
- package/docs/AUTHORING-PARTS.md +102 -36
- package/package.json +1 -1
- package/src/framework/animation-controls.js +108 -48
- package/src/framework/animation.js +47 -18
- package/src/framework/chrome.css +68 -0
- package/src/framework/cutaway.js +14 -0
- package/src/framework/lint/rules-animations.js +177 -92
- package/src/framework/mount.js +59 -12
- package/src/framework/rail.js +19 -2
- package/src/framework/viewer.js +155 -9
- package/src/parts/hinged-box.js +43 -27
- package/src/testing/render.js +43 -7
- package/types/index.d.ts +17 -7
- package/types/part.d.ts +38 -12
- package/types/testing.d.ts +8 -0
package/src/framework/mount.js
CHANGED
|
@@ -5,7 +5,7 @@ import { attachViewerControls } from "./viewer-controls.js";
|
|
|
5
5
|
import { attachCutawayControls } from "./cutaway-controls.js";
|
|
6
6
|
import { attachRail } from "./rail.js";
|
|
7
7
|
import { attachMobileTabs } from "./mobile-tabs.js";
|
|
8
|
-
import { createTooltipPresenter } from "./tooltip.js";
|
|
8
|
+
import { createTooltipPresenter, attachButtonTooltips } from "./tooltip.js";
|
|
9
9
|
import { loadCamera } from "./view-state.js";
|
|
10
10
|
import { buildControls } from "./controls.js";
|
|
11
11
|
import { relevantParamKeys } from "./param-deps.js";
|
|
@@ -29,11 +29,17 @@ import { resolveDefaultView } from "./default-view.js";
|
|
|
29
29
|
|
|
30
30
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
31
31
|
// the full mount() pipeline (WASM + workers + DOM).
|
|
32
|
-
|
|
32
|
+
// The default no-op tooltip binding, so a host can hold on to whatever
|
|
33
|
+
// attachTooltips returned without caring whether this mount resolved one.
|
|
34
|
+
const NOOP_TOOLTIP_BINDING = { sync: () => {}, hide: () => {}, detach: () => {} };
|
|
35
|
+
|
|
36
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips }) {
|
|
33
37
|
return {
|
|
34
38
|
ready, dispose, setParams,
|
|
35
|
-
// Part-declared animation playback (spec 2026-08-02):
|
|
36
|
-
//
|
|
39
|
+
// Part-declared animation playback (spec 2026-08-02): animations are
|
|
40
|
+
// VIEW-owned, so this is null only when NO view declares any.
|
|
41
|
+
// { play(name?), pause(), seek(t), stop(), state() } — play(name) resolves
|
|
42
|
+
// within the ACTIVE view's set, and state() reports that view.
|
|
37
43
|
animation: animation ?? null,
|
|
38
44
|
// Active view name (never null once mounted). See onViewChange for the push side.
|
|
39
45
|
getView,
|
|
@@ -58,6 +64,12 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
58
64
|
// (partforge-cloud does, at the window level). Defaulted to a no-op so the
|
|
59
65
|
// handle's shape never depends on whether this mount resolved a rail.
|
|
60
66
|
setHostPane: setHostPane ?? (() => {}),
|
|
67
|
+
// Join host-owned chrome buttons to this mount's shared hover tooltip, so
|
|
68
|
+
// a host's own viewbar/rail-foot buttons match the built-in ones. Entries
|
|
69
|
+
// are [{ element, getLabel? }] (label falls back to the button's
|
|
70
|
+
// title/aria-label); returns { sync, hide, detach }. Same no-op default
|
|
71
|
+
// stance as setHostPane above.
|
|
72
|
+
attachTooltips: attachTooltips ?? (() => NOOP_TOOLTIP_BINDING),
|
|
61
73
|
};
|
|
62
74
|
}
|
|
63
75
|
|
|
@@ -114,6 +126,15 @@ function createCleanupStack() {
|
|
|
114
126
|
// runtime.setHostPane("rail"); // narrow layout only: show just the controls
|
|
115
127
|
// // rail ('stage' | 'rail'), suppressing the
|
|
116
128
|
// // built-in tab bar. null hands selection back.
|
|
129
|
+
// runtime.attachTooltips([{ element: myButton }]); // host chrome buttons join the
|
|
130
|
+
// // mount's shared hover tooltip (the viewbar one).
|
|
131
|
+
// // Label = the button's title (or aria-label), or a
|
|
132
|
+
// // per-entry getLabel(); the title attribute is
|
|
133
|
+
// // absorbed while attached so it can't double up as a
|
|
134
|
+
// // native tooltip, and restored on detach. Returns
|
|
135
|
+
// // { sync, hide, detach } — call sync() after you
|
|
136
|
+
// // toggle a button's disabled state. Detached
|
|
137
|
+
// // automatically on dispose().
|
|
117
138
|
// runtime.setActive(false); // park the viewer: stop the render loop and release
|
|
118
139
|
// // both large GPU allocations (the drawing buffer and
|
|
119
140
|
// // the cached capture target). For a host that hides the
|
|
@@ -122,13 +143,20 @@ function createCleanupStack() {
|
|
|
122
143
|
// // loop would otherwise render a hidden pane forever.
|
|
123
144
|
// // Captures still work while parked (they re-allocate).
|
|
124
145
|
// // setActive(true) restores it. Safe after dispose().
|
|
125
|
-
// runtime.animation?.play("open"); // part-declared animation playback:
|
|
126
|
-
// //
|
|
146
|
+
// runtime.animation?.play("open"); // part-declared animation playback: animations are
|
|
147
|
+
// // VIEW-owned, so this is null only when NO view
|
|
148
|
+
// // declares any, else
|
|
127
149
|
// // { play(name?), pause(), seek(t), stop(), state() }.
|
|
128
|
-
// // play()
|
|
129
|
-
// //
|
|
130
|
-
// //
|
|
131
|
-
// //
|
|
150
|
+
// // play(name) resolves within the ACTIVE view's set —
|
|
151
|
+
// // an unknown name (including one declared by a
|
|
152
|
+
// // different view) warns and does nothing; state()
|
|
153
|
+
// // reports the view it applies to. Switching views
|
|
154
|
+
// // restores the outgoing animation's params, then
|
|
155
|
+
// // presents the incoming view's own set (empty in a
|
|
156
|
+
// // view that declares none). Any user/host param edit
|
|
157
|
+
// // pauses playback. A view's `autoplay: true` animation
|
|
158
|
+
// // self-starts when that view is first shown and on
|
|
159
|
+
// // each switch to it, until the user touches the
|
|
132
160
|
// // transport — no runtime call needed for that part.
|
|
133
161
|
// const off = runtime.onContextLost(() => …); // WebGL context loss, i.e. the GPU or the
|
|
134
162
|
// // OS gave up — surface it rather than showing a dead
|
|
@@ -194,7 +222,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
194
222
|
cleanup.defer(() => cutawayChrome.detach());
|
|
195
223
|
// Resizable/collapsible controls rail. No-ops when the host lays out the
|
|
196
224
|
// framework itself (no #panel / no elements.rail).
|
|
197
|
-
const railChrome = attachRail({ rail: els.rail, toggle: els.chrome.railToggle, shell: els.shell });
|
|
225
|
+
const railChrome = attachRail({ rail: els.rail, toggle: els.chrome.railToggle, shell: els.shell, tooltip });
|
|
198
226
|
cleanup.defer(() => railChrome.detach());
|
|
199
227
|
// Narrow-layout pane tabs. Below RAIL_NARROW_BREAKPOINT the rail cannot sit
|
|
200
228
|
// beside the viewer, so exactly one pane shows and this bar picks it. Same
|
|
@@ -239,6 +267,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
239
267
|
// cached assembly instantly if it's current, else auto-builds what's missing.
|
|
240
268
|
const tabsCtl = createViewTabs(els.tabs, part, {
|
|
241
269
|
onChange: (name) => {
|
|
270
|
+
// FIRST: the outgoing animation restores its param snapshot, so the
|
|
271
|
+
// incoming view composes its assembly from un-animated params. Anything
|
|
272
|
+
// that reads params (refreshView / updateRelevance / the loop kick) must
|
|
273
|
+
// run after it. autoplayKick stays LAST — it starts the new view's own.
|
|
274
|
+
animCtl?.viewChanged();
|
|
242
275
|
pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); animCtl?.autoplayKick();
|
|
243
276
|
onViewChange?.(name);
|
|
244
277
|
},
|
|
@@ -548,11 +581,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
548
581
|
loop.kick();
|
|
549
582
|
}
|
|
550
583
|
|
|
551
|
-
// Animation transport + driver (
|
|
584
|
+
// Animation transport + driver (null when NO view declares animations).
|
|
585
|
+
// Animations are view-owned: `getView` is how the driver knows which view's
|
|
586
|
+
// set is live, both at attach and after every `viewChanged()`.
|
|
552
587
|
animCtl = attachAnimationControls(viewer, part, {
|
|
553
588
|
container: els.viewer,
|
|
554
589
|
applyValues: applyAnimationValues,
|
|
555
590
|
getParamValues: (keys) => Object.fromEntries(keys.map((k) => [k, params[k]])),
|
|
591
|
+
getView: view,
|
|
556
592
|
});
|
|
557
593
|
if (animCtl) cleanup.defer(() => animCtl.detach());
|
|
558
594
|
|
|
@@ -622,8 +658,19 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
622
658
|
}
|
|
623
659
|
};
|
|
624
660
|
|
|
661
|
+
// Host chrome buttons joining the mount's shared tooltip (the one the
|
|
662
|
+
// viewbar and cutaway buttons already use). Bindings are detached by
|
|
663
|
+
// dispose() via the cleanup stack; a host detaching earlier is fine —
|
|
664
|
+
// attachButtonTooltips.detach is idempotent.
|
|
665
|
+
const attachHostTooltips = (entries) => {
|
|
666
|
+
const binding = attachButtonTooltips(tooltip, entries);
|
|
667
|
+
cleanup.defer(() => binding.detach());
|
|
668
|
+
return binding;
|
|
669
|
+
};
|
|
670
|
+
|
|
625
671
|
return makeHandle({
|
|
626
672
|
ready, dispose, viewer, setParams,
|
|
673
|
+
attachTooltips: attachHostTooltips,
|
|
627
674
|
setHostPane: paneTabs.setHostPane,
|
|
628
675
|
getView: view, // () => tabsCtl.current()
|
|
629
676
|
setView: (name) => tabsCtl.select(name),
|
package/src/framework/rail.js
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
RAIL_DEFAULT_WIDTH, RAIL_MIN_WIDTH, RAIL_NARROW_BREAKPOINT,
|
|
3
3
|
clampRailWidth, railMaxWidth, readRailPref, resolveRailDrag, writeRailPref,
|
|
4
4
|
} from "./rail-state.js";
|
|
5
|
+
import { attachButtonTooltips } from "./tooltip.js";
|
|
5
6
|
|
|
6
7
|
const KEY_STEP = 16;
|
|
7
8
|
const KEY_STEP_SHIFT = 64;
|
|
@@ -75,7 +76,11 @@ function safeStorage() {
|
|
|
75
76
|
//
|
|
76
77
|
// Everything is optional. With no rail this returns a no-op, so hosts that lay
|
|
77
78
|
// the framework out themselves (see embed-test.html) are unaffected.
|
|
78
|
-
|
|
79
|
+
//
|
|
80
|
+
// `tooltip` is the mount's shared presenter (tooltip.js). With it, the toggle's
|
|
81
|
+
// Hide/Show-controls label renders as the same anchored pf-hover-tip the
|
|
82
|
+
// viewbar buttons use; without it, the label falls back to a native title.
|
|
83
|
+
export function attachRail({ rail, toggle, shell = rail?.parentElement, storage = safeStorage(), tooltip } = {}) {
|
|
79
84
|
if (!rail || !shell) {
|
|
80
85
|
// No rail to resolve in this document: --pf-rail-w still defaults to 288px
|
|
81
86
|
// from tokens.css, but nothing is reserving that space, so anything that
|
|
@@ -107,6 +112,12 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
|
|
|
107
112
|
toggle.replaceChildren(svg);
|
|
108
113
|
toggleChevron = chevron;
|
|
109
114
|
}
|
|
115
|
+
// Attached BEFORE the first apply() below writes an aria-label, so the
|
|
116
|
+
// binding's original-attribute capture (what its detach() restores) sees the
|
|
117
|
+
// host's own markup, not our first label.
|
|
118
|
+
const tooltipBinding = toggle && tooltip
|
|
119
|
+
? attachButtonTooltips(tooltip, [{ element: toggle }])
|
|
120
|
+
: null;
|
|
110
121
|
|
|
111
122
|
const seam = document.createElement("div");
|
|
112
123
|
seam.className = "pf-rail-seam";
|
|
@@ -157,8 +168,11 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
|
|
|
157
168
|
const label = collapsed ? "Show controls" : "Hide controls";
|
|
158
169
|
toggle.setAttribute("aria-expanded", String(!collapsed));
|
|
159
170
|
toggle.setAttribute("aria-label", label);
|
|
160
|
-
|
|
171
|
+
// The shared tooltip reads the aria-label at show time, so a native
|
|
172
|
+
// title would double up as a second, competing tooltip.
|
|
173
|
+
if (!tooltipBinding) toggle.title = label;
|
|
161
174
|
toggle.classList.toggle("on", collapsed);
|
|
175
|
+
tooltipBinding?.sync();
|
|
162
176
|
}
|
|
163
177
|
if (persist) writeRailPref(state, storage);
|
|
164
178
|
}
|
|
@@ -324,6 +338,9 @@ export function attachRail({ rail, toggle, shell = rail?.parentElement, storage
|
|
|
324
338
|
shell.removeAttribute("data-pf-dragging");
|
|
325
339
|
rail.removeAttribute("inert");
|
|
326
340
|
root.style.removeProperty("--pf-rail-w");
|
|
341
|
+
// Before the attribute restore below: the binding's own detach rewrites
|
|
342
|
+
// title/aria-label from its capture, and ours must win.
|
|
343
|
+
tooltipBinding?.detach();
|
|
327
344
|
if (toggle) {
|
|
328
345
|
toggle.innerHTML = toggleOriginal.html;
|
|
329
346
|
toggle.title = toggleOriginal.title;
|
package/src/framework/viewer.js
CHANGED
|
@@ -177,6 +177,115 @@ export function createViewer(container, part) {
|
|
|
177
177
|
partsGroup.add(l);
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
// --- animated per-sub-part opacity (display-only) ---------------------------
|
|
181
|
+
// Overrides from the animation driver (spec 2026-08-10-per-view-animations):
|
|
182
|
+
// absent = normal, 0 = fully hidden (mesh AND lines), 0<v<1 = faded on cloned
|
|
183
|
+
// materials. Never touches geometry, params, or exports — this is the display
|
|
184
|
+
// half of "fade a part in, then animate it into place".
|
|
185
|
+
const animOpacity = new Map(); // name -> value in [0, 1)
|
|
186
|
+
const baseMats = Object.fromEntries(names.map((n) => [n, subMesh[n].material]));
|
|
187
|
+
const fadeMats = new Map(); // name -> lazily cloned MeshStandardMaterial
|
|
188
|
+
const fadeLineMats = new Map(); // name -> lazily cloned LineMaterial
|
|
189
|
+
const fadeUnregisters = new Map(); // fade material -> its cutaway unregister fn
|
|
190
|
+
let lastShown = []; // names last passed to showAssembly
|
|
191
|
+
|
|
192
|
+
const effectiveVisible = () => lastShown.filter((n) => (animOpacity.get(n) ?? 1) > 0);
|
|
193
|
+
|
|
194
|
+
// A fade clone is a material the cutaway does not own, so it has to be told
|
|
195
|
+
// about the clipping plane explicitly — otherwise a mid-fade part renders
|
|
196
|
+
// un-sectioned while its stencil caps and cut-face outline keep drawing.
|
|
197
|
+
// registerClippableMaterial syncs immediately, so a clone created while the
|
|
198
|
+
// cutaway is already on picks up the current state.
|
|
199
|
+
//
|
|
200
|
+
// Known cosmetic remainder, accepted: the hatch cap keeps its full-strength
|
|
201
|
+
// opacity while the surface above it fades, because the cap derives its
|
|
202
|
+
// colour/opacity from the base material at refreshSourceMaterial time. A part
|
|
203
|
+
// at opacity 0 drops out of the cutaway's visible set entirely, so the cap
|
|
204
|
+
// only over-reads during the transient middle of a fade; re-deriving cap
|
|
205
|
+
// opacity per frame would cost a material rebuild for a state that lasts
|
|
206
|
+
// under a second.
|
|
207
|
+
function fadeMatFor(name) {
|
|
208
|
+
let m = fadeMats.get(name);
|
|
209
|
+
if (!m) {
|
|
210
|
+
m = baseMats[name].clone();
|
|
211
|
+
m.transparent = true;
|
|
212
|
+
m.depthWrite = false;
|
|
213
|
+
fadeUnregisters.set(m, cutaway.registerClippableMaterial(m));
|
|
214
|
+
fadeMats.set(name, m);
|
|
215
|
+
}
|
|
216
|
+
return m;
|
|
217
|
+
}
|
|
218
|
+
function fadeLineMatFor(name) {
|
|
219
|
+
let m = fadeLineMats.get(name);
|
|
220
|
+
if (!m) {
|
|
221
|
+
m = lineMaterial.clone();
|
|
222
|
+
m.transparent = true;
|
|
223
|
+
m.resolution.copy(lineMaterial.resolution);
|
|
224
|
+
fadeUnregisters.set(m, cutaway.registerClippableMaterial(m));
|
|
225
|
+
fadeLineMats.set(name, m);
|
|
226
|
+
}
|
|
227
|
+
return m;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Re-derive one sub-part's material + visibility from (shown, override).
|
|
231
|
+
function applySubOpacity(name) {
|
|
232
|
+
const mesh = subMesh[name], lines = subLines[name];
|
|
233
|
+
if (!mesh) return;
|
|
234
|
+
const shown = lastShown.includes(name);
|
|
235
|
+
const v = animOpacity.get(name);
|
|
236
|
+
if (v === undefined) {
|
|
237
|
+
// Restore ONLY from our own fade clone. showAssembly runs on every regen
|
|
238
|
+
// (mount.js's refreshView) without disabling the cutaway, and an enabled
|
|
239
|
+
// cutaway has swapped these onto its clipped clones
|
|
240
|
+
// (createSectionRenderSet.setEnabled) — an unconditional write here would
|
|
241
|
+
// silently drop clipping on every sub-part on the next param edit.
|
|
242
|
+
const hadFade = mesh.material === fadeMats.get(name);
|
|
243
|
+
if (hadFade) mesh.material = baseMats[name];
|
|
244
|
+
if (lines.material === fadeLineMats.get(name)) lines.material = lineMaterial;
|
|
245
|
+
// We just took the mesh off our clone, so an enabled cutaway must get the
|
|
246
|
+
// chance to re-claim it onto its clipped clone — the base material we
|
|
247
|
+
// wrote above carries no plane, and nothing else would put it back until
|
|
248
|
+
// the next cutaway toggle or theme change. Guarded on hadFade so the
|
|
249
|
+
// every-regen showAssembly path stays a no-op for un-faded sub-parts.
|
|
250
|
+
if (hadFade) cutaway.resyncSubpart(name);
|
|
251
|
+
mesh.visible = shown;
|
|
252
|
+
lines.visible = shown;
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (v <= 0) {
|
|
256
|
+
mesh.visible = false;
|
|
257
|
+
lines.visible = false;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const staticOpacity = part.parts[name].display?.opacity ?? 1;
|
|
261
|
+
const fm = fadeMatFor(name);
|
|
262
|
+
fm.opacity = staticOpacity * v;
|
|
263
|
+
mesh.material = fm;
|
|
264
|
+
const flm = fadeLineMatFor(name);
|
|
265
|
+
flm.opacity = v;
|
|
266
|
+
lines.material = flm;
|
|
267
|
+
mesh.visible = shown;
|
|
268
|
+
lines.visible = shown;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function setSubPartOpacity(name, value) {
|
|
272
|
+
if (!subMesh[name]) return;
|
|
273
|
+
const wasZero = (animOpacity.get(name) ?? 1) <= 0;
|
|
274
|
+
if (value == null || !(value < 1)) animOpacity.delete(name); // null/undefined/NaN/>=1 clear
|
|
275
|
+
else animOpacity.set(name, Math.max(0, value));
|
|
276
|
+
applySubOpacity(name);
|
|
277
|
+
const isZero = (animOpacity.get(name) ?? 1) <= 0;
|
|
278
|
+
if (wasZero !== isZero) cutaway.setVisible(effectiveVisible());
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function clearSubPartOpacities() {
|
|
282
|
+
if (!animOpacity.size) return;
|
|
283
|
+
const touched = [...animOpacity.keys()];
|
|
284
|
+
animOpacity.clear();
|
|
285
|
+
for (const n of touched) applySubOpacity(n);
|
|
286
|
+
cutaway.setVisible(effectiveVisible());
|
|
287
|
+
}
|
|
288
|
+
|
|
180
289
|
// The cutaway plane lives in world space, so its initial/reset bounds must
|
|
181
290
|
// include the pivot rotation and the per-view recentering transform —
|
|
182
291
|
// mesh.matrixWorld carries both. Union each visible mesh's own
|
|
@@ -347,17 +456,19 @@ export function createViewer(container, part) {
|
|
|
347
456
|
// frame the camera to them — done only on the initial show and on view (tab)
|
|
348
457
|
// changes, NOT on regeneration, so a user's zoom/orbit survives editing params.
|
|
349
458
|
function showAssembly(visibleNames, { frame = false } = {}) {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (
|
|
353
|
-
|
|
459
|
+
lastShown = [...visibleNames];
|
|
460
|
+
for (const name of names) {
|
|
461
|
+
if (visibleNames.includes(name)) {
|
|
462
|
+
subMesh[name].geometry = subCache[name]; // cached geometries reused, not disposed
|
|
354
463
|
subLines[name].geometry = subCache[name].userData.edges;
|
|
464
|
+
applySubOpacity(name); // shown, but an active 0-override keeps it hidden
|
|
465
|
+
} else {
|
|
466
|
+
subMesh[name].visible = false;
|
|
467
|
+
subLines[name].visible = false;
|
|
355
468
|
}
|
|
356
|
-
mesh.visible = on;
|
|
357
|
-
subLines[name].visible = on;
|
|
358
469
|
}
|
|
359
470
|
if (frame) frameTo(visibleNames);
|
|
360
|
-
cutaway.setVisible(
|
|
471
|
+
cutaway.setVisible(effectiveVisible());
|
|
361
472
|
}
|
|
362
473
|
|
|
363
474
|
// Re-frame whatever is currently visible (the reframe button).
|
|
@@ -365,8 +476,22 @@ export function createViewer(container, part) {
|
|
|
365
476
|
frameTo(names.filter((n) => subMesh[n].visible && subCache[n]));
|
|
366
477
|
}
|
|
367
478
|
|
|
479
|
+
// Call after anything that rewrites sub-part materials out from under us.
|
|
480
|
+
// The cutaway assigns mesh.material itself — the clipped clone on enable, the
|
|
481
|
+
// captured original on disable, and a freshly re-cloned pair on every
|
|
482
|
+
// refreshSourceMaterial (which setTheme drives) — so a live fade has to be
|
|
483
|
+
// re-asserted on top or a PAUSED mid-fade part sticks at full opacity. A
|
|
484
|
+
// playing animation would self-heal on its next frame; a paused one has no
|
|
485
|
+
// next frame. Only the calls that reassign materials need this: flip and
|
|
486
|
+
// reset move the plane and nothing else.
|
|
487
|
+
function reassertLiveFades() {
|
|
488
|
+
for (const n of animOpacity.keys()) applySubOpacity(n);
|
|
489
|
+
}
|
|
490
|
+
|
|
368
491
|
function setCutawayEnabled(on) {
|
|
369
|
-
|
|
492
|
+
const result = cutaway.setEnabled(on);
|
|
493
|
+
reassertLiveFades();
|
|
494
|
+
return result;
|
|
370
495
|
}
|
|
371
496
|
|
|
372
497
|
// Swap the scene background, grid, and edge-line colors for the given theme.
|
|
@@ -378,10 +503,13 @@ export function createViewer(container, part) {
|
|
|
378
503
|
grid.position.y = floorY; // keep the floor at the bbox bottom across theme swaps
|
|
379
504
|
scene.add(grid);
|
|
380
505
|
lineMaterial.color.set(t.line);
|
|
506
|
+
for (const m of fadeLineMats.values()) m.color.set(t.line); // clones follow the theme
|
|
381
507
|
cutaway.setTheme(mode, t.line);
|
|
508
|
+
reassertLiveFades(); // setTheme re-clones every section's materials and reassigns them
|
|
382
509
|
}
|
|
383
510
|
|
|
384
511
|
function hideAssembly() {
|
|
512
|
+
lastShown = [];
|
|
385
513
|
for (const m of Object.values(subMesh)) m.visible = false;
|
|
386
514
|
for (const l of Object.values(subLines)) l.visible = false;
|
|
387
515
|
cutaway.setVisible([]);
|
|
@@ -399,6 +527,7 @@ export function createViewer(container, part) {
|
|
|
399
527
|
camera.aspect = w / h;
|
|
400
528
|
camera.updateProjectionMatrix();
|
|
401
529
|
lineMaterial.resolution.set(w, h); // fat lines need the viewport size for px width
|
|
530
|
+
for (const m of fadeLineMats.values()) m.resolution.set(w, h); // clones need it too
|
|
402
531
|
cutaway.setViewportSize(w, h, renderer.getPixelRatio());
|
|
403
532
|
}
|
|
404
533
|
const ro = new ResizeObserver(resize);
|
|
@@ -720,9 +849,22 @@ export function createViewer(container, part) {
|
|
|
720
849
|
for (const n of names) {
|
|
721
850
|
const g = subCache[n];
|
|
722
851
|
if (g) { g.userData.edges?.dispose(); g.dispose(); subCache[n] = null; }
|
|
723
|
-
subMesh[n].material
|
|
852
|
+
// baseMats[n], not subMesh[n].material: an active fade override has swapped
|
|
853
|
+
// the mesh onto a clone, and the base material would otherwise leak.
|
|
854
|
+
baseMats[n]?.dispose();
|
|
724
855
|
subMesh[n].geometry?.dispose(); // the initial empty BufferGeometry, if never replaced
|
|
725
856
|
}
|
|
857
|
+
// Hand the fade clones back before freeing them. cutaway.dispose() above has
|
|
858
|
+
// already restored their original clippingPlanes and emptied its registry,
|
|
859
|
+
// so these unregister closures find no entry and return without touching a
|
|
860
|
+
// disposed cutaway. They still earn their place: they release this map's
|
|
861
|
+
// hold on the registry's unregister closures rather than leaving it to GC.
|
|
862
|
+
for (const off of fadeUnregisters.values()) off();
|
|
863
|
+
fadeUnregisters.clear();
|
|
864
|
+
for (const m of fadeMats.values()) m.dispose();
|
|
865
|
+
for (const m of fadeLineMats.values()) m.dispose();
|
|
866
|
+
fadeMats.clear();
|
|
867
|
+
fadeLineMats.clear();
|
|
726
868
|
material.dispose();
|
|
727
869
|
lineMaterial.dispose();
|
|
728
870
|
grid.geometry.dispose();
|
|
@@ -737,6 +879,8 @@ export function createViewer(container, part) {
|
|
|
737
879
|
hideAssembly,
|
|
738
880
|
setSubGeometry,
|
|
739
881
|
setSubPose,
|
|
882
|
+
setSubPartOpacity,
|
|
883
|
+
clearSubPartOpacities,
|
|
740
884
|
hasSubMesh,
|
|
741
885
|
subTriangles,
|
|
742
886
|
frame,
|
|
@@ -756,6 +900,8 @@ export function createViewer(container, part) {
|
|
|
756
900
|
camera,
|
|
757
901
|
domElement: renderer.domElement,
|
|
758
902
|
_subMeshes: subMesh,
|
|
903
|
+
__subMesh: (n) => subMesh[n], // test hooks (cf. attachAnimationControls' __viewer)
|
|
904
|
+
__subLines: (n) => subLines[n],
|
|
759
905
|
flashPoint,
|
|
760
906
|
cutawaySupported: () => cutaway.isSupported,
|
|
761
907
|
cutawayEnabled: () => cutaway.isEnabled,
|
package/src/parts/hinged-box.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// Animation reference part — a box with a hinged lid. Worked example for
|
|
2
|
-
// docs/AUTHORING-PARTS.md "Animations":
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// docs/AUTHORING-PARTS.md "Animations": animations are VIEW-OWNED (declared
|
|
3
|
+
// under `views.box.animations`, so the transport bar belongs to that view),
|
|
4
|
+
// they drive pose-only params (lidAngle, lidLift) through place(), and
|
|
5
|
+
// `assemble` opens with an OPACITY fade that brings the lid in from nothing
|
|
6
|
+
// before any motion. Also shown: an intro camera + markdown description on
|
|
7
|
+
// `open`, a looping autoplay `cycle`, and per-step cameras on `assemble`.
|
|
5
8
|
export default {
|
|
6
9
|
meta: { title: "Hinged Box", units: "mm" },
|
|
7
10
|
parameters: [
|
|
@@ -58,30 +61,43 @@ export default {
|
|
|
58
61
|
: s.rotate(-p.lidAngle, [0, p.depth, p.height], [1, 0, 0]).translate([0, 0, p.lidLift]),
|
|
59
62
|
},
|
|
60
63
|
},
|
|
61
|
-
views: {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
64
|
+
views: {
|
|
65
|
+
box: {
|
|
66
|
+
label: "Box",
|
|
67
|
+
animations: {
|
|
68
|
+
open: {
|
|
69
|
+
label: "Open lid",
|
|
70
|
+
description: "Swings the lid to **110°** about the rear hinge line.\n\nPose-only: playback runs at frame rate with no geometry rebuild.",
|
|
71
|
+
camera: "front",
|
|
72
|
+
duration: 1.2,
|
|
73
|
+
tracks: { lidAngle: [[0, 0], [1, 110]] },
|
|
74
|
+
},
|
|
75
|
+
cycle: {
|
|
76
|
+
label: "Open / close",
|
|
77
|
+
duration: 2.4,
|
|
78
|
+
loop: true,
|
|
79
|
+
easing: "linear",
|
|
80
|
+
autoplay: true,
|
|
81
|
+
tracks: { lidAngle: [[0, 0], [0.5, 110], [1, 0]] },
|
|
82
|
+
},
|
|
83
|
+
assemble: {
|
|
84
|
+
label: "Assemble",
|
|
85
|
+
description: "How the parts come together: the lid fades in above the base, drops on, then swings open to check hinge clearance.",
|
|
86
|
+
steps: [
|
|
87
|
+
// The lidLift hold-track pins the lift at 40 while the lid fades in,
|
|
88
|
+
// so step 2's drop starts from where the fade showed it. Without it
|
|
89
|
+
// the lift would hold step 2's FIRST keyframe — also 40 — but
|
|
90
|
+
// stating it makes the pose explicit and survives a retune of step 2.
|
|
91
|
+
{ label: "Lid appears", camera: "iso", duration: 0.8,
|
|
92
|
+
opacity: { lid: [[0, 0], [1, 1]] },
|
|
93
|
+
tracks: { lidLift: [[0, 40], [1, 40]] } },
|
|
94
|
+
{ label: "Lower the lid", camera: "left", duration: 1.0,
|
|
95
|
+
tracks: { lidLift: [[0, 40], [1, 0]] } },
|
|
96
|
+
{ label: "Open to check clearance", camera: "iso", duration: 1.0,
|
|
97
|
+
tracks: { lidAngle: [[0, 0], [1, 110]] } },
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
85
101
|
},
|
|
86
102
|
},
|
|
87
103
|
verify: {
|
package/src/testing/render.js
CHANGED
|
@@ -32,21 +32,50 @@ const norm = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0]
|
|
|
32
32
|
// rasterizer (orthographic, z-buffered, Lambert-shaded, with depth-tested edge
|
|
33
33
|
// overlays). No native module, no browser. Returns the written file paths.
|
|
34
34
|
// pngjs is lazy-imported so importing the testing barrel for measure never loads it.
|
|
35
|
+
//
|
|
36
|
+
// `opacity` is a Record<subPartName, number> (an animation's evaluate() output,
|
|
37
|
+
// typically): a sub-part at 0 is skipped entirely — faces AND edges — but it
|
|
38
|
+
// still counts toward the SCENE BOUNDS, so a part crossing 0 cannot silently
|
|
39
|
+
// reframe the still. Framing is a property of the pose, not of what happens to
|
|
40
|
+
// be visible: without that, `--at 0,0.5,1` over a fade drew the same base at
|
|
41
|
+
// three different scales and the sequence read as a zoom.
|
|
42
|
+
// Values in (0,1) fade by PRE-BLENDING that part's shaded base and edge
|
|
43
|
+
// colours toward the background. That is a z-buffered approximation: a faded
|
|
44
|
+
// part still fully occludes whatever is behind it, because real transparency
|
|
45
|
+
// needs back-to-front sorting this rasterizer does not do. Stills only need to
|
|
46
|
+
// read as faded, so the approximation is the contract, not a stopgap.
|
|
35
47
|
export async function renderViews(kernel, part, view = Object.keys(part.views)[0], {
|
|
36
48
|
views = ["iso", "front", "top"], out = "render", size = [800, 600], edges = true, params = {}, tag = "",
|
|
49
|
+
opacity = {},
|
|
37
50
|
} = {}) {
|
|
38
51
|
const { PNG } = await import("pngjs");
|
|
39
52
|
const [W, H] = size;
|
|
40
|
-
|
|
53
|
+
// Sub-part names are kept alongside the meshes: opacity is keyed by name.
|
|
54
|
+
// Own-key lookups only — a part named "constructor" must not inherit a value
|
|
55
|
+
// off Object.prototype and vanish from the render.
|
|
56
|
+
const opacityOf = (name) => {
|
|
57
|
+
if (!Object.hasOwn(opacity ?? {}, name)) return 1;
|
|
58
|
+
const v = Number(opacity[name]);
|
|
59
|
+
return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 1; // a junk value renders solid
|
|
60
|
+
};
|
|
61
|
+
const built = buildView(kernel, part, view, params) // copied out
|
|
62
|
+
.map((b) => ({ name: b.name, mesh: b.mesh }));
|
|
41
63
|
|
|
42
|
-
//
|
|
64
|
+
// Scene bounds over EVERY built sub-part, visible or not (positions are
|
|
65
|
+
// JS-owned; safe after cleanup). Opacity is deliberately NOT consulted here —
|
|
66
|
+
// see the note above on why a fade must not move the camera.
|
|
43
67
|
const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
|
|
44
|
-
for (const m of
|
|
68
|
+
for (const { mesh: m } of built) {
|
|
45
69
|
const b = bounds(m.positions);
|
|
46
70
|
for (let i = 0; i < 3; i++) { lo[i] = Math.min(lo[i], b.min[i]); hi[i] = Math.max(hi[i], b.max[i]); }
|
|
47
71
|
}
|
|
72
|
+
// kernel.cleanup() walks the backend's own tracked list, not this array, so
|
|
73
|
+
// dropping the hidden sub-parts afterwards frees nothing and skips nothing.
|
|
48
74
|
kernel.cleanup?.();
|
|
49
75
|
|
|
76
|
+
// …and only the visible ones are rasterized: faces AND edges below iterate this.
|
|
77
|
+
const meshes = built.filter(({ name }) => opacityOf(name) > 0);
|
|
78
|
+
|
|
50
79
|
const center = [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2];
|
|
51
80
|
const radius = Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) / 2 || 5;
|
|
52
81
|
|
|
@@ -84,7 +113,12 @@ export async function renderViews(kernel, part, view = Object.keys(part.views)[0
|
|
|
84
113
|
for (let i = 0; i < W * H; i++) { color[i * 3] = bg[0]; color[i * 3 + 1] = bg[1]; color[i * 3 + 2] = bg[2]; }
|
|
85
114
|
const zbuf = new Float32Array(W * H).fill(-Infinity); // larger depth = nearer camera
|
|
86
115
|
|
|
87
|
-
for (const m of meshes) {
|
|
116
|
+
for (const { name, mesh: m } of meshes) {
|
|
117
|
+
// Pre-blend toward the background: the fade is baked into the material
|
|
118
|
+
// colour before shading, so no per-pixel compositing (and no depth sort)
|
|
119
|
+
// is needed. See the note on renderViews for why that is enough here.
|
|
120
|
+
const v = opacityOf(name);
|
|
121
|
+
const faded = v < 1 ? base.map((c, i) => Math.round(c * v + bg[i] * (1 - v))) : base;
|
|
88
122
|
const P = m.positions, N = m.normals, ind = m.indices;
|
|
89
123
|
// Manifold meshes are a non-indexed soup (3 consecutive verts/triangle);
|
|
90
124
|
// OCCT meshes are indexed. Both carry per-vertex normals.
|
|
@@ -109,16 +143,18 @@ export async function renderViews(kernel, part, view = Object.keys(part.views)[0
|
|
|
109
143
|
const I0 = Math.min(1, ambient + diffuse * Math.abs((nx * light[0] + ny * light[1] + nz * light[2]) / L));
|
|
110
144
|
inten = [I0, I0, I0];
|
|
111
145
|
}
|
|
112
|
-
rasterTri(sp, inten,
|
|
146
|
+
rasterTri(sp, inten, faded, color, zbuf, W, H);
|
|
113
147
|
}
|
|
114
148
|
}
|
|
115
149
|
|
|
116
150
|
if (edges) {
|
|
117
|
-
for (const m of meshes) {
|
|
151
|
+
for (const { name, mesh: m } of meshes) {
|
|
118
152
|
const E = m.edges;
|
|
119
153
|
if (!E?.length) continue;
|
|
154
|
+
const v = opacityOf(name);
|
|
155
|
+
const fadedEdge = v < 1 ? edgeColor.map((c, i) => Math.round(c * v + bg[i] * (1 - v))) : edgeColor;
|
|
120
156
|
for (let i = 0; i < E.length; i += 6)
|
|
121
|
-
drawLine(project([E[i], E[i + 1], E[i + 2]]), project([E[i + 3], E[i + 4], E[i + 5]]),
|
|
157
|
+
drawLine(project([E[i], E[i + 1], E[i + 2]]), project([E[i + 3], E[i + 4], E[i + 5]]), fadedEdge, color, zbuf, W, H, bias);
|
|
122
158
|
}
|
|
123
159
|
}
|
|
124
160
|
|
package/types/index.d.ts
CHANGED
|
@@ -146,8 +146,13 @@ export interface CaptureViewOptions {
|
|
|
146
146
|
export type AnimationStatus = "idle" | "intro" | "playing" | "paused";
|
|
147
147
|
|
|
148
148
|
export interface AnimationState {
|
|
149
|
-
/** The
|
|
150
|
-
|
|
149
|
+
/** The active view (tab) name — animations belong to a view. */
|
|
150
|
+
view: string;
|
|
151
|
+
/**
|
|
152
|
+
* The selected animation's key, or `null` while the active view declares no
|
|
153
|
+
* animations. Switching views re-selects that view's first animation.
|
|
154
|
+
*/
|
|
155
|
+
animation: string | null;
|
|
151
156
|
status: AnimationStatus;
|
|
152
157
|
/** Position on the timeline, 0..1 over the animation's total duration. */
|
|
153
158
|
t: number;
|
|
@@ -158,12 +163,16 @@ export interface AnimationState {
|
|
|
158
163
|
/**
|
|
159
164
|
* Part-declared animation playback — the same engine the viewer's transport bar
|
|
160
165
|
* drives. Playback writes real params, so exporting while paused exports the
|
|
161
|
-
* posed state, and any user or host param edit pauses it.
|
|
166
|
+
* posed state, and any user or host param edit pauses it. An animation's
|
|
167
|
+
* `opacity` tracks are the exception: display-only, never written to params and
|
|
168
|
+
* never visible to export.
|
|
162
169
|
*/
|
|
163
170
|
export interface AnimationRuntime {
|
|
164
171
|
/**
|
|
165
|
-
* Play, optionally switching to a named animation first.
|
|
166
|
-
*
|
|
172
|
+
* Play, optionally switching to a named animation first. The name resolves
|
|
173
|
+
* within the ACTIVE view — an animation declared by another view is not
|
|
174
|
+
* playable from here. An unknown name warns and does nothing rather than
|
|
175
|
+
* playing whatever is selected.
|
|
167
176
|
*/
|
|
168
177
|
play(name?: string): void;
|
|
169
178
|
pause(): void;
|
|
@@ -239,8 +248,9 @@ export interface PartRuntime {
|
|
|
239
248
|
*/
|
|
240
249
|
setHostPane(pane: HostPane): void;
|
|
241
250
|
/**
|
|
242
|
-
* Part-declared animation playback, or `null` when
|
|
243
|
-
* `animations` block.
|
|
251
|
+
* Part-declared animation playback, or `null` when NO view declares an
|
|
252
|
+
* `animations` block. Non-null while any view does — including while the
|
|
253
|
+
* active view has none, where `state().animation` reads `null`.
|
|
244
254
|
*/
|
|
245
255
|
animation: AnimationRuntime | null;
|
|
246
256
|
}
|