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

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.18",
3
+ "version": "0.2.20",
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.18"
14
+ "@deepblend/dsh-blender-contracts": "0.2.20"
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, cloth_entities=None):
1489
+ def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None, soft_entities=None, fluid_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
@@ -1500,6 +1500,11 @@ def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None):
1500
1500
  exactly like one that fell, unless something reads where it ended up.
1501
1501
  """
1502
1502
  cloth_entities = cloth_entities or {}
1503
+ soft_entities = soft_entities or {}
1504
+ fluid_entities = fluid_entities or {}
1505
+ # ONLY THE DOMAINS HAVE A READING: the flows are what pours in, and what the caller wants to know is
1506
+ # how much liquid there is, which is a property of the domain.
1507
+ fluid_domains = {key: value for key, value in fluid_entities.items() if value.get("role") == "domain"}
1503
1508
  settings = spec.get("simulation") or {}
1504
1509
  scene = bpy.context.scene
1505
1510
  if "gravity" in settings:
@@ -1544,7 +1549,7 @@ def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None):
1544
1549
  obj.rigid_body.friction = float(entry["friction"])
1545
1550
  bodies[entity["id"]] = entry
1546
1551
 
1547
- if not bodies and not cloth_entities:
1552
+ if not bodies and not cloth_entities and not soft_entities and not fluid_entities:
1548
1553
  return {}
1549
1554
 
1550
1555
  # A SCENE WITH CLOTH AND NO RIGID BODIES STILL HAS CACHES TO BAKE. MEASURED: the first version
@@ -1563,6 +1568,18 @@ def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None):
1563
1568
  for entity_id, entry in cloth_entities.items():
1564
1569
  build_cloth(entry, entity_objects[entity_id], guard)
1565
1570
 
1571
+ # SOFT BODIES ARE BUILT IN THE SAME PLACE, AND FOR THE SAME MEASURED REASON: MEASURED, and unlike
1572
+ # cloth, a soft body does NOT move without a bake (1.75 -> 1.75 before, 1.75 -> 1.6252 after), so its
1573
+ # modifier has to exist before `bake_all` — the three mechanisms behave three different ways, and each
1574
+ # one was measured rather than assumed from the last.
1575
+ for entity_id, entry in soft_entities.items():
1576
+ build_soft_body(entry, entity_objects[entity_id], guard)
1577
+
1578
+ # FLUIDS ARE BUILT BEFORE THE BAKE TOO, for the same reason as everything else: `bake_all` walks the
1579
+ # caches that exist, and a modifier created afterwards has no cache to walk.
1580
+ for entity_id, entry in fluid_entities.items():
1581
+ build_fluid(entry, entity_objects[entity_id], guard)
1582
+
1566
1583
  reading = {}
1567
1584
  first_frame = int((spec.get("project") or {}).get("frameStart", 1))
1568
1585
  last_frame = int((spec.get("project") or {}).get("frameEnd", first_frame))
@@ -1578,11 +1595,33 @@ def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None):
1578
1595
  "message": "the simulation could not be baked: %s" % (error_text(exc),),
1579
1596
  })
1580
1597
 
1598
+ # THE FLUID BAKE IS A DIFFERENT OPERATOR FROM THE POINT CACHES, and this is the second measurement
1599
+ # carried over from the last round: `ptcache.bake_all` bakes cloth and soft bodies, while a fluid
1600
+ # domain needs `fluid.bake_all` — and THAT operator reads the CONTEXT, so the domain has to be the
1601
+ # active object or it refuses with "Invalid domain" (the first carried measurement).
1602
+ if fluid_domains:
1603
+ for entity_id in fluid_domains:
1604
+ bpy.context.view_layer.objects.active = entity_objects[entity_id]
1605
+ try:
1606
+ bpy.ops.fluid.bake_all()
1607
+ except Exception as exc:
1608
+ guard.warnings.append({
1609
+ "code": "FLUID_BAKE_FAILED",
1610
+ "message": 'the fluid domain "%s" could not be baked: %s' % (entity_id, error_text(exc)),
1611
+ })
1612
+ break
1613
+
1614
+ for entity_id in soft_entities:
1615
+ reading[entity_id] = {"kind": "soft-body", **deform_reading(entity_objects[entity_id], first_frame, last_frame)}
1616
+
1617
+ # AFTER THE BAKE, because the liquid only exists once the mesh cache has been written.
1618
+ reading.update(fluid_reading(fluid_domains, entity_objects, first_frame, last_frame, guard))
1619
+
1581
1620
  for entity_id in cloth_entities:
1582
1621
  # INTO THE SAME READING as the rigid bodies: one report of what the physics did, whatever the
1583
1622
  # mechanism, because a caller asking "what happened in this scene" should not have to know which
1584
1623
  # kind of simulation each entity used.
1585
- reading[entity_id] = {"kind": "cloth", **cloth_reading(entity_objects[entity_id], first_frame, last_frame)}
1624
+ reading[entity_id] = {"kind": "cloth", **deform_reading(entity_objects[entity_id], first_frame, last_frame)}
1586
1625
 
1587
1626
  for entity_id in bodies:
1588
1627
  obj = entity_objects[entity_id]
@@ -1649,9 +1688,14 @@ def build_cloth(entry, obj, guard):
1649
1688
  return obj
1650
1689
 
1651
1690
 
1652
- def cloth_reading(obj, first_frame, last_frame):
1691
+ def deform_reading(obj, first_frame, last_frame):
1653
1692
  """The lowest and the highest simulated vertex, at each end of the range.
