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

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.
Files changed (43) hide show
  1. package/lib/index.js +50 -35
  2. package/package.json +2 -2
  3. package/python/bootstrap.py +4 -2
  4. package/python/deepblend_anisotropy.py +110 -0
  5. package/python/deepblend_asset_bundle.py +193 -0
  6. package/python/deepblend_avif_headers.py +222 -0
  7. package/python/deepblend_bridge.py +46 -4
  8. package/python/deepblend_capabilities.py +7 -0
  9. package/python/deepblend_frames.py +11 -1
  10. package/python/deepblend_geometry.py +210 -0
  11. package/python/deepblend_gltf_images.py +183 -0
  12. package/python/deepblend_gltf_import.py +95 -0
  13. package/python/deepblend_gltf_roles.py +56 -0
  14. package/python/deepblend_image_headers.py +294 -0
  15. package/python/deepblend_image_roles.py +2 -0
  16. package/python/deepblend_images.py +203 -0
  17. package/python/deepblend_imported_image_headers.py +364 -0
  18. package/python/deepblend_mesh_checks.py +145 -0
  19. package/python/deepblend_obj_images.py +54 -0
  20. package/python/deepblend_obj_import.py +58 -0
  21. package/python/deepblend_obj_resources.py +79 -0
  22. package/python/deepblend_parts.py +106 -0
  23. package/python/deepblend_render.py +53 -0
  24. package/python/deepblend_scene.py +447 -51
  25. package/python/deepblend_vessel.py +192 -0
  26. package/python/deepblend_vessel_math.py +116 -0
  27. package/python/deepblend_vessel_parameters.py +65 -0
  28. package/python/deepblend_views.py +18 -19
  29. package/python/__pycache__/bootstrap.cpython-313.pyc +0 -0
  30. package/python/__pycache__/bootstrap.cpython-314.pyc +0 -0
  31. package/python/__pycache__/deepblend_bridge.cpython-313.pyc +0 -0
  32. package/python/__pycache__/deepblend_bridge.cpython-314.pyc +0 -0
  33. package/python/__pycache__/deepblend_capabilities.cpython-313.pyc +0 -0
  34. package/python/__pycache__/deepblend_frames.cpython-313.pyc +0 -0
  35. package/python/__pycache__/deepblend_frames.cpython-314.pyc +0 -0
  36. package/python/__pycache__/deepblend_render.cpython-313.pyc +0 -0
  37. package/python/__pycache__/deepblend_render.cpython-314.pyc +0 -0
  38. package/python/__pycache__/deepblend_scene.cpython-313.pyc +0 -0
  39. package/python/__pycache__/deepblend_scene.cpython-314.pyc +0 -0
  40. package/python/__pycache__/deepblend_util.cpython-313.pyc +0 -0
  41. package/python/__pycache__/deepblend_util.cpython-314.pyc +0 -0
  42. package/python/__pycache__/deepblend_validate.cpython-313.pyc +0 -0
  43. package/python/__pycache__/deepblend_views.cpython-313.pyc +0 -0
@@ -29,12 +29,20 @@ Runs inside Blender's embedded interpreter; ``bpy`` is available at call time.
29
29
 
30
30
  import json
31
31
  import math
32
+ import hashlib
32
33
  import os
34
+ import re
33
35
 
34
36
  import bpy
35
37
  from mathutils import Vector
36
38
 
37
39
  from deepblend_util import ActionError, Guard, as_text, error_text, report_progress, warning
40
+ from deepblend_asset_bundle import verify_asset_bundle, verify_unbundled_gltf_asset, verify_unbundled_obj_asset
41
+ from deepblend_geometry import create_lathe, create_curve, apply_model_modifiers
42
+ from deepblend_images import build_image_maps, build_environment, pack_imported_material_images, check_scene_image_budget
43
+ from deepblend_parts import isolated_import_names, stamp_imported_parts, apply_material_bindings
44
+ from deepblend_anisotropy import build_anisotropy, validate_anisotropy_material, validate_anisotropy_usage, validate_native_material_usage
45
+ from deepblend_images import validate_image_uv_usage
38
46
 
39
47
  #: SceneSpec engine key -> Blender render engine identifier.
40
48
  BLENDER_ENGINE_BY_KEY = {
@@ -65,6 +73,8 @@ PRINCIPLED_SOCKETS = {
65
73
  "emissionStrength": ["Emission Strength"],
66
74
  "coatWeight": ["Coat Weight", "Clearcoat"],
67
75
  "transmissionWeight": ["Transmission Weight", "Transmission"],
76
+ "anisotropic": ["Anisotropic"],
77
+ "anisotropicRotation": ["Anisotropic Rotation"],
68
78
  }
69
79
 
70
80
  #: Layer-weight socket name for the Mix Shader node.
@@ -122,7 +132,7 @@ def reset_scene():
122
132
  return bpy.context.scene
123
133
 
124
134
 
125
- def build_world(scene, spec):
135
+ def build_world(scene, spec, options=None):
126
136
  """Apply the SceneSpec's `world` block, or the documented default.
127
137
 
128
138
  The background a viewer sees behind the product is the World, and until this
@@ -155,7 +165,13 @@ def build_world(scene, spec):
155
165
  if background is not None:
156
166
  background.inputs[0].default_value = color
157
167
  background.inputs[1].default_value = strength
158
- return {"declared": declared is not None, "color": list(color), "strength": strength}
168
+ report = {"declared": declared is not None, "color": list(color), "strength": strength}
169
+ if declared and declared.get('environment'):
170
+ image = build_environment(world, background, declared['environment'],
171
+ {asset['id']: asset for asset in spec.get('assets') or []},
172
+ (options or {}).get('project_root'))
173
+ report['environment'] = {**declared['environment'], 'image': image}
174
+ return report
159
175
  raise ActionError(
160
176
  "BLENDER_SCRIPT_ERROR",
161
177
  "the scene world has no addressable Background node, so the declared world "
@@ -326,9 +342,8 @@ def _shade_smooth(obj):
326
342
  def create_generator(name, spec):
327
343
  """Create the mesh for one ``generator`` entity and return the object.
328
344
 
329
- Every shape is built through a ``bpy.ops.mesh.primitive_*_add`` call so the
330
- mesh is Blender's own primitive rather than hand-built vertex data, and every
331
- call can be replayed to produce the same topology.
345
+ Primitive operators and deterministic profile meshes can be replayed to
346
+ produce the same topology from the same specification.
332
347
  """
333
348
  shape = spec.get("shape")
334
349
  size = float(spec.get("size", 2.0))
@@ -341,9 +356,6 @@ def create_generator(name, spec):
341
356
  bpy.ops.mesh.primitive_cube_add(size=size)
342
357
  elif shape == "rounded_box":
343
358
  bpy.ops.mesh.primitive_cube_add(size=size)
344
- bevel = spec.get("bevel") or {}
345
- if bevel:
346
- _apply_bevel(bpy.context.active_object, bevel)
347
359
  elif shape == "uv_sphere":
348
360
  bpy.ops.mesh.primitive_uv_sphere_add(radius=radius, segments=segments, ring_count=ring_count)
349
361
  elif shape == "cylinder":
@@ -359,16 +371,27 @@ def create_generator(name, spec):
359
371
  major_segments=segments,
360
372
  minor_segments=ring_count,
361
373
  )
374
+ elif shape == "lathe":
375
+ create_lathe(name, spec)
376
+ elif shape == "curve":
377
+ create_curve(name, spec)
378
+ elif shape == "handled_cup":
379
+ from deepblend_vessel import create_handled_cup
380
+ create_handled_cup(name, spec)
362
381
  else:
363
382
  raise ActionError(
364
383
  "BLENDER_SCRIPT_ERROR",
365
384
  'unknown generator shape "%s"' % (shape,),
366
- {"supported": ["cube", "rounded_box", "uv_sphere", "cylinder", "cone", "plane", "torus"]},
385
+ {"supported": ["cube", "rounded_box", "uv_sphere", "cylinder", "cone", "plane", "torus", "lathe", "curve", "handled_cup"]},
367
386
  )
368
387
 
369
388
  obj = bpy.context.active_object
370
389
  if obj is None:
371
390
  raise ActionError("BLENDER_SCRIPT_ERROR", 'primitive for shape "%s" produced no object' % (shape,))
391
+ # Bevel is a generator property, not a rounded_box-only option. Silently
392
+ # ignoring it on cylinders removes the edge highlights product shots need.
393
+ if spec.get("bevel"):
394
+ _apply_bevel(obj, spec["bevel"])
372
395
  obj.name = name
373
396
  obj.data.name = "%s_mesh" % (name,)
374
397
  return obj
@@ -384,23 +407,56 @@ def import_asset_into_scene(name, asset_path, asset_type, guard):
384
407
  format's add-on — is classified as the format being unavailable rather than as
385
408
  a generic script error, because the two need different fixes.
386
409
  """
387
- before = set(bpy.data.objects.keys())
410
+ before = set(bpy.data.objects)
388
411
 
389
412
  if asset_type in ("glb", "gltf"):
390
- call = lambda: bpy.ops.import_scene.gltf(filepath=asset_path) # noqa: E731
413
+ from deepblend_gltf_import import import_gltf
414
+ call = lambda: import_gltf(asset_path) # noqa: E731
391
415
  elif asset_type == "fbx":
392
416
  call = lambda: bpy.ops.import_scene.fbx(filepath=asset_path) # noqa: E731
393
417
  elif asset_type == "obj":
394
- call = lambda: bpy.ops.wm.obj_import(filepath=asset_path) # noqa: E731
418
+ from deepblend_obj_import import import_obj
419
+ call = lambda: import_obj(asset_path) # noqa: E731
395
420
  elif asset_type == "usd":
396
421
  call = lambda: bpy.ops.wm.usd_import(filepath=asset_path) # noqa: E731
397
422
  elif asset_type == "blend":
398
- call = lambda: bpy.ops.wm.append(filename=asset_path) # noqa: E731
423
+ def call():
424
+ # Read datablocks without opening the file or executing registered
425
+ # text scripts. One scene identifies its real members and excludes
426
+ # orphan object datablocks; an object-only library is also supported.
427
+ with bpy.data.libraries.load(asset_path, link=False) as (source, destination):
428
+ if len(source.scenes) > 1:
429
+ raise ValueError('.blend assets currently require at most one scene')
430
+ has_scene = bool(source.scenes)
431
+ if has_scene:
432
+ destination.scenes = list(source.scenes)
433
+ else:
434
+ destination.objects = list(source.objects)
435
+ source_scene = destination.scenes[0] if has_scene else None
436
+ imported_objects = list(source_scene.objects) if source_scene else [obj for obj in destination.objects if obj is not None]
437
+ if any(obj.instance_type == 'COLLECTION' for obj in imported_objects):
438
+ raise ValueError('.blend collection instances are not supported; realize them before importing')
439
+ if source_scene is not None:
440
+ if len(source_scene.view_layers) > 1:
441
+ raise ValueError('.blend multiple view layers are not supported')
442
+ def hidden_collection(layer):
443
+ return (layer.exclude or layer.hide_viewport or layer.collection.hide_render or layer.collection.hide_viewport
444
+ or any(hidden_collection(child) for child in layer.children))
445
+ if any(hidden_collection(layer.layer_collection) for layer in source_scene.view_layers):
446
+ raise ValueError('.blend collection or view-layer visibility overrides are not supported; use object visibility')
447
+ for obj in imported_objects:
448
+ bpy.context.scene.collection.objects.link(obj)
449
+ if source_scene is not None:
450
+ bpy.data.scenes.remove(source_scene)
451
+ return imported_objects
399
452
  else:
400
453
  raise ActionError("ASSET_FORMAT_UNAVAILABLE", 'unsupported asset type "%s"' % (asset_type,))
401
454
 
402
455
  try:
403
- call()
456
+ with isolated_import_names(name):
457
+ loaded = call()
458
+ imported = loaded if asset_type == 'blend' else [obj for obj in bpy.data.objects if obj not in before]
459
+ stamp_imported_parts(imported)
404
460
  except Exception as exc:
405
461
  raise ActionError(
406
462
  "ASSET_FORMAT_UNAVAILABLE",
@@ -408,7 +464,6 @@ def import_asset_into_scene(name, asset_path, asset_type, guard):
408
464
  {"assetPath": asset_path, "assetType": asset_type},
409
465
  )
410
466
 
411
- imported = [bpy.data.objects[key] for key in bpy.data.objects.keys() if key not in before]
412
467
  if not imported:
413
468
  raise ActionError(
414
469
  "ASSET_MISSING",
@@ -416,16 +471,27 @@ def import_asset_into_scene(name, asset_path, asset_type, guard):
416
471
  {"assetPath": asset_path},
417
472
  )
418
473
 
419
- root = imported[0]
420
- root.name = name
421
- for obj in imported[1:]:
422
- obj.parent = root
474
+ if asset_type in ('obj', 'gltf', 'glb'):
475
+ pack_imported_material_images(imported)
476
+
477
+ # The SceneSpec transform belongs to an instance container. Choosing the
478
+ # first imported object as the root overwrites its authored transform and
479
+ # flattening every parent destroys assemblies, rigs and nested transforms.
480
+ bpy.context.view_layer.update()
481
+ imported_set = set(imported)
482
+ root = bpy.data.objects.new(name, None)
483
+ bpy.context.scene.collection.objects.link(root)
484
+ for obj in imported:
485
+ if obj.parent not in imported_set:
486
+ world = obj.matrix_world.copy()
487
+ obj.parent = root
488
+ obj.matrix_world = world
423
489
  guard.note(
424
490
  "SCENE_ASSET_IMPORTED",
425
491
  'entity "%s" imported %d object(s) from %s' % (name, len(imported), asset_path),
426
492
  {"assetPath": asset_path, "objectCount": len(imported)},
427
493
  )
428
- return imported
494
+ return [root, *imported]
429
495
 
430
496
 
431
497
  # ---------------------------------------------------------------------------
@@ -532,8 +598,8 @@ def _build_texture_graph(material, principled, texture, guard, material_id):
532
598
 
533
599
  A material carrying only scalar parameters shades as one flat colour, which is
534
600
  why paper, wood and glazed ceramic all read as the same plastic. This builds the
535
- relief and variation that tells them apart, in OBJECT space so the pattern
536
- travels with the object and no image has to be ingested.
601
+ relief and variation that tells them apart. Object space is the legacy
602
+ default; UV space follows an authored surface layout without an image.
537
603
 
538
604
  Every socket is looked up by name and every stage is optional: a build missing
539
605
  one node degrades to a plainer material and says so, rather than failing the
@@ -563,12 +629,19 @@ def _build_texture_graph(material, principled, texture, guard, material_id):
563
629
  )
