@volter/blender-engine 0.1.132 → 0.1.134

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.
@@ -68,17 +68,11 @@ export type WorkerRequest =
68
68
  * Given no `object`, the view layer's active object answers. READ ONLY:
69
69
  * pinning, unwrapping and selecting are edits. */
70
70
  | { id: number; op: 'uv-layout'; object?: string; uvLayer?: string }
71
- /** ONE MESH'S SKIN BINDING (`session.py`'s `rna_rig`) — the armature's bones
72
- * with their rest AND bind matrices, plus up to four weighted influences
73
- * per Blender vertex as base64 typed-array bytes. The BIND pose is the
74
- * pose the exported columns were evaluated at, which is what lets three.js
75
- * own playback without Blender's frame ever moving. READ ONLY. */
76
- | { id: number; op: 'rig'; object?: string }
77
71
  /** ONE ACTION AS A THREE.JS CLIP (`session.py`'s `rna_action_clip`) — per
78
72
  * bone, the LOCAL transform at every integer frame of the action's range,
79
73
  * plus the scene's frame range/fps and the summary row's key columns.
80
74
  * READ ONLY: keying, moving a key and setting a range are edits. */
81
- | { id: number; op: 'action-clip'; object?: string; bake?: boolean }
75
+ | { id: number; op: 'action-clip'; object?: string; bake?: boolean; action?: string }
82
76
  /** ONE RESTRICTION COLUMN, written (`session.py`'s `outliner_set`). A column
83
77
  * Blender draws on no row of that type is refused by name. */
84
78
  | { id: number; op: 'outliner-set'; path: string; column: string; value: boolean }
package/browser/rna.ts CHANGED
@@ -527,79 +527,6 @@ export interface BlenderUvLayout {
527
527
  } | null;
528
528
  }
529
529
 
