lockwire 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. lockwire/__init__.py +9 -0
  2. lockwire/__main__.py +9 -0
  3. lockwire/advice.py +119 -0
  4. lockwire/api.py +1216 -0
  5. lockwire/api_payload.py +985 -0
  6. lockwire/bench.py +182 -0
  7. lockwire/blame.py +100 -0
  8. lockwire/catalog.py +100 -0
  9. lockwire/catalog_seed.yaml +545 -0
  10. lockwire/checks.py +3483 -0
  11. lockwire/cli.py +1799 -0
  12. lockwire/cli_line.py +73 -0
  13. lockwire/constraints.py +313 -0
  14. lockwire/copy.py +302 -0
  15. lockwire/describe.py +682 -0
  16. lockwire/edit.py +2098 -0
  17. lockwire/emit_ts.py +150 -0
  18. lockwire/export_mhs.py +115 -0
  19. lockwire/factdiff.py +254 -0
  20. lockwire/forces.py +334 -0
  21. lockwire/gitref.py +188 -0
  22. lockwire/hw.py +388 -0
  23. lockwire/mcp_server.py +276 -0
  24. lockwire/mechanism.py +379 -0
  25. lockwire/mesh_dae.py +555 -0
  26. lockwire/mesh_simple.py +198 -0
  27. lockwire/page.py +1485 -0
  28. lockwire/plan.py +849 -0
  29. lockwire/plan_payload.py +151 -0
  30. lockwire/plugins.py +70 -0
  31. lockwire/report.py +848 -0
  32. lockwire/report_html.py +707 -0
  33. lockwire/report_json.py +168 -0
  34. lockwire/review_draft.py +315 -0
  35. lockwire/scan.py +732 -0
  36. lockwire/scene.py +535 -0
  37. lockwire/scene_import.py +309 -0
  38. lockwire/scene_urdf.py +750 -0
  39. lockwire/scene_view.py +376 -0
  40. lockwire/schema.py +3616 -0
  41. lockwire/sdf.py +240 -0
  42. lockwire/serve.py +1256 -0
  43. lockwire/start_operation.py +92 -0
  44. lockwire/statics.py +77 -0
  45. lockwire/units.py +115 -0
  46. lockwire/urdf.py +348 -0
  47. lockwire/vcs.py +402 -0
  48. lockwire/whatif.py +161 -0
  49. lockwire-0.2.0.dist-info/METADATA +192 -0
  50. lockwire-0.2.0.dist-info/RECORD +52 -0
  51. lockwire-0.2.0.dist-info/WHEEL +4 -0
  52. lockwire-0.2.0.dist-info/entry_points.txt +3 -0
