meshprobe 0.2.0__py3-none-any.whl

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.
Files changed (51) hide show
  1. meshprobe/__init__.py +8 -0
  2. meshprobe/artifacts.py +333 -0
  3. meshprobe/blender/__init__.py +1 -0
  4. meshprobe/blender/curated_builder.py +172 -0
  5. meshprobe/blender/worker.py +2046 -0
  6. meshprobe/camera.py +225 -0
  7. meshprobe/cli.py +856 -0
  8. meshprobe/client.py +432 -0
  9. meshprobe/contact_sheet.py +83 -0
  10. meshprobe/controller.py +1076 -0
  11. meshprobe/daemon.py +128 -0
  12. meshprobe/evals/__init__.py +5 -0
  13. meshprobe/evals/curated.py +330 -0
  14. meshprobe/evals/curated_build.py +206 -0
  15. meshprobe/evals/curated_tasks.py +533 -0
  16. meshprobe/evals/ergonomics.py +1197 -0
  17. meshprobe/evals/factory.py +472 -0
  18. meshprobe/evals/generators.py +1040 -0
  19. meshprobe/evals/geometry.py +514 -0
  20. meshprobe/evals/harness/__init__.py +19 -0
  21. meshprobe/evals/harness/adapters.py +631 -0
  22. meshprobe/evals/harness/attempts.py +41 -0
  23. meshprobe/evals/harness/broker.py +544 -0
  24. meshprobe/evals/harness/replay.py +111 -0
  25. meshprobe/evals/harness/runner.py +208 -0
  26. meshprobe/evals/harness/sandbox.py +631 -0
  27. meshprobe/evals/harness/suite.py +246 -0
  28. meshprobe/evals/harness/windows_sandbox.py +760 -0
  29. meshprobe/evals/leakage.py +74 -0
  30. meshprobe/evals/migration.py +278 -0
  31. meshprobe/evals/oracles.py +777 -0
  32. meshprobe/evals/reference_agent.py +649 -0
  33. meshprobe/evals/reports.py +245 -0
  34. meshprobe/evals/schemas.py +369 -0
  35. meshprobe/evals/tiers.py +257 -0
  36. meshprobe/evals/variants.py +97 -0
  37. meshprobe/identity.py +12 -0
  38. meshprobe/mcp_server.py +65 -0
  39. meshprobe/models.py +625 -0
  40. meshprobe/protocol.py +152 -0
  41. meshprobe/py.typed +1 -0
  42. meshprobe/selectors.py +97 -0
  43. meshprobe/service.py +110 -0
  44. meshprobe/session.py +106 -0
  45. meshprobe/sources.py +263 -0
  46. meshprobe/workspace.py +682 -0
  47. meshprobe-0.2.0.dist-info/METADATA +248 -0
  48. meshprobe-0.2.0.dist-info/RECORD +51 -0
  49. meshprobe-0.2.0.dist-info/WHEEL +4 -0
  50. meshprobe-0.2.0.dist-info/entry_points.txt +3 -0
  51. meshprobe-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,2046 @@
