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.
@@ -51,12 +51,13 @@ export function normalizeAnimation(name, spec) {
51
51
  duration: s.duration,
52
52
  easing: s.easing ?? spec.easing ?? DEFAULT_EASING,
53
53
  tracks: s.tracks ?? {},
54
+ opacity: s.opacity ?? {},
54
55
  camera: s.camera ?? null,
55
56
  }))
56
57
  : [{
57
58
  label: null, duration: spec.duration,
58
59
  easing: spec.easing ?? DEFAULT_EASING,
59
- tracks: spec.tracks ?? {}, camera: null,
60
+ tracks: spec.tracks ?? {}, opacity: spec.opacity ?? {}, camera: null,
60
61
  }];
61
62
  const totalDuration = steps.reduce((sum, s) => sum + s.duration, 0) || 1;
62
63
  let acc = 0;
@@ -65,8 +66,13 @@ export function normalizeAnimation(name, spec) {
65
66
  if (typeof spec.camera === "string") cues = [{ t: 0, view: spec.camera }];
66
67
  else if (Array.isArray(spec.camera)) cues = spec.camera.map(([t, view]) => ({ t, view }));
67
68
  else cues = steps.flatMap((s, i) => (s.camera ? [{ t: stepStarts[i], view: s.camera }] : []));
68
- const trackedKeys = [...new Set(steps.flatMap((s) =>
69
- Object.entries(s.tracks).filter(([, kf]) => usableKeyframes(kf)).map(([key]) => key)))];
69
+ // Keys with at least one usable keyframe list in any step, for one field
70
+ // ("tracks" or "opacity"). Shares usableKeyframes with segmentsFor — the
71
+ // single rule both must agree on (see the comment on usableKeyframes).
72
+ const keysOf = (field) => [...new Set(steps.flatMap((s) =>
73
+ Object.entries(s[field]).filter(([, kf]) => usableKeyframes(kf)).map(([key]) => key)))];
74
+ const trackedKeys = keysOf("tracks");
75
+ const opacityKeys = keysOf("opacity");
70
76
  return {
71
77
  name, label: spec.label ?? name, description: spec.description ?? null,
72
78
  // Fail CLOSED on both flags: only a literal `true` turns them on. Coercing
@@ -76,12 +82,32 @@ export function normalizeAnimation(name, spec) {
76
82
  // having been linted. An invalid flag therefore does the quiet thing here and
77
83
  // is reported there.
78
84
  loop: spec.loop === true, autoplay: spec.autoplay === true,
79
- steps, stepStarts, totalDuration, cues, trackedKeys,
85
+ steps, stepStarts, totalDuration, cues, trackedKeys, opacityKeys,
80
86
  };
81
87
  }
82
88
 
83
- export function normalizeAnimations(part) {
84
- return Object.entries(part?.animations ?? {}).map(([name, spec]) => normalizeAnimation(name, spec));
89
+ // Per-view normalized animations (spec 2026-08-10-per-view-animations):
90
+ // Map(viewName -> NormalizedAnimation[]), one entry per declared view, [] for
91
+ // views without animations. Malformed entries are SKIPPED, not thrown — the
92
+ // runtime must degrade to "that animation doesn't exist" while lint reports
93
+ // the specifics. A legacy top-level `animations` key is deliberately ignored
94
+ // (clean break; lint's animation-not-in-view names the fix).
95
+ export function viewAnimations(part) {
96
+ const out = new Map();
97
+ const views = part?.views;
98
+ if (views === null || typeof views !== "object" || Array.isArray(views)) return out;
99
+ for (const [viewName, view] of Object.entries(views)) {
100
+ const block = view?.animations;
101
+ const entries = block !== null && typeof block === "object" && !Array.isArray(block)
102
+ ? Object.entries(block)
103
+ : [];
104
+ const anims = [];
105
+ for (const [name, spec] of entries) {
106
+ try { anims.push(normalizeAnimation(name, spec)); } catch { /* lint reports */ }
107
+ }
108
+ out.set(viewName, anims);
109
+ }
110
+ return out;
85
111
  }
86
112
 
87
113
  // Step containing t. Boundaries belong to the LATER step, and t clamps to [0,1].
@@ -100,11 +126,12 @@ export function cueAt(anim, t) {
100
126
  return g;
101
127
  }
102
128
 
103
- // The timeline segments (global [start,end] spans) in which `key` is tracked.
104
- function segmentsFor(anim, key) {
129
+ // The timeline segments (global [start,end] spans) in which `key` is tracked,
130
+ // for one keyframe field ("tracks" for params, "opacity" for sub-part fades).
131
+ function segmentsFor(anim, key, field = "tracks") {
105
132
  const out = [];
106
133
  anim.steps.forEach((step, i) => {
107
- const kf = step.tracks[key];
134
+ const kf = step[field][key];
108
135
  if (!usableKeyframes(kf)) return;
109
136
  const start = anim.stepStarts[i];
110
137
  const end = i + 1 < anim.steps.length ? anim.stepStarts[i + 1] : 1;
@@ -126,11 +153,11 @@ function interpKeyframes(kf, u) {
126
153
  return kf[kf.length - 1][1];
127
154
  }
128
155
 
129
- function evaluateTrack(anim, key, t) {
130
- const segs = segmentsFor(anim, key);
131
- // trackedKeys and segmentsFor share usableKeyframes, so a tracked key always
132
- // has a segment. Guard anyway: this runs inside the render loop, where a throw
133
- // costs the whole viewer, not just the frame.
156
+ function evaluateTrack(anim, key, t, field = "tracks") {
157
+ const segs = segmentsFor(anim, key, field);
158
+ // trackedKeys/opacityKeys and segmentsFor share usableKeyframes, so a tracked
159
+ // key always has a segment. Guard anyway: this runs inside the render loop,
160
+ // where a throw costs the whole viewer, not just the frame.
134
161
  if (!segs.length) return undefined;
135
162
  let prev = null;
136
163
  for (const seg of segs) {
@@ -142,8 +169,8 @@ function evaluateTrack(anim, key, t) {
142
169
  }
143
170
  prev = seg;
144
171
  }
145
- // Outside every segment: hold the nearest boundary value, so a param tracked
146
- // only in step 2 doesn't jump while step 1 plays.
172
+ // Outside every segment: hold the nearest boundary value, so a param (or an
173
+ // opacity) tracked only in step 2 doesn't jump while step 1 plays.
147
174
  return prev ? prev.keyframes[prev.keyframes.length - 1][1] : segs[0].keyframes[0][1];
148
175
  }
149
176
 
@@ -152,8 +179,10 @@ function evaluateTrack(anim, key, t) {
152
179
  export function evaluate(anim, t) {
153
180
  const tc = clampT(t);
154
181
  const values = {};
155
- for (const key of anim.trackedKeys) values[key] = evaluateTrack(anim, key, tc);
156
- return { stepIndex: stepIndexAt(anim, tc), values };
182
+ for (const key of anim.trackedKeys) values[key] = evaluateTrack(anim, key, tc, "tracks");
183
+ const opacity = {};
184
+ for (const key of anim.opacityKeys) opacity[key] = evaluateTrack(anim, key, tc, "opacity");
185
+ return { stepIndex: stepIndexAt(anim, tc), values, opacity };
157
186
  }
158
187
 
159
188
  // --- playback state machine --------------------------------------------------
@@ -412,6 +412,19 @@ export function createCutaway({
412
412
  };
413
413
  }
414
414
 
415
+ // Re-assert this sub-part's enabled-state material assignment. The viewer
416
+ // calls it after taking a sub-part OFF a fade clone: while the cutaway is
417
+ // enabled the mesh belongs on clippedMeshMaterial, and only the render set
418
+ // knows that material. setEnabled is idempotent, so this is a cheap
419
+ // re-assert, not a rebuild.
420
+ function resyncSubpart(name) {
421
+ if (disposed) return false;
422
+ const entry = renderSets.get(name);
423
+ if (!entry) return false;
424
+ entry.renderSet.setEnabled(enabled);
425
+ return true;
426
+ }
427
+
415
428
  // Per-frame maintenance while the cutaway is on: the gizmo rescales for the
416
429
  // camera, and every visible section re-slices its outline if anything it
417
430
  // depends on moved. Both are cheap no-ops when nothing changed.
@@ -499,6 +512,7 @@ export function createCutaway({
499
512
  updateGeometry,
500
513
  setVisible,
501
514
  setEnabled,
515
+ resyncSubpart,
502
516
  reset,
503
517
  flip,
504
518
  setTheme,