@volter/editor-blender 0.1.136 → 0.1.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * THE SKIN AND THE CLIP — three.js PLAYS Blender's animation; Blender holds it
2
+ * THE TIMELINE'S ANIMATION — three.js PLAYS Blender's animation; Blender holds it
3
3
  * as DATA (owner rule, 2026-09-20: "we visualize with three.js, not Blender").
4
4
  *
5
5
  * ## Why this exists at all
@@ -11,11 +11,12 @@
11
11
  * times a second under every bpy reader in the session. So the skin travels in the
12
12
  * frame itself: the export door ships each Armature-deformed mesh's weights per
13
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.
14
+ * rest, channels, constraints and pose and each armature's NLA stack, and the presenter
15
+ * builds the skeletons (`blender-runtime-skeleton.ts`). This director poses EVERY armature
16
+ * at the scene frame with Blender's own evaluation (`blender-pose.ts`): its NLA tracks and
17
+ * active action, blended as Blender blends them, and its Damped Track constraints. Every scrub
18
+ * and every played frame costs ZERO calls into Blender; an action is baked again exactly when
19
+ * the frame says its revision moved.
19
20
  *
20
21
  * ## The bind pose is the EXPORT pose, and that is the whole trick
21
22
  *
@@ -27,63 +28,60 @@
27
28
  * own evaluation of the same skin over the same columns. Nothing has to move
28
29
  * Blender's frame, ever.
29
30
  *
30
- * ## What that costs, stated rather than implied
31
+ * ## The fidelity check, and what is not played
31
32
  *
32
- * While a clip plays, the picture is three.js's skinning of the EXPORT-FRAME
33
- * mesh. Anything else Blender's depsgraph would do per frame — shape keys, a
34
- * Displace or Cast or Cloth modifier reading the frame, a driver on geometry —
35
- * does NOT follow the mixer. The Timeline's status line names any such
36
- * modifier on the played object rather than showing a confident picture of the
37
- * wrong thing (`modifiersNotPlayed`). A bone whose pose is a CONSTRAINT's
38
- * rather than its channels' is named the same way, because the clip is derived
39
- * from the F-Curves alone.
33
+ * At Blender's own frame, standing still, the picture IS Blender's pose (the frame's), and the
34
+ * evaluator's answer for that frame is compared with it bone by bone (`poseDivergence`). A bone
35
+ * further off than a tenth of a degree is said in the console with what would explain it, so a
36
+ * difference between this Timeline (and a game, which uses the same evaluator) and Blender is
37
+ * never silent. What the evaluator does not play — a constraint other than Damped Track, a
38
+ * driver, non-default parenting — is named once as well. Anything Blender's depsgraph does to
39
+ * GEOMETRY per frame (shape keys, a Displace or Cloth modifier) does not follow the bones.
40
40
  */
41
41
 
42
42
  import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
43
+ import type { BlenderArmature } from '@volter/blender-engine/browser/three/blender-runtime-armature';
43
44
  import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
44
- import type { StageTransportHandle } from '@volter/editor-sdk/host';
45
- import * as THREE from 'three';
45
+ import { editorHost, type StageTransportHandle } from '@volter/editor-sdk/host';
46
46
  import { blenderRnaSet } from '../host/blender-runtime-host';
47
+ import { actionLayer, ArmaturePose, nlaLayers, poseClip, poseDivergence, type PoseClip, type PoseLayer } from './blender-pose';
47
48
 
48
- /** Base64 → the typed array it holds. The same three lines every payload in
49
- * this package decodes with (`blender-uv-geometry.ts`'s `uvBytes`); kept
50
- * local because this module is in the presenter's closure and that one is in
51
- * the UV view's. */
52
- function bytesOf(base64: string): Uint8Array {
53
- const binary = atob(base64);
54
- const out = new Uint8Array(binary.length);
55
- for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
56
- return out;
49
+ /** How the director reads the engine: the Timeline's subject (its keys, the scene's range) and
50
+ * one action baked for one armature. */
51
+ export interface SkinReader {
52
+ clip(armature: string): Promise<BlenderActionClip | null>;
53
+ bake(armature: string, action: string): Promise<BlenderActionClip | null>;
57
54
  }
58
- function float32Of(base64: string): Float32Array {
59
- const bytes = bytesOf(base64);
60
- return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 2);
61
- }
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
- );
55
+
56
+ /** An armature's layers at a scene frame: its NLA, then its active action over them. */
57
+ export function sceneLayers(armature: BlenderArmature, frame: number,
58
+ clips: (action: string) => PoseClip | null | undefined): { layers: PoseLayer[]; waiting: boolean; skipped: string[] } {
59
+ const stack = nlaLayers(armature.animation, frame, clips);
60
+ const action = armature.action ?? null;
61
+ if (action) {
62
+ const clip = clips(action);
63
+ if (clip === undefined) stack.waiting = true;
64
+ const layer = clip ? actionLayer(armature.animation, clip, frame, stack.evaluated) : null;
65
+ if (layer) stack.layers.push(layer);
84
66
  }
85
- if (missing.size) warnings.push(`${clip.action} animates ${[...missing].join(', ')}, which this binding has no bone for.`);
86
- return tracks;
67
+ return stack;
68
+ }
69
+
70
+ /** What to do about what plays only in Blender, by kind. */
71
+ export function remedy(unsupported: readonly string[]): string {
72
+ const out: string[] = [];
73
+ if (unsupported.some((thing) => thing.includes(' constraint ') || thing.includes('drivers')))
74
+ out.push('Bake a constraint or driver into the action (Pose ▸ Animation ▸ Bake Action, Visual Keying), or aim with a Damped Track.');
75
+ if (unsupported.some((thing) => thing.includes('curves on ')))
76
+ out.push("Key the motion on a bone (the root bone) instead of the armature object, or move the object from the game.");
77
+ if (unsupported.some((thing) => thing.includes('interpolation') || thing.includes(' modifier')))
78
+ out.push('Use Bezier, Linear or Constant keys and the Cycles modifier only, or bake the curves (Key ▸ Bake Keyframes).');
79
+ return out.length ? ` ${out.join(' ')}` : '';
80
+ }
81
+
82
+ /** True when an armature has anything to evaluate: an action, or NLA strips. */
83
+ export function animated(armature: BlenderArmature): boolean {
84
+ return !!armature.action || !!armature.animation?.tracks.some((track) => track.strips.length);
87
85
  }