564
630
  return
565
631
 
566
- coord = nodes.new("ShaderNodeTexCoord")
632
+ mode = texture.get("coordinates", "object")
633
+ if mode not in ("object", "uv") or ("uvMap" in texture and
634
+ (mode != "uv" or not isinstance(texture['uvMap'], str) or not texture['uvMap'].strip())):
635
+ raise ActionError("SCENE_VALIDATION_FAILED", "procedural uvMap requires UV coordinates",
636
+ {"materialId": material_id})
637
+ coord = nodes.new("ShaderNodeUVMap" if mode == "uv" else "ShaderNodeTexCoord")
638
+ if mode == "uv":
639
+ coord.uv_map = texture.get("uvMap", "")
567
640
  coord.location = (-1080, -240)
568
641
  mapping = nodes.new("ShaderNodeMapping")
569
642
  mapping.location = (-900, -240)
570
643
  pattern.location = (-700, -240)
571
- links.new(coord.outputs["Object"], mapping.inputs["Vector"])
644
+ links.new(coord.outputs["UV" if mode == "uv" else "Object"], mapping.inputs["Vector"])
572
645
  links.new(mapping.outputs["Vector"], pattern.inputs["Vector"])
573
646
 
574
647
  stretch = texture.get("stretch") or [1.0, 1.0, 1.0]
@@ -659,12 +732,16 @@ def _build_texture_graph(material, principled, texture, guard, material_id):
659
732
  links.new(multiply.outputs["Vector"], base_socket)
660
733
 
661
734
 
662
- def build_material(spec, guard):
735
+ def build_material(spec, guard, assets=None, project_root=None):
663
736
  """Create one Blender material from a SceneSpec material entry."""
664
737
  material_id = spec["id"]
665
738
  shader = spec.get("shader", "principled")
666
739
  parameters = spec.get("parameters") or {}
667
740
 
741
+ if shader == 'emission' and (spec.get('texture') or {}).get('coordinates') == 'uv':
742
+ raise ActionError('SCENE_VALIDATION_FAILED', 'UV procedural texture requires a principled or glass material',
743
+ {'materialId': material_id})
744
+
668
745
  material = bpy.data.materials.new(name="%s%s" % (MATERIAL_PREFIX, material_id))
669
746
  # A material has `use_nodes` enabled by default in 5.x and arrives with a
670
747
  # Principled BSDF already wired to the output. Setting it again only emits a
@@ -698,15 +775,18 @@ def build_material(spec, guard):
698
775
 
699
776
  for key, value in parameters.items():
700
777
  _set_socket(principled, key, value, guard, material_id)
778
+ build_anisotropy(material, principled, spec)
701
779
 
702
780
  # After the scalars, so the pattern varies around the authored values
703
781
  # rather than replacing them.
704
782
  texture = spec.get("texture")
705
783
  if isinstance(texture, dict):
706
784
  _build_texture_graph(material, principled, texture, guard, material_id)
785
+ if spec.get('images'):
786
+ build_image_maps(material, principled, spec['images'], assets or {}, project_root)
707
787
 
708
788
  alpha = parameters.get("alpha")
709
- if isinstance(alpha, (int, float)) and float(alpha) < 1.0:
789
+ if (isinstance(alpha, (int, float)) and float(alpha) < 1.0) or 'alpha' in (spec.get('images') or {}):
710
790
  # Non-opaque alpha needs an explicit blend mode, or the viewport and the
711
791
  # render disagree about whether the surface is see-through.
712
792
  try:
@@ -1486,7 +1566,7 @@ def skin_entity(obj, armature_obj, armature_id, guard):
1486
1566
  return True
1487
1567
 
1488
1568
 
