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

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.16",
3
+ "version": "0.2.18",
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.16"
14
+ "@deepblend/dsh-blender-contracts": "0.2.18"
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,
@@ -1486,6 +1486,200 @@ def skin_entity(obj, armature_obj, armature_id, guard):
1486
1486
  return True
1487
1487
 
1488
1488
 
1489
+ def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None):
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
+ cloth_entities = cloth_entities or {}
1503
+ settings = spec.get("simulation") or {}
1504
+ scene = bpy.context.scene
1505
+ if "gravity" in settings:
1506
+ scene.gravity[2] = -abs(float(settings["gravity"]))
1507
+
1508
+ # THE PHYSICS WORLD HAS TO EXIST BEFORE A BODY CAN BE ATTACHED TO IT. MEASURED: without this line
1509
+ # `obj.rigid_body` is None and the assignment fails with an AttributeError — Blender's rigid-body
1510
+ # settings live on a scene-level world, and a scene that has never had one has nowhere to put them.
1511
+ if scene.rigidbody_world is None:
1512
+ bpy.ops.rigidbody.world_add()
1513
+ # AND THE WORLD NEEDS A COLLECTION, which a fresh one does not have. MEASURED, in three steps that
1514
+ # each looked like it should work: without the world there is nowhere to put a body; with the world
1515
+ # but no collection, `world.collection` is None; and only an object LINKED INTO that collection
1516
+ # gets its `rigid_body` materialised. The Blender UI does all three when a user clicks "Rigid Body".
1517
+ if scene.rigidbody_world.collection is None:
1518
+ collection = bpy.data.collections.new("DeepBlendRigidBodies")
1519
+ scene.collection.children.link(collection)
1520
+ scene.rigidbody_world.collection = collection
1521
+
1522
+ bodies = {}
1523
+ for entity in spec.get("entities") or []:
1524
+ entry = entity.get("rigidBody")
1525
+ if entry is None:
1526
+ continue
1527
+ obj = entity_objects.get(entity["id"])
1528
+ if obj is None:
1529
+ raise ActionError(
1530
+ "SCENE_SPEC_INVALID",
1531
+ 'entity "%s" declares a rigid body but has no object to attach it to' % (entity["id"],),
1532
+ )
1533
+ # JOINING THE PHYSICS COLLECTION IS WHAT CREATES `obj.rigid_body`. MEASURED, twice: adding the
1534
+ # world is not enough — Blender materialises the rigid-body settings when the object is linked
1535
+ # into the world's own collection, so an object that has "a rigid body" the obvious way is an
1536
+ # object with `None`.
1537
+ if obj.name not in scene.rigidbody_world.collection.objects:
1538
+ scene.rigidbody_world.collection.objects.link(obj)
1539
+
1540
+ obj.rigid_body.type = "ACTIVE" if entry["kind"] == "active" else "PASSIVE"
1541
+ if entry.get("mass") is not None:
1542
+ obj.rigid_body.mass = float(entry["mass"])
1543
+ if entry.get("friction") is not None:
1544
+ obj.rigid_body.friction = float(entry["friction"])
1545
+ bodies[entity["id"]] = entry
1546
+
1547
+ if not bodies and not cloth_entities:
1548
+ return {}
1549
+
1550
+ # A SCENE WITH CLOTH AND NO RIGID BODIES STILL HAS CACHES TO BAKE. MEASURED: the first version
1551
+ # returned early when there were no bodies, and a cloth-only scene reported an empty reading — the
1552
+ # bake and the whole reading sat behind a condition that had nothing to do with cloth.
1553
+ _ = bodies
1554
+
1555
+ # THE GROUND BODIES DO NOT MOVE, so they need the physics world to have something to collide against
1556
+ # and Blender needs to be told the world exists at all.
1557
+ scene.use_gravity = True
1558
+
1559
+ # THE CLOTH MODIFIERS ARE CREATED BEFORE THE BAKE, which is the whole reason they are built here
1560
+ # rather than in `build_scene`. MEASURED, and the comment above this loop said so before the code
1561
+ # did: created after it, the bake had nothing to walk and the reading saw an object with no
1562
+ # modifiers — a fabric that never moved, reported as a simulation that did nothing.
1563
+ for entity_id, entry in cloth_entities.items():
1564
+ build_cloth(entry, entity_objects[entity_id], guard)
1565
+
1566
+ reading = {}
1567
+ first_frame = int((spec.get("project") or {}).get("frameStart", 1))
1568
+ last_frame = int((spec.get("project") or {}).get("frameEnd", first_frame))
1569
+ if settings.get("bake", True):
1570
+ scene.frame_set(first_frame)
1571
+ # `bake_all` walks every cache in the scene, which is what a compile wants: the caller asked for
1572
+ # a checkpoint that shows the simulation, not for one cache to be fresh.
1573
+ try:
1574
+ bpy.ops.ptcache.bake_all(bake=True)
1575
+ except Exception as exc:
1576
+ guard.warnings.append({
1577
+ "code": "SIMULATION_BAKE_FAILED",
1578
+ "message": "the simulation could not be baked: %s" % (error_text(exc),),
1579
+ })
1580
+
1581
+ for entity_id in cloth_entities:
1582
+ # INTO THE SAME READING as the rigid bodies: one report of what the physics did, whatever the
1583
+ # mechanism, because a caller asking "what happened in this scene" should not have to know which
1584
+ # kind of simulation each entity used.
1585
+ reading[entity_id] = {"kind": "cloth", **cloth_reading(entity_objects[entity_id], first_frame, last_frame)}
1586
+
1587
+ for entity_id in bodies:
1588
+ obj = entity_objects[entity_id]
1589
+ scene.frame_set(first_frame)
1590
+ bpy.context.view_layer.update()
1591
+ first = float((obj.matrix_world.translation)[2])
1592
+ scene.frame_set(last_frame)
1593
+ bpy.context.view_layer.update()
1594
+ last = float((obj.matrix_world.translation)[2])
1595
+ reading[entity_id] = {
1596
+ "kind": bodies[entity_id]["kind"],
1597
+ "firstFrameZ": round(first, 6),
1598
+ "lastFrameZ": round(last, 6),
1599
+ "droppedZ": round(first - last, 6),
1600
+ }
1601
+ scene.frame_set(first_frame)
1602
+ bpy.context.view_layer.update()
1603
+ return reading
1604
+
1605
+
1606
+ def build_cloth(entry, obj, guard):
1607
+ """Hang a mesh as fabric, and report where its lowest point ended up.
1608
+
1609
+ WHY THE PIN GROUP IS BUILT FROM GEOMETRY: Blender pins cloth through a vertex group, and a
1610
+ SceneSpec that named one would be referring to something no generator creates. What the spec says
1611
+ is the RELATIONSHIP ("hang this from its top edge"); the group is the compiler's answer to it.
1612
+
1613
+ MEASURED, AND IT DIFFERS FROM RIGID BODIES: cloth is evaluated LIVE, so it drapes with or without a
1614
+ bake — but the BAKED result differs from the unbaked one, because cloth integrates frame by frame
1615
+ and a bake walks every frame while a jump to the last one does not. That is why nothing here claims
1616
+ "no bake means no motion": for cloth that claim would be false, and a false assertion is worse than
1617
+ none.
1618
+ """
1619
+ settings = entry or {}
1620
+ pin_top = settings.get("pinTop", True)
1621
+ group_name = None
1622
+ if pin_top:
1623
+ ys = [vertex.co.y for vertex in obj.data.vertices]
1624
+ if ys:
1625
+ top = max(ys)
1626
+ pinned = [vertex.index for vertex in obj.data.vertices if abs(vertex.co.y - top) < 1e-4]
1627
+ group = obj.vertex_groups.new(name="DeepBlendClothPin")
1628
+ group.add(pinned, 1.0, "REPLACE")
1629
+ group_name = group.name
1630
+
1631
+ # CLOTH NEEDS GEOMETRY TO BEND, AND THE PRODUCT'S PRIMITIVES ARE LOW-POLY. MEASURED: a cube has eight
1632
+ # vertices, so a cloth modifier on one has nothing to drape with — the reading came back with the
1633
+ # fabric exactly where it started, and it looked like a broken simulation rather than a mesh with no
1634
+ # interior. A SIMPLE subdivision keeps the shape and gives the solver something to work with; the
1635
+ # reading reports the vertex count so the subdivision is visible rather than assumed.
1636
+ subdivision = obj.modifiers.new(name="DeepBlendClothSubdivision", type="SUBSURF")
1637
+ subdivision.subdivision_type = "SIMPLE"
1638
+ subdivision.levels = int(settings.get("subdivisions", 3))
1639
+ subdivision.render_levels = subdivision.levels
1640
+
1641
+ modifier = obj.modifiers.new(name="DeepBlendCloth", type="CLOTH")
1642
+ cloth_settings = modifier.settings
1643
+ if group_name is not None:
1644
+ cloth_settings.vertex_group_mass = group_name
1645
+ if settings.get("mass") is not None:
1646
+ cloth_settings.mass = float(settings["mass"])
1647
+ if settings.get("stiffness") is not None:
1648
+ cloth_settings.tension_stiffness = float(settings["stiffness"]) * 15.0
1649
+ return obj
1650
+
1651
+
1652
+ def cloth_reading(obj, first_frame, last_frame):
1653
+ """The lowest and the highest simulated vertex, at each end of the range.
1654
+
1655
+ Read off the EVALUATED mesh rather than the modifier: what the fabric did is the geometry, and the
1656
+ modifier only says what was asked for. The highest point is the pinned edge, which is how the
1657
+ reading says "it draped FROM somewhere" rather than merely "it moved".
1658
+ """
1659
+ scene = bpy.context.scene
1660
+ reading = {}
1661
+ depsgraph = bpy.context.evaluated_depsgraph_get()
1662
+ for label, frame in (("firstFrame", first_frame), ("lastFrame", last_frame)):
1663
+ scene.frame_set(frame)
1664
+ bpy.context.view_layer.update()
1665
+ depsgraph = bpy.context.evaluated_depsgraph_get()
1666
+ evaluated = obj.evaluated_get(depsgraph)
1667
+ mesh = evaluated.to_mesh()
1668
+ heights = [(obj.matrix_world @ vertex.co).z for vertex in mesh.vertices]
1669
+ evaluated.to_mesh_clear()
1670
+ if heights:
1671
+ reading[label + "LowestZ"] = round(min(heights), 6)
1672
+ reading[label + "HighestZ"] = round(max(heights), 6)
1673
+ # THE VERTEX COUNT IS PART OF THE READING, not a diagnostic: cloth needs geometry to bend and
1674
+ # the product's primitives are low-poly, so the compiler subdivides — and a reading that says
1675
+ # how many vertices the fabric had is how "it draped" can be told apart from "it had nothing
1676
+ # to drape with".
1677
+ reading[label + "Vertices"] = len(heights)
1678
+ if "firstFrameLowestZ" in reading and "lastFrameLowestZ" in reading:
1679
+ reading["droppedZ"] = round(reading["firstFrameLowestZ"] - reading["lastFrameLowestZ"], 6)
1680
+ return reading
1681
+
1682
+
1489
1683
  def build_scene(spec, options, guard):
