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.
@@ -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
- for (const [name, mesh] of Object.entries(subMesh)) {
351
- const on = visibleNames.includes(name);
352
- if (on) {
353
- mesh.geometry = subCache[name]; // cached geometries reused, not disposed
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(visibleNames);
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
- return cutaway.setEnabled(on);
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?.dispose();
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,
@@ -1,7 +1,10 @@
1
1
  // Animation reference part — a box with a hinged lid. Worked example for
2
- // docs/AUTHORING-PARTS.md "Animations": pose-only animated params (lidAngle,
3
- // lidLift) driven through place(), an intro camera + markdown description on
4
- // `open`, a looping `cycle`, and a stepped `assemble` with per-step cameras.
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: { box: { label: "Box" } },
62
- animations: {
63
- open: {
64
- label: "Open lid",
65
- description: "Swings the lid to **110°** about the rear hinge line.\n\nPose-only: playback runs at frame rate with no geometry rebuild.",
66
- camera: "front",
67
- duration: 1.2,
68
- tracks: { lidAngle: [[0, 0], [1, 110]] },
69
- },
70
- cycle: {
71
- label: "Open / close",
72
- duration: 2.4,
73
- loop: true,
74
- easing: "linear",
75
- autoplay: true,
76
- tracks: { lidAngle: [[0, 0], [0.5, 110], [1, 0]] },
77
- },
78
- assemble: {
79
- label: "Assemble",
80
- description: "How the parts come together: the lid drops onto the base, then swings open to check hinge clearance.",
81
- steps: [
82
- { label: "Lower the lid", camera: "left", duration: 1.0, tracks: { lidLift: [[0, 40], [1, 0]] } },
83
- { label: "Open to check clearance", camera: "iso", duration: 1.0, tracks: { lidAngle: [[0, 0], [1, 110]] } },
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: {
@@ -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
- const meshes = buildView(kernel, part, view, params).map((b) => b.mesh); // copied out
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
- // scene bounds over all sub-parts (positions are JS-owned; safe after cleanup)
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 meshes) {
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, base, color, zbuf, W, H);
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]]), edgeColor, color, zbuf, W, H, bias);
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 selected animation's key. */
150
- animation: string;
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. An unknown name
166
- * warns and does nothing rather than playing whatever is selected.
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 the part declares no
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 establishing shot that
434
- * holds the pose while the view swings round. `partforge lint` still requires
435
- * that at least one step in the animation carries tracks, which is a
436
- * whole-animation rule the type system can't express per step.
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
- * part may set this; `partforge lint` enforces it
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` + `duration`) OR stepped
472
- * (`steps`) — never both, never neither. `partforge lint` enforces that
473
- * (`animation-tracks-or-steps`), and the union says the same thing, so a block
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
  }
@@ -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[]>;