partforge 0.48.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.
@@ -20,6 +20,15 @@ export function popoverTop({ glyphTop, glyphBottom, popHeight, viewportHeight })
20
20
  return Math.max(8, glyphTop - 6 - popHeight);
21
21
  }
22
22
 
23
+ // Popover left edge: aligned 8px left of the glyph when that fits, pulled
24
+ // left so the popover's right edge keeps a 10px margin from the viewport
25
+ // edge, and never past a 10px margin on the left (left margin wins when both
26
+ // would be violated). Pure, for direct unit testing — happy-dom reports zero
27
+ // layout metrics, same as popoverTop above.
28
+ export function popoverLeft({ glyphLeft, popWidth, viewportWidth }) {
29
+ return Math.max(10, Math.min(glyphLeft - 8, viewportWidth - 10 - popWidth));
30
+ }
31
+
23
32
  // One popover element per panel, shared by all its glyphs (only one open at a
24
33
  // time). Document-level dismiss listeners are registered per panel and removed
25
34
  // by panel.dispose().
@@ -51,7 +60,7 @@ export function createInfoPopover() {
51
60
  glyph.setAttribute("aria-expanded", "true");
52
61
  const r = glyph.getBoundingClientRect();
53
62
  pop.style.top = `${popoverTop({ glyphTop: r.top, glyphBottom: r.bottom, popHeight: pop.offsetHeight, viewportHeight: window.innerHeight })}px`;
54
- pop.style.left = `${Math.max(8, r.left - 8)}px`;
63
+ pop.style.left = `${popoverLeft({ glyphLeft: r.left, popWidth: pop.offsetWidth, viewportWidth: window.innerWidth })}px`;
55
64
  },
56
65
  dispose() {
57
66
  document.removeEventListener("click", onDocClick);
@@ -24,7 +24,7 @@ function indexNodes(nodes, map) {
24
24
  }
25
25
  }
26
26
 