1490
1684
  """Compile a SceneSpec into a live Blender scene.
1491
1685
 
@@ -1664,6 +1858,22 @@ def build_scene(spec, options, guard):
1664
1858
  "dataPaths": sorted({curve.data_path for curve in action_fcurves(action)}),
1665
1859
  })
1666
1860
 
1861
+ # CLOTH ENTRIES ARE RESOLVED HERE, and the modifiers are created inside the simulation step so that
1862
+ # everything the bake has to walk is in place before it runs.
1863
+ report_progress("build_simulation", 70)
1864
+ cloth_entities = {}
1865
+ for entity in spec.get("entities") or []:
1866
+ entry = entity.get("cloth")
1867
+ if entry is None:
1868
+ continue
1869
+ if entity_objects.get(entity["id"]) is None:
1870
+ raise ActionError(
1871
+ "SCENE_SPEC_INVALID",
1872
+ 'entity "%s" declares cloth but has no object to hang it on' % (entity["id"],),
1873
+ )
1874
+ cloth_entities[entity["id"]] = entry
1875
+ simulation_reading = build_rigid_bodies(spec, entity_objects, guard, cloth_entities)
1876
+
1667
1877
  report_progress("configure_render", 85)
1668
1878
  render_config = configure_scene(scene, spec, profile, guard)
1669
1879
 
@@ -1708,6 +1918,7 @@ def build_scene(spec, options, guard):
1708
1918
  "profileName": profile_name,
1709
1919
  "entityObjectNames": {key: value.name for key, value in entity_objects.items()},
1710
1920
  "armatureNames": {key: value.name for key, value in armature_objects.items()},
1921
+ "simulation": simulation_reading,
1711
1922
  "skinned": {
1712
1923
  key: {
1713
1924
  "armatureId": entry["armatureId"],