@deepblend/dsh-blender-provider-local 0.2.19 → 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.19",
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.19"
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, soft_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
@@ -1501,6 +1501,10 @@ def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None, soft_en
1501
1501
  """
1502
1502
  cloth_entities = cloth_entities or {}
1503
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"}
1504
1508
  settings = spec.get("simulation") or {}
1505
1509
  scene = bpy.context.scene
1506
1510
  if "gravity" in settings:
@@ -1545,7 +1549,7 @@ def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None, soft_en
1545
1549
  obj.rigid_body.friction = float(entry["friction"])
1546
1550
  bodies[entity["id"]] = entry
1547
1551
 
1548
- if not bodies and not cloth_entities and not soft_entities:
1552
+ if not bodies and not cloth_entities and not soft_entities and not fluid_entities:
1549
1553
  return {}
1550
1554
 
1551
1555
  # A SCENE WITH CLOTH AND NO RIGID BODIES STILL HAS CACHES TO BAKE. MEASURED: the first version
@@ -1571,6 +1575,11 @@ def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None, soft_en
1571
1575
  for entity_id, entry in soft_entities.items():
1572
1576
  build_soft_body(entry, entity_objects[entity_id], guard)
1573
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
+
1574
1583
  reading = {}
1575
1584
  first_frame = int((spec.get("project") or {}).get("frameStart", 1))
1576
1585
  last_frame = int((spec.get("project") or {}).get("frameEnd", first_frame))
@@ -1586,9 +1595,28 @@ def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None, soft_en
1586
1595
  "message": "the simulation could not be baked: %s" % (error_text(exc),),
1587
1596
  })
1588
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
+
1589
1614
  for entity_id in soft_entities:
1590
1615
  reading[entity_id] = {"kind": "soft-body", **deform_reading(entity_objects[entity_id], first_frame, last_frame)}
1591
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
+
1592
1620
  for entity_id in cloth_entities:
1593
1621
  # INTO THE SAME READING as the rigid bodies: one report of what the physics did, whatever the
1594
1622
  # mechanism, because a caller asking "what happened in this scene" should not have to know which
@@ -1738,6 +1766,100 @@ def build_soft_body(entry, obj, guard):
1738
1766
  return obj
1739
1767
 
1740
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
+
1741
1863
  def build_scene(spec, options, guard):
1742
1864
  """Compile a SceneSpec into a live Blender scene.
1743
1865
 
@@ -1941,7 +2063,23 @@ def build_scene(spec, options, guard):
1941
2063
  'entity "%s" declares a soft body but has no object to simulate' % (entity["id"],),
1942
2064
  )
1943
2065
  soft_entities[entity["id"]] = entry
1944
- simulation_reading = build_rigid_bodies(spec, entity_objects, guard, cloth_entities, soft_entities)
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)
1945
2083
 
1946
2084
  report_progress("configure_render", 85)
1947
2085
  render_config = configure_scene(scene, spec, profile, guard)