@volter/editor-blender 0.1.134 → 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.
@@ -1280,7 +1280,11 @@ export function blenderOutlinerAuthoringFor(
1280
1280
  // is the other end of the same seam `hierarchy.node` already normalizes.
1281
1281
  // An id that resolves to no row is DROPPED rather than stored: a row id
1282
1282
  // is this provider's whole currency and a foreign one is not a selection.
1283
- selected = ids.map(rowIdFor).filter((id): id is string => id !== null);
1283
+ // The WRITE below still names a presented object that has no row yet.
1284
+ // One row lookup per id: `rowIdForObject` scans the object rows, and a marquee over a
1285
+ // large scene passes every object it covers.
1286
+ const rowIds = ids.map(rowIdFor);
1287
+ selected = rowIds.filter((id): id is string => id !== null);
1284
1288
  publishPresentation();
1285
1289
  // AND IT WRITES BLENDER'S (see {@link writeBlenderSelection}). The
1286
1290
  // subject is the OWNING OBJECT of each selected row, which is Blender's
@@ -1294,14 +1298,37 @@ export function blenderOutlinerAuthoringFor(
1294
1298
  // its object selection, and a person who clicked one and saw the
1295
1299
  // Properties rail keep the last object deserves the sentence.
1296
1300
  const table = rows();
1301
+ const view = presented();
1297
1302
  const names: string[] = [];
1298
1303
  const ownerless: string[] = [];
1299
- for (const id of selected) {
1300
- const row = table.get(id);
1301
- const name = row?.object;
1302
- if (name === undefined) ownerless.push(row?.name ?? id);
1304
+ const unnamed: string[] = [];
1305
+ ids.forEach((id, index) => {
1306
+ const rowId = rowIds[index]!;
1307
+ if (rowId !== null) {
1308
+ const row = table.get(rowId);
1309
+ const name = row?.object;
1310
+ if (name === undefined) ownerless.push(row?.name ?? rowId);
1311
+ else if (!names.includes(name)) names.push(name);
1312
+ return;
1313
+ }
1314
+ // A PRESENTED OBJECT PAST THE TREE'S PAGE IS STILL BLENDER'S. `rna_outliner`
1315
+ // answers the first `_OUTLINER_PAGE` rows of every child list, so in a larger
1316
+ // scene the viewport picks objects that have no row yet (Canyon Comet: 1,146 rows
1317
+ // under its one collection, 64 answered, all six karts among the rest). The view
1318
+ // names the object by identity; the tree read for the frame this write presents
1319
+ // keeps the selection's rows (`page_tree`), and `syncFromEngine` takes the row up.
1320
+ const object = threeObject(defaultAdapter.hierarchy, id);
1321
+ const name = object === null ? null : (view?.blenderObjectName(object) ?? null);
1322
+ if (name === null) unnamed.push(id);
1303
1323
  else if (!names.includes(name)) names.push(name);
1304
- }
1324
+ });
1325
+ // Said, not swallowed: a picked object the view can't name is a selection Blender never gets.
1326
+ if (unnamed.length > 0)
1327
+ editorHost().console.warn(
1328
+ `No Blender object answers to ${unnamed.join(', ')}, so Blender's selection leaves ` +
1329
+ `${unnamed.length === 1 ? 'it' : 'them'} out.`,
1330
+ 'blender-outliner',
1331
+ );
1305
1332
  if (ownerless.length > 0)
1306
1333
  editorHost().console.warn(
1307
1334
  `${ownerless.join(', ')} ${ownerless.length === 1 ? 'is' : 'are'} not an object, so ` +
@@ -0,0 +1,256 @@
1
+ /**
2
+ * A CHARACTER'S ANIMATION IN A GAME: layered, weighted actions on one armature's bones, and a
3
+ * look-at for targets only the game knows.
4
+ *
5
+ * LAYERS: the base layer plays on every bone; a named layer plays on the bones from one bone down
6
+ * (`from: 'spine_01'`: the upper body), and takes those bones from the base. So legs can run while
7
+ * the upper body aims. A layer holds any number of actions at weights (`blend`); setting one action
8
+ * is a blend of that one, crossfaded (`set`).
9
+ *
10
+ * LOOK-AT: turns one bone so its axis points at a point, after the actions, by a weight and within
11
+ * an angle. The axis is the bone's FORWARD unless given: the direction in the bone that pointed the
12
+ * way the character faces (Blender's convention, the armature's -Y) in the pose the file was in. A
13
+ * bone's own Y runs along it, out of the top of a head, so it is not what "look" means. It is the game's, not Blender's: Blender's own IK and constraints are what an action was
14
+ * authored with; this aims at what exists only while the game runs (an aim target, the player).
15
+ *
16
+ * Everything advances on the game's clock (`update(dt)`).
17
+ */
18
+ import * as THREE from 'three';
19
+
20
+ export interface LayerOptions {
21
+ /** The layer: `base` (default) or a name of the game's choosing. */
22
+ readonly layer?: string;
23
+ /** For a layer other than `base`: the bone it plays from, with every bone below it. */
24
+ readonly from?: string;
25
+ /** Seconds for weights to reach what was set (default 0.2 for `set`, 0 for `blend`). */
26
+ readonly fade?: number;
27
+ /** Repeat (default true); `false` plays once and holds the last frame. */
28
+ readonly loop?: boolean;
29
+ /** Playback rate, 1 is the action's own. */
30
+ readonly speed?: number;
31
+ /** Start again from the first frame when this action already plays on the layer. */
32
+ readonly restart?: boolean;
33
+ }
34
+
35
+ export interface LookAtOptions {
36
+ /** How far toward the target, 0 to 1 (default 1). */
37
+ readonly weight?: number;
38
+ /** The bone's local axis that should point at the target: `forward` (default, see the header)
39
+ * or one of the bone's own axes (`y` is a Blender bone's length). */
40
+ readonly axis?: 'forward' | 'x' | 'y' | 'z' | '-x' | '-y' | '-z';
41
+ /** The most it turns away from the animated pose, in degrees (default 75). */
42
+ readonly limit?: number;
43
+ }
44
+
45
+ interface Entry {
46
+ readonly name: string;
47
+ action: THREE.AnimationAction | null;
48
+ weight: number;
49
+ target: number;
50
+ rate: number;
51
+ loop: boolean;
52
+ speed: number;
53
+ }
54
+
55
+ interface Layer {
56
+ readonly name: string;
57
+ from: string | null;
58
+ /** The bones it plays on, by uuid; null for the base (everything other layers do not hold). */
59
+ bones: Set<string> | null;
60
+ readonly entries: Map<string, Entry>;
61
+ }
62
+
63
+ const AXES: Record<Exclude<NonNullable<LookAtOptions['axis']>, 'forward'>, THREE.Vector3> = {
64
+ x: new THREE.Vector3(1, 0, 0), y: new THREE.Vector3(0, 1, 0), z: new THREE.Vector3(0, 0, 1),
65
+ '-x': new THREE.Vector3(-1, 0, 0), '-y': new THREE.Vector3(0, -1, 0), '-z': new THREE.Vector3(0, 0, -1),
66
+ };
67
+
68
+ export class LayeredMixer {
69
+ readonly #mixer: THREE.AnimationMixer;
70
+ readonly #layers = new Map<string, Layer>();
71
+ readonly #masked = new Map<string, THREE.AnimationClip>();
72
+ readonly #looks = new Map<string, { bone: THREE.Bone; target: THREE.Vector3 | THREE.Object3D; options: LookAtOptions }>();
73
+ /** Each bone's forward in its own space, from the pose the file was in (before any action). */
74
+ readonly #forward = new Map<string, THREE.Vector3>();
75
+
76
+ /** `clipFor` answers an action's full clip, or null while it is not available yet (a bake in
77
+ * flight); the mixer asks again on later updates. */
78
+ constructor(
79
+ readonly root: THREE.Object3D,
80
+ readonly bones: ReadonlyMap<string, THREE.Bone>,
81
+ private readonly clipFor: (action: string) => THREE.AnimationClip | null,
82
+ ) {
83
+ this.#mixer = new THREE.AnimationMixer(root);
84
+ // The character faces the armature's -Y, as in Blender; each bone's forward is that direction
85
+ // in the bone's frame at the pose the file was in.
86
+ root.updateWorldMatrix(true, true);
87
+ const rootWorld = root.getWorldQuaternion(new THREE.Quaternion());
88
+ const facing = new THREE.Vector3(0, -1, 0).applyQuaternion(rootWorld);
89
+ for (const [name, bone] of bones) {
90
+ const boneWorld = bone.getWorldQuaternion(new THREE.Quaternion());
91
+ this.#forward.set(name, facing.clone().applyQuaternion(boneWorld.invert()).normalize());
92
+ }
93
+ this.#layers.set('base', { name: 'base', from: null, bones: null, entries: new Map() });
94
+ }
95
+
96
+ /** Play one action on a layer, crossfading the layer's others out. */
97
+ set(action: string, options: LayerOptions = {}): string | null {
98
+ const layer = this.#layer(options);
99
+ if (typeof layer === 'string') return layer;
100
+ const held = layer.entries.get(action);
101
+ if (held && options.restart) held.action?.reset();
102
+ this.#weigh(layer, { [action]: 1 }, options.fade ?? 0.2, options);
103
+ return null;
104
+ }
105
+
106
+ /** Set a layer's actions at weights; an action left out fades to nothing. */
107
+ blend(weights: Readonly<Record<string, number>>, options: LayerOptions = {}): string | null {
108
+ const layer = this.#layer(options);
109
+ if (typeof layer === 'string') return layer;
110
+ this.#weigh(layer, weights, options.fade ?? 0, options);
111
+ return null;
112
+ }
113
+
114
+ /** Fade out what a layer plays (the base: everything the character plays). */
115
+ stop(options: LayerOptions = {}): void {
116
+ const layer = this.#layers.get(options.layer ?? 'base');
117
+ if (layer) this.#weigh(layer, {}, options.fade ?? 0.2, options);
118
+ }
119
+
120
+ /** The action that weighs most on a layer, as set (what a script asked for), or null. */
121
+ playing(layer = 'base'): string | null {
122
+ let best: Entry | null = null;
123
+ for (const entry of this.#layers.get(layer)?.entries.values() ?? []) if (entry.target > 0 && (!best || entry.target > best.target)) best = entry;
124
+ return best?.name ?? null;
125
+ }
126
+
127
+ /** Turn `bone` toward `target` (world point or object) every update until set to null. */
128
+ lookAt(bone: string, target: THREE.Vector3 | THREE.Object3D | null, options: LookAtOptions = {}): string | null {
129
+ if (target === null) { this.#looks.delete(bone); return null; }
130
+ const found = this.bones.get(bone);
131
+ if (!found) return `no bone "${bone}"`;
132
+ this.#looks.set(bone, { bone: found, target, options: { ...options, axis: options.axis ?? 'forward' } });
133
+ return null;
134
+ }
135
+
136
+ update(dt: number): void {
137
+ for (const layer of this.#layers.values()) {
138
+ for (const [name, entry] of [...layer.entries]) {
139
+ if (!entry.action) entry.action = this.#action(layer, name, entry);
140
+ entry.weight = entry.rate <= 0 ? entry.target
141
+ : entry.weight < entry.target ? Math.min(entry.target, entry.weight + dt * entry.rate)
142
+ : Math.max(entry.target, entry.weight - dt * entry.rate);
143
+ if (entry.action) entry.action.setEffectiveWeight(entry.weight);
144
+ if (entry.weight === 0 && entry.target === 0) {
145
+ entry.action?.stop();
146
+ layer.entries.delete(name);
147
+ }
148
+ }
149
+ }
150
+ this.#mixer.update(dt);
151
+ for (const look of this.#looks.values()) this.#turn(look.bone, look.target, look.options);
152
+ }
153
+
154
+ dispose(): void {
155
+ this.#mixer.stopAllAction();
156
+ this.#looks.clear();
157
+ }
158
+
159
+ // ---------------------------------------------------------------- internals
160
+
161
+ #layer(options: LayerOptions): Layer | string {
162
+ const name = options.layer ?? 'base';
163
+ if (name === 'base') return this.#layers.get('base')!;
164
+ let layer = this.#layers.get(name);
165
+ if (!layer || (options.from !== undefined && options.from !== layer.from)) {
166
+ if (!options.from) return `layer "${name}" needs the bone it plays from (\`from\`)`;
167
+ const root = this.bones.get(options.from);
168
+ if (!root) return `no bone "${options.from}" for layer "${name}"`;
169
+ const bones = new Set<string>();
170
+ root.traverse((child) => { if ((child as THREE.Bone).isBone) bones.add(child.uuid); });
171
+ if (layer) for (const entry of layer.entries.values()) entry.action?.stop();
172
+ layer = { name, from: options.from, bones, entries: layer?.entries ?? new Map() };
173
+ for (const entry of layer.entries.values()) entry.action = null;
174
+ this.#layers.set(name, layer);
175
+ // The base gives up these bones: its actions are rebuilt over what it still holds.
176
+ for (const entry of this.#layers.get('base')!.entries.values()) { entry.action?.stop(); entry.action = null; }
177
+ }
178
+ return layer;
179
+ }
180
+
181
+ #weigh(layer: Layer, weights: Readonly<Record<string, number>>, fade: number, options: LayerOptions): void {
182
+ const rate = fade > 0 ? 1 / fade : 0;
183
+ for (const entry of layer.entries.values()) { entry.target = 0; entry.rate = rate; }
184
+ for (const [name, raw] of Object.entries(weights)) {
185
+ const target = Math.max(0, Math.min(1, raw));
186
+ const entry = layer.entries.get(name) ?? { name, action: null, weight: 0, target: 0, rate, loop: true, speed: 1 };
187
+ entry.target = target;
188
+ entry.rate = rate;
189
+ entry.loop = options.loop ?? entry.loop;
190
+ entry.speed = options.speed ?? entry.speed;
191
+ if (entry.action) this.#configure(entry.action, entry);
192
+ layer.entries.set(name, entry);
193
+ }
194
+ }
195
+
196
+ /** The action of `name` on `layer`: its clip limited to the layer's bones. */
197
+ #action(layer: Layer, name: string, entry: Entry): THREE.AnimationAction | null {
198
+ const full = this.clipFor(name);
199
+ if (!full) return null;
200
+ const allowed = layer.bones ?? this.#baseBones();
201
+ const key = `${name}\u0000${layer.name}\u0000${allowed.size}`;
202
+ let clip = this.#masked.get(key);
203
+ if (!clip) {
204
+ const tracks = full.tracks.filter((track) => allowed.has(track.name.slice(0, track.name.indexOf('.'))));
205
+ clip = new THREE.AnimationClip(`${name}@${layer.name}`, full.duration, tracks);
206
+ this.#masked.set(key, clip);
207
+ }
208
+ const action = this.#mixer.clipAction(clip);
209
+ this.#configure(action, entry);
210
+ action.setEffectiveWeight(entry.weight);
211
+ action.play();
212
+ return action;
213
+ }
214
+
215
+ #configure(action: THREE.AnimationAction, entry: Entry): void {
216
+ action.setLoop(entry.loop ? THREE.LoopRepeat : THREE.LoopOnce, Number.POSITIVE_INFINITY);
217
+ action.clampWhenFinished = !entry.loop;
218
+ action.setEffectiveTimeScale(entry.speed);
219
+ }
220
+
221
+ /** Every bone no named layer holds. */
222
+ #baseBones(): Set<string> {
223
+ const taken = new Set<string>();
224
+ for (const layer of this.#layers.values()) for (const uuid of layer.bones ?? []) taken.add(uuid);
225
+ const bones = new Set<string>();
226
+ for (const bone of this.bones.values()) if (!taken.has(bone.uuid)) bones.add(bone.uuid);
227
+ return bones;
228
+ }
229
+
230
+ #turn(bone: THREE.Bone, target: THREE.Vector3 | THREE.Object3D, options: LookAtOptions): void {
231
+ const weight = Math.max(0, Math.min(1, options.weight ?? 1));
232
+ if (weight === 0) return;
233
+ bone.updateWorldMatrix(true, false);
234
+ // A script's Vector3 may come from its own copy of three, so it is recognised by its flag.
235
+ const at = (target as THREE.Vector3).isVector3 ? new THREE.Vector3().copy(target as THREE.Vector3) : (target as THREE.Object3D).getWorldPosition(new THREE.Vector3());
236
+ const position = new THREE.Vector3();
237
+ const world = new THREE.Quaternion();
238
+ bone.matrixWorld.decompose(position, world, new THREE.Vector3());
239
+ const desired = at.sub(position);
240
+ if (desired.lengthSq() < 1e-10) return;
241
+ desired.normalize();
242
+ const axis = options.axis ?? 'forward';
243
+ const local = axis === 'forward' ? this.#forward.get(bone.name) ?? AXES.y : AXES[axis];
244
+ const current = local.clone().applyQuaternion(world).normalize();
245
+ const turn = new THREE.Quaternion().setFromUnitVectors(current, desired);
246
+ const limit = THREE.MathUtils.degToRad(options.limit ?? 75);
247
+ const angle = 2 * Math.acos(Math.min(1, Math.abs(turn.w)));
248
+ const amount = angle > limit ? (limit / angle) * weight : weight;
249
+ const applied = new THREE.Quaternion().slerp(turn, amount);
250
+ const next = applied.multiply(world);
251
+ const parent = new THREE.Quaternion();
252
+ bone.parent?.getWorldQuaternion(parent);
253
+ bone.quaternion.copy(parent.invert().multiply(next));
254
+ bone.updateMatrixWorld(true);
255
+ }
256
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * ANIMATION IN A GAME: actions played on the skeletons of the game's copy of the model.
3
+ *
4
+ * The copy binds its own skins (`BlenderRuntimeView.detach` keeps each skinned mesh's weights and
5
+ * the frame's armatures, and its view's `skeletons` builds and binds them exactly as the editing
6
+ * view does), so nothing here binds or reads a rig. What a game plays is Blender's: each armature
7
+ * starts on the action the file assigns it (`animation_data.action`), and a script sets another by
8
+ * name (`play.setAction`), as Blender's own assignment does. Any action in the file may be set; one
9
+ * that animates none of an armature's bones refuses, with the reason, once.
10
+ *
11
+ * A CLIP IS BAKED ONCE PER ACTION, through the clip door, the first time it is wanted. The copy's
12
+ * frame is the frame it was detached from, so its revisions hold for the run. Until a bake lands
13
+ * the armature keeps what it showed before (at first, the pose Blender exported).
14
+ *
15
+ * LAYERS, BLENDS AND LOOK-AT: each armature has a layered mixer (`blender-play-mixer.ts`), so a
16
+ * game plays actions on parts of a body, at weights, and turns a bone toward what only it knows.
17
+ *
18
+ * THE GAME'S CLOCK DRIVES IT: the runner advances the mixers by each update's `dt`
19
+ * (`@volter/editor-model-play`'s `play-script.ts`), so pause, step, speed and Restart hold for
20
+ * animation exactly as they do for the script.
21
+ */
22
+ import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
23
+ import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
24
+ import type { ArmatureRig } from '@volter/blender-engine/browser/three/blender-runtime-skeleton';
25
+ import * as THREE from 'three';
26
+ import { clipTracks } from './blender-runtime-skin';
27
+ import { LayeredMixer, type LayerOptions, type LookAtOptions } from './blender-play-mixer';
28
+
29
+ export type PlayAnimateOptions = LayerOptions;
30
+
31
+ type Answer = { ok: true; armature: string } | { ok: false; why: string };
32
+
33
+ /** The game's door to its characters' actions (`ModelPlayContext.setAction`, `blend`, `lookAt`). */
34
+ export interface PlayAnimation {
35
+ /** The actions in the file, which an object's armature may be set to play. */
36
+ clips(object: THREE.Object3D): readonly string[];
37
+ /** Play one action on a layer of an object's armature; the reason when it cannot. */
38
+ play(object: THREE.Object3D, clip: string, options?: PlayAnimateOptions): Answer;
39
+ /** Set a layer's actions at weights. */
40
+ blend(object: THREE.Object3D, weights: Readonly<Record<string, number>>, options?: PlayAnimateOptions): Answer;
41
+ /** Fade out what a layer plays. */
42
+ stop(object: THREE.Object3D, fade?: number, layer?: string): void;
43
+ /** The action that weighs most on a layer, as last set, or null. */
44
+ playing(object: THREE.Object3D, layer?: string): string | null;
45
+ /** Turn one of an object's bones toward a point (or object) every update, until `null`. */
46
+ lookAt(object: THREE.Object3D, bone: string, target: THREE.Vector3 | THREE.Object3D | null, options?: LookAtOptions): Answer;
47
+ update(dt: number): void;
48
+ readonly warnings: readonly string[];
49
+ dispose(): void;
50
+ }
51
+
52
+ interface Armature {
53
+ readonly rig: ArmatureRig;
54
+ readonly mixer: LayeredMixer;
55
+ readonly clips: Map<string, THREE.AnimationClip>;
56
+ readonly baking: Set<string>;
57
+ /** Actions with nothing this armature can play: asked once, never re-baked every update. */
58
+ readonly failed: Map<string, string>;
59
+ }
60
+
61
+ /** The copy's animation. `bake` reads one action of one armature through the clip door. */
62
+ export function playAnimation(view: BlenderRuntimeView, bake: (armature: string, action: string) => Promise<BlenderActionClip | null>): PlayAnimation {
63
+ const warnings: string[] = [];
64
+ const facts = view.animationFacts();
65
+ const actions = Object.keys(facts.actions);
66
+ const armatures = new Map<string, Armature>();
67
+ let disposed = false;
68
+
69
+ const refuse = (armature: Armature, clip: string, why: string): void => {
70
+ armature.failed.set(clip, why);
71
+ warnings.push(`${armature.rig.armature} / ${clip}: ${why}`);
72
+ };
73
+
74
+ /** The clip the mixer asks for: baked, or baked now (once) and given on a later update. */
75
+ const clipFor = (armature: Armature, clip: string): THREE.AnimationClip | null => {
76
+ const held = armature.clips.get(clip);
77
+ if (held || disposed || armature.failed.has(clip) || armature.baking.has(clip)) return held ?? null;
78
+ armature.baking.add(clip);
79
+ void bake(armature.rig.armature, clip).then((baked) => {
80
+ armature.baking.delete(clip);
81
+ const tracks = baked && baked.clipStart !== undefined && baked.clipEnd !== undefined
82
+ ? clipTracks(baked, armature.rig.bones, warnings) : [];
83
+ if (!baked || !tracks.length) {
84
+ refuse(armature, clip, baked?.reason ?? `it animates none of ${armature.rig.armature}'s bones`);
85
+ return;
86
+ }
87
+ const duration = baked.duration ?? (baked.clipEnd! - baked.clipStart!) / baked.fps;
88
+ armature.clips.set(clip, new THREE.AnimationClip(clip, duration, tracks));
89
+ }, (error: unknown) => {
90
+ armature.baking.delete(clip);
91
+ refuse(armature, clip, `it could not be read: ${error instanceof Error ? error.message : String(error)}`);
92
+ });
93
+ return null;
94
+ };
95
+
96
+ for (const rig of view.skeletons.rigs()) {
97
+ const armature: Armature = {
98
+ rig, clips: new Map(), baking: new Set(), failed: new Map(),
99
+ mixer: new LayeredMixer(rig.object, rig.bones, (clip) => clipFor(armature, clip)),
100
+ };
101
+ armatures.set(rig.armature, armature);
102
+ }
103
+
104
+ // EACH ARMATURE STARTS ON ITS ASSIGNED ACTION, as Blender's viewport plays it.
105
+ for (const armature of armatures.values()) {
106
+ const assigned = facts.armatures[armature.rig.armature]?.action;
107
+ if (assigned) armature.mixer.set(assigned, { fade: 0 });
108
+ }
109
+
110
+ /** The armature that animates `object`: its own, the one its skin is bound to, or the nearest
111
+ * armature among its ancestors and descendants (a character's root holds its armature). */
112
+ const own = (candidate: THREE.Object3D): Armature | null => {
113
+ for (const armature of armatures.values()) if (armature.rig.object === candidate) return armature;
114
+ const skin = (candidate as THREE.Mesh).geometry?.userData['blenderSkin'] as { armature: string } | undefined;
115
+ return skin ? armatures.get(skin.armature) ?? null : null;
116
+ };
117
+ const armatureOf = (object: THREE.Object3D): Armature | null => {
118
+ for (let at: THREE.Object3D | null = object; at; at = at.parent) {
119
+ const found = own(at);
120
+ if (found) return found;
121
+ }
122
+ let found: Armature | null = null;
123
+ object.traverse((child) => { if (!found) found = own(child); });
124
+ return found;
125
+ };
126
+
127
+ /** The armature, after checking every action named is one it can play. */
128
+ const resolve = (object: THREE.Object3D, clips: readonly string[]): Armature | { why: string } => {
129
+ const armature = armatureOf(object);
130
+ if (!armature) return { why: `${object.name || 'this object'} has no armature` };
131
+ for (const clip of clips) {
132
+ if (!(clip in facts.actions)) return { why: `the file has no action "${clip}"` };
133
+ const failed = armature.failed.get(clip);
134
+ if (failed) return { why: failed };
135
+ }
136
+ return armature;
137
+ };
138
+ const answer = (armature: Armature, refused: string | null): Answer =>
139
+ refused ? { ok: false, why: refused } : { ok: true, armature: armature.rig.armature };
140
+
141
+ return {
142
+ warnings,
143
+ clips: (object) => (armatureOf(object) ? actions : []),
144
+ play(object, clip, options = {}) {
145
+ const armature = resolve(object, [clip]);
146
+ return 'why' in armature ? { ok: false, why: armature.why } : answer(armature, armature.mixer.set(clip, options));
147
+ },
148
+ blend(object, weights, options = {}) {
149
+ const armature = resolve(object, Object.keys(weights));
150
+ return 'why' in armature ? { ok: false, why: armature.why } : answer(armature, armature.mixer.blend(weights, options));
151
+ },
152
+ stop(object, fade = 0.2, layer) {
153
+ armatureOf(object)?.mixer.stop({ fade, ...(layer ? { layer } : {}) });
154
+ },
155
+ // What was last set, so a script setting the same action every update while it bakes asks once.
156
+ playing: (object, layer) => armatureOf(object)?.mixer.playing(layer) ?? null,
157
+ lookAt(object, bone, target, options = {}) {
158
+ const armature = armatureOf(object);
159
+ if (!armature) return { ok: false, why: `${object.name || 'this object'} has no armature` };
160
+ return answer(armature, armature.mixer.lookAt(bone, target, options));
161
+ },
162
+ update(dt) { for (const armature of armatures.values()) armature.mixer.update(dt); },
163
+ dispose() {
164
+ disposed = true;
165
+ for (const armature of armatures.values()) armature.mixer.dispose();
166
+ },
167
+ };
168
+ }
@@ -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,48 +59,31 @@ 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
- 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('|');
62
+ /**
63
+ * A BLENDER ACTION'S BAKED TRACKS as three.js keyframe tracks over `bones` (by Blender's bone
64
+ * name), addressed by bone UUID because three's track-name grammar splits on `.` and `hand.L`
65
+ * is an ordinary bone name. A track for a bone this binding lacks is named in `warnings`.
66
+ */
67
+ export function clipTracks(clip: BlenderActionClip, bones: ReadonlyMap<string, THREE.Bone>, warnings: string[]): THREE.KeyframeTrack[] {
68
+ const tracks: THREE.KeyframeTrack[] = [];
69
+ const missing = new Set<string>();
70
+ for (const track of clip.tracks) {
71
+ const bone = bones.get(track.bone);
72
+ if (!bone) {
73
+ missing.add(track.bone);
74
+ continue;
75
+ }
76
+ const times = float32Of(track.timeBase64);
77
+ const values = float32Of(track.valueBase64);
78
+ const path = `${bone.uuid}.${track.property}`;
79
+ tracks.push(
80
+ track.property === 'quaternion'
81
+ ? new THREE.QuaternionKeyframeTrack(path, Array.from(times), Array.from(values))
82
+ : new THREE.VectorKeyframeTrack(path, Array.from(times), Array.from(values)),
83
+ );
84
+ }
85
+ if (missing.size) warnings.push(`${clip.action} animates ${[...missing].join(', ')}, which this binding has no bone for.`);
86
+ return tracks;
104
87
  }
105
88
 
106
89
  /**
@@ -111,7 +94,12 @@ function rigSignature(rig: BlenderRigBinding, frame: number): string {
111
94
  * copy of the playback state anywhere.
112
95
  */
113
96
  export class BlenderSkinDirector {
114
- #rigs = new Map<string, BoundRig>();
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;
115
103
  #mixer: THREE.AnimationMixer | null = null;
116
104
  #action: THREE.AnimationAction | null = null;
117
105
  #clip: BlenderActionClip | null = null;
@@ -194,13 +182,14 @@ export class BlenderSkinDirector {
194
182
  action: clip?.action ?? null,
195
183
  object: clip?.object ?? null,
196
184
  armature: clip?.armature ?? null,
197
- 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,
198
186
  keyframes: clip?.keyframes ?? [],
199
187
  summary: clip?.summary ?? [],
200
188
  clipStart: clip?.clipStart ?? null,
201
189
  clipEnd: clip?.clipEnd ?? null,
202
190
  tracks: clip?.tracks.length ?? 0,
203
- 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] : [],
204
193
  warnings: this.#warnings,
205
194
  engineCalls: this.#engineCalls,
206
195
  blenderFrame: clip?.frameCurrent ?? null,
@@ -239,229 +228,77 @@ export class BlenderSkinDirector {
239
228
  return this.#clip !== null && this.#transport !== null;
240
229
  }
241
230
 
242
- // ------------------------------------------------------------ the binding
231
+ // ------------------------------------------------------------ the frame
243
232
 
244
233
  /**
245
- * Bind every rigged mesh the frame carries, and load the active action.
246
- *
247
- * IDEMPOTENT BY SIGNATURE: a rig whose mesh, armature, vertex count, bone
248
- * count and export frame are unchanged is left exactly as it is, mixer time
249
- * included. That is what lets the presenter call this after EVERY frame
250
- * without the picture jumping back to the bind pose each time something
251
- * 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.
252
239
  */
253
- async bind(
254
- presentation: SkinPresentation,
255
- read: {
256
- rig(): Promise<BlenderRig | null>;
257
- clip(): Promise<BlenderActionClip | null>;
258
- },
259
- ): Promise<void> {
260
- this.#engineCalls++;
261
- const answer = await read.rig();
262
- 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;
263
257
  const warnings: string[] = [];
264
- const wanted = new Set<string>();
265
- let changed = false;
266
- for (const rig of answer.rigs) {
267
- if (!rig.object || !rig.armature || !rig.skinIndexBase64 || !rig.skinWeightBase64) {
268
- if (rig.reason) warnings.push(rig.reason);
269
- continue;
270
- }
271
- wanted.add(rig.object);
272
- const signature = rigSignature(rig, answer.frame);
273
- if (this.#rigs.get(rig.object)?.signature === signature) continue;
274
- const bound = this.#bindOne(presentation, rig, answer.frame, warnings);
275
- if (bound) {
276
- this.#rigs.get(rig.object)?.skeleton.dispose();
277
- this.#rigs.set(rig.object, bound);
278
- changed = true;
279
- }
258
+ let clip: BlenderActionClip | null = null;
259
+ if (armature) {
260
+ this.#engineCalls++;
261
+ clip = await read.clip(armature);
280
262
  }
281
- for (const [name, rig] of [...this.#rigs])
282
- if (!wanted.has(name)) {
283
- rig.skeleton.dispose();
284
- rig.boneRoot.removeFromParent();
285
- this.#rigs.delete(name);
286
- changed = true;
287
- }
288
- this.#engineCalls++;
289
- const clip = await read.clip();
290
263
  const previous = this.#clip;
291
264
  const rangeChanged = clip?.frameStart !== previous?.frameStart ||
292
265
  clip?.frameEnd !== previous?.frameEnd || clip?.fps !== previous?.fps;
293
- const movedAction =
294
- clip?.action !== previous?.action ||
295
- clip?.tracks.length !== previous?.tracks.length ||
296
- clip?.clipStart !== previous?.clipStart ||
297
- clip?.clipEnd !== previous?.clipEnd;
298
266
  this.#clip = clip ?? null;
299
267
  if (!clip || clip.scene !== previous?.scene) this.#seeked = null;
300
268
  if (clip?.reason) warnings.push(clip.reason);
301
- if (clip && (movedAction || changed)) this.#loadClip(presentation, clip, warnings);
302
- // The subject can attach before the asynchronous scene read arrives.
303
- // 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
+ }
304
275
  const activeSubject = this.#transport?.snapshot().activeSubject;
305
276
  if (rangeChanged && activeSubject) this.#transport?.setActiveSubject(activeSubject);
306
- // BLENDER'S OWN FRAME RE-SYNCS THE PLAYHEAD. An agent that set
307
- // `scene.frame_current` in bpy has said where it wants to be, and a
308
- // present is how we hear about it; while the transport is PLAYING it would
309
- // be the Timeline arguing with itself, so it is honoured only at rest.
310
277
  const playing = this.#transport?.snapshot().playbackState === 'playing';
311
- if (clip && !playing && (rangeChanged || previous?.frameCurrent !== clip.frameCurrent)) {
278
+ if (clip && !playing) {
312
279
  this.#seek(clip.frameCurrent);
313
- // THE BOOKMARK READ, once per bind: the file says where it was left, and
314
- // the transport is what everything else now asks.
315
280
  if (this.#transport && clip.fps > 0) this.#transport.seek(clip.frameCurrent / clip.fps);
316
281
  }
317
282
  this.#warnings = warnings;
318
283
  this.#publish();
319
284
  }
320
285
 
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
- };
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;
431
290
  }
432
291
 
433
- #loadClip(presentation: SkinPresentation, clip: BlenderActionClip, warnings: string[]): void {
292
+ #loadClip(clip: BlenderActionClip, warnings: string[]): void {
434
293
  this.#mixer?.stopAllAction();
435
294
  this.#mixer = null;
436
295
  this.#action = null;
437
296
  if (!clip.tracks.length || clip.clipStart === undefined || clip.clipEnd === undefined) return;
438
- const armature = clip.armature ? presentation.objectForBlenderName(clip.armature) : null;
439
- if (!armature) return;
440
- const byName = new Map<string, THREE.Bone>();
441
- for (const rig of this.#rigs.values())
442
- 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
- );
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;
301
+ const tracks = clipTracks(clip, byName, warnings);
465
302
  if (!tracks.length) return;
466
303
  const duration = clip.duration ?? (clip.clipEnd - clip.clipStart) / clip.fps;
467
304
  const mixer = new THREE.AnimationMixer(armature);
@@ -506,10 +343,8 @@ export class BlenderSkinDirector {
506
343
  * render reads them. A `Bone` keeps `matrixAutoUpdate`, so this is a forced
507
344
  * pass over a handful of nodes rather than a recomposition of the scene. */
508
345
  #refresh(): void {
509
- for (const rig of this.#rigs.values()) {
510
- rig.boneRoot.updateMatrixWorld(true);
511
- rig.skeleton.update();
512
- }
346
+ const rig = this.#armature ? this.#view?.skeletons.rig(this.#armature) : null;
347
+ rig?.object.updateMatrixWorld(true);
513
348
  }
514
349
 
515
350
  /** The scene's name, for the one address the Timeline writes to. */
@@ -617,11 +452,9 @@ export class BlenderSkinDirector {
617
452
  this.#action = null;
618
453
  this.#clip = null;
619
454
  this.#seeked = null;
620
- for (const rig of this.#rigs.values()) {
621
- rig.skeleton.dispose();
622
- rig.boneRoot.removeFromParent();
623
- }
624
- this.#rigs.clear();
455
+ if (this.#armature) this.#view?.skeletons.restorePose(this.#armature);
456
+ this.#armature = null;
457
+ this.#clipKey = null;
625
458
  }
626
459
  }
627
460
 
@@ -70,10 +70,13 @@ import {
70
70
  openModelDocumentBlend,
71
71
  modelDocumentMayOpen,
72
72
  modelDocumentOwnsPresentation,
73
+ blenderActionClip,
74
+ blenderRnaVersion,
73
75
  } from '../host/blender-runtime-host';
74
76
  import { BlenderObjectModeHeader } from './blender-header-menus';
75
77
  import { blenderOutlinerAuthoringFor, createBlenderOutlinerAuthoring } from './blender-outliner-authoring';
76
78
  import { blenderSkin } from './blender-runtime-skin';
79
+ import { playAnimation, type PlayAnimation } from './blender-play-skin';
77
80
  import { areaSplit, subscribeAreaSplit } from '../src/area-split';
78
81
  import { noteModelDocument } from '../src/play-mode';
79
82
  import { documentPlayExtension, subscribeDocumentPlayExtensions } from '@volter/editor-sdk/kit/document-play-extension';
@@ -897,6 +900,7 @@ function BlenderViewportArea({
897
900
  let preparing = false;
898
901
  let stopScript: (() => void) | null = null;
899
902
  let orbit: { enabled: boolean } | null = null;
903
+ let animation: PlayAnimation | null = null;
900
904
  const start = (): void => {
901
905
  if (stopScript || preparing || stopped) return;
902
906
  const stage = viewportStages().find((one) => one.documentId === documentId);
@@ -904,9 +908,14 @@ function BlenderViewportArea({
904
908
  preparing = true;
905
909
  orbit = stage.rig().orbit;
906
910
  orbit.enabled = false;
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.
907
914
  void view.prepareRendered(stage.rig().drawCamera(), 'render').then(() => {
908
915
  if (stopped) return;
916
+ animation = playAnimation(view, (armature, action) => blenderActionClip({ object: armature, action }));
909
917
  stopScript = documentPlayExtension('model')?.run({
918
+ ...(animation ? { animation } : {}),
910
919
  documentId: modelId,
911
920
  container: layers,
912
921
  ready: () => { if (!stopped) { setPlayReady(true); playReadyRef.current?.(); } },
@@ -964,6 +973,8 @@ function BlenderViewportArea({
964
973
  stopPrepareFrames?.();
965
974
  window.removeEventListener('keydown', onEscape, true);
966
975
  stopScript?.();
976
+ animation?.dispose();
977
+ animation = null;
967
978
  layers.remove();
968
979
  if (orbit) orbit.enabled = true;
969
980
  };
@@ -1035,6 +1046,20 @@ function BlenderViewportArea({
1035
1046
  for (const { kind } of groups) stage?.setHelper(kind, null);
1036
1047
  };
1037
1048
  }, [documentId, view]);
1049
+ /**
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.
1053
+ */
1054
+ const rnaVersion = useSyncExternalStore(subscribeBlenderRna, blenderRnaVersion, blenderRnaVersion);
1055
+ useEffect(() => {
1056
+ if (!documentId || !main) return;
1057
+ let live = true;
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; };
1062
+ }, [documentId, main, view, rnaVersion]);
1038
1063
  /**
1039
1064
  * ATTACH THE SKIN TO THIS STAGE'S TRANSPORT.
1040
1065
  *
@@ -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;
@@ -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
@@ -677,11 +667,14 @@ export async function blenderRig(options?: { object?: string }): Promise<Blender
677
667
  export async function blenderActionClip(options?: {
678
668
  object?: string;
679
669
  bake?: boolean;
670
+ /** A named action baked against `object`'s armature, assigned or not. */
671
+ action?: string;
680
672
  }): Promise<BlenderActionClip | null> {
681
673
  if (!blenderSessionStarted()) return null;
682
674
  return blenderRuntime().actionClip(options);
683
675
  }
684
676
 
677
+
685
678
  export async function blenderOutlinerSet(
686
679
  path: string,
687
680
  column: string,
@@ -1448,18 +1441,8 @@ export async function handleBlenderCommand(cmd: {
1448
1441
  }),
1449
1442
  },
1450
1443
  };
1451
- // THE RIG AND CLIP DOORS on the wire, beside the UV one: the skin
1452
- // binding and the action as three.js tracks, so a `cyclotron eval` reads
1453
- // exactly what the presenter bound and what the Timeline plays.
1454
- case 'blender-rig':
1455
- return {
1456
- ok: true,
1457
- data: {
1458
- result: await session.rig({
1459
- ...(typeof cmd['object'] === 'string' ? { object: cmd['object'] } : {}),
1460
- }),
1461
- },
1462
- };
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.
1463
1446
  case 'blender-action-clip':
1464
1447
  return {
1465
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.134",
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.132",
76
- "@volter/editor-threejs": "0.5.195",
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.195",
82
- "@volter/editor-project": "0.5.195",
83
- "@volter/editor-sdk": "0.5.195",
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",