1654
1693
 
1694
+ SHARED BY EVERYTHING THAT DEFORMS A MESH rather than copied per mechanism: cloth and soft bodies ask
1695
+ the same question of the same evaluated geometry, and a second copy is the one that would miss the
1696
+ next fix — this reading already needed one when it turned out to be taken from the base mesh.
1697
+
1698
+
1655
1699
  Read off the EVALUATED mesh rather than the modifier: what the fabric did is the geometry, and the
1656
1700
  modifier only says what was asked for. The highest point is the pinned edge, which is how the
1657
1701
  reading says "it draped FROM somewhere" rather than merely "it moved".
@@ -1670,16 +1714,152 @@ def cloth_reading(obj, first_frame, last_frame):
1670
1714
  if heights:
1671
1715
  reading[label + "LowestZ"] = round(min(heights), 6)
1672
1716
  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".
1717
+ # THE VERTEX COUNTS ARE PART OF THE READING, not diagnostics. MEASURED, and a surviving
1718
+ # mutation is why there are TWO of them: "the fabric had at least 64 vertices" passed on a
1719
+ # generator that already had that many, so it proved nothing about the subdivision. What
1720
+ # proves it is the RATIO — how many vertices the evaluated mesh has against how many the
1721
+ # object was built with — and that needs both numbers to be read.
1677
1722
  reading[label + "Vertices"] = len(heights)
1723
+ reading[label + "SourceVertices"] = len(obj.data.vertices)
1678
1724
  if "firstFrameLowestZ" in reading and "lastFrameLowestZ" in reading:
1679
1725
  reading["droppedZ"] = round(reading["firstFrameLowestZ"] - reading["lastFrameLowestZ"], 6)
1680
1726
  return reading
1681
1727
 
1682
1728
 
