partforge 0.49.0 → 0.50.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 +8 -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/cutaway.js +14 -0
- package/src/framework/lint/rules-animations.js +177 -92
- package/src/framework/mount.js +26 -9
- 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/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
|
}
|
package/types/part.d.ts
CHANGED
|
@@ -308,6 +308,14 @@ export interface SubPartDefinition<P = ResolvedParams, D = Derived> {
|
|
|
308
308
|
|
|
309
309
|
export interface ViewDefinition {
|
|
310
310
|
label: string;
|
|
311
|
+
/**
|
|
312
|
+
* Named animations belonging to this view — keyframe data driving this view's
|
|
313
|
+
* params and sub-part opacity over time. See `AnimationSpec` below; the
|
|
314
|
+
* transport bar shows one view's animations at a time, and `play(name)`
|
|
315
|
+
* resolves within the active view. Animations live ONLY here: a top-level
|
|
316
|
+
* `part.animations` is a lint error and is ignored at runtime.
|
|
317
|
+
*/
|
|
318
|
+
animations?: Record<string, AnimationSpec>;
|
|
311
319
|
}
|
|
312
320
|
|
|
313
321
|
// --- the verify block -------------------------------------------------------
|
|
@@ -430,12 +438,20 @@ export interface AnimationStep {
|
|
|
430
438
|
/**
|
|
431
439
|
* Param key -> keyframes. A param tracked nowhere keeps its current value.
|
|
432
440
|
*
|
|
433
|
-
* Optional so a step can move only the camera — an
|
|
434
|
-
* holds the pose while the view swings round.
|
|
435
|
-
* that at least one step in the animation
|
|
436
|
-
* whole-animation rule the type
|
|
441
|
+
* Optional so a step can carry only `opacity`, or move only the camera — an
|
|
442
|
+
* establishing shot that holds the pose while the view swings round.
|
|
443
|
+
* `partforge lint` still requires that at least one step in the animation
|
|
444
|
+
* carries `tracks` or `opacity`, which is a whole-animation rule the type
|
|
445
|
+
* system can't express per step.
|
|
437
446
|
*/
|
|
438
447
|
tracks?: Record<string, Keyframes>;
|
|
448
|
+
/**
|
|
449
|
+
* Sub-part name → opacity keyframes (values 0–1; 0 = fully hidden, mesh and
|
|
450
|
+
* edge lines both; multiplies any static `display.opacity`). Same keyframe
|
|
451
|
+
* rules as `tracks`; the same hold rule applies across steps. Display-only:
|
|
452
|
+
* never affects params, export, measure, or verify.
|
|
453
|
+
*/
|
|
454
|
+
opacity?: Record<string, Keyframes>;
|
|
439
455
|
/** Swing the camera to this angle when the step begins. */
|
|
440
456
|
camera?: CameraCue;
|
|
441
457
|
}
|
|
@@ -460,7 +476,7 @@ export interface AnimationSpecCommon {
|
|
|
460
476
|
/**
|
|
461
477
|
* Start this animation automatically on first show and again on each view
|
|
462
478
|
* switch, until the user touches the transport. At most one animation per
|
|
463
|
-
*
|
|
479
|
+
* VIEW may set this; `partforge lint` enforces it
|
|
464
480
|
* (`animation-autoplay-invalid`). Not armed when the browser reports
|
|
465
481
|
* `prefers-reduced-motion: reduce`.
|
|
466
482
|
*/
|
|
@@ -468,10 +484,12 @@ export interface AnimationSpecCommon {
|
|
|
468
484
|
}
|
|
469
485
|
|
|
470
486
|
/**
|
|
471
|
-
* An animation is EITHER single-phase (`tracks`
|
|
472
|
-
* (`steps`) — never both, never neither. `partforge
|
|
473
|
-
* (`animation-tracks-or-steps`), and the union says the
|
|
474
|
-
* carrying both is rejected before it ever reaches lint.
|
|
487
|
+
* An animation is EITHER single-phase (`tracks` and/or `opacity`, plus a
|
|
488
|
+
* `duration`) OR stepped (`steps`) — never both, never neither. `partforge
|
|
489
|
+
* lint` enforces that (`animation-tracks-or-steps`), and the union says the
|
|
490
|
+
* same thing, so a block carrying both is rejected before it ever reaches lint.
|
|
491
|
+
* The first two arms are the two ways to satisfy "at least one of
|
|
492
|
+
* `tracks`/`opacity`".
|
|
475
493
|
*/
|
|
476
494
|
export type AnimationSpec =
|
|
477
495
|
| (AnimationSpecCommon & {
|
|
@@ -479,12 +497,22 @@ export type AnimationSpec =
|
|
|
479
497
|
duration: number;
|
|
480
498
|
/** Param key -> keyframes. */
|
|
481
499
|
tracks: Record<string, Keyframes>;
|
|
500
|
+
/** Sub-part name -> opacity keyframes (see AnimationStep.opacity). */
|
|
501
|
+
opacity?: Record<string, Keyframes>;
|
|
502
|
+
steps?: never;
|
|
503
|
+
})
|
|
504
|
+
| (AnimationSpecCommon & {
|
|
505
|
+
duration: number;
|
|
506
|
+
tracks?: Record<string, Keyframes>;
|
|
507
|
+
/** An opacity-only animation is legal — a pure fade. */
|
|
508
|
+
opacity: Record<string, Keyframes>;
|
|
482
509
|
steps?: never;
|
|
483
510
|
})
|
|
484
511
|
| (AnimationSpecCommon & {
|
|
485
512
|
/** The multi-step form; each step carries its own relative `duration`. */
|
|
486
513
|
steps: AnimationStep[];
|
|
487
514
|
tracks?: never;
|
|
515
|
+
opacity?: never;
|
|
488
516
|
duration?: never;
|
|
489
517
|
});
|
|
490
518
|
|
|
@@ -510,10 +538,8 @@ export interface PartDefinition<P = ResolvedParams, D = Derived> {
|
|
|
510
538
|
derive?: DeriveSpec<P, D>;
|
|
511
539
|
/** Named sub-parts; each builds exactly one solid. */
|
|
512
540
|
parts: Record<string, SubPartDefinition<P, D>>;
|
|
513
|
-
/** The view tabs. A view is a set of sub-parts
|
|
541
|
+
/** The view tabs. A view is a set of sub-parts, and owns its own `animations`. */
|
|
514
542
|
views: Record<string, ViewDefinition>;
|
|
515
543
|
/** Self-verification, co-located with the schema. */
|
|
516
544
|
verify?: VerifyBlock<P, D>;
|
|
517
|
-
/** Named animations: keyframe data driving existing params over time. */
|
|
518
|
-
animations?: Record<string, AnimationSpec>;
|
|
519
545
|
}
|
package/types/testing.d.ts
CHANGED
|
@@ -358,5 +358,13 @@ export function renderViews(
|
|
|
358
358
|
size?: [number, number];
|
|
359
359
|
edges?: boolean;
|
|
360
360
|
params?: ResolvedParams;
|
|
361
|
+
/** Frame suffix in the written filename (`<part>-<view>-<angle>-<tag>.png`). */
|
|
362
|
+
tag?: string;
|
|
363
|
+
/**
|
|
364
|
+
* Per-sub-part opacity, keyed by sub-part name (an animation `evaluate()`
|
|
365
|
+
* result). `0` omits the sub-part entirely; `0 < v < 1` fades it toward the
|
|
366
|
+
* background. Absent keys render solid.
|
|
367
|
+
*/
|
|
368
|
+
opacity?: Record<string, number>;
|
|
361
369
|
},
|
|
362
370
|
): Promise<string[]>;
|