@volter/editor-blender 0.1.135 → 0.1.136

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.
@@ -8,11 +8,14 @@
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
+ * pose, and the presenter builds the `THREE.SkinnedMesh`es and their skeletons
15
+ * (`blender-runtime-skeleton.ts`). This director only plays the assigned action on
16
+ * those bones with an `AnimationMixer`: every scrub and every played frame costs
17
+ * ZERO calls into Blender, and the clip is baked again exactly when the frame says
18
+ * its action's revision moved.
16
19
  *
17
20
  * ## The bind pose is the EXPORT pose, and that is the whole trick
18
21
  *
@@ -36,11 +39,8 @@
36
39
  * from the F-Curves alone.
37
40
  */
38
41
 
39
- import type {
40
- BlenderActionClip,
41
- BlenderRig,
42
- BlenderRigBinding,
43
- } from '@volter/blender-engine/browser/rna';
42
+ import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
43
+ import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
44
44
  import type { StageTransportHandle } from '@volter/editor-sdk/host';
45
45
  import * as THREE from 'three';
46
46
  import { blenderRnaSet } from '../host/blender-runtime-host';
@@ -59,50 +59,6 @@ function float32Of(base64: string): Float32Array {
59
59
  const bytes = bytesOf(base64);
60
60
  return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 2);
61
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;
85
- }
86
-
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('|');
104
- }
105
-
106
62
  /**
107
63
  * A BLENDER ACTION'S BAKED TRACKS as three.js keyframe tracks over `bones` (by Blender's bone
108
64
  * name), addressed by bone UUID because three's track-name grammar splits on `.` and `hand.L`
@@ -130,123 +86,6 @@ export function clipTracks(clip: BlenderActionClip, bones: ReadonlyMap<string, T
130
86
  return tracks;
131
87
  }
132
88
 
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
- };
248
- }
249
-
250
89
  /**
251
90
  * THE ONE DIRECTOR, module-scoped for the reason `BlenderRuntimeView` itself
252
91
  * is (`blender-runtime.document.tsx`): the Python session outlives workspace
@@ -255,18 +94,12 @@ export function bindRig(
255
94
  * copy of the playback state anywhere.
256
95
  */
257
96
  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
- }
97
+ /** The presented view whose skeletons the clip plays on (`sync`). */
98
+ #view: BlenderRuntimeView | null = null;
99
+ /** The armature the clip plays on, and what the clip was read for: armature, action, the
100
+ * action's revision, the scene's clock and frame. Read again only when this changes. */
101
+ #armature: string | null = null;
102
+ #clipKey: string | null = null;
270
103
  #mixer: THREE.AnimationMixer | null = null;
271
104
  #action: THREE.AnimationAction | null = null;
272
105
  #clip: BlenderActionClip | null = null;
@@ -349,13 +182,14 @@ export class BlenderSkinDirector {
349
182
  action: clip?.action ?? null,
350
183
  object: clip?.object ?? null,
351
184
  armature: clip?.armature ?? null,
352
- bones: [...this.#rigs.values()].reduce((sum, rig) => sum + rig.bones.length, 0),
185
+ bones: this.#armature ? this.#view?.skeletons.rig(this.#armature)?.bones.size ?? 0 : 0,
353
186
  keyframes: clip?.keyframes ?? [],
354
187
  summary: clip?.summary ?? [],
355
188
  clipStart: clip?.clipStart ?? null,
356
189
  clipEnd: clip?.clipEnd ?? null,
357
190
  tracks: clip?.tracks.length ?? 0,
358
- bound: [...this.#rigs.keys()],
191
+ // The armature the clip plays on, when the presenter holds its skeleton.
192
+ bound: this.#armature && this.#view?.skeletons.rig(this.#armature) ? [this.#armature] : [],
359
193
  warnings: this.#warnings,
360
194
  engineCalls: this.#engineCalls,
361
195
  blenderFrame: clip?.frameCurrent ?? null,
@@ -390,150 +224,80 @@ export class BlenderSkinDirector {
390
224
  }
391
225
 
392
226
  /** 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
227
  get playable(): boolean {
413
228
  return this.#clip !== null && this.#transport !== null;
414
229
  }
415
230
 
416
- // ------------------------------------------------------------ the binding
231
+ // ------------------------------------------------------------ the frame
417
232
 
418
233
  /**
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.
234
+ * FOLLOW THE PRESENTED FRAME. The armature played is the active object's (itself, or the one its
235
+ * skin is bound to), else the one armature that has an action. Its assigned action is read
236
+ * through the clip door when the frame says something about it moved: another armature or
237
+ * action, the action's revision, the scene's clock, or Blender's own frame. Otherwise this costs
238
+ * nothing, which is what lets the model call it after every present.
426
239
  */
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;
240
+ async sync(view: BlenderRuntimeView, read: { clip(armature: string): Promise<BlenderActionClip | null> }): Promise<void> {
241
+ this.#view = view;
242
+ const facts = view.animationFacts();
243
+ const names = Object.keys(facts.armatures);
244
+ const active = facts.active;
245
+ const armature = active && facts.armatures[active] ? active
246
+ : (active ? facts.skinArmature(active) : null)
247
+ ?? (names.filter((name) => facts.armatures[name]!.action).length === 1
248
+ ? names.find((name) => facts.armatures[name]!.action)! : null);
249
+ const action = armature ? facts.armatures[armature]?.action ?? null : null;
250
+ const key = JSON.stringify([armature, action, action ? facts.actions[action] ?? null : null, facts.clock, facts.frame,
251
+ armature ? view.skeletons.rig(armature) !== null : false]);
252
+ if (key === this.#clipKey && (!armature || this.#rig() === view.skeletons.rig(armature))) return;
253
+ this.#clipKey = key;
254
+ if (this.#armature && this.#armature !== armature) view.skeletons.restorePose(this.#armature);
255
+ this.#armature = armature;
256
+ this.#boundRig = armature ? view.skeletons.rig(armature) : null;
437
257
  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);
443
- continue;
444
- }
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
- }
474
- }
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;
481
- }
258
+ let clip: BlenderActionClip | null = null;
259
+ if (armature) {
260
+ this.#engineCalls++;
261
+ clip = await read.clip(armature);
482
262
  }
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
- }
490
- this.#engineCalls++;
491
- const clip = await read.clip();
492
263
  const previous = this.#clip;
493
264
  const rangeChanged = clip?.frameStart !== previous?.frameStart ||
494
265
  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
266
  this.#clip = clip ?? null;
501
267
  if (!clip || clip.scene !== previous?.scene) this.#seeked = null;
502
268
  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.
269
+ if (clip) this.#loadClip(clip, warnings);
270
+ else {
271
+ this.#mixer?.stopAllAction();
272
+ this.#mixer = null;
273
+ this.#action = null;
274
+ }
506
275
  const activeSubject = this.#transport?.snapshot().activeSubject;
507
276
  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.
512
277
  const playing = this.#transport?.snapshot().playbackState === 'playing';
513
- if (clip && !playing && (rangeChanged || previous?.frameCurrent !== clip.frameCurrent)) {
278
+ if (clip && !playing) {
514
279
  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
280
  if (this.#transport && clip.fps > 0) this.#transport.seek(clip.frameCurrent / clip.fps);
518
281
  }
519
282
  this.#warnings = warnings;
520
283
  this.#publish();
521
284
  }
522
285
 
523
- #bindOne(presentation: SkinPresentation, rig: BlenderRigBinding, frame: number, warnings: string[]): BoundRig | null {
524
- return bindRig(presentation, rig, frame, warnings);
286
+ /** The rig the clip was loaded over: a rebuilt rig (new bones) needs the clip loaded again. */
287
+ #boundRig: object | null = null;
288
+ #rig(): object | null {
289
+ return this.#boundRig;
525
290
  }
526
291
 
