@volter/editor-blender 0.1.133 → 0.1.135

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.
@@ -0,0 +1,236 @@
1
+ /**
2
+ * ANIMATION IN PLAY: the same skin and clips the Timeline plays (`blender-runtime-skin.ts`), bound
3
+ * to Play's detached copy and driven by the game.
4
+ *
5
+ * Play runs a game on a copy of the model (`BlenderRuntimeView.detach`). Until this module the copy
6
+ * was built without its skins, so every rigged character was a frozen mesh in a game while the same
7
+ * character played in Movie. Nothing here is a second animation system: the rigs are bound by
8
+ * `bindRig` and the clips built by `clipTracks`, the Timeline's own code, over the copy's graph.
9
+ *
10
+ * WHAT A GAME PLAYS is any action that animates an armature's bones, assigned or not, so a file can
11
+ * hold a clip library (Idle, Run, Attack, Die side by side) and a game chooses among them by name.
12
+ * Each armature has its own `THREE.AnimationMixer`; a play crossfades from what played before.
13
+ *
14
+ * THE GAME'S CLOCK DRIVES IT: the runner advances the mixers by the `dt` each `update` is handed
15
+ * (`@volter/editor-model-play`'s `play-script.ts`), so pause, step, speed and Restart hold for
16
+ * animation exactly as they do for the script. Nothing advances on the page's own time.
17
+ *
18
+ * READ ONCE PER PLAY: the rig and every clip are read from the engine when Play starts (`rna_rig`
19
+ * walks every vertex of every rigged mesh, a clip bakes every bone per frame), never per frame.
20
+ */
21
+ import type { BlenderActionClip, BlenderRig } from '@volter/blender-engine/browser/rna';
22
+ import * as THREE from 'three';
23
+ import { blenderActionClip, blenderArmatureActions, blenderRig } from '../host/blender-runtime-host';
24
+ import { bindRig, clipTracks, type BoundRig, type SkinPresentation } from './blender-runtime-skin';
25
+
26
+ /** What Play reads from the engine: the rigs, every armature's action names, and the assigned
27
+ * actions baked. Any other action is baked the first time a game sets it (`bake`). */
28
+ export interface PlayClipLibrary {
29
+ readonly rig: BlenderRig | null;
30
+ /** Armature name -> the actions that can play on it. */
31
+ readonly names: ReadonlyMap<string, readonly string[]>;
32
+ /** Armature name -> the action assigned to it in the file (`animation_data.action`), which it
33
+ * plays from the start of a run, as Blender would. */
34
+ readonly assigned: ReadonlyMap<string, string>;
35
+ /** Armature name -> its baked clips by action name (the assigned ones at first). */
36
+ readonly clips: ReadonlyMap<string, ReadonlyMap<string, BlenderActionClip>>;
37
+ /** Bake one more action for an armature; null, with the reason in `warnings`, when it cannot play. */
38
+ bake(armature: string, action: string): Promise<BlenderActionClip | null>;
39
+ readonly warnings: string[];
40
+ }
41
+
42
+ /** Read the rigs and the assigned clips a game starts with. Empty when the file has no armature. */
43
+ export async function readPlayClipLibrary(): Promise<PlayClipLibrary> {
44
+ const warnings: string[] = [];
45
+ const [rig, listing] = await Promise.all([blenderRig(), blenderArmatureActions()]);
46
+ const names = new Map<string, readonly string[]>();
47
+ const assigned = new Map<string, string>();
48
+ const clips = new Map<string, Map<string, BlenderActionClip>>();
49
+ const bake = async (armature: string, action: string): Promise<BlenderActionClip | null> => {
50
+ try {
51
+ const clip = await blenderActionClip({ object: armature, action });
52
+ if (clip?.reason) warnings.push(`${armature} / ${action}: ${clip.reason}`);
53
+ if (clip?.tracks.length && clip.clipStart !== undefined && clip.clipEnd !== undefined) return clip;
54
+ } catch (error) {
55
+ warnings.push(`${armature} / ${action} could not be read: ${error instanceof Error ? error.message : String(error)}`);
56
+ }
57
+ return null;
58
+ };
59
+ await Promise.all((listing?.armatures ?? []).map(async ({ armature, actions, current }) => {
60
+ names.set(armature, actions);
61
+ if (!current) return;
62
+ assigned.set(armature, current);
63
+ const clip = await bake(armature, current);
64
+ if (clip) clips.set(armature, new Map([[current, clip]]));
65
+ }));
66
+ return { rig, names, assigned, clips, bake, warnings };
67
+ }
68
+
69
+ export interface PlayAnimateOptions {
70
+ /** Repeat until something else plays (default true). */
71
+ readonly loop?: boolean;
72
+ /** Seconds to crossfade from what this armature played before (default 0.2). */
73
+ readonly fade?: number;
74
+ /** Playback rate, 1 is the clip's own speed. */
75
+ readonly speed?: number;
76
+ /** Start again from the first frame when this clip is already playing (default false). */
77
+ readonly restart?: boolean;
78
+ }
79
+
80
+ /** The game's door to its characters' clips (`ModelPlayContext.animate`). */
81
+ export interface PlayAnimation {
82
+ /** The clips an object can play: its own armature's, or the armature its rigged mesh is bound to. */
83
+ clips(object: THREE.Object3D): readonly string[];
84
+ /** Play a clip on an object's armature; the reason when it cannot. */
85
+ play(object: THREE.Object3D, clip: string, options?: PlayAnimateOptions): { ok: true; armature: string } | { ok: false; why: string };
86
+ /** Fade out whatever the object's armature plays. */
87
+ stop(object: THREE.Object3D, fade?: number): void;
88
+ /** The clip the object's armature plays now, or null. */
89
+ playing(object: THREE.Object3D): string | null;
90
+ /** Advance every mixer by one update's `dt` (the game's clock). */
91
+ update(dt: number): void;
92
+ readonly warnings: readonly string[];
93
+ dispose(): void;
94
+ }
95
+
96
+ interface Armature {
97
+ readonly name: string;
98
+ readonly object: THREE.Object3D;
99
+ readonly mixer: THREE.AnimationMixer;
100
+ /** Every action that can play on it, baked or not. */
101
+ readonly names: readonly string[];
102
+ readonly bones: ReadonlyMap<string, THREE.Bone>;
103
+ readonly clips: Map<string, THREE.AnimationClip>;
104
+ /** Bakes in flight, by action name. */
105
+ readonly baking: Map<string, Promise<void>>;
106
+ /** Actions whose bake gave nothing to play: asked once, never re-baked every update. */
107
+ readonly failed: Set<string>;
108
+ current: { readonly name: string; readonly action: THREE.AnimationAction } | null;
109
+ /** The action most recently set, which a bake in flight starts when it lands. */
110
+ wanted: { readonly name: string; readonly options: PlayAnimateOptions } | null;
111
+ }
112
+
113
+ /** Bind the library's rigs and clips to Play's copy. */
114
+ export function bindPlayAnimation(presentation: SkinPresentation, library: PlayClipLibrary): PlayAnimation {
115
+ const warnings = [...library.warnings];
116
+ const rigs: BoundRig[] = [];
117
+ for (const rig of library.rig?.rigs ?? []) {
118
+ if (!rig.object || !rig.armature || !rig.skinIndexBase64 || !rig.skinWeightBase64) {
119
+ if (rig.reason) warnings.push(rig.reason);
120
+ continue;
121
+ }
122
+ const bound = bindRig(presentation, rig, library.rig!.frame, warnings);
123
+ if (bound) rigs.push(bound);
124
+ }
125
+ const armatures = new Map<string, Armature>();
126
+ /** A mesh's presented object → the armature its skin is bound to. */
127
+ const meshArmature = new Map<THREE.Object3D, string>();
128
+ for (const rig of rigs) meshArmature.set(rig.mesh, rig.armature);
129
+ const toClip = (action: string, clip: BlenderActionClip, bones: ReadonlyMap<string, THREE.Bone>): THREE.AnimationClip | null => {
130
+ const tracks = clipTracks(clip, bones, warnings);
131
+ if (!tracks.length) return null;
132
+ return new THREE.AnimationClip(action, clip.duration ?? (clip.clipEnd! - clip.clipStart!) / clip.fps, tracks);
133
+ };
134
+ for (const [name, actions] of library.names) {
135
+ const object = presentation.objectForBlenderName(name);
136
+ if (!object || !actions.length) continue;
137
+ const bones = new Map<string, THREE.Bone>();
138
+ for (const rig of rigs) if (rig.armature === name) for (const bone of rig.bones) bones.set(bone.name, bone);
139
+ if (!bones.size) { warnings.push(`${name} has actions but no skinned mesh is bound to it, so they would move nothing.`); continue; }
140
+ const clips = new Map<string, THREE.AnimationClip>();
141
+ for (const [action, clip] of library.clips.get(name) ?? []) {
142
+ const built = toClip(action, clip, bones);
143
+ if (built) clips.set(action, built);
144
+ }
145
+ armatures.set(name, { name, object, mixer: new THREE.AnimationMixer(object), names: actions, bones, clips, baking: new Map(), failed: new Set(), current: null, wanted: null });
146
+ }
147
+ const objectArmature = new Map<THREE.Object3D, Armature>();
148
+ for (const armature of armatures.values()) objectArmature.set(armature.object, armature);
149
+ // EACH ARMATURE PLAYS ITS ASSIGNED ACTION from the first update, as Blender's viewport does.
150
+ for (const armature of armatures.values()) {
151
+ const name = library.assigned.get(armature.name);
152
+ const source = name ? armature.clips.get(name) : undefined;
153
+ if (!name || !source) continue;
154
+ const action = armature.mixer.clipAction(source);
155
+ action.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
156
+ action.play();
157
+ armature.current = { name, action };
158
+ }
159
+ /** The armature that animates `object`: itself, the one its skin is bound to, or the nearest
160
+ * armature among its ancestors and descendants (a character's root empty holds its armature). */
161
+ const armatureOf = (object: THREE.Object3D): Armature | null => {
162
+ const own = objectArmature.get(object) ?? armatures.get(meshArmature.get(object) ?? '');
163
+ if (own) return own;
164
+ for (let parent = object.parent; parent; parent = parent.parent) {
165
+ const found = objectArmature.get(parent);
166
+ if (found) return found;
167
+ }
168
+ let found: Armature | null = null;
169
+ object.traverse((child) => { if (!found) found = objectArmature.get(child) ?? armatures.get(meshArmature.get(child) ?? '') ?? null; });
170
+ return found;
171
+ };
172
+ let disposed = false;
173
+ /** Crossfade an armature to a baked action. */
174
+ const start = (armature: Armature, clip: string, options: PlayAnimateOptions): void => {
175
+ const source = armature.clips.get(clip)!;
176
+ const fade = Math.max(0, options.fade ?? 0.2);
177
+ const action = armature.mixer.clipAction(source);
178
+ action.setLoop(options.loop === false ? THREE.LoopOnce : THREE.LoopRepeat, Number.POSITIVE_INFINITY);
179
+ action.clampWhenFinished = options.loop === false;
180
+ action.setEffectiveTimeScale(options.speed ?? 1);
181
+ if (armature.current?.name === clip) {
182
+ if (options.restart) action.reset().play();
183
+ return;
184
+ }
185
+ action.reset().setEffectiveWeight(1).play();
186
+ if (armature.current && fade > 0) armature.current.action.crossFadeTo(action, fade, false);
187
+ else armature.current?.action.stop();
188
+ armature.current = { name: clip, action };
189
+ };
190
+ return {
191
+ warnings,
192
+ clips: (object) => [...(armatureOf(object)?.names ?? [])],
193
+ play(object, clip, options = {}) {
194
+ const armature = armatureOf(object);
195
+ if (!armature) return { ok: false, why: `${object.name || 'this object'} has no armature with actions` };
196
+ if (!armature.names.includes(clip)) return { ok: false, why: `${armature.name} has no action "${clip}"; it has ${armature.names.join(', ')}` };
197
+ if (armature.failed.has(clip)) return { ok: false, why: `${armature.name}'s action "${clip}" has nothing this armature can play (see the console)` };
198
+ armature.wanted = { name: clip, options };
199
+ if (!armature.clips.has(clip)) {
200
+ // FIRST USE: baked now, and started when it lands if it is still the one wanted.
201
+ if (!armature.baking.has(clip))
202
+ armature.baking.set(clip, library.bake(armature.name, clip).then((baked) => {
203
+ armature.baking.delete(clip);
204
+ const built = baked ? toClip(clip, baked, armature.bones) : null;
205
+ if (built) armature.clips.set(clip, built);
206
+ else {
207
+ armature.failed.add(clip);
208
+ if (armature.wanted?.name === clip) armature.wanted = null;
209
+ }
210
+ if (built && !disposed && armature.wanted?.name === clip) start(armature, clip, armature.wanted.options);
211
+ }));
212
+ return { ok: true, armature: armature.name };
213
+ }
214
+ start(armature, clip, options);
215
+ return { ok: true, armature: armature.name };
216
+ },
217
+ stop(object, fade = 0.2) {
218
+ const armature = armatureOf(object);
219
+ if (armature) armature.wanted = null;
220
+ if (!armature?.current) return;
221
+ if (fade > 0) armature.current.action.fadeOut(fade); else armature.current.action.stop();
222
+ armature.current = null;
223
+ },
224
+ // What was last set, so a script that sets the same action every update while it bakes asks once.
225
+ playing: (object) => {
226
+ const armature = armatureOf(object);
227
+ return armature?.wanted?.name ?? armature?.current?.name ?? null;
228
+ },
229
+ update(dt) { for (const armature of armatures.values()) armature.mixer.update(dt); },
230
+ dispose() {
231
+ disposed = true;
232
+ for (const armature of armatures.values()) armature.mixer.stopAllAction();
233
+ for (const rig of rigs) rig.skeleton.dispose();
234
+ },
235
+ };
236
+ }
@@ -84,7 +84,7 @@ export interface SkinPresentation {
84
84
  root: THREE.Object3D;
85
85
  }