530
- /**
531
- * ONE MESH'S SKIN BINDING (`session.py`'s `rna_rig`) — what a
532
- * `THREE.SkinnedMesh` needs, and nothing Blender does not carry.
533
- *
534
- * WE VISUALIZE WITH THREE.JS, NOT BLENDER (owner rule, 2026-09-20). Blender
535
- * holds the animation as DATA; three.js plays it. This door and
536
- * {@link BlenderActionClip} are the whole of what crosses to make that true —
537
- * once per rig, rather than a depsgraph evaluation and a re-export per frame.
538
- *
539
- * THE BIND POSE IS `pose`, NOT `rest`, and that is the load-bearing fact: the
540
- * export door runs with `evaluate: True`, so the mesh columns the presenter
541
- * holds are the mesh ALREADY DEFORMED at Blender's current frame. A skeleton
542
- * bound in that same pose makes the skinning an identity there, so the picture
543
- * at the bind frame is exactly the frame Blender presented — and Blender's
544
- * `frame_current` never has to move again for playback.
545
- *
546
- * THE INDICES ARE BLENDER VERTEX INDICES. `DrawArrays.sourceVertex` maps a
547
- * drawn vertex back to its Blender one, and the presenter expands these arrays
548
- * through it (`blender-runtime-skin.ts`) exactly as I4's weight ramp does.
549
- */
550
- export interface BlenderRigBone {
551
- readonly name: string;
552
- readonly parent: string | null;
553
- /** `Bone.matrix_local` — the REST pose in the armature object's space,
554
- * row-major as four rows. Diagnostic here; the bind is `pose`. */
555
- readonly rest: readonly (readonly [number, number, number, number])[];
556
- /** `PoseBone.matrix` (`pchan->pose_mat`), the same matrix I4's bone overlay
557
- * draws — the BIND pose, in the armature object's space. */
558
- readonly pose: readonly (readonly [number, number, number, number])[];
559
- readonly length: number;
560
- readonly deform: boolean;
561
- readonly connected: boolean;
562
- }
563
-
564
- export interface BlenderRigBinding {
565
- readonly object: string | null;
566
- readonly mesh?: string;
567
- readonly armature: string | null;
568
- readonly armatureData?: string;
569
- readonly bones: readonly BlenderRigBone[];
570
- readonly vertexCount: number;
571
- /** `scene.frame_current` when the binding was read — the frame the exported
572
- * columns, and therefore the bind pose, belong to. */
573
- readonly frame?: number;
574
- readonly mode?: string;
575
- /** Bones whose pose is a SOLVER's rather than their channels'. The clip is
576
- * derived from the F-Curves alone, so a constrained bone's playback cannot
577
- * reproduce Blender's own answer; the view names them instead of drifting. */
578
- readonly constrainedBones?: readonly string[];
579
- /** Uint16, `vertexCount * 4`. */
580
- readonly skinIndexBase64?: string;
581
- /** Float32, `vertexCount * 4`, each vertex's four normalized to sum to one. */
582
- readonly skinWeightBase64?: string;
583
- readonly maxInfluences?: number;
584
- readonly truncatedVertices?: number;
585
- readonly unweightedVertices?: number;
586
- readonly unmappedGroups?: readonly string[];
587
- /** Why there is no binding, when there is none. Never a silent empty. */
588
- readonly reason?: string;
589
- }
590
-
591
- /** EVERY SKIN BINDING THE SCENE NEEDS, which is the PRESENTER's question: a
592
- * presented frame carries every object at once and the presenter has to know
593
- * which of its meshes are `THREE.SkinnedMesh`es before it builds them. With
594
- * `named` set, the list is that one mesh. */
595
- export interface BlenderRig {
596
- /** `scene.frame_current` when the bindings were read — the frame the
597
- * exported columns, and therefore every bind pose here, belong to. */
598
- readonly frame: number;
599
- readonly named: string | null;
600
- readonly rigs: readonly BlenderRigBinding[];
601
- }
602
-
603
530
  /**
604
531
  * ONE TRACK of {@link BlenderActionClip}: a bone's LOCAL `position`,
605
532
  * `quaternion` or `scale` sampled at every integer frame.
@@ -21,7 +21,6 @@ import type {
21
21
  BlenderNodeTree,
22
22
  BlenderOutlinerTree,
23
23
  BlenderOutlinerWrite,
24
- BlenderRig,
25
24
  BlenderRnaContext,
26
25
  BlenderRnaView,
27
26
  BlenderRnaWrite,
@@ -408,30 +407,22 @@ export class BlenderRuntime {
408
407
  })) as BlenderUvLayout;
409
408
  }
410
409
 
411
- /** ONE MESH'S SKIN BINDING (`./rna.ts`, `BlenderRig`) — the armature's
412
- * bones and the per-vertex influences a `THREE.SkinnedMesh` needs. Given no
413
- * `object`, the view layer's ACTIVE mesh answers. */
414
- async rig(options?: { object?: string }): Promise<BlenderRig> {
415
- await this.#ready();
416
- return (await this.#request({
417
- op: 'rig',
418
- ...(options?.object === undefined ? {} : { object: options.object }),
419
- })) as BlenderRig;
420
- }
421
410
 
422
411
  /** ONE ACTION AS A THREE.JS CLIP (`./rna.ts`, `BlenderActionClip`). `bake:
423
412
  * false` answers the header and the summary row's key columns without the
424
413
  * sampled tracks, which is what a Timeline that only needs to DRAW asks
425
414
  * for. */
426
- async actionClip(options?: { object?: string; bake?: boolean }): Promise<BlenderActionClip> {
415
+ async actionClip(options?: { object?: string; bake?: boolean; action?: string }): Promise<BlenderActionClip> {
427
416
  await this.#ready();
428
417
  return (await this.#request({
429
418
  op: 'action-clip',
430
419
  ...(options?.object === undefined ? {} : { object: options.object }),
431
420
  ...(options?.bake === undefined ? {} : { bake: options.bake }),
421
+ ...(options?.action === undefined ? {} : { action: options.action }),
432
422
  })) as BlenderActionClip;
433
423
  }
434
424
 
425
+
435
426
  /** Write ONE restriction column (the eye, the render camera, a collection's
436
427
  * Exclude). A column Blender draws on no row of that type is refused by
437
428
  * name rather than written somewhere near it. */
@@ -1372,6 +1372,12 @@ def _armatures(view_layer):
1372
1372
  # `ARM_DRAW_MODE_*` switch, `:906-936`).
1373
1373
  "mode": obj.mode,
1374
1374
  "bones": _armature_bones(obj, locked),