1489
- def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None, soft_entities=None):
1569
+ def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None, soft_entities=None, fluid_entities=None):
1490
1570
  """Give the entities their physics, and BAKE the result into the checkpoint.
1491
1571
 
1492
1572
  WHY THE COMPILE BAKES: a rigid body that has not been baked is a SETTING, not a motion — Blender
@@ -1501,6 +1581,10 @@ def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None, soft_en
1501
1581
  """
1502
1582
  cloth_entities = cloth_entities or {}
1503
1583
  soft_entities = soft_entities or {}
1584
+ fluid_entities = fluid_entities or {}
1585
+ # ONLY THE DOMAINS HAVE A READING: the flows are what pours in, and what the caller wants to know is
1586
+ # how much liquid there is, which is a property of the domain.
1587
+ fluid_domains = {key: value for key, value in fluid_entities.items() if value.get("role") == "domain"}
1504
1588
  settings = spec.get("simulation") or {}
1505
1589
  scene = bpy.context.scene
1506
1590
  if "gravity" in settings:
@@ -1545,7 +1629,7 @@ def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None, soft_en
1545
1629
  obj.rigid_body.friction = float(entry["friction"])
1546
1630
  bodies[entity["id"]] = entry
1547
1631
 
1548
- if not bodies and not cloth_entities and not soft_entities:
1632
+ if not bodies and not cloth_entities and not soft_entities and not fluid_entities:
1549
1633
  return {}
1550
1634
 
1551
1635
  # A SCENE WITH CLOTH AND NO RIGID BODIES STILL HAS CACHES TO BAKE. MEASURED: the first version
@@ -1571,6 +1655,11 @@ def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None, soft_en
1571
1655
  for entity_id, entry in soft_entities.items():
1572
1656
  build_soft_body(entry, entity_objects[entity_id], guard)
1573
1657
 
1658
+ # FLUIDS ARE BUILT BEFORE THE BAKE TOO, for the same reason as everything else: `bake_all` walks the
1659
+ # caches that exist, and a modifier created afterwards has no cache to walk.
1660
+ for entity_id, entry in fluid_entities.items():
1661
+ build_fluid(entry, entity_objects[entity_id], guard)
1662
+
1574
1663
  reading = {}
1575
1664
  first_frame = int((spec.get("project") or {}).get("frameStart", 1))
1576
1665
  last_frame = int((spec.get("project") or {}).get("frameEnd", first_frame))
@@ -1586,9 +1675,28 @@ def build_rigid_bodies(spec, entity_objects, guard, cloth_entities=None, soft_en
1586
1675
  "message": "the simulation could not be baked: %s" % (error_text(exc),),
1587
1676
  })
1588
1677
 
1678
+ # THE FLUID BAKE IS A DIFFERENT OPERATOR FROM THE POINT CACHES, and this is the second measurement
1679
+ # carried over from the last round: `ptcache.bake_all` bakes cloth and soft bodies, while a fluid
1680
+ # domain needs `fluid.bake_all` — and THAT operator reads the CONTEXT, so the domain has to be the
1681
+ # active object or it refuses with "Invalid domain" (the first carried measurement).
1682
+ if fluid_domains:
1683
+ for entity_id in fluid_domains:
1684
+ bpy.context.view_layer.objects.active = entity_objects[entity_id]
1685
+ try:
1686
+ bpy.ops.fluid.bake_all()
1687
+ except Exception as exc:
1688
+ guard.warnings.append({
1689
+ "code": "FLUID_BAKE_FAILED",
1690
+ "message": 'the fluid domain "%s" could not be baked: %s' % (entity_id, error_text(exc)),
1691
+ })
1692
+ break
1693
+
1589
1694
  for entity_id in soft_entities:
1590
1695
  reading[entity_id] = {"kind": "soft-body", **deform_reading(entity_objects[entity_id], first_frame, last_frame)}
1591
1696
 
1697
+ # AFTER THE BAKE, because the liquid only exists once the mesh cache has been written.
1698
+ reading.update(fluid_reading(fluid_domains, entity_objects, first_frame, last_frame, guard))
1699
+
1592
1700
  for entity_id in cloth_entities:
1593
1701
  # INTO THE SAME READING as the rigid bodies: one report of what the physics did, whatever the
1594
1702
  # mechanism, because a caller asking "what happened in this scene" should not have to know which
@@ -1738,6 +1846,100 @@ def build_soft_body(entry, obj, guard):
1738
1846
  return obj
1739
1847
 
1740
1848
 