1729
+ def build_soft_body(entry, obj, guard):
1730
+ """Give a mesh soft-body physics: it deforms, and it remembers its shape.
1731
+
1732
+ THE GOAL IS WHAT MAKES IT A SOFT BODY rather than cloth — `goal` is how strongly each vertex wants to
1733
+ return to where it started, and Blender's default is high (0.7). MEASURED: a 0.5m cube dropped 25
1734
+ frames falls 0.125m and keeps its 0.5m spread, which is exactly what a body with a goal should do,
1735
+ and both halves of that are asserted so a future default change cannot quietly turn it into cloth.
1736
+ """
1737
+ settings = entry or {}
1738
+
1739
+ # THE SUBDIVISION IS NOT OPTIONAL, for the same measured reason as cloth: a primitive has no interior
1740
+ # to deform, so a soft body on an eight-vertex cube is a cube that moves.
1741
+ subdivision = obj.modifiers.new(name="DeepBlendSoftBodySubdivision", type="SUBSURF")
1742
+ subdivision.subdivision_type = "SIMPLE"
1743
+ subdivision.levels = int(settings.get("subdivisions", 3))
1744
+ subdivision.render_levels = subdivision.levels
1745
+
1746
+ modifier = obj.modifiers.new(name="DeepBlendSoftBody", type="SOFT_BODY")
1747
+ body_settings = modifier.settings
1748
+ if settings.get("mass") is not None:
1749
+ body_settings.mass = float(settings["mass"])
1750
+ if settings.get("goal") is not None:
1751
+ body_settings.goal_default = float(settings["goal"])
1752
+
1753
+ # PINNING IS EXPRESSED AS A GOAL, which is this modifier's own word for it: a vertex group at goal
1754
+ # 1.0 holds its vertices exactly, and one at 0.0 lets them go.
1755
+ if settings.get("pinTop"):
1756
+ ys = [vertex.co.y for vertex in obj.data.vertices]
1757
+ if ys:
1758
+ top = max(ys)
1759
+ pinned = [vertex.index for vertex in obj.data.vertices if abs(vertex.co.y - top) < 1e-4]
1760
+ group = obj.vertex_groups.new(name="DeepBlendSoftBodyPin")
1761
+ group.add(pinned, 1.0, "REPLACE")
1762
+ body_settings.use_goal = True
1763
+ body_settings.vertex_group_goal = group.name
1764
+ body_settings.goal_min = 1.0
1765
+ body_settings.goal_max = 1.0
1766
+ return obj
1767
+
1768
+
1769
+ DOMAIN_TYPE_BY_NAME = {"liquid": "LIQUID", "gas": "GAS"}
1770
+ FLOW_TYPE_BY_NAME = {"liquid": "LIQUID", "smoke": "SMOKE", "fire": "FIRE", "both": "BOTH"}
1771
+
1772
+
1773
+ def build_fluid(entry, obj, guard):
1774
+ """Give one object its part in a fluid simulation.
1775
+
1776
+ THE ROLE IS THE WHOLE REASON THIS IS ONE FIELD RATHER THAN A FLAG: a fluid is at least two entities —
1777
+ a domain, which is the volume the liquid lives in, and something inside it that emits. There is no
1778
+ such thing as a single entity that is a fluid.
1779
+
1780
+ AND THE DOMAIN TYPE IS THE POINT OF THIS SLICE. MEASURED, and it is why the product's default is
1781
+ `liquid` rather than Blender's `gas`: a LIQUID inflow into a GAS domain produces NOTHING — the bake
1782
+ reports success, both caches report baked, and the domain's evaluated mesh is its own cube. Nothing
1783
+ in that chain says "you chose the wrong domain type", which is exactly the kind of silence this
1784
+ product is built to avoid.
1785
+ """
1786
+ role = entry.get("role")
1787
+ modifier = obj.modifiers.new(name="DeepBlendFluid", type="FLUID")
1788
+ if role == "domain":
1789
+ modifier.fluid_type = "DOMAIN"
1790
+ settings = modifier.domain_settings
1791
+ settings.domain_type = DOMAIN_TYPE_BY_NAME.get(entry.get("domainType", "liquid"), "LIQUID")
1792
+ settings.resolution_max = int(entry.get("resolution", 32))
1793
+ # THE SURFACE HAS TO BE ASKED FOR. `use_mesh` defaults to true and the mesh cache is baked by
1794
+ # `fluid.bake_all`, but a build that turns either off gets a domain with no liquid surface and no
1795
+ # indication that anything is missing, so both are set explicitly rather than inherited.
1796
+ settings.use_mesh = True
1797
+ elif role in ("inflow", "outflow"):
1798
+ modifier.fluid_type = "FLOW"
1799
+ settings = modifier.flow_settings
1800
+ settings.flow_type = FLOW_TYPE_BY_NAME.get(entry.get("flowType", "liquid"), "LIQUID")
1801
+ settings.flow_behavior = "INFLOW" if role == "inflow" else "OUTFLOW"
1802
+ else:
1803
+ raise ActionError(
1804
+ "SCENE_SPEC_INVALID",
1805
+ 'fluid entity "%s" has role "%s"; expected domain, inflow or outflow'
1806
+ % (obj.get("deepblend_id"), role),
1807
+ )
1808
+ return obj
1809
+
1810
+
1811
+ def fluid_reading(domain_entries, entity_objects, first_frame, last_frame, guard):
1812
+ """How much liquid there is, read as the DOMAIN's evaluated mesh against its own cube.
1813
+
1814
+ THE RATIO IS THE READING, for the same measured reason as the subdivision check: a count says nothing
1815
+ on its own, because a domain with no liquid still evaluates to its own eight-vertex cube. Comparing
1816
+ the evaluated mesh against the mesh the object was built with is what tells "there is liquid in here"
1817
+ apart from "there is a box here".
1818
+ """
1819
+ scene = bpy.context.scene
1820
+ reading = {}
1821
+ for entity_id in domain_entries:
1822
+ obj = entity_objects[entity_id]
1823
+ entry = {}
1824
+ for label, frame in (("firstFrame", first_frame), ("lastFrame", last_frame)):
1825
+ scene.frame_set(frame)
1826
+ bpy.context.view_layer.update()
1827
+ depsgraph = bpy.context.evaluated_depsgraph_get()
1828
+ evaluated = obj.evaluated_get(depsgraph)
1829
+ mesh = evaluated.to_mesh()
1830
+ entry[label + "Vertices"] = len(mesh.vertices)
1831
+ evaluated.to_mesh_clear()
1832
+ entry["sourceVertices"] = len(obj.data.vertices)
1833
+ settings = obj.modifiers["DeepBlendFluid"].domain_settings
1834
+ entry["domainType"] = settings.domain_type
1835
+ entry["meshCacheBaked"] = bool(settings.has_cache_baked_mesh)
1836
+ entry["dataCacheBaked"] = bool(settings.has_cache_baked_data)
1837
+ entry["resolution"] = settings.resolution_max
1838
+ entry["useMesh"] = bool(settings.use_mesh)
1839
+ flows = [other for other in obj.users_scene[0].objects
1840
+ if other.modifiers.get("DeepBlendFluid") is not None
1841
+ and other.modifiers["DeepBlendFluid"].fluid_type == "FLOW"]
1842
+ entry["flows"] = [(other.name, other.modifiers["DeepBlendFluid"].flow_settings.flow_behavior,
1843
+ len(other.data.vertices) if other.type == "MESH" else 0) for other in flows]
1844
+ # A DOMAIN THAT PRODUCED NO LIQUID IS A WARNING, NOT A SILENCE. Every signal along the way says
1845
+ # it worked — the bake returns, both caches report baked, the domain type is what was asked for —
1846
+ # and the evaluated mesh is still the domain's own box. MEASURED, and it is the reason this
1847
+ # product's `domainType` default is `liquid` rather than Blender's `gas`; a build that asks for
1848
+ # something else, or whose inflow is finer than the grid, lands here and should be told.
1849
+ if entry["lastFrameVertices"] <= entry["sourceVertices"]:
1850
+ guard.warnings.append({
1851
+ "code": "FLUID_NO_LIQUID",
1852
+ "message": 'fluid domain "%s" baked but its evaluated mesh is still the domain itself '
1853
+ "(%d vertices against %d), so no liquid was produced — check that the domain "
1854
+ "type matches what the flows emit, and that its resolution is fine enough for "
1855
+ "them to be seen"
1856
+ % (entity_id, entry["lastFrameVertices"], entry["sourceVertices"]),
1857
+ "detail": {key: entry[key] for key in ("domainType", "resolution", "flows") if key in entry},
1858
+ })
1859
+ reading[entity_id] = {"kind": "fluid", **entry}
1860
+ return reading
1861
+
1862
+
1683
1863
  def build_scene(spec, options, guard):