1375
+ # THE ACTION IT PLAYS (`animation_data.action`): what the presenter's mixer plays on
1376
+ # this armature's skeleton, baked through the clip door at the revision the frame's
1377
+ # `actions` names for it.
1378
+ "action": (obj.animation_data.action.name
1379
+ if obj.animation_data is not None and obj.animation_data.action is not None
1380
+ else None),
1375
1381
  }
1376
1382
  return armatures
1377
1383
 
@@ -1642,6 +1648,9 @@ class Session:
1642
1648
  "scale_length": float(scene.unit_settings.scale_length),
1643
1649
  }
1644
1650
  frame["armatures"] = _armatures(view_layer)
1651
+ # THE SCENE'S CLOCK beside the frame number: what a Timeline draws and a mixer maps time by.
1652
+ frame["clock"] = {"start": int(scene.frame_start), "end": int(scene.frame_end),
1653
+ "fps": float(scene.render.fps) / float(scene.render.fps_base or 1.0)}
1645
1654
  frame["weights"] = _weights(scene, view_layer, frame, self._known)
1646
1655
  warnings = list(frame.get("warnings", ()))
1647
1656
  warnings.extend(_WARNINGS)
@@ -4617,31 +4626,6 @@ def _matrix_rows(matrix):
4617
4626
  return [[float(v) for v in row] for row in matrix]
4618
4627
 
4619
4628
 
4620
- def rna_rig(object_name=None):
4621
- """EVERY SKIN BINDING THE SCENE NEEDS -- or one named mesh's.
4622
-
4623
- A LIST, not a singleton, and that is what the PRESENTER's question is: a
4624
- presented frame carries every object at once, and the presenter has to know
4625
- which of its meshes are `THREE.SkinnedMesh`es before it builds them. Asking
4626
- per object would be one round trip per mesh; asking by NAME stays available
4627
- for a `cyclotron eval` that wants to read one.
4628
- """
4629
- scene_frame = int(bpy.context.scene.frame_current)
4630
- if object_name:
4631
- obj = bpy.data.objects.get(object_name)
4632
- if obj is None:
4633
- raise ValueError("The engine holds no object named %r" % (object_name,))
4634
- subjects = [obj]
4635
- else:
4636
- subjects = [obj for obj in bpy.context.view_layer.objects
4637
- if obj.type == "MESH" and obj.data is not None
4638
- and obj.find_armature() is not None]
4639
- rigs = []
4640
- for obj in subjects:
4641
- _asked({"checkpoint": "rig-object"})
4642
- rigs.append(_rig_of(obj))
4643
- return {"frame": scene_frame, "named": object_name, "rigs": rigs}
4644
-
4645
4629
 
4646
4630
  def _rig_of(obj):
4647
4631
  """ONE MESH'S SKIN BINDING: the armature's bones, and up to four weighted
@@ -4841,7 +4825,7 @@ def _summary_objects():
4841
4825
  return out
4842
4826
 
4843
4827
 
4844
- def rna_action_clip(object_name=None, bake=True):
4828
+ def rna_action_clip(object_name=None, bake=True, action_name=None):
4845
4829
  """ONE ACTION AS A THREE.JS CLIP: per bone, the LOCAL transform it has at
4846
4830
  every integer frame of the action's own range.
4847
4831
 
@@ -4910,13 +4894,32 @@ def rna_action_clip(object_name=None, bake=True):
4910
4894
  # when the subject below turns out to be nothing at all.
4911
4895
  "summary": _summary_objects(),
4912
4896
  }
4913
- if arm_obj is None or arm_obj.animation_data is None or arm_obj.animation_data.action is None:
4897
+ adt = arm_obj.animation_data if arm_obj is not None else None
4898
+ assigned = adt.action if adt is not None else None
4899
+ if arm_obj is None or (assigned is None and not action_name):
4914
4900
  header["reason"] = ("Nothing here carries an action: the Timeline draws the scene range "
4915
4901
  "and an empty summary row, which is Blender's own empty state.")
4916
4902
  return header
