@deepblend/dsh-blender-provider-local 0.2.15 → 0.2.17

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-provider-local",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "type": "module",
5
5
  "description": "Local Blender Provider: the BlenderRuntime Service over ctx.subprocess (batch execution).",
6
6
  "main": "lib/index.js",
@@ -11,7 +11,7 @@
11
11
  "./package.json": "./package.json"
12
12
  },
13
13
  "dependencies": {
14
- "@deepblend/dsh-blender-contracts": "0.2.15"
14
+ "@deepblend/dsh-blender-contracts": "0.2.17"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {
@@ -233,6 +233,13 @@ def action_compile_scene(request, options):
233
233
  # WHICH ENTITIES ARE SKINNED, AND TO WHAT. The vertex groups are the evidence that weights
234
234
  # exist at all: an entity bound to an armature with no groups renders as a static object while
235
235
  # the file says it is rigged, and the reading is where that difference becomes visible.
236
+ # WHAT THE PHYSICS DID, per entity. Not the settings: a body that is configured and never
237
+ # enabled looks exactly like one that fell, and only a reading of where it ended up tells them
238
+ # apart.
239
+ "simulation": [
240
+ {"entityId": entity_id, **entry}
241
+ for entity_id, entry in sorted((report.get("simulation") or {}).items())
242
+ ],
236
243
  "skinned": [
237
244
  {
238
245
  "entityId": entity_id,
@@ -1030,6 +1030,82 @@ def _apply_interpolation(action, data_path, axis, keyframes, track, guard):
1030
1030
  )
1031
1031
 
1032
1032
 
1033
+ def _insert_keyframes(target, bpy_attr, axis, keyframes):
1034
+ """Insert one component's keyframes on a target that has `keyframe_insert`.
1035
+
1036
+ EXTRACTED RATHER THAN COPIED: an object and a pose bone are keyframed by the same three lines, and
1037
+ a second copy is the one that would miss the next fix — the identity-first rule below is exactly the
1038
+ kind of thing that gets learned once and applied in one place.
1039
+ """
1040
+ for keyframe in keyframes:
1041
+ frame = int(keyframe["frame"])
1042
+ value = float(keyframe["value"])
1043
+ setattr(target, bpy_attr, _with_component(getattr(target, bpy_attr), axis, value))
1044
+ target.keyframe_insert(data_path=bpy_attr, index=axis, frame=frame)
1045
+
1046
+
1047
+ def build_bone_animation(armature_obj, track, guard):
1048
+ """Animate ONE pose bone of an armature.
1049
+
1050
+ WHY A BONE IS NOT AN OBJECT: the values live on the ARMATURE's pose, so the keyframes are inserted
1051
+ through the pose bone while the action belongs to the armature. Everything else — the property
1052
+ vocabulary, the two-keyframe minimum, the interpolation — is the same, which is why the insertion
1053
+ itself is shared rather than re-implemented.
1054
+
1055
+ The bone is named by the track; a name the armature does not have is refused, because a track that
1056
+ silently animates nothing is a scene that claims to move and does not.
1057
+ """
1058
+ bone_name = track.get("boneName")
1059
+ if not isinstance(bone_name, str) or bone_name == "":
1060
+ raise ActionError(
1061
+ "SCENE_VALIDATION_FAILED",
1062
+ 'animation track "%s" targets a bone but names none' % (track.get("id"),),
1063
+ )
1064
+ pose_bone = armature_obj.pose.bones.get(bone_name)
1065
+ if pose_bone is None:
1066
+ raise ActionError(
1067
+ "SCENE_VALIDATION_FAILED",
1068
+ 'animation track "%s" targets bone "%s", which armature "%s" does not have'
1069
+ % (track.get("id"), bone_name, armature_obj.get("deepblend_id")),
1070
+ )
1071
+
1072
+ property_name = track.get("property", "")
1073
+ if "." not in property_name:
1074
+ raise ActionError(
1075
+ "SCENE_VALIDATION_FAILED",
1076
+ 'animation track "%s" names property "%s" without a component' % (track.get("id"), property_name),
1077
+ )
1078
+ attr, _, component = property_name.rpartition(".")
1079
+ bpy_attr = TRACK_PROPERTY_ATTR.get(attr)
1080
+ if bpy_attr is None or component not in ("x", "y", "z"):
1081
+ raise ActionError(
1082
+ "SCENE_VALIDATION_FAILED",
1083
+ 'animation track "%s" names unsupported property "%s"' % (track.get("id"), property_name),
1084
+ {"supported": sorted("%s.%s" % (name, axis) for name in TRACK_PROPERTY_ATTR for axis in "xyz")},
1085
+ )
1086
+
1087
+ keyframes = track.get("keyframes") or []
1088
+ if len(keyframes) < 2:
1089
+ raise ActionError(
1090
+ "SCENE_VALIDATION_FAILED",
1091
+ 'animation track "%s" has %d keyframe(s); at least 2 are required to describe motion'
1092
+ % (track.get("id"), len(keyframes)),
1093
+ )
1094
+
1095
+ axis = "xyz".index(component)
1096
+ pose_bone.rotation_mode = "XYZ"
1097
+ _insert_keyframes(pose_bone, bpy_attr, axis, keyframes)
1098
+
1099
+ action = armature_obj.animation_data.action if armature_obj.animation_data is not None else None
1100
+ if action is None:
1101
+ raise ActionError(
1102
+ "BLENDER_SCRIPT_ERROR",
1103
+ 'animation track "%s" inserted keyframes but produced no action' % (track.get("id"),),
1104
+ )
1105
+ _apply_interpolation(action, bpy_attr, axis, keyframes, track, guard)
1106
+ return action
1107
+
1108
+
1033
1109
  def build_animation(obj, track, guard):
1034
1110
  """Key one animation track onto an object, returning the action name.
1035
1111
 
@@ -1072,11 +1148,7 @@ def build_animation(obj, track, guard):
1072
1148
  # values, or the first insert would capture a value from a different property.
1073
1149
  obj.rotation_mode = "XYZ"
1074
1150
 
1075
- for keyframe in keyframes:
1076
- frame = int(keyframe["frame"])
1077
- value = float(keyframe["value"])
1078
- setattr(obj, bpy_attr, _with_component(getattr(obj, bpy_attr), axis, value))
1079
- obj.keyframe_insert(data_path=bpy_attr, index=axis, frame=frame)
1151
+ _insert_keyframes(obj, bpy_attr, axis, keyframes)
1080
1152
 
1081
1153
  action = obj.animation_data.action if obj.animation_data is not None else None
1082
1154
  if action is None:
@@ -1414,6 +1486,104 @@ def skin_entity(obj, armature_obj, armature_id, guard):
1414
1486
  return True
1415
1487
 
1416
1488
 
1489
+ def build_rigid_bodies(spec, entity_objects, guard):
1490
+ """Give the entities their physics, and BAKE the result into the checkpoint.
1491
+
1492
+ WHY THE COMPILE BAKES: a rigid body that has not been baked is a SETTING, not a motion — Blender
1493
+ evaluates it on the fly in the viewport and a batch render of an unbaked simulation renders the
1494
+ initial pose. Baking here is what makes the checkpoint mean "this is what the simulation did", and
1495
+ it is why `simulation.bake: false` exists for a build that only wants the setup.
1496
+
1497
+ Returns a reading of what the physics DID, not of what it was told: each active body's height at the
1498
+ first and the last frame of the scene's range. MEASURED, and the lesson from the animation slice is
1499
+ why this is not just "the setup exists": a body that is configured correctly and never enabled looks
1500
+ exactly like one that fell, unless something reads where it ended up.
1501
+ """
1502
+ settings = spec.get("simulation") or {}
1503
+ scene = bpy.context.scene
1504
+ if "gravity" in settings:
1505
+ scene.gravity[2] = -abs(float(settings["gravity"]))
1506
+
1507
+ # THE PHYSICS WORLD HAS TO EXIST BEFORE A BODY CAN BE ATTACHED TO IT. MEASURED: without this line
1508
+ # `obj.rigid_body` is None and the assignment fails with an AttributeError — Blender's rigid-body
1509
+ # settings live on a scene-level world, and a scene that has never had one has nowhere to put them.
1510
+ if scene.rigidbody_world is None:
1511
+ bpy.ops.rigidbody.world_add()
1512
+ # AND THE WORLD NEEDS A COLLECTION, which a fresh one does not have. MEASURED, in three steps that
1513
+ # each looked like it should work: without the world there is nowhere to put a body; with the world
1514
+ # but no collection, `world.collection` is None; and only an object LINKED INTO that collection
1515
+ # gets its `rigid_body` materialised. The Blender UI does all three when a user clicks "Rigid Body".
1516
+ if scene.rigidbody_world.collection is None:
1517
+ collection = bpy.data.collections.new("DeepBlendRigidBodies")
1518
+ scene.collection.children.link(collection)
1519
+ scene.rigidbody_world.collection = collection
1520
+
1521
+ bodies = {}
1522
+ for entity in spec.get("entities") or []:
1523
+ entry = entity.get("rigidBody")
1524
+ if entry is None:
1525
+ continue
1526
+ obj = entity_objects.get(entity["id"])
1527
+ if obj is None:
1528
+ raise ActionError(
1529
+ "SCENE_SPEC_INVALID",
1530
+ 'entity "%s" declares a rigid body but has no object to attach it to' % (entity["id"],),
1531
+ )
1532
+ # JOINING THE PHYSICS COLLECTION IS WHAT CREATES `obj.rigid_body`. MEASURED, twice: adding the
1533
+ # world is not enough — Blender materialises the rigid-body settings when the object is linked
1534
+ # into the world's own collection, so an object that has "a rigid body" the obvious way is an
1535
+ # object with `None`.
1536
+ if obj.name not in scene.rigidbody_world.collection.objects:
1537
+ scene.rigidbody_world.collection.objects.link(obj)
1538
+
1539
+ obj.rigid_body.type = "ACTIVE" if entry["kind"] == "active" else "PASSIVE"
1540
+ if entry.get("mass") is not None:
1541
+ obj.rigid_body.mass = float(entry["mass"])
1542
+ if entry.get("friction") is not None:
1543
+ obj.rigid_body.friction = float(entry["friction"])
1544
+ bodies[entity["id"]] = entry
1545
+
1546
+ if not bodies:
1547
+ return {}
1548
+
1549
+ # THE GROUND BODIES DO NOT MOVE, so they need the physics world to have something to collide against
1550
+ # and Blender needs to be told the world exists at all.
1551
+ scene.use_gravity = True
1552
+
1553
+ reading = {}
1554
+ first_frame = int((spec.get("project") or {}).get("frameStart", 1))
1555
+ last_frame = int((spec.get("project") or {}).get("frameEnd", first_frame))
1556
+ if settings.get("bake", True):
1557
+ scene.frame_set(first_frame)
1558
+ # `bake_all` walks every cache in the scene, which is what a compile wants: the caller asked for
1559
+ # a checkpoint that shows the simulation, not for one cache to be fresh.
1560
+ try:
1561
+ bpy.ops.ptcache.bake_all(bake=True)
1562
+ except Exception as exc:
1563
+ guard.warnings.append({
1564
+ "code": "SIMULATION_BAKE_FAILED",
1565
+ "message": "the simulation could not be baked: %s" % (error_text(exc),),
1566
+ })
1567
+
1568
+ for entity_id in bodies:
1569
+ obj = entity_objects[entity_id]
1570
+ scene.frame_set(first_frame)
1571
+ bpy.context.view_layer.update()
1572
+ first = float((obj.matrix_world.translation)[2])
1573
+ scene.frame_set(last_frame)
1574
+ bpy.context.view_layer.update()
1575
+ last = float((obj.matrix_world.translation)[2])
1576
+ reading[entity_id] = {
1577
+ "kind": bodies[entity_id]["kind"],
1578
+ "firstFrameZ": round(first, 6),
1579
+ "lastFrameZ": round(last, 6),
1580
+ "droppedZ": round(first - last, 6),
1581
+ }
1582
+ scene.frame_set(first_frame)
1583
+ bpy.context.view_layer.update()
1584
+ return reading
1585
+
1586
+
1417
1587
  def build_scene(spec, options, guard):
1418
1588
  """Compile a SceneSpec into a live Blender scene.
1419
1589
 
@@ -1560,6 +1730,10 @@ def build_scene(spec, options, guard):
1560
1730
  target = cameras.get(target_id)
1561
1731
  elif kind == "material":
1562
1732
  target = materials.get(target_id)
1733
+ elif kind == "bone":
1734
+ # A BONE TRACK TARGETS THE ARMATURE, because the bones belong to it: the armature id is
1735
+ # what resolves, and `boneName` picks the bone inside it.
1736
+ target = armature_objects.get(target_id)
1563
1737
  else:
1564
1738
  target = entity_objects.get(target_id)
1565
1739
  if target is None:
@@ -1570,9 +1744,26 @@ def build_scene(spec, options, guard):
1570
1744
  )
1571
1745
  if kind == "material":
1572
1746
  action = build_material_animation(target, track, guard)
1747
+ elif kind == "bone":
1748
+ action = build_bone_animation(target, track, guard)
1573
1749
  else:
1574
1750
  action = build_animation(target, track, guard)
1575
- actions.append({"id": track.get("id"), "targetKind": kind, "targetEntityId": target_id, "action": action.name})
1751
+ actions.append({
1752
+ "id": track.get("id"),
1753
+ "targetKind": kind,
1754
+ "targetEntityId": target_id,
1755
+ "action": action.name,
1756
+ **({"boneName": track.get("boneName")} if kind == "bone" else {}),
1757
+ # WHAT THE ACTION ACTUALLY DROVE, read off its own fcurves. MEASURED, and a surviving
1758
+ # mutation is why: keying the ARMATURE OBJECT instead of the pose bone still moves the mesh
1759
+ # — the mesh is parented to the armature, so the two frames differ either way — and the
1760
+ # two-frame proof passed. The data paths are the difference: `pose.bones["chest"]…` is the
1761
+ # bone, `rotation_euler` on the object is not.
1762
+ "dataPaths": sorted({curve.data_path for curve in action_fcurves(action)}),
1763
+ })
1764
+
1765
+ report_progress("build_simulation", 70)
1766
+ simulation_reading = build_rigid_bodies(spec, entity_objects, guard)
1576
1767
 
1577
1768
  report_progress("configure_render", 85)
1578
1769
  render_config = configure_scene(scene, spec, profile, guard)
@@ -1618,6 +1809,7 @@ def build_scene(spec, options, guard):
1618
1809
  "profileName": profile_name,
1619
1810
  "entityObjectNames": {key: value.name for key, value in entity_objects.items()},
1620
1811
  "armatureNames": {key: value.name for key, value in armature_objects.items()},
1812
+ "simulation": simulation_reading,
1621
1813
  "skinned": {
1622
1814
  key: {
1623
1815
  "armatureId": entry["armatureId"],