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

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.17",
3
+ "version": "0.2.19",
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.17"
14
+ "@deepblend/dsh-blender-contracts": "0.2.19"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {
@@ -1486,7 +1486,7 @@ 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):
1489
+ def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None, soft_entities=None):
1490
1490
  """Give the entities their physics, and BAKE the result into the checkpoint.
1491
1491
 
1492
1492
  WHY THE COMPILE BAKES: a rigid body that has not been baked is a SETTING, not a motion — Blender
@@ -1499,6 +1499,8 @@ def build_rigid_bodies(spec, entity_objects, guard):
1499
1499
  why this is not just "the setup exists": a body that is configured correctly and never enabled looks
1500
1500
  exactly like one that fell, unless something reads where it ended up.
1501
1501
  """
1502
+ cloth_entities = cloth_entities or {}
1503
+ soft_entities = soft_entities or {}
1502
1504
  settings = spec.get("simulation") or {}
1503
1505
  scene = bpy.context.scene
1504
1506
  if "gravity" in settings:
@@ -1543,13 +1545,32 @@ def build_rigid_bodies(spec, entity_objects, guard):
1543
1545
  obj.rigid_body.friction = float(entry["friction"])
1544
1546
  bodies[entity["id"]] = entry
1545
1547
 
1546
- if not bodies:
1548
+ if not bodies and not cloth_entities and not soft_entities:
1547
1549
  return {}
1548
1550
 
1551
+ # A SCENE WITH CLOTH AND NO RIGID BODIES STILL HAS CACHES TO BAKE. MEASURED: the first version
1552
+ # returned early when there were no bodies, and a cloth-only scene reported an empty reading — the
1553
+ # bake and the whole reading sat behind a condition that had nothing to do with cloth.
1554
+ _ = bodies
1555
+
1549
1556
  # THE GROUND BODIES DO NOT MOVE, so they need the physics world to have something to collide against
1550
1557
  # and Blender needs to be told the world exists at all.
1551
1558
  scene.use_gravity = True
1552
1559
 
1560
+ # THE CLOTH MODIFIERS ARE CREATED BEFORE THE BAKE, which is the whole reason they are built here
1561
+ # rather than in `build_scene`. MEASURED, and the comment above this loop said so before the code
1562
+ # did: created after it, the bake had nothing to walk and the reading saw an object with no
1563
+ # modifiers — a fabric that never moved, reported as a simulation that did nothing.
1564
+ for entity_id, entry in cloth_entities.items():
1565
+ build_cloth(entry, entity_objects[entity_id], guard)
1566
+
1567
+ # SOFT BODIES ARE BUILT IN THE SAME PLACE, AND FOR THE SAME MEASURED REASON: MEASURED, and unlike
1568
+ # cloth, a soft body does NOT move without a bake (1.75 -> 1.75 before, 1.75 -> 1.6252 after), so its
1569
+ # modifier has to exist before `bake_all` — the three mechanisms behave three different ways, and each
1570
+ # one was measured rather than assumed from the last.
1571
+ for entity_id, entry in soft_entities.items():
1572
+ build_soft_body(entry, entity_objects[entity_id], guard)
1573
+
1553
1574
  reading = {}
1554
1575
  first_frame = int((spec.get("project") or {}).get("frameStart", 1))
1555
1576
  last_frame = int((spec.get("project") or {}).get("frameEnd", first_frame))
@@ -1565,6 +1586,15 @@ def build_rigid_bodies(spec, entity_objects, guard):
1565
1586
  "message": "the simulation could not be baked: %s" % (error_text(exc),),
1566
1587
  })
1567
1588
 
1589
+ for entity_id in soft_entities:
1590
+ reading[entity_id] = {"kind": "soft-body", **deform_reading(entity_objects[entity_id], first_frame, last_frame)}
1591
+
1592
+ for entity_id in cloth_entities:
1593
+ # INTO THE SAME READING as the rigid bodies: one report of what the physics did, whatever the
1594
+ # mechanism, because a caller asking "what happened in this scene" should not have to know which
1595
+ # kind of simulation each entity used.
1596
+ reading[entity_id] = {"kind": "cloth", **deform_reading(entity_objects[entity_id], first_frame, last_frame)}
1597
+
1568
1598
  for entity_id in bodies:
1569
1599
  obj = entity_objects[entity_id]
1570
1600
  scene.frame_set(first_frame)
@@ -1584,6 +1614,130 @@ def build_rigid_bodies(spec, entity_objects, guard):
1584
1614
  return reading
1585
1615
 
1586
1616
 
