@volter/blender-engine 0.1.132 → 0.1.133

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.
@@ -78,7 +78,10 @@ export type WorkerRequest =
78
78
  * bone, the LOCAL transform at every integer frame of the action's range,
79
79
  * plus the scene's frame range/fps and the summary row's key columns.
80
80
  * READ ONLY: keying, moving a key and setting a range are edits. */
81
- | { id: number; op: 'action-clip'; object?: string; bake?: boolean }
81
+ | { id: number; op: 'action-clip'; object?: string; bake?: boolean; action?: string }
82
+ /** EVERY ARMATURE AND THE ACTIONS THAT CAN PLAY ON IT (`session.py`'s `rna_armature_actions`):
83
+ * each action that animates one of its pose bones, assigned or not. READ ONLY. */
84
+ | { id: number; op: 'armature-actions' }
82
85
  /** ONE RESTRICTION COLUMN, written (`session.py`'s `outliner_set`). A column
83
86
  * Blender draws on no row of that type is refused by name. */
84
87
  | { id: number; op: 'outliner-set'; path: string; column: string; value: boolean }
@@ -423,15 +423,22 @@ export class BlenderRuntime {
423
423
  * false` answers the header and the summary row's key columns without the
424
424
  * sampled tracks, which is what a Timeline that only needs to DRAW asks
425
425
  * for. */
426
- async actionClip(options?: { object?: string; bake?: boolean }): Promise<BlenderActionClip> {
426
+ async actionClip(options?: { object?: string; bake?: boolean; action?: string }): Promise<BlenderActionClip> {
427
427
  await this.#ready();
428
428
  return (await this.#request({
429
429
  op: 'action-clip',
430
430
  ...(options?.object === undefined ? {} : { object: options.object }),
431
431
  ...(options?.bake === undefined ? {} : { bake: options.bake }),
432
+ ...(options?.action === undefined ? {} : { action: options.action }),
432
433
  })) as BlenderActionClip;
433
434
  }
434
435
 
436
+ /** Every armature and the actions that can play on it, assigned or not (a game's clip library). */
437
+ async armatureActions(): Promise<{ armatures: { armature: string; current: string | null; actions: string[]; keys?: string }[]; scene?: { frameCurrent: number; frameStart: number; frameEnd: number; fps: number } }> {
438
+ await this.#ready();
439
+ return (await this.#request({ op: 'armature-actions' })) as { armatures: { armature: string; current: string | null; actions: string[]; keys?: string }[]; scene?: { frameCurrent: number; frameStart: number; frameEnd: number; fps: number } };
440
+ }
441
+
435
442
  /** Write ONE restriction column (the eye, the render camera, a collection's
436
443
  * Exclude). A column Blender draws on no row of that type is refused by
437
444
  * name rather than written somewhere near it. */
@@ -4841,7 +4841,51 @@ def _summary_objects():
4841
4841
  return out
4842
4842
 
4843
4843
 
4844
- def rna_action_clip(object_name=None, bake=True):
4844
+ def rna_armature_actions():
4845
+ """EVERY ARMATURE AND THE ACTIONS THAT CAN PLAY ON IT -- what a game picks its clips from.
4846
+
4847
+ An action can play on an armature when it animates at least one of that armature's pose bones
4848
+ (`pose.bones["<name>"]...` on a bone the armature has). Read off each action's F-Curves through
4849
+ `_action_channelbag`, every channelbag, so an action assigned to nothing (a clip library: Idle,
4850
+ Run, Attack beside each other in the file) is offered as well as the one each armature plays now.
4851
+ READ ONLY."""
4852
+ out = []
4853
+ for arm in bpy.data.objects:
4854
+ if arm.type != "ARMATURE" or arm.pose is None:
4855
+ continue
4856
+ bones = {pchan.name for pchan in arm.pose.bones}
4857
+ current = arm.animation_data.action.name if (arm.animation_data and arm.animation_data.action) else None
4858
+ names = []
4859
+ keys, total = 0, 0.0
4860
+ for action in bpy.data.actions:
4861
+ curves = []
4862
+ legacy = getattr(action, "fcurves", None)
4863
+ if legacy is not None:
4864
+ curves = list(legacy)
4865
+ else:
4866
+ for layer in getattr(action, "layers", ()):
4867
+ for strip in getattr(layer, "strips", ()):
4868
+ for bag in (getattr(strip, "channelbags", None) or ()):
4869
+ curves.extend(bag.fcurves)
4870
+ if any((m := _BONE_PATH.match(fc.data_path)) is not None and m.group(1) in bones for fc in curves):
4871
+ names.append(action.name)
4872
+ # A CHEAP SIGNATURE of the keys, so a reader can tell a key edit from no change
4873
+ # without baking: the count and the sum of every key's frame and value.
4874
+ for fc in curves:
4875
+ for key in fc.keyframe_points:
4876
+ keys += 1
4877
+ total += float(key.co[0]) * 7.0 + float(key.co[1])
4878
+ out.append({"armature": arm.name, "current": current, "actions": sorted(names),
4879
+ "keys": "%d:%.6f" % (keys, total)})
4880
+ scene = bpy.context.scene
4881
+ # THE SCENE'S CLOCK TOO: a reader that skips a re-bind on an unchanged listing must still see
4882
+ # the playhead and range move when an agent sets them.
4883
+ return {"armatures": out, "scene": {"frameCurrent": int(scene.frame_current),
4884
+ "frameStart": int(scene.frame_start), "frameEnd": int(scene.frame_end),
4885
+ "fps": float(scene.render.fps) / float(scene.render.fps_base or 1.0)}}
4886
+
4887
+
4888
+ def rna_action_clip(object_name=None, bake=True, action_name=None):
4845
4889
  """ONE ACTION AS A THREE.JS CLIP: per bone, the LOCAL transform it has at
4846
4890
  every integer frame of the action's own range.
4847
4891
 
@@ -4910,13 +4954,32 @@ def rna_action_clip(object_name=None, bake=True):
4910
4954
  # when the subject below turns out to be nothing at all.
4911
4955
  "summary": _summary_objects(),
4912
4956
  }
4913
- if arm_obj is None or arm_obj.animation_data is None or arm_obj.animation_data.action is None:
4957
+ adt = arm_obj.animation_data if arm_obj is not None else None
4958
+ assigned = adt.action if adt is not None else None
4959
+ if arm_obj is None or (assigned is None and not action_name):
4914
4960
  header["reason"] = ("Nothing here carries an action: the Timeline draws the scene range "
4915
4961
  "and an empty summary row, which is Blender's own empty state.")
4916
4962
  return header
4917
- adt = arm_obj.animation_data
4918
- action = adt.action
4919
- slot = getattr(adt, "action_slot", None)
4963
+ action = assigned
4964
+ slot = getattr(adt, "action_slot", None) if adt is not None else None
4965
+ if action_name and (assigned is None or action_name != assigned.name):
4966
+ # A NAMED ACTION, assigned or not (a game's clip library: Idle, Run, Attack side by side):
4967
+ # baked against this armature WITHOUT assigning it, so the file is untouched. Its curves
4968
+ # are its first channelbag's (no slot of this object chooses among them).
4969
+ action = bpy.data.actions.get(action_name)
4970
+ if action is None:
4971
+ raise ValueError("The engine holds no action named %r" % (action_name,))
4972
+ # THE SLOT FOR THIS ARMATURE, as Blender would pick it on assignment: the one whose
4973
+ # identifier names this object (`OB<name>`), else the only object slot. A layered action
4974
+ # with several object slots and none for this armature has no right answer; its first
4975
+ # object slot is used and said in the reason.
4976
+ slots = [one for one in getattr(action, "slots", ()) if getattr(one, "target_id_type", "OBJECT") == "OBJECT"]
4977
+ slot = next((one for one in slots if getattr(one, "identifier", "") == "OB" + arm_obj.name), None)
4978
+ if slot is None and slots:
4979
+ slot = slots[0]
4980
+ if len(slots) > 1:
4981
+ header["reason"] = ("%r has %d object slots and none named for %r; the first, %r, is played."
4982
+ % (action.name, len(slots), arm_obj.name, getattr(slot, "identifier", "?")))
4920
4983
  curves, shape = _action_channelbag(action, getattr(slot, "handle", None))
4921
4984
  header.update({
4922
4985
  "action": action.name,
@@ -5451,7 +5514,9 @@ def _dispatch(request):
5451
5514
  if op == "rig":
5452
5515
  return rna_rig(request.get("object"))
5453
5516
  if op == "action-clip":
5454
- return rna_action_clip(request.get("object"), request.get("bake", True))
5517
+ return rna_action_clip(request.get("object"), request.get("bake", True), request.get("action"))
5518
+ if op == "armature-actions":
5519
+ return rna_armature_actions()
5455
5520
  if op == "outliner":
5456
5521
  return rna_outliner(request.get("selected"))
5457
5522
  if op == "outliner-set":
@@ -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));
@@ -147,6 +147,8 @@ const drawArraysSchema = z
147
147
  })
148
148
  .strict(),
149
149
  ),
150
+ /** The draw's vertex map (`DrawArrays.sourceVertex`), kept by a copy that plays (skins bind over it). */
151
+ sourceVertex: z.instanceof(Uint32Array).optional(),
150
152
  hash: z.string(),
151
153
  })
152
154
  .strict();
@@ -2248,7 +2250,7 @@ export class BlenderRuntimeView {
2248
2250
  /** Everything this view holds, as ONE self-contained frame another view can be built from:
2249
2251
  * every mesh's resident geometry and every resident image, with no reference to what a
2250
2252
  * receiver already holds. */
2251
- private fullFrame(): Frame {
2253
+ private fullFrame(options: { sourceVertices?: boolean } = {}): Frame {
2252
2254
  const source = this.frame;
2253
2255
  if (!source) throw new Error('Blender capture requires a presented frame');
2254
2256
  // Inspection overlays are not render content. In particular weight-paint
@@ -2290,6 +2292,12 @@ export class BlenderRuntimeView {
2290
2292
  count: group.count,
2291
2293
  materialIndex: group.materialIndex ?? 0,
2292
2294
  })),
2295
+ // A COPY THAT PLAYS keeps the draw's vertex map: Play binds skins over it (a Blender
2296
+ // vertex's weights expand onto its drawn vertices through it), where a render snapshot
2297
+ // needs none.
2298
+ ...(options.sourceVertices && geometry.getAttribute('blenderVertex')
2299
+ ? { sourceVertex: new Uint32Array((geometry.getAttribute('blenderVertex') as THREE.BufferAttribute).array) }
2300
+ : {}),
2293
2301
  };
2294
2302
  }
2295
2303
  for (const [id, { signature }] of this.volumes)
@@ -2368,7 +2376,7 @@ export class BlenderRuntimeView {
2368
2376
  */
2369
2377
  detach(): { readonly view: BlenderRuntimeView; dispose(): void } {
2370
2378
  const detached = new BlenderRuntimeView();
2371
- if (this.frame) detached.applyFrame(this.fullFrame());
2379
+ if (this.frame) detached.applyFrame(this.fullFrame({ sourceVertices: true }));
2372
2380
  let disposed = false;
2373
2381
  return {
2374
2382
  view: detached,
package/browser/worker.ts CHANGED
@@ -806,7 +806,10 @@ async function handle(request: WorkerRequest): Promise<unknown> {
806
806
  op: 'action-clip',
807
807
  ...(request.object === undefined ? {} : { object: request.object }),
808
808
  ...(request.bake === undefined ? {} : { bake: request.bake }),
809
+ ...(request.action === undefined ? {} : { action: request.action }),
809
810
  });
811
+ case 'armature-actions':
812
+ return ask({ op: 'armature-actions' });
810
813
  case 'outliner-set':
811
814
  return ask({
812
815
  op: 'outliner-set',
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@volter/blender-engine",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "GPL-3.0-or-later",
5
- "version": "0.1.132",
5
+ "version": "0.1.133",
6
6
  "publishConfig": {
7
7
  "access": "public"
8
8
  },