1849
+ DOMAIN_TYPE_BY_NAME = {"liquid": "LIQUID", "gas": "GAS"}
1850
+ FLOW_TYPE_BY_NAME = {"liquid": "LIQUID", "smoke": "SMOKE", "fire": "FIRE", "both": "BOTH"}
1851
+
1852
+
1853
+ def build_fluid(entry, obj, guard):
1854
+ """Give one object its part in a fluid simulation.
1855
+
1856
+ THE ROLE IS THE WHOLE REASON THIS IS ONE FIELD RATHER THAN A FLAG: a fluid is at least two entities —
1857
+ a domain, which is the volume the liquid lives in, and something inside it that emits. There is no
1858
+ such thing as a single entity that is a fluid.
1859
+
1860
+ AND THE DOMAIN TYPE IS THE POINT OF THIS SLICE. MEASURED, and it is why the product's default is
1861
+ `liquid` rather than Blender's `gas`: a LIQUID inflow into a GAS domain produces NOTHING — the bake
1862
+ reports success, both caches report baked, and the domain's evaluated mesh is its own cube. Nothing
1863
+ in that chain says "you chose the wrong domain type", which is exactly the kind of silence this
1864
+ product is built to avoid.
1865
+ """
1866
+ role = entry.get("role")
1867
+ modifier = obj.modifiers.new(name="DeepBlendFluid", type="FLUID")
1868
+ if role == "domain":
1869
+ modifier.fluid_type = "DOMAIN"
1870
+ settings = modifier.domain_settings
1871
+ settings.domain_type = DOMAIN_TYPE_BY_NAME.get(entry.get("domainType", "liquid"), "LIQUID")
1872
+ settings.resolution_max = int(entry.get("resolution", 32))
1873
+ # THE SURFACE HAS TO BE ASKED FOR. `use_mesh` defaults to true and the mesh cache is baked by
1874
+ # `fluid.bake_all`, but a build that turns either off gets a domain with no liquid surface and no
1875
+ # indication that anything is missing, so both are set explicitly rather than inherited.
1876
+ settings.use_mesh = True
1877
+ elif role in ("inflow", "outflow"):
1878
+ modifier.fluid_type = "FLOW"
1879
+ settings = modifier.flow_settings
1880
+ settings.flow_type = FLOW_TYPE_BY_NAME.get(entry.get("flowType", "liquid"), "LIQUID")
1881
+ settings.flow_behavior = "INFLOW" if role == "inflow" else "OUTFLOW"
1882
+ else:
1883
+ raise ActionError(
1884
+ "SCENE_SPEC_INVALID",
1885
+ 'fluid entity "%s" has role "%s"; expected domain, inflow or outflow'
1886
+ % (obj.get("deepblend_id"), role),
1887
+ )
1888
+ return obj
1889
+
1890
+
1891
+ def fluid_reading(domain_entries, entity_objects, first_frame, last_frame, guard):
1892
+ """How much liquid there is, read as the DOMAIN's evaluated mesh against its own cube.
1893
+
1894
+ THE RATIO IS THE READING, for the same measured reason as the subdivision check: a count says nothing
1895
+ on its own, because a domain with no liquid still evaluates to its own eight-vertex cube. Comparing
1896
+ the evaluated mesh against the mesh the object was built with is what tells "there is liquid in here"
1897
+ apart from "there is a box here".
1898
+ """
1899
+ scene = bpy.context.scene
1900
+ reading = {}
1901
+ for entity_id in domain_entries:
1902
+ obj = entity_objects[entity_id]
1903
+ entry = {}
1904
+ for label, frame in (("firstFrame", first_frame), ("lastFrame", last_frame)):
1905
+ scene.frame_set(frame)
1906
+ bpy.context.view_layer.update()
1907
+ depsgraph = bpy.context.evaluated_depsgraph_get()
1908
+ evaluated = obj.evaluated_get(depsgraph)
1909
+ mesh = evaluated.to_mesh()
1910
+ entry[label + "Vertices"] = len(mesh.vertices)
1911
+ evaluated.to_mesh_clear()
1912
+ entry["sourceVertices"] = len(obj.data.vertices)
1913
+ settings = obj.modifiers["DeepBlendFluid"].domain_settings
1914
+ entry["domainType"] = settings.domain_type
1915
+ entry["meshCacheBaked"] = bool(settings.has_cache_baked_mesh)
1916
+ entry["dataCacheBaked"] = bool(settings.has_cache_baked_data)
1917
+ entry["resolution"] = settings.resolution_max
1918
+ entry["useMesh"] = bool(settings.use_mesh)
1919
+ flows = [other for other in obj.users_scene[0].objects
1920
+ if other.modifiers.get("DeepBlendFluid") is not None
1921
+ and other.modifiers["DeepBlendFluid"].fluid_type == "FLOW"]
1922
+ entry["flows"] = [(other.name, other.modifiers["DeepBlendFluid"].flow_settings.flow_behavior,
1923
+ len(other.data.vertices) if other.type == "MESH" else 0) for other in flows]
1924
+ # A DOMAIN THAT PRODUCED NO LIQUID IS A WARNING, NOT A SILENCE. Every signal along the way says
1925
+ # it worked — the bake returns, both caches report baked, the domain type is what was asked for —
1926
+ # and the evaluated mesh is still the domain's own box. MEASURED, and it is the reason this
1927
+ # product's `domainType` default is `liquid` rather than Blender's `gas`; a build that asks for
1928
+ # something else, or whose inflow is finer than the grid, lands here and should be told.
1929
+ if entry["lastFrameVertices"] <= entry["sourceVertices"]:
1930
+ guard.warnings.append({
1931
+ "code": "FLUID_NO_LIQUID",
1932
+ "message": 'fluid domain "%s" baked but its evaluated mesh is still the domain itself '
1933
+ "(%d vertices against %d), so no liquid was produced — check that the domain "
1934
+ "type matches what the flows emit, and that its resolution is fine enough for "
1935
+ "them to be seen"
1936
+ % (entity_id, entry["lastFrameVertices"], entry["sourceVertices"]),
1937
+ "detail": {key: entry[key] for key in ("domainType", "resolution", "flows") if key in entry},
1938
+ })
1939
+ reading[entity_id] = {"kind": "fluid", **entry}
1940
+ return reading
1941
+
1942
+
1741
1943
  def build_scene(spec, options, guard):
1742
1944
  """Compile a SceneSpec into a live Blender scene.
1743
1945
 
@@ -1747,24 +1949,48 @@ def build_scene(spec, options, guard):
1747
1949
  Returns a report describing what was built. Raises ``ActionError`` on any
1748
1950
  failure that must not be silently tolerated.
1749
1951
  """
1750
- report_progress("reset_scene", 5)
1751
- scene = reset_scene()
1752
-
1753
- report_progress("build_world", 10)
1754
- world_report = build_world(scene, spec)
1755
-
1756
- report_progress("configure_scene", 12)
1757
1952
  profile_name = options.get("profile") or "preview"
1758
- profiles = spec.get("renderProfiles") or {}
1759
- profile = profiles.get(profile_name)
1760
- if profile is None:
1761
- profile = profiles.get("preview")
1953
+ profile = (spec.get("renderProfiles") or {}).get(profile_name)
1762
1954
  if profile is None:
1763
1955
  raise ActionError(
1764
1956
  "RENDER_PROFILE_MISSING",
1765
- 'the SceneSpec defines no render profile named "%s" and no "preview" profile' % (profile_name,),
1957
+ 'the SceneSpec defines no render profile named "%s"' % (profile_name,),
1766
1958
  )
1767
1959
 