1617
+ def build_cloth(entry, obj, guard):
1618
+ """Hang a mesh as fabric, and report where its lowest point ended up.
1619
+
1620
+ WHY THE PIN GROUP IS BUILT FROM GEOMETRY: Blender pins cloth through a vertex group, and a
1621
+ SceneSpec that named one would be referring to something no generator creates. What the spec says
1622
+ is the RELATIONSHIP ("hang this from its top edge"); the group is the compiler's answer to it.
1623
+
1624
+ MEASURED, AND IT DIFFERS FROM RIGID BODIES: cloth is evaluated LIVE, so it drapes with or without a
1625
+ bake — but the BAKED result differs from the unbaked one, because cloth integrates frame by frame
1626
+ and a bake walks every frame while a jump to the last one does not. That is why nothing here claims
1627
+ "no bake means no motion": for cloth that claim would be false, and a false assertion is worse than
1628
+ none.
1629
+ """
1630
+ settings = entry or {}
1631
+ pin_top = settings.get("pinTop", True)
1632
+ group_name = None
1633
+ if pin_top:
1634
+ ys = [vertex.co.y for vertex in obj.data.vertices]
1635
+ if ys:
1636
+ top = max(ys)
1637
+ pinned = [vertex.index for vertex in obj.data.vertices if abs(vertex.co.y - top) < 1e-4]
1638
+ group = obj.vertex_groups.new(name="DeepBlendClothPin")
1639
+ group.add(pinned, 1.0, "REPLACE")
1640
+ group_name = group.name
1641
+
1642
+ # CLOTH NEEDS GEOMETRY TO BEND, AND THE PRODUCT'S PRIMITIVES ARE LOW-POLY. MEASURED: a cube has eight
1643
+ # vertices, so a cloth modifier on one has nothing to drape with — the reading came back with the
1644
+ # fabric exactly where it started, and it looked like a broken simulation rather than a mesh with no
1645
+ # interior. A SIMPLE subdivision keeps the shape and gives the solver something to work with; the
1646
+ # reading reports the vertex count so the subdivision is visible rather than assumed.
1647
+ subdivision = obj.modifiers.new(name="DeepBlendClothSubdivision", type="SUBSURF")
1648
+ subdivision.subdivision_type = "SIMPLE"
1649
+ subdivision.levels = int(settings.get("subdivisions", 3))
1650
+ subdivision.render_levels = subdivision.levels
1651
+
1652
+ modifier = obj.modifiers.new(name="DeepBlendCloth", type="CLOTH")
1653
+ cloth_settings = modifier.settings
1654
+ if group_name is not None:
1655
+ cloth_settings.vertex_group_mass = group_name
1656
+ if settings.get("mass") is not None:
1657
+ cloth_settings.mass = float(settings["mass"])
1658
+ if settings.get("stiffness") is not None:
1659
+ cloth_settings.tension_stiffness = float(settings["stiffness"]) * 15.0
1660
+ return obj
1661
+
1662
+
1663
+ def deform_reading(obj, first_frame, last_frame):
1664
+ """The lowest and the highest simulated vertex, at each end of the range.
1665
+
1666
+ SHARED BY EVERYTHING THAT DEFORMS A MESH rather than copied per mechanism: cloth and soft bodies ask
1667
+ the same question of the same evaluated geometry, and a second copy is the one that would miss the
1668
+ next fix — this reading already needed one when it turned out to be taken from the base mesh.
1669
+
1670
+
1671
+ Read off the EVALUATED mesh rather than the modifier: what the fabric did is the geometry, and the
1672
+ modifier only says what was asked for. The highest point is the pinned edge, which is how the
1673
+ reading says "it draped FROM somewhere" rather than merely "it moved".
1674
+ """
1675
+ scene = bpy.context.scene
1676
+ reading = {}
1677
+ depsgraph = bpy.context.evaluated_depsgraph_get()
1678
+ for label, frame in (("firstFrame", first_frame), ("lastFrame", last_frame)):
1679
+ scene.frame_set(frame)
1680
+ bpy.context.view_layer.update()
1681
+ depsgraph = bpy.context.evaluated_depsgraph_get()
1682
+ evaluated = obj.evaluated_get(depsgraph)
1683
+ mesh = evaluated.to_mesh()
1684
+ heights = [(obj.matrix_world @ vertex.co).z for vertex in mesh.vertices]
1685
+ evaluated.to_mesh_clear()
1686
+ if heights:
1687
+ reading[label + "LowestZ"] = round(min(heights), 6)
1688
+ reading[label + "HighestZ"] = round(max(heights), 6)
1689
+ # THE VERTEX COUNTS ARE PART OF THE READING, not diagnostics. MEASURED, and a surviving
1690
+ # mutation is why there are TWO of them: "the fabric had at least 64 vertices" passed on a
1691
+ # generator that already had that many, so it proved nothing about the subdivision. What
1692
+ # proves it is the RATIO — how many vertices the evaluated mesh has against how many the
1693
+ # object was built with — and that needs both numbers to be read.
1694
+ reading[label + "Vertices"] = len(heights)
1695
+ reading[label + "SourceVertices"] = len(obj.data.vertices)
1696
+ if "firstFrameLowestZ" in reading and "lastFrameLowestZ" in reading:
1697
+ reading["droppedZ"] = round(reading["firstFrameLowestZ"] - reading["lastFrameLowestZ"], 6)
1698
+ return reading
1699
+
1700
+
1701
+ def build_soft_body(entry, obj, guard):
1702
+ """Give a mesh soft-body physics: it deforms, and it remembers its shape.
1703
+
1704
+ THE GOAL IS WHAT MAKES IT A SOFT BODY rather than cloth — `goal` is how strongly each vertex wants to
1705
+ return to where it started, and Blender's default is high (0.7). MEASURED: a 0.5m cube dropped 25
1706
+ frames falls 0.125m and keeps its 0.5m spread, which is exactly what a body with a goal should do,
1707
+ and both halves of that are asserted so a future default change cannot quietly turn it into cloth.
1708
+ """
1709
+ settings = entry or {}
1710
+
1711
+ # THE SUBDIVISION IS NOT OPTIONAL, for the same measured reason as cloth: a primitive has no interior
1712
+ # to deform, so a soft body on an eight-vertex cube is a cube that moves.
1713
+ subdivision = obj.modifiers.new(name="DeepBlendSoftBodySubdivision", type="SUBSURF")
1714
+ subdivision.subdivision_type = "SIMPLE"
1715
+ subdivision.levels = int(settings.get("subdivisions", 3))
1716
+ subdivision.render_levels = subdivision.levels
1717
+
1718
+ modifier = obj.modifiers.new(name="DeepBlendSoftBody", type="SOFT_BODY")
1719
+ body_settings = modifier.settings
1720
+ if settings.get("mass") is not None:
1721
+ body_settings.mass = float(settings["mass"])
1722
+ if settings.get("goal") is not None:
1723
+ body_settings.goal_default = float(settings["goal"])
1724
+
1725
+ # PINNING IS EXPRESSED AS A GOAL, which is this modifier's own word for it: a vertex group at goal
1726
+ # 1.0 holds its vertices exactly, and one at 0.0 lets them go.
1727
+ if settings.get("pinTop"):
1728
+ ys = [vertex.co.y for vertex in obj.data.vertices]
1729
+ if ys:
1730
+ top = max(ys)
1731
+ pinned = [vertex.index for vertex in obj.data.vertices if abs(vertex.co.y - top) < 1e-4]
1732
+ group = obj.vertex_groups.new(name="DeepBlendSoftBodyPin")
1733
+ group.add(pinned, 1.0, "REPLACE")
1734
+ body_settings.use_goal = True
1735
+ body_settings.vertex_group_goal = group.name
1736
+ body_settings.goal_min = 1.0
1737
+ body_settings.goal_max = 1.0
1738
+ return obj
1739
+
1740
+
1587
1741
  def build_scene(spec, options, guard):
