@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.
- package/lib/index.js +1610 -0
- package/package.json +25 -0
- package/python/__pycache__/bootstrap.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
- package/python/bootstrap.py +625 -0
- package/python/deepblend_capabilities.py +633 -0
- package/python/deepblend_frames.py +402 -0
- package/python/deepblend_render.py +299 -0
- package/python/deepblend_scene.py +1537 -0
- package/python/deepblend_util.py +264 -0
- package/python/deepblend_validate.py +358 -0
- package/python/deepblend_views.py +745 -0
|
@@ -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
|