1960
+ # Check pinned source bytes again at the actual compile boundary. This also
1961
+ # protects lazy checkpoint rebuilds, which do not pass through asset.add.
1962
+ for asset in spec.get("assets") or []:
1963
+ verify_asset_bundle(options.get("project_root") or "", asset)
1964
+ addressed = re.fullmatch(r"assets/raw/([a-f0-9]{64})\.[a-z0-9]+", asset.get("path", ""))
1965
+ declared = addressed.group(1) if addressed else asset.get("sha256")
1966
+ if not declared:
1967
+ verify_unbundled_gltf_asset(options.get("project_root") or "", asset)
1968
+ verify_unbundled_obj_asset(options.get("project_root") or "", asset)
1969
+ continue
1970
+ asset_path = os.path.join(options.get("project_root") or "", asset.get("path", ""))
1971
+ digest = hashlib.sha256()
1972
+ try:
1973
+ with open(asset_path, "rb") as source:
1974
+ for chunk in iter(lambda: source.read(64 * 1024), b""):
1975
+ digest.update(chunk)
1976
+ except OSError as error:
1977
+ raise ActionError("ASSET_MISSING", 'cannot read pinned asset "%s": %s' % (asset.get("id"), error))
1978
+ actual = digest.hexdigest()
1979
+ if actual != declared or (asset.get("sha256") and asset["sha256"] != declared):
1980
+ raise ActionError("ASSET_HASH_MISMATCH", 'asset "%s" changed since its revision was declared' % asset.get("id"),
1981
+ {"assetId": asset.get("id"), "declared": declared, "actual": actual})
1982
+
1983
+ verify_unbundled_gltf_asset(options.get("project_root") or "", asset)
1984
+ verify_unbundled_obj_asset(options.get("project_root") or "", asset)
1985
+
1986
+ check_scene_image_budget(spec, options.get('project_root'))
1987
+ report_progress("reset_scene", 5)
1988
+ scene = reset_scene()
1989
+
1990
+ report_progress("build_world", 10)
1991
+ world_report = build_world(scene, spec, options)
1992
+
1993
+ report_progress("configure_scene", 12)
1768
1994
  requested_engine = profile.get("engine", "cycles")
1769
1995
  engine, downgrade = resolve_engine(requested_engine, guard)
1770
1996
  try:
@@ -1780,8 +2006,10 @@ def build_scene(spec, options, guard):
1780
2006
  # Materials first: entities reference them by id during construction.
1781
2007
  report_progress("build_materials", 20)
1782
2008
  materials = {}
2009
+ assets = {asset["id"]: asset for asset in spec.get("assets") or []}
1783
2010
  for entry in spec.get("materials") or []:
1784
- materials[entry["id"]] = build_material(entry, guard)
2011
+ validate_anisotropy_material(entry, spec.get('animationTracks') or [])
2012
+ materials[entry["id"]] = build_material(entry, guard, assets, options.get('project_root'))
1785
2013
  default_material = build_default_material(guard)
1786
2014
 
1787
2015
  assets = {asset["id"]: asset for asset in spec.get("assets") or []}
@@ -1795,6 +2023,9 @@ def build_scene(spec, options, guard):
1795
2023
 
1796
2024
  report_progress("build_entities", 35)
1797
2025
  entity_objects = {}
2026
+ entity_meshes = {}
2027
+ native_material_surfaces = []
2028
+ simulation_objects = {}
1798
2029
  entity_positions = {}
1799
2030
  for entity in spec.get("entities") or []:
1800
2031
  entity_id = entity["id"]
@@ -1833,14 +2064,18 @@ def build_scene(spec, options, guard):
1833
2064
  apply_transform(root, entity.get("transform") or {})
1834
2065
  apply_visibility(root, visible)
1835
2066
  for obj in objects:
1836
- apply_visibility(obj, visible)
2067
+ # The instance wrapper follows the entity flag. A visible asset keeps
2068
+ # each source object's hidden helpers; explicit false hides all of it.
2069
+ if entity.get('type') != 'asset-instance' or not visible:
2070
+ apply_visibility(obj, visible)
1837
2071
 
1838
2072
  material_id = entity.get("materialId")
1839
- if material_id is None and entity.get("type") != "empty":
2073
+ preserve_materials = entity.get("type") == "asset-instance" and material_id is None
2074
+ if material_id is None and entity.get("type") not in ("empty", "asset-instance"):
1840
2075
  material_id = "default"
1841
2076
  materials.setdefault("default", default_material)
1842
2077
  material = materials.get(material_id)
