@volter/editor-blender 0.1.150 → 0.1.152

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.
Files changed (93) hide show
  1. package/LICENSE +18 -9
  2. package/README.md +5 -5
  3. package/contributions/blender-animation-editors.tsx +17 -69
  4. package/contributions/blender-display-settings.ts +36 -0
  5. package/contributions/blender-export-gltf.ts +228 -0
  6. package/contributions/blender-export.verbs.ts +39 -0
  7. package/contributions/blender-gltf-export.verbs.ts +312 -0
  8. package/contributions/blender-header-menus.tsx +37 -133
  9. package/contributions/blender-icon-trace.mjs +8 -98
  10. package/contributions/blender-icons.source.mjs +96 -860
  11. package/contributions/blender-mixer-pose.ts +369 -0
  12. package/contributions/blender-model-opening.tsx +26 -56
  13. package/contributions/blender-node-editor.document.tsx +30 -181
  14. package/contributions/blender-node-geometry.ts +44 -230
  15. package/contributions/blender-node-panels.source.mjs +7 -121
  16. package/contributions/blender-outliner-authoring.ts +61 -415
  17. package/contributions/blender-outliner-model.ts +15 -112
  18. package/contributions/blender-play-area.tsx +99 -0
  19. package/contributions/blender-playback-movie.ts +409 -0
  20. package/contributions/blender-pose.ts +18 -368
  21. package/contributions/blender-properties-context.ts +1 -1
  22. package/contributions/blender-properties-model.ts +59 -91
  23. package/contributions/blender-properties-tab.tsx +3 -50
  24. package/contributions/blender-properties-view.tsx +39 -181
  25. package/contributions/blender-remedy.ts +14 -0
  26. package/contributions/blender-runtime-skin.ts +103 -99
  27. package/contributions/blender-runtime.document.tsx +170 -618
  28. package/contributions/blender-scene-mixer.ts +51 -0
  29. package/contributions/blender-screenshot.service.ts +28 -0
  30. package/contributions/blender-timeline-geometry.ts +16 -136
  31. package/contributions/blender-timeline.document.tsx +145 -333
  32. package/contributions/blender-uv-editor.document.tsx +48 -112
  33. package/contributions/blender-uv-geometry.ts +14 -179
  34. package/contributions/blender-version.status.tsx +45 -31
  35. package/contributions/blender.environment.ts +2 -2
  36. package/contributions/blender.icons.json +0 -9
  37. package/contributions/blender.keymap.ts +9 -51
  38. package/contributions/blender.node-panels.json +4 -13
  39. package/contributions/blender.page.ts +64 -0
  40. package/contributions/blender.status.tsx +8 -150
  41. package/contributions/blender.style.ts +25 -152
  42. package/contributions/blender.verbs.ts +52 -0
  43. package/contributions/model.layout.ts +2 -34
  44. package/contributions/models.finder.ts +3 -30
  45. package/contributions/properties-bone-constraints.inspector.tsx +1 -16
  46. package/contributions/properties-bone.inspector.tsx +2 -31
  47. package/contributions/properties-collection.inspector.tsx +1 -34
  48. package/contributions/properties-constraints.inspector.tsx +1 -21
  49. package/contributions/properties-data.inspector.tsx +2 -39
  50. package/contributions/properties-material.inspector.tsx +2 -33
  51. package/contributions/properties-modifiers.inspector.tsx +2 -30
  52. package/contributions/properties-object.inspector.tsx +2 -44
  53. package/contributions/properties-output.inspector.tsx +1 -27
  54. package/contributions/properties-particles.inspector.tsx +2 -46
  55. package/contributions/properties-physics.inspector.tsx +2 -41
  56. package/contributions/properties-render.inspector.tsx +1 -45
  57. package/contributions/properties-scene.inspector.tsx +1 -35
  58. package/contributions/properties-texture.inspector.tsx +1 -49
  59. package/contributions/properties-view-layer.inspector.tsx +1 -29
  60. package/contributions/properties-world.inspector.tsx +2 -39
  61. package/contributions/shading.layout.ts +3 -78
  62. package/contributions/uv-editing.layout.ts +3 -70
  63. package/dist-node/serving.mjs +13 -32
  64. package/host/blender-runtime-host.ts +795 -412
  65. package/host/scene-sound.ts +143 -0
  66. package/node/blender-mcp.ts +52 -95
  67. package/package.json +1221 -46
  68. package/producticons/blender-icons.otf +0 -0
  69. package/producticons/blender-product-icon-theme.json +129 -0
  70. package/serving/blender-routes.ts +9 -48
  71. package/serving/blender-wasm-artifact.ts +29 -166
  72. package/serving/index.ts +1 -1
  73. package/src/api.ts +9 -0
  74. package/src/layouts.tsx +1 -17
  75. package/src/looks.ts +1 -12
  76. package/src/model-document-saving.ts +25 -0
  77. package/src/node-view-state.ts +3 -25
  78. package/src/play-mode.ts +13 -42
  79. package/src/play-tool.ts +44 -0
  80. package/src/presentation.ts +6 -18
  81. package/src/regions.ts +3 -43
  82. package/src/start-document.ts +15 -0
  83. package/src/timeline-view-state.ts +4 -39
  84. package/src/uv-view-state.ts +4 -20
  85. package/themes/blender.json +299 -0
  86. package/view/blender-view-routes.ts +1 -1
  87. package/contributions/blender-game-panel.tsx +0 -809
  88. package/contributions/blender-palette.source.mjs +0 -350
  89. package/contributions/blender-play-skin.ts +0 -508
  90. package/contributions/blender-screenshot.menu.ts +0 -32
  91. package/contributions/blender.command.ts +0 -109
  92. package/contributions/blender.palette.json +0 -102
  93. package/src/play-live.ts +0 -27