27
- export function buildControls(root, parameters, params, onDirty) {
27
+ export function buildControls(root, parameters, params, onDirty, onCommit) {
28
28
  const info = createInfoPopover();
29
29
  const tree = buildTree(desugar(parameters));
30
30
 
@@ -38,9 +38,11 @@ export function buildControls(root, parameters, params, onDirty) {
38
38
  const lastVisible = new Map(); // id -> previous `visible`, to detect a reveal
39
39
  const lastDisabled = new Map(); // id -> previous `disabled`, to skip a no-op input pass
40
40
  // Containers that own a disclosure: sections, and titled inner groups (the
41
- // legacy "Advanced" fold). `label` is set only for the inner groups, whose
42
- // button text carries the ▾/▴ instead of a chevron span.
43
- const disclosures = new Map(); // id -> { body, button, label }
41
+ // legacy "Advanced" fold). Both share the same anatomy a header row with
42
+ // the aria-carrying button and a text-free chevron span so `el` (the
43
+ // section element / the fold wrapper) mirrors the disclosure with a
44
+ // `.collapsed` class and CSS draws the closed-band affordance off that.
45
+ const disclosures = new Map(); // id -> { body, button, el }
44
46
  indexNodes(tree, nodeById);
45
47
  let relevant = null;
46
48
 
@@ -62,7 +64,7 @@ export function buildControls(root, parameters, params, onDirty) {
62
64
  const open = state.get(id)?.open ?? true;
63
65
  d.body.classList.toggle("hidden", !open);
64
66
  d.button.setAttribute("aria-expanded", String(open));
65
- if (d.label) d.button.textContent = open ? `${d.label} ▴` : `${d.label} ▾`;
67
+ d.el.classList.toggle("collapsed", !open);
66
68
  }
67
69
  };
68
70
 
@@ -112,6 +114,15 @@ export function buildControls(root, parameters, params, onDirty) {
112
114
 
113
115
  const onEdit = () => { applyState(); onDirty?.(); };
114
116
 
117
+ // A commit = the user FINISHED an interaction (slider released, box
118
+ // committed, checkbox ticked, preset applied). Distinct from onDirty, which
119
+ // fires on every input event mid-drag. Wrapped: a throwing host handler
120
+ // must never break the panel.
121
+ const commit = (keys) => {
122
+ if (!onCommit) return;
123
+ try { onCommit(keys); } catch { /* host's problem, not the panel's */ }
124
+ };
125
+
115
126
  // --- build ---------------------------------------------------------------
116
127
  //
117
128
  // TWO DIFFERENT THINGS USE `.hidden`, and conflating them is a real bug:
@@ -136,17 +147,24 @@ export function buildControls(root, parameters, params, onDirty) {
136
147
  const wrap = el("div", "adv-wrap");
137
148
  const body = el("div", "adv hidden"); // starts closed — legacy parity
138
149
  body.id = `pf-fold-${node.id.replaceAll("/", "-")}`;
139
- const toggle = el("button", "adv-toggle", `${node.title} ▾`);
150
+ // Same row anatomy as a section header — title button (text-free of any
151
+ // arrow, same exact-textContent reasoning), chevron span on the right,
152
+ // whole row clickable — at the fold's subordinate scale.
153
+ const foldHeader = el("div", "adv-header");
154
+ const toggle = el("button", "adv-toggle");
155
+ toggle.type = "button";
156
+ toggle.append(el("span", "adv-name", node.title));
140
157
  toggle.setAttribute("aria-controls", body.id);
141
- toggle.addEventListener("click", () => {
158
+ foldHeader.append(toggle, el("span", "chev"));
159
+ foldHeader.addEventListener("click", () => {
142
160
  const nowHidden = body.classList.toggle("hidden");
143
- toggle.textContent = nowHidden ? `${node.title} ▾` : `${node.title} ▴`;
144
161
  toggle.setAttribute("aria-expanded", String(!nowHidden));
162
+ wrap.classList.toggle("collapsed", nowHidden);
145
163
  });
146
164
  for (const child of node.children) renderNode(child, body, sectionCtx);
147
- wrap.append(toggle, body);
165
+ wrap.append(foldHeader, body);
148
166
  nodeEls.set(node.id, wrap); // conditions act on the wrapper
149
- disclosures.set(node.id, { body, button: toggle, label: node.title });
167
+ disclosures.set(node.id, { body, button: toggle, el: wrap });
150
168
  container.append(wrap);
151
169
  }
152
170
 
@@ -173,6 +191,7 @@ export function buildControls(root, parameters, params, onDirty) {
173
191
  Object.assign(params, bundle);
174
192
  for (const { key, sync } of rawSyncs.get(sectionCtx.id)) if (key in params) sync();
175
193
  onEdit();
194
+ commit(Object.keys(bundle));
176
195
  });
177
196
  // The section's controls need a handle on the picker to drop it to Custom
178
197
  // when one of them is edited. First picker in the section wins.
@@ -203,6 +222,7 @@ export function buildControls(root, parameters, params, onDirty) {
203
222
  };
204
223
  const widget = factory(node, params, {
205
224
  onChange: () => { markCustom(); onEdit(); },
225
+ onCommit: () => commit([node.key]),
206
226
  info,
207
227
  });
208
228
  nodeEls.set(node.id, widget.el);
@@ -233,12 +253,14 @@ export function buildControls(root, parameters, params, onDirty) {
233
253
  // because sectionByTitle-style lookups match `.sec-title` by exact
234
254
  // textContent === title (controls.test.js:210), and a text chevron here
235
255
  // would break that match.
236
- title.append(el("span", "sec-name", section.title ?? ""), el("span", "chev"));
256
+ title.append(el("span", "sec-name", section.title ?? ""));
237
257
  header.append(title);
258
+ // Row order: title (flex:1), then ⓘ, then the chevron on the far right.
238
259
  // The ⓘ is a SIBLING of the button, never a child: attachInfo appends a
239
260
  // <button>, and a button nested in a button is invalid HTML that never
240
261
  // receives clicks.
241
262
  attachInfo(header, section.description, info);
263
+ header.append(el("span", "chev"));
242
264
  secEl.append(header);
243
265
 
244
266
  const body = el("div", "sec-body");
@@ -246,11 +268,15 @@ export function buildControls(root, parameters, params, onDirty) {
246
268
  title.setAttribute("aria-controls", body.id);
247
269
  secEl.append(body);
248
270
 
249
- title.addEventListener("click", () => {
271
+ // The whole header row toggles: the title button's own click bubbles up
272
+ // here, the chevron and the empty row space hit it directly, and the ⓘ
273
+ // stops propagation in attachInfo. aria state stays on the title button.
274
+ header.addEventListener("click", () => {
250
275
  const nowHidden = body.classList.toggle("hidden");
251
276
  title.setAttribute("aria-expanded", String(!nowHidden));
277
+ secEl.classList.toggle("collapsed", nowHidden);
252
278
  });
253
- disclosures.set(section.id, { body, button: title, label: null });
279
+ disclosures.set(section.id, { body, button: title, el: secEl });
254
280
 
255
281
  // `preset` is filled in when a preset node renders. Controls read it late, so
256
282
  // one appearing after them in the children array still works.
@@ -14,7 +14,7 @@ function el(tag, className, text) {
14
14
  return node;
15
15
  }
16
16
 
17
- export function makeCheckbox(node, params, { onChange, info }) {
17
+ export function makeCheckbox(node, params, { onChange, onCommit, info }) {
18
18
  const row = el("label", "feat");
19
19
  const box = document.createElement("input");
20
20
  box.type = "checkbox";
@@ -30,6 +30,7 @@ export function makeCheckbox(node, params, { onChange, info }) {
30
30
  params[node.key] = 0;
31
31
  }
32
32
  onChange?.();
33
+ onCommit?.();
33
34
  });
34
35
 
35
36
  const sync = () => { box.checked = params[node.key] > 0; };
@@ -19,7 +19,7 @@ function el(tag, className, text) {
19
19
  return node;
20
20
  }
21
21
 
22
- export function makeNumeric(node, params, { onChange, info }) {
22
+ export function makeNumeric(node, params, { onChange, onCommit, info }) {
23
23
  const numeric = node.type === "number";
24
24
  const wrap = el("div", "slider");
25
25
  const row = el("div", "row");
@@ -68,6 +68,7 @@ export function makeNumeric(node, params, { onChange, info }) {
68
68
  paintWarn();
69
69
  onChange?.();
70
70
  });
71
+ slider.addEventListener("change", () => onCommit?.());
71
72
  wrap.append(slider);
72
73
  }
73
74
 
@@ -124,6 +125,7 @@ export function makeNumeric(node, params, { onChange, info }) {
124
125
  if (slider) slider.value = log ? toPosSafe(v) : v;
125
126
  paintWarn();
126
127
  onChange?.();
128
+ onCommit?.();
127
129
  });
128
130
 
129
131
  const sync = () => {
@@ -23,7 +23,7 @@ function labeledRow(node, info) {
23
23
  return wrap;
24
24
  }
25
25
 
26
- export function makeSelect(node, params, { onChange, info }) {
26
+ export function makeSelect(node, params, { onChange, onCommit, info }) {
27
27
  const wrap = labeledRow(node, info);
28
28
  const opts = normalizeOptions(node.options);
29
29
  const byString = new Map(opts.map((o) => [String(o.value), o.value]));
@@ -40,13 +40,14 @@ export function makeSelect(node, params, { onChange, info }) {
40
40
  select.addEventListener("change", () => {
41
41
  params[node.key] = byString.get(select.value);
42
42
  onChange?.();
43
+ onCommit?.();
43
44
  });
44
45
  wrap.append(select);
45
46
  const sync = () => { select.value = String(params[node.key]); };
46
47
  return { el: wrap, sync };
47
48
  }
48
49
 
49
- export function makeRadio(node, params, { onChange, info }) {
50
+ export function makeRadio(node, params, { onChange, onCommit, info }) {
50
51
  const wrap = labeledRow(node, info);
51
52
  const opts = normalizeOptions(node.options);
52
53
  const seg = el("div", "seg");
@@ -58,6 +59,7 @@ export function makeRadio(node, params, { onChange, info }) {
58
59
  params[node.key] = o.value;
59
60
  paint();
60
61
  onChange?.();
62
+ onCommit?.();
61
63
  });
62
64
  seg.append(b);
63
65
  return { b, value: o.value };
@@ -9,7 +9,7 @@ function el(tag, className, text) {
9
9
  return node;
10
10
  }
11
11
 
12
- export function makeText(node, params, { onChange, info }) {
12
+ export function makeText(node, params, { onChange, onCommit, info }) {
13
13
  const multiline = node.type === "textarea";
14
14
  const wrap = el("div", "slider");
15
15
  const row = el("div", "row");
@@ -26,6 +26,7 @@ export function makeText(node, params, { onChange, info }) {
26
26
  params[node.key] = field.value;
27
27
  onChange?.();
28
28
  });
29
+ field.addEventListener("change", () => onCommit?.());
29
30
  wrap.append(field);
30
31
 
31
32
  const sync = () => { field.value = String(params[node.key] ?? ""); };
@@ -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