1684
1864
  """Compile a SceneSpec into a live Blender scene.
1685
1865
 
@@ -1872,7 +2052,34 @@ def build_scene(spec, options, guard):
1872
2052
  'entity "%s" declares cloth but has no object to hang it on' % (entity["id"],),
1873
2053
  )
1874
2054
  cloth_entities[entity["id"]] = entry
1875
- simulation_reading = build_rigid_bodies(spec, entity_objects, guard, cloth_entities)
2055
+ soft_entities = {}
2056
+ for entity in spec.get("entities") or []:
2057
+ entry = entity.get("softBody")
2058
+ if entry is None:
2059
+ continue
2060
+ if entity_objects.get(entity["id"]) is None:
2061
+ raise ActionError(
2062
+ "SCENE_SPEC_INVALID",
2063
+ 'entity "%s" declares a soft body but has no object to simulate' % (entity["id"],),
2064
+ )
2065
+ soft_entities[entity["id"]] = entry
2066
+ fluid_entities = {}
2067
+ for entity in spec.get("entities") or []:
2068
+ entry = entity.get("fluid")
2069
+ if entry is None:
2070
+ continue
2071
+ if entity_objects.get(entity["id"]) is None:
2072
+ raise ActionError(
2073
+ "SCENE_SPEC_INVALID",
2074
+ 'entity "%s" declares a fluid role but has no object to give it to' % (entity["id"],),
2075
+ )
2076
+ fluid_entities[entity["id"]] = entry
2077
+ if len([entry for entry in fluid_entities.values() if entry.get("role") == "domain"]) == 0 and fluid_entities:
2078
+ raise ActionError(
2079
+ "SCENE_SPEC_INVALID",
2080
+ "the scene declares fluid objects but no domain, so the liquid would have nowhere to be",
2081
+ )
2082
+ simulation_reading = build_rigid_bodies(spec, entity_objects, guard, cloth_entities, soft_entities, fluid_entities)
1876
2083
 
1877
2084
  report_progress("configure_render", 85)
1878
2085
  render_config = configure_scene(scene, spec, profile, guard)