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/mesh_dae.py
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
"""Stdlib Collada (DAE) mesh reader.
|
|
2
|
+
|
|
3
|
+
Reads `<library_geometries>` (`<triangles>` and `<polylist>`, fan-triangulated) composed
|
|
4
|
+
through the `<library_visual_scenes>` node tree (`<matrix>`/`<translate>`/`<rotate>`/`<scale>`,
|
|
5
|
+
`<instance_node>` indirection), converting the file's declared up-axis and unit into metres,
|
|
6
|
+
Z-up. Namespace-agnostic (Collada documents are commonly namespaced,
|
|
7
|
+
`http://www.collada.org/2005/11/COLLADASchema`); tags are matched after stripping `{...}`.
|
|
8
|
+
|
|
9
|
+
stdlib only: `xml.etree.ElementTree`, `math`, `pathlib`. Refusal posture mirrors
|
|
10
|
+
`urdf.py`'s `_refuse_doctype_entity` (checked on raw bytes before any XML parser sees them)
|
|
11
|
+
and reuses `scene.py`'s `SceneRefusal` — plain words, never a raw exception.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
import xml.etree.ElementTree as ET
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from lockwire.scene import SceneRefusal
|
|
20
|
+
|
|
21
|
+
_MAX_FILE_BYTES = 64 * 1024 * 1024
|
|
22
|
+
_MAX_INSTANCE_NODE_DEPTH = 32
|
|
23
|
+
|
|
24
|
+
Mesh = tuple[tuple[float, ...], tuple[float, ...] | None, tuple[int, ...]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class DaeResult:
|
|
29
|
+
meshes: tuple[Mesh, ...]
|
|
30
|
+
skipped: tuple[str, ...]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _tag(el: ET.Element) -> str:
|
|
34
|
+
t = el.tag
|
|
35
|
+
return t.split("}", 1)[1] if t.startswith("{") else t
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _children(el: ET.Element, name: str) -> list[ET.Element]:
|
|
39
|
+
return [c for c in el if _tag(c) == name]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _child(el: ET.Element, name: str) -> ET.Element | None:
|
|
43
|
+
for c in el:
|
|
44
|
+
if _tag(c) == name:
|
|
45
|
+
return c
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _refuse_doctype_entity(raw: bytes, path: Path) -> None:
|
|
50
|
+
"""Same posture as `urdf._refuse_doctype_entity`: refuse on raw bytes before parsing —
|
|
51
|
+
an internal `<!ENTITY>` still expands under stdlib `xml.etree`, so refusing before the
|
|
52
|
+
parser ever sees it is the only safe posture."""
|
|
53
|
+
if b"<!DOCTYPE" in raw or b"<!ENTITY" in raw:
|
|
54
|
+
raise SceneRefusal(
|
|
55
|
+
f"{path}: DOCTYPE/ENTITY declarations are refused (XML entity expansion risk). "
|
|
56
|
+
"Re-export the file without an internal DTD."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_dae(path: Path) -> DaeResult:
|
|
61
|
+
"""Read a Collada (.dae) file's visual-scene geometry as metres, Z-up.
|
|
62
|
+
|
|
63
|
+
Raises `SceneRefusal` on anything malformed or unsupported, naming the defect and the
|
|
64
|
+
remedy; never propagates a raw parser exception.
|
|
65
|
+
"""
|
|
66
|
+
raw = path.read_bytes()
|
|
67
|
+
if len(raw) > _MAX_FILE_BYTES:
|
|
68
|
+
raise SceneRefusal(f"{path}: file is over the 64 MiB size guard. Re-export a smaller mesh.")
|
|
69
|
+
_refuse_doctype_entity(raw, path)
|
|
70
|
+
try:
|
|
71
|
+
root = ET.fromstring(raw)
|
|
72
|
+
except ET.ParseError as exc:
|
|
73
|
+
raise SceneRefusal(f"{path}: not valid XML ({exc}). Re-export the file.") from exc
|
|
74
|
+
if _tag(root) != "COLLADA":
|
|
75
|
+
raise SceneRefusal(
|
|
76
|
+
f"{path}: root element is <{_tag(root)}>, expected <COLLADA>. "
|
|
77
|
+
"This is not a Collada (.dae) file."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
unit_scale = 1.0
|
|
81
|
+
up_axis = "Y_UP"
|
|
82
|
+
asset = _child(root, "asset")
|
|
83
|
+
if asset is not None:
|
|
84
|
+
unit_el = _child(asset, "unit")
|
|
85
|
+
if unit_el is not None and unit_el.get("meter") is not None:
|
|
86
|
+
unit_scale = float(unit_el.get("meter"))
|
|
87
|
+
up_el = _child(asset, "up_axis")
|
|
88
|
+
if up_el is not None and up_el.text:
|
|
89
|
+
up_axis = up_el.text.strip()
|
|
90
|
+
|
|
91
|
+
geometries = _parse_geometries(root, path)
|
|
92
|
+
nodes_by_id = _index_nodes(root, path)
|
|
93
|
+
|
|
94
|
+
visual_scene = _find_visual_scene(root, path)
|
|
95
|
+
skipped: list[str] = []
|
|
96
|
+
meshes: list[Mesh]
|
|
97
|
+
if visual_scene is None:
|
|
98
|
+
# No visual scene at all -> every geometry once, identity transform.
|
|
99
|
+
meshes = [
|
|
100
|
+
_apply_transform(geom, _IDENTITY, unit_scale, up_axis) for geom in geometries.values()
|
|
101
|
+
]
|
|
102
|
+
else:
|
|
103
|
+
meshes = []
|
|
104
|
+
for node in _children(visual_scene, "node"):
|
|
105
|
+
_walk_node(
|
|
106
|
+
node,
|
|
107
|
+
_IDENTITY,
|
|
108
|
+
geometries,
|
|
109
|
+
nodes_by_id,
|
|
110
|
+
unit_scale,
|
|
111
|
+
up_axis,
|
|
112
|
+
meshes,
|
|
113
|
+
skipped,
|
|
114
|
+
path,
|
|
115
|
+
depth=0,
|
|
116
|
+
)
|
|
117
|
+
return DaeResult(meshes=tuple(meshes), skipped=tuple(skipped))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --------------------------------------------------------------------------- geometry parsing
|
|
121
|
+
|
|
122
|
+
_GeomData = tuple[
|
|
123
|
+
list[tuple[float, float, float]], list[tuple[float, float, float]] | None, list[int]
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_float_array(source: ET.Element, path: Path) -> list[float]:
|
|
128
|
+
arr = _child(source, "float_array")
|
|
129
|
+
if arr is None or not arr.text:
|
|
130
|
+
return []
|
|
131
|
+
return [float(x) for x in arr.text.split()]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _source_stride(source: ET.Element, path: Path) -> tuple[list[float], int]:
|
|
135
|
+
values = _parse_float_array(source, path)
|
|
136
|
+
tc = _child(source, "technique_common")
|
|
137
|
+
stride = 3
|
|
138
|
+
if tc is not None:
|
|
139
|
+
accessor = _child(tc, "accessor")
|
|
140
|
+
if accessor is not None:
|
|
141
|
+
stride = int(accessor.get("stride", "3"))
|
|
142
|
+
if stride <= 0:
|
|
143
|
+
raise SceneRefusal(
|
|
144
|
+
f"{path}: source {source.get('id')!r} has stride {stride} (must be > 0)."
|
|
145
|
+
)
|
|
146
|
+
return values, stride
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _parse_geometries(root: ET.Element, path: Path) -> dict[str, _GeomData]:
|
|
150
|
+
result: dict[str, _GeomData] = {}
|
|
151
|
+
for lib in _children(root, "library_geometries"):
|
|
152
|
+
for geom in _children(lib, "geometry"):
|
|
153
|
+
geom_id = geom.get("id")
|
|
154
|
+
mesh_el = _child(geom, "mesh")
|
|
155
|
+
if geom_id is None or mesh_el is None:
|
|
156
|
+
continue
|
|
157
|
+
result[geom_id] = _parse_mesh(mesh_el, path, geom_id)
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _sources_by_id(mesh_el: ET.Element) -> dict[str, ET.Element]:
|
|
162
|
+
return {s.get("id"): s for s in _children(mesh_el, "source") if s.get("id")}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _vertices_position_source(mesh_el: ET.Element, sources: dict[str, ET.Element]) -> str | None:
|
|
166
|
+
vertices = _child(mesh_el, "vertices")
|
|
167
|
+
if vertices is None:
|
|
168
|
+
return None
|
|
169
|
+
for inp in _children(vertices, "input"):
|
|
170
|
+
if inp.get("semantic") == "POSITION":
|
|
171
|
+
return inp.get("source", "").lstrip("#")
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _parse_mesh(mesh_el: ET.Element, path: Path, geom_id: str) -> _GeomData:
|
|
176
|
+
if _child(mesh_el, "polygons") is not None:
|
|
177
|
+
raise SceneRefusal(
|
|
178
|
+
f"{path}: geometry {geom_id!r} uses the <polygons> form, which is not "
|
|
179
|
+
"supported. Re-export as triangles (or polylist)."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
sources = _sources_by_id(mesh_el)
|
|
183
|
+
vertices_id = "vertices-of-" + geom_id
|
|
184
|
+
vertices_el = _child(mesh_el, "vertices")
|
|
185
|
+
if vertices_el is not None and vertices_el.get("id"):
|
|
186
|
+
vertices_id = vertices_el.get("id")
|
|
187
|
+
|
|
188
|
+
positions_out: list[tuple[float, float, float]] = []
|
|
189
|
+
normals_out: list[tuple[float, float, float]] | None = None
|
|
190
|
+
indices_out: list[int] = []
|
|
191
|
+
|
|
192
|
+
for prim_name in ("triangles", "polylist"):
|
|
193
|
+
for prim in _children(mesh_el, prim_name):
|
|
194
|
+
inputs = _children(prim, "input")
|
|
195
|
+
offsets: dict[str, tuple[int, str]] = {}
|
|
196
|
+
max_offset = 0
|
|
197
|
+
for inp in inputs:
|
|
198
|
+
semantic = inp.get("semantic")
|
|
199
|
+
offset = int(inp.get("offset", "0"))
|
|
200
|
+
src = inp.get("source", "").lstrip("#")
|
|
201
|
+
if semantic == "VERTEX" and src == vertices_id:
|
|
202
|
+
src = _vertices_position_source(mesh_el, sources) or src
|
|
203
|
+
if semantic in ("VERTEX", "NORMAL"):
|
|
204
|
+
offsets[semantic] = (offset, src)
|
|
205
|
+
max_offset = max(max_offset, offset)
|
|
206
|
+
stride = max_offset + 1
|
|
207
|
+
|
|
208
|
+
if "VERTEX" not in offsets:
|
|
209
|
+
continue
|
|
210
|
+
pos_offset, pos_src_id = offsets["VERTEX"]
|
|
211
|
+
pos_source = sources.get(pos_src_id)
|
|
212
|
+
if pos_source is None:
|
|
213
|
+
raise SceneRefusal(
|
|
214
|
+
f"{path}: geometry {geom_id!r} references missing source {pos_src_id!r}."
|
|
215
|
+
)
|
|
216
|
+
pos_values, pos_stride = _source_stride(pos_source, path)
|
|
217
|
+
pos_count = len(pos_values) // pos_stride
|
|
218
|
+
|
|
219
|
+
norm_offset = norm_values = norm_stride = norm_count = None
|
|
220
|
+
if "NORMAL" in offsets:
|
|
221
|
+
n_offset, n_src_id = offsets["NORMAL"]
|
|
222
|
+
norm_source = sources.get(n_src_id)
|
|
223
|
+
if norm_source is None:
|
|
224
|
+
raise SceneRefusal(
|
|
225
|
+
f"{path}: geometry {geom_id!r} references missing source {n_src_id!r}."
|
|
226
|
+
)
|
|
227
|
+
norm_values, norm_stride = _source_stride(norm_source, path)
|
|
228
|
+
norm_count = len(norm_values) // norm_stride
|
|
229
|
+
norm_offset = n_offset
|
|
230
|
+
if normals_out is None:
|
|
231
|
+
normals_out = []
|
|
232
|
+
|
|
233
|
+
p_el = _child(prim, "p")
|
|
234
|
+
indices_flat = [int(x) for x in (p_el.text or "").split()] if p_el is not None else []
|
|
235
|
+
|
|
236
|
+
def emit_vertex(base: int) -> int:
|
|
237
|
+
pi = indices_flat[base + pos_offset]
|
|
238
|
+
if pi < 0 or pi >= pos_count:
|
|
239
|
+
raise SceneRefusal(
|
|
240
|
+
f"{path}: geometry {geom_id!r} <p> index {pi} is out of range for "
|
|
241
|
+
f"source with {pos_count} entries."
|
|
242
|
+
)
|
|
243
|
+
positions_out.append(
|
|
244
|
+
tuple(pos_values[pi * pos_stride : pi * pos_stride + 3]) # type: ignore[arg-type]
|
|
245
|
+
)
|
|
246
|
+
if norm_offset is not None and normals_out is not None:
|
|
247
|
+
ni = indices_flat[base + norm_offset]
|
|
248
|
+
if ni < 0 or ni >= norm_count:
|
|
249
|
+
raise SceneRefusal(
|
|
250
|
+
f"{path}: geometry {geom_id!r} <p> normal index {ni} is out of "
|
|
251
|
+
f"range for source with {norm_count} entries."
|
|
252
|
+
)
|
|
253
|
+
normals_out.append(
|
|
254
|
+
tuple(norm_values[ni * norm_stride : ni * norm_stride + 3]) # type: ignore[arg-type]
|
|
255
|
+
)
|
|
256
|
+
idx = len(positions_out) - 1
|
|
257
|
+
return idx
|
|
258
|
+
|
|
259
|
+
if prim_name == "triangles":
|
|
260
|
+
n_verts = len(indices_flat) // stride
|
|
261
|
+
for v in range(0, n_verts, 3):
|
|
262
|
+
a = emit_vertex((v) * stride)
|
|
263
|
+
b = emit_vertex((v + 1) * stride)
|
|
264
|
+
c = emit_vertex((v + 2) * stride)
|
|
265
|
+
indices_out.extend((a, b, c))
|
|
266
|
+
else: # polylist
|
|
267
|
+
vcount_el = _child(prim, "vcount")
|
|
268
|
+
vcounts = (
|
|
269
|
+
[int(x) for x in (vcount_el.text or "").split()]
|
|
270
|
+
if vcount_el is not None
|
|
271
|
+
else []
|
|
272
|
+
)
|
|
273
|
+
cursor = 0
|
|
274
|
+
for vcount in vcounts:
|
|
275
|
+
verts = [emit_vertex((cursor + k) * stride) for k in range(vcount)]
|
|
276
|
+
cursor += vcount
|
|
277
|
+
# Fan-triangulate.
|
|
278
|
+
for k in range(1, vcount - 1):
|
|
279
|
+
indices_out.extend((verts[0], verts[k], verts[k + 1]))
|
|
280
|
+
|
|
281
|
+
return positions_out, normals_out, indices_out
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# --------------------------------------------------------------------------- visual scenes
|
|
285
|
+
|
|
286
|
+
_Matrix = tuple[float, ...] # 16 row-major
|
|
287
|
+
_IDENTITY: _Matrix = (
|
|
288
|
+
1.0,
|
|
289
|
+
0.0,
|
|
290
|
+
0.0,
|
|
291
|
+
0.0,
|
|
292
|
+
0.0,
|
|
293
|
+
1.0,
|
|
294
|
+
0.0,
|
|
295
|
+
0.0,
|
|
296
|
+
0.0,
|
|
297
|
+
0.0,
|
|
298
|
+
1.0,
|
|
299
|
+
0.0,
|
|
300
|
+
0.0,
|
|
301
|
+
0.0,
|
|
302
|
+
0.0,
|
|
303
|
+
1.0,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _mat_mul(a: _Matrix, b: _Matrix) -> _Matrix:
|
|
308
|
+
out = [0.0] * 16
|
|
309
|
+
for r in range(4):
|
|
310
|
+
for c in range(4):
|
|
311
|
+
out[r * 4 + c] = sum(a[r * 4 + k] * b[k * 4 + c] for k in range(4))
|
|
312
|
+
return tuple(out)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _translate_matrix(x: float, y: float, z: float) -> _Matrix:
|
|
316
|
+
return (
|
|
317
|
+
1.0,
|
|
318
|
+
0.0,
|
|
319
|
+
0.0,
|
|
320
|
+
x,
|
|
321
|
+
0.0,
|
|
322
|
+
1.0,
|
|
323
|
+
0.0,
|
|
324
|
+
y,
|
|
325
|
+
0.0,
|
|
326
|
+
0.0,
|
|
327
|
+
1.0,
|
|
328
|
+
z,
|
|
329
|
+
0.0,
|
|
330
|
+
0.0,
|
|
331
|
+
0.0,
|
|
332
|
+
1.0,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _scale_matrix(x: float, y: float, z: float) -> _Matrix:
|
|
337
|
+
return (
|
|
338
|
+
x,
|
|
339
|
+
0.0,
|
|
340
|
+
0.0,
|
|
341
|
+
0.0,
|
|
342
|
+
0.0,
|
|
343
|
+
y,
|
|
344
|
+
0.0,
|
|
345
|
+
0.0,
|
|
346
|
+
0.0,
|
|
347
|
+
0.0,
|
|
348
|
+
z,
|
|
349
|
+
0.0,
|
|
350
|
+
0.0,
|
|
351
|
+
0.0,
|
|
352
|
+
0.0,
|
|
353
|
+
1.0,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _rotate_matrix(x: float, y: float, z: float, degrees: float) -> _Matrix:
|
|
358
|
+
length = math.sqrt(x * x + y * y + z * z)
|
|
359
|
+
if length == 0:
|
|
360
|
+
return _IDENTITY
|
|
361
|
+
x, y, z = x / length, y / length, z / length
|
|
362
|
+
theta = math.radians(degrees)
|
|
363
|
+
c = math.cos(theta)
|
|
364
|
+
s = math.sin(theta)
|
|
365
|
+
t = 1 - c
|
|
366
|
+
return (
|
|
367
|
+
t * x * x + c,
|
|
368
|
+
t * x * y - s * z,
|
|
369
|
+
t * x * z + s * y,
|
|
370
|
+
0.0,
|
|
371
|
+
t * x * y + s * z,
|
|
372
|
+
t * y * y + c,
|
|
373
|
+
t * y * z - s * x,
|
|
374
|
+
0.0,
|
|
375
|
+
t * x * z - s * y,
|
|
376
|
+
t * y * z + s * x,
|
|
377
|
+
t * z * z + c,
|
|
378
|
+
0.0,
|
|
379
|
+
0.0,
|
|
380
|
+
0.0,
|
|
381
|
+
0.0,
|
|
382
|
+
1.0,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _node_local_transform(node: ET.Element) -> _Matrix:
|
|
387
|
+
"""Compose a node's transform elements in document order, as COLLADA requires."""
|
|
388
|
+
m = _IDENTITY
|
|
389
|
+
for child in node:
|
|
390
|
+
name = _tag(child)
|
|
391
|
+
if name == "matrix" and child.text:
|
|
392
|
+
vals = [float(x) for x in child.text.split()]
|
|
393
|
+
if len(vals) == 16:
|
|
394
|
+
m = _mat_mul(m, tuple(vals))
|
|
395
|
+
elif name == "translate" and child.text:
|
|
396
|
+
x, y, z = (float(v) for v in child.text.split())
|
|
397
|
+
m = _mat_mul(m, _translate_matrix(x, y, z))
|
|
398
|
+
elif name == "rotate" and child.text:
|
|
399
|
+
x, y, z, deg = (float(v) for v in child.text.split())
|
|
400
|
+
m = _mat_mul(m, _rotate_matrix(x, y, z, deg))
|
|
401
|
+
elif name == "scale" and child.text:
|
|
402
|
+
x, y, z = (float(v) for v in child.text.split())
|
|
403
|
+
m = _mat_mul(m, _scale_matrix(x, y, z))
|
|
404
|
+
return m
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _transform_point(m: _Matrix, p: tuple[float, float, float]) -> tuple[float, float, float]:
|
|
408
|
+
x, y, z = p
|
|
409
|
+
return (
|
|
410
|
+
m[0] * x + m[1] * y + m[2] * z + m[3],
|
|
411
|
+
m[4] * x + m[5] * y + m[6] * z + m[7],
|
|
412
|
+
m[8] * x + m[9] * y + m[10] * z + m[11],
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _transform_normal(m: _Matrix, n: tuple[float, float, float]) -> tuple[float, float, float]:
|
|
417
|
+
# Rotation/scale only, no translation, for direction vectors.
|
|
418
|
+
# ponytail: renormalize only; non-uniform scale needs the inverse-transpose.
|
|
419
|
+
x, y, z = n
|
|
420
|
+
nx, ny, nz = (
|
|
421
|
+
m[0] * x + m[1] * y + m[2] * z,
|
|
422
|
+
m[4] * x + m[5] * y + m[6] * z,
|
|
423
|
+
m[8] * x + m[9] * y + m[10] * z,
|
|
424
|
+
)
|
|
425
|
+
length = math.sqrt(nx * nx + ny * ny + nz * nz)
|
|
426
|
+
return (nx / length, ny / length, nz / length) if length else (nx, ny, nz)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _to_zup(p: tuple[float, float, float], up_axis: str) -> tuple[float, float, float]:
|
|
430
|
+
x, y, z = p
|
|
431
|
+
if up_axis == "Y_UP":
|
|
432
|
+
return (x, -z, y)
|
|
433
|
+
if up_axis == "X_UP":
|
|
434
|
+
return (y, z, x)
|
|
435
|
+
return (x, y, z) # Z_UP as-is
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _apply_transform(geom: _GeomData, m: _Matrix, unit_scale: float, up_axis: str) -> Mesh:
|
|
439
|
+
positions, normals, indices = geom
|
|
440
|
+
out_positions: list[float] = []
|
|
441
|
+
for p in positions:
|
|
442
|
+
scaled = (p[0] * unit_scale, p[1] * unit_scale, p[2] * unit_scale)
|
|
443
|
+
world = _transform_point(m, scaled)
|
|
444
|
+
out_positions.extend(_to_zup(world, up_axis))
|
|
445
|
+
out_normals: list[float] | None = None
|
|
446
|
+
if normals is not None:
|
|
447
|
+
out_normals = []
|
|
448
|
+
for n in normals:
|
|
449
|
+
world_n = _transform_normal(m, n)
|
|
450
|
+
out_normals.extend(_to_zup(world_n, up_axis))
|
|
451
|
+
return (
|
|
452
|
+
tuple(out_positions),
|
|
453
|
+
tuple(out_normals) if out_normals is not None else None,
|
|
454
|
+
tuple(indices),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _index_nodes(root: ET.Element, path: Path) -> dict[str, ET.Element]:
|
|
459
|
+
nodes: dict[str, ET.Element] = {}
|
|
460
|
+
|
|
461
|
+
def collect(el: ET.Element) -> None:
|
|
462
|
+
for child in el:
|
|
463
|
+
if _tag(child) == "node" and child.get("id"):
|
|
464
|
+
nodes[child.get("id")] = child
|
|
465
|
+
collect(child)
|
|
466
|
+
|
|
467
|
+
for lib_name in ("library_nodes", "library_visual_scenes"):
|
|
468
|
+
for lib in _children(root, lib_name):
|
|
469
|
+
collect(lib)
|
|
470
|
+
return nodes
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _find_visual_scene(root: ET.Element, path: Path) -> ET.Element | None:
|
|
474
|
+
scene_el = _child(root, "scene")
|
|
475
|
+
if scene_el is None:
|
|
476
|
+
return None
|
|
477
|
+
inst = _child(scene_el, "instance_visual_scene")
|
|
478
|
+
if inst is None:
|
|
479
|
+
return None
|
|
480
|
+
url = inst.get("url", "")
|
|
481
|
+
scene_id = url.lstrip("#")
|
|
482
|
+
for lib in _children(root, "library_visual_scenes"):
|
|
483
|
+
for vs in _children(lib, "visual_scene"):
|
|
484
|
+
if vs.get("id") == scene_id:
|
|
485
|
+
return vs
|
|
486
|
+
raise SceneRefusal(f"{path}: <instance_visual_scene> targets missing url {url!r}.")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _walk_node(
|
|
490
|
+
node: ET.Element,
|
|
491
|
+
parent_m: _Matrix,
|
|
492
|
+
geometries: dict[str, _GeomData],
|
|
493
|
+
nodes_by_id: dict[str, ET.Element],
|
|
494
|
+
unit_scale: float,
|
|
495
|
+
up_axis: str,
|
|
496
|
+
meshes: list[Mesh],
|
|
497
|
+
skipped: list[str],
|
|
498
|
+
path: Path,
|
|
499
|
+
depth: int,
|
|
500
|
+
visited: frozenset[str] = frozenset(),
|
|
501
|
+
) -> None:
|
|
502
|
+
if depth > _MAX_INSTANCE_NODE_DEPTH:
|
|
503
|
+
raise SceneRefusal(
|
|
504
|
+
f"{path}: <instance_node> nesting exceeds depth {_MAX_INSTANCE_NODE_DEPTH}."
|
|
505
|
+
)
|
|
506
|
+
m = _mat_mul(parent_m, _node_local_transform(node))
|
|
507
|
+
|
|
508
|
+
for inst in _children(node, "instance_geometry"):
|
|
509
|
+
url = inst.get("url", "")
|
|
510
|
+
geom_id = url.lstrip("#")
|
|
511
|
+
geom = geometries.get(geom_id)
|
|
512
|
+
if geom is None:
|
|
513
|
+
raise SceneRefusal(f"{path}: <instance_geometry> targets missing url {url!r}.")
|
|
514
|
+
meshes.append(_apply_transform(geom, m, unit_scale, up_axis))
|
|
515
|
+
|
|
516
|
+
for inst in _children(node, "instance_controller"):
|
|
517
|
+
url = inst.get("url", "")
|
|
518
|
+
skipped.append(f"{url.lstrip('#')}: <instance_controller> (skin/morph) not read")
|
|
519
|
+
|
|
520
|
+
for inst in _children(node, "instance_node"):
|
|
521
|
+
url = inst.get("url", "")
|
|
522
|
+
ref_id = url.lstrip("#")
|
|
523
|
+
target = nodes_by_id.get(ref_id)
|
|
524
|
+
if target is None:
|
|
525
|
+
raise SceneRefusal(f"{path}: <instance_node> targets missing url {url!r}.")
|
|
526
|
+
if ref_id in visited:
|
|
527
|
+
raise SceneRefusal(f"{path}: <instance_node> cycle detected at {url!r}.")
|
|
528
|
+
_walk_node(
|
|
529
|
+
target,
|
|
530
|
+
m,
|
|
531
|
+
geometries,
|
|
532
|
+
nodes_by_id,
|
|
533
|
+
unit_scale,
|
|
534
|
+
up_axis,
|
|
535
|
+
meshes,
|
|
536
|
+
skipped,
|
|
537
|
+
path,
|
|
538
|
+
depth + 1,
|
|
539
|
+
visited | {ref_id},
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
for child in _children(node, "node"):
|
|
543
|
+
_walk_node(
|
|
544
|
+
child,
|
|
545
|
+
m,
|
|
546
|
+
geometries,
|
|
547
|
+
nodes_by_id,
|
|
548
|
+
unit_scale,
|
|
549
|
+
up_axis,
|
|
550
|
+
meshes,
|
|
551
|
+
skipped,
|
|
552
|
+
path,
|
|
553
|
+
depth + 1,
|
|
554
|
+
visited,
|
|
555
|
+
)
|