86
86
 
87
- interface BoundRig {
87
+ export interface BoundRig {
88
88
  /** The MESH object's Blender name. */
89
89
  readonly object: string;
90
90
  readonly armature: string;
@@ -103,6 +103,150 @@ function rigSignature(rig: BlenderRigBinding, frame: number): string {
103
103
  return [rig.object, rig.mesh, rig.armature, rig.vertexCount, rig.bones.length, frame].join('|');
104
104
  }
105
105
 
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;
131
+ }
132
+
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
+
106
250
  /**
107
251
  * THE ONE DIRECTOR, module-scoped for the reason `BlenderRuntimeView` itself
108
252
  * is (`blender-runtime.document.tsx`): the Python session outlives workspace
@@ -112,6 +256,17 @@ function rigSignature(rig: BlenderRigBinding, frame: number): string {
112
256
  */
113
257
  export class BlenderSkinDirector {
114
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
+ }
115
270
  #mixer: THREE.AnimationMixer | null = null;
116
271
  #action: THREE.AnimationAction | null = null;
117
272
  #clip: BlenderActionClip | null = null;
@@ -235,6 +390,25 @@ export class BlenderSkinDirector {
235
390
  }
236
391
 
237
392
  /** 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
+
238
412
  get playable(): boolean {
239
413
  return this.#clip !== null && this.#transport !== null;
240
414
  }
@@ -270,10 +444,38 @@ export class BlenderSkinDirector {
270
444
  }
271
445
  wanted.add(rig.object);
272
446
  const signature = rigSignature(rig, answer.frame);
273
- if (this.#rigs.get(rig.object)?.signature === signature) continue;
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;
274
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
+ }
275
475
  if (bound) {
276
- this.#rigs.get(rig.object)?.skeleton.dispose();
476
+ this.#unbindable.delete(rig.object);
477
+ held?.skeleton.dispose();
478
+ held?.boneRoot.removeFromParent();
277
479
  this.#rigs.set(rig.object, bound);
278
480
  changed = true;
279
481
  }
@@ -318,116 +520,8 @@ export class BlenderSkinDirector {
318
520
  this.#publish();
319
521
  }
320
522
 
321
- #bindOne(
322
- presentation: SkinPresentation,
323
- rig: BlenderRigBinding,
324
- frame: number,
325
- warnings: string[],
326
- ): BoundRig | null {
327
- const meshObject = presentation.objectForBlenderName(rig.object ?? '');
328
- const armatureObject = presentation.objectForBlenderName(rig.armature ?? '');
329
- if (!meshObject || !armatureObject) return null;
330
- const source = meshObject as THREE.Mesh;
331
- const geometry = source.geometry;
332
- if (!geometry) return null;
333
- const blenderVertex = geometry.getAttribute('blenderVertex');
334
- if (!blenderVertex) {
335
- warnings.push(
336
- `"${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.`,
337
- );
338
- return null;
339
- }
340
- if (rig.vertexCount === 0) return null;
341
- // THE COLUMNS AND THE BINDING MUST BE THE SAME MESH. `rna_rig` reads the
342
- // ORIGINAL mesh's vertices (a deform layer is not geometry, so that is
343
- // where the weights live); a generative modifier — Subdivision, Mirror,
344
- // Array — makes the EVALUATED mesh the export door ships a different
345
- // vertex set, and a skin bound across that mismatch would weight the wrong
346
- // vertices. Named, never silently drawn.
347
- let highest = 0;
348
- for (let i = 0; i < blenderVertex.count; i++)
349
- highest = Math.max(highest, blenderVertex.getX(i));
350
- if (highest >= rig.vertexCount) {
351
- warnings.push(
352
- `"${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.`,
353
- );
354
- return null;
355
- }
356
- const skinIndex = uint16Of(rig.skinIndexBase64 ?? '');
357
- const skinWeight = float32Of(rig.skinWeightBase64 ?? '');
358
- const drawn = blenderVertex.count;
359
- const indices = new Uint16Array(drawn * 4);
360
- const weights = new Float32Array(drawn * 4);
361
- for (let i = 0; i < drawn; i++) {
362
- const vertex = blenderVertex.getX(i);
363
- for (let k = 0; k < 4; k++) {
364
- indices[i * 4 + k] = skinIndex[vertex * 4 + k] ?? 0;
365
- weights[i * 4 + k] = skinWeight[vertex * 4 + k] ?? 0;
366
- }
367
- }
368
- geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(indices, 4));
369
- geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(weights, 4));
370
-
371
- const boneRoot = new THREE.Group();
372
- boneRoot.name = `${rig.armature}:bones`;
373
- const bones: THREE.Bone[] = [];
374
- const armatureSpace = rig.bones.map((bone) => matrixOf(bone.pose));
375
- const indexByName = new Map(rig.bones.map((bone, index) => [bone.name, index]));
376
- rig.bones.forEach((declared, index) => {
377
- const bone = new THREE.Bone();
378
- // THE NAME IS BLENDER'S, unsanitized, because a game reads it: the
379
- // arena's `Player.tsx` finds its bones by Blender's own names. The CLIP
380
- // therefore addresses bones by UUID rather than by name — three's
381
- // `PropertyBinding` track-name grammar splits on `.`, and Blender's
382
- // `hand.L` is an ordinary bone name.
383
- bone.name = declared.name;
384
- bones.push(bone);
385
- const parentIndex = declared.parent === null ? undefined : indexByName.get(declared.parent);
386
- const local =
387
- parentIndex === undefined
388
- ? armatureSpace[index]!.clone()
389
- : armatureSpace[parentIndex]!.clone().invert().multiply(armatureSpace[index]!);
390
- local.decompose(bone.position, bone.quaternion, bone.scale);
391
- (parentIndex === undefined ? boneRoot : bones[parentIndex]!).add(bone);
392
- });
393
- armatureObject.add(boneRoot);
394
-
395
- const skinned = new THREE.SkinnedMesh(geometry, source.material);
396
- skinned.name = source.name;
397
- skinned.matrixAutoUpdate = false;
398
- skinned.matrix.copy(source.matrix);
399
- skinned.matrix.decompose(skinned.position, skinned.quaternion, skinned.scale);
400
- skinned.userData = source.userData;
401
- // A SKIN MOVES PAST ITS BIND BOUNDS. three computes a SkinnedMesh's
402
- // bounding sphere from the bind pose, so a raised arm at frame 24 is
403
- // culled while its bind-pose sphere is off screen — a picture that
404
- // vanishes mid-scrub with no error anywhere.
405
- skinned.frustumCulled = false;
406
- presentation.replacePresentedObject(source, skinned);
407
- // THE BIND IS TAKEN AFTER THE GRAPH STANDS, because both halves of it are
408
- // WORLD matrices: `Skeleton`'s bone inverses are the bones' `matrixWorld`
409
- // at this instant and `bindMatrix` is the mesh's.
410
- presentation.root.updateMatrixWorld(true);
411
- const skeleton = new THREE.Skeleton(bones);
412
- skinned.bind(skeleton, skinned.matrixWorld.clone());
413
- if (rig.constrainedBones?.length)
414
- warnings.push(
415
- `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.`,
416
- );
417
- if (rig.unmappedGroups?.length)
418
- warnings.push(
419
- `${rig.object} carries vertex groups no bone is named for (${rig.unmappedGroups.join(', ')}); they weight nothing here, exactly as they deform nothing in Blender.`,
420
- );
421
- return {
422
- object: rig.object ?? '',
423
- armature: rig.armature ?? '',
424
- mesh: skinned,
425
- boneRoot,
426
- bones,
427
- skeleton,
428
- frame,
429
- signature: rigSignature(rig, frame),
430
- };
523
+ #bindOne(presentation: SkinPresentation, rig: BlenderRigBinding, frame: number, warnings: string[]): BoundRig | null {
524
+ return bindRig(presentation, rig, frame, warnings);
431
525
  }
432
526
 
433
527
  #loadClip(presentation: SkinPresentation, clip: BlenderActionClip, warnings: string[]): void {
@@ -440,28 +534,7 @@ export class BlenderSkinDirector {
440
534
  const byName = new Map<string, THREE.Bone>();
441
535
  for (const rig of this.#rigs.values())
442
536
  if (rig.armature === clip.armature) for (const bone of rig.bones) byName.set(bone.name, bone);
443
- const tracks: THREE.KeyframeTrack[] = [];
444
- const missing = new Set<string>();
445
- for (const track of clip.tracks) {
446
- const bone = byName.get(track.bone);
447
- if (!bone) {
448
- missing.add(track.bone);
449
- continue;
450
- }
451
- const times = float32Of(track.timeBase64);
452
- const values = float32Of(track.valueBase64);
453
- // ADDRESSED BY UUID, not by name — see the bone-naming note above.
454
- const path = `${bone.uuid}.${track.property}`;
455
- tracks.push(
456
- track.property === 'quaternion'
457
- ? new THREE.QuaternionKeyframeTrack(path, Array.from(times), Array.from(values))
458
- : new THREE.VectorKeyframeTrack(path, Array.from(times), Array.from(values)),
459
- );
460
- }
461
- if (missing.size)
462
- warnings.push(
463
- `${clip.action} animates ${[...missing].join(', ')}, which this binding has no bone for.`,
464
- );
537
+ const tracks = clipTracks(clip, byName, warnings);
465
538
  if (!tracks.length) return;
466
539
  const duration = clip.duration ?? (clip.clipEnd - clip.clipStart) / clip.fps;
467
540
  const mixer = new THREE.AnimationMixer(armature);
@@ -70,10 +70,15 @@ import {
70
70
  openModelDocumentBlend,
71
71
  modelDocumentMayOpen,
72
72
  modelDocumentOwnsPresentation,
73
+ blenderActionClip,
74
+ blenderArmatureActions,
75
+ blenderRig,
76
+ blenderRnaVersion,
73
77
  } from '../host/blender-runtime-host';
74
78
  import { BlenderObjectModeHeader } from './blender-header-menus';
75
79
  import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
76
80
  import { blenderSkin } from './blender-runtime-skin';
81
+ import { bindPlayAnimation, readPlayClipLibrary, type PlayAnimation } from './blender-play-skin';
77
82
  import { areaSplit, subscribeAreaSplit } from '../src/area-split';
78
83
  import { noteModelDocument } from '../src/play-mode';
79
84
  import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/editor-sdk/kit/document-play-extension';
@@ -897,6 +902,7 @@ function BlenderViewportArea({
897
902
  let preparing = false;
898
903
  let stopScript: (() => void) | null = null;
899
904
  let orbit: { enabled: boolean } | null = null;
905
+ let animation: PlayAnimation | null = null;
900
906
  const start = (): void => {
901
907
  if (stopScript || preparing || stopped) return;
902
908
  const stage = viewportStages().find((one) => one.documentId === documentId);
@@ -904,9 +910,21 @@ function BlenderViewportArea({
904
910
  preparing = true;
905
911
  orbit = stage.rig().orbit;
906
912
  orbit.enabled = false;
907
- void view.prepareRendered(stage.rig().drawCamera(), 'render').then(() => {
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]) => {
908
923
  if (stopped) return;
924
+ animation = library ? bindPlayAnimation(view, library) : null;
925
+ for (const warning of animation?.warnings ?? []) editorHost().console.warn(warning, 'blender-play');
909
926
  stopScript = documentPlayExtension('model')?.run({
927
+ ...(animation ? { animation } : {}),
910
928
  documentId: modelId,
911
929
  container: layers,
912
930
  ready: () => { if (!stopped) { setPlayReady(true); playReadyRef.current?.(); } },
@@ -964,6 +982,8 @@ function BlenderViewportArea({
964
982
  stopPrepareFrames?.();
965
983
  window.removeEventListener('keydown', onEscape, true);
966
984
  stopScript?.();
985
+ animation?.dispose();
986
+ animation = null;
967
987
  layers.remove();
968
988
  if (orbit) orbit.enabled = true;
969
989
  };
@@ -1035,6 +1055,33 @@ function BlenderViewportArea({
1035
1055
  for (const { kind } of groups) stage?.setHelper(kind, null);
1036
1056
  };
1037
1057
  }, [documentId, view]);
1058
+ /**
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.
1063
+ */
1064
+ 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
+ useEffect(() => {
1069
+ if (!documentId || !main) return;
1070
+ 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); };
1084
+ }, [documentId, main, view, rnaVersion]);
1038
1085
  /**
1039
1086
  * ATTACH THE SKIN TO THIS STAGE'S TRANSPORT.
1040
1087
  *
@@ -38,7 +38,6 @@
38
38
  * you are looking.
39
39
  */
