@deepblend/dsh-blender-provider-local 0.1.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.
@@ -0,0 +1,1537 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """DeepBlend SceneSpec -> Blender scene compiler.
4
+
5
+ This is the heart of M1's batch provider: it turns a JSON SceneSpec into a real,
6
+ renderable Blender scene and saves it as a ``.blend`` checkpoint.
7
+
8
+ DESIGN RULES
9
+ ------------
10
+ **The spec is the authority.** Every object, material, light, camera and
11
+ keyframe in the resulting scene is derived from the document. Nothing is
12
+ preserved from a previous state and nothing is inferred from the currently open
13
+ blend, because a compile that depended on what was already there could not be
14
+ replayed — and replayability is the entire reason SceneSpec exists (SPEC §8.1).
15
+
16
+ **Determinism where it is observable, honesty where it is not.** Object names,
17
+ counts, transforms, camera parameters, frame range and the keyframe trajectory
18
+ are all exactly specified, so they are asserted. Blender's own float rounding,
19
+ modifier tessellation and GPU sampling are not reproducible across builds, so
20
+ they are reported rather than asserted (SPEC §19.5 "不要求像素完全一致").
21
+
22
+ **Every decision is recorded.** When the compiler has to choose something the
23
+ author did not state — a default sample count, an engine downgrade, a fallback
24
+ aim — it appends a warning or a notice. A silent guess that shows up only in a
25
+ render is the most expensive kind of bug to find.
26
+
27
+ Runs inside Blender's embedded interpreter; ``bpy`` is available at call time.
28
+ """
29
+
30
+ import json
31
+ import math
32
+ import os
33
+
34
+ import bpy
35
+ from mathutils import Vector
36
+
37
+ from deepblend_util import ActionError, Guard, as_text, error_text, report_progress, warning
38
+
39
+ #: SceneSpec engine key -> Blender render engine identifier.
40
+ BLENDER_ENGINE_BY_KEY = {
41
+ "eevee": "BLENDER_EEVEE",
42
+ "cycles": "CYCLES",
43
+ "workbench": "BLENDER_WORKBENCH",
44
+ }
45
+
46
+ #: Engines probed for actual assignability. The static enum cannot be trusted on
47
+ #: Blender 5.2 (it reports only BLENDER_EEVEE while Cycles demonstrably works),
48
+ #: which is finding D1 from the M0 runtime audit.
49
+ CANDIDATE_ENGINES = ["BLENDER_EEVEE", "CYCLES", "BLENDER_WORKBENCH"]
50
+
51
+ #: Preferred engine when the authored one is unavailable, best first.
52
+ ENGINE_FALLBACK_ORDER = ["CYCLES", "BLENDER_EEVEE", "BLENDER_WORKBENCH"]
53
+
54
+ #: Principled BSDF socket names, with the historical spellings each replaced.
55
+ #: Blender 4.0 renamed several; looking up the modern name first and falling back
56
+ #: keeps one code path working across both, instead of a version switch that has
57
+ #: to be re-verified on every release.
58
+ PRINCIPLED_SOCKETS = {
59
+ "baseColor": ["Base Color"],
60
+ "metallic": ["Metallic"],
61
+ "roughness": ["Roughness"],
62
+ "ior": ["IOR"],
63
+ "alpha": ["Alpha"],
64
+ "emissionColor": ["Emission Color", "Emission"],
65
+ "emissionStrength": ["Emission Strength"],
66
+ "coatWeight": ["Coat Weight", "Clearcoat"],
67
+ "transmissionWeight": ["Transmission Weight", "Transmission"],
68
+ }
69
+
70
+ #: Layer-weight socket name for the Mix Shader node.
71
+ MIX_FACTOR_SOCKET = "Factor"
72
+
73
+ #: An `emission` shader is a different node with two sockets of its own, so it gets
74
+ #: its own table rather than being forced through a Principled one. The KEYS are the
75
+ #: same SceneSpec parameter names, which is the point: `material.parameter.update`
76
+ #: and a material animation track accept exactly the same vocabulary.
77
+ EMISSION_SHADER_SOCKETS = {
78
+ "baseColor": ["Color"],
79
+ "emissionColor": ["Color"],
80
+ "emissionStrength": ["Strength"],
81
+ }
82
+
83
+ #: Object name prefixes, so a compiled scene is readable in Blender's outliner
84
+ #: and so validation can tell an entity object from a light by name alone.
85
+ ENTITY_PREFIX = "db_entity__"
86
+ LIGHT_PREFIX = "db_light__"
87
+ CAMERA_PREFIX = "db_camera__"
88
+
89
+ #: Material name prefix.
90
+ MATERIAL_PREFIX = "db_mat__"
91
+
92
+ #: 2*pi, used when a full-turn rotation is requested by a shorthand.
93
+ TAU = 6.283185307179586
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Scene reset
98
+ # ---------------------------------------------------------------------------
99
+
100
+
101
+ #: What a SceneSpec that declares no `world` is lit by. Mirrors `DEFAULT_WORLD` in
102
+ #: `contracts/lib/scene-spec.js`, which mirrors the schema's `default` keywords; the
103
+ #: contract suite asserts the JS and schema copies agree, and the Blender integration
104
+ #: suite asserts this copy produces them.
105
+ DEFAULT_WORLD_COLOR = (0.02, 0.021, 0.026, 1.0)
106
+ DEFAULT_WORLD_STRENGTH = 0.6
107
+
108
+
109
+ def reset_scene():
110
+ """Empty the current Blender file, leaving a pristine scene.
111
+
112
+ ``bpy.ops.wm.read_factory_settings(use_empty=True)`` is the honest way to do
113
+ this: it resets preferences-independent scene state AND removes the startup
114
+ cube/camera/light, in one call whose semantics are Blender's own. Deleting
115
+ datablocks by hand leaves orphaned meshes, materials and collections behind,
116
+ which then reappear in the count assertions and in the saved file.
117
+
118
+ Returns the scene that must be used afterwards — the call replaces
119
+ ``bpy.data``, so any reference captured before it is dead.
120
+ """
121
+ bpy.ops.wm.read_factory_settings(use_empty=True)
122
+ return bpy.context.scene
123
+
124
+
125
+ def build_world(scene, spec):
126
+ """Apply the SceneSpec's `world` block, or the documented default.
127
+
128
+ The background a viewer sees behind the product is the World, and until this
129
+ existed the values were literals in this file: a brief asking for a black
130
+ background could not be honoured through the spec at all, and the workaround
131
+ (a near-black backdrop plane) still rendered mid-grey because a 0-albedo
132
+ Principled surface keeps ~4% Fresnel specular. A `world` block with
133
+ `strength: 0` is now the actual answer, and it is reachable from ScenePatch
134
+ through `world.set`.
135
+ """
136
+ world = scene.world
137
+ if world is None:
138
+ world = bpy.data.worlds.new("db_world")
139
+ scene.world = world
140
+
141
+ declared = spec.get("world") if isinstance(spec.get("world"), dict) else None
142
+ color = declared.get("color") if declared else None
143
+ color = tuple(list(color)[:4]) if color is not None else DEFAULT_WORLD_COLOR
144
+ if len(color) < 4:
145
+ color = tuple(list(color) + [1.0] * (4 - len(color)))
146
+ strength = declared.get("strength") if declared else None
147
+ strength = float(strength) if strength is not None else DEFAULT_WORLD_STRENGTH
148
+
149
+ # `World.use_nodes` is deprecated in 5.x and already defaults to True. Its
150
+ # node tree is read rather than forced, so no deprecation warning reaches
151
+ # stdout (which the provider captures as diagnostics) and an older build
152
+ # without the default still gets a deterministic world.
153
+ if getattr(world, "use_nodes", True) and world.node_tree is not None:
154
+ background = world.node_tree.nodes.get("Background")
155
+ if background is not None:
156
+ background.inputs[0].default_value = color
157
+ background.inputs[1].default_value = strength
158
+ return {"declared": declared is not None, "color": list(color), "strength": strength}
159
+ raise ActionError(
160
+ "BLENDER_SCRIPT_ERROR",
161
+ "the scene world has no addressable Background node, so the declared world "
162
+ "cannot be applied; the render would silently use Blender's default",
163
+ )
164
+
165
+
166
+ def available_engines():
167
+ """Probe which render engines are genuinely assignable (finding D1).
168
+
169
+ Each identifier is assigned to ``scene.render.engine`` and read back. A
170
+ readback that does not match means the identifier does not exist in this
171
+ build, which is the only reliable test: the static ``engine`` enum omits
172
+ engines that work, and ``hasattr``-style checks report operators that are
173
+ declared but unregistered (finding D10).
174
+ """
175
+ scene = bpy.context.scene
176
+ original = scene.render.engine
177
+ engines = {}
178
+ for identifier in CANDIDATE_ENGINES:
179
+ try:
180
+ scene.render.engine = identifier
181
+ engines[identifier] = scene.render.engine == identifier
182
+ except Exception:
183
+ engines[identifier] = False
184
+ try:
185
+ scene.render.engine = original
186
+ except Exception:
187
+ pass
188
+ return engines
189
+
190
+
191
+ def resolve_engine(requested_key, guard):
192
+ """Map a SceneSpec engine key onto an assignable Blender engine.
193
+
194
+ An unavailable engine is NOT a hard failure: the caller asked for a preview,
195
+ and refusing to render because EEVEE needs a GL context the headless build
196
+ does not have would be less useful than rendering it with Cycles and saying
197
+ so. The downgrade is always reported as a warning, never applied silently.
198
+ """
199
+ requested = BLENDER_ENGINE_BY_KEY.get(requested_key)
200
+ if requested is None:
201
+ raise ActionError(
202
+ "BLENDER_ENGINE_UNAVAILABLE",
203
+ 'render profile names engine "%s"; available keys are %s'
204
+ % (requested_key, ", ".join(sorted(BLENDER_ENGINE_BY_KEY))),
205
+ )
206
+
207
+ engines = available_engines()
208
+ if engines.get(requested, False):
209
+ return requested, None
210
+
211
+ for candidate in ENGINE_FALLBACK_ORDER:
212
+ if engines.get(candidate, False):
213
+ return candidate, warning(
214
+ "ENGINE_DOWNGRADED",
215
+ 'engine "%s" is not assignable in this Blender build; using %s instead'
216
+ % (requested, candidate),
217
+ {"requested": requested, "used": candidate, "probed": engines},
218
+ )
219
+
220
+ raise ActionError(
221
+ "BLENDER_ENGINE_UNAVAILABLE",
222
+ "none of the candidate engines are assignable in this Blender build (probed: %s)" % (engines,),
223
+ {"probed": engines},
224
+ )
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Primitives, bevel and shading
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ def _select_only(obj):
233
+ """Make ``obj`` the only selected AND active object.
234
+
235
+ Object-level operators act on the SELECTION, not on a named argument. The
236
+ primitive operators leave their new object selected, and that selection
237
+ persists, so ``bpy.ops.object.modifier_apply`` on a later object would apply to
238
+ whatever was still selected instead — which reports ``{'FINISHED'}`` while
239
+ doing nothing to the object you meant. Deselecting first is the only way to
240
+ make an object operator's target unambiguous.
241
+ """
242
+ bpy.ops.object.select_all(action="DESELECT")
243
+ obj.select_set(True)
244
+ bpy.context.view_layer.objects.active = obj
245
+
246
+
247
+ def _apply_bevel(obj, bevel):
248
+ """Add and apply a bevel modifier, in the requested order.
249
+
250
+ Applied rather than left live so the saved ``.blend`` has the geometry the
251
+ preview actually shows; a live modifier would make the checkpoint's polygon
252
+ count disagree with what was rendered.
253
+
254
+ ``modifier_apply`` returns ``{'CANCELLED'}`` — not an exception — when the
255
+ operator's poll fails, so the return value is checked and a silent no-op is
256
+ turned into a real error. A bevel that quietly did not happen would show up
257
+ only as geometry that looks sharper than the spec asked for.
258
+ """
259
+ width = float(bevel.get("width", 0.01))
260
+ segments = int(bevel.get("segments", 3))
261
+ modifier = obj.modifiers.new(name="db_bevel", type="BEVEL")
262
+ modifier.width = width
263
+ modifier.segments = max(1, segments)
264
+ modifier.limit_method = "ANGLE"
265
+ modifier.angle_limit = math.radians(30.0)
266
+ # `use_angle_clamp` was removed in 5.x; `use_clamp_overlap` is the surviving
267
+ # guard against a bevel wider than the face it cuts into.
268
+ modifier.use_clamp_overlap = True
269
+
270
+ before = len(obj.data.polygons)
271
+ _select_only(obj)
272
+ try:
273
+ result = bpy.ops.object.modifier_apply(modifier=modifier.name)
274
+ except Exception as exc:
275
+ raise ActionError(
276
+ "BLENDER_SCRIPT_ERROR",
277
+ 'could not apply the bevel modifier on "%s": %s' % (obj.name, error_text(exc)),
278
+ )
279
+ if "FINISHED" not in result:
280
+ raise ActionError(
281
+ "BLENDER_SCRIPT_ERROR",
282
+ 'applying the bevel modifier on "%s" was cancelled (operator poll failed)' % (obj.name,),
283
+ {"operatorResult": sorted(result)},
284
+ )
285
+ if len(obj.data.polygons) <= before:
286
+ raise ActionError(
287
+ "BLENDER_SCRIPT_ERROR",
288
+ 'the bevel on "%s" reported success but changed no geometry (%d polygons before and after)'
289
+ % (obj.name, before),
290
+ {"polygonsBefore": before, "polygonsAfter": len(obj.data.polygons)},
291
+ )
292
+
293
+
294
+ def _shade_smooth(obj):
295
+ """Smooth-shade an object, across the operator churn since Blender 4.1.
296
+
297
+ Three spellings exist and they are NOT aliases:
298
+
299
+ * ``shade_smooth_by_angle`` (4.1+) bakes a ``sharp_edge`` attribute and leaves
300
+ no modifier — the right choice for a checkpoint, because nothing is left
301
+ live that the saved file has to carry.
302
+ * ``shade_auto_smooth`` (4.1+) ADDS a "Smooth by Angle" geometry-nodes
303
+ modifier, which then shows up in the modifier stack of a file that is
304
+ supposed to be a plain compile artifact.
305
+ * ``shade_smooth`` (all versions) smooths everything, including the hard edges
306
+ a bevel just created.
307
+
308
+ They are tried in that order, so the best available behaviour is used on the
309
+ Blender actually running. A total failure is a warning and never a hard error:
310
+ shading is cosmetic, and failing a revision over a cosmetic operator would be
311
+ the wrong trade.
312
+ """
313
+ _select_only(obj)
314
+ for name in ("shade_smooth_by_angle", "shade_auto_smooth", "shade_smooth"):
315
+ operator = getattr(bpy.ops.object, name, None)
316
+ if operator is None:
317
+ continue
318
+ try:
319
+ result = operator()
320
+ except Exception:
321
+ continue
322
+ if "FINISHED" in result:
323
+ return
324
+
325
+
326
+ def create_generator(name, spec):
327
+ """Create the mesh for one ``generator`` entity and return the object.
328
+
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.
332
+ """
333
+ shape = spec.get("shape")
334
+ size = float(spec.get("size", 2.0))
335
+ radius = float(spec.get("radius", 1.0))
336
+ depth = float(spec.get("depth", 2.0))
337
+ segments = int(spec.get("segments", 32))
338
+ ring_count = int(spec.get("ringCount", 16))
339
+
340
+ if shape == "cube":
341
+ bpy.ops.mesh.primitive_cube_add(size=size)
342
+ elif shape == "rounded_box":
343
+ 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
+ elif shape == "uv_sphere":
348
+ bpy.ops.mesh.primitive_uv_sphere_add(radius=radius, segments=segments, ring_count=ring_count)
349
+ elif shape == "cylinder":
350
+ bpy.ops.mesh.primitive_cylinder_add(radius=radius, depth=depth, vertices=segments)
351
+ elif shape == "cone":
352
+ bpy.ops.mesh.primitive_cone_add(radius1=radius, depth=depth, vertices=segments)
353
+ elif shape == "plane":
354
+ bpy.ops.mesh.primitive_plane_add(size=size)
355
+ elif shape == "torus":
356
+ bpy.ops.mesh.primitive_torus_add(
357
+ major_radius=float(spec.get("majorRadius", 1.0)),
358
+ minor_radius=float(spec.get("minorRadius", 0.25)),
359
+ major_segments=segments,
360
+ minor_segments=ring_count,
361
+ )
362
+ else:
363
+ raise ActionError(
364
+ "BLENDER_SCRIPT_ERROR",
365
+ 'unknown generator shape "%s"' % (shape,),
366
+ {"supported": ["cube", "rounded_box", "uv_sphere", "cylinder", "cone", "plane", "torus"]},
367
+ )
368
+
369
+ obj = bpy.context.active_object
370
+ if obj is None:
371
+ raise ActionError("BLENDER_SCRIPT_ERROR", 'primitive for shape "%s" produced no object' % (shape,))
372
+ obj.name = name
373
+ obj.data.name = "%s_mesh" % (name,)
374
+ return obj
375
+
376
+
377
+ def import_asset_into_scene(name, asset_path, asset_type, guard):
378
+ """Import a user asset and return the imported objects.
379
+
380
+ Availability is decided by calling the operator inside a try block, not by
381
+ asking whether it exists: ``bpy.ops.export_scene.obj`` is attribute-present
382
+ but unregistered in this build, so the only honest availability test is to
383
+ invoke it and see (finding D10). The likely failure — a build without the
384
+ format's add-on — is classified as the format being unavailable rather than as
385
+ a generic script error, because the two need different fixes.
386
+ """
387
+ before = set(bpy.data.objects.keys())
388
+
389
+ if asset_type in ("glb", "gltf"):
390
+ call = lambda: bpy.ops.import_scene.gltf(filepath=asset_path) # noqa: E731
391
+ elif asset_type == "fbx":
392
+ call = lambda: bpy.ops.import_scene.fbx(filepath=asset_path) # noqa: E731
393
+ elif asset_type == "obj":
394
+ call = lambda: bpy.ops.wm.obj_import(filepath=asset_path) # noqa: E731
395
+ elif asset_type == "usd":
396
+ call = lambda: bpy.ops.wm.usd_import(filepath=asset_path) # noqa: E731
397
+ elif asset_type == "blend":
398
+ call = lambda: bpy.ops.wm.append(filename=asset_path) # noqa: E731
399
+ else:
400
+ raise ActionError("ASSET_FORMAT_UNAVAILABLE", 'unsupported asset type "%s"' % (asset_type,))
401
+
402
+ try:
403
+ call()
404
+ except Exception as exc:
405
+ raise ActionError(
406
+ "ASSET_FORMAT_UNAVAILABLE",
407
+ 'this Blender build cannot import "%s" (%s): %s' % (asset_path, asset_type, error_text(exc)),
408
+ {"assetPath": asset_path, "assetType": asset_type},
409
+ )
410
+
411
+ imported = [bpy.data.objects[key] for key in bpy.data.objects.keys() if key not in before]
412
+ if not imported:
413
+ raise ActionError(
414
+ "ASSET_MISSING",
415
+ 'importing "%s" produced no objects' % (asset_path,),
416
+ {"assetPath": asset_path},
417
+ )
418
+
419
+ root = imported[0]
420
+ root.name = name
421
+ for obj in imported[1:]:
422
+ obj.parent = root
423
+ guard.note(
424
+ "SCENE_ASSET_IMPORTED",
425
+ 'entity "%s" imported %d object(s) from %s' % (name, len(imported), asset_path),
426
+ {"assetPath": asset_path, "objectCount": len(imported)},
427
+ )
428
+ return imported
429
+
430
+
431
+ # ---------------------------------------------------------------------------
432
+ # Transforms
433
+ # ---------------------------------------------------------------------------
434
+
435
+
436
+ def apply_transform(obj, transform):
437
+ """Apply a spec transform to an object.
438
+
439
+ ``rotation_mode`` is forced to XYZ because the SceneSpec's ``rotationEuler``
440
+ is documented as XYZ radians; leaving Blender's default (also XYZ, but a
441
+ project could change it) implicit would make an authored rotation mean
442
+ something different in a re-opened file.
443
+ """
444
+ location = transform.get("location") or [0, 0, 0]
445
+ rotation = transform.get("rotationEuler") or [0, 0, 0]
446
+ scale = transform.get("scale") or [1, 1, 1]
447
+ obj.location = Vector(location)
448
+ obj.rotation_mode = "XYZ"
449
+ obj.rotation_euler = (float(rotation[0]), float(rotation[1]), float(rotation[2]))
450
+ obj.scale = Vector(scale)
451
+ return obj
452
+
453
+
454
+ def apply_visibility(obj, visible):
455
+ """Apply the spec's visibility flag to both viewport and render.
456
+
457
+ Both are set: ``hide_render`` alone leaves a hidden object consuming a
458
+ viewport slot, and ``hide_viewport`` alone still renders. A "hidden" entity
459
+ that appears in the preview would be a straight lie to the model.
460
+ """
461
+ obj.hide_viewport = not visible
462
+ obj.hide_render = not visible
463
+
464
+
465
+ # ---------------------------------------------------------------------------
466
+ # Materials
467
+ # ---------------------------------------------------------------------------
468
+
469
+
470
+ def _find_socket(node, names):
471
+ """Find an input socket by any of its historical names."""
472
+ for name in names:
473
+ socket = node.inputs.get(name)
474
+ if socket is not None:
475
+ return socket
476
+ return None
477
+
478
+
479
+ def _set_socket(node, key, value, guard, material_id):
480
+ """Set one Principled socket, warning when this build does not have it."""
481
+ socket = _find_socket(node, PRINCIPLED_SOCKETS.get(key, [key]))
482
+ if socket is None:
483
+ guard.warn(
484
+ "ADDON_ENABLE_FAILED",
485
+ 'material "%s" sets "%s", but this Blender build has no such Principled BSDF socket'
486
+ % (material_id, key),
487
+ {"materialId": material_id, "parameter": key},
488
+ )
489
+ return
490
+ try:
491
+ if isinstance(value, (list, tuple)):
492
+ existing = socket.default_value
493
+ if hasattr(existing, "__len__") and len(existing) == 4:
494
+ payload = list(value)
495
+ while len(payload) < 4:
496
+ payload.append(1.0)
497
+ socket.default_value = tuple(payload[:4])
498
+ else:
499
+ socket.default_value = tuple(value[:3])
500
+ else:
501
+ socket.default_value = float(value)
502
+ except Exception as exc:
503
+ guard.warn(
504
+ "ADDON_ENABLE_FAILED",
505
+ 'material "%s" could not set "%s": %s' % (material_id, key, error_text(exc)),
506
+ {"materialId": material_id, "parameter": key},
507
+ )
508
+
509
+
510
+ #: Pattern node per SceneSpec texture `type`, with every name its scalar output has
511
+ #: carried. Blender renamed Noise's `Fac` to `Factor`, and Voronoi has no scalar
512
+ #: `Fac` at all (its scalar is `Distance`), so the output is resolved by trying the
513
+ #: historical spellings rather than by assuming one.
514
+ TEXTURE_PATTERN_NODES = {
515
+ "noise": ("ShaderNodeTexNoise", ["Factor", "Fac"]),
516
+ "wave": ("ShaderNodeTexWave", ["Factor", "Fac"]),
517
+ "voronoi": ("ShaderNodeTexVoronoi", ["Distance", "Fac", "Factor"]),
518
+ }
519
+
520
+
521
+ def _pattern_value_output(pattern, names):
522
+ """The scalar output of a pattern node, by any of its historical names."""
523
+ for name in names:
524
+ socket = pattern.outputs.get(name)
525
+ if socket is not None:
526
+ return socket
527
+ return None
528
+
529
+
530
+ def _build_texture_graph(material, principled, texture, guard, material_id):
531
+ """Wire a procedural pattern into a Principled BSDF.
532
+
533
+ A material carrying only scalar parameters shades as one flat colour, which is
534
+ 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.
537
+
538
+ Every socket is looked up by name and every stage is optional: a build missing
539
+ one node degrades to a plainer material and says so, rather than failing the
540
+ whole compile over a cosmetic detail.
541
+ """
542
+ nodes = material.node_tree.nodes
543
+ links = material.node_tree.links
544
+
545
+ entry = TEXTURE_PATTERN_NODES.get(texture.get("type"))
546
+ if entry is None:
547
+ guard.warn(
548
+ "ADDON_ENABLE_FAILED",
549
+ 'material "%s" asks for texture type "%s", which this compiler cannot build'
550
+ % (material_id, texture.get("type")),
551
+ {"materialId": material_id, "textureType": texture.get("type")},
552
+ )
553
+ return
554
+ node_id, output_names = entry
555
+ try:
556
+ pattern = nodes.new(node_id)
557
+ except Exception as exc:
558
+ guard.warn(
559
+ "ADDON_ENABLE_FAILED",
560
+ 'material "%s": this Blender build has no %s node: %s'
561
+ % (material_id, node_id, error_text(exc)),
562
+ {"materialId": material_id, "node": node_id},
563
+ )
564
+ return
565
+
566
+ coord = nodes.new("ShaderNodeTexCoord")
567
+ coord.location = (-1080, -240)
568
+ mapping = nodes.new("ShaderNodeMapping")
569
+ mapping.location = (-900, -240)
570
+ pattern.location = (-700, -240)
571
+ links.new(coord.outputs["Object"], mapping.inputs["Vector"])
572
+ links.new(mapping.outputs["Vector"], pattern.inputs["Vector"])
573
+
574
+ stretch = texture.get("stretch") or [1.0, 1.0, 1.0]
575
+ scale = float(texture["scale"])
576
+ mapping.inputs["Scale"].default_value = (
577
+ scale * float(stretch[0]),
578
+ scale * float(stretch[1]),
579
+ scale * float(stretch[2]),
580
+ )
581
+
582
+ # The pattern node carries a Scale of its own (Blender's default is 5.0) which
583
+ # MULTIPLIES the mapping. Left alone the two compound, so an authored scale of
584
+ # 60 renders as 300 cycles across the object - about three pixels per cycle at
585
+ # 1080p, which aliases into flat shading and looks like no texture at all.
586
+ # Pinning it to 1 makes the mapping the single, readable frequency control.
587
+ if pattern.inputs.get("Scale") is not None:
588
+ pattern.inputs["Scale"].default_value = 1.0
589
+
590
+ if pattern.inputs.get("Detail") is not None and texture.get("detail") is not None:
591
+ pattern.inputs["Detail"].default_value = float(texture["detail"])
592
+ if pattern.inputs.get("Distortion") is not None and texture.get("distortion") is not None:
593
+ pattern.inputs["Distortion"].default_value = float(texture["distortion"])
594
+
595
+ value = _pattern_value_output(pattern, output_names)
596
+ if value is None:
597
+ guard.warn(
598
+ "ADDON_ENABLE_FAILED",
599
+ 'material "%s": %s exposes none of %s, so its texture was skipped'
600
+ % (material_id, node_id, output_names),
601
+ {"materialId": material_id, "node": node_id},
602
+ )
603
+ return
604
+
605
+ # 1) Surface relief. This is what breaks a highlight up across a surface
606
+ # instead of letting it slide over like glass.
607
+ bump_strength = float(texture.get("bump") or 0.0)
608
+ normal_socket = _find_socket(principled, ["Normal"])
609
+ if bump_strength > 0 and normal_socket is not None:
610
+ bump = nodes.new("ShaderNodeBump")
611
+ bump.location = (-260, -560)
612
+ bump.inputs["Strength"].default_value = min(1.0, bump_strength)
613
+ # Distance is the virtual relief in object units and defaults to 1.0 - a
614
+ # full metre of height on a 30 cm desk. Pinned to a few millimetres, which
615
+ # is the scale of the grain actually being described.
616
+ if bump.inputs.get("Distance") is not None:
617
+ bump.inputs["Distance"].default_value = 0.005
618
+ links.new(value, bump.inputs["Height"])
619
+ links.new(bump.outputs["Normal"], normal_socket)
620
+
621
+ # 2) Roughness variation, swung around whatever the material authored, so the
622
+ # authored value stays the centre of the range.
623
+ variation = float(texture.get("roughnessVariation") or 0.0)
624
+ roughness_socket = _find_socket(principled, PRINCIPLED_SOCKETS["roughness"])
625
+ if variation > 0 and roughness_socket is not None:
626
+ authored = float(roughness_socket.default_value)
627
+ span = nodes.new("ShaderNodeMapRange")
628
+ span.location = (-460, -160)
629
+ span.inputs["From Min"].default_value = 0.0
630
+ span.inputs["From Max"].default_value = 1.0
631
+ span.inputs["To Min"].default_value = max(0.0, authored - variation * 0.5)
632
+ span.inputs["To Max"].default_value = min(1.0, authored + variation * 0.5)
633
+ links.new(value, span.inputs["Value"])
634
+ links.new(span.outputs["Result"], roughness_socket)
635
+
636
+ # 3) Colour variation, as a multiplicative tint centred on 1.0 so the authored
637
+ # base colour still decides the hue. Vector maths is used rather than a Mix
638
+ # node because Mix's A/B sockets are only distinguishable by index.
639
+ color_variation = float(texture.get("colorVariation") or 0.0)
640
+ base_socket = _find_socket(principled, PRINCIPLED_SOCKETS["baseColor"])
641
+ if color_variation > 0 and base_socket is not None:
642
+ tint = nodes.new("ShaderNodeMapRange")
643
+ tint.location = (-460, 160)
644
+ tint.inputs["From Min"].default_value = 0.0
645
+ tint.inputs["From Max"].default_value = 1.0
646
+ tint.inputs["To Min"].default_value = max(0.0, 1.0 - color_variation)
647
+ tint.inputs["To Max"].default_value = 1.0
648
+ links.new(value, tint.inputs["Value"])
649
+ combine = nodes.new("ShaderNodeCombineXYZ")
650
+ combine.location = (-260, 160)
651
+ links.new(tint.outputs["Result"], combine.inputs["X"])
652
+ links.new(tint.outputs["Result"], combine.inputs["Y"])
653
+ links.new(tint.outputs["Result"], combine.inputs["Z"])
654
+ multiply = nodes.new("ShaderNodeVectorMath")
655
+ multiply.location = (-80, 160)
656
+ multiply.operation = "MULTIPLY"
657
+ links.new(combine.outputs["Vector"], multiply.inputs[0])
658
+ multiply.inputs[1].default_value = tuple(list(base_socket.default_value)[:3])
659
+ links.new(multiply.outputs["Vector"], base_socket)
660
+
661
+
662
+ def build_material(spec, guard):
663
+ """Create one Blender material from a SceneSpec material entry."""
664
+ material_id = spec["id"]
665
+ shader = spec.get("shader", "principled")
666
+ parameters = spec.get("parameters") or {}
667
+
668
+ material = bpy.data.materials.new(name="%s%s" % (MATERIAL_PREFIX, material_id))
669
+ # A material has `use_nodes` enabled by default in 5.x and arrives with a
670
+ # Principled BSDF already wired to the output. Setting it again only emits a
671
+ # deprecation warning, so the node tree is reset explicitly below instead.
672
+ nodes = material.node_tree.nodes
673
+ links = material.node_tree.links
674
+
675
+ for node in list(nodes):
676
+ nodes.remove(node)
677
+
678
+ output = nodes.new("ShaderNodeOutputMaterial")
679
+ output.location = (320, 0)
680
+
681
+ if shader == "emission":
682
+ emission = nodes.new("ShaderNodeEmission")
683
+ emission.location = (0, 0)
684
+ color = parameters.get("emissionColor") or parameters.get("baseColor") or [1, 1, 1]
685
+ emission.inputs["Color"].default_value = tuple(list(color)[:3] + [1.0]) if len(color) < 4 else tuple(color[:4])
686
+ emission.inputs["Strength"].default_value = float(parameters.get("emissionStrength", 1.0))
687
+ links.new(emission.outputs["Emission"], output.inputs["Surface"])
688
+ else:
689
+ principled = nodes.new("ShaderNodeBsdfPrincipled")
690
+ principled.location = (0, 0)
691
+ links.new(principled.outputs["BSDF"], output.inputs["Surface"])
692
+
693
+ if shader == "glass":
694
+ # A glass shader is a Principled with the transmission path opened up,
695
+ # rather than a separate node graph: one node type means one set of
696
+ # socket names to keep working across Blender versions.
697
+ parameters = {"roughness": 0.05, "ior": 1.45, "transmissionWeight": 1.0, **parameters}
698
+
699
+ for key, value in parameters.items():
700
+ _set_socket(principled, key, value, guard, material_id)
701
+
702
+ # After the scalars, so the pattern varies around the authored values
703
+ # rather than replacing them.
704
+ texture = spec.get("texture")
705
+ if isinstance(texture, dict):
706
+ _build_texture_graph(material, principled, texture, guard, material_id)
707
+
708
+ alpha = parameters.get("alpha")
709
+ if isinstance(alpha, (int, float)) and float(alpha) < 1.0:
710
+ # Non-opaque alpha needs an explicit blend mode, or the viewport and the
711
+ # render disagree about whether the surface is see-through.
712
+ try:
713
+ material.surface_render_method = "BLENDED"
714
+ except Exception:
715
+ try:
716
+ material.blend_method = "BLEND"
717
+ except Exception:
718
+ guard.warn(
719
+ "ADDON_ENABLE_FAILED",
720
+ 'material "%s" sets alpha < 1, but this build exposes no blend-mode attribute' % (material_id,),
721
+ )
722
+
723
+ material["deepblend_id"] = material_id
724
+ return material
725
+
726
+
727
+ def build_default_material(guard):
728
+ """The material applied to an entity that names none.
729
+
730
+ A mid-grey diffuse surface: it renders legibly under any lighting the scene
731
+ provides, so an entity with no material still produces a usable preview
732
+ instead of a black silhouette that looks like a lighting bug.
733
+ """
734
+ material = bpy.data.materials.new(name="%sdefault" % (MATERIAL_PREFIX,))
735
+ principled = material.node_tree.nodes.get("Principled BSDF")
736
+ if principled is not None:
737
+ _set_socket(principled, "baseColor", [0.62, 0.62, 0.64, 1.0], guard, "default")
738
+ _set_socket(principled, "roughness", 0.55, guard, "default")
739
+ _set_socket(principled, "metallic", 0.0, guard, "default")
740
+ material["deepblend_id"] = "default"
741
+ return material
742
+
743
+
744
+ # ---------------------------------------------------------------------------
745
+ # Lights and cameras
746
+ # ---------------------------------------------------------------------------
747
+
748
+
749
+ def build_light(spec):
750
+ """Create one light object from a SceneSpec light entry."""
751
+ light_id = spec["id"]
752
+ light_type = spec.get("type", "point")
753
+ data = bpy.data.lights.new(name="%s%s" % (LIGHT_PREFIX, light_id), type=light_type.upper())
754
+ data.energy = float(spec.get("energy", 100.0))
755
+
756
+ color = spec.get("color") or [1, 1, 1]
757
+ data.color = tuple(list(color)[:3])
758
+
759
+ if light_type == "area":
760
+ # `size` is the area light's radius. The attribute is `size` on an Area
761
+ # light and `shadow_soft_size` on Point/Spot, so the two are set through
762
+ # explicit branches rather than one shared line that would only work for
763
+ # one of them.
764
+ data.shape = "SQUARE"
765
+ data.size = float(spec.get("size", 1.0))
766
+ elif light_type in ("point", "spot"):
767
+ data.shadow_soft_size = float(spec.get("size", 0.25))
768
+ if light_type == "spot":
769
+ data.spot_size = float(spec.get("spotSize", math.radians(45.0)))
770
+ data.spot_blend = float(spec.get("spotBlend", 0.15))
771
+ if light_type == "sun":
772
+ data.angle = float(spec.get("angle", math.radians(0.526)))
773
+
774
+ obj = bpy.data.objects.new("%s%s" % (LIGHT_PREFIX, light_id), data)
775
+ bpy.context.scene.collection.objects.link(obj)
776
+ obj["deepblend_id"] = light_id
777
+ return obj
778
+
779
+
780
+ def build_camera(spec, entity_positions):
781
+ """Create one camera object, aiming it at its target when it has one.
782
+
783
+ The aim is computed here, with Blender's own ``to_track_quat``, rather than
784
+ pre-computed in Node: a camera rotation is Blender's convention to define, and
785
+ re-implementing it in a second language is how the two ends end up framing the
786
+ same subject slightly differently.
787
+
788
+ A camera with a target but no authored position is a compile error rather than
789
+ a guess — the Node-side resolver fills that in deterministically, so reaching
790
+ this point without a location means the spec was compiled without being
791
+ resolved.
792
+ """
793
+ camera_id = spec["id"]
794
+ data = bpy.data.cameras.new(name="%s%s" % (CAMERA_PREFIX, camera_id))
795
+ data.lens = float(spec.get("lens", 50.0))
796
+ data.sensor_width = float(spec.get("sensorWidth", 36.0))
797
+ clipping = spec.get("clipping")
798
+ if clipping:
799
+ data.clip_start = float(clipping[0])
800
+ data.clip_end = float(clipping[1])
801
+ if spec.get("fStop") is not None:
802
+ data.dof.use_dof = True
803
+ data.dof.aperture_fstop = float(spec["fStop"])
804
+
805
+ obj = bpy.data.objects.new("%s%s" % (CAMERA_PREFIX, camera_id), data)
806
+ bpy.context.scene.collection.objects.link(obj)
807
+ obj["deepblend_id"] = camera_id
808
+
809
+ transform = spec.get("transform") or {}
810
+ location = transform.get("location")
811
+ if location is None:
812
+ raise ActionError(
813
+ "BLENDER_SCRIPT_ERROR",
814
+ 'camera "%s" has no resolved transform.location; compile the SceneSpec before sending it '
815
+ "(compileSceneSpec fills in a derived position for targeted cameras)" % (camera_id,),
816
+ )
817
+ obj.location = Vector(location)
818
+ obj.rotation_mode = "XYZ"
819
+
820
+ target_entity = spec.get("targetEntityId")
821
+ target_point = spec.get("targetPoint")
822
+ if target_entity is not None:
823
+ if target_entity not in entity_positions:
824
+ raise ActionError(
825
+ "SCENE_VALIDATION_FAILED",
826
+ 'camera "%s" targets entity "%s", which produced no object in this scene'
827
+ % (camera_id, target_entity),
828
+ {"cameraId": camera_id, "targetEntityId": target_entity},
829
+ )
830
+ target_point = entity_positions[target_entity]
831
+
832
+ if target_point is not None:
833
+ direction = Vector(target_point) - obj.location
834
+ if direction.length > 0:
835
+ # -Z is a camera's viewing axis and +Y its up axis, so this is the
836
+ # rotation that points the lens at the target with a level horizon.
837
+ obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler("XYZ")
838
+ else:
839
+ rotation = transform.get("rotationEuler")
840
+ if rotation is not None:
841
+ obj.rotation_euler = (float(rotation[0]), float(rotation[1]), float(rotation[2]))
842
+
843
+ if data.dof.use_dof:
844
+ # An f-stop on its own is NOT depth of field. Blender's focus_distance
845
+ # defaults to 10 m, so a desk-sized scene sits far outside the field and
846
+ # the whole frame comes back uniformly soft — every plane blurred, nothing
847
+ # in focus, which reads as a mistake rather than as a shallow depth of
848
+ # field. Focus on whatever the camera is already aimed at.
849
+ focus_object = None
850
+ if target_entity is not None:
851
+ focus_object = bpy.data.objects.get("%s%s" % (ENTITY_PREFIX, target_entity))
852
+ if focus_object is not None:
853
+ # Focusing the object rather than a distance keeps a MOVING subject
854
+ # sharp for the whole shot instead of only at the frame it was
855
+ # measured on.
856
+ data.dof.focus_object = focus_object
857
+ elif target_point is not None:
858
+ data.dof.focus_distance = max(1e-4, (Vector(target_point) - obj.location).length)
859
+
860
+ return obj
861
+
862
+
863
+ # ---------------------------------------------------------------------------
864
+ # Animation
865
+ # ---------------------------------------------------------------------------
866
+
867
+ #: SceneSpec track property -> the bpy attribute its index addresses.
868
+ TRACK_PROPERTY_ATTR = {
869
+ "location": "location",
870
+ "rotationEuler": "rotation_euler",
871
+ "scale": "scale",
872
+ }
873
+
874
+ #: SceneSpec interpolation name -> Blender fcurve interpolation identifier.
875
+ INTERPOLATION_BY_NAME = {
876
+ "constant": "CONSTANT",
877
+ "linear": "LINEAR",
878
+ "bezier": "BEZIER",
879
+ "ease_in": "SINE",
880
+ "ease_out": "SINE",
881
+ "ease_in_out": "SINE",
882
+ }
883
+
884
+
885
+ def _surface_node(material):
886
+ """The node feeding a material's output, or None.
887
+
888
+ Animation addresses the socket on the node that actually shades the surface, so
889
+ this follows the same link `build_material` created rather than guessing a node
890
+ name: an `emission` material has no Principled BSDF at all.
891
+ """
892
+ tree = getattr(material, "node_tree", None)
893
+ if tree is None:
894
+ return None
895
+ for node in tree.nodes:
896
+ if node.type != "OUTPUT_MATERIAL":
897
+ continue
898
+ links = node.inputs["Surface"].links if "Surface" in node.inputs else []
899
+ if links:
900
+ return links[0].from_node
901
+ return None
902
+
903
+
904
+ def _material_socket(material, parameter):
905
+ """Resolve a SceneSpec material parameter to (node, socket), or (node, None).
906
+
907
+ One vocabulary for parameter names: the socket table below is the SAME one
908
+ `material.parameter.update` goes through, so an animation cannot address a
909
+ parameter a static set could not.
910
+ """
911
+ node = _surface_node(material)
912
+ if node is None:
913
+ return None, None
914
+ if node.type == "EMISSION":
915
+ names = EMISSION_SHADER_SOCKETS.get(parameter)
916
+ else:
917
+ names = PRINCIPLED_SOCKETS.get(parameter)
918
+ if names is None:
919
+ return node, None
920
+ return node, _find_socket(node, names)
921
+
922
+
923
+ def build_material_animation(material, track, guard):
924
+ """Key one material parameter onto its node socket.
925
+
926
+ ``emissionStrength`` is why this exists: "the dial gradually lights up" is a ramp
927
+ on one socket, and before animation could target a material the only way to
928
+ express it was emissive geometry that scaled in.
929
+
930
+ Keyframes are inserted through the SOCKET's own ``keyframe_insert`` rather than by
931
+ naming a data path, because a socket's path embeds its index in the node's input
932
+ list (``nodes["Principled BSDF"].inputs[29].default_value``) and that index moves
933
+ whenever Blender reorders its sockets.
934
+ """
935
+ property_name = track.get("property", "")
936
+ parameter, _, component = property_name.partition(".")
937
+ node, socket = _material_socket(material, parameter)
938
+ if node is None:
939
+ raise ActionError(
940
+ "BLENDER_SCRIPT_ERROR",
941
+ 'animation track "%s" targets material "%s", which has no surface node to animate'
942
+ % (track.get("id"), material.get("deepblend_id") or material.name),
943
+ )
944
+ if socket is None:
945
+ raise ActionError(
946
+ "BLENDER_SCRIPT_ERROR",
947
+ 'animation track "%s" animates "%s", which is not a parameter of material "%s"'
948
+ % (track.get("id"), property_name, material.get("deepblend_id") or material.name),
949
+ {"supported": sorted(set(PRINCIPLED_SOCKETS) | set(EMISSION_SHADER_SOCKETS))},
950
+ )
951
+
952
+ axis = None
953
+ if component:
954
+ if component not in ("r", "g", "b", "a"):
955
+ raise ActionError(
956
+ "BLENDER_SCRIPT_ERROR",
957
+ 'animation track "%s" names component "%s"; expected r, g, b or a'
958
+ % (track.get("id"), component),
959
+ )
960
+ axis = "rgba".index(component)
961
+ if not hasattr(socket.default_value, "__len__"):
962
+ raise ActionError(
963
+ "BLENDER_SCRIPT_ERROR",
964
+ 'animation track "%s" names component "%s" of "%s", which is a single value, not a colour'
965
+ % (track.get("id"), component, parameter),
966
+ )
967
+
968
+ keyframes = track.get("keyframes") or []
969
+ if len(keyframes) < 2:
970
+ raise ActionError(
971
+ "SCENE_VALIDATION_FAILED",
972
+ 'animation track "%s" has %d keyframe(s); at least 2 are required to describe motion'
973
+ % (track.get("id"), len(keyframes)),
974
+ )
975
+
976
+ data_path = socket.path_from_id("default_value")
977
+ for keyframe in keyframes:
978
+ frame = int(keyframe["frame"])
979
+ value = float(keyframe["value"])
980
+ if axis is None:
981
+ socket.default_value = value
982
+ socket.keyframe_insert("default_value", frame=frame)
983
+ else:
984
+ current = list(socket.default_value)
985
+ while len(current) <= axis:
986
+ current.append(1.0)
987
+ current[axis] = value
988
+ socket.default_value = tuple(current)
989
+ socket.keyframe_insert("default_value", index=axis, frame=frame)
990
+
991
+ tree = material.node_tree
992
+ action = tree.animation_data.action if tree.animation_data is not None else None
993
+ if action is None:
994
+ raise ActionError(
995
+ "BLENDER_SCRIPT_ERROR",
996
+ 'animation track "%s" inserted keyframes but produced no action' % (track.get("id"),),
997
+ )
998
+
999
+ _apply_interpolation(action, data_path, axis, keyframes, track, guard)
1000
+ action.name = "db_anim__%s" % (track.get("id"),)
1001
+ action["deepblend_id"] = track.get("id")
1002
+ return action
1003
+
1004
+
1005
+ def _apply_interpolation(action, data_path, axis, keyframes, track, guard):
1006
+ """Set each keyframe point's interpolation from the track, and say if none could be."""
1007
+ interpolations = set()
1008
+ for fcurve in action_fcurves(action):
1009
+ if fcurve.data_path != data_path:
1010
+ continue
1011
+ if axis is not None and fcurve.array_index != axis:
1012
+ continue
1013
+ for point in fcurve.keyframe_points:
1014
+ wanted = None
1015
+ for keyframe in keyframes:
1016
+ if int(round(point.co[0])) == int(keyframe["frame"]):
1017
+ wanted = keyframe.get("interpolation")
1018
+ break
1019
+ if wanted is None:
1020
+ continue
1021
+ point.interpolation = INTERPOLATION_BY_NAME.get(wanted, "BEZIER")
1022
+ interpolations.add(point.interpolation)
1023
+
1024
+ if not interpolations:
1025
+ guard.warn(
1026
+ "SCENE_ANIMATION_KEYFRAMES_ADJUSTED",
1027
+ 'animation track "%s" produced no addressable fcurve for %s; interpolation was left '
1028
+ "at Blender's default" % (track.get("id"), data_path),
1029
+ {"trackId": track.get("id"), "property": track.get("property")},
1030
+ )
1031
+
1032
+
1033
+ def build_animation(obj, track, guard):
1034
+ """Key one animation track onto an object, returning the action name.
1035
+
1036
+ Keyframes are inserted through ``keyframe_insert`` on the object's own
1037
+ property path, which lets Blender create the action, slot and fcurve with its
1038
+ own current conventions. Hand-building an action and assigning it to
1039
+ ``animation_data.action`` is what silently produced an un-evaluated scene in
1040
+ Blender 4.4+, where an action needs a SLOT as well as a datablock pointer.
1041
+
1042
+ Interpolation is then applied to every point of the created fcurve, because
1043
+ ``keyframe_insert`` has no interpolation argument and always writes the user
1044
+ preference's default.
1045
+ """
1046
+ property_name = track.get("property", "")
1047
+ if "." not in property_name:
1048
+ raise ActionError(
1049
+ "BLENDER_SCRIPT_ERROR",
1050
+ 'animation track "%s" names property "%s" without a component' % (track.get("id"), property_name),
1051
+ )
1052
+ attr, _, component = property_name.rpartition(".")
1053
+ bpy_attr = TRACK_PROPERTY_ATTR.get(attr)
1054
+ if bpy_attr is None or component not in ("x", "y", "z"):
1055
+ raise ActionError(
1056
+ "BLENDER_SCRIPT_ERROR",
1057
+ 'animation track "%s" names unsupported property "%s"' % (track.get("id"), property_name),
1058
+ {"supported": sorted("%s.%s" % (name, axis) for name in TRACK_PROPERTY_ATTR for axis in "xyz")},
1059
+ )
1060
+ axis = "xyz".index(component)
1061
+
1062
+ keyframes = track.get("keyframes") or []
1063
+ if len(keyframes) < 2:
1064
+ raise ActionError(
1065
+ "SCENE_VALIDATION_FAILED",
1066
+ 'animation track "%s" has %d keyframe(s); at least 2 are required to describe motion'
1067
+ % (track.get("id"), len(keyframes)),
1068
+ )
1069
+
1070
+ bpy.context.view_layer.objects.active = obj
1071
+ # Keyframes must be inserted on the identity transform before the track's own
1072
+ # values, or the first insert would capture a value from a different property.
1073
+ obj.rotation_mode = "XYZ"
1074
+
1075
+ for keyframe in keyframes:
1076
+ frame = int(keyframe["frame"])
1077
+ value = float(keyframe["value"])
1078
+ setattr(obj, bpy_attr, _with_component(getattr(obj, bpy_attr), axis, value))
1079
+ obj.keyframe_insert(data_path=bpy_attr, index=axis, frame=frame)
1080
+
1081
+ action = obj.animation_data.action if obj.animation_data is not None else None
1082
+ if action is None:
1083
+ raise ActionError(
1084
+ "BLENDER_SCRIPT_ERROR",
1085
+ 'animation track "%s" inserted keyframes but produced no action' % (track.get("id"),),
1086
+ )
1087
+
1088
+ # One implementation of "apply the track's interpolation", shared with material
1089
+ # tracks: the two used to be the same twelve lines, and the copy that rots is the
1090
+ # one nothing runs.
1091
+ _apply_interpolation(action, bpy_attr, None, keyframes, track, guard)
1092
+
1093
+ action.name = "db_anim__%s" % (track.get("id"),)
1094
+ action["deepblend_id"] = track.get("id")
1095
+ return action
1096
+
1097
+
1098
+ def _with_component(vector, axis, value):
1099
+ """Return a copy of a 3-component vector with one component replaced."""
1100
+ components = [vector[0], vector[1], vector[2]]
1101
+ components[axis] = value
1102
+ return components
1103
+
1104
+
1105
+ def action_fcurves(action):
1106
+ """Every fcurve in an action, across Blender's legacy and layered APIs.
1107
+
1108
+ Blender 5.x made actions LAYERED: ``action.fcurves`` no longer exists, and the
1109
+ curves live at ``action.layers[*].strips[*].channelbags[*].fcurves``. The
1110
+ legacy attribute is tried first so this works unchanged on 3.x/4.x builds,
1111
+ where it is the only location.
1112
+
1113
+ This is precisely the kind of difference that cannot be inferred from a
1114
+ version number: the attribute is simply absent at runtime, so a version switch
1115
+ would have to be re-verified on every Blender release. Walking both shapes and
1116
+ returning whichever exists is correct by construction.
1117
+ """
1118
+ legacy = getattr(action, "fcurves", None)
1119
+ if legacy is not None:
1120
+ return list(legacy)
1121
+
1122
+ collected = []
1123
+ for layer in getattr(action, "layers", []) or []:
1124
+ for strip in getattr(layer, "strips", []) or []:
1125
+ for channelbag in getattr(strip, "channelbags", []) or []:
1126
+ collected.extend(channelbag.fcurves)
1127
+ return collected
1128
+
1129
+
1130
+ # ---------------------------------------------------------------------------
1131
+ # Scene-level render configuration
1132
+ # ---------------------------------------------------------------------------
1133
+
1134
+
1135
+ def configure_scene(scene, spec, profile, guard):
1136
+ """Apply project frame range, fps and one render profile to the scene."""
1137
+ project = spec.get("project") or {}
1138
+ scene.frame_start = int(project.get("frameStart", 1))
1139
+ scene.frame_end = int(project.get("frameEnd", 1))
1140
+ scene.render.fps = int(round(float(project.get("fps", 24))))
1141
+ scene.render.fps_base = 1.0
1142
+
1143
+ resolution = profile.get("resolution") or [640, 360]
1144
+ scene.render.resolution_x = int(resolution[0])
1145
+ scene.render.resolution_y = int(resolution[1])
1146
+ scene.render.resolution_percentage = 100
1147
+ scene.render.image_settings.file_format = "PNG"
1148
+ scene.render.image_settings.color_mode = "RGB"
1149
+ scene.render.film_transparent = bool(profile.get("filmTransparent", False))
1150
+
1151
+ samples = profile.get("samples")
1152
+ samples_applied = None
1153
+ if samples is not None:
1154
+ samples = int(samples)
1155
+ # Applying a profile's sample count is engine-specific: EEVEE has no
1156
+ # `samples` (it has `taa_render_samples`), and Cycles' attribute is the
1157
+ # plain one. Both are attempted so a profile is honoured whichever engine
1158
+ # the downgrade path landed on.
1159
+ applied = False
1160
+ if scene.render.engine == "CYCLES":
1161
+ try:
1162
+ scene.cycles.samples = samples
1163
+ scene.cycles.use_denoising = True
1164
+ # Cycles on Apple silicon: METAL is used by setting the device and
1165
+ # nothing else. `preferences.refresh_devices()` before the first
1166
+ # render HANGS this Blender build, so it is deliberately never
1167
+ # called here — the device list starts empty and Cycles finds the
1168
+ # GPU anyway.
1169
+ try:
1170
+ scene.cycles.device = "GPU"
1171
+ except Exception:
1172
+ pass
1173
+ applied = True
1174
+ except Exception:
1175
+ applied = False
1176
+ elif scene.render.engine == "BLENDER_EEVEE":
1177
+ try:
1178
+ scene.eevee.taa_render_samples = samples
1179
+ applied = True
1180
+ except Exception:
1181
+ applied = False
1182
+ if applied:
1183
+ samples_applied = samples
1184
+ guard.note(
1185
+ "SCENE_COMPILER_DECISION",
1186
+ "render profile %s: engine %s, %d samples, %dx%d"
1187
+ % (profile_name_of(spec, profile), scene.render.engine, samples,
1188
+ scene.render.resolution_x, scene.render.resolution_y),
1189
+ )
1190
+ else:
1191
+ guard.note(
1192
+ "SCENE_COMPILER_DECISION",
1193
+ "sample count %d was not applied to engine %s (that engine exposes no sample attribute)"
1194
+ % (samples, scene.render.engine),
1195
+ )
1196
+
1197
+ if profile.get("raytracing") is not None:
1198
+ # EEVEE only. Its raytracing pipeline defaults OFF, which leaves every
1199
+ # surface lit by direct light alone: objects lose their contact shadow and
1200
+ # read as floating, which is exactly the flatness the flag exists to fix.
1201
+ # Cycles has real global illumination and no such switch, so the key is
1202
+ # reported as ignored there rather than treated as an error.
1203
+ if scene.render.engine == "BLENDER_EEVEE":
1204
+ try:
1205
+ scene.eevee.use_raytracing = bool(profile["raytracing"])
1206
+ except Exception as exc:
1207
+ guard.warn(
1208
+ "ADDON_ENABLE_FAILED",
1209
+ "could not set EEVEE raytracing on profile %s: %s"
1210
+ % (profile_name_of(spec, profile), error_text(exc)),
1211
+ )
1212
+ else:
1213
+ options = getattr(scene.eevee, "ray_tracing_options", None)
1214
+ if options is not None:
1215
+ # The stock defaults trade quality for speed. A delivery wants
1216
+ # the screen trace at full resolution and still applied to
1217
+ # rougher surfaces than the default cutoff allows.
1218
+ #
1219
+ # Each option is set on its own: `resolution_scale` is an ENUM
1220
+ # of the strings "1"/"2"/"4"/"8"/"16", not a number, and when
1221
+ # one bad value was raised inside a shared try block it aborted
1222
+ # the whole loop silently - the render still had raytracing on,
1223
+ # so nothing looked wrong, but every quality setting below the
1224
+ # failure was quietly skipped.
1225
+ for attribute, value in (
1226
+ ("resolution_scale", "1"),
1227
+ ("screen_trace_quality", 0.5),
1228
+ ("trace_max_roughness", 0.9),
1229
+ ("use_denoise", True),
1230
+ ):
1231
+ if not hasattr(options, attribute):
1232
+ continue
1233
+ try:
1234
+ setattr(options, attribute, value)
1235
+ except Exception as exc:
1236
+ guard.warn(
1237
+ "ADDON_ENABLE_FAILED",
1238
+ 'EEVEE raytracing option "%s" rejected %r: %s'
1239
+ % (attribute, value, error_text(exc)),
1240
+ {"option": attribute},
1241
+ )
1242
+ guard.note(
1243
+ "SCENE_COMPILER_DECISION",
1244
+ "render profile %s: EEVEE raytracing %s"
1245
+ % (profile_name_of(spec, profile), "enabled" if profile["raytracing"] else "disabled"),
1246
+ )
1247
+ else:
1248
+ guard.note(
1249
+ "SCENE_COMPILER_DECISION",
1250
+ "render profile %s sets raytracing, which engine %s has no switch for; ignored"
1251
+ % (profile_name_of(spec, profile), scene.render.engine),
1252
+ )
1253
+
1254
+ color = profile.get("colorManagement") or {}
1255
+ view_transform = color.get("viewTransform")
1256
+ if view_transform:
1257
+ # Availability is decided by ASSIGNING and reading back, never by the
1258
+ # enum. On Blender 5.2 both `enum_items` and `enum_items_static` report
1259
+ # only ['NONE'] for view_transform while 'Standard', 'AgX', 'Filmic',
1260
+ # 'Raw' and 'False Color' all assign successfully — the same class of
1261
+ # lying metadata as the render-engine enum (finding D1). Enumerating it
1262
+ # rejected valid values and silently kept AgX, which would have made every
1263
+ # colour-managed preview differ from the profile that asked for it.
1264
+ applied = assign_view_transform(scene, view_transform)
1265
+ if not applied:
1266
+ guard.warn(
1267
+ "SCENE_COMPILER_DECISION",
1268
+ 'view transform "%s" was not accepted by this Blender build; keeping "%s"'
1269
+ % (view_transform, scene.view_settings.view_transform),
1270
+ {"requested": view_transform, "kept": scene.view_settings.view_transform},
1271
+ )
1272
+ look = color.get("look")
1273
+ if look:
1274
+ try:
1275
+ scene.view_settings.look = look
1276
+ except Exception:
1277
+ guard.warn("SCENE_COMPILER_DECISION", 'view look "%s" is not available; keeping the default' % (look,))
1278
+ if color.get("exposure") is not None:
1279
+ scene.view_settings.exposure = float(color["exposure"])
1280
+
1281
+ return {
1282
+ "engine": scene.render.engine,
1283
+ "resolution": [scene.render.resolution_x, scene.render.resolution_y],
1284
+ "filmTransparent": scene.render.film_transparent,
1285
+ "frameStart": scene.frame_start,
1286
+ "frameEnd": scene.frame_end,
1287
+ "fps": scene.render.fps,
1288
+ "viewTransform": scene.view_settings.view_transform,
1289
+ "profileName": profile_name_of(spec, profile),
1290
+ "requestedEngine": profile.get("engine"),
1291
+ "profileEngine": profile.get("engine"),
1292
+ "samples": samples_applied,
1293
+ }
1294
+
1295
+
1296
+ def profile_name_of(spec, profile):
1297
+ """Which named profile in the spec is this object, for the audit record."""
1298
+ for name, candidate in (spec.get("renderProfiles") or {}).items():
1299
+ if candidate is profile:
1300
+ return name
1301
+ return None
1302
+
1303
+
1304
+ def assign_view_transform(scene, name):
1305
+ """Assign a view transform, returning whether it actually took effect.
1306
+
1307
+ A rejected enum value is not always an exception in Blender: it can be
1308
+ silently ignored. Comparing the read-back is therefore the only reliable test,
1309
+ and it is also what makes a success claim here trustworthy.
1310
+ """
1311
+ settings = scene.view_settings
1312
+ try:
1313
+ settings.view_transform = name
1314
+ except Exception:
1315
+ return False
1316
+ try:
1317
+ return settings.view_transform == name
1318
+ except Exception:
1319
+ return False
1320
+
1321
+
1322
+ # ---------------------------------------------------------------------------
1323
+ # The compile entry point
1324
+ # ---------------------------------------------------------------------------
1325
+
1326
+
1327
+ def build_scene(spec, options, guard):
1328
+ """Compile a SceneSpec into a live Blender scene.
1329
+
1330
+ ``options`` may carry:
1331
+ - ``profile``: which render profile to apply (default ``preview``).
1332
+
1333
+ Returns a report describing what was built. Raises ``ActionError`` on any
1334
+ failure that must not be silently tolerated.
1335
+ """
1336
+ report_progress("reset_scene", 5)
1337
+ scene = reset_scene()
1338
+
1339
+ report_progress("build_world", 10)
1340
+ world_report = build_world(scene, spec)
1341
+
1342
+ report_progress("configure_scene", 12)
1343
+ profile_name = options.get("profile") or "preview"
1344
+ profiles = spec.get("renderProfiles") or {}
1345
+ profile = profiles.get(profile_name)
1346
+ if profile is None:
1347
+ profile = profiles.get("preview")
1348
+ if profile is None:
1349
+ raise ActionError(
1350
+ "RENDER_PROFILE_MISSING",
1351
+ 'the SceneSpec defines no render profile named "%s" and no "preview" profile' % (profile_name,),
1352
+ )
1353
+
1354
+ requested_engine = profile.get("engine", "cycles")
1355
+ engine, downgrade = resolve_engine(requested_engine, guard)
1356
+ try:
1357
+ scene.render.engine = engine
1358
+ except Exception as exc:
1359
+ raise ActionError(
1360
+ "BLENDER_ENGINE_UNAVAILABLE",
1361
+ 'could not assign render engine "%s": %s' % (engine, error_text(exc)),
1362
+ )
1363
+ if downgrade is not None:
1364
+ guard.warnings.append(downgrade)
1365
+
1366
+ # Materials first: entities reference them by id during construction.
1367
+ report_progress("build_materials", 20)
1368
+ materials = {}
1369
+ for entry in spec.get("materials") or []:
1370
+ materials[entry["id"]] = build_material(entry, guard)
1371
+ default_material = build_default_material(guard)
1372
+
1373
+ assets = {asset["id"]: asset for asset in spec.get("assets") or []}
1374
+
1375
+ report_progress("build_entities", 35)
1376
+ entity_objects = {}
1377
+ entity_positions = {}
1378
+ for entity in spec.get("entities") or []:
1379
+ entity_id = entity["id"]
1380
+ name = "%s%s" % (ENTITY_PREFIX, entity_id)
1381
+ visible = entity.get("visible", True)
1382
+
1383
+ if entity.get("type") == "empty":
1384
+ obj = bpy.data.objects.new(name, None)
1385
+ bpy.context.scene.collection.objects.link(obj)
1386
+ objects = [obj]
1387
+ elif entity.get("type") == "asset-instance":
1388
+ asset = assets.get(entity.get("assetId"))
1389
+ if asset is None:
1390
+ raise ActionError(
1391
+ "ASSET_MISSING",
1392
+ 'entity "%s" instantiates asset "%s", which the SceneSpec does not declare'
1393
+ % (entity_id, entity.get("assetId")),
1394
+ )
1395
+ project_root = options.get("project_root")
1396
+ relative = asset.get("path", "")
1397
+ if project_root:
1398
+ asset_path = os.path.normpath(os.path.join(project_root, relative))
1399
+ else:
1400
+ asset_path = relative
1401
+ objects = import_asset_into_scene(name, asset_path, asset.get("type"), guard)
1402
+ else:
1403
+ generator = entity.get("generator")
1404
+ if generator is None:
1405
+ raise ActionError(
1406
+ "SCENE_VALIDATION_FAILED",
1407
+ 'entity "%s" is a generator entity with no generator block' % (entity_id,),
1408
+ )
1409
+ objects = [create_generator(name, generator)]
1410
+
1411
+ root = objects[0]
1412
+ apply_transform(root, entity.get("transform") or {})
1413
+ apply_visibility(root, visible)
1414
+ for obj in objects:
1415
+ apply_visibility(obj, visible)
1416
+
1417
+ material_id = entity.get("materialId")
1418
+ if material_id is None and entity.get("type") != "empty":
1419
+ material_id = "default"
1420
+ materials.setdefault("default", default_material)
1421
+ material = materials.get(material_id)
1422
+ if material is None:
1423
+ raise ActionError(
1424
+ "SCENE_VALIDATION_FAILED",
1425
+ 'entity "%s" references material "%s", which was not built' % (entity_id, material_id),
1426
+ )
1427
+ for obj in objects:
1428
+ if not hasattr(obj.data, "materials"):
1429
+ continue
1430
+ obj.data.materials.clear()
1431
+ obj.data.materials.append(material)
1432
+ if entity.get("generator", {}).get("shape") in ("uv_sphere", "cylinder", "cone", "torus"):
1433
+ _shade_smooth(obj)
1434
+
1435
+ for obj in objects:
1436
+ obj["deepblend_id"] = entity_id
1437
+ obj["deepblend_kind"] = "entity"
1438
+ entity_objects[entity_id] = root
1439
+ entity_positions[entity_id] = [float(root.location[0]), float(root.location[1]), float(root.location[2])]
1440
+
1441
+ report_progress("build_lights", 55)
1442
+ for light in spec.get("lights") or []:
1443
+ obj = build_light(light)
1444
+ apply_transform(obj, light.get("transform") or {})
1445
+
1446
+ report_progress("build_cameras", 65)
1447
+ cameras = {}
1448
+ for camera in spec.get("cameras") or []:
1449
+ cameras[camera["id"]] = build_camera(camera, entity_positions)
1450
+ if cameras:
1451
+ scene.camera = cameras[spec["cameras"][0]["id"]]
1452
+
1453
+ report_progress("build_animation", 75)
1454
+ actions = []
1455
+ for track in spec.get("animationTracks") or []:
1456
+ target_id = track.get("targetEntityId")
1457
+ # `targetKind` decides WHICH collection the id resolves against. Ids are unique
1458
+ # per collection, not globally, so the kind is the only thing that says whether
1459
+ # "watch-dial" means the mesh or the material. Absent means `entity`, which is
1460
+ # what every track written before kinds existed meant.
1461
+ kind = track.get("targetKind") or "entity"
1462
+ if kind == "camera":
1463
+ target = cameras.get(target_id)
1464
+ elif kind == "material":
1465
+ target = materials.get(target_id)
1466
+ else:
1467
+ target = entity_objects.get(target_id)
1468
+ if target is None:
1469
+ raise ActionError(
1470
+ "SCENE_VALIDATION_FAILED",
1471
+ 'animation track "%s" targets %s "%s", which this scene does not contain'
1472
+ % (track.get("id"), kind, target_id),
1473
+ )
1474
+ if kind == "material":
1475
+ action = build_material_animation(target, track, guard)
1476
+ else:
1477
+ action = build_animation(target, track, guard)
1478
+ actions.append({"id": track.get("id"), "targetKind": kind, "targetEntityId": target_id, "action": action.name})
1479
+
1480
+ report_progress("configure_render", 85)
1481
+ render_config = configure_scene(scene, spec, profile, guard)
1482
+
1483
+ scene["deepblend_spec_schema"] = spec.get("schemaVersion", "")
1484
+ scene["deepblend_project_id"] = (spec.get("project") or {}).get("id", "")
1485
+ scene["deepblend_render_config"] = json.dumps(render_config, sort_keys=True)
1486
+
1487
+ # Every transform above was written to the datablocks; `matrix_world` (and
1488
+ # therefore every bound box and every camera-framing projection) is STALE
1489
+ # until the dependency graph is told to re-evaluate. Without this the
1490
+ # validation report describes an empty scene, and the camera framing check
1491
+ # would score every object as being at the origin.
1492
+ bpy.context.view_layer.update()
1493
+
1494
+ report_progress("compile_done", 90)
1495
+ return {
1496
+ "objects": describe_objects(),
1497
+ "actions": actions,
1498
+ "world": world_report,
1499
+ "renderConfig": render_config,
1500
+ "engine": engine,
1501
+ "requestedEngine": requested_engine,
1502
+ "profileName": profile_name,
1503
+ "entityObjectNames": {key: value.name for key, value in entity_objects.items()},
1504
+ "cameraNames": {key: value.name for key, value in cameras.items()},
1505
+ }
1506
+
1507
+
1508
+ def describe_objects():
1509
+ """Inventory of every object in the scene, for validation and golden checks."""
1510
+ entries = []
1511
+ for obj in bpy.data.objects:
1512
+ entry = {
1513
+ "name": obj.name,
1514
+ "type": obj.type,
1515
+ "deepblendId": obj.get("deepblend_id"),
1516
+ "deepblendKind": obj.get("deepblend_kind"),
1517
+ "location": [round(float(value), 6) for value in obj.location],
1518
+ "rotationEuler": [round(float(value), 6) for value in obj.rotation_euler],
1519
+ "scale": [round(float(value), 6) for value in obj.scale],
1520
+ "visible": not (obj.hide_render or obj.hide_viewport),
1521
+ "parent": obj.parent.name if obj.parent is not None else None,
1522
+ }
1523
+ if obj.type == "MESH":
1524
+ entry["vertexCount"] = len(obj.data.vertices)
1525
+ entry["polygonCount"] = len(obj.data.polygons)
1526
+ entry["materialNames"] = [material.name for material in obj.data.materials if material is not None]
1527
+ if obj.type == "LIGHT":
1528
+ entry["lightType"] = obj.data.type
1529
+ entry["energy"] = round(float(obj.data.energy), 6)
1530
+ if obj.type == "CAMERA":
1531
+ entry["lens"] = round(float(obj.data.lens), 6)
1532
+ entry["sensorWidth"] = round(float(obj.data.sensor_width), 6)
1533
+ if obj.animation_data is not None and obj.animation_data.action is not None:
1534
+ entry["action"] = as_text(obj.animation_data.action.name)
1535
+ entries.append(entry)
1536
+ entries.sort(key=lambda item: item["name"])
1537
+ return entries