lockwire/scene.py ADDED
@@ -0,0 +1,535 @@
1
+ """Stdlib glTF 2.0 reader for the scene door (a subset; ids in the extras join key).
2
+
3
+ Accepts `.glb` (binary container) and `.gltf` with either a relative sidecar `.bin` or a
4
+ `data:` base64 URI buffer. No third-party glTF library (no tool-specific dependency) —
5
+ `json`, `struct`, `base64`, `math`, `pathlib`, `dataclasses` only.
6
+
7
+ Refusal posture mirrors `urdf.py`'s DOCTYPE/xacro refusals: refuse on raw bytes/structure
8
+ before any geometry is trusted, name the specific defect, and say the remedy.
9
+ """
10
+
11
+ import base64
12
+ import hashlib
13
+ import json
14
+ import math
15
+ import struct
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Literal
19
+
20
+ _MAX_FILE_BYTES = 64 * 1024 * 1024
21
+ _MAX_JSON_BYTES = 16 * 1024 * 1024
22
+
23
+ _COMPONENT_FORMATS = {
24
+ 5121: ("B", 1), # UNSIGNED_BYTE
25
+ 5123: ("H", 2), # UNSIGNED_SHORT
26
+ 5125: ("I", 4), # UNSIGNED_INT
27
+ 5126: ("f", 4), # FLOAT
28
+ }
29
+ _TYPE_COUNTS = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}
30
+ _TRIANGLES_MODE = 4
31
+
32
+
33
+ class SceneRefusal(ValueError):
34
+ """The scene file is malformed or uses an unsupported feature; message names the
35
+ defect and the remedy. The CLI maps this to exit 2."""
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ScenePart:
40
+ node: str
41
+ extras_id: str | None
42
+ name_chain: tuple[str, ...]
43
+ positions: tuple[float, ...]
44
+ normals: tuple[float, ...] | None
45
+ indices: tuple[int, ...]
46
+ color: tuple[float, float, float, float]
47
+ translation: tuple[float, float, float]
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Scene:
52
+ parts: tuple[ScenePart, ...]
53
+ up: Literal["y", "z"]
54
+ nodes_with_ids: dict[str, str]
55
+ source: str
56
+ bounds: tuple[tuple[float, float, float], tuple[float, float, float]]
57
+ triangles: int
58
+ vertices: int
59
+ skipped: tuple[str, ...] = field(default_factory=tuple)
60
+ asset_path: str | None = None
61
+ asset_sha256: str | None = None
62
+
63
+
64
+ # --------------------------------------------------------------------------- containers
65
+
66
+
67
+ def _read_glb(raw: bytes, path: Path) -> tuple[dict, bytes | None]:
68
+ if len(raw) < 12 or raw[0:4] != b"glTF":
69
+ raise SceneRefusal(
70
+ f"{path}: not a valid GLB — bad magic header (expected b'glTF'). "
71
+ "Re-export as glTF 2.0 binary (.glb)."
72
+ )
73
+ magic, version, length = struct.unpack_from("<4sII", raw, 0)
74
+ if version != 2:
75
+ raise SceneRefusal(
76
+ f"{path}: unsupported glTF binary version {version} (only version 2 is supported)."
77
+ )
78
+ if length > len(raw):
79
+ raise SceneRefusal(
80
+ f"{path}: truncated GLB — header declares {length} bytes but the file has "
81
+ f"{len(raw)}. Re-export the file."
82
+ )
83
+ offset = 12
84
+ json_chunk: bytes | None = None
85
+ bin_chunk: bytes | None = None
86
+ while offset + 8 <= length:
87
+ chunk_len, chunk_type = struct.unpack_from("<II", raw, offset)
88
+ chunk_start = offset + 8
89
+ chunk_end = chunk_start + chunk_len
90
+ if chunk_end > length:
91
+ raise SceneRefusal(f"{path}: truncated GLB — a chunk overruns the declared length.")
92
+ chunk_data = raw[chunk_start:chunk_end]
93
+ if chunk_type == 0x4E4F534A: # 'JSON'
94
+ if len(chunk_data) > _MAX_JSON_BYTES:
95
+ raise SceneRefusal(
96
+ f"{path}: GLB JSON chunk is {len(chunk_data)} bytes, over the "
97
+ f"{_MAX_JSON_BYTES}-byte guard. Refusing rather than parse an "
98
+ "unexpectedly large document."
99
+ )
100
+ json_chunk = chunk_data
101
+ elif chunk_type == 0x004E4942: # 'BIN\0'
102
+ bin_chunk = chunk_data
103
+ offset = chunk_end
104
+ if json_chunk is None:
105
+ raise SceneRefusal(f"{path}: GLB has no JSON chunk.")
106
+ try:
107
+ doc = json.loads(json_chunk)
108
+ except json.JSONDecodeError as exc:
109
+ raise SceneRefusal(f"{path}: GLB JSON chunk is not valid JSON ({exc}).") from exc
110
+ return doc, bin_chunk
111
+
112
+
113
+ def _refuse_buffer_uri(uri: str, base_dir: Path, path: Path) -> None:
114
+ if uri.startswith("data:"):
115
+ return
116
+ if uri.startswith("http://") or uri.startswith("https://") or uri.startswith("file:"):
117
+ raise SceneRefusal(
118
+ f"{path}: buffer URI {uri!r} is a network/file URI; only a relative sidecar "
119
+ "path or a data: URI is accepted. Re-export with a local relative buffer."
120
+ )
121
+ if Path(uri).is_absolute():
122
+ raise SceneRefusal(
123
+ f"{path}: buffer URI {uri!r} is an absolute path; only a relative sidecar "
124
+ "path is accepted. Re-export with a relative buffer path."
125
+ )
126
+ resolved = (base_dir / uri).resolve()
127
+ try:
128
+ resolved.relative_to(base_dir.resolve())
129
+ except ValueError as exc:
130
+ raise SceneRefusal(
131
+ f"{path}: buffer URI {uri!r} escapes the scene file's directory. "
132
+ "Keep buffer files alongside the .gltf."
133
+ ) from exc
134
+
135
+
136
+ def _load_buffer(uri: str, base_dir: Path, path: Path) -> bytes:
137
+ _refuse_buffer_uri(uri, base_dir, path)
138
+ if uri.startswith("data:"):
139
+ _, _, data_part = uri.partition(",")
140
+ try:
141
+ return base64.b64decode(data_part)
142
+ except Exception as exc: # noqa: BLE001 - report as a scene refusal, not a crash
143
+ raise SceneRefusal(f"{path}: buffer data: URI is not valid base64.") from exc
144
+ sidecar = base_dir / uri
145
+ if not sidecar.is_file():
146
+ raise SceneRefusal(f"{path}: buffer sidecar {uri!r} does not exist next to the scene file.")
147
+ return sidecar.read_bytes()
148
+
149
+
150
+ def _read_gltf_json(path: Path, raw: bytes) -> dict:
151
+ if len(raw) > _MAX_JSON_BYTES:
152
+ raise SceneRefusal(
153
+ f"{path}: .gltf JSON is {len(raw)} bytes, over the {_MAX_JSON_BYTES}-byte guard."
154
+ )
155
+ try:
156
+ return json.loads(raw)
157
+ except json.JSONDecodeError as exc:
158
+ raise SceneRefusal(f"{path}: not valid JSON ({exc}).") from exc
159
+
160
+
161
+ # --------------------------------------------------------------------------- accessors
162
+
163
+
164
+ def _accessor_data(doc: dict, index: int, buffers: list[bytes], path: Path) -> list[float | int]:
165
+ accessors = doc.get("accessors", [])
166
+ if index < 0 or index >= len(accessors):
167
+ raise SceneRefusal(f"{path}: accessor index {index} out of range.")
168
+ acc = accessors[index]
169
+ if "sparse" in acc:
170
+ raise SceneRefusal(
171
+ f"{path}: accessor {index} is sparse; sparse accessors are not supported. "
172
+ "Re-export without sparse accessors."
173
+ )
174
+ if acc.get("normalized"):
175
+ raise SceneRefusal(
176
+ f"{path}: accessor {index} sets `normalized`, which is not supported. "
177
+ "Re-export with un-normalized accessors."
178
+ )
179
+ component_type = acc["componentType"]
180
+ if component_type not in _COMPONENT_FORMATS:
181
+ raise SceneRefusal(
182
+ f"{path}: accessor {index} has unsupported componentType {component_type}."
183
+ )
184
+ fmt_char, comp_size = _COMPONENT_FORMATS[component_type]
185
+ acc_type = acc["type"]
186
+ if acc_type not in _TYPE_COUNTS:
187
+ raise SceneRefusal(f"{path}: accessor {index} has unsupported type {acc_type!r}.")
188
+ n_components = _TYPE_COUNTS[acc_type]
189
+ count = acc["count"]
190
+
191
+ bv_index = acc.get("bufferView")
192
+ if bv_index is None:
193
+ # Accessor with no bufferView is legal (implies zero-filled); not needed for our subset.
194
+ return [0.0] * (count * n_components)
195
+ buffer_views = doc.get("bufferViews", [])
196
+ if bv_index < 0 or bv_index >= len(buffer_views):
197
+ raise SceneRefusal(f"{path}: accessor {index} references missing bufferView {bv_index}.")
198
+ bv = buffer_views[bv_index]
199
+ buf_index = bv.get("buffer", 0)
200
+ if buf_index < 0 or buf_index >= len(buffers):
201
+ raise SceneRefusal(f"{path}: bufferView {bv_index} references missing buffer {buf_index}.")
202
+ buf = buffers[buf_index]
203
+ base_offset = bv.get("byteOffset", 0) + acc.get("byteOffset", 0)
204
+ default_stride = n_components * comp_size
205
+ stride = bv.get("byteStride", default_stride)
206
+
207
+ out: list[float | int] = []
208
+ struct_fmt = "<" + fmt_char * n_components
209
+ elem_size = n_components * comp_size
210
+ for i in range(count):
211
+ offset = base_offset + i * stride
212
+ if offset + elem_size > len(buf):
213
+ raise SceneRefusal(
214
+ f"{path}: accessor {index} reads past the end of its buffer (truncated file)."
215
+ )
216
+ out.extend(struct.unpack_from(struct_fmt, buf, offset))
217
+ return out
218
+
219
+
220
+ # --------------------------------------------------------------------------- transforms
221
+
222
+
223
+ def _quat_to_matrix(x: float, y: float, z: float, w: float) -> list[list[float]]:
224
+ xx, yy, zz = x * x, y * y, z * z
225
+ xy, xz, yz = x * y, x * z, y * z
226
+ wx, wy, wz = w * x, w * y, w * z
227
+ return [
228
+ [1 - 2 * (yy + zz), 2 * (xy - wz), 2 * (xz + wy)],
229
+ [2 * (xy + wz), 1 - 2 * (xx + zz), 2 * (yz - wx)],
230
+ [2 * (xz - wy), 2 * (yz + wx), 1 - 2 * (xx + yy)],
231
+ ]
232
+
233
+
234
+ def _mat4_mul(a: list[list[float]], b: list[list[float]]) -> list[list[float]]:
235
+ return [[sum(a[i][k] * b[k][j] for k in range(4)) for j in range(4)] for i in range(4)]
236
+
237
+
238
+ def _identity4() -> list[list[float]]:
239
+ return [[1.0 if i == j else 0.0 for j in range(4)] for i in range(4)]
240
+
241
+
242
+ def _node_local_matrix(node: dict) -> list[list[float]]:
243
+ if "matrix" in node:
244
+ m = node["matrix"] # column-major, 16 floats
245
+ return [[m[c * 4 + r] for c in range(4)] for r in range(4)]
246
+ t = node.get("translation", [0.0, 0.0, 0.0])
247
+ r = node.get("rotation", [0.0, 0.0, 0.0, 1.0])
248
+ s = node.get("scale", [1.0, 1.0, 1.0])
249
+ rot3 = _quat_to_matrix(*r)
250
+ mat = _identity4()
251
+ for i in range(3):
252
+ for j in range(3):
253
+ mat[i][j] = rot3[i][j] * s[j]
254
+ mat[i][3] = t[i]
255
+ return mat
256
+
257
+
258
+ def _apply_matrix(mat: list[list[float]], point: tuple[float, float, float], w: float = 1.0):
259
+ x, y, z = point
260
+ out = []
261
+ for i in range(3):
262
+ out.append(mat[i][0] * x + mat[i][1] * y + mat[i][2] * z + mat[i][3] * w)
263
+ return tuple(out)
264
+
265
+
266
+ def _yup_to_zup(x: float, y: float, z: float) -> tuple[float, float, float]:
267
+ return (x, -z, y)
268
+
269
+
270
+ # --------------------------------------------------------------------------- ids / hierarchy
271
+
272
+
273
+ def _extras_id(extras: dict | None) -> str | None:
274
+ if not extras:
275
+ return None
276
+ lockwire = extras.get("lockwire")
277
+ if isinstance(lockwire, dict) and isinstance(lockwire.get("id"), str):
278
+ return lockwire["id"]
279
+ flat = extras.get("lockwire_id")
280
+ if isinstance(flat, str):
281
+ return flat
282
+ return None
283
+
284
+
285
+ @dataclass
286
+ class _Walked:
287
+ node_index: int
288
+ name_chain: tuple[str, ...]
289
+ owning_id: str | None # nearest ancestor-or-self extras id
290
+ owning_id_node_index: int | None
291
+ world: list[list[float]]
292
+
293
+
294
+ def _walk(
295
+ doc: dict,
296
+ node_indices: list[int],
297
+ parent_world: list[list[float]],
298
+ parent_names: tuple[str, ...],
299
+ parent_id: str | None,
300
+ parent_id_node_index: int | None,
301
+ out: list[_Walked],
302
+ ids_seen: dict[str, list[int]],
303
+ ) -> None:
304
+ nodes = doc.get("nodes", [])
305
+ for idx in node_indices:
306
+ node = nodes[idx]
307
+ local = _node_local_matrix(node)
308
+ world = _mat4_mul(parent_world, local)
309
+ name = node.get("name", "")
310
+ name_chain = parent_names + (name,)
311
+ extras = node.get("extras")
312
+ own_id = _extras_id(extras)
313
+ if own_id is not None:
314
+ ids_seen.setdefault(own_id, []).append(idx)
315
+ owning_id, owning_id_node_index = own_id, idx
316
+ else:
317
+ # Name-fallback join (a node named after a contract id) is resolved by
318
+ # the caller against the contract; the reader only records name_chain.
319
+ owning_id, owning_id_node_index = parent_id, parent_id_node_index
320
+ out.append(_Walked(idx, name_chain, owning_id, owning_id_node_index, world))
321
+ children = node.get("children", [])
322
+ if children:
323
+ _walk(doc, children, world, name_chain, owning_id, owning_id_node_index, out, ids_seen)
324
+
325
+
326
+ def _check_duplicate_ids(ids_seen: dict[str, list[int]], doc: dict, path: Path) -> None:
327
+ nodes = doc.get("nodes", [])
328
+ # Build a parent map to test ancestor relationships.
329
+ parent_of: dict[int, int] = {}
330
+ for i, node in enumerate(nodes):
331
+ for c in node.get("children", []):
332
+ parent_of[c] = i
333
+
334
+ def is_ancestor(a: int, b: int) -> bool:
335
+ cur = b
336
+ while cur in parent_of:
337
+ cur = parent_of[cur]
338
+ if cur == a:
339
+ return True
340
+ return False
341
+
342
+ for lw_id, indices in ids_seen.items():
343
+ if len(indices) < 2:
344
+ continue
345
+ for i in range(len(indices)):
346
+ for j in range(i + 1, len(indices)):
347
+ a, b = indices[i], indices[j]
348
+ if a == b or is_ancestor(a, b) or is_ancestor(b, a):
349
+ continue
350
+ raise SceneRefusal(
351
+ f"{path}: two parts in the scene both claim `{lw_id}`; give each part one id."
352
+ )
353
+
354
+
355
+ # --------------------------------------------------------------------------- top level
356
+
357
+
358
+ def load_scene(path: Path) -> Scene:
359
+ path = Path(path)
360
+ if not path.is_file():
361
+ raise SceneRefusal(f"{path}: file does not exist.")
362
+ size = path.stat().st_size
363
+ if size > _MAX_FILE_BYTES:
364
+ raise SceneRefusal(
365
+ f"{path}: file is {size} bytes, over the {_MAX_FILE_BYTES}-byte guard. "
366
+ "Re-export a smaller scene or split it."
367
+ )
368
+ raw = path.read_bytes()
369
+
370
+ buffers: list[bytes]
371
+ if path.suffix.lower() == ".glb":
372
+ doc, bin_chunk = _read_glb(raw, path)
373
+ buffers = [bin_chunk if bin_chunk is not None else b""]
374
+ for i in range(1, len(doc.get("buffers", []))):
375
+ raise SceneRefusal(f"{path}: GLB with more than one external buffer is not supported.")
376
+ elif path.suffix.lower() == ".gltf":
377
+ doc = _read_gltf_json(path, raw)
378
+ buffers = []
379
+ for buf_def in doc.get("buffers", []):
380
+ uri = buf_def.get("uri")
381
+ if uri is None:
382
+ raise SceneRefusal(f"{path}: .gltf buffer has no uri and this is not a .glb.")
383
+ buffers.append(_load_buffer(uri, path.parent, path))
384
+ else:
385
+ raise SceneRefusal(
386
+ f"{path}: unsupported file extension {path.suffix!r} (expected .glb or .gltf)."
387
+ )
388
+
389
+ ext_required = doc.get("extensionsRequired") or []
390
+ if ext_required:
391
+ names = ", ".join(ext_required)
392
+ raise SceneRefusal(
393
+ f"{path}: scene requires extensions this reader does not implement: {names}. "
394
+ "Re-export without them."
395
+ )
396
+
397
+ asset_up = ((doc.get("asset") or {}).get("extras") or {}).get("lockwire", {}).get("up")
398
+ scene_index = doc.get("scene", 0)
399
+ scenes = doc.get("scenes", [])
400
+ if scene_index < 0 or scene_index >= len(scenes):
401
+ raise SceneRefusal(f"{path}: default scene index {scene_index} out of range.")
402
+ root_indices = scenes[scene_index].get("nodes", [])
403
+
404
+ up: Literal["y", "z"] = "z" if asset_up == "z" else "y"
405
+
406
+ walked: list[_Walked] = []
407
+ ids_seen: dict[str, list[int]] = {}
408
+ _walk(doc, root_indices, _identity4(), (), None, None, walked, ids_seen)
409
+ _check_duplicate_ids(ids_seen, doc, path)
410
+
411
+ nodes_with_ids = {
412
+ lw_id: doc["nodes"][idxs[0]].get("name", "") for lw_id, idxs in ids_seen.items()
413
+ }
414
+
415
+ materials = doc.get("materials", [])
416
+ meshes = doc.get("meshes", [])
417
+ nodes = doc.get("nodes", [])
418
+
419
+ parts: list[ScenePart] = []
420
+ skipped: list[str] = []
421
+ total_tris = 0
422
+ total_verts = 0
423
+ min_b = [math.inf, math.inf, math.inf]
424
+ max_b = [-math.inf, -math.inf, -math.inf]
425
+
426
+ def convert(p: tuple[float, float, float]) -> tuple[float, float, float]:
427
+ return p if up == "z" else _yup_to_zup(*p)
428
+
429
+ for w in walked:
430
+ node = nodes[w.node_index]
431
+ mesh_index = node.get("mesh")
432
+ if mesh_index is None:
433
+ continue
434
+ mesh = meshes[mesh_index]
435
+ for prim in mesh.get("primitives", []):
436
+ mode = prim.get("mode", _TRIANGLES_MODE)
437
+ if mode != _TRIANGLES_MODE:
438
+ skipped.append(node.get("name", ""))
439
+ continue
440
+ attrs = prim.get("attributes", {})
441
+ if "POSITION" not in attrs:
442
+ raise SceneRefusal(
443
+ f"{path}: primitive on node {node.get('name', '')!r} has no POSITION accessor."
444
+ )
445
+ raw_pos = _accessor_data(doc, attrs["POSITION"], buffers, path)
446
+ n_verts = len(raw_pos) // 3
447
+ world_positions: list[float] = []
448
+ for i in range(n_verts):
449
+ local_pt = (raw_pos[3 * i], raw_pos[3 * i + 1], raw_pos[3 * i + 2])
450
+ world_pt = _apply_matrix(w.world, local_pt, 1.0)
451
+ gx, gy, gz = convert(world_pt)
452
+ world_positions.extend((gx, gy, gz))
453
+ min_b[0] = min(min_b[0], gx)
454
+ min_b[1] = min(min_b[1], gy)
455
+ min_b[2] = min(min_b[2], gz)
456
+ max_b[0] = max(max_b[0], gx)
457
+ max_b[1] = max(max_b[1], gy)
458
+ max_b[2] = max(max_b[2], gz)
459
+
460
+ normals_tuple: tuple[float, ...] | None = None
461
+ if "NORMAL" in attrs:
462
+ raw_norm = _accessor_data(doc, attrs["NORMAL"], buffers, path)
463
+ world_normals: list[float] = []
464
+ for i in range(len(raw_norm) // 3):
465
+ local_n = (raw_norm[3 * i], raw_norm[3 * i + 1], raw_norm[3 * i + 2])
466
+ world_n = _apply_matrix(w.world, local_n, 0.0)
467
+ world_normals.extend(convert(world_n))
468
+ normals_tuple = tuple(world_normals)
469
+
470
+ indices_index = prim.get("indices")
471
+ if indices_index is not None:
472
+ idx_data = _accessor_data(doc, indices_index, buffers, path)
473
+ indices = tuple(int(i) for i in idx_data)
474
+ else:
475
+ indices = tuple(range(n_verts))
476
+ if len(indices) % 3 != 0:
477
+ raise SceneRefusal(
478
+ f"{path}: primitive on node {node.get('name', '')!r} has "
479
+ f"{len(indices)} indices, not a multiple of 3 (mode 4 requires triangles)."
480
+ )
481
+ if indices and max(indices) >= n_verts:
482
+ raise SceneRefusal(
483
+ f"{path}: index {max(indices)} points past the {n_verts} vertices of "
484
+ f"node {node.get('name', '')!r}; re-export the mesh."
485
+ )
486
+
487
+ mat_index = prim.get("material")
488
+ color = (0.8, 0.8, 0.8, 1.0)
489
+ if mat_index is not None and 0 <= mat_index < len(materials):
490
+ bcf = (materials[mat_index].get("pbrMetallicRoughness") or {}).get(
491
+ "baseColorFactor"
492
+ )
493
+ if bcf is not None:
494
+ color = tuple(float(c) for c in bcf) # type: ignore[assignment]
495
+
496
+ if w.owning_id_node_index is not None:
497
+ owning_world = next(
498
+ ww.world for ww in walked if ww.node_index == w.owning_id_node_index
499
+ )
500
+ ox, oy, oz = _apply_matrix(owning_world, (0.0, 0.0, 0.0), 1.0)
501
+ else:
502
+ ox, oy, oz = _apply_matrix(w.world, (0.0, 0.0, 0.0), 1.0)
503
+ translation = convert((ox, oy, oz))
504
+
505
+ parts.append(
506
+ ScenePart(
507
+ node=node.get("name", ""),
508
+ extras_id=w.owning_id,
509
+ name_chain=w.name_chain,
510
+ positions=tuple(world_positions),
511
+ normals=normals_tuple,
512
+ indices=indices,
513
+ color=color, # type: ignore[arg-type]
514
+ translation=translation,
515
+ )
516
+ )
517
+ total_tris += len(indices) // 3
518
+ total_verts += n_verts
519
+
520
+ if not parts:
521
+ min_b = [0.0, 0.0, 0.0]
522
+ max_b = [0.0, 0.0, 0.0]
523
+
524
+ return Scene(
525
+ parts=tuple(parts),
526
+ up=up,
527
+ nodes_with_ids=nodes_with_ids,
528
+ source=path.name,
529
+ asset_path=str(path.resolve()),
530
+ asset_sha256=hashlib.sha256(raw).hexdigest(),
531
+ bounds=(tuple(min_b), tuple(max_b)), # type: ignore[arg-type]
532
+ triangles=total_tris,
533
+ vertices=total_verts,
534
+ skipped=tuple(skipped),
535
+ )