1843
- if material is None:
2078
+ if material is None and not preserve_materials and entity.get("type") != "empty":
1844
2079
  raise ActionError(
1845
2080
  "SCENE_VALIDATION_FAILED",
1846
2081
  'entity "%s" references material "%s", which was not built' % (entity_id, material_id),
@@ -1848,17 +2083,72 @@ def build_scene(spec, options, guard):
1848
2083
  for obj in objects:
1849
2084
  if not hasattr(obj.data, "materials"):
1850
2085
  continue
1851
- obj.data.materials.clear()
1852
- obj.data.materials.append(material)
1853
- if entity.get("generator", {}).get("shape") in ("uv_sphere", "cylinder", "cone", "torus"):
2086
+ if not preserve_materials and not entity.get("materialBindings"):
2087
+ obj.data.materials.clear()
2088
+ obj.data.materials.append(material)
2089
+ # Whole-asset override has one slot; imported face indices may
2090
+ # still refer to several slots from the original asset.
2091
+ for polygon in getattr(obj.data, "polygons", []):
2092
+ polygon.material_index = 0
2093
+ generator = entity.get("generator", {})
2094
+ if generator.get("bevel") or generator.get("shape") in ("uv_sphere", "cylinder", "cone", "torus", "lathe", "curve", "handled_cup"):
1854
2095
  _shade_smooth(obj)
1855
2096
 
2097
+ if entity.get("materialBindings"):
2098
+ apply_material_bindings(entity, objects, materials)
2099
+
1856
2100
  for obj in objects:
1857
2101
  obj["deepblend_id"] = entity_id
1858
2102
  obj["deepblend_kind"] = "entity"
1859
2103
  entity_objects[entity_id] = root
2104
+ meshes = [obj for obj in objects if obj.type == "MESH"]
2105
+ entity_meshes[entity_id] = meshes
2106
+ native_material_surfaces.extend(obj for obj in objects if obj.type in ('CURVE', 'SURFACE', 'FONT', 'META'))
2107
+ if any(entity.get(field) is not None for field in ("rigidBody", "cloth", "softBody", "fluid")):
2108
+ if len(meshes) != 1:
2109
+ raise ActionError(
2110
+ "SCENE_SPEC_INVALID",
2111
+ 'entity "%s" needs exactly one mesh for entity-level physics; found %d. '
2112
+ "Declare separately simulated parts as separate entities." % (entity_id, len(meshes)),
2113
+ )
2114
+ simulation_objects[entity_id] = meshes[0]
1860
2115
  entity_positions[entity_id] = [float(root.location[0]), float(root.location[1]), float(root.location[2])]
1861
2116
 
2117
+ apply_model_modifiers(spec.get("entities") or [], entity_meshes)
2118
+ scene['deepblend_requires_cycles'] = False
2119
+ for entry in spec.get('materials') or []:
2120
+ for surface in native_material_surfaces:
2121
+ if validate_native_material_usage(entry, materials[entry['id']], surface, scene.render.engine,
2122
+ spec.get('animationTracks') or [], bpy.context.evaluated_depsgraph_get()):
2123
+ scene['deepblend_requires_cycles'] = True
2124
+ for entity_id, meshes in entity_meshes.items():
2125
+ for mesh in meshes:
2126
+ if (entry.get('texture') or {}).get('coordinates') == 'uv':
2127
+ if validate_native_material_usage(entry, materials[entry['id']], mesh, scene.render.engine,
2128
+ spec.get('animationTracks') or [], bpy.context.evaluated_depsgraph_get()):
2129
+ scene['deepblend_requires_cycles'] = True
2130
+ continue
2131
+ used_slots = {polygon.material_index for polygon in mesh.data.polygons}
2132
+ if not any(slot.material == materials[entry['id']] and index in used_slots
2133
+ for index, slot in enumerate(mesh.material_slots)):
2134
+ continue
2135
+ if validate_anisotropy_usage(entry, mesh, scene.render.engine, spec.get('animationTracks') or []):
2136
+ scene['deepblend_requires_cycles'] = True
2137
+ validate_image_uv_usage(entry, mesh, mesh.data.uv_layers)
2138
+ for entity in spec.get("entities") or []:
2139
+ generator = entity.get("generator") or {}
2140
+ if entity.get("modifiers") and entity.get("type") == "generator" and (
2141
+ generator.get("bevel") or any(entry["type"] == "bevel" for entry in entity["modifiers"])
2142
+ or generator.get("shape") in ("uv_sphere", "cylinder", "cone", "torus", "lathe", "curve", "handled_cup")
2143
+ ):
2144
+ for mesh in entity_meshes[entity["id"]]:
2145
+ hidden = mesh.hide_viewport
2146
+ mesh.hide_viewport = False
2147
+ try:
2148
+ _shade_smooth(mesh)
2149
+ finally:
2150
+ mesh.hide_viewport = hidden
2151
+
1862
2152
  report_progress("build_lights", 55)
1863
2153
  for light in spec.get("lights") or []:
1864
2154
  obj = build_light(light)
@@ -1941,7 +2231,23 @@ def build_scene(spec, options, guard):
1941
2231
  'entity "%s" declares a soft body but has no object to simulate' % (entity["id"],),
1942
2232
  )
1943
2233
  soft_entities[entity["id"]] = entry
1944
- simulation_reading = build_rigid_bodies(spec, entity_objects, guard, cloth_entities, soft_entities)
2234
+ fluid_entities = {}
2235
+ for entity in spec.get("entities") or []:
2236
+ entry = entity.get("fluid")
2237
+ if entry is None:
2238
+ continue
2239
+ if entity_objects.get(entity["id"]) is None:
2240
+ raise ActionError(
2241
+ "SCENE_SPEC_INVALID",
2242
+ 'entity "%s" declares a fluid role but has no object to give it to' % (entity["id"],),
2243
+ )
2244
+ fluid_entities[entity["id"]] = entry
2245
+ if len([entry for entry in fluid_entities.values() if entry.get("role") == "domain"]) == 0 and fluid_entities:
2246
+ raise ActionError(
2247
+ "SCENE_SPEC_INVALID",
2248
+ "the scene declares fluid objects but no domain, so the liquid would have nowhere to be",
2249
+ )
2250
+ simulation_reading = build_rigid_bodies(spec, simulation_objects, guard, cloth_entities, soft_entities, fluid_entities)
1945
2251
 
1946
2252
  report_progress("configure_render", 85)
1947
2253
  render_config = configure_scene(scene, spec, profile, guard)
@@ -1974,7 +2280,18 @@ def build_scene(spec, options, guard):
1974
2280
  'entity "%s" is skinned to armature "%s", which the same SceneSpec does not declare'
1975
2281
  % (entity["id"], armature_id),
1976
2282
  )
1977
- skin_entity(obj, armature_obj, armature_id, guard)
2283
+ meshes = entity_meshes[entity["id"]]
2284
+ if not meshes:
2285
+ raise ActionError("SCENE_SPEC_INVALID", 'entity "%s" has no mesh to skin' % entity["id"])
2286
+ for mesh in meshes:
2287
+ previous_parent = mesh.parent
2288
+ previous_world = mesh.matrix_world.copy()
2289
+ skin_entity(mesh, armature_obj, armature_id, guard)
2290
+ if entity.get("type") == "asset-instance":
2291
+ # Automatic weights also reparents the mesh. Keep the authored
2292
+ # assembly and instance transform; the armature modifier deforms it.
2293
+ mesh.parent = previous_parent
2294
+ mesh.matrix_world = previous_world
1978
2295
  skinned_entities[entity["id"]] = entity
1979
2296
 
1980
2297
  return {
@@ -1994,7 +2311,7 @@ def build_scene(spec, options, guard):
1994
2311
  # THE GROUPS COME FROM THE OBJECT, not from the spec entry: what the file asked for is
1995
2312
  # the relationship, and what Blender produced is the weights. Reading the latter back is
1996
2313
  # the whole point — an entity bound with no groups renders as a static object.
1997
- "vertexGroups": sorted(group.name for group in entity_objects[key].vertex_groups),
2314
+ "vertexGroups": sorted({group.name for mesh in entity_meshes[key] for group in mesh.vertex_groups}),
1998
2315
  }
1999
2316
  for key, entry in skinned_entities.items()