1588
1742
  """Compile a SceneSpec into a live Blender scene.
1589
1743
 
@@ -1762,8 +1916,32 @@ def build_scene(spec, options, guard):
1762
1916
  "dataPaths": sorted({curve.data_path for curve in action_fcurves(action)}),
1763
1917
  })
1764
1918
 
1919
+ # CLOTH ENTRIES ARE RESOLVED HERE, and the modifiers are created inside the simulation step so that
1920
+ # everything the bake has to walk is in place before it runs.
1765
1921
  report_progress("build_simulation", 70)
1766
- simulation_reading = build_rigid_bodies(spec, entity_objects, guard)
1922
+ cloth_entities = {}
1923
+ for entity in spec.get("entities") or []:
1924
+ entry = entity.get("cloth")
1925
+ if entry is None:
1926
+ continue
1927
+ if entity_objects.get(entity["id"]) is None:
1928
+ raise ActionError(
1929
+ "SCENE_SPEC_INVALID",
1930
+ 'entity "%s" declares cloth but has no object to hang it on' % (entity["id"],),
1931
+ )
1932
+ cloth_entities[entity["id"]] = entry
1933
+ soft_entities = {}
1934
+ for entity in spec.get("entities") or []:
1935
+ entry = entity.get("softBody")
1936
+ if entry is None:
1937
+ continue
1938
+ if entity_objects.get(entity["id"]) is None:
1939
+ raise ActionError(
1940
+ "SCENE_SPEC_INVALID",
1941
+ 'entity "%s" declares a soft body but has no object to simulate' % (entity["id"],),
1942
+ )
1943
+ soft_entities[entity["id"]] = entry
1944
+ simulation_reading = build_rigid_bodies(spec, entity_objects, guard, cloth_entities, soft_entities)
1767
1945
 
1768
1946
  report_progress("configure_render", 85)
1769
1947
  render_config = configure_scene(scene, spec, profile, guard)