@@ -1,61 +1,31 @@
1
- /**
2
- * THE TIMELINE'S ANIMATION — three.js PLAYS Blender's animation; Blender holds it
3
- * as DATA (owner rule, 2026-09-20: "we visualize with three.js, not Blender").
4
- *
5
- * ## Why this exists at all
6
- *
7
- * The obvious Timeline asks Blender for frame N, lets the depsgraph evaluate
8
- * it and re-exports the mesh columns. That is the wrong architecture: it puts
9
- * a WASM depsgraph evaluation and a full geometry round trip between the
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 the skin travels in the
12
- * frame itself: the export door ships each Armature-deformed mesh's weights per
13
- * exported vertex (`bpy_web_export.cc` `write_skin`), the frame names every bone's
14
- * rest, channels, constraints and pose and each armature's NLA stack, and the presenter
15
- * builds the skeletons (`blender-runtime-skeleton.ts`). This director poses EVERY armature
16
- * at the scene frame with Blender's own evaluation (`blender-pose.ts`): its NLA tracks and
17
- * active action, blended as Blender blends them, and its Damped Track constraints. Every scrub
18
- * and every played frame costs ZERO calls into Blender; an action is baked again exactly when
19
- * the frame says its revision moved.
20
- *
21
- * ## The bind pose is the EXPORT pose, and that is the whole trick
22
- *
23
- * The export door runs with `evaluate: True`, so the columns the presenter
24
- * holds are the mesh ALREADY DEFORMED at whatever frame Blender sits on. Bind
25
- * a skeleton whose bones are in that same pose and the skinning is an identity
26
- * there — `skinMatrix = Σ wᵢ·Bᵢ·Bᵢ⁻¹ = I` — so the picture at the bind frame is
27
- * byte-for-byte the frame Blender presented, and every other frame is three's
28
- * own evaluation of the same skin over the same columns. Nothing has to move
29
- * Blender's frame, ever.
30
- *
31
- * ## The fidelity check, and what is not played
32
- *
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
- */
1
+ /** The Timeline poses Blender-sampled animation on the scene’s three.js mixer. Exported weights bind against the presented export pose, making skinning an identity at that frame. Each action is sampled again only when its revision changes; scrubs make no engine calls. Compare the mixed pose against Blender’s presented pose and report divergence and unsupported evaluation features. */
41
2
 
