@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.
- package/lib/index.js +50 -35
- package/package.json +2 -2
- package/python/bootstrap.py +4 -2
- package/python/deepblend_anisotropy.py +110 -0
- package/python/deepblend_asset_bundle.py +193 -0
- package/python/deepblend_avif_headers.py +222 -0
- package/python/deepblend_bridge.py +46 -4
- package/python/deepblend_capabilities.py +7 -0
- package/python/deepblend_frames.py +11 -1
- package/python/deepblend_geometry.py +210 -0
- package/python/deepblend_gltf_images.py +183 -0
- package/python/deepblend_gltf_import.py +95 -0
- package/python/deepblend_gltf_roles.py +56 -0
- package/python/deepblend_image_headers.py +294 -0
- package/python/deepblend_image_roles.py +2 -0
- package/python/deepblend_images.py +203 -0
- package/python/deepblend_imported_image_headers.py +364 -0
- package/python/deepblend_mesh_checks.py +145 -0
- package/python/deepblend_obj_images.py +54 -0
- package/python/deepblend_obj_import.py +58 -0
- package/python/deepblend_obj_resources.py +79 -0
- package/python/deepblend_parts.py +106 -0
- package/python/deepblend_render.py +53 -0
- package/python/deepblend_scene.py +447 -51
- package/python/deepblend_vessel.py +192 -0
- package/python/deepblend_vessel_math.py +116 -0
- package/python/deepblend_vessel_parameters.py +65 -0
- package/python/deepblend_views.py +18 -19
- package/python/__pycache__/bootstrap.cpython-313.pyc +0 -0
- package/python/__pycache__/bootstrap.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_bridge.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_bridge.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_capabilities.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_frames.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_frames.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_render.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_render.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_util.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_util.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_validate.cpython-313.pyc +0 -0
- 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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
|
410
|
+
before = set(bpy.data.objects)
|
|
388
411
|
|
|
389
412
|
if asset_type in ("glb", "gltf"):
|
|
390
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
536
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|