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
meshprobe/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Read-only 3D model inspection primitives."""
|
|
2
|
+
|
|
3
|
+
from meshprobe.controller import BlenderController
|
|
4
|
+
from meshprobe.models import SceneManifest
|
|
5
|
+
from meshprobe.session import InspectionSession
|
|
6
|
+
|
|
7
|
+
__all__ = ["BlenderController", "InspectionSession", "SceneManifest"]
|
|
8
|
+
__version__ = "0.2.0"
|
meshprobe/artifacts.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Deterministic, atomic storage for derived MeshProbe artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import uuid
|
|
10
|
+
from collections.abc import Callable, Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
from typing import cast
|
|
14
|
+
|
|
15
|
+
from meshprobe.sources import sha256_file
|
|
16
|
+
|
|
17
|
+
type JsonScalar = str | int | float | bool | None
|
|
18
|
+
type JsonValue = JsonScalar | list[JsonValue] | dict[str, JsonValue]
|
|
19
|
+
|
|
20
|
+
_MANIFEST_NAME = "manifest.json"
|
|
21
|
+
_PAYLOAD_DIRECTORY = "payload"
|
|
22
|
+
_SCHEMA_VERSION = 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ArtifactOutput:
|
|
27
|
+
"""One immutable file in a cache entry."""
|
|
28
|
+
|
|
29
|
+
relative_path: str
|
|
30
|
+
sha256: str
|
|
31
|
+
bytes: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ArtifactCacheEntry:
|
|
36
|
+
"""A validated, completely published cache entry."""
|
|
37
|
+
|
|
38
|
+
key: str
|
|
39
|
+
source_sha256: str
|
|
40
|
+
importer_version: str
|
|
41
|
+
normalization_parameters: dict[str, JsonValue]
|
|
42
|
+
output_sha256: str
|
|
43
|
+
outputs: tuple[ArtifactOutput, ...]
|
|
44
|
+
path: Path
|
|
45
|
+
|
|
46
|
+
def output_path(self, relative_path: str) -> Path:
|
|
47
|
+
"""Return a declared output path and reject undeclared names."""
|
|
48
|
+
|
|
49
|
+
declared = {output.relative_path for output in self.outputs}
|
|
50
|
+
normalized = _normalized_relative_path(relative_path)
|
|
51
|
+
if normalized not in declared:
|
|
52
|
+
raise KeyError(f"cache entry has no output named {normalized!r}")
|
|
53
|
+
return self.path / _PAYLOAD_DIRECTORY / Path(*PurePosixPath(normalized).parts)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ArtifactCache:
|
|
57
|
+
"""Content-addressed cache whose incomplete writes are never visible."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, root: Path) -> None:
|
|
60
|
+
self.root = root.expanduser().resolve()
|
|
61
|
+
self._entries = self.root / "entries"
|
|
62
|
+
self._staging = self.root / ".staging"
|
|
63
|
+
self._entries.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
self._staging.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def key_for(
|
|
68
|
+
source_sha256: str,
|
|
69
|
+
importer_version: str,
|
|
70
|
+
normalization_parameters: Mapping[str, JsonValue],
|
|
71
|
+
) -> str:
|
|
72
|
+
"""Derive an entry key from every input that can change normalization."""
|
|
73
|
+
|
|
74
|
+
normalized = _normalized_parameters(normalization_parameters)
|
|
75
|
+
_validate_sha256(source_sha256, "source_sha256")
|
|
76
|
+
_validate_importer_version(importer_version)
|
|
77
|
+
material = {
|
|
78
|
+
"schema_version": _SCHEMA_VERSION,
|
|
79
|
+
"source_sha256": source_sha256,
|
|
80
|
+
"importer_version": importer_version,
|
|
81
|
+
"normalization_parameters": normalized,
|
|
82
|
+
}
|
|
83
|
+
return hashlib.sha256(_canonical_json(material)).hexdigest()
|
|
84
|
+
|
|
85
|
+
def lookup(
|
|
86
|
+
self,
|
|
87
|
+
source_sha256: str,
|
|
88
|
+
importer_version: str,
|
|
89
|
+
normalization_parameters: Mapping[str, JsonValue],
|
|
90
|
+
) -> ArtifactCacheEntry | None:
|
|
91
|
+
"""Return a verified entry or ``None``; staging data is never consulted."""
|
|
92
|
+
|
|
93
|
+
key = self.key_for(source_sha256, importer_version, normalization_parameters)
|
|
94
|
+
entry_path = self._entry_path(key)
|
|
95
|
+
if not entry_path.exists():
|
|
96
|
+
return None
|
|
97
|
+
entry = self._validate_entry(entry_path)
|
|
98
|
+
if entry.key != key:
|
|
99
|
+
raise ValueError(f"cache manifest key mismatch at {entry_path}")
|
|
100
|
+
return entry
|
|
101
|
+
|
|
102
|
+
def publish(
|
|
103
|
+
self,
|
|
104
|
+
source_sha256: str,
|
|
105
|
+
importer_version: str,
|
|
106
|
+
normalization_parameters: Mapping[str, JsonValue],
|
|
107
|
+
write_outputs: Callable[[Path], None],
|
|
108
|
+
) -> ArtifactCacheEntry:
|
|
109
|
+
"""Create and atomically publish one cache entry.
|
|
110
|
+
|
|
111
|
+
``write_outputs`` receives an empty payload directory on the cache volume.
|
|
112
|
+
It may create files and directories but not symbolic links. If another
|
|
113
|
+
process publishes the same key first, its validated entry wins.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
parameters = _normalized_parameters(normalization_parameters)
|
|
117
|
+
key = self.key_for(source_sha256, importer_version, parameters)
|
|
118
|
+
existing = self.lookup(source_sha256, importer_version, parameters)
|
|
119
|
+
if existing is not None:
|
|
120
|
+
return existing
|
|
121
|
+
|
|
122
|
+
staging_path = self._staging / f"{key}-{uuid.uuid4().hex}"
|
|
123
|
+
payload_path = staging_path / _PAYLOAD_DIRECTORY
|
|
124
|
+
payload_path.mkdir(parents=True)
|
|
125
|
+
try:
|
|
126
|
+
write_outputs(payload_path)
|
|
127
|
+
outputs = _inventory_payload(payload_path)
|
|
128
|
+
if not outputs:
|
|
129
|
+
raise ValueError("cache publication produced no output files")
|
|
130
|
+
output_sha256 = _outputs_digest(outputs)
|
|
131
|
+
manifest = {
|
|
132
|
+
"schema_version": _SCHEMA_VERSION,
|
|
133
|
+
"key": key,
|
|
134
|
+
"source_sha256": source_sha256,
|
|
135
|
+
"importer_version": importer_version,
|
|
136
|
+
"normalization_parameters": parameters,
|
|
137
|
+
"output_sha256": output_sha256,
|
|
138
|
+
"outputs": [
|
|
139
|
+
{
|
|
140
|
+
"relative_path": output.relative_path,
|
|
141
|
+
"sha256": output.sha256,
|
|
142
|
+
"bytes": output.bytes,
|
|
143
|
+
}
|
|
144
|
+
for output in outputs
|
|
145
|
+
],
|
|
146
|
+
}
|
|
147
|
+
(staging_path / _MANIFEST_NAME).write_bytes(_canonical_json(manifest) + b"\n")
|
|
148
|
+
self._validate_entry(staging_path)
|
|
149
|
+
|
|
150
|
+
destination = self._entry_path(key)
|
|
151
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
try:
|
|
153
|
+
os.replace(staging_path, destination)
|
|
154
|
+
except OSError as error:
|
|
155
|
+
if not destination.exists():
|
|
156
|
+
raise
|
|
157
|
+
winner = self._validate_entry(destination)
|
|
158
|
+
if winner.key != key:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"concurrent cache publication produced wrong key {winner.key}"
|
|
161
|
+
) from error
|
|
162
|
+
return self._validate_entry(destination)
|
|
163
|
+
finally:
|
|
164
|
+
shutil.rmtree(staging_path, ignore_errors=True)
|
|
165
|
+
|
|
166
|
+
def evict(self, key: str) -> bool:
|
|
167
|
+
"""Remove one complete entry and return whether it existed."""
|
|
168
|
+
|
|
169
|
+
_validate_sha256(key, "cache key")
|
|
170
|
+
entry_path = self._entry_path(key)
|
|
171
|
+
if not entry_path.exists():
|
|
172
|
+
return False
|
|
173
|
+
shutil.rmtree(entry_path)
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
def cleanup_staging(self) -> int:
|
|
177
|
+
"""Remove abandoned unpublished directories and return their count."""
|
|
178
|
+
|
|
179
|
+
removed = 0
|
|
180
|
+
for candidate in self._staging.iterdir():
|
|
181
|
+
if candidate.is_dir() and not candidate.is_symlink():
|
|
182
|
+
shutil.rmtree(candidate)
|
|
183
|
+
else:
|
|
184
|
+
candidate.unlink()
|
|
185
|
+
removed += 1
|
|
186
|
+
return removed
|
|
187
|
+
|
|
188
|
+
def _entry_path(self, key: str) -> Path:
|
|
189
|
+
_validate_sha256(key, "cache key")
|
|
190
|
+
return self._entries / key[:2] / key
|
|
191
|
+
|
|
192
|
+
def _validate_entry(self, entry_path: Path) -> ArtifactCacheEntry:
|
|
193
|
+
manifest_path = entry_path / _MANIFEST_NAME
|
|
194
|
+
payload_path = entry_path / _PAYLOAD_DIRECTORY
|
|
195
|
+
try:
|
|
196
|
+
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
197
|
+
except (FileNotFoundError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
198
|
+
raise ValueError(f"invalid cache manifest at {manifest_path}: {error}") from error
|
|
199
|
+
if not isinstance(raw, dict):
|
|
200
|
+
raise ValueError(f"cache manifest must be an object at {manifest_path}")
|
|
201
|
+
if raw.get("schema_version") != _SCHEMA_VERSION:
|
|
202
|
+
raise ValueError(f"unsupported cache manifest schema at {manifest_path}")
|
|
203
|
+
|
|
204
|
+
key = _required_string(raw, "key")
|
|
205
|
+
source_sha256 = _required_string(raw, "source_sha256")
|
|
206
|
+
importer_version = _required_string(raw, "importer_version")
|
|
207
|
+
output_sha256 = _required_string(raw, "output_sha256")
|
|
208
|
+
_validate_sha256(key, "cache manifest key")
|
|
209
|
+
_validate_sha256(source_sha256, "cache manifest source_sha256")
|
|
210
|
+
_validate_sha256(output_sha256, "cache manifest output_sha256")
|
|
211
|
+
_validate_importer_version(importer_version)
|
|
212
|
+
|
|
213
|
+
parameters_raw = raw.get("normalization_parameters")
|
|
214
|
+
if not isinstance(parameters_raw, dict):
|
|
215
|
+
raise ValueError("cache manifest normalization_parameters must be an object")
|
|
216
|
+
parameters = _normalized_parameters(cast(dict[str, JsonValue], parameters_raw))
|
|
217
|
+
|
|
218
|
+
outputs_raw = raw.get("outputs")
|
|
219
|
+
if not isinstance(outputs_raw, list) or not outputs_raw:
|
|
220
|
+
raise ValueError("cache manifest outputs must be a non-empty array")
|
|
221
|
+
outputs: list[ArtifactOutput] = []
|
|
222
|
+
for item in outputs_raw:
|
|
223
|
+
if not isinstance(item, dict):
|
|
224
|
+
raise ValueError("cache manifest output must be an object")
|
|
225
|
+
relative_path = _normalized_relative_path(_required_string(item, "relative_path"))
|
|
226
|
+
sha256 = _required_string(item, "sha256")
|
|
227
|
+
size = item.get("bytes")
|
|
228
|
+
_validate_sha256(sha256, "cache output sha256")
|
|
229
|
+
if not isinstance(size, int) or isinstance(size, bool) or size < 0:
|
|
230
|
+
raise ValueError("cache output bytes must be a non-negative integer")
|
|
231
|
+
outputs.append(ArtifactOutput(relative_path, sha256, size))
|
|
232
|
+
if len({output.relative_path for output in outputs}) != len(outputs):
|
|
233
|
+
raise ValueError("cache manifest output paths must be unique")
|
|
234
|
+
declared = tuple(sorted(outputs, key=lambda output: output.relative_path))
|
|
235
|
+
actual = _inventory_payload(payload_path)
|
|
236
|
+
if actual != declared:
|
|
237
|
+
raise ValueError(f"cache payload does not match manifest at {entry_path}")
|
|
238
|
+
if _outputs_digest(declared) != output_sha256:
|
|
239
|
+
raise ValueError(f"cache output digest mismatch at {entry_path}")
|
|
240
|
+
expected_key = self.key_for(source_sha256, importer_version, parameters)
|
|
241
|
+
if expected_key != key:
|
|
242
|
+
raise ValueError(f"cache input digest mismatch at {entry_path}")
|
|
243
|
+
|
|
244
|
+
return ArtifactCacheEntry(
|
|
245
|
+
key=key,
|
|
246
|
+
source_sha256=source_sha256,
|
|
247
|
+
importer_version=importer_version,
|
|
248
|
+
normalization_parameters=parameters,
|
|
249
|
+
output_sha256=output_sha256,
|
|
250
|
+
outputs=declared,
|
|
251
|
+
path=entry_path,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _inventory_payload(payload_path: Path) -> tuple[ArtifactOutput, ...]:
|
|
256
|
+
if not payload_path.is_dir() or payload_path.is_symlink():
|
|
257
|
+
raise ValueError("cache payload must be a real directory")
|
|
258
|
+
outputs: list[ArtifactOutput] = []
|
|
259
|
+
for path in sorted(payload_path.rglob("*")):
|
|
260
|
+
if path.is_symlink():
|
|
261
|
+
raise ValueError(f"cache payload cannot contain symbolic links: {path}")
|
|
262
|
+
if path.is_dir():
|
|
263
|
+
continue
|
|
264
|
+
if not path.is_file():
|
|
265
|
+
raise ValueError(f"cache payload contains unsupported entry: {path}")
|
|
266
|
+
relative_path = path.relative_to(payload_path).as_posix()
|
|
267
|
+
outputs.append(
|
|
268
|
+
ArtifactOutput(
|
|
269
|
+
relative_path=_normalized_relative_path(relative_path),
|
|
270
|
+
sha256=sha256_file(path),
|
|
271
|
+
bytes=path.stat().st_size,
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
return tuple(outputs)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _outputs_digest(outputs: tuple[ArtifactOutput, ...]) -> str:
|
|
278
|
+
records = [
|
|
279
|
+
{
|
|
280
|
+
"relative_path": output.relative_path,
|
|
281
|
+
"sha256": output.sha256,
|
|
282
|
+
"bytes": output.bytes,
|
|
283
|
+
}
|
|
284
|
+
for output in outputs
|
|
285
|
+
]
|
|
286
|
+
return hashlib.sha256(_canonical_json(records)).hexdigest()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _normalized_parameters(parameters: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
|
|
290
|
+
if any(not isinstance(key, str) for key in parameters):
|
|
291
|
+
raise ValueError("normalization parameter keys must be strings")
|
|
292
|
+
try:
|
|
293
|
+
encoded = _canonical_json(dict(parameters))
|
|
294
|
+
normalized = json.loads(encoded)
|
|
295
|
+
except (TypeError, ValueError) as error:
|
|
296
|
+
raise ValueError(f"normalization parameters must be finite JSON values: {error}") from error
|
|
297
|
+
if not isinstance(normalized, dict):
|
|
298
|
+
raise ValueError("normalization parameters must be an object")
|
|
299
|
+
return cast(dict[str, JsonValue], normalized)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _canonical_json(value: object) -> bytes:
|
|
303
|
+
return json.dumps(
|
|
304
|
+
value,
|
|
305
|
+
sort_keys=True,
|
|
306
|
+
separators=(",", ":"),
|
|
307
|
+
ensure_ascii=False,
|
|
308
|
+
allow_nan=False,
|
|
309
|
+
).encode("utf-8")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _normalized_relative_path(relative_path: str) -> str:
|
|
313
|
+
path = PurePosixPath(relative_path.replace("\\", "/"))
|
|
314
|
+
if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts):
|
|
315
|
+
raise ValueError(f"cache output path must be a normalized relative path: {relative_path!r}")
|
|
316
|
+
return path.as_posix()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _required_string(value: Mapping[str, object], key: str) -> str:
|
|
320
|
+
item = value.get(key)
|
|
321
|
+
if not isinstance(item, str) or not item:
|
|
322
|
+
raise ValueError(f"cache manifest {key} must be a non-empty string")
|
|
323
|
+
return item
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _validate_sha256(value: str, label: str) -> None:
|
|
327
|
+
if len(value) != 64 or any(character not in "0123456789abcdef" for character in value):
|
|
328
|
+
raise ValueError(f"{label} must be a lowercase SHA-256 digest")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _validate_importer_version(importer_version: str) -> None:
|
|
332
|
+
if not importer_version or len(importer_version) > 256:
|
|
333
|
+
raise ValueError("importer_version must contain between 1 and 256 characters")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Blender-side worker implementation."""
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Factory-clean Blender batch builder for curated evaluation assemblies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import bpy # type: ignore[import-not-found]
|
|
13
|
+
from mathutils import Vector # type: ignore[import-not-found]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def arguments() -> tuple[Path, Path]:
|
|
17
|
+
args = sys.argv[sys.argv.index("--") + 1 :]
|
|
18
|
+
if len(args) != 2:
|
|
19
|
+
raise ValueError("curated builder expects jobs.json and checkpoint.json")
|
|
20
|
+
return Path(args[0]).resolve(strict=True), Path(args[1]).resolve()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def clear_scene() -> None:
|
|
24
|
+
bpy.ops.object.select_all(action="SELECT")
|
|
25
|
+
bpy.ops.object.delete(use_global=False)
|
|
26
|
+
for collection in list(bpy.data.collections):
|
|
27
|
+
if collection.users == 0:
|
|
28
|
+
bpy.data.collections.remove(collection)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def imported_objects(path: Path) -> list[bpy.types.Object]:
|
|
32
|
+
before = set(bpy.data.objects)
|
|
33
|
+
result = bpy.ops.import_scene.gltf(filepath=str(path))
|
|
34
|
+
if "FINISHED" not in result:
|
|
35
|
+
raise RuntimeError(f"curated source import failed: {path}")
|
|
36
|
+
return [obj for obj in bpy.data.objects if obj not in before]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parent_preserving_world(obj: bpy.types.Object, parent: bpy.types.Object) -> None:
|
|
40
|
+
matrix = obj.matrix_world.copy()
|
|
41
|
+
obj.parent = parent
|
|
42
|
+
obj.matrix_world = matrix
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def mesh_span(objects: list[bpy.types.Object]) -> float:
|
|
46
|
+
points = [
|
|
47
|
+
obj.matrix_world @ Vector(corner)
|
|
48
|
+
for obj in objects
|
|
49
|
+
if obj.type == "MESH"
|
|
50
|
+
for corner in obj.bound_box
|
|
51
|
+
]
|
|
52
|
+
if not points:
|
|
53
|
+
raise ValueError("curated source contains no mesh objects")
|
|
54
|
+
minimum = Vector(min(point[axis] for point in points) for axis in range(3))
|
|
55
|
+
maximum = Vector(max(point[axis] for point in points) for axis in range(3))
|
|
56
|
+
return float(max((maximum - minimum).length, 1e-6))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def add_camera_and_light(variant: str) -> None:
|
|
60
|
+
camera_data = bpy.data.cameras.new("inspection_camera")
|
|
61
|
+
camera = bpy.data.objects.new("inspection_camera", camera_data)
|
|
62
|
+
bpy.context.scene.collection.objects.link(camera)
|
|
63
|
+
camera.location = (5.5, -8.0, 4.5)
|
|
64
|
+
camera.rotation_euler = (math.radians(67), 0, math.radians(34))
|
|
65
|
+
camera_data.lens = 24 if variant == "start_state" else 50
|
|
66
|
+
bpy.context.scene.camera = camera
|
|
67
|
+
light_data = bpy.data.lights.new("inspection_key", "AREA")
|
|
68
|
+
light_data.energy = 1_000 if variant == "start_state" else 700
|
|
69
|
+
light_data.shape = "DISK"
|
|
70
|
+
light_data.size = 5
|
|
71
|
+
light = bpy.data.objects.new("inspection_key", light_data)
|
|
72
|
+
bpy.context.scene.collection.objects.link(light)
|
|
73
|
+
light.location = (-4, -5, 6) if variant == "start_state" else (4, -5, 6)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build(job: dict[str, Any]) -> dict[str, object]:
|
|
77
|
+
clear_scene()
|
|
78
|
+
variant = str(job["variant"])
|
|
79
|
+
assembly_root = bpy.data.objects.new("assembly", None)
|
|
80
|
+
bpy.context.scene.collection.objects.link(assembly_root)
|
|
81
|
+
placements = list(job["placements"])
|
|
82
|
+
if variant == "reorder_hierarchy":
|
|
83
|
+
placements.reverse()
|
|
84
|
+
roles: dict[str, list[str]] = {}
|
|
85
|
+
for placement_index, placement in enumerate(placements):
|
|
86
|
+
role = str(placement["role"])
|
|
87
|
+
group = bpy.data.objects.new(f"group_{placement_index + 1:02d}", None)
|
|
88
|
+
bpy.context.scene.collection.objects.link(group)
|
|
89
|
+
group.parent = assembly_root
|
|
90
|
+
objects = imported_objects(Path(str(placement["path"])).resolve(strict=True))
|
|
91
|
+
top_level = [obj for obj in objects if obj.parent not in objects]
|
|
92
|
+
for obj in top_level:
|
|
93
|
+
parent_preserving_world(obj, group)
|
|
94
|
+
scale = 1 / mesh_span(objects)
|
|
95
|
+
group.scale = (scale, scale, scale)
|
|
96
|
+
group.location = tuple(float(value) / 1_000 for value in placement["position_mm"])
|
|
97
|
+
role_names: list[str] = []
|
|
98
|
+
meshes = sorted((obj for obj in objects if obj.type == "MESH"), key=lambda obj: obj.name)
|
|
99
|
+
for mesh_index, obj in enumerate(meshes, start=1):
|
|
100
|
+
suffix = f"part_{mesh_index:02d}" if variant == "rename" else obj.name
|
|
101
|
+
obj.name = f"{role}__{suffix}"
|
|
102
|
+
role_names.append(obj.name)
|
|
103
|
+
roles[role] = role_names
|
|
104
|
+
|
|
105
|
+
if variant == "rigid_transform":
|
|
106
|
+
assembly_root.location = (1.7, -0.8, 0.6)
|
|
107
|
+
assembly_root.rotation_euler[2] = math.radians(37)
|
|
108
|
+
if variant == "rescale":
|
|
109
|
+
assembly_root.scale = (0.1, 0.1, 0.1)
|
|
110
|
+
if variant == "mirror":
|
|
111
|
+
assembly_root.scale.x = -1
|
|
112
|
+
if variant == "material":
|
|
113
|
+
material = bpy.data.materials.new("curated_diagnostic_blue")
|
|
114
|
+
material.diffuse_color = (0.08, 0.25, 0.7, 1)
|
|
115
|
+
for obj in bpy.context.scene.objects:
|
|
116
|
+
if obj.type == "MESH":
|
|
117
|
+
obj.data.materials.clear()
|
|
118
|
+
obj.data.materials.append(material)
|
|
119
|
+
if variant == "extra_occluder":
|
|
120
|
+
bpy.ops.mesh.primitive_cube_add(location=(0.2, -0.55, 0.25), scale=(0.45, 0.12, 0.45))
|
|
121
|
+
occluder = bpy.context.active_object
|
|
122
|
+
occluder.name = "distractor__irrelevant_panel"
|
|
123
|
+
occluder.parent = assembly_root
|
|
124
|
+
roles["distractor"] = [occluder.name]
|
|
125
|
+
add_camera_and_light(variant)
|
|
126
|
+
output = Path(str(job["output"])).resolve()
|
|
127
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
result = bpy.ops.export_scene.gltf(
|
|
129
|
+
filepath=str(output),
|
|
130
|
+
export_format="GLB",
|
|
131
|
+
export_cameras=True,
|
|
132
|
+
export_lights=True,
|
|
133
|
+
)
|
|
134
|
+
if "FINISHED" not in result or not output.is_file():
|
|
135
|
+
raise RuntimeError(f"curated assembly export failed: {output}")
|
|
136
|
+
return {"roles": roles, "variant": variant, "model_file": output.name}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def atomic_json(path: Path, payload: object) -> None:
|
|
140
|
+
pending = path.with_name(f".{path.name}.pending")
|
|
141
|
+
pending.write_text(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n")
|
|
142
|
+
os.replace(pending, path)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main() -> None:
|
|
146
|
+
jobs_path, checkpoint_path = arguments()
|
|
147
|
+
payload = json.loads(jobs_path.read_text(encoding="utf-8"))
|
|
148
|
+
build_id = str(payload["build_id"])
|
|
149
|
+
jobs = list(payload["jobs"])
|
|
150
|
+
checkpoint: dict[str, Any] = {"build_id": build_id, "completed": []}
|
|
151
|
+
if checkpoint_path.is_file():
|
|
152
|
+
candidate = json.loads(checkpoint_path.read_text(encoding="utf-8"))
|
|
153
|
+
if candidate.get("build_id") == build_id:
|
|
154
|
+
checkpoint = candidate
|
|
155
|
+
completed = set(checkpoint.get("completed", []))
|
|
156
|
+
for job in jobs:
|
|
157
|
+
key = str(job["key"])
|
|
158
|
+
output = Path(str(job["output"]))
|
|
159
|
+
metadata = Path(str(job["metadata"]))
|
|
160
|
+
if key in completed and output.is_file() and metadata.is_file():
|
|
161
|
+
continue
|
|
162
|
+
result = build(job)
|
|
163
|
+
atomic_json(metadata, result)
|
|
164
|
+
completed.add(key)
|
|
165
|
+
atomic_json(
|
|
166
|
+
checkpoint_path,
|
|
167
|
+
{"build_id": build_id, "completed": sorted(completed)},
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
main()
|