@volter/editor-blender 0.1.0

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 (58) hide show
  1. package/LICENSE +1409 -0
  2. package/README.md +17 -0
  3. package/contributions/blender-header-menus.tsx +483 -0
  4. package/contributions/blender-icon-trace.mjs +403 -0
  5. package/contributions/blender-icons.source.mjs +2925 -0
  6. package/contributions/blender-node-editor.document.tsx +1402 -0
  7. package/contributions/blender-node-geometry.ts +1138 -0
  8. package/contributions/blender-node-panels.source.mjs +485 -0
  9. package/contributions/blender-outliner-authoring.ts +1729 -0
  10. package/contributions/blender-outliner-model.ts +389 -0
  11. package/contributions/blender-palette.source.mjs +319 -0
  12. package/contributions/blender-properties-model.ts +351 -0
  13. package/contributions/blender-properties-tab.tsx +100 -0
  14. package/contributions/blender-properties-view.tsx +1191 -0
  15. package/contributions/blender-runtime-skin.ts +619 -0
  16. package/contributions/blender-runtime.document.tsx +232 -0
  17. package/contributions/blender-timeline-geometry.ts +323 -0
  18. package/contributions/blender-timeline.document.tsx +1056 -0
  19. package/contributions/blender-uv-editor.document.tsx +483 -0
  20. package/contributions/blender-uv-geometry.ts +305 -0
  21. package/contributions/blender-version.status.tsx +93 -0
  22. package/contributions/blender.command.ts +102 -0
  23. package/contributions/blender.icons.json +1247 -0
  24. package/contributions/blender.icons.traced.json +1561 -0
  25. package/contributions/blender.keymap.ts +39 -0
  26. package/contributions/blender.node-panels.json +2436 -0
  27. package/contributions/blender.palette.json +93 -0
  28. package/contributions/blender.status.tsx +263 -0
  29. package/contributions/blender.style.ts +271 -0
  30. package/contributions/model.layout.ts +53 -0
  31. package/contributions/models.finder.ts +59 -0
  32. package/contributions/properties-bone-constraints.inspector.tsx +50 -0
  33. package/contributions/properties-bone.inspector.tsx +184 -0
  34. package/contributions/properties-collection.inspector.tsx +96 -0
  35. package/contributions/properties-constraints.inspector.tsx +69 -0
  36. package/contributions/properties-data.inspector.tsx +229 -0
  37. package/contributions/properties-material.inspector.tsx +121 -0
  38. package/contributions/properties-modifiers.inspector.tsx +74 -0
  39. package/contributions/properties-object.inspector.tsx +215 -0
  40. package/contributions/properties-output.inspector.tsx +210 -0
  41. package/contributions/properties-particles.inspector.tsx +494 -0
  42. package/contributions/properties-physics.inspector.tsx +614 -0
  43. package/contributions/properties-render.inspector.tsx +446 -0
  44. package/contributions/properties-scene.inspector.tsx +174 -0
  45. package/contributions/properties-texture.inspector.tsx +300 -0
  46. package/contributions/properties-view-layer.inspector.tsx +145 -0
  47. package/contributions/properties-world.inspector.tsx +130 -0
  48. package/contributions/sculpt.layout.ts +25 -0
  49. package/contributions/shading.layout.ts +99 -0
  50. package/contributions/texture.layout.ts +16 -0
  51. package/contributions/uv-editing.layout.ts +93 -0
  52. package/host/blender-runtime-host.ts +1256 -0
  53. package/package.json +77 -0
  54. package/src/layouts.tsx +48 -0
  55. package/src/looks.ts +14 -0
  56. package/src/node-view-state.ts +125 -0
  57. package/src/timeline-view-state.ts +154 -0
  58. package/src/uv-view-state.ts +125 -0