42
- import type { BlenderActionClip } from '@volter/blender-engine/browser/rna';
3
+ import { bytesFromBase64 } from '@volter/blender-engine/browser/three/blender-base64';
4
+ import type { BlenderActionClip, BlenderSceneMovie } from '@volter/blender-engine/browser/rna';
43
5
  import type { BlenderArmature } from '@volter/blender-engine/browser/three/blender-runtime-armature';
44
6
  import type { BlenderRuntimeView } from '@volter/blender-engine/browser/three/blender-runtime-view';
45
7
  import { editorHost, type StageTransportHandle } from '@volter/sdk/host';
46
8
  import { blenderRnaSet } from '../host/blender-runtime-host';
47
- import { actionLayer, ArmaturePose, nlaLayers, poseClip, poseDivergence, type PoseClip, type PoseLayer } from './blender-pose';
9
+ import { remedy } from './blender-remedy';
10
+ import { actionLayer, nlaLayers, type PoseLayer } from './blender-pose';
11
+ import { MixerPose, mixerClip, poseDivergence, type MixerClip } from './blender-mixer-pose';
12
+ import { playMovie, type PlayMovie } from './blender-playback-movie';
13
+ import { sceneMixer } from './blender-scene-mixer';
14
+ import { invalidateStages } from '@volter/sdk/stage-invalidation';
48
15
 
49
16
  /** How the director reads the engine: the Timeline's subject (its keys, the scene's range) and
50
17
  * one action baked for one armature. */
51
18
  export interface SkinReader {
52
- clip(armature: string): Promise<BlenderActionClip | null>;
19
+ /** The scene's clock and range, and `armature`'s action when there is one (null: the scene alone). */
20
+ clip(armature: string | null): Promise<BlenderActionClip | null>;
53
21
  bake(armature: string, action: string): Promise<BlenderActionClip | null>;
22
+ /** Animated objects, cameras and marker cuts sampled at each scene frame for Timeline playback. */
23
+ movie(): Promise<BlenderSceneMovie | null>;
54
24
  }
55
25
 
56
26
  /** An armature's layers at a scene frame: its NLA, then its active action over them. */
