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.
- meshprobe/__init__.py +8 -0
- meshprobe/artifacts.py +333 -0
- meshprobe/blender/__init__.py +1 -0
- meshprobe/blender/curated_builder.py +172 -0
- meshprobe/blender/worker.py +2046 -0
- meshprobe/camera.py +225 -0
- meshprobe/cli.py +856 -0
- meshprobe/client.py +432 -0
- meshprobe/contact_sheet.py +83 -0
- meshprobe/controller.py +1076 -0
- meshprobe/daemon.py +128 -0
- meshprobe/evals/__init__.py +5 -0
- meshprobe/evals/curated.py +330 -0
- meshprobe/evals/curated_build.py +206 -0
- meshprobe/evals/curated_tasks.py +533 -0
- meshprobe/evals/ergonomics.py +1197 -0
- meshprobe/evals/factory.py +472 -0
- meshprobe/evals/generators.py +1040 -0
- meshprobe/evals/geometry.py +514 -0
- meshprobe/evals/harness/__init__.py +19 -0
- meshprobe/evals/harness/adapters.py +631 -0
- meshprobe/evals/harness/attempts.py +41 -0
- meshprobe/evals/harness/broker.py +544 -0
- meshprobe/evals/harness/replay.py +111 -0
- meshprobe/evals/harness/runner.py +208 -0
- meshprobe/evals/harness/sandbox.py +631 -0
- meshprobe/evals/harness/suite.py +246 -0
- meshprobe/evals/harness/windows_sandbox.py +760 -0
- meshprobe/evals/leakage.py +74 -0
- meshprobe/evals/migration.py +278 -0
- meshprobe/evals/oracles.py +777 -0
- meshprobe/evals/reference_agent.py +649 -0
- meshprobe/evals/reports.py +245 -0
- meshprobe/evals/schemas.py +369 -0
- meshprobe/evals/tiers.py +257 -0
- meshprobe/evals/variants.py +97 -0
- meshprobe/identity.py +12 -0
- meshprobe/mcp_server.py +65 -0
- meshprobe/models.py +625 -0
- meshprobe/protocol.py +152 -0
- meshprobe/py.typed +1 -0
- meshprobe/selectors.py +97 -0
- meshprobe/service.py +110 -0
- meshprobe/session.py +106 -0
- meshprobe/sources.py +263 -0
- meshprobe/workspace.py +682 -0
- meshprobe-0.2.0.dist-info/METADATA +248 -0
- meshprobe-0.2.0.dist-info/RECORD +51 -0
- meshprobe-0.2.0.dist-info/WHEEL +4 -0
- meshprobe-0.2.0.dist-info/entry_points.txt +3 -0
- 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()
|