@@ -0,0 +1,619 @@
1
+ /**
2
+ * THE SKIN AND THE CLIP — 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 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.
16
+ *
17
+ * ## The bind pose is the EXPORT pose, and that is the whole trick
18
+ *
19
+ * The export door runs with `evaluate: True`, so the columns the presenter
20
+ * holds are the mesh ALREADY DEFORMED at whatever frame Blender sits on. Bind
21
+ * a skeleton whose bones are in that same pose and the skinning is an identity
22
+ * there — `skinMatrix = Σ wᵢ·Bᵢ·Bᵢ⁻¹ = I` — so the picture at the bind frame is
23
+ * byte-for-byte the frame Blender presented, and every other frame is three's
24
+ * own evaluation of the same skin over the same columns. Nothing has to move
25
+ * Blender's frame, ever.
26
+ *
27
+ * ## What that costs, stated rather than implied
28
+ *
29
+ * While a clip plays, the picture is three.js's skinning of the EXPORT-FRAME
30
+ * mesh. Anything else Blender's depsgraph would do per frame — shape keys, a
31
+ * Displace or Cast or Cloth modifier reading the frame, a driver on geometry —
32
+ * does NOT follow the mixer. The Timeline's status line names any such
33
+ * modifier on the played object rather than showing a confident picture of the
34
+ * wrong thing (`modifiersNotPlayed`). A bone whose pose is a CONSTRAINT's
35
+ * rather than its channels' is named the same way, because the clip is derived
36
+ * from the F-Curves alone.
37
+ */
38
+
39
+ import type {
40
+ BlenderActionClip,
41
+ BlenderRig,
42
+ BlenderRigBinding,
43
+ } from '@volter/blender-engine/browser/rna';
44
+ import type { StageTransportHandle } from '@volter/editor-sdk/host';
45
+ import * as THREE from 'three';
46
+ import { blenderRnaSet } from '../host/blender-runtime-host';
47
+
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;
57
+ }
58
+ function float32Of(base64: string): Float32Array {
59
+ const bytes = bytesOf(base64);
60
+ return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 2);
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('|');
104
+ }
105
+
106
+ /**
107
+ * THE ONE DIRECTOR, module-scoped for the reason `BlenderRuntimeView` itself
108
+ * is (`blender-runtime.document.tsx`): the Python session outlives workspace
109
+ * switches and document remounts, so the skeleton it bound must too. The
110
+ * Timeline document reads and drives this exact instance; there is no second
111
+ * copy of the playback state anywhere.
112
+ */
113
+ export class BlenderSkinDirector {
114
+ #rigs = new Map<string, BoundRig>();
115
+ #mixer: THREE.AnimationMixer | null = null;
116
+ #action: THREE.AnimationAction | null = null;
117
+ #clip: BlenderActionClip | null = null;
118
+ /** The frame the last SEEK asked for — see {@link frame}. */
119
+ #seeked: number | null = null;
120
+ /** The stage transport this skin is attached to, or null before `attachTo`.
121
+ * The Timeline look drives THIS (Step 7); the skin holds no clock. */
122
+ #transport: StageTransportHandle | null = null;
123
+ #detach: (() => void) | null = null;
124
+ #listeners = new Set<() => void>();
125
+ #version = 0;
126
+ #warnings: string[] = [];
127
+ /** Engine calls this director has made, counted so a walk can prove that a
128
+ * scrub costs none (the acceptance the owner named). */
129
+ #engineCalls = 0;
130
+
131
+ get version(): number {
132
+ return this.#version;
133
+ }
134
+
135
+ get engineCalls(): number {
136
+ return this.#engineCalls;
137
+ }
138
+
139
+ subscribe(listener: () => void): () => void {
140
+ this.#listeners.add(listener);
141
+ return () => this.#listeners.delete(listener);
142
+ }
143
+
144
+ #publish(): void {
145
+ this.#version++;
146
+ for (const listener of [...this.#listeners]) listener();
147
+ }
148
+
149
+ /** The clip's own facts plus the mixer's live time, expressed in BLENDER
150
+ * FRAMES — which is the only number the Timeline draws, and the only one a
151
+ * person or an agent ever names. */
152
+ state(): {
153
+ frame: number;
154
+ start: number;
155
+ end: number;
156
+ fps: number;
157
+ action: string | null;
158
+ object: string | null;
159
+ armature: string | null;
160
+ bones: number;
161
+ keyframes: readonly { frame: number; type: string; select: boolean }[];
162
+ /** Every animated object's own columns plus its SELECTION — what the
163
+ * summary row's `show_only_selected` filter chooses between. The mixer
164
+ * never reads it: what plays is the bound action, filter or no filter,
165
+ * exactly as in Blender. */
166
+ summary: readonly {
167
+ object: string;
168
+ action: string;
169
+ selected: boolean;
170
+ keyframes: readonly { frame: number; type: string; select: boolean }[];
171
+ }[];
172
+ clipStart: number | null;
173
+ clipEnd: number | null;
174
+ tracks: number;
175
+ bound: readonly string[];
176
+ warnings: readonly string[];
177
+ engineCalls: number;
178
+ /** BLENDER'S OWN `scene.frame_current`, as the clip door last read it —
179
+ * which is a different number from `frame` on purpose. `frame` is where
180
+ * the person is looking (the mixer's); this is where bpy and the
181
+ * Properties rail are. They agree after a pause or a scrub-end, and they
182
+ * are deliberately allowed to differ in between. */
183
+ blenderFrame: number | null;
184
+ } {
185
+ const clip = this.#clip;
186
+ const start = clip?.frameStart ?? 1;
187
+ const end = clip?.frameEnd ?? 250;
188
+ const fps = clip?.fps ?? 24;
189
+ return {
190
+ frame: this.frame(),
191
+ start,
192
+ end,
193
+ fps,
194
+ action: clip?.action ?? null,
195
+ object: clip?.object ?? null,
196
+ armature: clip?.armature ?? null,
197
+ bones: [...this.#rigs.values()].reduce((sum, rig) => sum + rig.bones.length, 0),
198
+ keyframes: clip?.keyframes ?? [],
199
+ summary: clip?.summary ?? [],
200
+ clipStart: clip?.clipStart ?? null,
201
+ clipEnd: clip?.clipEnd ?? null,
202
+ tracks: clip?.tracks.length ?? 0,
203
+ bound: [...this.#rigs.keys()],
204
+ warnings: this.#warnings,
205
+ engineCalls: this.#engineCalls,
206
+ blenderFrame: clip?.frameCurrent ?? null,
207
+ };
208
+ }
209
+
210
+ /** The mixer's time as a Blender frame. With no clip the scene's own current
211
+ * frame stands, which is Blender's answer for a file with no animation. */
212
+ frame(): number {
213
+ const clip = this.#clip;
214
+ if (!clip || !this.#action || clip.clipStart === undefined) return clip?.frameCurrent ?? 1;
215
+ // A SEEK'S OWN ANSWER, while one is standing. `LoopRepeat` wraps
216
+ // `action.time` into `[0, duration)`, so a seek to the LAST frame lands on
217
+ // `time === duration` and reads back as the FIRST — measured on the first
218
+ // walk, where `jump-end` answered frame 1 over a frame-48 pose. The
219
+ // wrapping is right for playback and wrong for a question, so a standing
220
+ // seek answers with the frame it was given and the play tick clears it.
221
+ if (this.#seeked !== null) return this.#seeked;
222
+ // THE ACTION'S TIME, NOT THE MIXER'S, and the difference is the whole of
223
+ // looping: `AnimationMixer.time` is monotonic and never wraps, while
224
+ // `AnimationAction.time` is wrapped into `[0, duration]` by `LoopRepeat`.
225
+ // Measured on the first walk: two seconds of playback over a 47-frame clip
226
+ // read as frame 63.5 off the mixer, where the picture was correctly back
227
+ // near the start.
228
+ return clip.clipStart + this.#action.time * clip.fps;
229
+ }
230
+
231
+ /** Whether anything is actually playable — a Timeline over a file with no
232
+ * action still draws its ruler and its range. */
233
+ get playable(): boolean {
234
+ return this.#action !== null;
235
+ }
236
+
237
+ // ------------------------------------------------------------ the binding
238
+
239
+ /**
240
+ * Bind every rigged mesh the frame carries, and load the active action.
241
+ *
242
+ * IDEMPOTENT BY SIGNATURE: a rig whose mesh, armature, vertex count, bone
243
+ * count and export frame are unchanged is left exactly as it is, mixer time
244
+ * included. That is what lets the presenter call this after EVERY frame
245
+ * without the picture jumping back to the bind pose each time something
246
+ * unrelated moved.
247
+ */
248
+ async bind(
249
+ presentation: SkinPresentation,
250
+ read: {
251
+ rig(): Promise<BlenderRig | null>;
252
+ clip(): Promise<BlenderActionClip | null>;
253
+ },
254
+ ): Promise<void> {
255
+ this.#engineCalls++;
256
+ const answer = await read.rig();
257
+ if (!answer) return;
258
+ const warnings: string[] = [];
259
+ const wanted = new Set<string>();
260
+ let changed = false;
261
+ for (const rig of answer.rigs) {
262
+ if (!rig.object || !rig.armature || !rig.skinIndexBase64 || !rig.skinWeightBase64) {
263
+ if (rig.reason) warnings.push(rig.reason);
264
+ continue;
265
+ }
266
+ wanted.add(rig.object);
267
+ const signature = rigSignature(rig, answer.frame);
268
+ if (this.#rigs.get(rig.object)?.signature === signature) continue;
269
+ const bound = this.#bindOne(presentation, rig, answer.frame, warnings);
270
+ if (bound) {
271
+ this.#rigs.get(rig.object)?.skeleton.dispose();
272
+ this.#rigs.set(rig.object, bound);
273
+ changed = true;
274
+ }
275
+ }
276
+ for (const [name, rig] of [...this.#rigs])
277
+ if (!wanted.has(name)) {
278
+ rig.skeleton.dispose();
279
+ rig.boneRoot.removeFromParent();
280
+ this.#rigs.delete(name);
281
+ changed = true;
282
+ }
283
+ this.#engineCalls++;
284
+ const clip = await read.clip();
285
+ const previous = this.#clip;
286
+ const movedAction =
287
+ clip?.action !== previous?.action ||
288
+ clip?.tracks.length !== previous?.tracks.length ||
289
+ clip?.clipStart !== previous?.clipStart ||
290
+ clip?.clipEnd !== previous?.clipEnd;
291
+ this.#clip = clip ?? null;
292
+ if (clip?.reason) warnings.push(clip.reason);
293
+ if (clip && (movedAction || changed)) this.#loadClip(presentation, clip, warnings);
294
+ // BLENDER'S OWN FRAME RE-SYNCS THE PLAYHEAD. An agent that set
295
+ // `scene.frame_current` in bpy has said where it wants to be, and a
296
+ // present is how we hear about it; while the transport is PLAYING it would
297
+ // be the Timeline arguing with itself, so it is honoured only at rest.
298
+ const playing = this.#transport?.snapshot().playbackState === 'playing';
299
+ if (clip && !playing && previous?.frameCurrent !== clip.frameCurrent) {
300
+ this.#seek(clip.frameCurrent);
301
+ // THE BOOKMARK READ, once per bind: the file says where it was left, and
302
+ // the transport is what everything else now asks.
303
+ if (this.#transport && clip.fps > 0) this.#transport.seek(clip.frameCurrent / clip.fps);
304
+ }
305
+ this.#warnings = warnings;
306
+ this.#publish();
307
+ }
308
+
309
+ #bindOne(
310
+ presentation: SkinPresentation,
311
+ rig: BlenderRigBinding,
312
+ frame: number,
313
+ warnings: string[],
314
+ ): BoundRig | null {
315
+ const meshObject = presentation.objectForBlenderName(rig.object ?? '');
316
+ const armatureObject = presentation.objectForBlenderName(rig.armature ?? '');
317
+ if (!meshObject || !armatureObject) return null;
318
+ const source = meshObject as THREE.Mesh;
319
+ const geometry = source.geometry;
320
+ if (!geometry) return null;
321
+ const blenderVertex = geometry.getAttribute('blenderVertex');
322
+ if (!blenderVertex) {
323
+ warnings.push(
324
+ `"${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.`,
325
+ );
326
+ return null;
327
+ }
328
+ if (rig.vertexCount === 0) return null;
329
+ // THE COLUMNS AND THE BINDING MUST BE THE SAME MESH. `rna_rig` reads the
330
+ // ORIGINAL mesh's vertices (a deform layer is not geometry, so that is
331
+ // where the weights live); a generative modifier — Subdivision, Mirror,
332
+ // Array — makes the EVALUATED mesh the export door ships a different
333
+ // vertex set, and a skin bound across that mismatch would weight the wrong
334
+ // vertices. Named, never silently drawn.
335
+ let highest = 0;
336
+ for (let i = 0; i < blenderVertex.count; i++)
337
+ highest = Math.max(highest, blenderVertex.getX(i));
338
+ if (highest >= rig.vertexCount) {
339
+ warnings.push(
340
+ `"${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.`,
341
+ );
342
+ return null;
343
+ }
344
+ const skinIndex = uint16Of(rig.skinIndexBase64 ?? '');
345
+ const skinWeight = float32Of(rig.skinWeightBase64 ?? '');
346
+ const drawn = blenderVertex.count;
347
+ const indices = new Uint16Array(drawn * 4);
348
+ const weights = new Float32Array(drawn * 4);
349
+ for (let i = 0; i < drawn; i++) {
350
+ const vertex = blenderVertex.getX(i);
351
+ for (let k = 0; k < 4; k++) {
352
+ indices[i * 4 + k] = skinIndex[vertex * 4 + k] ?? 0;
353
+ weights[i * 4 + k] = skinWeight[vertex * 4 + k] ?? 0;
354
+ }
355
+ }
356
+ geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(indices, 4));
357
+ geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(weights, 4));
358
+
359
+ const boneRoot = new THREE.Group();
360
+ boneRoot.name = `${rig.armature}:bones`;
361
+ const bones: THREE.Bone[] = [];
362
+ const armatureSpace = rig.bones.map((bone) => matrixOf(bone.pose));
363
+ const indexByName = new Map(rig.bones.map((bone, index) => [bone.name, index]));
364
+ rig.bones.forEach((declared, index) => {
365
+ const bone = new THREE.Bone();
366
+ // THE NAME IS BLENDER'S, unsanitized, because a game reads it: the
367
+ // arena's `Player.tsx` finds its bones by Blender's own names. The CLIP
368
+ // therefore addresses bones by UUID rather than by name — three's
369
+ // `PropertyBinding` track-name grammar splits on `.`, and Blender's
370
+ // `hand.L` is an ordinary bone name.
371
+ bone.name = declared.name;
372
+ bones.push(bone);
373
+ const parentIndex = declared.parent === null ? undefined : indexByName.get(declared.parent);
374
+ const local =
375
+ parentIndex === undefined
376
+ ? armatureSpace[index]!.clone()
377
+ : armatureSpace[parentIndex]!.clone().invert().multiply(armatureSpace[index]!);
378
+ local.decompose(bone.position, bone.quaternion, bone.scale);
379
+ (parentIndex === undefined ? boneRoot : bones[parentIndex]!).add(bone);
380
+ });
381
+ armatureObject.add(boneRoot);
382
+
383
+ const skinned = new THREE.SkinnedMesh(geometry, source.material);
384
+ skinned.name = source.name;
385
+ skinned.matrixAutoUpdate = false;
386
+ skinned.matrix.copy(source.matrix);
387
+ skinned.matrix.decompose(skinned.position, skinned.quaternion, skinned.scale);
388
+ skinned.userData = source.userData;
389
+ // A SKIN MOVES PAST ITS BIND BOUNDS. three computes a SkinnedMesh's
390
+ // bounding sphere from the bind pose, so a raised arm at frame 24 is
391
+ // culled while its bind-pose sphere is off screen — a picture that
392
+ // vanishes mid-scrub with no error anywhere.
393
+ skinned.frustumCulled = false;
394
+ presentation.replacePresentedObject(source, skinned);
395
+ // THE BIND IS TAKEN AFTER THE GRAPH STANDS, because both halves of it are
396
+ // WORLD matrices: `Skeleton`'s bone inverses are the bones' `matrixWorld`
397
+ // at this instant and `bindMatrix` is the mesh's.
398
+ presentation.root.updateMatrixWorld(true);
399
+ const skeleton = new THREE.Skeleton(bones);
400
+ skinned.bind(skeleton, skinned.matrixWorld.clone());
401
+ if (rig.constrainedBones?.length)
402
+ warnings.push(
403
+ `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.`,
404
+ );
405
+ if (rig.unmappedGroups?.length)
406
+ warnings.push(
407
+ `${rig.object} carries vertex groups no bone is named for (${rig.unmappedGroups.join(', ')}); they weight nothing here, exactly as they deform nothing in Blender.`,
408
+ );
409
+ return {
410
+ object: rig.object ?? '',
411
+ armature: rig.armature ?? '',
412
+ mesh: skinned,
413
+ boneRoot,
414
+ bones,
415
+ skeleton,
416
+ frame,
417
+ signature: rigSignature(rig, frame),
418
+ };
419
+ }
420
+
421
+ #loadClip(presentation: SkinPresentation, clip: BlenderActionClip, warnings: string[]): void {
422
+ this.#mixer?.stopAllAction();
423
+ this.#mixer = null;
424
+ this.#action = null;
425
+ if (!clip.tracks.length || clip.clipStart === undefined || clip.clipEnd === undefined) return;
426
+ const armature = clip.armature ? presentation.objectForBlenderName(clip.armature) : null;
427
+ if (!armature) return;
428
+ const byName = new Map<string, THREE.Bone>();
429
+ for (const rig of this.#rigs.values())
430
+ if (rig.armature === clip.armature) for (const bone of rig.bones) byName.set(bone.name, bone);
431
+ const tracks: THREE.KeyframeTrack[] = [];
432
+ const missing = new Set<string>();
433
+ for (const track of clip.tracks) {
434
+ const bone = byName.get(track.bone);
435
+ if (!bone) {
436
+ missing.add(track.bone);
437
+ continue;
438
+ }
439
+ const times = float32Of(track.timeBase64);
440
+ const values = float32Of(track.valueBase64);
441
+ // ADDRESSED BY UUID, not by name — see the bone-naming note above.
442
+ const path = `${bone.uuid}.${track.property}`;
443
+ tracks.push(
444
+ track.property === 'quaternion'
445
+ ? new THREE.QuaternionKeyframeTrack(path, Array.from(times), Array.from(values))
446
+ : new THREE.VectorKeyframeTrack(path, Array.from(times), Array.from(values)),
447
+ );
448
+ }
449
+ if (missing.size)
450
+ warnings.push(
451
+ `${clip.action} animates ${[...missing].join(', ')}, which this binding has no bone for.`,
452
+ );
453
+ if (!tracks.length) return;
454
+ const duration = clip.duration ?? (clip.clipEnd - clip.clipStart) / clip.fps;
455
+ const mixer = new THREE.AnimationMixer(armature);
456
+ const action = mixer.clipAction(
457
+ new THREE.AnimationClip(clip.action ?? 'action', duration, tracks),
458
+ );
459
+ action.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
460
+ action.play();
461
+ this.#mixer = mixer;
462
+ this.#action = action;
463
+ // THE FRESH MIXER STARTS AT BLENDER'S OWN FRAME, not at zero, and that is
464
+ // the invariant this whole design rests on: the mesh columns were exported
465
+ // at `frameCurrent` and the skeleton was bound in that pose, so seeking
466
+ // anywhere else would make the first picture after a re-bind disagree with
467
+ // the geometry underneath it. Measured on the first walk: a pause wrote
468
+ // frame 16, the write presented, the present re-exported the mesh at 16
469
+ // and re-bound — and `setTime(0)` snapped the playhead back to frame 1
470
+ // over a frame-16 mesh.
471
+ this.#seek(clip.frameCurrent);
472
+ this.#refresh();
473
+ }
474
+
475
+ // ------------------------------------------------------------ the playback
476
+
477
+ #seek(frame: number): void {
478
+ const clip = this.#clip;
479
+ if (!clip || !this.#mixer || clip.clipStart === undefined || clip.clipEnd === undefined) return;
480
+ const clamped = Math.min(clip.clipEnd, Math.max(clip.clipStart, frame));
481
+ this.#seeked = clamped;
482
+ // The TIME is nudged inside the clip so the last frame evaluates as the
483
+ // last frame rather than wrapping to the first; the number REPORTED is the
484
+ // one asked for (see `frame`).
485
+ const duration = (clip.clipEnd - clip.clipStart) / clip.fps;
486
+ this.#mixer.setTime(Math.min(duration - 1e-4, (clamped - clip.clipStart) / clip.fps));
487
+ this.#refresh();
488
+ }
489
+
490
+ /** The bones moved; make the world matrices agree before the stage's next
491
+ * render reads them. A `Bone` keeps `matrixAutoUpdate`, so this is a forced
492
+ * pass over a handful of nodes rather than a recomposition of the scene. */
493
+ #refresh(): void {
494
+ for (const rig of this.#rigs.values()) {
495
+ rig.boneRoot.updateMatrixWorld(true);
496
+ rig.skeleton.update();
497
+ }
498
+ }
499
+
500
+ /** The scene's name, for the one address the Timeline writes to. */
501
+ get scene(): string | null {
502
+ return this.#clip?.scene ?? null;
503
+ }
504
+
505
+ /** Hand Blender back the frame the person is looking at — THE BOOKMARK.
506
+ *
507
+ * ONCE, HERE — never per played frame. `scene.frame_current` is what every
508
+ * bpy reader and the whole Properties rail agree with, so leaving it behind
509
+ * while the picture moved would be the Timeline lying to the rest of the
510
+ * session; writing it sixty times a second would be the architecture this
511
+ * module exists to avoid. The transport's `onSettled` is what calls this —
512
+ * a pause, or the quiet at the end of a scrub — so a slider drag writes
513
+ * once. Moved verbatim from the Timeline look, which is no longer where a
514
+ * playhead write belongs. */
515
+ async writeBookmark(): Promise<number> {
516
+ const frame = Math.round(this.frame());
517
+ const clip = this.#clip;
518
+ if (!clip) return frame;
519
+ const scene = clip.scene;
520
+ if (!scene || clip.frameCurrent === frame) return frame;
521
+ this.#engineCalls++;
522
+ // `rna_set` refuses `bpy.context.scene` — the scene must be addressed by
523
+ // name through `bpy.data.scenes[...]`.
524
+ await blenderRnaSet(`bpy.data.scenes[${JSON.stringify(scene)}]`, 'frame_current', frame);
525
+ this.#clip = { ...clip, frameCurrent: frame };
526
+ this.#publish();
527
+ return frame;
528
+ }
529
+
530
+ /** The handle this skin was attached to, for the Timeline look to drive.
531
+ * Published through the existing `subscribe`/`version`. */
532
+ get transport(): StageTransportHandle | null {
533
+ return this.#transport;
534
+ }
535
+
536
+ /**
537
+ * ATTACH THIS SKIN TO A STAGE'S TRANSPORT — the Model document calls it with
538
+ * its own document id's handle, because that document is the one that HAS
539
+ * the id (the Timeline binds to this singleton and cannot name one).
540
+ *
541
+ * The subject is the action: Blender has no several-clips-per-subject
542
+ * question, so `clips`/`setClip` are deliberately absent. Seconds are the
543
+ * seam; the frames↔seconds conversion is this file's edge and the look's,
544
+ * and nowhere in between.
545
+ */
546
+ attachTo(transport: StageTransportHandle): () => void {
547
+ this.#detach?.();
548
+ this.#transport = transport;
549
+ const detachSubject = transport.attach({
550
+ id: this.#clip?.action ?? 'blender-action',
551
+ label: this.#clip?.action ?? 'Action',
552
+ range: () => {
553
+ const clip = this.#clip;
554
+ const fps = clip?.fps || 24;
555
+ return {
556
+ start: (clip?.frameStart ?? 1) / fps,
557
+ end: (clip?.frameEnd ?? 250) / fps,
558
+ fps,
559
+ };
560
+ },
561
+ seek: (seconds) => this.seekSeconds(seconds),
562
+ });
563
+ const stopSettled = transport.onSettled(() => {
564
+ void this.writeBookmark();
565
+ });
566
+ const detach = () => {
567
+ stopSettled();
568
+ detachSubject();
569
+ if (this.#transport === transport) this.#transport = null;
570
+ this.#detach = null;
571
+ };
572
+ this.#detach = detach;
573
+ // The bookmark READ, if the clip is already loaded when we attach.
574
+ const clip = this.#clip;
575
+ if (clip && clip.fps > 0) transport.seek(clip.frameCurrent / clip.fps);
576
+ return detach;
577
+ }
578
+
579
+ /** The transport's one write, in ITS unit. Frames are Blender's; seconds are
580
+ * the seam's; this is the single conversion on this side. */
581
+ seekSeconds(seconds: number): void {
582
+ const fps = this.#clip?.fps || 24;
583
+ this.#seek(Math.round(seconds * fps));
584
+ this.#publish();
585
+ }
586
+
587
+ /** The frames the summary row draws a diamond at, sorted. */
588
+ keyframes(): readonly number[] {
589
+ return (this.#clip?.keyframes ?? []).map((column) => column.frame);
590
+ }
591
+
592
+ dispose(): void {
593
+ this.#detach?.();
594
+ this.#mixer?.stopAllAction();
595
+ this.#mixer = null;
596
+ this.#action = null;
597
+ for (const rig of this.#rigs.values()) {
598
+ rig.skeleton.dispose();
599
+ rig.boneRoot.removeFromParent();
600
+ }
601
+ this.#rigs.clear();
602
+ }
603
+ }
604
+
605
+ /** THE ONE DIRECTOR — see the class comment for why it is module-scoped. */
606
+ export const blenderSkin = new BlenderSkinDirector();
607
+
608
+ /** STABLE FUNCTION IDENTITIES for `useSyncExternalStore`, and they are not
609
+ * ceremony: passing `blenderSkin.subscribe.bind(blenderSkin)` inline mints a
610
+ * NEW subscribe and a NEW getSnapshot on every render, so React tears the
611
+ * subscription down and rebuilds it each time — measured on the Timeline's
612
+ * first walk, where the view bound its rig and its published `drawn` never
613
+ * moved again because no re-render ever arrived. */
614
+ export function subscribeBlenderSkin(listener: () => void): () => void {
615
+ return blenderSkin.subscribe(listener);
616
+ }
617
+ export function blenderSkinVersion(): number {
618
+ return blenderSkin.version;
619
+ }