88
86
 
89
87
  /**
@@ -94,15 +92,17 @@ export function clipTracks(clip: BlenderActionClip, bones: ReadonlyMap<string, T
94
92
  * copy of the playback state anywhere.
95
93
  */
96
94
  export class BlenderSkinDirector {
97
- /** The presented view whose skeletons the clip plays on (`sync`). */
95
+ /** The presented view whose skeletons are posed (`sync`). */
98
96
  #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. */
97
+ #reader: SkinReader | null = null;
98
+ /** The Timeline's subject: the armature its keys and header describe, and what its header was
99
+ * read for. Read again only when that changes. */
101
100
  #armature: string | null = null;
102
101
  #clipKey: string | null = null;
103
- #mixer: THREE.AnimationMixer | null = null;
104
- #action: THREE.AnimationAction | null = null;
105
102
  #clip: BlenderActionClip | null = null;
103
+ /** Each posed armature's evaluator, and every action baked for it, at the revision baked. */
104
+ #poses = new Map<string, ArmaturePose>();
105
+ #baked = new Map<string, { revision: number | null; clip: PoseClip | null | undefined }>();
106
106
  /** The frame the last SEEK asked for — see {@link frame}. */
107
107
  #seeked: number | null = null;
108
108
  /** The stage transport this skin is attached to, or null before `attachTo`.
@@ -112,6 +112,8 @@ export class BlenderSkinDirector {
112
112
  #listeners = new Set<() => void>();
113
113
  #version = 0;
114
114
  #warnings: string[] = [];
115
+ /** What was already said in the console, so each finding is said once. */
116
+ #said = new Set<string>();
115
117
  /** Engine calls this director has made, counted so a walk can prove that a
116
118
  * scrub costs none (the acceptance the owner named). */
117
119
  #engineCalls = 0;
@@ -134,9 +136,8 @@ export class BlenderSkinDirector {
134
136
  for (const listener of [...this.#listeners]) listener();
135
137
  }
136
138
 
137
- /** The clip's own facts plus the mixer's live time, expressed in BLENDER
138
- * FRAMES — which is the only number the Timeline draws, and the only one a
139
- * person or an agent ever names. */
139
+ /** The subject's facts plus the playhead, expressed in BLENDER FRAMES — which is the only
140
+ * number the Timeline draws, and the only one a person or an agent ever names. */
140
141
  state(): {
141
142
  frame: number;
142
143
  start: number;
@@ -148,9 +149,8 @@ export class BlenderSkinDirector {
148
149
  bones: number;
149
150
  keyframes: readonly { frame: number; type: string; select: boolean }[];
150
151
  /** Every animated object's own columns plus its SELECTION — what the
151
- * summary row's `show_only_selected` filter chooses between. The mixer
152
- * never reads it: what plays is the bound action, filter or no filter,
153
- * exactly as in Blender. */
152
+ * summary row's `show_only_selected` filter chooses between. What plays
153
+ * is every armature's stack, filter or no filter, exactly as in Blender. */
154
154
  summary: readonly {
155
155
  object: string;
156
156
  action: string;
@@ -165,20 +165,17 @@ export class BlenderSkinDirector {
165
165
  engineCalls: number;
166
166
  /** BLENDER'S OWN `scene.frame_current`, as the clip door last read it —
167
167
  * which is a different number from `frame` on purpose. `frame` is where
168
- * the person is looking (the mixer's); this is where bpy and the
169
- * Properties rail are. They agree after a pause or a scrub-end, and they
170
- * are deliberately allowed to differ in between. */
168
+ * the person is looking; this is where bpy and the Properties rail are.
169
+ * They agree after a pause or a scrub-end, and they are deliberately
170
+ * allowed to differ in between. */
171
171
  blenderFrame: number | null;
172
172
  } {
173
173
  const clip = this.#clip;
174
- const start = clip?.frameStart ?? 1;
175
- const end = clip?.frameEnd ?? 250;
176
- const fps = clip?.fps ?? 24;
177
174
  return {
178
175
  frame: this.frame(),
179
- start,
180
- end,
181
- fps,
176
+ start: clip?.frameStart ?? 1,
177
+ end: clip?.frameEnd ?? 250,
178
+ fps: clip?.fps ?? 24,
182
179
  action: clip?.action ?? null,
183
180
  object: clip?.object ?? null,
184
181
  armature: clip?.armature ?? null,
@@ -188,8 +185,7 @@ export class BlenderSkinDirector {
188
185
  clipStart: clip?.clipStart ?? null,
189
186
  clipEnd: clip?.clipEnd ?? null,
190
187
  tracks: clip?.tracks.length ?? 0,
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] : [],
188
+ bound: [...this.#poses.keys()],
193
189
  warnings: this.#warnings,
194
190
  engineCalls: this.#engineCalls,
195
191
  blenderFrame: clip?.frameCurrent ?? null,
@@ -202,25 +198,9 @@ export class BlenderSkinDirector {
202
198
  return this.#clip ? this.frame() : null;
203
199
  }
204
200
 
205
- /** The mixer's time as a Blender frame. With no clip the scene's own current
206
- * frame stands, which is Blender's answer for a file with no animation. */
201
+ /** The frame on screen: the last seek's, else Blender's own. */
207
202
  frame(): number {
208
- const clip = this.#clip;
209
- if (this.#seeked !== null) return this.#seeked;
210
- if (!clip || !this.#action || clip.clipStart === undefined) return clip?.frameCurrent ?? 1;
211
- // A SEEK'S OWN ANSWER, while one is standing. `LoopRepeat` wraps
212
- // `action.time` into `[0, duration)`, so a seek to the LAST frame lands on
213
- // `time === duration` and reads back as the FIRST — measured on the first
214
- // walk, where `jump-end` answered frame 1 over a frame-48 pose. The
215
- // wrapping is right for playback and wrong for a question, so a standing
216
- // seek answers with the frame it was given and the play tick clears it.
217
- // THE ACTION'S TIME, NOT THE MIXER'S, and the difference is the whole of
218
- // looping: `AnimationMixer.time` is monotonic and never wraps, while
219
- // `AnimationAction.time` is wrapped into `[0, duration]` by `LoopRepeat`.
220
- // Measured on the first walk: two seconds of playback over a 47-frame clip
221
- // read as frame 63.5 off the mixer, where the picture was correctly back
222
- // near the start.
223
- return clip.clipStart + this.#action.time * clip.fps;
203
+ return this.#seeked ?? this.#clip?.frameCurrent ?? 1;
224
204
  }
225
205
 
226
206
  /** Blender's scene clock exists even when the scene has no action. */
@@ -231,14 +211,15 @@ export class BlenderSkinDirector {
231
211
  // ------------------------------------------------------------ the frame
232
212
 
233
213
  /**
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.
214
+ * FOLLOW THE PRESENTED FRAME. Every armature the frame names is posed; the Timeline's subject is
215
+ * the active object's armature (itself, or the one its skin is bound to), else the one armature
216
+ * that has an action, and its header is read through the clip door when the frame says
217
+ * something about it moved. Actions are baked once per revision, the first time a frame needs
218
+ * them. Otherwise this costs nothing, which is what lets the model call it after every present.
239
219
  */
240
- async sync(view: BlenderRuntimeView, read: { clip(armature: string): Promise<BlenderActionClip | null> }): Promise<void> {
220
+ async sync(view: BlenderRuntimeView, read: SkinReader): Promise<void> {
241
221
  this.#view = view;
222
+ this.#reader = read;
242
223
  const facts = view.animationFacts();
243
224
  const names = Object.keys(facts.armatures);
244
225
  const active = facts.active;
@@ -246,79 +227,138 @@ export class BlenderSkinDirector {
246
227
  : (active ? facts.skinArmature(active) : null)
247
228
  ?? (names.filter((name) => facts.armatures[name]!.action).length === 1
248
229
  ? 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;
257
- const warnings: string[] = [];
258
- let clip: BlenderActionClip | null = null;
259
- if (armature) {
260
- this.#engineCalls++;
261
- clip = await read.clip(armature);
230
+ // EVERY ARMATURE'S EVALUATOR, over the rig the presenter holds for it now.
231
+ for (const [name, pose] of [...this.#poses])
232
+ if (!facts.armatures[name] || view.skeletons.rig(name) !== pose.rig) this.#poses.delete(name);
233
+ for (const [name, entry] of Object.entries(facts.armatures)) {
234
+ const rig = view.skeletons.rig(name);
235
+ if (!rig || !animated(entry)) {
236
+ this.#poses.delete(name);
237
+ continue;
238
+ }
239
+ let pose = this.#poses.get(name);
240
+ if (!pose) this.#poses.set(name, pose = new ArmaturePose(rig));
241
+ pose.facts(entry);
242
+ }
243
+ // A BAKE IS GOOD FOR ITS ACTION'S REVISION; a new revision bakes it again when next needed.
244
+ for (const [key, entry] of [...this.#baked]) {
245
+ const action = key.slice(key.indexOf('\u0000') + 1);
246
+ if ((facts.actions[action] ?? null) !== entry.revision) this.#baked.delete(key);
262
247
  }
263
- const previous = this.#clip;
264
- const rangeChanged = clip?.frameStart !== previous?.frameStart ||
265
- clip?.frameEnd !== previous?.frameEnd || clip?.fps !== previous?.fps;
266
- this.#clip = clip ?? null;
267
- if (!clip || clip.scene !== previous?.scene) this.#seeked = null;
268
- if (clip?.reason) warnings.push(clip.reason);
269
- if (clip) this.#loadClip(clip, warnings);
270
- else {
271
- this.#mixer?.stopAllAction();
272
- this.#mixer = null;
273
- this.#action = null;
248
+ const action = armature ? facts.armatures[armature]?.action ?? null : null;
249
+ const key = JSON.stringify([armature, action, action ? facts.actions[action] ?? null : null, facts.clock, facts.frame]);
250
+ if (key !== this.#clipKey) {
251
+ this.#clipKey = key;
252
+ this.#armature = armature;
253
+ let clip: BlenderActionClip | null = null;
254
+ if (armature) {
255
+ this.#engineCalls++;
256
+ clip = await read.clip(armature);
257
+ if (clip?.action && clip.armature) this.#baked.set(`${clip.armature}\u0000${clip.action}`, { revision: facts.actions[clip.action] ?? null, clip: poseClip(clip) });
258
+ }
259
+ const previous = this.#clip;
260
+ const rangeChanged = clip?.frameStart !== previous?.frameStart ||
261
+ clip?.frameEnd !== previous?.frameEnd || clip?.fps !== previous?.fps;
262
+ this.#clip = clip ?? null;
263
+ if (!clip || clip.scene !== previous?.scene) this.#seeked = null;
264
+ const activeSubject = this.#transport?.snapshot().activeSubject;
265
+ if (rangeChanged && activeSubject) this.#transport?.setActiveSubject(activeSubject);
266
+ const playing = this.#transport?.snapshot().playbackState === 'playing';
267
+ if (clip && !playing) {
268
+ this.#seeked = null;
269
+ if (this.#transport && clip.fps > 0) this.#transport.seek(clip.frameCurrent / clip.fps);
270
+ }
274
271
  }
275
- const activeSubject = this.#transport?.snapshot().activeSubject;
276
- if (rangeChanged && activeSubject) this.#transport?.setActiveSubject(activeSubject);
272
+ this.#pose();
273
+ this.#publish();
274
+ }
275
+
276
+ /** One action of one armature: baked, or being baked now (once), or null when it plays nothing. */
277
+ #clipOf(armature: string, action: string): PoseClip | null | undefined {
278
+ const key = `${armature}\u0000${action}`;
279
+ const held = this.#baked.get(key);
280
+ if (held) return held.clip;
281
+ const reader = this.#reader;
282
+ const revision = this.#view?.animationFacts().actions[action] ?? null;
283
+ if (!reader) return null;
284
+ const entry: { revision: number | null; clip: PoseClip | null | undefined } = { revision, clip: undefined };
285
+ this.#baked.set(key, entry);
286
+ this.#engineCalls++;
287
+ void reader.bake(armature, action).then((baked) => {
288
+ entry.clip = baked ? poseClip(baked) : null;
289
+ if (this.#baked.get(key) !== entry) return;
290
+ this.#pose();
291
+ this.#publish();
292
+ }, (error: unknown) => {
293
+ entry.clip = null;
294
+ this.#say(`bake:${key}`, `${armature} / ${action} could not be read: ${error instanceof Error ? error.message : String(error)}`);
295
+ });
296
+ return undefined;
297
+ }
298
+
299
+ /**
300
+ * POSE EVERY ARMATURE AT THE FRAME ON SCREEN. At Blender's own frame, standing still, the bones
301
+ * take Blender's pose (the frame's, exact) and the evaluator's answer is checked against it;
302
+ * anywhere else they take the evaluator's.
303
+ */
304
+ #pose(): void {
305
+ const view = this.#view;
306
+ if (!view) return;
307
+ const facts = view.animationFacts();
308
+ const frame = this.frame();
309
+ // BLENDER'S OWN FRAME is the one the presented pose was evaluated at, not the bookmark: a pause
310
+ // writes the bookmark first and the frame evaluated there arrives after.
311
+ const atBlender = frame === facts.frame;
277
312
  const playing = this.#transport?.snapshot().playbackState === 'playing';
278
- if (clip && !playing) {
279
- this.#seek(clip.frameCurrent);
280
- if (this.#transport && clip.fps > 0) this.#transport.seek(clip.frameCurrent / clip.fps);
313
+ const warnings: string[] = this.#clip?.reason ? [this.#clip.reason] : [];
314
+ const world = { object: (name: string) => view.objectForBlenderName(name) };
315
+ for (const [name, pose] of this.#poses) {
316
+ const armature = facts.armatures[name];
317
+ if (!armature) continue;
318
+ const unsupported = [...pose.unsupported(), ...this.#clipsOf(name).flatMap((clip) => clip.unsupported)];
319
+ if (unsupported.length) {
320
+ const said = `${name} plays ${unsupported.join(', ')} only in Blender, not in the Timeline or a game.${remedy(unsupported)}`;
321
+ warnings.push(said);
322
+ this.#say(`unsupported:${name}:${unsupported.join('|')}`, said);
323
+ }
324
+ const stack = sceneLayers(armature, frame, (action) => this.#clipOf(name, action));
325
+ for (const skipped of stack.skipped) this.#say(`skipped:${name}:${skipped}`, `${name}: ${skipped} plays only in Blender, not in the Timeline or a game.`);
326
+ if (stack.waiting) continue;
327
+ if (atBlender && !playing) {
328
+ view.skeletons.restorePose(name);
329
+ pose.rig.object.updateMatrixWorld(true);
330
+ this.#check(name, pose, armature, stack.layers, world, unsupported);
331
+ } else {
332
+ pose.apply(stack.layers, world);
333
+ }
281
334
  }
282
335
  this.#warnings = warnings;
283
- this.#publish();
284
336
  }
285
337
 
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;
338
+ /** The clips baked so far for one armature. */
339
+ #clipsOf(armature: string): PoseClip[] {
340
+ const out: PoseClip[] = [];
341
+ for (const [key, entry] of this.#baked) if (key.startsWith(`${armature}`) && entry.clip) out.push(entry.clip);
342
+ return out;
290
343
  }
291
344
 
292
- #loadClip(clip: BlenderActionClip, warnings: string[]): void {
293
- this.#mixer?.stopAllAction();
294
- this.#mixer = null;
295
- this.#action = null;
296
- if (!clip.tracks.length || clip.clipStart === undefined || clip.clipEnd === undefined) return;
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);
302
- if (!tracks.length) return;
303
- const duration = clip.duration ?? (clip.clipEnd - clip.clipStart) / clip.fps;
304
- const mixer = new THREE.AnimationMixer(armature);
305
- const action = mixer.clipAction(
306
- new THREE.AnimationClip(clip.action ?? 'action', duration, tracks),
307
- );
308
- action.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
309
- action.play();
310
- this.#mixer = mixer;
311
- this.#action = action;
312
- // THE FRESH MIXER STARTS AT BLENDER'S OWN FRAME, not at zero, and that is
313
- // the invariant this whole design rests on: the mesh columns were exported
314
- // at `frameCurrent` and the skeleton was bound in that pose, so seeking
315
- // anywhere else would make the first picture after a re-bind disagree with
316
- // the geometry underneath it. Measured on the first walk: a pause wrote
317
- // frame 16, the write presented, the present re-exported the mesh at 16
318
- // and re-bound — and `setTime(0)` snapped the playhead back to frame 1
319
- // over a frame-16 mesh.
320
- this.#seek(clip.frameCurrent);
321
- this.#refresh();
345
+ /** THE FIDELITY CHECK at Blender's own frame: the evaluator's pose against Blender's. */
346
+ #check(name: string, pose: ArmaturePose, armature: BlenderArmature, layers: readonly PoseLayer[],
347
+ world: { object(name: string): ReturnType<BlenderRuntimeView['objectForBlenderName']> }, unsupported: readonly string[]): void {
348
+ const off = poseDivergence(pose.apply(layers, world, false), armature);
349
+ if (!off) return;
350
+ const frame = this.#clip?.frameCurrent ?? this.frame();
351
+ const why = unsupported.length
352
+ ? `What Blender plays here and the evaluator does not: ${unsupported.join(', ')}.`
353
+ : 'Nothing the evaluator knows of explains it; this is a defect in the presenter, report it with the file.';
354
+ this.#say(`divergence:${name}:${frame}:${off.bone}:${Math.round(off.degrees)}`,
355
+ `${name} at frame ${frame}: bone "${off.bone}" is ${off.degrees.toFixed(1)}° and ${(off.offset * 100).toFixed(1)}% of its length off Blender's own pose, so the Timeline and a game show it differently from Blender. ${why}`);
356
+ }
357
+
358
+ #say(key: string, message: string): void {
359
+ if (this.#said.has(key)) return;
360
+ this.#said.add(key);
361
+ editorHost().console.warn(message, 'blender-animation');
322
362
  }
323
363
 
324
364
  // ------------------------------------------------------------ the playback
@@ -326,25 +366,8 @@ export class BlenderSkinDirector {
326
366
  #seek(frame: number): void {
327
367
  const clip = this.#clip;
328
368
  if (!clip) return;
329
- const clamped = Math.min(clip.frameEnd, Math.max(clip.frameStart, frame));
330
- this.#seeked = clamped;
331
- // An action supplies a pose at this scene time; it does not own whether
332
- // the scene clock can move. Empty/static scenes still play and scrub.
333
- if (!this.#mixer || clip.clipStart === undefined || clip.clipEnd === undefined) return;
334
- // The TIME is nudged inside the clip so the last frame evaluates as the
335
- // last frame rather than wrapping to the first; the number REPORTED is the
336
- // one asked for (see `frame`).
337
- const duration = (clip.clipEnd - clip.clipStart) / clip.fps;
338
- this.#mixer.setTime(Math.max(0, Math.min(duration - 1e-4, (clamped - clip.clipStart) / clip.fps)));
339
- this.#refresh();
340
- }
341
-
342
- /** The bones moved; make the world matrices agree before the stage's next
343
- * render reads them. A `Bone` keeps `matrixAutoUpdate`, so this is a forced
344
- * pass over a handful of nodes rather than a recomposition of the scene. */
345
- #refresh(): void {
346
- const rig = this.#armature ? this.#view?.skeletons.rig(this.#armature) : null;
347
- rig?.object.updateMatrixWorld(true);
369
+ this.#seeked = Math.min(clip.frameEnd, Math.max(clip.frameStart, frame));
370
+ this.#pose();
348
371
  }
349
372
 
350
373
  /** The scene's name, for the one address the Timeline writes to. */
@@ -447,12 +470,11 @@ export class BlenderSkinDirector {
447
470
 
448
471
  dispose(): void {
449
472
  this.#detach?.();
450
- this.#mixer?.stopAllAction();
451
- this.#mixer = null;
452
- this.#action = null;
453
473
  this.#clip = null;
454
474
  this.#seeked = null;
455
- if (this.#armature) this.#view?.skeletons.restorePose(this.#armature);
475
+ for (const name of this.#poses.keys()) this.#view?.skeletons.restorePose(name);
476
+ this.#poses.clear();
477
+ this.#baked.clear();
456
478
  this.#armature = null;
457
479
  this.#clipKey = null;
458
480
  }
@@ -1055,7 +1055,10 @@ function BlenderViewportArea({
1055
1055
  useEffect(() => {
1056
1056
  if (!documentId || !main) return;
1057
1057
  let live = true;
1058
- void blenderSkin.sync(view, { clip: (armature) => blenderActionClip({ object: armature }) }).catch((thrown: unknown) => {
1058
+ void blenderSkin.sync(view, {
1059
+ clip: (armature) => blenderActionClip({ object: armature }),
1060
+ bake: (armature, action) => blenderActionClip({ object: armature, action }),
1061
+ }).catch((thrown: unknown) => {
1059
1062
  if (live) editorHost().console.warn(`This file's action could not be read: ${thrown instanceof Error ? thrown.message : String(thrown)}`, 'blender-skin');
1060
1063
  });
1061
1064
  return () => { live = false; };
@@ -51,7 +51,10 @@
51
51
  "axisX": "#cb293f",
52
52
  "axisY": "#69aa15",
53
53
  "selection": "#ed5700",
54
- "active": "#ffa028"
54
+ "active": "#ffa028",
55
+ "marqueeLine": "#666666",
56
+ "marqueeGap": "#ffffff",
57
+ "marqueeFill": "rgba(255,255,255,0.05)"
55
58
  },
56
59
  "gizmo": {
57
60
  "x": "#ff3352",
@@ -205,6 +205,13 @@ export const style: StyleContribution = {
205
205
  gridLineWidth: 1.5,
206
206
  gridMajorWidth: 2.25,
207
207
  gridMajorContrast: 39 / 21,
208
+ // BLENDER'S BOX-SELECT OUTLINE IS DASHED in two colours, 4 device pixels of each:
209
+ // `wm_gesture_draw_rect` (`wm_gesture.cc`) sets `dash_width` 8 at `udash_factor` 0.5, and
210
+ // the dashed-line shader measures that in framebuffer pixels
211
+ // (`gpu_shader_3D_line_dashed_uniform_color_vert.glsl`), so it does not grow with the UI
212
+ // scale the way the line's width does. Its colours are the palette's
213
+ // `color.viewport.marqueeLine` and `marqueeGap`.
214
+ marqueeDash: 4,
208
215
  },
209
216
  },
210
217
  icons,
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.136",
5
+ "version": "0.1.138",
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.134",
76
- "@volter/editor-threejs": "0.5.197",
75
+ "@volter/blender-engine": "0.1.136",
76
+ "@volter/editor-threejs": "0.5.199",
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.197",
82
- "@volter/editor-project": "0.5.197",
83
- "@volter/editor-sdk": "0.5.197",
81
+ "@volter/editor-live": "0.5.199",
82
+ "@volter/editor-project": "0.5.199",
83
+ "@volter/editor-sdk": "0.5.199",
84
84
  "react": "^19.0.0",
85
85
  "react-dom": "^19.0.0",
86
86
  "three": "^0.180.0",