@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 +2 -2
- package/python/__pycache__/bootstrap.cpython-313.pyc +0 -0
- package/python/__pycache__/bootstrap.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-314.pyc +0 -0
- package/python/bootstrap.py +7 -0
- package/python/deepblend_scene.py +198 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepblend/dsh-blender-provider-local",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
14
|
+
"@deepblend/dsh-blender-contracts": "0.2.17"
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"repository": {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/python/bootstrap.py
CHANGED
|
@@ -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
|
-
|
|
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({
|
|
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"],
|