4917
- adt = arm_obj.animation_data
4918
- action = adt.action
4919
- slot = getattr(adt, "action_slot", None)
4903
+ action = assigned
4904
+ slot = getattr(adt, "action_slot", None) if adt is not None else None
4905
+ if action_name and (assigned is None or action_name != assigned.name):
4906
+ # A NAMED ACTION, assigned or not (a game's clip library: Idle, Run, Attack side by side):
4907
+ # baked against this armature WITHOUT assigning it, so the file is untouched. Its curves
4908
+ # are its first channelbag's (no slot of this object chooses among them).
4909
+ action = bpy.data.actions.get(action_name)
4910
+ if action is None:
4911
+ raise ValueError("The engine holds no action named %r" % (action_name,))
4912
+ # THE SLOT FOR THIS ARMATURE, as Blender would pick it on assignment: the one whose
4913
+ # identifier names this object (`OB<name>`), else the only object slot. A layered action
4914
+ # with several object slots and none for this armature has no right answer; its first
4915
+ # object slot is used and said in the reason.
4916
+ slots = [one for one in getattr(action, "slots", ()) if getattr(one, "target_id_type", "OBJECT") == "OBJECT"]
4917
+ slot = next((one for one in slots if getattr(one, "identifier", "") == "OB" + arm_obj.name), None)
4918
+ if slot is None and slots:
4919
+ slot = slots[0]
4920
+ if len(slots) > 1:
4921
+ header["reason"] = ("%r has %d object slots and none named for %r; the first, %r, is played."
4922
+ % (action.name, len(slots), arm_obj.name, getattr(slot, "identifier", "?")))
4920
4923
  curves, shape = _action_channelbag(action, getattr(slot, "handle", None))
4921
4924
  header.update({
4922
4925
  "action": action.name,
@@ -5185,13 +5188,44 @@ def rna_outliner(selected=None):
5185
5188
  parent_row["children"].append(duplicate)
5186
5189
  mine.append([duplicate, parent_row, parent_collection])
5187
5190
 
5191
+ # THE SELECTION KEEPS ITS ROWS PAST THE PAGE. A row is how the editor
5192
+ # addresses an object -- the viewport's outline and gizmo, the Properties
5193
+ # rail -- so a selected or active object, the caller's or the engine's, keeps
5194
+ # its row and every row above it however far down a long list it sits.
5195
+ # BOUNDED LIKE THE PAGE ITSELF: the active object always, then at most one
5196
+ # page of the selection, so selecting everything cannot turn one read back
5197
+ # into the whole tree the page exists to keep out (Canyon Comet, 1,493
5198
+ # objects selected: 1.77 MB a read uncapped, 142 KB capped).
5199
+ wanted = [active] if active is not None else []
5200
+ wanted += [o.name for o in view_layer.objects if o.select_get(view_layer=view_layer)]
5201
+ wanted += sorted(chosen)
5202
+ kept = set()
5203
+ for name in wanted:
5204
+ if len(kept) > _OUTLINER_PAGE:
5205
+ break
5206
+ kept.add(name)
5207
+ holding = set()
5208
+
5209
+ def hold(row):
5210
+ inside = row.get("struct") == "Object" and row.get("object") in kept
5211
+ for child in row.get("children", []):
5212
+ inside = hold(child) or inside
5213
+ if inside:
5214
+ holding.add(id(row))
5215
+ return inside
5216
+
5217
+ hold(scene_collection)
5218
+
5188
5219
  # Cap the hierarchy's visible child lists, not the collection's flat input.
5189
5220
  # Every parent participates in folding even when its child list needs a page.
5190
5221
  def page_tree(row):
5191
- children, more = _outliner_page(row.get("children", []))
5222
+ members = row.get("children", [])
5223
+ children, more = _outliner_page(members)
5224
+ beyond = [child for child in members[len(children):] if id(child) in holding]
5225
+ children = children + beyond
5192
5226
  row["children"] = children
5193
- if more:
5194
- row["more"] = more
5227
+ if more - len(beyond):
5228
+ row["more"] = more - len(beyond)
5195
5229
  for child in children:
5196
5230
  page_tree(child)
5197
5231
 
@@ -5448,10 +5482,8 @@ def _dispatch(request):
5448
5482
  # THE RIG AND CLIP DOORS (the Timeline): both READS, so neither presents
5449
5483
  # and neither owes a derivation. There is deliberately no writer beside
5450
5484
  # them -- keying, moving a key and setting a range are edits.
5451
- if op == "rig":
5452
- return rna_rig(request.get("object"))
5453
5485
  if op == "action-clip":
5454
- return rna_action_clip(request.get("object"), request.get("bake", True))
5486
+ return rna_action_clip(request.get("object"), request.get("bake", True), request.get("action"))
5455
5487
  if op == "outliner":
5456
5488
  return rna_outliner(request.get("selected"))
5457
5489
  if op == "outliner-set":
@@ -61,6 +61,8 @@ export const armatureSchema = z
61
61
  showInFront: z.boolean(),
62
62
  mode: z.string(),
63
63
  bones: z.array(boneSchema),
64
+ /** `animation_data.action`, the action it plays (`session.py` `_armatures`). */
65
+ action: z.string().nullable().optional(),
64
66
  })
