@deepblend/dsh-blender-provider-local 0.2.14 → 0.2.16

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.14",
3
+ "version": "0.2.16",
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.14"
14
+ "@deepblend/dsh-blender-contracts": "0.2.16"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {
@@ -230,6 +230,17 @@ def action_compile_scene(request, options):
230
230
  # THE RIGS THIS SCENE DECLARES, with their bones. A count alone would say an armature exists;
231
231
  # a rig is only useful if the bones are the ones the spec named, so the names travel with it and
232
232
  # a test can read them back rather than trust the count.
233
+ # WHICH ENTITIES ARE SKINNED, AND TO WHAT. The vertex groups are the evidence that weights
234
+ # exist at all: an entity bound to an armature with no groups renders as a static object while
235
+ # the file says it is rigged, and the reading is where that difference becomes visible.
236
+ "skinned": [
237
+ {
238
+ "entityId": entity_id,
239
+ "armatureId": entry["armatureId"],
240
+ "vertexGroups": entry["vertexGroups"],
241
+ }
242
+ for entity_id, entry in sorted((report.get("skinned") or {}).items())
243
+ ],
233
244
  "armatures": [
234
245
  {
235
246
  "id": armature_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:
@@ -1375,6 +1447,45 @@ def build_armature(entry, guard):
1375
1447
  return obj
1376
1448
 
1377
1449
 
1450
+ def skin_entity(obj, armature_obj, armature_id, guard):
1451
+ """Bind a mesh to an armature with Blender's own automatic weights.
1452
+
1453
+ WHY AUTOMATIC WEIGHTS RATHER THAN A WEIGHT TABLE. A per-vertex weight table in the SceneSpec would
1454
+ be a second, hand-written copy of something Blender already computes from the geometry — and the
1455
+ copy would be the one that goes stale, because nothing regenerates it when a generator's parameters
1456
+ change. What the spec states is the RELATIONSHIP ("this entity is skinned to that armature"); the
1457
+ weights are Blender's answer to it.
1458
+
1459
+ Bone heat can fail on a degenerate mesh (Blender reports "failed to find solution for one or more
1460
+ bones"), and that is a WARNING rather than a refusal: the binding still exists and the bones that
1461
+ did solve still deform, so the honest outcome is a scene that mostly works with the failure named.
1462
+ """
1463
+ for target in bpy.context.selected_objects:
1464
+ target.select_set(False)
1465
+ obj.select_set(True)
1466
+ bpy.context.view_layer.objects.active = armature_obj
1467
+ try:
1468
+ bpy.ops.object.parent_set(type="ARMATURE_AUTO")
1469
+ except Exception as exc:
1470
+ guard.warnings.append({
1471
+ "code": "SKIN_WEIGHTS_FAILED",
1472
+ "message": 'entity "%s" could not be skinned to armature "%s": %s'
1473
+ % (obj.get("deepblend_id"), armature_id, error_text(exc)),
1474
+ })
1475
+ return False
1476
+ finally:
1477
+ obj.select_set(False)
1478
+
1479
+ if len(obj.vertex_groups) == 0:
1480
+ guard.warnings.append({
1481
+ "code": "SKIN_WEIGHTS_EMPTY",
1482
+ "message": 'entity "%s" is bound to armature "%s" but Blender produced no vertex groups, so '
1483
+ "it will not deform" % (obj.get("deepblend_id"), armature_id),
1484
+ })
1485
+ return False
1486
+ return True
1487
+
1488
+
1378
1489
  def build_scene(spec, options, guard):
1379
1490
  """Compile a SceneSpec into a live Blender scene.
1380
1491
 
@@ -1521,6 +1632,10 @@ def build_scene(spec, options, guard):
1521
1632
  target = cameras.get(target_id)
1522
1633
  elif kind == "material":
1523
1634
  target = materials.get(target_id)
1635
+ elif kind == "bone":
1636
+ # A BONE TRACK TARGETS THE ARMATURE, because the bones belong to it: the armature id is
1637
+ # what resolves, and `boneName` picks the bone inside it.
1638
+ target = armature_objects.get(target_id)
1524
1639
  else:
1525
1640
  target = entity_objects.get(target_id)
1526
1641
  if target is None:
@@ -1531,9 +1646,23 @@ def build_scene(spec, options, guard):
1531
1646
  )
1532
1647
  if kind == "material":
1533
1648
  action = build_material_animation(target, track, guard)
1649
+ elif kind == "bone":
1650
+ action = build_bone_animation(target, track, guard)
1534
1651
  else:
1535
1652
  action = build_animation(target, track, guard)
1536
- actions.append({"id": track.get("id"), "targetKind": kind, "targetEntityId": target_id, "action": action.name})
1653
+ actions.append({
1654
+ "id": track.get("id"),
1655
+ "targetKind": kind,
1656
+ "targetEntityId": target_id,
1657
+ "action": action.name,
1658
+ **({"boneName": track.get("boneName")} if kind == "bone" else {}),
1659
+ # WHAT THE ACTION ACTUALLY DROVE, read off its own fcurves. MEASURED, and a surviving
1660
+ # mutation is why: keying the ARMATURE OBJECT instead of the pose bone still moves the mesh
1661
+ # — the mesh is parented to the armature, so the two frames differ either way — and the
1662
+ # two-frame proof passed. The data paths are the difference: `pose.bones["chest"]…` is the
1663
+ # bone, `rotation_euler` on the object is not.
1664
+ "dataPaths": sorted({curve.data_path for curve in action_fcurves(action)}),
1665
+ })
1537
1666
 
1538
1667
  report_progress("configure_render", 85)
1539
1668
  render_config = configure_scene(scene, spec, profile, guard)
@@ -1550,6 +1679,25 @@ def build_scene(spec, options, guard):
1550
1679
  bpy.context.view_layer.update()
1551
1680
 
1552
1681
  report_progress("compile_done", 90)
1682
+ # SKINNING LAST: it needs both the mesh and the rig, and doing it before either exists would mean
1683
+ # ordering the compile around a step that is about the RELATIONSHIP between them.
1684
+ report_progress("skin_entities", 60)
1685
+ skinned_entities = {}
1686
+ for entity in spec.get("entities") or []:
1687
+ armature_id = entity.get("armatureId")
1688
+ if armature_id is None:
1689
+ continue
1690
+ obj = entity_objects.get(entity["id"])
1691
+ armature_obj = armature_objects.get(armature_id)
1692
+ if obj is None or armature_obj is None:
1693
+ raise ActionError(
1694
+ "SCENE_SPEC_INVALID",
1695
+ 'entity "%s" is skinned to armature "%s", which the same SceneSpec does not declare'
1696
+ % (entity["id"], armature_id),
1697
+ )
1698
+ skin_entity(obj, armature_obj, armature_id, guard)
1699
+ skinned_entities[entity["id"]] = entity
1700
+
1553
1701
  return {
1554
1702
  "objects": describe_objects(),
1555
1703
  "actions": actions,
@@ -1560,6 +1708,16 @@ def build_scene(spec, options, guard):
1560
1708
  "profileName": profile_name,
1561
1709
  "entityObjectNames": {key: value.name for key, value in entity_objects.items()},
1562
1710
  "armatureNames": {key: value.name for key, value in armature_objects.items()},
1711
+ "skinned": {
1712
+ key: {
1713
+ "armatureId": entry["armatureId"],
1714
+ # THE GROUPS COME FROM THE OBJECT, not from the spec entry: what the file asked for is
1715
+ # the relationship, and what Blender produced is the weights. Reading the latter back is
1716
+ # the whole point — an entity bound with no groups renders as a static object.
1717
+ "vertexGroups": sorted(group.name for group in entity_objects[key].vertex_groups),
1718
+ }
1719
+ for key, entry in skinned_entities.items()
1720
+ },
1563
1721
  "cameraNames": {key: value.name for key, value in cameras.items()},
1564
1722
  }
1565
1723