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/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
+ )