65
67
  .strict();
66
68
 
@@ -277,7 +277,7 @@ export function graphAttributeName(name: string): string {
277
277
  }
278
278
 
279
279
  export function geometryFromDrawArrays(
280
- arrays: Omit<DrawArrays, 'hash' | 'sourceVertex'> & { sourceVertex?: Uint32Array },
280
+ arrays: Omit<DrawArrays, 'hash' | 'sourceVertex'> & { sourceVertex?: Uint32Array | undefined },
281
281
  ): THREE.BufferGeometry {
282
282
  const geometry = new THREE.BufferGeometry();
283
283
  geometry.setAttribute('position', new THREE.BufferAttribute(arrays.positions, 3));
@@ -0,0 +1,204 @@
1
+ /**
2
+ * THE SKELETONS A FRAME DESCRIBES, and the skinned meshes bound to them.
3
+ *
4
+ * WHERE THE SKIN COMES FROM: the export door itself (`bpy_web_export.cc`, `write_skin`). Every mesh
5
+ * an Armature modifier deforms arrives with a `skin` block: the armature's name, the mesh's vertex
6
+ * group names and, per EXPORTED vertex, its four strongest groups and their normalized weights. The
7
+ * frame's `armatures` already name every bone with its parent and its pose matrix
8
+ * (`PoseBone.matrix`, armature space). So one frame holds everything a skin needs, from one
9
+ * evaluation, and nothing here reads the engine or guesses whether what it holds is stale: a weight
10
+ * edit, a modifier added or removed, a new pose, each re-exports the mesh under a new revision (the
11
+ * door's skin digest moves it) and the skin is rebuilt with the geometry.
12
+ *
13
+ * THE BIND IS THE EXPORTED POSE. The exported vertices are already deformed, at the pose the frame
14
+ * names, so each bone's inverse is that pose's inverse: at Blender's own pose the skinning is an
15
+ * identity and the picture is exactly the one Blender evaluated. A clip played on the bones
16
+ * (`AnimationMixer` over `rig().bones`) moves the mesh from there. The inverses come from the
17
+ * frame's matrices, never from the bones' current transforms, so a bind taken while a clip is
18
+ * playing is still right.
19
+ *
20
+ * AN UNWEIGHTED VERTEX stays where it is, as in Blender: the door gives it the index one past the
21
+ * groups with weight 1, and that index is a bone at the armature's origin that never moves. A group
22
+ * no bone is named for weights nothing (it is that same still bone), as it deforms nothing in Blender.
23
+ */
24
+ import * as THREE from 'three';
25
+ import type { BlenderArmature } from './blender-runtime-armature';
26
+
27
+ /** The `skin` block of an exported mesh, as the door writes it (typed arrays by the worker). */
28
+ export interface FrameSkin {
29
+ readonly armature: string;
30
+ readonly groups: readonly string[];
31
+ readonly columns: { readonly joints: Uint16Array; readonly weights: Float32Array };
32
+ }
33
+
34
+ export interface ArmatureRig {
35
+ /** The armature this rig belongs to, by Blender object name. */
36
+ readonly armature: string;
37
+ /** The armature's presented object, which the bones hang under. */
38
+ readonly object: THREE.Object3D;
39
+ /** Every bone by Blender's own bone name (unsanitized: a game finds them by those names). */
40
+ readonly bones: ReadonlyMap<string, THREE.Bone>;
41
+ /** The action the file assigns this armature (`animation_data.action`), if any. */
42
+ readonly action: string | null;
43
+ }
44
+
45
+ interface Rig extends ArmatureRig {
46
+ readonly root: THREE.Group;
47
+ readonly still: THREE.Bone;
48
+ /** Bone names and parents: a change rebuilds the bones. */
49
+ readonly structure: string;
50
+ /** The pose matrices last applied, armature space, by bone name. */
51
+ pose: Map<string, THREE.Matrix4>;
52
+ poseKey: string;
53
+ action: string | null;
54
+ }
55
+
56
+ const matrixOf = (rows: readonly (readonly number[])[]): THREE.Matrix4 =>
57
+ new THREE.Matrix4().set(...(rows.flat() as Parameters<THREE.Matrix4['set']>));
58
+
59
+ /** Expand an exported mesh's per-vertex skin onto its DRAWN vertices (`blenderVertex`, the draw's
60
+ * map back to Blender's vertices), as the geometry's `skinIndex` and `skinWeight`. */
61
+ export function skinGeometry(geometry: THREE.BufferGeometry, skin: FrameSkin): void {
62
+ const source = geometry.getAttribute('blenderVertex');
63
+ if (!source) throw new Error(`A skinned mesh of ${skin.armature} was drawn without its vertex map`);
64
+ const drawn = source.count;
65
+ const indices = new Uint16Array(drawn * 4);
66
+ const weights = new Float32Array(drawn * 4);
67
+ for (let i = 0; i < drawn; i++) {
68
+ const vertex = source.getX(i);
69
+ indices.set(skin.columns.joints.subarray(vertex * 4, vertex * 4 + 4), i * 4);
70
+ weights.set(skin.columns.weights.subarray(vertex * 4, vertex * 4 + 4), i * 4);
71
+ }
72
+ geometry.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(indices, 4));
73
+ geometry.setAttribute('skinWeight', new THREE.Float32BufferAttribute(weights, 4));
74
+ geometry.userData['blenderSkin'] = { armature: skin.armature, groups: [...skin.groups] };
75
+ }
76
+
77
+ export class RuntimeSkeletons {
78
+ #rigs = new Map<string, Rig>();
79
+
80
+ /** The rig of one armature, or null when the frame names none by that name. */
81
+ rig(armature: string): ArmatureRig | null {
82
+ return this.#rigs.get(armature) ?? null;
83
+ }
84
+
85
+ rigs(): readonly ArmatureRig[] {
86
+ return [...this.#rigs.values()];
87
+ }
88
+
89
+ /**
90
+ * Bring the rigs to this frame: build an armature's bones when it is new or its bones changed,
91
+ * and pose them as Blender posed them WHEN THAT POSE CHANGED, so a clip a reader is playing is
92
+ * not reset by a frame that says nothing new about the pose.
93
+ */
94
+ apply(armatures: Readonly<Record<string, BlenderArmature>>,
95
+ objectFor: (name: string) => THREE.Object3D | null): void {
96
+ for (const [name, rig] of [...this.#rigs]) {
97
+ const armature = armatures[name];
98
+ const object = objectFor(name);
99
+ if (!armature || !object || object !== rig.object) {
100
+ rig.root.removeFromParent();
101
+ this.#rigs.delete(name);
102
+ }
103
+ }
104
+ for (const [name, armature] of Object.entries(armatures)) {
105
+ const object = objectFor(name);
106
+ if (!object) continue;
107
+ const structure = armature.bones.map((bone) => `${bone.name}\u0000${bone.parent ?? ''}`).join('\u0001');
108
+ let rig = this.#rigs.get(name);
109
+ if (rig && rig.structure !== structure) {
110
+ rig.root.removeFromParent();
111
+ rig = undefined;
112
+ }
113
+ if (!rig) {
114
+ const root = new THREE.Group();
115
+ root.name = `${name}:bones`;
116
+ const still = new THREE.Bone();
117
+ still.name = `${name}:still`;
118
+ root.add(still);
119
+ const bones = new Map<string, THREE.Bone>();
120
+ for (const declared of armature.bones) {
121
+ const bone = new THREE.Bone();
122
+ bone.name = declared.name;
123
+ bones.set(declared.name, bone);
124
+ }
125
+ for (const declared of armature.bones) {
126
+ const parent = declared.parent === null ? undefined : bones.get(declared.parent);
127
+ (parent ?? root).add(bones.get(declared.name)!);
128
+ }
129
+ object.add(root);
130
+ rig = { armature: name, object, root, still, bones, structure, pose: new Map(), poseKey: '', action: null };
131
+ this.#rigs.set(name, rig);
132
+ }
133
+ rig.action = armature.action ?? null;
134
+ const poseKey = armature.bones.map((bone) => bone.matrix.flat().join(',')).join(';');
135
+ if (poseKey !== rig.poseKey) {
136
+ rig.poseKey = poseKey;
137
+ rig.pose = new Map(armature.bones.map((bone) => [bone.name, matrixOf(bone.matrix)]));
138
+ RuntimeSkeletons.#pose(rig, armature);
139
+ }
140
+ }
141
+ }
142
+
143
+ /** Set every bone's local transform from the frame's armature-space pose matrices. */
144
+ static #pose(rig: Rig, armature: BlenderArmature): void {
145
+ for (const declared of armature.bones) {
146
+ const own = rig.pose.get(declared.name)!;
147
+ const parent = declared.parent === null ? undefined : rig.pose.get(declared.parent);
148
+ const local = parent ? parent.clone().invert().multiply(own) : own.clone();
149
+ const bone = rig.bones.get(declared.name)!;
150
+ local.decompose(bone.position, bone.quaternion, bone.scale);
151
+ }
152
+ }
153
+
154
+ /** Put Blender's pose back on an armature's bones, after a reader that played a clip lets go. */
155
+ restorePose(armature: string): void {
156
+ const rig = this.#rigs.get(armature);
157
+ if (!rig) return;
158
+ for (const [name, own] of rig.pose) {
159
+ const bone = rig.bones.get(name)!;
160
+ const parentBone = bone.parent instanceof THREE.Bone && bone.parent !== rig.still ? bone.parent : null;
161
+ const parent = parentBone ? rig.pose.get(parentBone.name) : undefined;
162
+ const local = parent ? parent.clone().invert().multiply(own) : own.clone();
163
+ local.decompose(bone.position, bone.quaternion, bone.scale);
164
+ }
165
+ }
166
+
167
+ /**
168
+ * A mesh object's skinned form: a `THREE.SkinnedMesh` over its geometry, bound to its armature's
169
+ * rig at the pose the frame was exported in. Null when its geometry carries no skin or its
170
+ * armature is not in the frame (it then draws as the plain, exported mesh: Blender's own pose).
171
+ * The world matrices must be current (`root.updateMatrixWorld(true)`) before this is called.
172
+ */
173
+ bind(mesh: THREE.SkinnedMesh | THREE.Mesh): THREE.SkinnedMesh | null {
174
+ const skin = mesh.geometry.userData['blenderSkin'] as { armature: string; groups: string[] } | undefined;
175
+ const rig = skin ? this.#rigs.get(skin.armature) : undefined;
176
+ if (!skin || !rig) return null;
177
+ const skinned = (mesh as THREE.SkinnedMesh).isSkinnedMesh ? mesh as THREE.SkinnedMesh
178
+ : new THREE.SkinnedMesh(mesh.geometry, mesh.material);
179
+ if (skinned !== mesh) {
180
+ skinned.name = mesh.name;
181
+ skinned.matrixAutoUpdate = mesh.matrixAutoUpdate;
182
+ skinned.matrix.copy(mesh.matrix);
183
+ skinned.matrix.decompose(skinned.position, skinned.quaternion, skinned.scale);
184
+ skinned.userData = mesh.userData;
185
+ // A skin moves past its bind bounds: three computes them from the bind pose.
186
+ skinned.frustumCulled = false;
187
+ }
188
+ const armatureWorld = rig.object.matrixWorld;
189
+ const bones = [...skin.groups.map((group) => rig.bones.get(group) ?? rig.still), rig.still];
190
+ const inverses = bones.map((bone) => {
191
+ const pose = bone === rig.still ? undefined : rig.pose.get(bone.name);
192
+ const world = pose ? armatureWorld.clone().multiply(pose) : armatureWorld.clone();
193
+ return world.invert();
194
+ });
195
+ mesh.updateWorldMatrix(true, false);
196
+ skinned.bind(new THREE.Skeleton(bones, inverses), mesh.matrixWorld.clone());
197
+ return skinned;
198
+ }
199
+
200
+ dispose(): void {
201
+ for (const rig of this.#rigs.values()) rig.root.removeFromParent();
202
+ this.#rigs.clear();
203
+ }
204
+ }