57
27
  export function sceneLayers(armature: BlenderArmature, frame: number,
58
- clips: (action: string) => PoseClip | null | undefined): { layers: PoseLayer[]; waiting: boolean; skipped: string[] } {
28
+ clips: (action: string) => MixerClip | null | undefined): { layers: PoseLayer[]; waiting: boolean; skipped: string[] } {
59
29
  const stack = nlaLayers(armature.animation, frame, clips);
60
30
  const action = armature.action ?? null;
61
31
  if (action) {
@@ -67,17 +37,7 @@ export function sceneLayers(armature: BlenderArmature, frame: number,
67
37
  return stack;
68
38
  }
69
39
 
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
- }
40
+ export { remedy } from './blender-remedy';
81
41
 
82
42
  /** True when an armature has anything to evaluate: an action, or NLA strips. */
83
43
  export function animated(armature: BlenderArmature): boolean {
@@ -95,14 +55,21 @@ export class BlenderSkinDirector {
95
55
  /** The presented view whose skeletons are posed (`sync`). */
96
56
  #view: BlenderRuntimeView | null = null;
97
57
  #reader: SkinReader | null = null;
58
+ /** THE SCENE'S MOVIE on the document's view (`docs/SCENE-ANIMATION.md`, step 2): its objects and
59
+ * cameras posed at every Timeline frame beside the armatures, and its camera cuts. */
60
+ #movie: PlayMovie | null = null;
61
+ #movieKey: string | null = null;
62
+ #moviePosed: number | null = null;
63
+ /** What the last movie read held (null before one): said in `state()`, so an empty movie says why. */
64
+ #movieRead: { objects: number; materials: number; shapes: number } | null = null;
98
65
  /** The Timeline's subject: the armature its keys and header describe, and what its header was
99
66
  * read for. Read again only when that changes. */
100
67
  #armature: string | null = null;
101
68
  #clipKey: string | null = null;
102
69
  #clip: BlenderActionClip | null = null;
103
70
  /** 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 }>();
71
+ #poses = new Map<string, MixerPose>();
72
+ #baked = new Map<string, { revision: number | null; clip: MixerClip | null | undefined }>();
106
73
  /** The frame the last SEEK asked for — see {@link frame}. */
107
74
  #seeked: number | null = null;
108
75
  /** The stage transport this skin is attached to, or null before `attachTo`.
@@ -114,8 +81,7 @@ export class BlenderSkinDirector {
114
81
  #warnings: string[] = [];
115
82
  /** What was already said in the console, so each finding is said once. */
116
83
  #said = new Set<string>();
117
- /** Engine calls this director has made, counted so a walk can prove that a
118
- * scrub costs none (the acceptance the owner named). */
84
+ /** Engine-call count exposed in the Timeline state. */
119
85
  #engineCalls = 0;
120
86
 
121
87
  get version(): number {
@@ -169,6 +135,12 @@ export class BlenderSkinDirector {
169
135
  * They agree after a pause or a scrub-end, and they are deliberately
170
136
  * allowed to differ in between. */
171
137
  blenderFrame: number | null;
138
+ /** The scene's movie on the Timeline: how many objects it moves, the camera it cuts to now, the
139
+ * frame it last posed (null: posed by Blender, standing at its own frame), and the camera the
140
+ * camera view looks through. */
141
+ movie: { objects: number; camera: string | null; posed: number | null; viewCamera: string | null } | null;
142
+ /** What the last read of the scene's movie held: objects moved, materials and meshes keyed. */
143
+ movieRead: { objects: number; materials: number; shapes: number } | null;
172
144
  } {
173
145
  const clip = this.#clip;
174
146
  return {
@@ -189,6 +161,8 @@ export class BlenderSkinDirector {
189
161
  warnings: this.#warnings,
190
162
  engineCalls: this.#engineCalls,
191
163
  blenderFrame: clip?.frameCurrent ?? null,
164
+ movieRead: this.#movieRead,
165
+ movie: this.#movie ? { objects: this.#movie.objects.length, camera: this.#moviePosed === null ? null : this.#movie.cameraAt(this.#moviePosed), posed: this.#moviePosed, viewCamera: this.#view?.cameraViewCamera() ?? null } : null,
192
166
  };
193
167
  }
194
168
 
@@ -229,15 +203,16 @@ export class BlenderSkinDirector {
229
203
  ? names.find((name) => facts.armatures[name]!.action)! : null);
230
204
  // EVERY ARMATURE'S EVALUATOR, over the rig the presenter holds for it now.
231
205
  for (const [name, pose] of [...this.#poses])
232
- if (!facts.armatures[name] || view.skeletons.rig(name) !== pose.rig) this.#poses.delete(name);
206
+ if (!facts.armatures[name] || view.skeletons.rig(name) !== pose.rig) { pose.dispose(); this.#poses.delete(name); }
233
207
  for (const [name, entry] of Object.entries(facts.armatures)) {
234
208
  const rig = view.skeletons.rig(name);
235
209
  if (!rig || !animated(entry)) {
210
+ this.#poses.get(name)?.dispose();
236
211
  this.#poses.delete(name);
237
212
  continue;
238
213
  }
239
214
  let pose = this.#poses.get(name);
240
- if (!pose) this.#poses.set(name, pose = new ArmaturePose(rig));
215
+ if (!pose) this.#poses.set(name, pose = new MixerPose(rig, sceneMixer(view).mixer));
241
216
  pose.facts(entry);
242
217
  }
243
218
  // A BAKE IS GOOD FOR ITS ACTION'S REVISION; a new revision bakes it again when next needed.
@@ -250,12 +225,11 @@ export class BlenderSkinDirector {
250
225
  if (key !== this.#clipKey) {
251
226
  this.#clipKey = key;
252
227
  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
- }
228
+ // THE SCENE'S CLOCK IS READ WITH OR WITHOUT AN ARMATURE (`docs/SCENE-ANIMATION.md`, step 1): a
229
+ // film of keyed cameras and props has a range, a frame and markers, and the Timeline plays it
230
+ this.#engineCalls++;
231
+ const clip: BlenderActionClip | null = await read.clip(armature);
232
+ if (clip?.action && clip.armature) this.#baked.set(`${clip.armature}\u0000${clip.action}`, { revision: facts.actions[clip.action] ?? null, clip: mixerClip(clip) });
259
233
  const previous = this.#clip;
260
234
  const rangeChanged = clip?.frameStart !== previous?.frameStart ||
261
235
  clip?.frameEnd !== previous?.frameEnd || clip?.fps !== previous?.fps;
@@ -269,23 +243,36 @@ export class BlenderSkinDirector {
269
243
  if (this.#transport && clip.fps > 0) this.#transport.seek(clip.frameCurrent / clip.fps);
270
244
  }
271
245
  }
246
+ // THE MOVIE IS READ AGAIN WHEN AN ACTION OR THE SCENE'S CLOCK MOVED (a key, a marker, the range), OR
247
+ // ITS CAMERAS DID: the scene camera switched, a camera's lens or placement changed unkeyed (the movie
248
+ // carries the scene camera and its settings as they were read)
249
+ const movieKey = JSON.stringify([facts.actions, facts.clock, this.#clip?.frameStart, this.#clip?.frameEnd, facts.cameras]);
250
+ if (movieKey !== this.#movieKey) {
251
+ this.#movieKey = movieKey;
252
+ this.#engineCalls++;
253
+ const data = await read.movie();
254
+ this.#movieRead = data ? { objects: data.objects.length, materials: data.materials.length, shapes: data.shapes.length } : null;
255
+ this.#movie?.dispose();
256
+ this.#movie = data && (data.objects.length || data.materials.length || data.shapes.length) ? playMovie(view, data, sceneMixer(view).mixer) : null;
257
+ for (const warning of this.#movie?.warnings ?? []) this.#say(`movie:${warning}`, warning);
258
+ }
272
259
  this.#pose();
273
260
  this.#publish();
274
261
  }
275
262
 
276
263
  /** 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 {
264
+ #clipOf(armature: string, action: string): MixerClip | null | undefined {
278
265
  const key = `${armature}\u0000${action}`;
279
266
  const held = this.#baked.get(key);
280
267
  if (held) return held.clip;
281
268
  const reader = this.#reader;
282
269
  const revision = this.#view?.animationFacts().actions[action] ?? null;
283
270
  if (!reader) return null;
284
- const entry: { revision: number | null; clip: PoseClip | null | undefined } = { revision, clip: undefined };
271
+ const entry: { revision: number | null; clip: MixerClip | null | undefined } = { revision, clip: undefined };
285
272
  this.#baked.set(key, entry);
286
273
  this.#engineCalls++;
287
274
  void reader.bake(armature, action).then((baked) => {
288
- entry.clip = baked ? poseClip(baked) : null;
275
+ entry.clip = baked ? mixerClip(baked) : null;
289
276
  if (this.#baked.get(key) !== entry) return;
290
277
  this.#pose();
291
278
  this.#publish();
@@ -312,47 +299,70 @@ export class BlenderSkinDirector {
312
299
  const playing = this.#transport?.snapshot().playbackState === 'playing';
313
300
  const warnings: string[] = this.#clip?.reason ? [this.#clip.reason] : [];
314
301
  const world = { object: (name: string) => view.objectForBlenderName(name) };
302
+ const scene = sceneMixer(view);
303
+ // ONE FRAME, THREE STEPS (`blender-scene-mixer.ts`): every player places its actions, the scene's
304
+ // mixer evaluates them all at once, then each settles (constraints, the fidelity check).
305
+ // OBJECTS AND CAMERAS (an armature may ride on one): standing still at Blender's own frame the
306
+ // picture is Blender's, as for the armatures (an object moved by hand and not keyed stays where it
307
+ // was put until the frame changes, as in Blender), so the movie holds off the mixer there.
308
+ const movieHere = !!this.#movie && !(atBlender && !playing);
309
+ // (the drawn geometry is Blender's evaluation at its own frame: a shape key plays relative to it)
310
+ this.#movie?.place(movieHere ? frame : null, facts.frame);
311
+ this.#moviePosed = movieHere ? frame : null;
312
+ const placed: { name: string; pose: MixerPose; armature: BlenderArmature; check: boolean; unsupported: string[] }[] = [];
315
313
  for (const [name, pose] of this.#poses) {
316
314
  const armature = facts.armatures[name];
317
315
  if (!armature) continue;
318
316
  const unsupported = [...pose.unsupported(), ...this.#clipsOf(name).flatMap((clip) => clip.unsupported)];
319
317
  if (unsupported.length) {
320
- const said = `${name} plays ${unsupported.join(', ')} only in Blender, not in the Timeline or a game.${remedy(unsupported)}`;
318
+ const said = `${name} plays ${unsupported.join(', ')} only in Blender, not in the Timeline preview.${remedy(unsupported)}`;
321
319
  warnings.push(said);
322
320
  this.#say(`unsupported:${name}:${unsupported.join('|')}`, said);
323
321
  }
324
322
  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.`);
323
+ for (const skipped of stack.skipped) this.#say(`skipped:${name}:${skipped}`, `${name}: ${skipped} plays only in Blender, not in the Timeline preview.`);
326
324
  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
- }
325
+ pose.place(stack.layers);
326
+ placed.push({ name, pose, armature, check: atBlender && !playing, unsupported });
327
+ }
328
+ scene.evaluate();
329
+ this.#movie?.settle();
330
+ if (this.#movie) {
331
+ // THE CUT ALWAYS FOLLOWS THE MARKERS, at Blender's own frame too: Blender switches `scene.camera`
332
+ // at bound markers as it plays, which a frame written back to it does not do
333
+ const cut = this.#movie.cameraAt(frame);
334
+ view.setPlaybackCamera(cut, cut === null ? null : this.#movie.cameraData(cut));
335
+ // (a scrub that moved only objects and cameras asks the stage for the frame it now shows)
336
+ invalidateStages();
337
+ } else view.setPlaybackCamera(null);
338
+ for (const one of placed) {
339
+ const posed = one.pose.settle(world);
340
+ if (!one.check) continue;
341
+ // AT BLENDER'S OWN FRAME the picture is Blender's pose, and the evaluator's answer is checked against it
342
+ this.#check(one.name, posed, one.armature, one.unsupported);
343
+ view.skeletons.restorePose(one.name);
344
+ one.pose.rig.object.updateMatrixWorld(true);
334
345
  }
335
346
  this.#warnings = warnings;
336
347
  }
337
348
 
338
349
  /** The clips baked so far for one armature. */
339
- #clipsOf(armature: string): PoseClip[] {
340
- const out: PoseClip[] = [];
350
+ #clipsOf(armature: string): MixerClip[] {
351
+ const out: MixerClip[] = [];
341
352
  for (const [key, entry] of this.#baked) if (key.startsWith(`${armature}`) && entry.clip) out.push(entry.clip);
342
353
  return out;
343
354
  }
344
355
 
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);
356
+ /** THE FIDELITY CHECK at Blender's own frame: the evaluator's pose (as settled) against Blender's. */
357
+ #check(name: string, posed: ReadonlyMap<string, import('three').Matrix4>, armature: BlenderArmature, unsupported: readonly string[]): void {
358
+ const off = poseDivergence(posed, armature);
349
359
  if (!off) return;
350
360
  const frame = this.#clip?.frameCurrent ?? this.frame();
351
361
  const why = unsupported.length
352
362
  ? `What Blender plays here and the evaluator does not: ${unsupported.join(', ')}.`
353
363
  : 'Nothing the evaluator knows of explains it; this is a defect in the presenter, report it with the file.';
354
364
  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}`);
365
+ `${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 preview differs from Blender. ${why}`);
356
366
  }
357
367
 
358
368
  #say(key: string, message: string): void {
@@ -386,9 +396,7 @@ export class BlenderSkinDirector {
386
396
  channels(): { bone: string; keys: number[] }[] {
387
397
  const bones = new Map<string, Set<number>>();
388
398
  for (const track of this.#clip?.tracks ?? []) {
389
- const raw = atob(track.keysBase64);
390
- const bytes = new Uint8Array(raw.length);
391
- for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
399
+ const bytes = bytesFromBase64(track.keysBase64);
392
400
  const keys = new Float32Array(bytes.buffer, 0, bytes.length >> 2);
393
401
  let frames = bones.get(track.bone);
394
402
  if (!frames) bones.set(track.bone, frames = new Set());
@@ -417,14 +425,13 @@ export class BlenderSkinDirector {
417
425
  * session; writing it sixty times a second would be the architecture this
418
426
  * module exists to avoid. The transport's `onSettled` is what calls this —
419
427
  * a pause, or the quiet at the end of a scrub — so a slider drag writes
420
- * once. Moved verbatim from the Timeline look, which is no longer where a
421
- * playhead write belongs. */
428
+ * once. */
422
429
  async writeBookmark(): Promise<number> {
423
430
  const frame = Math.round(this.frame());
424
431
  const clip = this.#clip;
425
432
  if (!clip) return frame;
426
433
  const scene = clip.scene;
427
- if (!scene || clip.frameCurrent === frame) return frame;
434
+ if (clip.frameCurrent === frame) return frame;
428
435
  this.#engineCalls++;
429
436
  // `rna_set` refuses `bpy.context.scene` — the scene must be addressed by
430
437
  // name through `bpy.data.scenes[...]`.
@@ -506,8 +513,10 @@ export class BlenderSkinDirector {
506
513
  this.#detach?.();
507
514
  this.#clip = null;
508
515
  this.#seeked = null;
509
- for (const name of this.#poses.keys()) this.#view?.skeletons.restorePose(name);
516
+ for (const [name, pose] of this.#poses) { pose.dispose(); this.#view?.skeletons.restorePose(name); }
510
517
  this.#poses.clear();
518
+ this.#movie?.dispose();
519
+ this.#movie = null;
511
520
  this.#baked.clear();
512
521
  this.#armature = null;
513
522
  this.#clipKey = null;
@@ -517,12 +526,7 @@ export class BlenderSkinDirector {
517
526
  /** THE ONE DIRECTOR — see the class comment for why it is module-scoped. */
518
527
  export const blenderSkin = new BlenderSkinDirector();
519
528
 
520
- /** STABLE FUNCTION IDENTITIES for `useSyncExternalStore`, and they are not
521
- * ceremony: passing `blenderSkin.subscribe.bind(blenderSkin)` inline mints a
522
- * NEW subscribe and a NEW getSnapshot on every render, so React tears the
523
- * subscription down and rebuilds it each time — measured on the Timeline's
524
- * first walk, where the view bound its rig and its published `drawn` never
525
- * moved again because no re-render ever arrived. */
529
+ /** Stable subscribe and snapshot function identities preserve useSyncExternalStore subscriptions across renders. */
526
530
  export function subscribeBlenderSkin(listener: () => void): () => void {
527
531
  return blenderSkin.subscribe(listener);
528
532
  }