2000
2317
  },
@@ -2002,10 +2319,58 @@ def build_scene(spec, options, guard):
2002
2319
  }
2003
2320
 
2004
2321
 
2322
+ def _uv_map_facts(mesh, source):
2323
+ """Names and finite coordinates are inventory, not an overlap/unwrap verdict."""
2324
+ return [{'name': layer.name, 'activeRender': bool(layer.active_render),
2325
+ 'loopCount': len(layer.data),
2326
+ 'finite': all(math.isfinite(value) for loop in layer.data for value in loop.uv),
2327
+ 'source': source}
2328
+ for layer in mesh.uv_layers]
2329
+
2330
+
2331
+ def _render_collection_members(layer, inherited_excluded=False, members=None):
2332
+ """An object can have several collection paths; one render-enabled path suffices."""
2333
+ members = set() if members is None else members
2334
+ excluded = inherited_excluded or layer.exclude or layer.collection.hide_render
2335
+ if not excluded:
2336
+ members.update(layer.collection.objects)
2337
+ for child in layer.children:
2338
+ _render_collection_members(child, excluded, members)
2339
+ return members
2340
+
2341
+
2342
+ def _object_world_bounds(obj, depsgraph):
2343
+ """Conservative world AABB of an evaluated mesh; wrappers have no geometry.
2344
+
2345
+ Bounds use the evaluated local bounding box transformed by matrix_world.
2346
+ They include the object hierarchy and modifiers, but are not a tight vertex
2347
+ hull or a claim that every enclosed pixel is visible to the camera.
2348
+ """
2349
+ if obj.type != 'MESH':
2350
+ return None
2351
+ evaluated = obj.evaluated_get(depsgraph)
2352
+ if not evaluated.data.vertices:
2353
+ return None
2354
+ local = [tuple(corner) for corner in evaluated.bound_box]
2355
+ if not local or all(corner == (-1.0, -1.0, -1.0) for corner in local):
2356
+ return None
2357
+ corners = [evaluated.matrix_world @ Vector(corner) for corner in local]
2358
+ if not all(math.isfinite(value) for corner in corners for value in corner):
2359
+ return None
2360
+ return {'min': [min(float(corner[axis]) for corner in corners) for axis in range(3)],
2361
+ 'max': [max(float(corner[axis]) for corner in corners) for axis in range(3)]}
2362
+
2363
+
2005
2364
  def describe_objects():
2006
2365
  """Inventory of every object in the scene, for validation and golden checks."""
2007
2366
  entries = []
2367
+ depsgraph = bpy.context.evaluated_depsgraph_get()
2368
+ frame = int(bpy.context.scene.frame_current)
2369
+ render_members = _render_collection_members(bpy.context.view_layer.layer_collection)
2008
2370
  for obj in bpy.data.objects:
2371
+ # Disabled viewport objects can retain stale evaluated transforms. Do
2372
+ # not present that cache as a measured world bound or evaluated UV set.
2373
+ evaluation_available = obj.type == 'MESH' and obj.visible_get(view_layer=bpy.context.view_layer)
2009
2374
  entry = {
2010
2375
  "name": obj.name,
2011
2376
  "type": obj.type,
@@ -2015,12 +2380,43 @@ def describe_objects():
2015
2380
  "rotationEuler": [round(float(value), 6) for value in obj.rotation_euler],
2016
2381
  "scale": [round(float(value), 6) for value in obj.scale],
2017
2382
  "visible": not (obj.hide_render or obj.hide_viewport),
2383
+ # Render eligibility only: viewport hiding is a different flag, and
2384
+ # this does not test occlusion, clipping, alpha or material output.
2385
+ "renderVisible": obj in render_members and not obj.hide_render and obj.visible_camera,
2386
+ "worldBounds": _object_world_bounds(obj, depsgraph) if evaluation_available else None,
2387
+ "boundsFrame": frame,
2018
2388
  "parent": obj.parent.name if obj.parent is not None else None,
2019
2389
  }
2020
2390
  if obj.type == "MESH":
2021
2391
  entry["vertexCount"] = len(obj.data.vertices)
2022
2392
  entry["polygonCount"] = len(obj.data.polygons)
2023
- entry["materialNames"] = [material.name for material in obj.data.materials if material is not None]
2393
+ entry["materialNames"] = [slot.material.name for slot in obj.material_slots if slot.material is not None]
2394
+ entry['uvMaps'] = _uv_map_facts(obj.data, 'mesh-data')
2395
+ if evaluation_available:
2396
+ evaluated = obj.evaluated_get(depsgraph)
2397
+ try:
2398
+ surface = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
2399
+ entry['evaluatedUvMaps'] = _uv_map_facts(surface, 'evaluated-mesh') if surface else []
2400
+ finally:
2401
+ evaluated.to_mesh_clear()
2402
+ if entry['worldBounds'] is None:
2403
+ entry['boundsUnavailable'] = 'empty-or-invalid-bounds'
2404
+ else:
2405
+ entry['boundsUnavailable'] = 'viewport-disabled'
2406
+ entry['evaluatedUvMaps'] = None
2407
+ entry['evaluatedUvMapsUnavailable'] = 'viewport-disabled'
2408
+ if obj.get('deepblend_part_id'):
2409
+ entry['partId'] = obj['deepblend_part_id']
2410
+ entry['parentPartId'] = obj.get('deepblend_parent_part_id') or None
2411
+ entry['sourceMaterialSlots'] = json.loads(obj.get('deepblend_source_material_slots', '[]'))
2412
+ used_slots = {}
2413
+ for polygon in obj.data.polygons:
2414
+ used_slots[polygon.material_index] = used_slots.get(polygon.material_index, 0) + 1
2415
+ entry['materialSlots'] = [
2416
+ {'index': index, 'materialName': slot.material.name if slot.material else None,
2417
+ 'materialId': slot.material.get('deepblend_id') if slot.material else None,
2418
+ 'usedPolygonCount': used_slots.get(index, 0)}
2419
+ for index, slot in enumerate(obj.material_slots)]
2024
2420
  if obj.type == "LIGHT":
2025
2421
  entry["lightType"] = obj.data.type
2026
2422
  entry["energy"] = round(float(obj.data.energy), 6)