1
+ """Factory-clean Blender worker using newline-delimited JSON over stdio.
2
+
3
+ This module runs inside Blender's bundled Python. Keep it free of third-party
4
+ dependencies; the controller validates its output with the package's Pydantic
5
+ contracts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import math
13
+ import os
14
+ import shlex
15
+ import struct
16
+ import sys
17
+ import tempfile
18
+ import traceback
19
+ from collections import Counter
20
+ from copy import deepcopy
21
+ from pathlib import Path
22
+ from statistics import median
23
+ from typing import Any
24
+
25
+ import bpy # type: ignore[import-not-found]
26
+ from mathutils import Matrix, Quaternion, Vector # type: ignore[import-not-found]
27
+
28
+ PROTOCOL_VERSION = 2
29
+ MILLIMETERS_PER_METER = 1_000.0
30
+ PRESET_REFERENCE_SPAN_MM = 5_000.0
31
+ DISPLAY_MODES = {"shown", "hidden", "isolated", "ghosted"}
32
+ MARK_MODES = {"unmarked", "selected", "highlighted", "labeled"}
33
+ SUPPORTED_GLTF_EXTENSIONS = {
34
+ "EXT_mesh_gpu_instancing",
35
+ "KHR_draco_mesh_compression",
36
+ "KHR_lights_punctual",
37
+ "KHR_materials_anisotropy",
38
+ "KHR_materials_clearcoat",
39
+ "KHR_materials_emissive_strength",
40
+ "KHR_materials_ior",
41
+ "KHR_materials_iridescence",
42
+ "KHR_materials_pbrSpecularGlossiness",
43
+ "KHR_materials_sheen",
44
+ "KHR_materials_specular",
45
+ "KHR_materials_transmission",
46
+ "KHR_materials_unlit",
47
+ "KHR_materials_variants",
48
+ "KHR_materials_volume",
49
+ "KHR_mesh_quantization",
50
+ "KHR_texture_basisu",
51
+ "KHR_texture_transform",
52
+ "KHR_xmp_json_ld",
53
+ }
54
+ MANIFEST: dict[str, Any] | None = None
55
+ COMPONENT_OBJECTS: dict[str, bpy.types.Object] = {}
56
+ ORIGINAL_MESHES: dict[str, bpy.types.Mesh] = {}
57
+ ORIGINAL_VISIBILITY: dict[str, tuple[bool, bool]] = {}
58
+ COMPONENT_STATES: dict[str, dict[str, str]] = {}
59
+ IMPORTED_CAMERA: dict[str, Any] | None = None
60
+ CURRENT_CAMERA: dict[str, Any] | None = None
61
+ CURRENT_CAMERA_DIAGNOSTICS: dict[str, Any] | None = None
62
+ IMPORTED_ILLUMINATION: dict[str, Any] | None = None
63
+ CURRENT_ILLUMINATION: dict[str, Any] | None = None
64
+ MARK_OBJECTS: dict[str, bpy.types.Object] = {}
65
+ OVERRIDE_MATERIALS: dict[str, bpy.types.Material] = {}
66
+ EMISSION_MATERIALS: dict[str, bpy.types.Material] = {}
67
+
68
+
69
+ def emit(payload: dict[str, Any]) -> None:
70
+ print(json.dumps(payload, sort_keys=True, separators=(",", ":")), flush=True)
71
+
72
+
73
+ def stable_component_id(source_sha256: str, instance_path: str) -> str:
74
+ digest = hashlib.sha256(f"{source_sha256}\0{instance_path}".encode()).hexdigest()
75
+ return f"cmp_{digest[:24]}"
76
+
77
+
78
+ def sha256_file(path: Path) -> str:
79
+ digest = hashlib.sha256()
80
+ with path.open("rb") as source:
81
+ while chunk := source.read(1024 * 1024):
82
+ digest.update(chunk)
83
+ return digest.hexdigest()
84
+
85
+
86
+ def artifact(path: Path, media_type: str) -> dict[str, Any]:
87
+ return {
88
+ "path": str(path),
89
+ "media_type": media_type,
90
+ "sha256": sha256_file(path),
91
+ "bytes": path.stat().st_size,
92
+ }
93
+
94
+
95
+ def object_path(obj: bpy.types.Object) -> str:
96
+ names: list[str] = []
97
+ current: bpy.types.Object | None = obj
98
+ while current is not None:
99
+ names.append(escape_path_segment(current.name))
100
+ current = current.parent
101
+ return "/".join(reversed(names))
102
+
103
+
104
+ def escape_path_segment(name: str) -> str:
105
+ return name.replace("%", "%25").replace("/", "%2F")
106
+
107
+
108
+ def nearest_component_parent(
109
+ obj: bpy.types.Object, component_objects: set[bpy.types.Object]
110
+ ) -> bpy.types.Object | None:
111
+ current = obj.parent
112
+ while current is not None:
113
+ if current in component_objects:
114
+ return current
115
+ current = current.parent
116
+ return None
117
+
118
+
119
+ def bounds_of_points(points: list[Vector]) -> dict[str, list[float]]:
120
+ return {
121
+ "minimum_mm": [
122
+ min(point[axis] for point in points) * MILLIMETERS_PER_METER for axis in range(3)
123
+ ],
124
+ "maximum_mm": [
125
+ max(point[axis] for point in points) * MILLIMETERS_PER_METER for axis in range(3)
126
+ ],
127
+ }
128
+
129
+
130
+ def object_bounds(obj: bpy.types.Object) -> tuple[dict[str, list[float]], dict[str, list[float]]]:
131
+ local_points = [Vector(corner) for corner in obj.bound_box]
132
+ world_points = [obj.matrix_world @ point for point in local_points]
133
+ return bounds_of_points(local_points), bounds_of_points(world_points)
134
+
135
+
136
+ def matrix_in_millimeters(obj: bpy.types.Object) -> list[float]:
137
+ matrix = obj.matrix_world.copy()
138
+ for axis in range(3):
139
+ matrix[axis][3] *= MILLIMETERS_PER_METER
140
+ return [float(matrix[row][column]) for row in range(4) for column in range(4)]
141
+
142
+
143
+ def mesh_hash(mesh: bpy.types.Mesh) -> str:
144
+ digest = hashlib.sha256()
145
+ digest.update(struct.pack("<II", len(mesh.vertices), len(mesh.polygons)))
146
+ for vertex in mesh.vertices:
147
+ digest.update(struct.pack("<ddd", *vertex.co))
148
+ for polygon in mesh.polygons:
149
+ digest.update(struct.pack("<I", len(polygon.vertices)))
150
+ for vertex_index in polygon.vertices:
151
+ digest.update(struct.pack("<I", vertex_index))
152
+ return digest.hexdigest()
153
+
154
+
155
+ def material_summary(obj: bpy.types.Object) -> tuple[dict[str, list[str]], int]:
156
+ names: list[str] = []
157
+ missing_textures: list[str] = []
158
+ texture_count = 0
159
+ for slot in obj.material_slots:
160
+ material = slot.material
161
+ if material is None:
162
+ continue
163
+ names.append(material.name)
164
+ if material.node_tree is None:
165
+ continue
166
+ for node in material.node_tree.nodes:
167
+ if node.type != "TEX_IMAGE":
168
+ continue
169
+ texture_count += 1
170
+ image = node.image
171
+ if image is None:
172
+ missing_textures.append(f"{material.name}:{node.name}")
173
+ continue
174
+ if image.packed_file is not None or not image.filepath:
175
+ continue
176
+ resolved = Path(bpy.path.abspath(image.filepath))
177
+ if not resolved.exists():
178
+ missing_textures.append(str(resolved))
179
+ return (
180
+ {
181
+ "names": sorted(set(names)),
182
+ "missing_textures": sorted(set(missing_textures)),
183
+ },
184
+ texture_count,
185
+ )
186
+
187
+
188
+ def quaternion_xyzw(obj: bpy.types.Object) -> list[float]:
189
+ quaternion = obj.matrix_world.to_quaternion().normalized()
190
+ return [quaternion.x, quaternion.y, quaternion.z, quaternion.w]
191
+
192
+
193
+ def imported_camera(
194
+ root_bounds: dict[str, list[float]],
195
+ ) -> tuple[dict[str, Any], dict[str, Any] | None]:
196
+ cameras = sorted(
197
+ (obj for obj in bpy.context.scene.objects if obj.type == "CAMERA"), key=lambda obj: obj.name
198
+ )
199
+ if cameras:
200
+ active_camera = bpy.context.scene.camera
201
+ camera = active_camera if active_camera in cameras else cameras[0]
202
+ data = camera.data
203
+ projection: dict[str, Any]
204
+ if data.type == "ORTHO":
205
+ projection = {
206
+ "mode": "orthographic",
207
+ "scale_mm": data.ortho_scale * MILLIMETERS_PER_METER,
208
+ "near_clip_mm": data.clip_start * MILLIMETERS_PER_METER,
209
+ "far_clip_mm": data.clip_end * MILLIMETERS_PER_METER,
210
+ }
211
+ else:
212
+ projection = {
213
+ "mode": "perspective",
214
+ "focal_length_mm": data.lens,
215
+ "sensor_width_mm": data.sensor_width,
216
+ "sensor_height_mm": data.sensor_height,
217
+ "sensor_fit": data.sensor_fit.lower(),
218
+ "near_clip_mm": data.clip_start * MILLIMETERS_PER_METER,
219
+ "far_clip_mm": data.clip_end * MILLIMETERS_PER_METER,
220
+ }
221
+ return (
222
+ {
223
+ "pose": {
224
+ "position_mm": [
225
+ value * MILLIMETERS_PER_METER for value in camera.matrix_world.translation
226
+ ],
227
+ "orientation_xyzw": quaternion_xyzw(camera),
228
+ },
229
+ "projection": projection,
230
+ },
231
+ None,
232
+ )
233
+
234
+ minimum = root_bounds["minimum_mm"]
235
+ maximum = root_bounds["maximum_mm"]
236
+ center = [(low + high) / 2 for low, high in zip(minimum, maximum, strict=True)]
237
+ span = max(high - low for low, high in zip(minimum, maximum, strict=True))
238
+ distance = max(span * 2, 100.0)
239
+ position = Vector((center[0] + distance, center[1] + distance, center[2] + distance))
240
+ direction = Vector(center) - position
241
+ quaternion = direction.to_track_quat("-Z", "Y").normalized()
242
+ return (
243
+ {
244
+ "pose": {
245
+ "position_mm": list(position),
246
+ "orientation_xyzw": [quaternion.x, quaternion.y, quaternion.z, quaternion.w],
247
+ },
248
+ "projection": {
249
+ "mode": "perspective",
250
+ "focal_length_mm": 50.0,
251
+ "sensor_width_mm": 36.0,
252
+ "sensor_height_mm": 24.0,
253
+ "sensor_fit": "auto",
254
+ "near_clip_mm": 0.5,
255
+ "far_clip_mm": max(100_000.0, distance * 4.0 + span * 2.0),
256
+ },
257
+ },
258
+ {
259
+ "code": "camera.generated",
260
+ "message": (
261
+ "The source had no camera; MeshProbe created a deterministic inspection camera."
262
+ ),
263
+ "component_ids": [],
264
+ },
265
+ )
266
+
267
+
268
+ def light_color(data: bpy.types.Light) -> dict[str, list[float]]:
269
+ return {"linear_rgb": [float(channel) for channel in data.color]}
270
+
271
+
272
+ def imported_illumination() -> tuple[dict[str, Any], list[dict[str, Any]]]:
273
+ lights = sorted(
274
+ (obj for obj in bpy.context.scene.objects if obj.type == "LIGHT"), key=lambda obj: obj.name
275
+ )
276
+ if not lights:
277
+ return (
278
+ {"preset": "neutral_studio"},
279
+ [
280
+ {
281
+ "code": "illumination.generated",
282
+ "message": "The source had no lights; MeshProbe selected neutral_studio.",
283
+ "component_ids": [],
284
+ }
285
+ ],
286
+ )
287
+
288
+ converted: list[dict[str, Any]] = []
289
+ for obj in lights:
290
+ data = obj.data
291
+ if data.energy <= 0 or not any(data.color):
292
+ continue
293
+ base: dict[str, Any] = {"id": obj.name, **light_color(data)}
294
+ if data.type == "AREA":
295
+ converted.append(
296
+ {
297
+ **base,
298
+ "type": "area",
299
+ "position_mm": [
300
+ value * MILLIMETERS_PER_METER for value in obj.matrix_world.translation
301
+ ],
302
+ "orientation_xyzw": quaternion_xyzw(obj),
303
+ "power_w": data.energy,
304
+ "size_mm": data.size * MILLIMETERS_PER_METER,
305
+ }
306
+ )
307
+ continue
308
+ if data.type == "POINT":
309
+ converted.append(
310
+ {
311
+ **base,
312
+ "type": "point",
313
+ "position_mm": [
314
+ value * MILLIMETERS_PER_METER for value in obj.matrix_world.translation
315
+ ],
316
+ "power_w": data.energy,
317
+ }
318
+ )
319
+ continue
320
+ if data.type == "SPOT":
321
+ converted.append(
322
+ {
323
+ **base,
324
+ "type": "spot",
325
+ "position_mm": [
326
+ value * MILLIMETERS_PER_METER for value in obj.matrix_world.translation
327
+ ],
328
+ "orientation_xyzw": quaternion_xyzw(obj),
329
+ "power_w": data.energy,
330
+ "spot_size_degrees": math.degrees(data.spot_size),
331
+ "blend": data.spot_blend,
332
+ }
333
+ )
334
+ continue
335
+ if data.type == "SUN":
336
+ converted.append(
337
+ {
338
+ **base,
339
+ "type": "sun",
340
+ "orientation_xyzw": quaternion_xyzw(obj),
341
+ "strength": data.energy,
342
+ "angle_degrees": math.degrees(data.angle),
343
+ }
344
+ )
345
+
346
+ if not converted:
347
+ return (
348
+ {"preset": "neutral_studio"},
349
+ [
350
+ {
351
+ "code": "illumination.generated",
352
+ "message": (
353
+ "The source had no positive-energy supported lights; MeshProbe selected "
354
+ "neutral_studio."
355
+ ),
356
+ "component_ids": [],
357
+ }
358
+ ],
359
+ )
360
+
361
+ world_color = bpy.context.scene.world.color if bpy.context.scene.world else (0.0, 0.0, 0.0)
362
+ return (
363
+ {
364
+ "preset": "custom",
365
+ "background_rgb": [float(channel) for channel in world_color],
366
+ "ambient_strength": 0.0,
367
+ "lights": converted,
368
+ },
369
+ [],
370
+ )
371
+
372
+
373
+ def require_session() -> dict[str, Any]:
374
+ if MANIFEST is None:
375
+ raise ValueError("no scene is open")
376
+ return MANIFEST
377
+
378
+
379
+ def camera_object() -> bpy.types.Object:
380
+ cameras = sorted(
381
+ (obj for obj in bpy.context.scene.objects if obj.type == "CAMERA"), key=lambda obj: obj.name
382
+ )
383
+ if cameras:
384
+ active_camera = bpy.context.scene.camera
385
+ camera = active_camera if active_camera in cameras else cameras[0]
386
+ bpy.context.scene.camera = camera
387
+ return camera
388
+ data = bpy.data.cameras.new("MeshProbeCamera")
389
+ camera = bpy.data.objects.new("MeshProbeCamera", data)
390
+ bpy.context.scene.collection.objects.link(camera)
391
+ bpy.context.scene.camera = camera
392
+ return camera
393
+
394
+
395
+ def apply_camera(
396
+ camera: dict[str, Any],
397
+ focus_component_ids: list[str] | tuple[str, ...] = (),
398
+ aspect_ratio: float = 1.0,
399
+ target_mm: list[float] | tuple[float, float, float] | None = None,
400
+ ) -> dict[str, Any]:
401
+ obj = camera_object()
402
+ pose = camera["pose"]
403
+ position = Vector(value / MILLIMETERS_PER_METER for value in pose["position_mm"])
404
+ x, y, z, w = pose["orientation_xyzw"]
405
+ rotation = Quaternion((w, x, y, z)).normalized()
406
+ obj.matrix_world = Matrix.Translation(position) @ rotation.to_matrix().to_4x4()
407
+ projection = camera["projection"]
408
+ data = obj.data
409
+ data.clip_start = projection["near_clip_mm"] / MILLIMETERS_PER_METER
410
+ data.clip_end = projection["far_clip_mm"] / MILLIMETERS_PER_METER
411
+ mode = projection["mode"]
412
+ if mode not in {"orthographic", "perspective"}:
413
+ raise ValueError(f"unknown projection mode: {mode}")
414
+ if mode == "orthographic":
415
+ data.type = "ORTHO"
416
+ data.ortho_scale = projection["scale_mm"] / MILLIMETERS_PER_METER
417
+ else:
418
+ data.type = "PERSP"
419
+ data.lens = projection["focal_length_mm"]
420
+ data.sensor_width = projection["sensor_width_mm"]
421
+ data.sensor_height = projection.get("sensor_height_mm", 24.0)
422
+ sensor_fit = projection["sensor_fit"]
423
+ if sensor_fit not in {"auto", "horizontal", "vertical"}:
424
+ raise ValueError(f"unknown sensor fit: {sensor_fit}")
425
+ data.sensor_fit = sensor_fit.upper()
426
+ global CURRENT_CAMERA, CURRENT_CAMERA_DIAGNOSTICS
427
+ CURRENT_CAMERA = deepcopy(camera)
428
+ CURRENT_CAMERA_DIAGNOSTICS = camera_diagnostics(
429
+ obj,
430
+ focus_component_ids,
431
+ aspect_ratio,
432
+ target_mm,
433
+ )
434
+ orient_component_labels()
435
+ return session_snapshot()
436
+
437
+
438
+ def orbit_camera(command: dict[str, Any]) -> dict[str, Any]:
439
+ target = Vector(value / MILLIMETERS_PER_METER for value in command["target_mm"])
440
+ azimuth = math.radians(command["azimuth_degrees"])
441
+ elevation = math.radians(command["elevation_degrees"])
442
+ distance = command["distance_mm"] / MILLIMETERS_PER_METER
443
+ offset = Vector(
444
+ (
445
+ distance * math.cos(elevation) * math.cos(azimuth),
446
+ distance * math.cos(elevation) * math.sin(azimuth),
447
+ distance * math.sin(elevation),
448
+ )
449
+ )
450
+ position = target + offset
451
+ direction = target - position
452
+ if direction.length <= 1e-12:
453
+ raise ValueError("orbit camera direction is degenerate")
454
+ rotation = direction.to_track_quat("-Z", "Y").normalized()
455
+ roll = math.radians(command.get("roll_degrees", 0.0))
456
+ if roll:
457
+ rotation = Quaternion(direction.normalized(), roll) @ rotation
458
+ camera = {
459
+ "pose": {
460
+ "position_mm": [value * MILLIMETERS_PER_METER for value in position],
461
+ "orientation_xyzw": [rotation.x, rotation.y, rotation.z, rotation.w],
462
+ },
463
+ "projection": command["projection"],
464
+ }
465
+ return apply_camera(
466
+ camera,
467
+ command.get("focus_component_ids", ()),
468
+ command.get("aspect_ratio", 1.0),
469
+ command["target_mm"],
470
+ )
471
+
472
+
473
+ def camera_diagnostics(
474
+ camera: bpy.types.Object,
475
+ focus_component_ids: list[str] | tuple[str, ...],
476
+ aspect_ratio: float,
477
+ target_mm: list[float] | tuple[float, float, float] | None,
478
+ ) -> dict[str, Any]:
479
+ focus_ids = set(focus_component_ids)
480
+ unknown = focus_ids - COMPONENT_OBJECTS.keys()
481
+ if unknown:
482
+ raise ValueError(f"unknown focus component ids: {sorted(unknown)}")
483
+ if not math.isfinite(aspect_ratio) or not 0.01 <= aspect_ratio <= 100:
484
+ raise ValueError("aspect_ratio must be between 0.01 and 100")
485
+
486
+ matrix = camera.matrix_world
487
+ right = matrix.to_3x3().col[0].normalized()
488
+ up = matrix.to_3x3().col[1].normalized()
489
+ forward = -matrix.to_3x3().col[2].normalized()
490
+ y_resolution = 1_000
491
+ x_resolution = max(1, round(y_resolution * aspect_ratio))
492
+ projection = camera.calc_matrix_camera(
493
+ bpy.context.evaluated_depsgraph_get(),
494
+ x=x_resolution,
495
+ y=y_resolution,
496
+ scale_x=1.0,
497
+ scale_y=1.0,
498
+ )
499
+ inverse_projection = projection.inverted()
500
+ frustum: list[list[float]] = []
501
+ for clip_z in (-1.0, 1.0):
502
+ for clip_y in (-1.0, 1.0):
503
+ for clip_x in (-1.0, 1.0):
504
+ local_homogeneous = inverse_projection @ Vector((clip_x, clip_y, clip_z, 1.0))
505
+ local = local_homogeneous.xyz / local_homogeneous.w
506
+ world = matrix @ local
507
+ frustum.append([value * MILLIMETERS_PER_METER for value in world])
508
+
509
+ if target_mm is None:
510
+ target_mm = focus_center_mm(focus_ids) if focus_ids else scene_center_mm()
511
+ target_world = Vector(value / MILLIMETERS_PER_METER for value in target_mm)
512
+ target_camera = matrix.inverted() @ target_world
513
+ projected = {
514
+ component_id: projected_component_bounds(
515
+ camera,
516
+ projection,
517
+ COMPONENT_OBJECTS[component_id],
518
+ )
519
+ for component_id in sorted(focus_ids)
520
+ }
521
+ horizontal_fov, vertical_fov = camera_fov_degrees(camera.data, aspect_ratio)
522
+ return {
523
+ "aspect_ratio": aspect_ratio,
524
+ "horizontal_fov_degrees": horizontal_fov,
525
+ "vertical_fov_degrees": vertical_fov,
526
+ "right": list(right),
527
+ "up": list(up),
528
+ "forward": list(forward),
529
+ "frustum_corners_mm": frustum,
530
+ "target_depth_mm": -target_camera.z * MILLIMETERS_PER_METER,
531
+ "projected_bounds": projected,
532
+ }
533
+
534
+
535
+ def camera_fov_degrees(
536
+ camera: bpy.types.Camera,
537
+ aspect_ratio: float,
538
+ ) -> tuple[float | None, float | None]:
539
+ if camera.type == "ORTHO":
540
+ return None, None
541
+ sensor_fit = camera.sensor_fit
542
+ if sensor_fit == "AUTO":
543
+ sensor_fit = "HORIZONTAL" if aspect_ratio >= 1 else "VERTICAL"
544
+ sensor_width = camera.sensor_width
545
+ sensor_height = camera.sensor_height
546
+ if sensor_fit == "VERTICAL":
547
+ sensor_width = sensor_height * aspect_ratio
548
+ else:
549
+ sensor_height = sensor_width / aspect_ratio
550
+ horizontal = math.degrees(2 * math.atan(sensor_width / (2 * camera.lens)))
551
+ vertical = math.degrees(2 * math.atan(sensor_height / (2 * camera.lens)))
552
+ return horizontal, vertical
553
+
554
+
555
+ def scene_center_mm() -> list[float]:
556
+ manifest = require_session()
557
+ minimum = manifest["root_bounds"]["minimum_mm"]
558
+ maximum = manifest["root_bounds"]["maximum_mm"]
559
+ return [(low + high) / 2 for low, high in zip(minimum, maximum, strict=True)]
560
+
561
+
562
+ def focus_center_mm(focus_ids: set[str]) -> list[float]:
563
+ components = {component["id"]: component for component in require_session()["components"]}
564
+ minimum = [
565
+ min(
566
+ components[component_id]["world_bounds"]["minimum_mm"][axis]
567
+ for component_id in focus_ids
568
+ )
569
+ for axis in range(3)
570
+ ]
571
+ maximum = [
572
+ max(
573
+ components[component_id]["world_bounds"]["maximum_mm"][axis]
574
+ for component_id in focus_ids
575
+ )
576
+ for axis in range(3)
577
+ ]
578
+ return [(low + high) / 2 for low, high in zip(minimum, maximum, strict=True)]
579
+
580
+
581
+ def projected_component_bounds(
582
+ camera: bpy.types.Object,
583
+ projection: Matrix,
584
+ obj: bpy.types.Object,
585
+ ) -> dict[str, Any]:
586
+ world_to_camera = camera.matrix_world.inverted()
587
+ points = [world_to_camera @ (obj.matrix_world @ Vector(corner)) for corner in obj.bound_box]
588
+ depths = [-point.z * MILLIMETERS_PER_METER for point in points]
589
+ in_front = [depth > 0 for depth in depths]
590
+ if any(in_front) and not all(in_front):
591
+ return {
592
+ "projection_status": "crosses_camera_plane",
593
+ "minimum_image_xy": None,
594
+ "maximum_image_xy": None,
595
+ "minimum_depth_mm": min(depths),
596
+ "maximum_depth_mm": max(depths),
597
+ }
598
+ image_points: list[tuple[float, float]] = []
599
+ for point in points:
600
+ clip = projection @ point.to_4d()
601
+ if abs(clip.w) <= 1e-12:
602
+ return {
603
+ "projection_status": "crosses_camera_plane",
604
+ "minimum_image_xy": None,
605
+ "maximum_image_xy": None,
606
+ "minimum_depth_mm": min(depths),
607
+ "maximum_depth_mm": max(depths),
608
+ }
609
+ image_points.append(((clip.x / clip.w + 1) / 2, (clip.y / clip.w + 1) / 2))
610
+ return {
611
+ "projection_status": "in_front" if all(in_front) else "behind",
612
+ "minimum_image_xy": [
613
+ min(point[0] for point in image_points),
614
+ min(point[1] for point in image_points),
615
+ ],
616
+ "maximum_image_xy": [
617
+ max(point[0] for point in image_points),
618
+ max(point[1] for point in image_points),
619
+ ],
620
+ "minimum_depth_mm": min(depths),
621
+ "maximum_depth_mm": max(depths),
622
+ }
623
+
624
+
625
+ def linear_rgb_from_temperature(kelvin: float) -> list[float]:
626
+ temperature = kelvin / 100.0
627
+ if temperature <= 66:
628
+ red = 255.0
629
+ green = 99.4708025861 * math.log(temperature) - 161.1195681661
630
+ blue = (
631
+ 0.0
632
+ if temperature <= 19
633
+ else 138.5177312231 * math.log(temperature - 10) - 305.0447927307
634
+ )
635
+ else:
636
+ red = 329.698727446 * ((temperature - 60) ** -0.1332047592)
637
+ green = 288.1221695283 * ((temperature - 60) ** -0.0755148492)
638
+ blue = 255.0
639
+
640
+ def to_linear(channel: float) -> float:
641
+ srgb = min(255.0, max(0.0, channel)) / 255.0
642
+ if srgb <= 0.04045:
643
+ return srgb / 12.92
644
+ return float(((srgb + 0.055) / 1.055) ** 2.4)
645
+
646
+ return [to_linear(red), to_linear(green), to_linear(blue)]
647
+
648
+
649
+ def light_rgb(spec: dict[str, Any]) -> list[float]:
650
+ if spec.get("linear_rgb") is not None:
651
+ return list(spec["linear_rgb"])
652
+ return linear_rgb_from_temperature(spec["color_temperature_k"])
653
+
654
+
655
+ def clear_lights() -> None:
656
+ for obj in list(bpy.context.scene.objects):
657
+ if obj.type != "LIGHT":
658
+ continue
659
+ data = obj.data
660
+ bpy.data.objects.remove(obj, do_unlink=True)
661
+ if data.users == 0:
662
+ bpy.data.lights.remove(data)
663
+
664
+
665
+ def scene_center_and_span() -> tuple[Vector, float]:
666
+ manifest = require_session()
667
+ minimum = manifest["root_bounds"]["minimum_mm"]
668
+ maximum = manifest["root_bounds"]["maximum_mm"]
669
+ center = Vector((low + high) / 2 for low, high in zip(minimum, maximum, strict=True))
670
+ span = max(high - low for low, high in zip(minimum, maximum, strict=True))
671
+ return center, max(span, 100.0)
672
+
673
+
674
+ def scene_frame() -> tuple[Vector, Vector, Vector]:
675
+ manifest = require_session()
676
+ roots = [component for component in manifest["components"] if component["parent_id"] is None]
677
+ if len(roots) != 1:
678
+ return Vector((1, 0, 0)), Vector((0, 1, 0)), Vector((0, 0, 1))
679
+ transform = roots[0]["world_transform"]
680
+ axes = tuple(
681
+ Vector((transform[index], transform[4 + index], transform[8 + index])).normalized()
682
+ for index in range(3)
683
+ )
684
+ if any(axis.length_squared == 0 for axis in axes):
685
+ return Vector((1, 0, 0)), Vector((0, 1, 0)), Vector((0, 0, 1))
686
+ return axes
687
+
688
+
689
+ def frame_offset(
690
+ frame: tuple[Vector, Vector, Vector], values: tuple[float, float, float]
691
+ ) -> Vector:
692
+ return sum((axis * value for axis, value in zip(frame, values, strict=True)), Vector())
693
+
694
+
695
+ def look_orientation(position_mm: Vector, target_mm: Vector) -> list[float]:
696
+ rotation = (target_mm - position_mm).to_track_quat("-Z", "Y").normalized()
697
+ return [rotation.x, rotation.y, rotation.z, rotation.w]
698
+
699
+
700
+ def preset_lights(preset: str) -> dict[str, Any]:
701
+ center, span = scene_center_and_span()
702
+ frame = scene_frame()
703
+ power_scale = (span / PRESET_REFERENCE_SPAN_MM) ** 2
704
+ background = [0.03, 0.03, 0.03]
705
+ ambient = 0.15
706
+ definitions: list[tuple[str, Vector, float, float]] = []
707
+ if preset == "neutral_studio":
708
+ definitions = [
709
+ ("key", center + frame_offset(frame, (span, -span, span)), 900.0, span),
710
+ (
711
+ "fill",
712
+ center + frame_offset(frame, (-span, -span / 2, span / 2)),
713
+ 450.0,
714
+ span,
715
+ ),
716
+ ("rim", center + frame_offset(frame, (0, span, span)), 600.0, span / 2),
717
+ ]
718
+ elif preset == "high_key":
719
+ background = [0.25, 0.25, 0.25]
720
+ ambient = 0.45
721
+ definitions = [
722
+ (
723
+ "key",
724
+ center + frame_offset(frame, (span, -span, span)),
725
+ 1_200.0,
726
+ span * 1.5,
727
+ ),
728
+ (
729
+ "fill",
730
+ center + frame_offset(frame, (-span, -span, span)),
731
+ 1_000.0,
732
+ span * 1.5,
733
+ ),
734
+ (
735
+ "under",
736
+ center + frame_offset(frame, (0, 0, -span)),
737
+ 700.0,
738
+ span * 1.5,
739
+ ),
740
+ ]
741
+ elif preset in {"raking_left", "raking_right"}:
742
+ side = -1 if preset == "raking_left" else 1
743
+ definitions = [
744
+ (
745
+ "rake",
746
+ center + frame_offset(frame, (side * span * 2, -span / 4, span / 8)),
747
+ 1_000.0,
748
+ span / 3,
749
+ )
750
+ ]
751
+ elif preset == "backlit":
752
+ definitions = [("back", center + frame_offset(frame, (0, span * 2, 0)), 1_200.0, span)]
753
+ elif preset == "flat_diagnostic":
754
+ background = [0.18, 0.18, 0.18]
755
+ ambient = 0.25
756
+ definitions = [
757
+ (
758
+ f"axis-{index}",
759
+ center + frame_offset(frame, tuple(direction * span)),
760
+ 500.0,
761
+ span * 2,
762
+ )
763
+ for index, direction in enumerate(
764
+ (
765
+ Vector((1, 0, 0)),
766
+ Vector((-1, 0, 0)),
767
+ Vector((0, 1, 0)),
768
+ Vector((0, -1, 0)),
769
+ Vector((0, 0, 1)),
770
+ Vector((0, 0, -1)),
771
+ ),
772
+ start=1,
773
+ )
774
+ ]
775
+ else:
776
+ raise ValueError(f"unknown illumination preset: {preset}")
777
+ lights = [
778
+ {
779
+ "id": light_id,
780
+ "type": "area",
781
+ "position_mm": list(position),
782
+ "orientation_xyzw": look_orientation(position, center),
783
+ "power_w": power * power_scale,
784
+ "linear_rgb": [1.0, 1.0, 1.0],
785
+ "size_mm": max(size, 1.0),
786
+ }
787
+ for light_id, position, power, size in definitions
788
+ ]
789
+ return {
790
+ "preset": "custom",
791
+ "background_rgb": background,
792
+ "ambient_strength": ambient,
793
+ "lights": lights,
794
+ }
795
+
796
+
797
+ def configure_world(
798
+ background_rgb: list[float],
799
+ ambient_strength: float,
800
+ environment_map: dict[str, Any] | None,
801
+ ) -> None:
802
+ world = bpy.context.scene.world or bpy.data.worlds.new("MeshProbeWorld")
803
+ bpy.context.scene.world = world
804
+ world.use_nodes = True
805
+ nodes = world.node_tree.nodes
806
+ nodes.clear()
807
+ output = nodes.new("ShaderNodeOutputWorld")
808
+ ambient = nodes.new("ShaderNodeBackground")
809
+ ambient.name = "MeshProbeAmbient"
810
+ ambient.inputs["Color"].default_value = (*background_rgb, 1.0)
811
+ ambient.inputs["Strength"].default_value = ambient_strength
812
+ if environment_map is None:
813
+ world.node_tree.links.new(ambient.outputs["Background"], output.inputs["Surface"])
814
+ return
815
+
816
+ path = Path(environment_map["path"]).expanduser().resolve(strict=True)
817
+ if sha256_file(path) != environment_map["sha256"]:
818
+ raise ValueError("environment map hash does not match its declared content")
819
+ texture_coordinates = nodes.new("ShaderNodeTexCoord")
820
+ mapping = nodes.new("ShaderNodeMapping")
821
+ mapping.vector_type = "POINT"
822
+ mapping.inputs["Rotation"].default_value[2] = math.radians(environment_map["rotation_degrees"])
823
+ texture = nodes.new("ShaderNodeTexEnvironment")
824
+ texture.name = "MeshProbeEnvironment"
825
+ texture.projection = "EQUIRECTANGULAR"
826
+ image = bpy.data.images.load(str(path), check_existing=False)
827
+ image.name = f"MeshProbeEnvironment-{environment_map['sha256'][:16]}"
828
+ texture.image = image
829
+ environment = nodes.new("ShaderNodeBackground")
830
+ environment.name = "MeshProbeEnvironmentBackground"
831
+ environment.inputs["Strength"].default_value = environment_map["strength"]
832
+ add = nodes.new("ShaderNodeAddShader")
833
+ links = world.node_tree.links
834
+ links.new(texture_coordinates.outputs["Generated"], mapping.inputs["Vector"])
835
+ links.new(mapping.outputs["Vector"], texture.inputs["Vector"])
836
+ links.new(texture.outputs["Color"], environment.inputs["Color"])
837
+ links.new(ambient.outputs["Background"], add.inputs[0])
838
+ links.new(environment.outputs["Background"], add.inputs[1])
839
+ links.new(add.outputs["Shader"], output.inputs["Surface"])
840
+
841
+
842
+ def create_light(spec: dict[str, Any]) -> None:
843
+ blender_type = {"area": "AREA", "point": "POINT", "spot": "SPOT", "sun": "SUN"}[spec["type"]]
844
+ data = bpy.data.lights.new(f"MeshProbe-{spec['id']}", blender_type)
845
+ data.color = light_rgb(spec)
846
+ obj = bpy.data.objects.new(f"MeshProbe-{spec['id']}", data)
847
+ bpy.context.scene.collection.objects.link(obj)
848
+ if spec.get("position_mm") is not None:
849
+ obj.location = [value / MILLIMETERS_PER_METER for value in spec["position_mm"]]
850
+ if spec.get("orientation_xyzw") is not None:
851
+ x, y, z, w = spec["orientation_xyzw"]
852
+ obj.rotation_mode = "QUATERNION"
853
+ obj.rotation_quaternion = Quaternion((w, x, y, z)).normalized()
854
+ if spec["type"] == "area":
855
+ data.energy = spec["power_w"]
856
+ data.shape = "DISK"
857
+ data.size = spec["size_mm"] / MILLIMETERS_PER_METER
858
+ elif spec["type"] == "point":
859
+ data.energy = spec["power_w"]
860
+ elif spec["type"] == "spot":
861
+ data.energy = spec["power_w"]
862
+ data.spot_size = math.radians(spec["spot_size_degrees"])
863
+ data.spot_blend = spec["blend"]
864
+ else:
865
+ data.energy = spec["strength"]
866
+ data.angle = math.radians(spec["angle_degrees"])
867
+
868
+
869
+ def apply_illumination(illumination: dict[str, Any]) -> dict[str, Any]:
870
+ runtime = (
871
+ preset_lights(illumination["preset"])
872
+ if illumination["preset"] != "custom"
873
+ else illumination
874
+ )
875
+ clear_lights()
876
+ configure_world(
877
+ runtime["background_rgb"],
878
+ runtime["ambient_strength"],
879
+ runtime.get("environment_map"),
880
+ )
881
+ for light in runtime["lights"]:
882
+ create_light(light)
883
+ global CURRENT_ILLUMINATION
884
+ CURRENT_ILLUMINATION = deepcopy(illumination)
885
+ return session_snapshot()
886
+
887
+
888
+ def restore_mesh(component_id: str) -> None:
889
+ obj = COMPONENT_OBJECTS[component_id]
890
+ original = ORIGINAL_MESHES[component_id]
891
+ if obj.data is original:
892
+ return
893
+ replacement = obj.data
894
+ obj.data = original
895
+ if replacement.users == 0:
896
+ bpy.data.meshes.remove(replacement)
897
+
898
+
899
+ def replacement_material(name: str, color: tuple[float, float, float, float]) -> bpy.types.Material:
900
+ material = OVERRIDE_MATERIALS.get(name)
901
+ if material is None:
902
+ material = bpy.data.materials.new(name)
903
+ OVERRIDE_MATERIALS[name] = material
904
+ material.diffuse_color = color
905
+ material.use_nodes = True
906
+ principled = material.node_tree.nodes.get("Principled BSDF")
907
+ if principled is not None:
908
+ principled.inputs["Base Color"].default_value = color
909
+ principled.inputs["Alpha"].default_value = color[3]
910
+ if color[3] < 1 and hasattr(material, "surface_render_method"):
911
+ material.surface_render_method = "DITHERED"
912
+ return material
913
+
914
+
915
+ def replace_component_material(component_id: str, material: bpy.types.Material) -> None:
916
+ obj = COMPONENT_OBJECTS[component_id]
917
+ restore_mesh(component_id)
918
+ obj.data = obj.data.copy()
919
+ obj.data.materials.clear()
920
+ obj.data.materials.append(material)
921
+ for polygon in obj.data.polygons:
922
+ polygon.material_index = 0
923
+
924
+
925
+ def clear_component_label(component_id: str) -> None:
926
+ label = MARK_OBJECTS.pop(component_id, None)
927
+ if label is None:
928
+ return
929
+ data = label.data
930
+ bpy.data.objects.remove(label, do_unlink=True)
931
+ if data.users == 0:
932
+ bpy.data.curves.remove(data)
933
+
934
+
935
+ def create_component_label(component_id: str) -> None:
936
+ component = next(item for item in require_session()["components"] if item["id"] == component_id)
937
+ bounds = component["world_bounds"]
938
+ minimum = bounds["minimum_mm"]
939
+ maximum = bounds["maximum_mm"]
940
+ _, scene_span = scene_center_and_span()
941
+ data = bpy.data.curves.new(f"MeshProbeLabel-{component_id}", "FONT")
942
+ data.body = component["display_name"]
943
+ data.align_x = "CENTER"
944
+ data.align_y = "BOTTOM"
945
+ data.size = max(scene_span / MILLIMETERS_PER_METER / 30.0, 0.002)
946
+ data.extrude = data.size * 0.01
947
+ label = bpy.data.objects.new(f"MeshProbeLabel-{component_id}", data)
948
+ bpy.context.scene.collection.objects.link(label)
949
+ label.location = (
950
+ (minimum[0] + maximum[0]) / 2 / MILLIMETERS_PER_METER,
951
+ (minimum[1] + maximum[1]) / 2 / MILLIMETERS_PER_METER,
952
+ (maximum[2] + scene_span * 0.02) / MILLIMETERS_PER_METER,
953
+ )
954
+ label.rotation_mode = "QUATERNION"
955
+ label.rotation_quaternion = camera_object().matrix_world.to_quaternion()
956
+ data.materials.append(replacement_material("MeshProbeMark-labeled", (1.0, 0.85, 0.05, 1.0)))
957
+ MARK_OBJECTS[component_id] = label
958
+
959
+
960
+ def orient_component_labels() -> None:
961
+ orientation = camera_object().matrix_world.to_quaternion()
962
+ for label in MARK_OBJECTS.values():
963
+ label.rotation_quaternion = orientation
964
+
965
+
966
+ def refresh_component_appearance(component_id: str) -> None:
967
+ restore_mesh(component_id)
968
+ clear_component_label(component_id)
969
+ state = COMPONENT_STATES[component_id]
970
+ if state["display"] == "hidden":
971
+ return
972
+ mark = state["mark"]
973
+ if mark != "unmarked":
974
+ colors = {
975
+ "selected": (0.05, 0.8, 1.0, 1.0),
976
+ "highlighted": (1.0, 0.2, 0.02, 1.0),
977
+ "labeled": (1.0, 0.85, 0.05, 1.0),
978
+ }
979
+ replace_component_material(
980
+ component_id, replacement_material(f"MeshProbeMark-{mark}", colors[mark])
981
+ )
982
+ if mark == "labeled":
983
+ create_component_label(component_id)
984
+ return
985
+ if state["display"] == "ghosted":
986
+ replace_component_material(
987
+ component_id, replacement_material("MeshProbeGhost", (0.35, 0.65, 1.0, 0.2))
988
+ )
989
+
990
+
991
+ def set_component_visibility(component_id: str, mode: str) -> None:
992
+ obj = COMPONENT_OBJECTS[component_id]
993
+ hidden = mode == "hidden"
994
+ obj.hide_render = hidden
995
+ obj.hide_viewport = hidden
996
+ COMPONENT_STATES[component_id]["display"] = mode
997
+ refresh_component_appearance(component_id)
998
+
999
+
1000
+ def selected_component_ids(command: dict[str, Any]) -> set[str]:
1001
+ selected = set(command["component_ids"])
1002
+ if not selected:
1003
+ raise ValueError("at least one component id is required")
1004
+ unknown = selected - COMPONENT_OBJECTS.keys()
1005
+ if unknown:
1006
+ raise ValueError(f"unknown component ids: {sorted(unknown)}")
1007
+ return selected
1008
+
1009
+
1010
+ def component_display(command: dict[str, Any]) -> dict[str, Any]:
1011
+ selected = selected_component_ids(command)
1012
+ mode = command["mode"]
1013
+ if mode not in DISPLAY_MODES:
1014
+ raise ValueError(f"unknown display mode: {mode}")
1015
+ if mode == "isolated":
1016
+ for component_id in COMPONENT_OBJECTS:
1017
+ set_component_visibility(
1018
+ component_id, "isolated" if component_id in selected else "hidden"
1019
+ )
1020
+ return session_snapshot()
1021
+ for component_id in selected:
1022
+ set_component_visibility(component_id, mode)
1023
+ return session_snapshot()
1024
+
1025
+
1026
+ def component_mark(command: dict[str, Any]) -> dict[str, Any]:
1027
+ selected = selected_component_ids(command)
1028
+ mode = command["mode"]
1029
+ if mode not in MARK_MODES:
1030
+ raise ValueError(f"unknown mark mode: {mode}")
1031
+ for component_id in selected:
1032
+ COMPONENT_STATES[component_id]["mark"] = mode
1033
+ refresh_component_appearance(component_id)
1034
+ return session_snapshot()
1035
+
1036
+
1037
+ def session_snapshot() -> dict[str, Any]:
1038
+ if CURRENT_CAMERA is None or CURRENT_CAMERA_DIAGNOSTICS is None or CURRENT_ILLUMINATION is None:
1039
+ raise ValueError("session state is not initialized")
1040
+ state = {
1041
+ "camera": CURRENT_CAMERA,
1042
+ "illumination": CURRENT_ILLUMINATION,
1043
+ "components": {key: COMPONENT_STATES[key] for key in sorted(COMPONENT_STATES)},
1044
+ }
1045
+ hashed_state = deepcopy(state)
1046
+ environment = hashed_state["illumination"].get("environment_map")
1047
+ if environment is not None:
1048
+ environment.pop("path", None)
1049
+ canonical = json.dumps(hashed_state, sort_keys=True, separators=(",", ":")).encode()
1050
+ return {
1051
+ **deepcopy(state),
1052
+ "camera_diagnostics": deepcopy(CURRENT_CAMERA_DIAGNOSTICS),
1053
+ "state_sha256": hashlib.sha256(canonical).hexdigest(),
1054
+ }
1055
+
1056
+
1057
+ def reset_session() -> dict[str, Any]:
1058
+ if IMPORTED_CAMERA is None or IMPORTED_ILLUMINATION is None:
1059
+ raise ValueError("session state is not initialized")
1060
+ for component_id, obj in COMPONENT_OBJECTS.items():
1061
+ restore_mesh(component_id)
1062
+ clear_component_label(component_id)
1063
+ hide_render, hide_viewport = ORIGINAL_VISIBILITY[component_id]
1064
+ obj.hide_render = hide_render
1065
+ obj.hide_viewport = hide_viewport
1066
+ COMPONENT_STATES[component_id] = {
1067
+ "display": "hidden" if hide_render else "shown",
1068
+ "mark": "unmarked",
1069
+ }
1070
+ apply_camera(deepcopy(IMPORTED_CAMERA))
1071
+ apply_illumination(deepcopy(IMPORTED_ILLUMINATION))
1072
+ return session_snapshot()
1073
+
1074
+
1075
+ def runtime_diagnostics() -> dict[str, Any]:
1076
+ camera = camera_object()
1077
+ world_nodes = bpy.context.scene.world.node_tree.nodes
1078
+ environment = world_nodes.get("MeshProbeEnvironment")
1079
+ return {
1080
+ "components": {
1081
+ component_id: {
1082
+ "hide_render": obj.hide_render,
1083
+ "hide_viewport": obj.hide_viewport,
1084
+ "mesh_name": obj.data.name,
1085
+ "materials": [material.name for material in obj.data.materials],
1086
+ "material_colors": [
1087
+ list(material.diffuse_color) for material in obj.data.materials
1088
+ ],
1089
+ "label": MARK_OBJECTS[component_id].name if component_id in MARK_OBJECTS else None,
1090
+ "label_rotation_wxyz": (
1091
+ list(MARK_OBJECTS[component_id].rotation_quaternion)
1092
+ if component_id in MARK_OBJECTS
1093
+ else None
1094
+ ),
1095
+ }
1096
+ for component_id, obj in COMPONENT_OBJECTS.items()
1097
+ },
1098
+ "camera": {
1099
+ "type": camera.data.type,
1100
+ "lens": camera.data.lens,
1101
+ "sensor_fit": camera.data.sensor_fit,
1102
+ "ortho_scale_mm": camera.data.ortho_scale * MILLIMETERS_PER_METER,
1103
+ },
1104
+ "lights": sorted(obj.name for obj in bpy.context.scene.objects if obj.type == "LIGHT"),
1105
+ "environment_map": (
1106
+ {
1107
+ "path": environment.image.filepath,
1108
+ "projection": environment.projection,
1109
+ }
1110
+ if environment is not None and environment.image is not None
1111
+ else None
1112
+ ),
1113
+ "render": {
1114
+ "engine": bpy.context.scene.render.engine,
1115
+ "eevee_samples": eevee_render_samples(),
1116
+ },
1117
+ }
1118
+
1119
+
1120
+ def configure_render(command: dict[str, Any]) -> str:
1121
+ scene = bpy.context.scene
1122
+ engine = command["engine"]
1123
+ scene.render.engine = eevee_engine() if engine == "eevee" else "CYCLES"
1124
+ scene.render.resolution_x = command["width"]
1125
+ scene.render.resolution_y = command["height"]
1126
+ scene.render.resolution_percentage = 100
1127
+ scene.render.film_transparent = False
1128
+ scene.render.image_settings.color_mode = "RGBA"
1129
+ scene.render.image_settings.color_depth = "8"
1130
+ scene.render.image_settings.file_format = "PNG"
1131
+ scene.render.use_file_extension = False
1132
+ if engine == "cycles":
1133
+ configure_cycles_gpu(command["samples"])
1134
+ return "cuda"
1135
+ configure_eevee_samples(command["samples"])
1136
+ return "graphics"
1137
+
1138
+
1139
+ def eevee_engine() -> str:
1140
+ identifiers = {
1141
+ item.identifier for item in bpy.context.scene.render.bl_rna.properties["engine"].enum_items
1142
+ }
1143
+ if "BLENDER_EEVEE" in identifiers:
1144
+ return "BLENDER_EEVEE"
1145
+ if "BLENDER_EEVEE_NEXT" in identifiers:
1146
+ return "BLENDER_EEVEE_NEXT"
1147
+ raise RuntimeError(f"Blender exposes no Eevee render engine: {sorted(identifiers)}")
1148
+
1149
+
1150
+ def configure_eevee_samples(samples: int) -> None:
1151
+ settings = bpy.context.scene.eevee
1152
+ if hasattr(settings, "taa_render_samples"):
1153
+ settings.taa_render_samples = samples
1154
+ return
1155
+ if hasattr(settings, "taa_samples"):
1156
+ settings.taa_samples = samples
1157
+ return
1158
+ raise RuntimeError("Blender exposes no supported Eevee render sample setting")
1159
+
1160
+
1161
+ def eevee_render_samples() -> int:
1162
+ settings = bpy.context.scene.eevee
1163
+ if hasattr(settings, "taa_render_samples"):
1164
+ return int(settings.taa_render_samples)
1165
+ if hasattr(settings, "taa_samples"):
1166
+ return int(settings.taa_samples)
1167
+ raise RuntimeError("Blender exposes no supported Eevee render sample setting")
1168
+
1169
+
1170
+ def configure_cycles_gpu(samples: int) -> None:
1171
+ scene = bpy.context.scene
1172
+ preferences = bpy.context.preferences.addons["cycles"].preferences
1173
+ try:
1174
+ preferences.compute_device_type = "CUDA"
1175
+ preferences.get_devices()
1176
+ except Exception as error:
1177
+ raise RuntimeError(f"CUDA initialization failed: {error}") from error
1178
+ devices = [device for device in preferences.devices if device.type == "CUDA"]
1179
+ if not devices:
1180
+ raise RuntimeError("Cycles CUDA rendering requested but no CUDA device is available")
1181
+ for device in preferences.devices:
1182
+ device.use = device in devices
1183
+ scene.cycles.device = "GPU"
1184
+ scene.cycles.samples = samples
1185
+ scene.cycles.use_denoising = True
1186
+
1187
+
1188
+ def render_still(path: Path) -> None:
1189
+ path.parent.mkdir(parents=True, exist_ok=True)
1190
+ bpy.context.scene.render.filepath = str(path)
1191
+ bpy.ops.render.render(write_still=True)
1192
+ if not path.is_file():
1193
+ raise RuntimeError(f"Blender did not publish render output: {path}")
1194
+
1195
+
1196
+ def luminance_summary(path: Path) -> dict[str, float]:
1197
+ image = bpy.data.images.load(str(path), check_existing=False)
1198
+ try:
1199
+ if image.size[0] == 0 or image.size[1] == 0:
1200
+ raise RuntimeError("render result has no pixels")
1201
+ width, height = image.size
1202
+ x_stride = max(1, math.ceil(width / 512))
1203
+ y_stride = max(1, math.ceil(height / 512))
1204
+ values: list[float] = []
1205
+ for y in range(0, height, y_stride):
1206
+ row_start = y * width * 4
1207
+ row = image.pixels[row_start : row_start + width * 4]
1208
+ for x in range(0, width, x_stride):
1209
+ offset = x * 4
1210
+ value = 0.2126 * row[offset] + 0.7152 * row[offset + 1] + 0.0722 * row[offset + 2]
1211
+ values.append(min(1.0, max(0.0, value)))
1212
+ return {
1213
+ "minimum": min(values),
1214
+ "median": median(values),
1215
+ "maximum": max(values),
1216
+ "crushed_fraction": sum(value <= 0.01 for value in values) / len(values),
1217
+ "clipped_fraction": sum(value >= 0.99 for value in values) / len(values),
1218
+ }
1219
+ finally:
1220
+ bpy.data.images.remove(image)
1221
+
1222
+
1223
+ def emission_material(name: str, color: tuple[float, float, float, float]) -> bpy.types.Material:
1224
+ material = EMISSION_MATERIALS.get(name)
1225
+ if material is None:
1226
+ material = bpy.data.materials.new(name)
1227
+ EMISSION_MATERIALS[name] = material
1228
+ material.use_nodes = True
1229
+ nodes = material.node_tree.nodes
1230
+ nodes.clear()
1231
+ output = nodes.new("ShaderNodeOutputMaterial")
1232
+ emission = nodes.new("ShaderNodeEmission")
1233
+ emission.inputs["Color"].default_value = color
1234
+ emission.inputs["Strength"].default_value = 1.0
1235
+ material.node_tree.links.new(emission.outputs["Emission"], output.inputs["Surface"])
1236
+ return material
1237
+
1238
+
1239
+ def srgb_channel_to_linear(channel: int) -> float:
1240
+ value = channel / 255.0
1241
+ if value <= 0.04045:
1242
+ return value / 12.92
1243
+ return float(((value + 0.055) / 1.055) ** 2.4)
1244
+
1245
+
1246
+ def component_pass_colors() -> dict[str, tuple[int, int, int]]:
1247
+ colors: dict[str, tuple[int, int, int]] = {}
1248
+ used: set[tuple[int, int, int]] = set()
1249
+ for component_id in sorted(COMPONENT_OBJECTS):
1250
+ seed = hashlib.sha256(component_id.encode()).digest()
1251
+ color = (max(seed[0], 16), max(seed[1], 16), max(seed[2], 16))
1252
+ counter = 0
1253
+ while color in used:
1254
+ counter += 1
1255
+ seed = hashlib.sha256(f"{component_id}:{counter}".encode()).digest()
1256
+ color = (max(seed[0], 16), max(seed[1], 16), max(seed[2], 16))
1257
+ colors[component_id] = color
1258
+ used.add(color)
1259
+ return colors
1260
+
1261
+
1262
+ def render_mask(path: Path, colors: dict[str, tuple[int, int, int]]) -> None:
1263
+ scene = bpy.context.scene
1264
+ original_meshes = {component_id: obj.data for component_id, obj in COMPONENT_OBJECTS.items()}
1265
+ other_visibility = {
1266
+ obj: obj.hide_render for obj in scene.objects if obj.type not in {"MESH", "CAMERA"}
1267
+ }
1268
+ original_world = scene.world
1269
+ mask_world = bpy.data.worlds.get("MeshProbeMaskWorld") or bpy.data.worlds.new(
1270
+ "MeshProbeMaskWorld"
1271
+ )
1272
+ mask_world.use_nodes = True
1273
+ background = mask_world.node_tree.nodes.get("Background")
1274
+ background.inputs["Color"].default_value = (0.0, 0.0, 0.0, 1.0)
1275
+ background.inputs["Strength"].default_value = 0.0
1276
+ scene.world = mask_world
1277
+ original_transform = scene.view_settings.view_transform
1278
+ original_look = scene.view_settings.look
1279
+ original_exposure = scene.view_settings.exposure
1280
+ original_gamma = scene.view_settings.gamma
1281
+ original_dither = scene.render.dither_intensity
1282
+ scene.view_settings.view_transform = "Standard"
1283
+ scene.view_settings.look = "None"
1284
+ scene.view_settings.exposure = 0.0
1285
+ scene.view_settings.gamma = 1.0
1286
+ scene.render.dither_intensity = 0.0
1287
+ scene.render.engine = eevee_engine()
1288
+ try:
1289
+ for obj in other_visibility:
1290
+ obj.hide_render = True
1291
+ for component_id, obj in COMPONENT_OBJECTS.items():
1292
+ mesh = obj.data.copy()
1293
+ mesh.materials.clear()
1294
+ red, green, blue = colors[component_id]
1295
+ mesh.materials.append(
1296
+ emission_material(
1297
+ f"MeshProbeMask-{red:02x}{green:02x}{blue:02x}",
1298
+ (
1299
+ srgb_channel_to_linear(red),
1300
+ srgb_channel_to_linear(green),
1301
+ srgb_channel_to_linear(blue),
1302
+ 1.0,
1303
+ ),
1304
+ )
1305
+ )
1306
+ for polygon in mesh.polygons:
1307
+ polygon.material_index = 0
1308
+ obj.data = mesh
1309
+ render_still(path)
1310
+ finally:
1311
+ for component_id, obj in COMPONENT_OBJECTS.items():
1312
+ replacement = obj.data
1313
+ obj.data = original_meshes[component_id]
1314
+ if replacement.users == 0:
1315
+ bpy.data.meshes.remove(replacement)
1316
+ for obj, hide_render in other_visibility.items():
1317
+ obj.hide_render = hide_render
1318
+ scene.world = original_world
1319
+ scene.view_settings.view_transform = original_transform
1320
+ scene.view_settings.look = original_look
1321
+ scene.view_settings.exposure = original_exposure
1322
+ scene.view_settings.gamma = original_gamma
1323
+ scene.render.dither_intensity = original_dither
1324
+
1325
+
1326
+ def count_mask_pixels(path: Path, selected_colors: set[tuple[int, int, int]]) -> int:
1327
+ image = bpy.data.images.load(str(path), check_existing=False)
1328
+ try:
1329
+ pixels = image.pixels[:]
1330
+ count = 0
1331
+ for offset in range(0, len(pixels), 4):
1332
+ color = tuple(round(pixels[offset + channel] * 255) for channel in range(3))
1333
+ if color in selected_colors:
1334
+ count += 1
1335
+ return count
1336
+ finally:
1337
+ bpy.data.images.remove(image)
1338
+
1339
+
1340
+ def component_visibility(command: dict[str, Any]) -> dict[str, Any]:
1341
+ focus_ids = selected_component_ids(command)
1342
+ width = int(command.get("width", 256))
1343
+ height = int(command.get("height", 256))
1344
+ if not 64 <= width <= 1_024 or not 64 <= height <= 1_024:
1345
+ raise ValueError("component.visibility dimensions must be between 64 and 1024")
1346
+ configure_render(
1347
+ {
1348
+ "engine": "eevee",
1349
+ "width": width,
1350
+ "height": height,
1351
+ "samples": 1,
1352
+ }
1353
+ )
1354
+ colors = component_pass_colors()
1355
+ selected_colors = {colors[component_id] for component_id in focus_ids}
1356
+ original_visibility = {
1357
+ component_id: obj.hide_render for component_id, obj in COMPONENT_OBJECTS.items()
1358
+ }
1359
+ with tempfile.TemporaryDirectory(prefix="meshprobe-visibility-") as directory:
1360
+ root = Path(directory)
1361
+ context_path = root / "context.png"
1362
+ isolated_path = root / "isolated.png"
1363
+ try:
1364
+ render_mask(context_path, colors)
1365
+ visible_pixels = count_mask_pixels(context_path, selected_colors)
1366
+ for component_id, obj in COMPONENT_OBJECTS.items():
1367
+ obj.hide_render = component_id not in focus_ids
1368
+ render_mask(isolated_path, colors)
1369
+ isolated_pixels = count_mask_pixels(isolated_path, selected_colors)
1370
+ finally:
1371
+ for component_id, hidden in original_visibility.items():
1372
+ COMPONENT_OBJECTS[component_id].hide_render = hidden
1373
+ return {
1374
+ "focus_component_ids": sorted(focus_ids),
1375
+ "width": width,
1376
+ "height": height,
1377
+ "visible_pixels": visible_pixels,
1378
+ "isolated_pixels": isolated_pixels,
1379
+ "visible_fraction": visible_pixels / isolated_pixels if isolated_pixels else 0.0,
1380
+ "projected": isolated_pixels > 0,
1381
+ }
1382
+
1383
+
1384
+ def render_evaluator_passes(output_dir: Path, stem: str) -> dict[str, Any]:
1385
+ scene = bpy.context.scene
1386
+ view_layer = bpy.context.view_layer
1387
+ view_layer.use_pass_z = True
1388
+ view_layer.use_pass_normal = True
1389
+ original_group = scene.compositing_node_group
1390
+ group = bpy.data.node_groups.new(f"MeshProbePasses-{stem}", "CompositorNodeTree")
1391
+ scene.compositing_node_group = group
1392
+ render_layers = group.nodes.new("CompositorNodeRLayers")
1393
+ pass_output = compositor_file_output(group, output_dir, f"{stem}.passes")
1394
+ pass_output.file_output_items.new("FLOAT", "Depth")
1395
+ pass_output.file_output_items.new("VECTOR", "Normal")
1396
+ group.links.new(render_layers.outputs["Depth"], pass_output.inputs["Depth"])
1397
+ group.links.new(render_layers.outputs["Normal"], pass_output.inputs["Normal"])
1398
+ prefix = f"{stem}.passes"
1399
+ before = {path: file_version(path) for path in output_dir.glob(f"{prefix}*.exr")}
1400
+ try:
1401
+ bpy.ops.render.render()
1402
+ finally:
1403
+ scene.compositing_node_group = original_group
1404
+ bpy.data.node_groups.remove(group)
1405
+ multilayer_path = published_compositor_path(output_dir, prefix, before)
1406
+
1407
+ scene.render.image_settings.file_format = "PNG"
1408
+ scene.render.image_settings.color_depth = "8"
1409
+ component_path = output_dir / f"{stem}.components.png"
1410
+ component_colors = component_pass_colors()
1411
+ render_mask(component_path, component_colors)
1412
+ highlight_path = output_dir / f"{stem}.highlighted.png"
1413
+ highlight_colors = {
1414
+ component_id: (
1415
+ (255, 255, 255) if COMPONENT_STATES[component_id]["mark"] != "unmarked" else (0, 0, 0)
1416
+ )
1417
+ for component_id in COMPONENT_OBJECTS
1418
+ }
1419
+ render_mask(highlight_path, highlight_colors)
1420
+ return {
1421
+ "multilayer": artifact(multilayer_path, "image/x-exr"),
1422
+ "component_ids": artifact(component_path, "image/png"),
1423
+ "highlighted": artifact(highlight_path, "image/png"),
1424
+ "component_colors": component_colors,
1425
+ }
1426
+
1427
+
1428
+ def compositor_file_output(
1429
+ group: bpy.types.NodeTree,
1430
+ output_dir: Path,
1431
+ file_name: str,
1432
+ ) -> bpy.types.Node:
1433
+ node = group.nodes.new("CompositorNodeOutputFile")
1434
+ node.directory = str(output_dir)
1435
+ node.file_name = file_name
1436
+ node.use_file_extension = True
1437
+ node.format.file_format = "OPEN_EXR_MULTILAYER"
1438
+ node.format.color_depth = "32"
1439
+ return node
1440
+
1441
+
1442
+ def file_version(path: Path) -> tuple[int, int, int]:
1443
+ stat = path.stat()
1444
+ return stat.st_mtime_ns, stat.st_ctime_ns, stat.st_size
1445
+
1446
+
1447
+ def published_compositor_path(
1448
+ output_dir: Path,
1449
+ prefix: str,
1450
+ before: dict[Path, tuple[int, int, int]],
1451
+ ) -> Path:
1452
+ expected = output_dir / f"{prefix}.exr"
1453
+ candidates = [
1454
+ path
1455
+ for path in output_dir.glob(f"{prefix}*.exr")
1456
+ if path not in before or file_version(path) != before[path]
1457
+ ]
1458
+ if len(candidates) != 1:
1459
+ names = sorted(path.name for path in candidates)
1460
+ raise RuntimeError(f"expected one fresh compositor output for {prefix}, found {names}")
1461
+ candidate = candidates[0]
1462
+ if candidate == expected:
1463
+ return expected
1464
+ expected.unlink(missing_ok=True)
1465
+ candidate.replace(expected)
1466
+ return expected
1467
+
1468
+
1469
+ def render_image(command: dict[str, Any]) -> dict[str, Any]:
1470
+ manifest = require_session()
1471
+ output = Path(command["output_path"]).expanduser().resolve()
1472
+ if output.suffix.lower() != ".png":
1473
+ raise ValueError("render.image output_path must end in .png")
1474
+ device = configure_render(command)
1475
+ render_still(output)
1476
+ luminance = luminance_summary(output)
1477
+ evaluator = None
1478
+ if command.get("evaluator_output_dir") is not None:
1479
+ evaluator_dir = Path(command["evaluator_output_dir"]).expanduser().resolve()
1480
+ evaluator_dir.mkdir(parents=True, exist_ok=True)
1481
+ evaluator = render_evaluator_passes(evaluator_dir, output.stem)
1482
+ session = session_snapshot()
1483
+ return {
1484
+ "schema_version": 1,
1485
+ "source_sha256": manifest["source_sha256"],
1486
+ "state_sha256": session["state_sha256"],
1487
+ "width": command["width"],
1488
+ "height": command["height"],
1489
+ "samples": command["samples"],
1490
+ "engine": command["engine"],
1491
+ "device": device,
1492
+ "blender_version": bpy.app.version_string,
1493
+ "session": session,
1494
+ "color": artifact(output, "image/png"),
1495
+ "evaluator": evaluator,
1496
+ "luminance": luminance,
1497
+ }
1498
+
1499
+
1500
+ def export_normalized(command: dict[str, Any]) -> dict[str, Any]:
1501
+ require_session()
1502
+ output = Path(command["output_path"]).expanduser().resolve()
1503
+ if output.suffix.lower() != ".glb":
1504
+ raise ValueError("scene.export_normalized output_path must end in .glb")
1505
+ if output.exists():
1506
+ raise ValueError("scene.export_normalized refuses to overwrite an existing file")
1507
+ output.parent.mkdir(parents=True, exist_ok=True)
1508
+ result = bpy.ops.export_scene.gltf(
1509
+ filepath=str(output),
1510
+ export_format="GLB",
1511
+ export_apply=True,
1512
+ export_cameras=False,
1513
+ export_lights=False,
1514
+ export_yup=True,
1515
+ )
1516
+ if "FINISHED" not in result:
1517
+ raise RuntimeError(f"Blender exporter did not finish: {sorted(result)}")
1518
+ return artifact(output, "model/gltf-binary")
1519
+
1520
+
1521
+ def rank_occluders(command: dict[str, Any]) -> dict[str, Any]:
1522
+ focus_ids = selected_component_ids(command)
1523
+ camera_origin = camera_object().matrix_world.translation
1524
+ object_ids = {obj: component_id for component_id, obj in COMPONENT_OBJECTS.items()}
1525
+ points: list[Vector] = []
1526
+ for component_id in sorted(focus_ids):
1527
+ obj = COMPONENT_OBJECTS[component_id]
1528
+ vertices = obj.data.vertices
1529
+ stride = max(1, len(vertices) // 128)
1530
+ points.extend(
1531
+ obj.matrix_world @ vertices[index].co for index in range(0, len(vertices), stride)
1532
+ )
1533
+ points.append(obj.matrix_world.translation.copy())
1534
+ counts: dict[str, int] = {}
1535
+ depsgraph = bpy.context.evaluated_depsgraph_get()
1536
+ for point in points:
1537
+ direction = point - camera_origin
1538
+ distance = direction.length
1539
+ if distance <= 1e-9:
1540
+ continue
1541
+ hit, _, _, _, hit_object, _ = bpy.context.scene.ray_cast(
1542
+ depsgraph, camera_origin, direction.normalized(), distance=distance + 1e-6
1543
+ )
1544
+ if not hit or hit_object is None:
1545
+ continue
1546
+ original = hit_object.original if hasattr(hit_object, "original") else hit_object
1547
+ hit_component_id = object_ids.get(original)
1548
+ if hit_component_id is None or hit_component_id in focus_ids:
1549
+ continue
1550
+ if COMPONENT_STATES[hit_component_id]["display"] == "hidden":
1551
+ continue
1552
+ counts[hit_component_id] = counts.get(hit_component_id, 0) + 1
1553
+ sample_count = len(points)
1554
+ ranked = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
1555
+ return {
1556
+ "focus_component_ids": sorted(focus_ids),
1557
+ "sample_count": sample_count,
1558
+ "occluders": [
1559
+ {
1560
+ "component_id": component_id,
1561
+ "blocked_rays": count,
1562
+ "fraction": count / sample_count if sample_count else 0.0,
1563
+ }
1564
+ for component_id, count in ranked
1565
+ ],
1566
+ }
1567
+
1568
+
1569
+ def initialize_session(manifest: dict[str, Any], source_path: Path) -> None:
1570
+ global MANIFEST, COMPONENT_OBJECTS, ORIGINAL_MESHES, ORIGINAL_VISIBILITY
1571
+ global COMPONENT_STATES, IMPORTED_CAMERA, CURRENT_CAMERA
1572
+ global IMPORTED_ILLUMINATION, CURRENT_ILLUMINATION, MARK_OBJECTS
1573
+ global OVERRIDE_MATERIALS, EMISSION_MATERIALS
1574
+
1575
+ MANIFEST = manifest
1576
+ objects = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
1577
+ path_objects = set(objects)
1578
+ for obj in objects:
1579
+ ancestor = obj.parent
1580
+ while ancestor is not None:
1581
+ path_objects.add(ancestor)
1582
+ ancestor = ancestor.parent
1583
+ source_names, _ = source_names_for_objects(source_path, sorted(path_objects, key=object_path))
1584
+ objects_by_path = {path: obj for obj, path in source_paths(objects, source_names).items()}
1585
+ COMPONENT_OBJECTS = {
1586
+ component["id"]: objects_by_path[component["path"]] for component in manifest["components"]
1587
+ }
1588
+ ORIGINAL_MESHES = {component_id: obj.data for component_id, obj in COMPONENT_OBJECTS.items()}
1589
+ ORIGINAL_VISIBILITY = {
1590
+ component_id: (obj.hide_render, obj.hide_viewport)
1591
+ for component_id, obj in COMPONENT_OBJECTS.items()
1592
+ }
1593
+ COMPONENT_STATES = {
1594
+ component_id: {
1595
+ "display": "hidden" if obj.hide_render else "shown",
1596
+ "mark": "unmarked",
1597
+ }
1598
+ for component_id, obj in COMPONENT_OBJECTS.items()
1599
+ }
1600
+ IMPORTED_CAMERA = deepcopy(manifest["imported_camera"])
1601
+ CURRENT_CAMERA = deepcopy(IMPORTED_CAMERA)
1602
+ IMPORTED_ILLUMINATION = deepcopy(manifest["imported_illumination"])
1603
+ CURRENT_ILLUMINATION = deepcopy(IMPORTED_ILLUMINATION)
1604
+ MARK_OBJECTS = {}
1605
+ OVERRIDE_MATERIALS = {}
1606
+ EMISSION_MATERIALS = {}
1607
+ apply_camera(deepcopy(IMPORTED_CAMERA))
1608
+ apply_illumination(deepcopy(IMPORTED_ILLUMINATION))
1609
+
1610
+
1611
+ def import_source(path: Path) -> list[dict[str, Any]]:
1612
+ suffix = path.suffix.lower()
1613
+ warnings: list[dict[str, Any]] = []
1614
+ if suffix in {".glb", ".gltf"}:
1615
+ result = bpy.ops.import_scene.gltf(filepath=str(path))
1616
+ elif suffix == ".obj":
1617
+ result = bpy.ops.wm.obj_import(filepath=str(path))
1618
+ warnings.append(
1619
+ {
1620
+ "code": "units.assumed",
1621
+ "message": (
1622
+ "OBJ has no reliable unit declaration; imported coordinates are treated "
1623
+ "as meters."
1624
+ ),
1625
+ "component_ids": [],
1626
+ }
1627
+ )
1628
+ elif suffix == ".stl":
1629
+ result = bpy.ops.wm.stl_import(filepath=str(path))
1630
+ warnings.append(
1631
+ {
1632
+ "code": "hierarchy.flattened",
1633
+ "message": "STL does not carry component hierarchy or source names.",
1634
+ "component_ids": [],
1635
+ }
1636
+ )
1637
+ warnings.append(
1638
+ {
1639
+ "code": "units.assumed",
1640
+ "message": (
1641
+ "STL has no unit declaration; imported coordinates are treated as meters."
1642
+ ),
1643
+ "component_ids": [],
1644
+ }
1645
+ )
1646
+ else:
1647
+ raise ValueError(f"unsupported source format: {suffix or '<none>'}")
1648
+ if "FINISHED" not in result:
1649
+ raise RuntimeError(f"Blender importer did not finish: {sorted(result)}")
1650
+ return warnings
1651
+
1652
+
1653
+ def build_manifest(
1654
+ source_path: Path, source_sha256: str, import_warnings: list[dict[str, Any]]
1655
+ ) -> dict[str, Any]:
1656
+ objects = sorted(
1657
+ (obj for obj in bpy.context.scene.objects if obj.type == "MESH"), key=object_path
1658
+ )
1659
+ if not objects:
1660
+ raise ValueError("the imported scene contains no mesh components")
1661
+
1662
+ component_objects = set(objects)
1663
+ path_objects = set(objects)
1664
+ for obj in objects:
1665
+ current = obj.parent
1666
+ while current is not None:
1667
+ path_objects.add(current)
1668
+ current = current.parent
1669
+ source_names, names_preserved = source_names_for_objects(
1670
+ source_path, sorted(path_objects, key=object_path)
1671
+ )
1672
+ paths = source_paths(objects, source_names)
1673
+ objects.sort(key=paths.__getitem__)
1674
+ ids = {obj: stable_component_id(source_sha256, paths[obj]) for obj in objects}
1675
+ children: dict[bpy.types.Object, list[str]] = {obj: [] for obj in objects}
1676
+ parents: dict[bpy.types.Object, bpy.types.Object | None] = {}
1677
+ for obj in objects:
1678
+ parent = nearest_component_parent(obj, component_objects)
1679
+ parents[obj] = parent
1680
+ if parent is not None:
1681
+ children[parent].append(ids[obj])
1682
+
1683
+ components: list[dict[str, Any]] = []
1684
+ all_world_points: list[Vector] = []
1685
+ missing_texture_count = 0
1686
+ components_with_materials = 0
1687
+ texture_count = 0
1688
+ for obj in objects:
1689
+ local_bounds, world_bounds = object_bounds(obj)
1690
+ all_world_points.extend(obj.matrix_world @ Vector(corner) for corner in obj.bound_box)
1691
+ materials, object_texture_count = material_summary(obj)
1692
+ components_with_materials += bool(materials["names"])
1693
+ texture_count += object_texture_count
1694
+ missing_texture_count += len(materials["missing_textures"])
1695
+ components.append(
1696
+ {
1697
+ "id": ids[obj],
1698
+ "path": paths[obj],
1699
+ "display_name": source_names[obj],
1700
+ "source_name": source_names[obj],
1701
+ "parent_id": ids[parents[obj]] if parents[obj] is not None else None,
1702
+ "child_ids": sorted(children[obj]),
1703
+ "mesh_hash": mesh_hash(obj.data),
1704
+ "world_transform": matrix_in_millimeters(obj),
1705
+ "local_bounds": local_bounds,
1706
+ "world_bounds": world_bounds,
1707
+ "materials": materials,
1708
+ }
1709
+ )
1710
+
1711
+ root_bounds = bounds_of_points(all_world_points)
1712
+ camera, camera_warning = imported_camera(root_bounds)
1713
+ illumination, illumination_warnings = imported_illumination()
1714
+ source_format = source_path.suffix.lower().removeprefix(".")
1715
+ document = gltf_document(source_path) if source_format in {"glb", "gltf"} else {}
1716
+ animations = document.get("animations", [])
1717
+ has_animations = isinstance(animations, list) and bool(animations)
1718
+ extensions = document.get("extensionsUsed", [])
1719
+ extension_names = (
1720
+ {name for name in extensions if isinstance(name, str)}
1721
+ if isinstance(extensions, list)
1722
+ else set()
1723
+ )
1724
+ unverified_extensions = sorted(extension_names - SUPPORTED_GLTF_EXTENSIONS)
1725
+ procedural_extensions = [
1726
+ name
1727
+ for name in unverified_extensions
1728
+ if "procedural" in name.casefold() or "materialx" in name.casefold()
1729
+ ]
1730
+ hierarchy_flattened = source_format == "stl" or has_unaddressable_hierarchy(objects)
1731
+ warnings = [*import_warnings, *illumination_warnings]
1732
+ if hierarchy_flattened and not any(
1733
+ warning["code"] == "hierarchy.flattened" for warning in warnings
1734
+ ):
1735
+ warnings.append(
1736
+ {
1737
+ "code": "hierarchy.flattened",
1738
+ "message": (
1739
+ "The source contains non-mesh hierarchy nodes; component parent links "
1740
+ "represent the addressable mesh hierarchy."
1741
+ ),
1742
+ "component_ids": [],
1743
+ }
1744
+ )
1745
+ if camera_warning is not None:
1746
+ warnings.append(camera_warning)
1747
+
1748
+ if has_animations:
1749
+ warnings.append(
1750
+ {
1751
+ "code": "animation.static_pose",
1752
+ "message": (
1753
+ "The source contains animation data; MeshProbe inspects the imported "
1754
+ "rest state and does not evaluate animation timelines."
1755
+ ),
1756
+ "component_ids": [],
1757
+ }
1758
+ )
1759
+ if unverified_extensions:
1760
+ warnings.append(
1761
+ {
1762
+ "code": "extension.unverified",
1763
+ "message": (
1764
+ "The source declares glTF extensions outside MeshProbe's verified import "
1765
+ f"set: {', '.join(unverified_extensions)}."
1766
+ ),
1767
+ "component_ids": [],
1768
+ }
1769
+ )
1770
+ if procedural_extensions:
1771
+ warnings.append(
1772
+ {
1773
+ "code": "material.procedural_unsupported",
1774
+ "message": (
1775
+ "Procedural material extensions are not evaluated: "
1776
+ f"{', '.join(procedural_extensions)}."
1777
+ ),
1778
+ "component_ids": [],
1779
+ }
1780
+ )
1781
+
1782
+ if source_format in {"glb", "gltf"} and camera_warning is None:
1783
+ warnings.append(
1784
+ {
1785
+ "code": "camera.lens_reconstructed",
1786
+ "message": (
1787
+ "glTF stores angular field of view, not physical lens metadata; Blender "
1788
+ "reconstructed an optically equivalent lens and sensor combination."
1789
+ ),
1790
+ "component_ids": [],
1791
+ }
1792
+ )
1793
+ texture_capability = "absent"
1794
+ if texture_count:
1795
+ texture_capability = "partial" if missing_texture_count else "preserved"
1796
+ material_capability = "absent"
1797
+ if components_with_materials:
1798
+ material_capability = (
1799
+ "preserved" if components_with_materials == len(components) else "partial"
1800
+ )
1801
+ return {
1802
+ "schema_version": 1,
1803
+ "source_sha256": source_sha256,
1804
+ "source_format": source_format,
1805
+ "units": "millimeter",
1806
+ "root_bounds": root_bounds,
1807
+ "components": components,
1808
+ "imported_camera": camera,
1809
+ "imported_illumination": illumination,
1810
+ "capabilities": {
1811
+ "hierarchy": "flattened" if hierarchy_flattened else "preserved",
1812
+ "component_names": "source" if names_preserved else "generated",
1813
+ "materials": material_capability,
1814
+ "textures": texture_capability,
1815
+ "animations": "static_pose" if has_animations else "absent",
1816
+ "procedural_materials": "unsupported" if procedural_extensions else "absent",
1817
+ },
1818
+ "warnings": warnings,
1819
+ }
1820
+
1821
+
1822
+ def has_unaddressable_hierarchy(objects: list[bpy.types.Object]) -> bool:
1823
+ component_objects = set(objects)
1824
+ for obj in objects:
1825
+ current = obj.parent
1826
+ while current is not None:
1827
+ if current not in component_objects:
1828
+ return True
1829
+ current = current.parent
1830
+ return False
1831
+
1832
+
1833
+ def source_names_for_objects(
1834
+ source_path: Path,
1835
+ objects: list[bpy.types.Object],
1836
+ ) -> tuple[dict[bpy.types.Object, str], bool]:
1837
+ original_names = source_node_names(source_path)
1838
+ available = Counter(original_names)
1839
+ names: dict[bpy.types.Object, str] = {}
1840
+ preserved = source_path.suffix.lower() != ".stl"
1841
+ for obj in objects:
1842
+ if available[obj.name] <= 0:
1843
+ continue
1844
+ names[obj] = obj.name
1845
+ available[obj.name] -= 1
1846
+ for obj in objects:
1847
+ if obj in names:
1848
+ continue
1849
+ base, separator, suffix = obj.name.rpartition(".")
1850
+ if separator and suffix.isdigit() and len(suffix) == 3 and available[base] > 0:
1851
+ names[obj] = base
1852
+ available[base] -= 1
1853
+ continue
1854
+ names[obj] = obj.name
1855
+ preserved = False
1856
+ return names, preserved
1857
+
1858
+
1859
+ def source_paths(
1860
+ components: list[bpy.types.Object], source_names: dict[bpy.types.Object, str]
1861
+ ) -> dict[bpy.types.Object, str]:
1862
+ nodes = set(components)
1863
+ for component in components:
1864
+ ancestor = component.parent
1865
+ while ancestor is not None:
1866
+ nodes.add(ancestor)
1867
+ ancestor = ancestor.parent
1868
+
1869
+ siblings: dict[tuple[bpy.types.Object | None, str], list[bpy.types.Object]] = {}
1870
+ for node in nodes:
1871
+ name = source_names[node]
1872
+ siblings.setdefault((node.parent, name), []).append(node)
1873
+
1874
+ segments: dict[bpy.types.Object, str] = {}
1875
+ for (_, name), matching_nodes in siblings.items():
1876
+ ordered = sorted(matching_nodes, key=lambda node: node.name)
1877
+ for index, node in enumerate(ordered, start=1):
1878
+ suffix = f"~{index}" if len(ordered) > 1 else ""
1879
+ segments[node] = escape_path_segment(f"{name}{suffix}")
1880
+
1881
+ paths: dict[bpy.types.Object, str] = {}
1882
+ for component in components:
1883
+ path_segments: list[str] = []
1884
+ current: bpy.types.Object | None = component
1885
+ while current is not None:
1886
+ path_segments.append(segments[current])
1887
+ current = current.parent
1888
+ paths[component] = "/".join(reversed(path_segments))
1889
+ return paths
1890
+
1891
+
1892
+ def source_node_names(source_path: Path) -> tuple[str, ...]:
1893
+ suffix = source_path.suffix.lower()
1894
+ if suffix == ".obj":
1895
+ names: list[str] = []
1896
+ for line in source_path.read_text(encoding="utf-8-sig").splitlines():
1897
+ tokens = shlex.split(line, comments=True, posix=True)
1898
+ if len(tokens) >= 2 and tokens[0].lower() in {"o", "g"}:
1899
+ names.extend(tokens[1:])
1900
+ return tuple(names)
1901
+ if suffix not in {".glb", ".gltf"}:
1902
+ return ()
1903
+ document = gltf_document(source_path)
1904
+ nodes = document.get("nodes", [])
1905
+ if not isinstance(nodes, list):
1906
+ return ()
1907
+ return tuple(
1908
+ node["name"]
1909
+ for node in nodes
1910
+ if isinstance(node, dict) and isinstance(node.get("name"), str)
1911
+ )
1912
+
1913
+
1914
+ def gltf_document(source_path: Path) -> dict[str, Any]:
1915
+ if source_path.suffix.lower() == ".gltf":
1916
+ document = json.loads(source_path.read_text(encoding="utf-8"))
1917
+ return document if isinstance(document, dict) else {}
1918
+ payload = source_path.read_bytes()
1919
+ if len(payload) < 20 or payload[:4] != b"glTF":
1920
+ return {}
1921
+ offset = 12
1922
+ while offset + 8 <= len(payload):
1923
+ chunk_length, chunk_type = struct.unpack_from("<II", payload, offset)
1924
+ offset += 8
1925
+ chunk = payload[offset : offset + chunk_length]
1926
+ offset += chunk_length
1927
+ if chunk_type != 0x4E4F534A:
1928
+ continue
1929
+ document = json.loads(chunk.rstrip(b"\x00 \t\r\n").decode("utf-8"))
1930
+ return document if isinstance(document, dict) else {}
1931
+ return {}
1932
+
1933
+
1934
+ def scene_open(command: dict[str, Any]) -> dict[str, Any]:
1935
+ source_path = Path(command["source_path"]).expanduser().resolve(strict=True)
1936
+ if not source_path.is_file():
1937
+ raise ValueError(f"source is not a file: {source_path}")
1938
+ source_sha256 = command.get("source_sha256") or sha256_file(source_path)
1939
+ clear_session_state()
1940
+ bpy.ops.wm.read_factory_settings(use_empty=True)
1941
+ import_warnings = import_source(source_path)
1942
+ manifest = build_manifest(source_path, source_sha256, import_warnings)
1943
+ initialize_session(manifest, source_path)
1944
+ return manifest
1945
+
1946
+
1947
+ def clear_session_state() -> None:
1948
+ global MANIFEST, COMPONENT_OBJECTS, ORIGINAL_MESHES, ORIGINAL_VISIBILITY
1949
+ global COMPONENT_STATES, IMPORTED_CAMERA, CURRENT_CAMERA, CURRENT_CAMERA_DIAGNOSTICS
1950
+ global IMPORTED_ILLUMINATION, CURRENT_ILLUMINATION, MARK_OBJECTS
1951
+ MANIFEST = None
1952
+ COMPONENT_OBJECTS = {}
1953
+ ORIGINAL_MESHES = {}
1954
+ ORIGINAL_VISIBILITY = {}
1955
+ COMPONENT_STATES = {}
1956
+ IMPORTED_CAMERA = None
1957
+ CURRENT_CAMERA = None
1958
+ CURRENT_CAMERA_DIAGNOSTICS = None
1959
+ IMPORTED_ILLUMINATION = None
1960
+ CURRENT_ILLUMINATION = None
1961
+ MARK_OBJECTS = {}
1962
+
1963
+
1964
+ def dispatch(command: dict[str, Any]) -> dict[str, Any]:
1965
+ operation = command.get("op")
1966
+ if operation == "scene.open":
1967
+ return scene_open(command)
1968
+ if operation == "session.snapshot":
1969
+ return {"scene": require_session(), "session": session_snapshot()}
1970
+ if operation == "view.set":
1971
+ require_session()
1972
+ return apply_camera(
1973
+ command["camera"],
1974
+ command.get("focus_component_ids", ()),
1975
+ command.get("aspect_ratio", 1.0),
1976
+ )
1977
+ if operation == "view.orbit":
1978
+ require_session()
1979
+ return orbit_camera(command)
1980
+ if operation == "illumination.set":
1981
+ require_session()
1982
+ return apply_illumination(command["illumination"])
1983
+ if operation == "component.display":
1984
+ require_session()
1985
+ return component_display(command)
1986
+ if operation == "component.mark":
1987
+ require_session()
1988
+ return component_mark(command)
1989
+ if operation == "session.reset":
1990
+ require_session()
1991
+ return reset_session()
1992
+ if operation == "session.runtime":
1993
+ require_session()
1994
+ return runtime_diagnostics()
1995
+ if operation == "render.image":
1996
+ require_session()
1997
+ return render_image(command)
1998
+ if operation == "scene.export_normalized":
1999
+ return export_normalized(command)
2000
+ if operation == "component.visibility":
2001
+ require_session()
2002
+ return component_visibility(command)
2003
+ if operation == "component.occluders":
2004
+ require_session()
2005
+ return rank_occluders(command)
2006
+ if operation == "session.shutdown":
2007
+ return {"shutdown": True}
2008
+ raise ValueError(f"unsupported worker operation: {operation}")
2009
+
2010
+
2011
+ def main() -> None:
2012
+ emit(
2013
+ {
2014
+ "event": "ready",
2015
+ "protocol_version": PROTOCOL_VERSION,
2016
+ "blender_version": bpy.app.version_string,
2017
+ "pid": os.getpid(),
2018
+ }
2019
+ )
2020
+ for line in sys.stdin:
2021
+ if not line.strip():
2022
+ continue
2023
+ request_id: str | None = None
2024
+ try:
2025
+ command = json.loads(line)
2026
+ request_id = command.get("request_id")
2027
+ result = dispatch(command)
2028
+ emit({"request_id": request_id, "ok": True, "result": result})
2029
+ if command.get("op") == "session.shutdown":
2030
+ return
2031
+ except Exception as error:
2032
+ emit(
2033
+ {
2034
+ "request_id": request_id,
2035
+ "ok": False,
2036
+ "error": {
2037
+ "code": f"worker.{type(error).__name__}",
2038
+ "message": str(error),
2039
+ "traceback": traceback.format_exc(),
2040
+ },
2041
+ }
2042
+ )
2043
+
2044
+
2045
+ if __name__ == "__main__":
2046
+ main()