527
- #loadClip(presentation: SkinPresentation, clip: BlenderActionClip, warnings: string[]): void {
292
+ #loadClip(clip: BlenderActionClip, warnings: string[]): void {
528
293
  this.#mixer?.stopAllAction();
529
294
  this.#mixer = null;
530
295
  this.#action = null;
531
296
  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);
297
+ const rig = clip.armature ? this.#view?.skeletons.rig(clip.armature) : null;
298
+ if (!rig) return;
299
+ const armature = rig.object;
300
+ const byName = rig.bones;
537
301
  const tracks = clipTracks(clip, byName, warnings);
538
302
  if (!tracks.length) return;
539
303
  const duration = clip.duration ?? (clip.clipEnd - clip.clipStart) / clip.fps;
@@ -579,10 +343,8 @@ export class BlenderSkinDirector {
579
343
  * render reads them. A `Bone` keeps `matrixAutoUpdate`, so this is a forced
580
344
  * pass over a handful of nodes rather than a recomposition of the scene. */
581
345
  #refresh(): void {
582
- for (const rig of this.#rigs.values()) {
583
- rig.boneRoot.updateMatrixWorld(true);
584
- rig.skeleton.update();
585
- }
346
+ const rig = this.#armature ? this.#view?.skeletons.rig(this.#armature) : null;
347
+ rig?.object.updateMatrixWorld(true);
586
348
  }
587
349
 
588
350
  /** The scene's name, for the one address the Timeline writes to. */
@@ -690,11 +452,9 @@ export class BlenderSkinDirector {
690
452
  this.#action = null;
691
453
  this.#clip = null;
692
454
  this.#seeked = null;
693
- for (const rig of this.#rigs.values()) {
694
- rig.skeleton.dispose();
695
- rig.boneRoot.removeFromParent();
696
- }
697
- this.#rigs.clear();
455
+ if (this.#armature) this.#view?.skeletons.restorePose(this.#armature);
456
+ this.#armature = null;
457
+ this.#clipKey = null;
698
458
  }
699
459
  }
700
460
 
@@ -71,14 +71,12 @@ import {
71
71
  modelDocumentMayOpen,
72
72
  modelDocumentOwnsPresentation,
73
73
  blenderActionClip,
74
- blenderArmatureActions,
75
- blenderRig,
76
74
  blenderRnaVersion,
77
75
  } from '../host/blender-runtime-host';
78
76
  import { BlenderObjectModeHeader } from './blender-header-menus';
79
77
  import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
80
78
  import { blenderSkin } from './blender-runtime-skin';
81
- import { bindPlayAnimation, readPlayClipLibrary, type PlayAnimation } from './blender-play-skin';
79
+ import { playAnimation, type PlayAnimation } from './blender-play-skin';
82
80
  import { areaSplit, subscribeAreaSplit } from '../src/area-split';
83
81
  import { noteModelDocument } from '../src/play-mode';
84
82
  import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/editor-sdk/kit/document-play-extension';
