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.
- lockwire/__init__.py +9 -0
- lockwire/__main__.py +9 -0
- lockwire/advice.py +119 -0
- lockwire/api.py +1216 -0
- lockwire/api_payload.py +985 -0
- lockwire/bench.py +182 -0
- lockwire/blame.py +100 -0
- lockwire/catalog.py +100 -0
- lockwire/catalog_seed.yaml +545 -0
- lockwire/checks.py +3483 -0
- lockwire/cli.py +1799 -0
- lockwire/cli_line.py +73 -0
- lockwire/constraints.py +313 -0
- lockwire/copy.py +302 -0
- lockwire/describe.py +682 -0
- lockwire/edit.py +2098 -0
- lockwire/emit_ts.py +150 -0
- lockwire/export_mhs.py +115 -0
- lockwire/factdiff.py +254 -0
- lockwire/forces.py +334 -0
- lockwire/gitref.py +188 -0
- lockwire/hw.py +388 -0
- lockwire/mcp_server.py +276 -0
- lockwire/mechanism.py +379 -0
- lockwire/mesh_dae.py +555 -0
- lockwire/mesh_simple.py +198 -0
- lockwire/page.py +1485 -0
- lockwire/plan.py +849 -0
- lockwire/plan_payload.py +151 -0
- lockwire/plugins.py +70 -0
- lockwire/report.py +848 -0
- lockwire/report_html.py +707 -0
- lockwire/report_json.py +168 -0
- lockwire/review_draft.py +315 -0
- lockwire/scan.py +732 -0
- lockwire/scene.py +535 -0
- lockwire/scene_import.py +309 -0
- lockwire/scene_urdf.py +750 -0
- lockwire/scene_view.py +376 -0
- lockwire/schema.py +3616 -0
- lockwire/sdf.py +240 -0
- lockwire/serve.py +1256 -0
- lockwire/start_operation.py +92 -0
- lockwire/statics.py +77 -0
- lockwire/units.py +115 -0
- lockwire/urdf.py +348 -0
- lockwire/vcs.py +402 -0
- lockwire/whatif.py +161 -0
- lockwire-0.2.0.dist-info/METADATA +192 -0
- lockwire-0.2.0.dist-info/RECORD +52 -0
- lockwire-0.2.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|