40
40
 
41
- import { blenderModelView } from '@volter/blender-engine/browser/three/blender-runtime-view';
42
41
  import type { StageTransportHandle } from '@volter/editor-sdk/host';
43
42
  import { registerViewVerbs } from '@volter/editor-sdk/views';
44
43
  import {
@@ -51,8 +50,6 @@ import {
51
50
  useSyncExternalStore,
52
51
  } from 'react';
53
52
  import {
54
- blenderActionClip,
55
- blenderRig,
56
53
  blenderRnaVersion,
57
54
  subscribeBlenderRna,
58
55
  } from '../host/blender-runtime-host';
@@ -392,7 +389,7 @@ function BlenderTimeline() {
392
389
  // THE FRESHNESS IS THE RNA DOOR'S (ruling 3, 2026-09-19): every write and
393
390
  // every presented frame bumps it, which is exactly when a rig or an action
394
391
  // can have moved.
395
- const rnaVersion = useSyncExternalStore(
392
+ useSyncExternalStore(
396
393
  subscribeBlenderRna,
397
394
  blenderRnaVersion,
398
395
  blenderRnaVersion,
@@ -404,30 +401,9 @@ function BlenderTimeline() {
404
401
  const { transform, size, refusal, onlySelected } = timelineViewState();
405
402
  const play = blenderSkin.state();
406
403
 
407
- // THE BIND LIVES WITH THE TIMELINE, not with every present, and that is a
408
- // cost decision stated rather than implied: `rna_rig` walks every vertex of
409
- // every rigged mesh and `rna_action_clip` bakes every bone per frame, so
410
- // paying for both on each of a session's hundreds of `blender-execute`
411
- // presents would be a round trip nobody asked for. A project with no
412
- // Timeline open therefore presents exactly as it did before this unit, with
413
- // ordinary `THREE.Mesh`es.
414
- useEffect(() => {
415
- let live = true;
416
- void blenderSkin
417
- .bind(blenderModelView, {
418
- rig: () => blenderRig(),
419
- clip: () => blenderActionClip(),
420
- })
421
- .catch((thrown: unknown) => {
422
- if (!live) return;
423
- refuseTimelineGesture(
424
- `The Timeline could not read this file's rig: ${thrown instanceof Error ? thrown.message : String(thrown)}`,
425
- );
426
- });
427
- return () => {
428
- live = false;
429
- };
430
- }, [rnaVersion]);
404
+ // The skins and the assigned action are the presented model's (`blender-runtime.document.tsx`
405
+ // binds them whatever is on screen, as Blender's viewport evaluates whatever editor is open);
406
+ // the Timeline only shows and drives their clock.
431
407
 
432
408
  useEffect(() => {
433
409
  const element = canvas.current;
@@ -43,7 +43,7 @@ import type {
43
43
  BlenderUvLayout,
44
44
  } from '@volter/blender-engine/browser/rna';
45
45
  import { createBlenderDisplayTransform, blenderDisplaySettingsForRender } from '@volter/blender-engine/browser/three/blender-display-transform';
46
- import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
46
+ import { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
47
47
  import type { EditorCommandResult } from '@volter/editor-sdk/commands';
48
48
  import { editorHost } from '@volter/editor-sdk/host';
49
49
  import { invokeViewVerb, type ViewVerbContribution } from '@volter/editor-sdk/views';
@@ -677,11 +677,19 @@ export async function blenderRig(options?: { object?: string }): Promise<Blender
677
677
  export async function blenderActionClip(options?: {
678
678
  object?: string;
679
679
  bake?: boolean;
680
+ /** A named action baked against `object`'s armature, assigned or not. */
681
+ action?: string;
680
682
  }): Promise<BlenderActionClip | null> {
681
683
  if (!blenderSessionStarted()) return null;
682
684
  return blenderRuntime().actionClip(options);
683
685
  }
684
686
 
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
+
685
693
  export async function blenderOutlinerSet(
686
694
  path: string,
687
695
  column: string,
@@ -821,6 +829,7 @@ function driveNodeView(cmd: { type: string; [key: string]: unknown }): NodeViewS
821
829
  }
822
830
 
823
831
  interface RuntimeView {
832
+ readonly root: THREE.Group;
824
833
  stageFrame(part: { session: string; revision: number; abort?: boolean; mesh?: string; image?: string; piece?: unknown }): void;
825
834
  applyFrame(frame: unknown): unknown;
826
835
  snapshot(): ReturnType<BlenderRuntimeView['snapshot']>;
@@ -903,6 +912,7 @@ const isRuntimeView = (value: unknown): value is RuntimeView =>
903
912
  typeof (value as RuntimeView).applyFrame === 'function' &&
904
913
  typeof (value as RuntimeView).stageFrame === 'function' &&
905
914
  typeof (value as RuntimeView).snapshot === 'function' &&
915
+ (value as RuntimeView).root?.isObject3D === true &&
906
916
  typeof (value as RuntimeView).captureSnapshot === 'function' &&
907
917
  typeof (value as RuntimeView).recordPresentation === 'function' &&
908
918
  typeof (value as RuntimeView).recordPhotograph === 'function';
@@ -951,11 +961,20 @@ export function blenderRuntime(): BlenderRuntime {
951
961
  captureLifetime = lifetime;
952
962
  let photographing = false;
953
963
  let streamedFrame: { view: RuntimeView; session: string; revision: number } | null = null;
964
+ let renderFrame: { view: BlenderRuntimeView; source: RuntimeView; sourceFrame: ReturnType<RuntimeView['snapshot']>; documentId: string; session: string; revision: number } | null = null;
965
+ const discardRenderFrame = () => {
966
+ const owned = renderFrame;
967
+ renderFrame = null;
968
+ owned?.view.dispose();
969
+ owned?.view.root.removeFromParent();
970
+ };
954
971
  const discardStreamedFrame = () => {
955
972
  const staged = streamedFrame;
956
973
  streamedFrame = null;
957
974
  if (staged) staged.view.stageFrame({ session: staged.session, revision: staged.revision, abort: true });
975
+ discardRenderFrame();
958
976
  };
977
+ lifetime.signal.addEventListener('abort', discardStreamedFrame, { once: true });
959
978
  runtime = new BlenderRuntime({
960
979
  work: beginBlenderWork,
961
980
  history: (entries) => {
@@ -997,6 +1016,20 @@ export function blenderRuntime(): BlenderRuntime {
997
1016
  const view = await runtimeView();
998
1017
  lifetime.signal.throwIfAborted();
999
1018
  if (presentationDocumentId() !== documentId) throw new Error('Blender document changed during frame transfer');
1019
+ if (part.evaluation === 'render') {
1020
+ if (!renderFrame) {
1021
+ if (part.mesh !== undefined || part.image !== undefined) throw new Error('Render resource arrived before its frame');
1022
+ const detached = BlenderRuntimeView.forPhotograph();
1023
+ view.root.updateWorldMatrix(true, false);
1024
+ detached.root.matrix.copy(view.root.matrixWorld);
1025
+ renderFrame = { view: detached, source: view, sourceFrame: view.snapshot(), documentId, session: part.session, revision: part.revision };
1026
+ }
1027
+ if (renderFrame.source !== view || renderFrame.sourceFrame !== view.snapshot() || renderFrame.documentId !== documentId || renderFrame.session !== part.session || renderFrame.revision !== part.revision)
1028
+ throw new Error('Blender capture source changed during render transfer');
1029
+ renderFrame.view.stageFrame(part);
1030
+ return;
1031
+ }
1032
+ if (renderFrame) throw new Error('Viewport frame arrived during render transfer');
1000
1033
  if (streamedFrame && streamedFrame.view !== view)
1001
1034
  throw new Error('Blender presenter changed during frame transfer');
1002
1035
  view.stageFrame(part);
@@ -1027,6 +1060,12 @@ export function blenderRuntime(): BlenderRuntime {
1027
1060
  throw new Error('Blender document changed before its frame could be presented');
1028
1061
  if (streamedFrame && streamedFrame.view !== view)
1029
1062
  throw new Error('Blender presenter changed before its frame could be presented');
1063
+ const rendering = capture?.evaluation === 'render' ? renderFrame : null;
1064
+ if (capture?.evaluation === 'render' && !rendering)
1065
+ throw new Error('Render-evaluated frame arrived without its staged resources');
1066
+ if (rendering && (rendering.source !== view || rendering.sourceFrame !== view.snapshot() || rendering.documentId !== documentId))
1067
+ throw new Error('Blender capture source changed before render presentation');
1068
+ const presenter = rendering?.view ?? view;
1030
1069
  // WHAT THE PRESENTER HELD BEFORE THIS FRAME, carried back to the session
1031
1070
  // beside whatever this present produced. The session only has a RECORD of
1032
1071
  // what it sent; this view is the authority on what it actually holds, and
@@ -1036,7 +1075,7 @@ export function blenderRuntime(): BlenderRuntime {
1036
1075
  // all, rather than a wrong `null`.
1037
1076
  const endApply = beginBlenderWork('applying frame to Model');
1038
1077
  const applied = (() => {
1039
- try { return view.applyFrame(frame) as { held?: unknown } | null | undefined; }
1078
+ try { return presenter.applyFrame(frame) as { held?: unknown } | null | undefined; }
1040
1079
  finally { endApply(); }
1041
1080
  })();
1042
1081
  streamedFrame = null;
@@ -1049,7 +1088,7 @@ export function blenderRuntime(): BlenderRuntime {
1049
1088
  // THE RECORD OF WHAT WAS SUBMITTED, kept only once the frame was taken:
1050
1089
  // a refused frame is not displayed and must not be described as if it
1051
1090
  // were.
1052
- view.recordPresentation(description);
1091
+ presenter.recordPresentation(description);
1053
1092
  if (!capture) return answer(undefined);
1054
1093
  lastCapture = capture;
1055
1094
  if (!capture.render) return answer(undefined);
@@ -1091,7 +1130,7 @@ export function blenderRuntime(): BlenderRuntime {
1091
1130
  'A Blender render capture must carry the scene camera position, target and up',
1092
1131
  );
1093
1132
  if (photographing) throw new Error('A Blender render capture is already in progress');
1094
- const snapshot = view.captureSnapshot();
1133
+ const snapshot = rendering ? rendering.view.takeCaptureSnapshot() : view.captureSnapshot();
1095
1134
  photographing = true;
1096
1135
  lifetime.signal.addEventListener('abort', snapshot.dispose, { once: true });
1097
1136
  try {
@@ -1150,6 +1189,7 @@ export function blenderRuntime(): BlenderRuntime {
1150
1189
  photographing = false;
1151
1190
  lifetime.signal.removeEventListener('abort', snapshot.dispose);
1152
1191
  snapshot.dispose();
1192
+ if (rendering && renderFrame === rendering) renderFrame = null;
1153
1193
  }
1154
1194
  },
1155
1195
  // The worker's OWN stdout and stderr, forwarded so a developer can read
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.133",
5
+ "version": "0.1.135",
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.131",
76
- "@volter/editor-threejs": "0.5.194",
75
+ "@volter/blender-engine": "0.1.133",
76
+ "@volter/editor-threejs": "0.5.196",
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.194",
82
- "@volter/editor-project": "0.5.194",
83
- "@volter/editor-sdk": "0.5.194",
81
+ "@volter/editor-live": "0.5.196",
82
+ "@volter/editor-project": "0.5.196",
83
+ "@volter/editor-sdk": "0.5.196",
84
84
  "react": "^19.0.0",
85
85
  "react-dom": "^19.0.0",
86
86
  "three": "^0.180.0",