@@ -910,19 +908,12 @@ function BlenderViewportArea({
910
908
  preparing = true;
911
909
  orbit = stage.rig().orbit;
912
910
  orbit.enabled = false;
913
- // THE COPY'S CHARACTERS ANIMATE: their skins and every clip they can play, read once for
914
- // this run and bound to the copy (`blender-play-skin.ts`). A read that fails costs the game its
915
- // animation, said in the console, never the game itself.
916
- void Promise.all([
917
- view.prepareRendered(stage.rig().drawCamera(), 'render'),
918
- readPlayClipLibrary().catch((error: unknown) => {
919
- editorHost().console.warn(`The game's animation could not be read: ${error instanceof Error ? error.message : String(error)}`, 'blender-play');
920
- return null;
921
- }),
922
- ]).then(([, library]) => {
911
+ // THE COPY'S CHARACTERS ANIMATE: its view bound their skins from the copied frame, and each
912
+ // armature plays the action the file assigns it until the game sets another
913
+ // (`blender-play-skin.ts`). Nothing is read before the game starts.
914
+ void view.prepareRendered(stage.rig().drawCamera(), 'render').then(() => {
923
915
  if (stopped) return;
924
- animation = library ? bindPlayAnimation(view, library) : null;
925
- for (const warning of animation?.warnings ?? []) editorHost().console.warn(warning, 'blender-play');
916
+ animation = playAnimation(view, (armature, action) => blenderActionClip({ object: armature, action }));
926
917
  stopScript = documentPlayExtension('model')?.run({
927
918
  ...(animation ? { animation } : {}),
928
919
  documentId: modelId,
@@ -1056,31 +1047,18 @@ function BlenderViewportArea({
1056
1047
  };
1057
1048
  }, [documentId, view]);
1058
1049
  /**
1059
- * THE MODEL'S SKINS AND ASSIGNED ACTION, AS BLENDER'S VIEWPORT SHOWS THEM: a rigged object is
1060
- * presented skinned, whatever editors are open, and plays the action assigned to it. Bound on
1061
- * every change of the file, a moment after it settles, because `rna_rig` walks every vertex of
1062
- * every rigged mesh; a file with no armature pays only the cheap listing.
1050
+ * THE ASSIGNED ACTION ON THE PRESENTED SKELETONS. The skins themselves come with every frame (the
1051
+ * presenter binds them, `blender-runtime-skeleton.ts`); after each present the director follows
1052
+ * the frame, and reads a clip only when the frame says its action, revision or clock moved.
1063
1053
  */
1064
1054
  const rnaVersion = useSyncExternalStore(subscribeBlenderRna, blenderRnaVersion, blenderRnaVersion);
1065
- /** The armatures, their actions and what each is assigned, as last bound: an edit that changes
1066
- * none of them and replaced no skinned mesh costs no rig read and no bake. */
1067
- const boundListing = useRef<string | null>(null);
1068
1055
  useEffect(() => {
1069
1056
  if (!documentId || !main) return;
1070
1057
  let live = true;
1071
- const timer = setTimeout(() => {
1072
- void (async () => {
1073
- const listing = await blenderArmatureActions();
1074
- if (!live || !listing?.armatures.length) return;
1075
- const key = JSON.stringify([listing.armatures, listing.scene ?? null]);
1076
- if (key === boundListing.current && !blenderSkin.stale(view)) return;
1077
- await blenderSkin.bind(view, { rig: () => blenderRig(), clip: () => blenderActionClip() });
1078
- boundListing.current = key;
1079
- })().catch((thrown: unknown) => {
1080
- if (live) editorHost().console.warn(`This file's rig could not be read: ${thrown instanceof Error ? thrown.message : String(thrown)}`, 'blender-skin');
1081
- });
1082
- }, 400);
1083
- return () => { live = false; clearTimeout(timer); };
1058
+ void blenderSkin.sync(view, { clip: (armature) => blenderActionClip({ object: armature }) }).catch((thrown: unknown) => {
1059
+ if (live) editorHost().console.warn(`This file's action could not be read: ${thrown instanceof Error ? thrown.message : String(thrown)}`, 'blender-skin');
1060
+ });
1061
+ return () => { live = false; };
1084
1062
  }, [documentId, main, view, rnaVersion]);
1085
1063
  /**
1086
1064
  * ATTACH THE SKIN TO THIS STAGE'S TRANSPORT.
@@ -93,7 +93,6 @@ export const commands: CommandContribution['commands'] = {
93
93
  // and the ONE write the Timeline makes (`scene.frame_current`, once on
94
94
  // pause and at scrub-end) goes through `blender-rna-set`, which already
95
95
  // exists and already presents.
96
- 'blender-rig': verb('none', 60_000),
97
96
  'blender-action-clip': verb('none', 120_000),
98
97
  // THE NODE VIEW'S OWN ACTIONS as a session verb — the keyboard ruling's
99
98
  // `volter.*`-command-per-action pattern, and the only way the drawer's view
@@ -36,7 +36,6 @@ import type {
36
36
  BlenderNodeTree,
37
37
  BlenderOutlinerTree,
38
38
  BlenderOutlinerWrite,
39
- BlenderRig,
40
39
  BlenderRnaContext,
41
40
  BlenderRnaView,
42
41
  BlenderRnaWrite,
@@ -661,15 +660,6 @@ export async function blenderUvLayout(options?: {
661
660
  return blenderRuntime().uvLayout(options);
662
661
  }
663
662
 
664
- /** THE RIG DOOR, in-page — one mesh's skin binding, which the presenter turns
665
- * into a `THREE.SkinnedMesh` (`../contributions/blender-runtime-skin.ts`).
666
- * WE VISUALIZE WITH THREE.JS, NOT BLENDER (owner rule, 2026-09-20): this is
667
- * called once per rig, never per played frame. Same rule as the doors above:
668
- * it never starts the engine. */
669
- export async function blenderRig(options?: { object?: string }): Promise<BlenderRig | null> {
670
- if (!blenderSessionStarted()) return null;
671
- return blenderRuntime().rig(options);
672
- }
673
663
 
674
664
  /** THE CLIP DOOR, in-page — one action as three.js keyframe tracks plus the
675
665
  * scene's frame range and the Timeline's summary columns. Called when the
@@ -684,11 +674,6 @@ export async function blenderActionClip(options?: {
684
674
  return blenderRuntime().actionClip(options);
685
675
  }
686
676
 
687
- /** Every armature and the actions that can play on it, for Play's clip library. Never starts the engine. */
688
- export async function blenderArmatureActions(): Promise<{ armatures: { armature: string; current: string | null; actions: string[]; keys?: string }[]; scene?: { frameCurrent: number; frameStart: number; frameEnd: number; fps: number } } | null> {
689
- if (!blenderSessionStarted()) return null;
690
- return blenderRuntime().armatureActions();
691
- }
692
677
 
693
678
  export async function blenderOutlinerSet(
694
679
  path: string,
@@ -1456,18 +1441,8 @@ export async function handleBlenderCommand(cmd: {
1456
1441
  }),
1457
1442
  },
1458
1443
  };
1459
- // THE RIG AND CLIP DOORS on the wire, beside the UV one: the skin
1460
- // binding and the action as three.js tracks, so a `cyclotron eval` reads
1461
- // exactly what the presenter bound and what the Timeline plays.
1462
- case 'blender-rig':
1463
- return {
1464
- ok: true,
1465
- data: {
1466
- result: await session.rig({
1467
- ...(typeof cmd['object'] === 'string' ? { object: cmd['object'] } : {}),
1468
- }),
1469
- },
1470
- };
1444
+ // THE CLIP DOOR on the wire, beside the UV one: an action as three.js tracks, so a
1445
+ // `cyclotron eval` reads exactly what the Timeline and a game play.
1471
1446
  case 'blender-action-clip':
1472
1447
  return {
1473
1448
  ok: true,
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/editor-blender",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "AGPL-3.0-only AND GPL-3.0-or-later",
5
- "version": "0.1.135",
5
+ "version": "0.1.136",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },
@@ -72,15 +72,15 @@
72
72
  },
73
73
  "dependencies": {
74
74
  "@modelcontextprotocol/sdk": "1.32.1",
75
- "@volter/blender-engine": "0.1.133",
76
- "@volter/editor-threejs": "0.5.196",
75
+ "@volter/blender-engine": "0.1.134",
76
+ "@volter/editor-threejs": "0.5.197",
77
77
  "zod": "^4.3.6",
78
78
  "@fortawesome/free-solid-svg-icons": "^7.2.0"
79
79
  },
80
80
  "peerDependencies": {
81
- "@volter/editor-live": "0.5.196",
82
- "@volter/editor-project": "0.5.196",
83
- "@volter/editor-sdk": "0.5.196",
81
+ "@volter/editor-live": "0.5.197",
82
+ "@volter/editor-project": "0.5.197",
83
+ "@volter/editor-sdk": "0.5.197",
84
84
  "react": "^19.0.0",
85
85
  "react-dom": "^19.0.0",
86
86
  "three": "^0.180.0",