@deepblend/dsh-blender-provider-local 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }