@volter/editor-blender 0.1.135 → 0.1.137

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * THE SKIN AND THE CLIP — three.js PLAYS Blender's animation; Blender holds it
2
+ * THE TIMELINE'S ANIMATION — three.js PLAYS Blender's animation; Blender holds it
3
3
  * as DATA (owner rule, 2026-09-20: "we visualize with three.js, not Blender").
4
4
  *
5
5
  * ## Why this exists at all
@@ -8,11 +8,15 @@
8
8
  * it and re-exports the mesh columns. That is the wrong architecture: it puts
9
9
  * a WASM depsgraph evaluation and a full geometry round trip between the
10
10
  * person's pointer and the picture, and it moves `scene.frame_current` sixty
11
- * times a second under every bpy reader in the session. So instead the tab
12
- * reads the rig ONCE (`session.py`'s `rna_rig`) and the action ONCE
13
- * (`rna_action_clip`), builds a real `THREE.SkinnedMesh` + `Skeleton` +
14
- * `AnimationMixer`, and every scrub and every played frame after that costs
15
- * ZERO calls into Blender.
11
+ * times a second under every bpy reader in the session. So the skin travels in the
12
+ * frame itself: the export door ships each Armature-deformed mesh's weights per
13
+ * exported vertex (`bpy_web_export.cc` `write_skin`), the frame names every bone's
14
+ * rest, channels, constraints and pose and each armature's NLA stack, and the presenter
15
+ * builds the skeletons (`blender-runtime-skeleton.ts`). This director poses EVERY armature
16
+ * at the scene frame with Blender's own evaluation (`blender-pose.ts`): its NLA tracks and
17
+ * active action, blended as Blender blends them, and its Damped Track constraints. Every scrub
18
+ * and every played frame costs ZERO calls into Blender; an action is baked again exactly when
19
+ * the frame says its revision moved.
16
20
  *
17
21
  * ## The bind pose is the EXPORT pose, and that is the whole trick
18
22
  *
@@ -24,227 +28,60 @@
24
28
  * own evaluation of the same skin over the same columns. Nothing has to move
25
29
  * Blender's frame, ever.
26
30
  *
27
- * ## What that costs, stated rather than implied
31
+ * ## The fidelity check, and what is not played
28
32
  *
29
- * While a clip plays, the picture is three.js's skinning of the EXPORT-FRAME
30
- * mesh. Anything else Blender's depsgraph would do per frame — shape keys, a
31
- * Displace or Cast or Cloth modifier reading the frame, a driver on geometry —
32
- * does NOT follow the mixer. The Timeline's status line names any such
33
- * modifier on the played object rather than showing a confident picture of the
34
- * wrong thing (`modifiersNotPlayed`). A bone whose pose is a CONSTRAINT's
35
- * rather than its channels' is named the same way, because the clip is derived
36
- * from the F-Curves alone.
33
+ * At Blender's own frame, standing still, the picture IS Blender's pose (the frame's), and the
34
+ * evaluator's answer for that frame is compared with it bone by bone (`poseDivergence`). A bone
35
+ * further off than a tenth of a degree is said in the console with what would explain it, so a
36
+ * difference between this Timeline (and a game, which uses the same evaluator) and Blender is
37
+ * never silent. What the evaluator does not play — a constraint other than Damped Track, a
38
+ * driver, non-default parenting — is named once as well. Anything Blender's depsgraph does to
39
+ * GEOMETRY per frame (shape keys, a Displace or Cloth modifier) does not follow the bones.
37
40
  */
38
41
 
39
- import type {
40
- BlenderActionClip,
41
- BlenderRig,
42
- BlenderRigBinding,
43
- } from '@volter/blender-engine/browser/rna';
44
- import type { StageTransportHandle } from '@volter/editor-sdk/host';
45
- import * as THREE from 'three';
42
+ import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
43
+ import type { BlenderArmature } from '@volter/blender-engine/browser/three/blender-runtime-armature';
44
+ import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
45
+ import { editorHost, type StageTransportHandle } from '@volter/editor-sdk/host';
46
46
  import { blenderRnaSet } from '../host/blender-runtime-host';
47
+ import { actionLayer, ArmaturePose, nlaLayers, poseClip, poseDivergence, type PoseClip, type PoseLayer } from './blender-pose';
47
48
 
48
- /** Base64 → the typed array it holds. The same three lines every payload in
49
- * this package decodes with (`blender-uv-geometry.ts`'s `uvBytes`); kept
50
- * local because this module is in the presenter's closure and that one is in
51
- * the UV view's. */
52
- function bytesOf(base64: string): Uint8Array {
53
- const binary = atob(base64);
54
- const out = new Uint8Array(binary.length);
55
- for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
56
- return out;
57
- }
58
- function float32Of(base64: string): Float32Array {
59
- const bytes = bytesOf(base64);
60
- return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 2);
61
- }
62
- function uint16Of(base64: string): Uint16Array {
63
- const bytes = bytesOf(base64);
64
- return new Uint16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 1);
65
- }
66
-
67
- /** A row-major four-row matrix, as every matrix in the RNA doors crosses —
68
- * `THREE.Matrix4.set` takes its arguments row-major too, so this is a spread
69
- * and not a transpose. */
70
- function matrixOf(rows: readonly (readonly [number, number, number, number])[]): THREE.Matrix4 {
71
- return new THREE.Matrix4().set(...(rows.flat() as unknown as Parameters<THREE.Matrix4['set']>));
72
- }
73
-
74
- /** What the presenter needs of the view to swap a Mesh for a SkinnedMesh and
75
- * to hang bones off an armature. Deliberately three methods and not the view:
76
- * this module has no business knowing about frames, materials or overlays. */
77
- export interface SkinPresentation {
78
- /** The presented `Object3D` for a Blender object NAME, or null. */
79
- objectForBlenderName(name: string): THREE.Object3D | null;
80
- /** Put `next` where `previous` was — same parent, same place in the object
81
- * table — so the next frame's reuse check finds it. */
82
- replacePresentedObject(previous: THREE.Object3D, next: THREE.Object3D): void;
83
- /** The presented root, for the one forced `updateMatrixWorld` a bind needs. */
84
- root: THREE.Object3D;
49
+ /** How the director reads the engine: the Timeline's subject (its keys, the scene's range) and
50
+ * one action baked for one armature. */
51
+ export interface SkinReader {
52
+ clip(armature: string): Promise<BlenderActionClip | null>;
53
+ bake(armature: string, action: string): Promise<BlenderActionClip | null>;
85
54
  }
86
55
 
87
- export interface BoundRig {
88
- /** The MESH object's Blender name. */
89
- readonly object: string;
90
- readonly armature: string;
91
- readonly mesh: THREE.SkinnedMesh;
92
- readonly boneRoot: THREE.Group;
93
- readonly bones: readonly THREE.Bone[];
94
- readonly skeleton: THREE.Skeleton;
95
- /** `scene.frame_current` the binding was read at. */
96
- readonly frame: number;
97
- /** The identity a re-read is compared against: rebuilding a skeleton on
98
- * every present would throw away the mixer's time sixty times a minute. */
99
- readonly signature: string;
100
- }
101
-
102
- function rigSignature(rig: BlenderRigBinding, frame: number): string {
103
- return [rig.object, rig.mesh, rig.armature, rig.vertexCount, rig.bones.length, frame].join('|');
56
+ /** An armature's layers at a scene frame: its NLA, then its active action over them. */
57
+ export function sceneLayers(armature: BlenderArmature, frame: number,
58
+ clips: (action: string) => PoseClip | null | undefined): { layers: PoseLayer[]; waiting: boolean; skipped: string[] } {
59
+ const stack = nlaLayers(armature.animation, frame, clips);
60
+ const action = armature.action ?? null;
61
+ if (action) {
62
+ const clip = clips(action);
63
+ if (clip === undefined) stack.waiting = true;
64
+ const layer = clip ? actionLayer(armature.animation, clip, frame, stack.evaluated) : null;
65
+ if (layer) stack.layers.push(layer);
66
+ }
67
+ return stack;
104
68
  }
105
69
 
106
- /**
107
- * A BLENDER ACTION'S BAKED TRACKS as three.js keyframe tracks over `bones` (by Blender's bone
108
- * name), addressed by bone UUID because three's track-name grammar splits on `.` and `hand.L`
109
- * is an ordinary bone name. A track for a bone this binding lacks is named in `warnings`.
110
- */
111
- export function clipTracks(clip: BlenderActionClip, bones: ReadonlyMap<string, THREE.Bone>, warnings: string[]): THREE.KeyframeTrack[] {
112
- const tracks: THREE.KeyframeTrack[] = [];
113
- const missing = new Set<string>();
114
- for (const track of clip.tracks) {
115
- const bone = bones.get(track.bone);
116
- if (!bone) {
117
- missing.add(track.bone);
118
- continue;
119
- }
120
- const times = float32Of(track.timeBase64);
121
- const values = float32Of(track.valueBase64);
122
- const path = `${bone.uuid}.${track.property}`;
123
- tracks.push(
124
- track.property === 'quaternion'
125
- ? new THREE.QuaternionKeyframeTrack(path, Array.from(times), Array.from(values))
126
- : new THREE.VectorKeyframeTrack(path, Array.from(times), Array.from(values)),
127
- );
128
- }
129
- if (missing.size) warnings.push(`${clip.action} animates ${[...missing].join(', ')}, which this binding has no bone for.`);
130
- return tracks;
70
+ /** What to do about what plays only in Blender, by kind. */
71
+ export function remedy(unsupported: readonly string[]): string {
72
+ const out: string[] = [];
73
+ if (unsupported.some((thing) => thing.includes(' constraint ') || thing.includes('drivers')))
74
+ out.push('Bake a constraint or driver into the action (Pose ▸ Animation ▸ Bake Action, Visual Keying), or aim with a Damped Track.');
75
+ if (unsupported.some((thing) => thing.includes('curves on ')))
76
+ out.push("Key the motion on a bone (the root bone) instead of the armature object, or move the object from the game.");
77
+ if (unsupported.some((thing) => thing.includes('interpolation') || thing.includes(' modifier')))
78
+ out.push('Use Bezier, Linear or Constant keys and the Cycles modifier only, or bake the curves (Key ▸ Bake Keyframes).');
79
+ return out.length ? ` ${out.join(' ')}` : '';
131
80
  }
132
81
 
133
- /**
134
- * BIND ONE RIGGED MESH of a presentation: its drawn mesh becomes a `THREE.SkinnedMesh` over a
135
- * `Skeleton` built from Blender's bones, in the pose the columns were exported at (so the bind
136
- * is an identity there). Shared by the Timeline's director and Play's copy (`blender-play-skin.ts`).
137
- */
138
- export function bindRig(
139
- presentation: SkinPresentation,
140
- rig: BlenderRigBinding,
141
- frame: number,
142
- warnings: string[],
143
- ): BoundRig | null {
144
- const meshObject = presentation.objectForBlenderName(rig.object ?? '');
145
- const armatureObject = presentation.objectForBlenderName(rig.armature ?? '');
146
- if (!meshObject || !armatureObject) return null;
147
- const source = meshObject as THREE.Mesh;
148
- const geometry = source.geometry;
149
- if (!geometry) return null;
150
- const blenderVertex = geometry.getAttribute('blenderVertex');
151
- if (!blenderVertex) {
152
- warnings.push(
153
- `"${rig.object}" cannot be skinned: its presented geometry carries no \`blenderVertex\` attribute, so a per-Blender-vertex weight cannot be expanded onto its drawn vertices (presented as ${(meshObject as THREE.Object3D).type} with ${Object.keys(geometry.attributes).join(', ') || 'no attributes'}).`,
154
- );
155
- return null;
156
- }
157
- if (rig.vertexCount === 0) return null;
158
- // THE COLUMNS AND THE BINDING MUST BE THE SAME MESH. `rna_rig` reads the
159
- // ORIGINAL mesh's vertices (a deform layer is not geometry, so that is
160
- // where the weights live); a generative modifier — Subdivision, Mirror,
161
- // Array — makes the EVALUATED mesh the export door ships a different
162
- // vertex set, and a skin bound across that mismatch would weight the wrong
163
- // vertices. Named, never silently drawn.
164
- let highest = 0;
165
- for (let i = 0; i < blenderVertex.count; i++)
166
- highest = Math.max(highest, blenderVertex.getX(i));
167
- if (highest >= rig.vertexCount) {
168
- warnings.push(
169
- `"${rig.object}" is not skinned here: its presented geometry references Blender vertex ${highest} while the mesh declares ${rig.vertexCount}, which is a generative modifier (Subdivision, Mirror, Array…) between the two. Blender's own viewport shows the evaluated result; this presenter shows the exported columns unskinned.`,
170
- );
171
- return null;
172
- }
173
- const skinIndex = uint16Of(rig.skinIndexBase64 ?? '');
174
- const skinWeight = float32Of(rig.skinWeightBase64 ?? '');
175
- const drawn = blenderVertex.count;
176
- const indices = new Uint16Array(drawn * 4);
177
- const weights = new Float32Array(drawn * 4);
178
- for (let i = 0; i < drawn; i++) {
179
- const vertex = blenderVertex.getX(i);
180
- for (let k = 0; k < 4; k++) {
181
- indices[i * 4 + k] = skinIndex[vertex * 4 + k] ?? 0;
182
- weights[i * 4 + k] = skinWeight[vertex * 4 + k] ?? 0;
183
- }
184
- }
185
- geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(indices, 4));
186
- geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(weights, 4));
187
-
188
- const boneRoot = new THREE.Group();
189
- boneRoot.name = `${rig.armature}:bones`;
190
- const bones: THREE.Bone[] = [];
191
- const armatureSpace = rig.bones.map((bone) => matrixOf(bone.pose));
192
- const indexByName = new Map(rig.bones.map((bone, index) => [bone.name, index]));
193
- rig.bones.forEach((declared, index) => {
194
- const bone = new THREE.Bone();
195
- // THE NAME IS BLENDER'S, unsanitized, because a game reads it: the
196
- // arena's `Player.tsx` finds its bones by Blender's own names. The CLIP
197
- // therefore addresses bones by UUID rather than by name — three's
198
- // `PropertyBinding` track-name grammar splits on `.`, and Blender's
199
- // `hand.L` is an ordinary bone name.
200
- bone.name = declared.name;
201
- bones.push(bone);
202
- const parentIndex = declared.parent === null ? undefined : indexByName.get(declared.parent);
203
- const local =
204
- parentIndex === undefined
205
- ? armatureSpace[index]!.clone()
206
- : armatureSpace[parentIndex]!.clone().invert().multiply(armatureSpace[index]!);
207
- local.decompose(bone.position, bone.quaternion, bone.scale);
208
- (parentIndex === undefined ? boneRoot : bones[parentIndex]!).add(bone);
209
- });
210
- armatureObject.add(boneRoot);
211
-
212
- const skinned = new THREE.SkinnedMesh(geometry, source.material);
213
- skinned.name = source.name;
214
- skinned.matrixAutoUpdate = false;
215
- skinned.matrix.copy(source.matrix);
216
- skinned.matrix.decompose(skinned.position, skinned.quaternion, skinned.scale);
217
- skinned.userData = source.userData;
218
- // A SKIN MOVES PAST ITS BIND BOUNDS. three computes a SkinnedMesh's
219
- // bounding sphere from the bind pose, so a raised arm at frame 24 is
220
- // culled while its bind-pose sphere is off screen — a picture that
221
- // vanishes mid-scrub with no error anywhere.
222
- skinned.frustumCulled = false;
223
- presentation.replacePresentedObject(source, skinned);
224
- // THE BIND IS TAKEN AFTER THE GRAPH STANDS, because both halves of it are
225
- // WORLD matrices: `Skeleton`'s bone inverses are the bones' `matrixWorld`
226
- // at this instant and `bindMatrix` is the mesh's.
227
- presentation.root.updateMatrixWorld(true);
228
- const skeleton = new THREE.Skeleton(bones);
229
- skinned.bind(skeleton, skinned.matrixWorld.clone());
230
- if (rig.constrainedBones?.length)
231
- warnings.push(
232
- `Bone constraints on ${rig.constrainedBones.join(', ')}: the clip is derived from the F-Curves alone, so those bones play their channels rather than Blender's solved pose.`,
233
- );
234
- if (rig.unmappedGroups?.length)
235
- warnings.push(
236
- `${rig.object} carries vertex groups no bone is named for (${rig.unmappedGroups.join(', ')}); they weight nothing here, exactly as they deform nothing in Blender.`,
237
- );
238
- return {
239
- object: rig.object ?? '',
240
- armature: rig.armature ?? '',
241
- mesh: skinned,
242
- boneRoot,
243
- bones,
244
- skeleton,
245
- frame,
246
- signature: rigSignature(rig, frame),
247
- };
82
+ /** True when an armature has anything to evaluate: an action, or NLA strips. */
83
+ export function animated(armature: BlenderArmature): boolean {
84
+ return !!armature.action || !!armature.animation?.tracks.some((track) => track.strips.length);
248
85
  }
249
86
 
250
87
  /**
@@ -255,21 +92,17 @@ export function bindRig(
255
92
  * copy of the playback state anywhere.
256
93
  */
257
94
  export class BlenderSkinDirector {
258
- #rigs = new Map<string, BoundRig>();
259
- /** Rigs that could not be bound, by mesh name, at the signature tried: not retried until the
260
- * rig changes (a generative modifier, no vertex map), so a settle does not re-read and re-warn. */
261
- #unbindable = new Map<string, string>();
262
-
263
- /** What could not be bound, keyed by the rig AND what is presented for its mesh: the fix for a
264
- * refusal (removing a generative modifier, showing a hidden object) changes the presentation,
265
- * not the rig, and must be retried. */
266
- static #tried(presentation: SkinPresentation, mesh: string, signature: string): string {
267
- const object = presentation.objectForBlenderName(mesh) as THREE.Mesh | null;
268
- return `${signature}|${object?.uuid ?? 'absent'}|${object?.geometry?.uuid ?? 'none'}`;
269
- }
270
- #mixer: THREE.AnimationMixer | null = null;
271
- #action: THREE.AnimationAction | null = null;
95
+ /** The presented view whose skeletons are posed (`sync`). */
96
+ #view: BlenderRuntimeView | null = null;
97
+ #reader: SkinReader | null = null;
98
+ /** The Timeline's subject: the armature its keys and header describe, and what its header was
99
+ * read for. Read again only when that changes. */
100
+ #armature: string | null = null;
101
+ #clipKey: string | null = null;
272
102
  #clip: BlenderActionClip | null = null;
103
+ /** Each posed armature's evaluator, and every action baked for it, at the revision baked. */
104
+ #poses = new Map<string, ArmaturePose>();
105
+ #baked = new Map<string, { revision: number | null; clip: PoseClip | null | undefined }>();
273
106
  /** The frame the last SEEK asked for — see {@link frame}. */
274
107
  #seeked: number | null = null;
275
108
  /** The stage transport this skin is attached to, or null before `attachTo`.
@@ -279,6 +112,8 @@ export class BlenderSkinDirector {
279
112
  #listeners = new Set<() => void>();
280
113
  #version = 0;
281
114
  #warnings: string[] = [];
115
+ /** What was already said in the console, so each finding is said once. */
116
+ #said = new Set<string>();
282
117
  /** Engine calls this director has made, counted so a walk can prove that a
283
118
  * scrub costs none (the acceptance the owner named). */
284
119
  #engineCalls = 0;
@@ -301,9 +136,8 @@ export class BlenderSkinDirector {
301
136
  for (const listener of [...this.#listeners]) listener();
302
137
  }
303
138
 
304
- /** The clip's own facts plus the mixer's live time, expressed in BLENDER
305
- * FRAMES — which is the only number the Timeline draws, and the only one a
306
- * person or an agent ever names. */
139
+ /** The subject's facts plus the playhead, expressed in BLENDER FRAMES — which is the only
140
+ * number the Timeline draws, and the only one a person or an agent ever names. */
307
141
  state(): {
308
142
  frame: number;
309
143
  start: number;
@@ -315,9 +149,8 @@ export class BlenderSkinDirector {
315
149
  bones: number;
316
150
  keyframes: readonly { frame: number; type: string; select: boolean }[];
317
151
  /** Every animated object's own columns plus its SELECTION — what the
318
- * summary row's `show_only_selected` filter chooses between. The mixer
319
- * never reads it: what plays is the bound action, filter or no filter,
320
- * exactly as in Blender. */
152
+ * summary row's `show_only_selected` filter chooses between. What plays
153
+ * is every armature's stack, filter or no filter, exactly as in Blender. */
321
154
  summary: readonly {
322
155
  object: string;
323
156
  action: string;
@@ -332,30 +165,27 @@ export class BlenderSkinDirector {
332
165
  engineCalls: number;
333
166
  /** BLENDER'S OWN `scene.frame_current`, as the clip door last read it —
334
167
  * which is a different number from `frame` on purpose. `frame` is where
335
- * the person is looking (the mixer's); this is where bpy and the
336
- * Properties rail are. They agree after a pause or a scrub-end, and they
337
- * are deliberately allowed to differ in between. */
168
+ * the person is looking; this is where bpy and the Properties rail are.
169
+ * They agree after a pause or a scrub-end, and they are deliberately
170
+ * allowed to differ in between. */
338
171
  blenderFrame: number | null;
339
172
  } {
340
173
  const clip = this.#clip;
341
- const start = clip?.frameStart ?? 1;
342
- const end = clip?.frameEnd ?? 250;
343
- const fps = clip?.fps ?? 24;
344
174
  return {
345
175
  frame: this.frame(),
346
- start,
347
- end,
348
- fps,
176
+ start: clip?.frameStart ?? 1,
177
+ end: clip?.frameEnd ?? 250,
178
+ fps: clip?.fps ?? 24,
349
179
  action: clip?.action ?? null,
350
180
  object: clip?.object ?? null,
351
181
  armature: clip?.armature ?? null,
352
- bones: [...this.#rigs.values()].reduce((sum, rig) => sum + rig.bones.length, 0),
182
+ bones: this.#armature ? this.#view?.skeletons.rig(this.#armature)?.bones.size ?? 0 : 0,
353
183
  keyframes: clip?.keyframes ?? [],
354
184
  summary: clip?.summary ?? [],
355
185
  clipStart: clip?.clipStart ?? null,
356
186
  clipEnd: clip?.clipEnd ?? null,
357
187
  tracks: clip?.tracks.length ?? 0,
358
- bound: [...this.#rigs.keys()],
188
+ bound: [...this.#poses.keys()],
359
189
  warnings: this.#warnings,
360
190
  engineCalls: this.#engineCalls,
361
191
  blenderFrame: clip?.frameCurrent ?? null,
@@ -368,193 +198,167 @@ export class BlenderSkinDirector {
368
198
  return this.#clip ? this.frame() : null;
369
199
  }
370
200
 
371
- /** The mixer's time as a Blender frame. With no clip the scene's own current
372
- * frame stands, which is Blender's answer for a file with no animation. */
201
+ /** The frame on screen: the last seek's, else Blender's own. */
373
202
  frame(): number {
374
- const clip = this.#clip;
375
- if (this.#seeked !== null) return this.#seeked;
376
- if (!clip || !this.#action || clip.clipStart === undefined) return clip?.frameCurrent ?? 1;
377
- // A SEEK'S OWN ANSWER, while one is standing. `LoopRepeat` wraps
378
- // `action.time` into `[0, duration)`, so a seek to the LAST frame lands on
379
- // `time === duration` and reads back as the FIRST — measured on the first
380
- // walk, where `jump-end` answered frame 1 over a frame-48 pose. The
381
- // wrapping is right for playback and wrong for a question, so a standing
382
- // seek answers with the frame it was given and the play tick clears it.
383
- // THE ACTION'S TIME, NOT THE MIXER'S, and the difference is the whole of
384
- // looping: `AnimationMixer.time` is monotonic and never wraps, while
385
- // `AnimationAction.time` is wrapped into `[0, duration]` by `LoopRepeat`.
386
- // Measured on the first walk: two seconds of playback over a 47-frame clip
387
- // read as frame 63.5 off the mixer, where the picture was correctly back
388
- // near the start.
389
- return clip.clipStart + this.#action.time * clip.fps;
203
+ return this.#seeked ?? this.#clip?.frameCurrent ?? 1;
390
204
  }
391
205
 
392
206
  /** Blender's scene clock exists even when the scene has no action. */
393
- /** Whether a bind would change anything: nothing is bound yet, or a bound mesh is no longer the
394
- * object presented for its name (the presenter replaced it with a new draw). Lets the model
395
- * skip the rig read on the edits that touch no rig. */
396
- stale(presentation: SkinPresentation): boolean {
397
- // An unbindable rig whose presentation changed since it was tried is worth trying again.
398
- for (const [mesh, tried] of this.#unbindable) {
399
- const signature = tried.split('|').slice(0, -2).join('|');
400
- if (BlenderSkinDirector.#tried(presentation, mesh, signature) !== tried) return true;
401
- }
402
- if (this.#rigs.size === 0) return this.#unbindable.size === 0;
403
- for (const rig of this.#rigs.values()) {
404
- if (presentation.objectForBlenderName(rig.object) !== rig.mesh) return true;
405
- // A redraw that kept the object but replaced its geometry (a weight or mesh edit with the same
406
- // vertex count) leaves the skinned mesh without its skin attributes.
407
- if (!rig.mesh.geometry.getAttribute('skinIndex')) return true;
408
- }
409
- return false;
410
- }
411
-
412
207
  get playable(): boolean {
413
208
  return this.#clip !== null && this.#transport !== null;
414
209
  }
415
210
 
416
- // ------------------------------------------------------------ the binding
211
+ // ------------------------------------------------------------ the frame
417
212
 
418
213
  /**
419
- * Bind every rigged mesh the frame carries, and load the active action.
420
- *
421
- * IDEMPOTENT BY SIGNATURE: a rig whose mesh, armature, vertex count, bone
422
- * count and export frame are unchanged is left exactly as it is, mixer time
423
- * included. That is what lets the presenter call this after EVERY frame
424
- * without the picture jumping back to the bind pose each time something
425
- * unrelated moved.
214
+ * FOLLOW THE PRESENTED FRAME. Every armature the frame names is posed; the Timeline's subject is
215
+ * the active object's armature (itself, or the one its skin is bound to), else the one armature
216
+ * that has an action, and its header is read through the clip door when the frame says
217
+ * something about it moved. Actions are baked once per revision, the first time a frame needs
218
+ * them. Otherwise this costs nothing, which is what lets the model call it after every present.
426
219
  */
427
- async bind(
428
- presentation: SkinPresentation,
429
- read: {
430
- rig(): Promise<BlenderRig | null>;
431
- clip(): Promise<BlenderActionClip | null>;
432
- },
433
- ): Promise<void> {
434
- this.#engineCalls++;
435
- const answer = await read.rig();
436
- if (!answer) return;
437
- const warnings: string[] = [];
438
- const wanted = new Set<string>();
439
- let changed = false;
440
- for (const rig of answer.rigs) {
441
- if (!rig.object || !rig.armature || !rig.skinIndexBase64 || !rig.skinWeightBase64) {
442
- if (rig.reason) warnings.push(rig.reason);
220
+ async sync(view: BlenderRuntimeView, read: SkinReader): Promise<void> {
221
+ this.#view = view;
222
+ this.#reader = read;
223
+ const facts = view.animationFacts();
224
+ const names = Object.keys(facts.armatures);
225
+ const active = facts.active;
226
+ const armature = active && facts.armatures[active] ? active
227
+ : (active ? facts.skinArmature(active) : null)
228
+ ?? (names.filter((name) => facts.armatures[name]!.action).length === 1
229
+ ? names.find((name) => facts.armatures[name]!.action)! : null);
230
+ // EVERY ARMATURE'S EVALUATOR, over the rig the presenter holds for it now.
231
+ for (const [name, pose] of [...this.#poses])
232
+ if (!facts.armatures[name] || view.skeletons.rig(name) !== pose.rig) this.#poses.delete(name);
233
+ for (const [name, entry] of Object.entries(facts.armatures)) {
234
+ const rig = view.skeletons.rig(name);
235
+ if (!rig || !animated(entry)) {
236
+ this.#poses.delete(name);
443
237
  continue;
444
238
  }
445
- wanted.add(rig.object);
446
- const signature = rigSignature(rig, answer.frame);
447
- const held = this.#rigs.get(rig.object);
448
- // Unchanged only while what it bound is still what is presented, skinned: a redraw that
449
- // replaced the object or its geometry (same vertex count, same signature) is bound again.
450
- if (held?.signature === signature && presentation.objectForBlenderName(rig.object) === held.mesh
451
- && held.mesh.geometry.getAttribute('skinIndex')) continue;
452
- const tried = BlenderSkinDirector.#tried(presentation, rig.object, signature);
453
- if (this.#unbindable.get(rig.object) === tried) continue;
454
- const bound = this.#bindOne(presentation, rig, answer.frame, warnings);
455
- if (!bound) {
456
- this.#unbindable.set(rig.object, tried);
457
- if (held) {
458
- held.skeleton.dispose();
459
- held.boneRoot.removeFromParent();
460
- // A skinned mesh still presented with no skeleton would hold its last pose; it draws as
461
- // the plain mesh it is again.
462
- if (presentation.objectForBlenderName(rig.object) === held.mesh) {
463
- const plain = new THREE.Mesh(held.mesh.geometry, held.mesh.material);
464
- plain.name = held.mesh.name;
465
- plain.matrixAutoUpdate = false;
466
- plain.matrix.copy(held.mesh.matrix);
467
- plain.matrix.decompose(plain.position, plain.quaternion, plain.scale);
468
- plain.userData = held.mesh.userData;
469
- presentation.replacePresentedObject(held.mesh, plain);
470
- }
471
- this.#rigs.delete(rig.object);
472
- changed = true;
473
- }
239
+ let pose = this.#poses.get(name);
240
+ if (!pose) this.#poses.set(name, pose = new ArmaturePose(rig));
241
+ pose.facts(entry);
242
+ }
243
+ // A BAKE IS GOOD FOR ITS ACTION'S REVISION; a new revision bakes it again when next needed.
244
+ for (const [key, entry] of [...this.#baked]) {
245
+ const action = key.slice(key.indexOf('\u0000') + 1);
246
+ if ((facts.actions[action] ?? null) !== entry.revision) this.#baked.delete(key);
247
+ }
248
+ const action = armature ? facts.armatures[armature]?.action ?? null : null;
249
+ const key = JSON.stringify([armature, action, action ? facts.actions[action] ?? null : null, facts.clock, facts.frame]);
250
+ if (key !== this.#clipKey) {
251
+ this.#clipKey = key;
252
+ this.#armature = armature;
253
+ let clip: BlenderActionClip | null = null;
254
+ if (armature) {
255
+ this.#engineCalls++;
256
+ clip = await read.clip(armature);
257
+ if (clip?.action && clip.armature) this.#baked.set(`${clip.armature}\u0000${clip.action}`, { revision: facts.actions[clip.action] ?? null, clip: poseClip(clip) });
474
258
  }
475
- if (bound) {
476
- this.#unbindable.delete(rig.object);
477
- held?.skeleton.dispose();
478
- held?.boneRoot.removeFromParent();
479
- this.#rigs.set(rig.object, bound);
480
- changed = true;
259
+ const previous = this.#clip;
260
+ const rangeChanged = clip?.frameStart !== previous?.frameStart ||
261
+ clip?.frameEnd !== previous?.frameEnd || clip?.fps !== previous?.fps;
262
+ this.#clip = clip ?? null;
263
+ if (!clip || clip.scene !== previous?.scene) this.#seeked = null;
264
+ const activeSubject = this.#transport?.snapshot().activeSubject;
265
+ if (rangeChanged && activeSubject) this.#transport?.setActiveSubject(activeSubject);
266
+ const playing = this.#transport?.snapshot().playbackState === 'playing';
267
+ if (clip && !playing) {
268
+ this.#seeked = null;
269
+ if (this.#transport && clip.fps > 0) this.#transport.seek(clip.frameCurrent / clip.fps);
481
270
  }
482
271
  }
483
- for (const [name, rig] of [...this.#rigs])
484
- if (!wanted.has(name)) {
485
- rig.skeleton.dispose();
486
- rig.boneRoot.removeFromParent();
487
- this.#rigs.delete(name);
488
- changed = true;
489
- }
272
+ this.#pose();
273
+ this.#publish();
274
+ }
275
+
276
+ /** One action of one armature: baked, or being baked now (once), or null when it plays nothing. */
277
+ #clipOf(armature: string, action: string): PoseClip | null | undefined {
278
+ const key = `${armature}\u0000${action}`;
279
+ const held = this.#baked.get(key);
280
+ if (held) return held.clip;
281
+ const reader = this.#reader;
282
+ const revision = this.#view?.animationFacts().actions[action] ?? null;
283
+ if (!reader) return null;
284
+ const entry: { revision: number | null; clip: PoseClip | null | undefined } = { revision, clip: undefined };
285
+ this.#baked.set(key, entry);
490
286
  this.#engineCalls++;
491
- const clip = await read.clip();
492
- const previous = this.#clip;
493
- const rangeChanged = clip?.frameStart !== previous?.frameStart ||
494
- clip?.frameEnd !== previous?.frameEnd || clip?.fps !== previous?.fps;
495
- const movedAction =
496
- clip?.action !== previous?.action ||
497
- clip?.tracks.length !== previous?.tracks.length ||
498
- clip?.clipStart !== previous?.clipStart ||
499
- clip?.clipEnd !== previous?.clipEnd;
500
- this.#clip = clip ?? null;
501
- if (!clip || clip.scene !== previous?.scene) this.#seeked = null;
502
- if (clip?.reason) warnings.push(clip.reason);
503
- if (clip && (movedAction || changed)) this.#loadClip(presentation, clip, warnings);
504
- // The subject can attach before the asynchronous scene read arrives.
505
- // Refresh its clock range through the transport's own subject door.
506
- const activeSubject = this.#transport?.snapshot().activeSubject;
507
- if (rangeChanged && activeSubject) this.#transport?.setActiveSubject(activeSubject);
508
- // BLENDER'S OWN FRAME RE-SYNCS THE PLAYHEAD. An agent that set
509
- // `scene.frame_current` in bpy has said where it wants to be, and a
510
- // present is how we hear about it; while the transport is PLAYING it would
511
- // be the Timeline arguing with itself, so it is honoured only at rest.
287
+ void reader.bake(armature, action).then((baked) => {
288
+ entry.clip = baked ? poseClip(baked) : null;
289
+ if (this.#baked.get(key) !== entry) return;
290
+ this.#pose();
291
+ this.#publish();
292
+ }, (error: unknown) => {
293
+ entry.clip = null;
294
+ this.#say(`bake:${key}`, `${armature} / ${action} could not be read: ${error instanceof Error ? error.message : String(error)}`);
295
+ });
296
+ return undefined;
297
+ }
298
+
299
+ /**
300
+ * POSE EVERY ARMATURE AT THE FRAME ON SCREEN. At Blender's own frame, standing still, the bones
301
+ * take Blender's pose (the frame's, exact) and the evaluator's answer is checked against it;
302
+ * anywhere else they take the evaluator's.
303
+ */
304
+ #pose(): void {
305
+ const view = this.#view;
306
+ if (!view) return;
307
+ const facts = view.animationFacts();
308
+ const frame = this.frame();
309
+ // BLENDER'S OWN FRAME is the one the presented pose was evaluated at, not the bookmark: a pause
310
+ // writes the bookmark first and the frame evaluated there arrives after.
311
+ const atBlender = frame === facts.frame;
512
312
  const playing = this.#transport?.snapshot().playbackState === 'playing';
513
- if (clip && !playing && (rangeChanged || previous?.frameCurrent !== clip.frameCurrent)) {
514
- this.#seek(clip.frameCurrent);
515
- // THE BOOKMARK READ, once per bind: the file says where it was left, and
516
- // the transport is what everything else now asks.
517
- if (this.#transport && clip.fps > 0) this.#transport.seek(clip.frameCurrent / clip.fps);
313
+ const warnings: string[] = this.#clip?.reason ? [this.#clip.reason] : [];
314
+ const world = { object: (name: string) => view.objectForBlenderName(name) };
315
+ for (const [name, pose] of this.#poses) {
316
+ const armature = facts.armatures[name];
317
+ if (!armature) continue;
318
+ const unsupported = [...pose.unsupported(), ...this.#clipsOf(name).flatMap((clip) => clip.unsupported)];
319
+ if (unsupported.length) {
320
+ const said = `${name} plays ${unsupported.join(', ')} only in Blender, not in the Timeline or a game.${remedy(unsupported)}`;
321
+ warnings.push(said);
322
+ this.#say(`unsupported:${name}:${unsupported.join('|')}`, said);
323
+ }
324
+ const stack = sceneLayers(armature, frame, (action) => this.#clipOf(name, action));
325
+ for (const skipped of stack.skipped) this.#say(`skipped:${name}:${skipped}`, `${name}: ${skipped} plays only in Blender, not in the Timeline or a game.`);
326
+ if (stack.waiting) continue;
327
+ if (atBlender && !playing) {
328
+ view.skeletons.restorePose(name);
329
+ pose.rig.object.updateMatrixWorld(true);
330
+ this.#check(name, pose, armature, stack.layers, world, unsupported);
331
+ } else {
332
+ pose.apply(stack.layers, world);
333
+ }
518
334
  }
519
335
  this.#warnings = warnings;
520
- this.#publish();
521
336
  }
522
337
 
523
- #bindOne(presentation: SkinPresentation, rig: BlenderRigBinding, frame: number, warnings: string[]): BoundRig | null {
524
- return bindRig(presentation, rig, frame, warnings);
338
+ /** The clips baked so far for one armature. */
339
+ #clipsOf(armature: string): PoseClip[] {
340
+ const out: PoseClip[] = [];
341
+ for (const [key, entry] of this.#baked) if (key.startsWith(`${armature}`) && entry.clip) out.push(entry.clip);
342
+ return out;
525
343
  }
526
344
 
527
- #loadClip(presentation: SkinPresentation, clip: BlenderActionClip, warnings: string[]): void {
528
- this.#mixer?.stopAllAction();
529
- this.#mixer = null;
530
- this.#action = null;
531
- if (!clip.tracks.length || clip.clipStart === undefined || clip.clipEnd === undefined) return;
532
- const armature = clip.armature ? presentation.objectForBlenderName(clip.armature) : null;
533
- if (!armature) return;
534
- const byName = new Map<string, THREE.Bone>();
535
- for (const rig of this.#rigs.values())
536
- if (rig.armature === clip.armature) for (const bone of rig.bones) byName.set(bone.name, bone);
537
- const tracks = clipTracks(clip, byName, warnings);
538
- if (!tracks.length) return;
539
- const duration = clip.duration ?? (clip.clipEnd - clip.clipStart) / clip.fps;
540
- const mixer = new THREE.AnimationMixer(armature);
541
- const action = mixer.clipAction(
542
- new THREE.AnimationClip(clip.action ?? 'action', duration, tracks),
543
- );
544
- action.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
545
- action.play();
546
- this.#mixer = mixer;
547
- this.#action = action;
548
- // THE FRESH MIXER STARTS AT BLENDER'S OWN FRAME, not at zero, and that is
549
- // the invariant this whole design rests on: the mesh columns were exported
550
- // at `frameCurrent` and the skeleton was bound in that pose, so seeking
551
- // anywhere else would make the first picture after a re-bind disagree with
552
- // the geometry underneath it. Measured on the first walk: a pause wrote
553
- // frame 16, the write presented, the present re-exported the mesh at 16
554
- // and re-bound — and `setTime(0)` snapped the playhead back to frame 1
555
- // over a frame-16 mesh.
556
- this.#seek(clip.frameCurrent);
557
- this.#refresh();
345
+ /** THE FIDELITY CHECK at Blender's own frame: the evaluator's pose against Blender's. */
346
+ #check(name: string, pose: ArmaturePose, armature: BlenderArmature, layers: readonly PoseLayer[],
347
+ world: { object(name: string): ReturnType<BlenderRuntimeView['objectForBlenderName']> }, unsupported: readonly string[]): void {
348
+ const off = poseDivergence(pose.apply(layers, world, false), armature);
349
+ if (!off) return;
350
+ const frame = this.#clip?.frameCurrent ?? this.frame();
351
+ const why = unsupported.length
352
+ ? `What Blender plays here and the evaluator does not: ${unsupported.join(', ')}.`
353
+ : 'Nothing the evaluator knows of explains it; this is a defect in the presenter, report it with the file.';
354
+ this.#say(`divergence:${name}:${frame}:${off.bone}:${Math.round(off.degrees)}`,
355
+ `${name} at frame ${frame}: bone "${off.bone}" is ${off.degrees.toFixed(1)}° and ${(off.offset * 100).toFixed(1)}% of its length off Blender's own pose, so the Timeline and a game show it differently from Blender. ${why}`);
356
+ }
357
+
358
+ #say(key: string, message: string): void {
359
+ if (this.#said.has(key)) return;
360
+ this.#said.add(key);
361
+ editorHost().console.warn(message, 'blender-animation');
558
362
  }
559
363
 
560
364
  // ------------------------------------------------------------ the playback
@@ -562,27 +366,8 @@ export class BlenderSkinDirector {
562
366
  #seek(frame: number): void {
563
367
  const clip = this.#clip;
564
368
  if (!clip) return;
565
- const clamped = Math.min(clip.frameEnd, Math.max(clip.frameStart, frame));
566
- this.#seeked = clamped;
567
- // An action supplies a pose at this scene time; it does not own whether
568
- // the scene clock can move. Empty/static scenes still play and scrub.
569
- if (!this.#mixer || clip.clipStart === undefined || clip.clipEnd === undefined) return;
570
- // The TIME is nudged inside the clip so the last frame evaluates as the
571
- // last frame rather than wrapping to the first; the number REPORTED is the
572
- // one asked for (see `frame`).
573
- const duration = (clip.clipEnd - clip.clipStart) / clip.fps;
574
- this.#mixer.setTime(Math.max(0, Math.min(duration - 1e-4, (clamped - clip.clipStart) / clip.fps)));
575
- this.#refresh();
576
- }
577
-
578
- /** The bones moved; make the world matrices agree before the stage's next
579
- * render reads them. A `Bone` keeps `matrixAutoUpdate`, so this is a forced
580
- * pass over a handful of nodes rather than a recomposition of the scene. */
581
- #refresh(): void {
582
- for (const rig of this.#rigs.values()) {
583
- rig.boneRoot.updateMatrixWorld(true);
584
- rig.skeleton.update();
585
- }
369
+ this.#seeked = Math.min(clip.frameEnd, Math.max(clip.frameStart, frame));
370
+ this.#pose();
586
371
  }
587
372
 
588
373
  /** The scene's name, for the one address the Timeline writes to. */
@@ -685,16 +470,13 @@ export class BlenderSkinDirector {
685
470
 
686
471
  dispose(): void {
687
472
  this.#detach?.();
688
- this.#mixer?.stopAllAction();
689
- this.#mixer = null;
690
- this.#action = null;
691
473
  this.#clip = null;
692
474
  this.#seeked = null;
693
- for (const rig of this.#rigs.values()) {
694
- rig.skeleton.dispose();
695
- rig.boneRoot.removeFromParent();
696
- }
697
- this.#rigs.clear();
475
+ for (const name of this.#poses.keys()) this.#view?.skeletons.restorePose(name);
476
+ this.#poses.clear();
477
+ this.#baked.clear();
478
+ this.#armature = null;
479
+ this.#clipKey = null;
698
480
  }
699
481
  }
700
482