@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,264 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Shared helpers for the DeepBlend Blender-side bootstrap actions.
|
|
4
|
+
|
|
5
|
+
Everything in this module runs inside Blender's embedded CPython interpreter. It
|
|
6
|
+
is deliberately free of ``bpy`` imports: it is the part of the runtime that can
|
|
7
|
+
be read, reasoned about and reviewed as plain Python.
|
|
8
|
+
|
|
9
|
+
CONTRACT DISCIPLINE
|
|
10
|
+
-------------------
|
|
11
|
+
Two rules run through the whole Blender-side runtime and are worth stating once:
|
|
12
|
+
|
|
13
|
+
1. **The result is a JSON document written to a caller-supplied path.** stdout is
|
|
14
|
+
never a result channel: Blender writes banners, add-on chatter and progress
|
|
15
|
+
lines to stdout, so anything that parses stdout is parsing around noise. Short
|
|
16
|
+
diagnostics go to stderr prefixed with ``[deepblend]`` and are informational.
|
|
17
|
+
|
|
18
|
+
2. **The SceneSpec is the authority, not the .blend.** The compiler applies every
|
|
19
|
+
transform, visibility flag and animation TRACK, but it never treats a
|
|
20
|
+
pre-existing object as something to preserve. That is what makes a rebuild
|
|
21
|
+
reproducible and what makes "the spec changed" and "the scene changed" the
|
|
22
|
+
same statement (SPEC §8.1).
|
|
23
|
+
|
|
24
|
+
Standard library only, plus whatever the caller's action module needs.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
import tempfile
|
|
31
|
+
import traceback
|
|
32
|
+
|
|
33
|
+
#: Protocol version for the request/result envelope. Bumped only on a breaking
|
|
34
|
+
#: wire change; ``bootstrap.py`` refuses a request that disagrees.
|
|
35
|
+
PROTOCOL_VERSION = "deepblend.blender/v1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def eprint(message):
|
|
39
|
+
"""Emit an informational diagnostic on stderr. Never part of the contract."""
|
|
40
|
+
try:
|
|
41
|
+
sys.stderr.write("[deepblend] %s\n" % (message,))
|
|
42
|
+
sys.stderr.flush()
|
|
43
|
+
except Exception:
|
|
44
|
+
# Diagnostics must never be able to break a run.
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def as_text(value):
|
|
49
|
+
"""Coerce a bpy value into plain JSON-safe text.
|
|
50
|
+
|
|
51
|
+
``bpy.app.build_hash`` is ``bytes`` (``b'9e2066aef7ef'``) and blindly
|
|
52
|
+
JSON-encoding that fails, so every leaf scalar that reaches JSON passes
|
|
53
|
+
through here.
|
|
54
|
+
"""
|
|
55
|
+
if value is None:
|
|
56
|
+
return None
|
|
57
|
+
if isinstance(value, bytes):
|
|
58
|
+
try:
|
|
59
|
+
return value.decode("utf-8", "replace")
|
|
60
|
+
except Exception:
|
|
61
|
+
return repr(value)
|
|
62
|
+
if isinstance(value, (str, int, float, bool)):
|
|
63
|
+
return value
|
|
64
|
+
try:
|
|
65
|
+
return str(value)
|
|
66
|
+
except Exception:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def error_text(exc):
|
|
71
|
+
"""Render an exception as a compact, JSON-safe ``"Type: message"`` string."""
|
|
72
|
+
try:
|
|
73
|
+
return "%s: %s" % (type(exc).__name__, exc)
|
|
74
|
+
except Exception:
|
|
75
|
+
return "unprintable exception"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
#: Frame-sequence file naming. The host computes expected paths from the same
|
|
79
|
+
#: rule (`frameFileName` in @deepblend/dsh-blender-contracts) to build the frame
|
|
80
|
+
#: ledger, so the two must agree byte for byte — a disagreement makes every frame
|
|
81
|
+
#: read as missing. These live here, in the bpy-free module, so the contract test
|
|
82
|
+
#: can compare the two implementations WITHOUT launching Blender; a naming rule
|
|
83
|
+
#: that can only be checked by a 1-second process launch is a rule that gets
|
|
84
|
+
#: checked by nobody.
|
|
85
|
+
FRAME_FILE_PREFIX = "frame_"
|
|
86
|
+
FRAME_FILE_PADDING = 4
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def frame_file_name(frame, prefix=FRAME_FILE_PREFIX, padding=FRAME_FILE_PADDING):
|
|
90
|
+
"""The exact file name one frame is written to.
|
|
91
|
+
|
|
92
|
+
MEASURED, and the reason the renderer sets its own output path: on Blender
|
|
93
|
+
5.2.1, `scene.render.frame_path(frame=f)` PREDICTS `<dir>/frame_0001.png`
|
|
94
|
+
while `bpy.ops.render.render(write_still=True)` writes `<dir>/frame_.png`.
|
|
95
|
+
"""
|
|
96
|
+
return "%s%0*d.png" % (prefix, int(padding), int(frame))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def frame_path(directory, frame, prefix=FRAME_FILE_PREFIX, padding=FRAME_FILE_PADDING):
|
|
100
|
+
"""The absolute path one frame is written to."""
|
|
101
|
+
return os.path.join(directory, frame_file_name(frame, prefix, padding))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def write_json_atomic(path, document):
|
|
105
|
+
"""Write ``document`` as pretty JSON, atomically.
|
|
106
|
+
|
|
107
|
+
The temp file is a sibling so the final ``os.replace`` stays on one
|
|
108
|
+
filesystem, where it is atomic. A reader therefore sees either the previous
|
|
109
|
+
file or the complete new one, never a half-written document.
|
|
110
|
+
"""
|
|
111
|
+
directory = os.path.dirname(os.path.abspath(path))
|
|
112
|
+
if directory and not os.path.isdir(directory):
|
|
113
|
+
os.makedirs(directory, exist_ok=True)
|
|
114
|
+
handle, temporary = tempfile.mkstemp(
|
|
115
|
+
prefix=os.path.basename(path) + ".", suffix=".tmp", dir=directory or None
|
|
116
|
+
)
|
|
117
|
+
try:
|
|
118
|
+
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
|
119
|
+
json.dump(document, stream, indent=2, ensure_ascii=False, sort_keys=False)
|
|
120
|
+
stream.write("\n")
|
|
121
|
+
os.replace(temporary, path)
|
|
122
|
+
except Exception:
|
|
123
|
+
try:
|
|
124
|
+
os.unlink(temporary)
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
raise
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def read_json(path):
|
|
131
|
+
"""Read a JSON document, raising a message that names the file."""
|
|
132
|
+
with open(path, "r", encoding="utf-8") as stream:
|
|
133
|
+
return json.load(stream)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_args(argv, known_options):
|
|
137
|
+
"""Parse ``--opt value`` / ``--opt=value`` tokens after a bare ``--``.
|
|
138
|
+
|
|
139
|
+
Blender consumes its own arguments before the separator; everything after it
|
|
140
|
+
belongs to the bootstrap script. Returns ``(options, unknown)`` where
|
|
141
|
+
``options`` is a dict of the recognised keys (absent when not supplied) and
|
|
142
|
+
``unknown`` lists anything unexpected, so the caller can fail loudly on a
|
|
143
|
+
typo instead of silently running with a default.
|
|
144
|
+
"""
|
|
145
|
+
options = {}
|
|
146
|
+
unknown = []
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
separator = argv.index("--")
|
|
150
|
+
except ValueError:
|
|
151
|
+
return options, unknown
|
|
152
|
+
|
|
153
|
+
tokens = list(argv[separator + 1:])
|
|
154
|
+
index = 0
|
|
155
|
+
while index < len(tokens):
|
|
156
|
+
token = tokens[index]
|
|
157
|
+
index += 1
|
|
158
|
+
|
|
159
|
+
inline_value = None
|
|
160
|
+
name = token
|
|
161
|
+
if token.startswith("--") and "=" in token:
|
|
162
|
+
name, inline_value = token.split("=", 1)
|
|
163
|
+
|
|
164
|
+
if name in known_options:
|
|
165
|
+
if inline_value is not None:
|
|
166
|
+
value = inline_value
|
|
167
|
+
elif index < len(tokens):
|
|
168
|
+
value = tokens[index]
|
|
169
|
+
index += 1
|
|
170
|
+
else:
|
|
171
|
+
unknown.append("%s (missing value)" % (name,))
|
|
172
|
+
continue
|
|
173
|
+
options[name.lstrip("-").replace("-", "_")] = value
|
|
174
|
+
elif token.startswith("--"):
|
|
175
|
+
unknown.append(token)
|
|
176
|
+
|
|
177
|
+
return options, unknown
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def report_progress(stage, percent, detail=None):
|
|
181
|
+
"""Report progress on stdout as one JSON object per line (SPEC §9.3).
|
|
182
|
+
|
|
183
|
+
This is the ONE thing that legitimately goes to stdout, and it is
|
|
184
|
+
distinguishable by construction: each line is a complete JSON document with a
|
|
185
|
+
``type`` key, so a consumer can filter Blender's own output by parsing rather
|
|
186
|
+
than by pattern-matching prose. It is advisory only — the contract is still
|
|
187
|
+
the result file.
|
|
188
|
+
"""
|
|
189
|
+
payload = {"type": "progress", "stage": stage, "percent": percent}
|
|
190
|
+
if detail is not None:
|
|
191
|
+
payload["detail"] = detail
|
|
192
|
+
try:
|
|
193
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
194
|
+
sys.stdout.flush()
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def warning(code, message, detail=None):
|
|
200
|
+
"""Build one warning entry. Warnings degrade a success; they never throw."""
|
|
201
|
+
entry = {"code": code, "message": message}
|
|
202
|
+
if detail is not None:
|
|
203
|
+
entry["detail"] = detail
|
|
204
|
+
return entry
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Guard:
|
|
208
|
+
"""Collects warnings, notices and per-item decisions for one action.
|
|
209
|
+
|
|
210
|
+
One collector per action run, passed down rather than returned up, so a
|
|
211
|
+
decision made five call levels deep still reaches the caller's envelope. A
|
|
212
|
+
decision that is only visible in a log — or worse, only in the render — is the
|
|
213
|
+
most expensive kind of bug to find, so nothing is allowed to decide silently.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
def __init__(self):
|
|
217
|
+
self.warnings = []
|
|
218
|
+
self.notices = []
|
|
219
|
+
|
|
220
|
+
def warn(self, code, message, detail=None):
|
|
221
|
+
self.warnings.append(warning(code, message, detail))
|
|
222
|
+
|
|
223
|
+
def note(self, code, message, detail=None):
|
|
224
|
+
self.notices.append(warning(code, message, detail))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class ActionError(Exception):
|
|
228
|
+
"""A classified failure that must reach the caller as a stable code.
|
|
229
|
+
|
|
230
|
+
``code`` is a ``BlenderErrorCode`` value from the contracts package. The
|
|
231
|
+
``bootstrap.py`` dispatcher converts this into an error envelope and a
|
|
232
|
+
non-zero exit code, so an unsupported or refused action is never mistaken for
|
|
233
|
+
a success with empty output.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(self, code, message, detail=None):
|
|
237
|
+
super().__init__(message)
|
|
238
|
+
self.code = code
|
|
239
|
+
self.message = message
|
|
240
|
+
self.detail = detail
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def guard(handler):
|
|
244
|
+
"""Run ``handler`` and return ``(result, error)``.
|
|
245
|
+
|
|
246
|
+
Used by the dispatcher so that a single action cannot take the process down
|
|
247
|
+
with an unhandled exception: every failure becomes a classified error
|
|
248
|
+
envelope, and a genuinely unexpected one still carries its traceback in the
|
|
249
|
+
detail for post-mortem.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
def wrapped(*args, **kwargs):
|
|
253
|
+
try:
|
|
254
|
+
return handler(*args, **kwargs), None
|
|
255
|
+
except ActionError as exc:
|
|
256
|
+
return None, {"code": exc.code, "message": exc.message, "detail": exc.detail}
|
|
257
|
+
except Exception as exc: # noqa: BLE001 - the boundary must catch everything
|
|
258
|
+
return None, {
|
|
259
|
+
"code": "BLENDER_SCRIPT_ERROR",
|
|
260
|
+
"message": error_text(exc),
|
|
261
|
+
"detail": {"traceback": traceback.format_exc()},
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return wrapped
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""DeepBlend technical scene validation.
|
|
4
|
+
|
|
5
|
+
The seam between the two validation layers of SPEC §13.2:
|
|
6
|
+
|
|
7
|
+
* **JSON Schema + semantic rules** run in Node, before Blender is launched, and
|
|
8
|
+
answer "is this document a coherent scene?" (`deepblend/contracts/lib/scene-spec.js`).
|
|
9
|
+
* **This module** runs inside Blender, after a compile, and answers "did that
|
|
10
|
+
document actually become a renderable scene?" — a different question, and the
|
|
11
|
+
only one that can be answered with the real depsgraph in hand.
|
|
12
|
+
|
|
13
|
+
The distinction is not academic. A SceneSpec can be perfectly valid and still
|
|
14
|
+
compile to something a camera cannot photograph: an entity whose primitive
|
|
15
|
+
produced no geometry, a scale of 0 that collapsed an object to a point, a camera
|
|
16
|
+
aimed at a hidden object, or a frames-per-second of 0. Every one of those passes
|
|
17
|
+
schema validation and fails here.
|
|
18
|
+
|
|
19
|
+
Findings carry a severity:
|
|
20
|
+
- ``error`` — the scene must not be committed.
|
|
21
|
+
- ``warning`` — suspicious but renderable; the caller decides.
|
|
22
|
+
- ``notice`` — something the compiler decided, recorded for the audit trail.
|
|
23
|
+
|
|
24
|
+
Runs inside Blender's embedded interpreter.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import bpy
|
|
28
|
+
from mathutils import Vector
|
|
29
|
+
|
|
30
|
+
from deepblend_scene import ENTITY_PREFIX
|
|
31
|
+
from deepblend_util import as_text, warning
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _evaluated_bounds(obj):
|
|
35
|
+
"""World-space bounding box of an evaluated object, or ``None``.
|
|
36
|
+
|
|
37
|
+
The evaluated object is used rather than the raw mesh so a modifier's effect
|
|
38
|
+
is included — without it, a bevel or a subsurf would be scored as if it did
|
|
39
|
+
nothing. ``obj.bound_box`` is in local space, so every corner is transformed.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
from bpy import context
|
|
43
|
+
depsgraph = context.evaluated_depsgraph_get()
|
|
44
|
+
evaluated = obj.evaluated_get(depsgraph)
|
|
45
|
+
corners = [evaluated.matrix_world @ Vector(corner) for corner in evaluated.bound_box]
|
|
46
|
+
except Exception:
|
|
47
|
+
return None
|
|
48
|
+
if not corners:
|
|
49
|
+
return None
|
|
50
|
+
return {
|
|
51
|
+
"min": [round(min(corner[axis] for corner in corners), 6) for axis in range(3)],
|
|
52
|
+
"max": [round(max(corner[axis] for corner in corners), 6) for axis in range(3)],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _box_size(bounds):
|
|
57
|
+
if bounds is None:
|
|
58
|
+
return None
|
|
59
|
+
return [round(bounds["max"][axis] - bounds["min"][axis], 6) for axis in range(3)]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def validate_scene(scene, spec, guard):
|
|
63
|
+
"""Validate the live scene against the spec that produced it.
|
|
64
|
+
|
|
65
|
+
``guard`` receives warnings and notices so nothing found here is lost; the
|
|
66
|
+
returned report carries the errors plus the structural facts a golden test
|
|
67
|
+
asserts on (counts, bounds, frame range, camera parameters).
|
|
68
|
+
"""
|
|
69
|
+
errors = []
|
|
70
|
+
artifacts = []
|
|
71
|
+
|
|
72
|
+
def fail(code, message, detail=None):
|
|
73
|
+
entry = {"severity": "error", "code": code, "message": message}
|
|
74
|
+
if detail is not None:
|
|
75
|
+
entry["detail"] = detail
|
|
76
|
+
errors.append(entry)
|
|
77
|
+
|
|
78
|
+
objects = [obj for obj in scene.objects]
|
|
79
|
+
meshes = [obj for obj in objects if obj.type == "MESH"]
|
|
80
|
+
lights = [obj for obj in objects if obj.type == "LIGHT"]
|
|
81
|
+
cameras = [obj for obj in objects if obj.type == "CAMERA"]
|
|
82
|
+
|
|
83
|
+
spec_entities = spec.get("entities") or []
|
|
84
|
+
spec_lights = spec.get("lights") or []
|
|
85
|
+
spec_cameras = spec.get("cameras") or []
|
|
86
|
+
spec_materials = spec.get("materials") or []
|
|
87
|
+
spec_tracks = spec.get("animationTracks") or []
|
|
88
|
+
spec_shots = spec.get("shots") or []
|
|
89
|
+
|
|
90
|
+
expected_mesh_entities = [entity for entity in spec_entities if entity.get("type") != "empty"]
|
|
91
|
+
if len(meshes) < len(expected_mesh_entities):
|
|
92
|
+
fail(
|
|
93
|
+
"SCENE_VALIDATION_FAILED",
|
|
94
|
+
"the spec declares %d renderable entit(y/ies) but the scene holds %d mesh object(s)"
|
|
95
|
+
% (len(expected_mesh_entities), len(meshes)),
|
|
96
|
+
{
|
|
97
|
+
"expected": len(expected_mesh_entities),
|
|
98
|
+
"actual": len(meshes),
|
|
99
|
+
"objects": sorted(obj.name for obj in meshes),
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
if len(lights) != len(spec_lights):
|
|
103
|
+
fail(
|
|
104
|
+
"SCENE_VALIDATION_FAILED",
|
|
105
|
+
"the spec declares %d light(s) but the scene holds %d" % (len(spec_lights), len(lights)),
|
|
106
|
+
)
|
|
107
|
+
if len(cameras) != len(spec_cameras):
|
|
108
|
+
fail(
|
|
109
|
+
"SCENE_VALIDATION_FAILED",
|
|
110
|
+
"the spec declares %d camera(s) but the scene holds %d" % (len(spec_cameras), len(cameras)),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# ---- geometry ---------------------------------------------------------
|
|
114
|
+
total_vertices = 0
|
|
115
|
+
total_polygons = 0
|
|
116
|
+
degenerate = []
|
|
117
|
+
for obj in meshes:
|
|
118
|
+
vertices = len(obj.data.vertices)
|
|
119
|
+
polygons = len(obj.data.polygons)
|
|
120
|
+
total_vertices += vertices
|
|
121
|
+
total_polygons += polygons
|
|
122
|
+
if vertices == 0 or polygons == 0:
|
|
123
|
+
degenerate.append(obj.name)
|
|
124
|
+
bounds = _evaluated_bounds(obj)
|
|
125
|
+
size = _box_size(bounds)
|
|
126
|
+
if size is not None and max(size) <= 1e-6:
|
|
127
|
+
degenerate.append(obj.name)
|
|
128
|
+
if degenerate:
|
|
129
|
+
fail(
|
|
130
|
+
"SCENE_VALIDATION_FAILED",
|
|
131
|
+
"%d object(s) have no usable geometry: %s" % (len(degenerate), ", ".join(sorted(set(degenerate)))),
|
|
132
|
+
{"objects": sorted(set(degenerate))},
|
|
133
|
+
)
|
|
134
|
+
if not meshes:
|
|
135
|
+
fail("SCENE_VALIDATION_FAILED", "the compiled scene contains no mesh object, so nothing can be rendered")
|
|
136
|
+
|
|
137
|
+
# ---- visibility -------------------------------------------------------
|
|
138
|
+
hidden_renderable = [
|
|
139
|
+
obj.name for obj in meshes
|
|
140
|
+
if obj.hide_render or obj.hide_viewport
|
|
141
|
+
]
|
|
142
|
+
if len(hidden_renderable) == len(meshes) and meshes:
|
|
143
|
+
fail(
|
|
144
|
+
"SCENE_VALIDATION_FAILED",
|
|
145
|
+
"every mesh object is hidden; a render would contain no geometry",
|
|
146
|
+
{"objects": hidden_renderable},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# ---- cameras ----------------------------------------------------------
|
|
150
|
+
if not cameras:
|
|
151
|
+
fail("SCENE_VALIDATION_FAILED", "the scene contains no camera")
|
|
152
|
+
else:
|
|
153
|
+
for camera in cameras:
|
|
154
|
+
if camera.data.lens <= 0:
|
|
155
|
+
fail(
|
|
156
|
+
"SCENE_VALIDATION_FAILED",
|
|
157
|
+
'camera "%s" has lens %.4f; a non-positive focal length cannot form an image'
|
|
158
|
+
% (camera.name, camera.data.lens),
|
|
159
|
+
)
|
|
160
|
+
if camera.data.clip_start >= camera.data.clip_end:
|
|
161
|
+
fail(
|
|
162
|
+
"SCENE_VALIDATION_FAILED",
|
|
163
|
+
'camera "%s" has clip_start %.4f >= clip_end %.4f'
|
|
164
|
+
% (camera.name, camera.data.clip_start, camera.data.clip_end),
|
|
165
|
+
)
|
|
166
|
+
if scene.camera is None:
|
|
167
|
+
fail("SCENE_VALIDATION_FAILED", "no camera is active in the scene")
|
|
168
|
+
elif camera is scene.camera:
|
|
169
|
+
# Framing check on the ACTIVE camera only: whether the subject is
|
|
170
|
+
# inside the frustum is the single most valuable technical fact a
|
|
171
|
+
# preview can be checked against before spending a render on it.
|
|
172
|
+
framing = camera_framing(scene, camera, meshes)
|
|
173
|
+
if framing is not None and not framing["subjectInFrame"]:
|
|
174
|
+
guard.warn(
|
|
175
|
+
"SCENE_VALIDATION_FAILED",
|
|
176
|
+
'the active camera "%s" does not appear to contain the scene subject'
|
|
177
|
+
% (camera.name,),
|
|
178
|
+
framing,
|
|
179
|
+
)
|
|
180
|
+
if framing is not None:
|
|
181
|
+
artifacts.append({"kind": "framing", **framing})
|
|
182
|
+
|
|
183
|
+
# ---- frame range and animation ---------------------------------------
|
|
184
|
+
project = spec.get("project") or {}
|
|
185
|
+
if scene.frame_start >= scene.frame_end:
|
|
186
|
+
fail(
|
|
187
|
+
"SCENE_VALIDATION_FAILED",
|
|
188
|
+
"frame range %d..%d is empty" % (scene.frame_start, scene.frame_end),
|
|
189
|
+
)
|
|
190
|
+
if scene.render.fps <= 0:
|
|
191
|
+
fail("SCENE_VALIDATION_FAILED", "fps is %d; a frame rate must be positive" % (scene.render.fps,))
|
|
192
|
+
if project.get("frameStart") is not None and scene.frame_start != int(project["frameStart"]):
|
|
193
|
+
fail(
|
|
194
|
+
"SCENE_VALIDATION_FAILED",
|
|
195
|
+
"scene frame_start is %d but the spec asks for %d" % (scene.frame_start, int(project["frameStart"])),
|
|
196
|
+
)
|
|
197
|
+
if project.get("frameEnd") is not None and scene.frame_end != int(project["frameEnd"]):
|
|
198
|
+
fail(
|
|
199
|
+
"SCENE_VALIDATION_FAILED",
|
|
200
|
+
"scene frame_end is %d but the spec asks for %d" % (scene.frame_end, int(project["frameEnd"])),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
animated = [obj.name for obj in objects if obj.animation_data is not None and obj.animation_data.action is not None]
|
|
204
|
+
if spec_tracks and not animated:
|
|
205
|
+
fail(
|
|
206
|
+
"SCENE_VALIDATION_FAILED",
|
|
207
|
+
"the spec declares %d animation track(s) but no object carries an action" % (len(spec_tracks),),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# ---- materials --------------------------------------------------------
|
|
211
|
+
materials_in_use = {
|
|
212
|
+
material.name
|
|
213
|
+
for obj in meshes
|
|
214
|
+
for material in obj.data.materials
|
|
215
|
+
if material is not None
|
|
216
|
+
}
|
|
217
|
+
if spec_materials and not materials_in_use:
|
|
218
|
+
fail("SCENE_VALIDATION_FAILED", "the spec declares materials but no mesh references one")
|
|
219
|
+
|
|
220
|
+
# ---- shots ------------------------------------------------------------
|
|
221
|
+
camera_ids = {camera.get("deepblend_id") for camera in cameras}
|
|
222
|
+
for shot in spec_shots:
|
|
223
|
+
if shot.get("cameraId") not in camera_ids:
|
|
224
|
+
fail(
|
|
225
|
+
"SCENE_VALIDATION_FAILED",
|
|
226
|
+
'shot "%s" references camera "%s", which does not exist in the compiled scene'
|
|
227
|
+
% (shot.get("id"), shot.get("cameraId")),
|
|
228
|
+
)
|
|
229
|
+
frame_range = shot.get("frameRange")
|
|
230
|
+
if frame_range and (frame_range[0] < scene.frame_start or frame_range[1] > scene.frame_end):
|
|
231
|
+
guard.warn(
|
|
232
|
+
"SCENE_VALIDATION_FAILED",
|
|
233
|
+
'shot "%s" spans frames %d..%d, outside the project range %d..%d'
|
|
234
|
+
% (shot.get("id"), frame_range[0], frame_range[1], scene.frame_start, scene.frame_end),
|
|
235
|
+
{"shotId": shot.get("id"), "frameRange": frame_range},
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
report = {
|
|
239
|
+
"ok": len(errors) == 0,
|
|
240
|
+
"errors": errors,
|
|
241
|
+
"counts": {
|
|
242
|
+
"objects": len(objects),
|
|
243
|
+
"meshObjects": len(meshes),
|
|
244
|
+
"lightObjects": len(lights),
|
|
245
|
+
"cameraObjects": len(cameras),
|
|
246
|
+
"materials": len(bpy.data.materials),
|
|
247
|
+
"actions": len(bpy.data.actions),
|
|
248
|
+
"specEntities": len(spec_entities),
|
|
249
|
+
"specLights": len(spec_lights),
|
|
250
|
+
"specCameras": len(spec_cameras),
|
|
251
|
+
"specMaterials": len(spec_materials),
|
|
252
|
+
"specShots": len(spec_shots),
|
|
253
|
+
"specAnimationTracks": len(spec_tracks),
|
|
254
|
+
},
|
|
255
|
+
"geometry": {
|
|
256
|
+
"totalVertices": total_vertices,
|
|
257
|
+
"totalPolygons": total_polygons,
|
|
258
|
+
"degenerateObjects": sorted(set(degenerate)),
|
|
259
|
+
"hiddenRenderableObjects": sorted(hidden_renderable),
|
|
260
|
+
},
|
|
261
|
+
"frameRange": [int(scene.frame_start), int(scene.frame_end)],
|
|
262
|
+
"fps": int(scene.render.fps),
|
|
263
|
+
"engine": scene.render.engine,
|
|
264
|
+
"activeCamera": scene.camera.name if scene.camera is not None else None,
|
|
265
|
+
"animatedObjects": sorted(animated),
|
|
266
|
+
"bounds": scene_bounds(meshes),
|
|
267
|
+
"subjectBounds": subject_bounds(meshes),
|
|
268
|
+
"cameraParameters": [
|
|
269
|
+
{
|
|
270
|
+
"id": camera.get("deepblend_id"),
|
|
271
|
+
"name": camera.name,
|
|
272
|
+
"lens": round(float(camera.data.lens), 6),
|
|
273
|
+
"sensorWidth": round(float(camera.data.sensor_width), 6),
|
|
274
|
+
"location": [round(float(value), 6) for value in camera.location],
|
|
275
|
+
"rotationEuler": [round(float(value), 6) for value in camera.rotation_euler],
|
|
276
|
+
}
|
|
277
|
+
for camera in cameras
|
|
278
|
+
],
|
|
279
|
+
"artifacts": artifacts,
|
|
280
|
+
}
|
|
281
|
+
return report
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def subject_bounds(meshes):
|
|
285
|
+
"""Aggregate world-space bounds of every mesh, ignoring nothing.
|
|
286
|
+
|
|
287
|
+
Entities tagged ``environment`` in the spec cannot be excluded here because
|
|
288
|
+
the tag lives in the spec, not in the blend; the caller correlates the two
|
|
289
|
+
when it needs the subject alone.
|
|
290
|
+
"""
|
|
291
|
+
boxes = [bounds for bounds in (_evaluated_bounds(obj) for obj in meshes) if bounds is not None]
|
|
292
|
+
if not boxes:
|
|
293
|
+
return None
|
|
294
|
+
return {
|
|
295
|
+
"min": [round(min(box["min"][axis] for box in boxes), 6) for axis in range(3)],
|
|
296
|
+
"max": [round(max(box["max"][axis] for box in boxes), 6) for axis in range(3)],
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def scene_bounds(meshes):
|
|
301
|
+
"""Alias kept explicit so a reader can see both are intentionally reported."""
|
|
302
|
+
return subject_bounds(meshes)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def camera_framing(scene, camera, meshes):
|
|
306
|
+
"""Project the scene's mesh centres into the active camera's frustum.
|
|
307
|
+
|
|
308
|
+
Deliberately cheap: the eight corners of each object's bound box are projected
|
|
309
|
+
with ``world_to_camera_view`` and compared against the unit frame. This is not
|
|
310
|
+
a hidden-surface analysis and does not claim to be one — it answers "is the
|
|
311
|
+
subject somewhere in front of the lens", which is the question worth asking
|
|
312
|
+
before paying for a render.
|
|
313
|
+
"""
|
|
314
|
+
try:
|
|
315
|
+
from bpy_extras.object_utils import world_to_camera_view
|
|
316
|
+
except Exception:
|
|
317
|
+
return None
|
|
318
|
+
if not meshes:
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
inside = 0
|
|
322
|
+
considered = 0
|
|
323
|
+
projected = []
|
|
324
|
+
for obj in meshes:
|
|
325
|
+
bounds = _evaluated_bounds(obj)
|
|
326
|
+
if bounds is None:
|
|
327
|
+
continue
|
|
328
|
+
considered += 1
|
|
329
|
+
corners = [
|
|
330
|
+
Vector((bounds["min"][0] if bit & 1 else bounds["max"][0],
|
|
331
|
+
bounds["min"][1] if bit & 2 else bounds["max"][1],
|
|
332
|
+
bounds["min"][2] if bit & 4 else bounds["max"][2]))
|
|
333
|
+
for bit in range(8)
|
|
334
|
+
]
|
|
335
|
+
in_frame = False
|
|
336
|
+
for corner in corners:
|
|
337
|
+
coordinate = world_to_camera_view(scene, camera, corner)
|
|
338
|
+
if coordinate.z > 0 and 0.0 <= coordinate.x <= 1.0 and 0.0 <= coordinate.y <= 1.0:
|
|
339
|
+
in_frame = True
|
|
340
|
+
if in_frame:
|
|
341
|
+
inside += 1
|
|
342
|
+
centre = Vector(((bounds["min"][0] + bounds["max"][0]) / 2,
|
|
343
|
+
(bounds["min"][1] + bounds["max"][1]) / 2,
|
|
344
|
+
(bounds["min"][2] + bounds["max"][2]) / 2))
|
|
345
|
+
coordinate = world_to_camera_view(scene, camera, centre)
|
|
346
|
+
projected.append({
|
|
347
|
+
"object": obj.name,
|
|
348
|
+
"uv": [round(coordinate.x, 4), round(coordinate.y, 4)],
|
|
349
|
+
"depth": round(coordinate.z, 4),
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
"cameraId": camera.get("deepblend_id"),
|
|
354
|
+
"consideredObjects": considered,
|
|
355
|
+
"objectsInFrame": inside,
|
|
356
|
+
"subjectInFrame": inside > 0,
|
|
357
|
+
"projectedCentres": projected,
|
|
358
|
+
}
|