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_urdf.py ADDED
@@ -0,0 +1,750 @@
1
+ """URDF/SDF -> Scene. Stdlib only: xml.etree, struct, math, pathlib.
2
+
3
+ A link's visuals belong to the link (`extras_id = name_chain = (link)`); link poses
4
+ compose joint `<origin xyz rpy>` down the tree from the root with every joint at zero
5
+ (fixed/revolute/prismatic/continuous alike — no joint *type* changes the pose used here).
6
+ URDF and SDF are Z-up metres already, same as the contract: no axis conversion (`up = "z"`).
7
+
8
+ SDF link poses are used directly (`<link><pose>`), model-relative — this build does not
9
+ walk SDF `<joint>` chains or honour `relative_to`; a link's own `<pose>` is taken as its
10
+ final pose under the (identity) model frame.
11
+
12
+ `package://pkg/rel` and `model://name/rel` resolve, in order: `AMENT_PREFIX_PATH`
13
+ (`<dir>/share/pkg/rel`) and `ROS_PACKAGE_PATH` (`<dir>/pkg/rel`); each `package_roots` entry,
14
+ walking up to 3 levels for a `package.xml` naming `pkg` or a directory named `pkg`; then the
15
+ URDF/SDF's own directory. Anything else is a `SceneRefusal` naming the remedy.
16
+ """
17
+
18
+ import hashlib
19
+ import math
20
+ from collections.abc import Mapping
21
+ from dataclasses import replace
22
+ import os
23
+ import xml.etree.ElementTree as ET
24
+ from pathlib import Path
25
+
26
+ from lockwire.mesh_simple import MeshRefusal, read_obj, read_stl
27
+ from lockwire.scene import Scene, ScenePart, SceneRefusal
28
+ from lockwire.urdf import UnresolvedXacroError, _refuse_doctype_entity, _refuse_unresolved_xacro
29
+ from lockwire.urdf import _xyz_rpy as _urdf_xyz_rpy
30
+
31
+ _CYL_SEGMENTS = 24
32
+ _SPHERE_LAT = 12
33
+ _SPHERE_LONG = 24
34
+ _DEFAULT_COLOR = (0.8, 0.8, 0.8, 1.0)
35
+
36
+ # (rotation-3x3, translation) pose pairs, composed like URDF joint chains.
37
+ _Pose = tuple[tuple[tuple[float, float, float], ...], tuple[float, float, float]]
38
+ _IDENTITY_POSE: _Pose = (((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)), (0.0, 0.0, 0.0))
39
+
40
+
41
+ # --------------------------------------------------------------------------- pose algebra
42
+
43
+
44
+ def _rpy_matrix(r: float, p: float, y: float) -> tuple[tuple[float, float, float], ...]:
45
+ cr, sr = math.cos(r), math.sin(r)
46
+ cp, sp = math.cos(p), math.sin(p)
47
+ cy, sy = math.cos(y), math.sin(y)
48
+ return (
49
+ (cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr),
50
+ (sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr),
51
+ (-sp, cp * sr, cp * cr),
52
+ )
53
+
54
+
55
+ def _mat3_mul(
56
+ a: tuple[tuple[float, float, float], ...], b: tuple[tuple[float, float, float], ...]
57
+ ) -> tuple[tuple[float, float, float], ...]:
58
+ return tuple(
59
+ tuple(sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)) for i in range(3)
60
+ )
61
+
62
+
63
+ def _apply_r(
64
+ r_mat: tuple[tuple[float, float, float], ...], v: tuple[float, float, float]
65
+ ) -> tuple[float, float, float]:
66
+ x, y, z = v
67
+ return (
68
+ r_mat[0][0] * x + r_mat[0][1] * y + r_mat[0][2] * z,
69
+ r_mat[1][0] * x + r_mat[1][1] * y + r_mat[1][2] * z,
70
+ r_mat[2][0] * x + r_mat[2][1] * y + r_mat[2][2] * z,
71
+ )
72
+
73
+
74
+ def _compose(
75
+ parent: _Pose, xyz: tuple[float, float, float], rpy: tuple[float, float, float]
76
+ ) -> _Pose:
77
+ r_parent, t_parent = parent
78
+ r_local = _rpy_matrix(*rpy)
79
+ r_new = _mat3_mul(r_parent, r_local)
80
+ dx, dy, dz = _apply_r(r_parent, xyz)
81
+ t_new = (t_parent[0] + dx, t_parent[1] + dy, t_parent[2] + dz)
82
+ return r_new, t_new
83
+
84
+
85
+ def _apply_pose(pose: _Pose, point: tuple[float, float, float]) -> tuple[float, float, float]:
86
+ r_mat, t = pose
87
+ rx, ry, rz = _apply_r(r_mat, point)
88
+ return (rx + t[0], ry + t[1], rz + t[2])
89
+
90
+
91
+ # --------------------------------------------------------------------------- geometry
92
+
93
+
94
+ def _box_mesh(sx: float, sy: float, sz: float):
95
+ hx, hy, hz = sx / 2, sy / 2, sz / 2
96
+ # 6 faces, 4 verts each (flat-shaded), CCW looking from outside.
97
+ faces = [
98
+ ((-hx, -hy, hz), (hx, -hy, hz), (hx, hy, hz), (-hx, hy, hz), (0, 0, 1)), # +Z
99
+ ((-hx, hy, -hz), (hx, hy, -hz), (hx, -hy, -hz), (-hx, -hy, -hz), (0, 0, -1)), # -Z
100
+ ((-hx, -hy, -hz), (hx, -hy, -hz), (hx, -hy, hz), (-hx, -hy, hz), (0, -1, 0)), # -Y
101
+ ((hx, hy, -hz), (-hx, hy, -hz), (-hx, hy, hz), (hx, hy, hz), (0, 1, 0)), # +Y
102
+ ((-hx, hy, -hz), (-hx, -hy, -hz), (-hx, -hy, hz), (-hx, hy, hz), (-1, 0, 0)), # -X
103
+ ((hx, -hy, -hz), (hx, hy, -hz), (hx, hy, hz), (hx, -hy, hz), (1, 0, 0)), # +X
104
+ ]
105
+ positions: list[float] = []
106
+ normals: list[float] = []
107
+ for a, b, c, d, n in faces:
108
+ for v in (a, b, c, a, c, d):
109
+ positions.extend(v)
110
+ normals.extend(n)
111
+ indices = tuple(range(len(positions) // 3))
112
+ return tuple(positions), tuple(normals), indices
113
+
114
+
115
+ def _cylinder_mesh(radius: float, length: float, segments: int = _CYL_SEGMENTS):
116
+ half = length / 2
117
+ positions: list[float] = []
118
+ normals: list[float] = []
119
+ for i in range(segments):
120
+ a0 = 2 * math.pi * i / segments
121
+ a1 = 2 * math.pi * (i + 1) / segments
122
+ x0, y0 = radius * math.cos(a0), radius * math.sin(a0)
123
+ x1, y1 = radius * math.cos(a1), radius * math.sin(a1)
124
+ n0 = (math.cos(a0), math.sin(a0), 0.0)
125
+ n1 = (math.cos(a1), math.sin(a1), 0.0)
126
+ # side wall, two triangles
127
+ for v, n in (
128
+ ((x0, y0, -half), n0),
129
+ ((x1, y1, -half), n1),
130
+ ((x1, y1, half), n1),
131
+ ((x0, y0, -half), n0),
132
+ ((x1, y1, half), n1),
133
+ ((x0, y0, half), n0),
134
+ ):
135
+ positions.extend(v)
136
+ normals.extend(n)
137
+ # top cap
138
+ for v in ((0.0, 0.0, half), (x0, y0, half), (x1, y1, half)):
139
+ positions.extend(v)
140
+ normals.extend((0.0, 0.0, 1.0))
141
+ # bottom cap
142
+ for v in ((0.0, 0.0, -half), (x1, y1, -half), (x0, y0, -half)):
143
+ positions.extend(v)
144
+ normals.extend((0.0, 0.0, -1.0))
145
+ indices = tuple(range(len(positions) // 3))
146
+ return tuple(positions), tuple(normals), indices
147
+
148
+
149
+ def _sphere_mesh(radius: float, lat_segments: int = _SPHERE_LAT, lon_segments: int = _SPHERE_LONG):
150
+ def point(lat_i: int, lon_i: int) -> tuple[float, float, float]:
151
+ theta = math.pi * lat_i / lat_segments # 0..pi (pole to pole)
152
+ phi = 2 * math.pi * lon_i / lon_segments
153
+ x = math.sin(theta) * math.cos(phi)
154
+ y = math.sin(theta) * math.sin(phi)
155
+ z = math.cos(theta)
156
+ return x, y, z
157
+
158
+ positions: list[float] = []
159
+ normals: list[float] = []
160
+ for lat_i in range(lat_segments):
161
+ for lon_i in range(lon_segments):
162
+ p00 = point(lat_i, lon_i)
163
+ p01 = point(lat_i, lon_i + 1)
164
+ p10 = point(lat_i + 1, lon_i)
165
+ p11 = point(lat_i + 1, lon_i + 1)
166
+ quad = (p00, p10, p11, p00, p11, p01)
167
+ for n in quad:
168
+ positions.extend((n[0] * radius, n[1] * radius, n[2] * radius))
169
+ normals.extend(n)
170
+ indices = tuple(range(len(positions) // 3))
171
+ return tuple(positions), tuple(normals), indices
172
+
173
+
174
+ # --------------------------------------------------------------------------- mesh files
175
+
176
+
177
+ def _load_mesh_file(resolved: Path, source: Path) -> list[tuple]:
178
+ """-> a list of (positions, normals_or_None, indices) submeshes — one for every
179
+ reader except DAE, which may carry several geometries in one file."""
180
+ suffix = resolved.suffix.lower()
181
+ if suffix == ".stl":
182
+ try:
183
+ return [read_stl(resolved)]
184
+ except MeshRefusal as exc:
185
+ raise SceneRefusal(str(exc)) from exc
186
+ if suffix == ".obj":
187
+ try:
188
+ return [read_obj(resolved)]
189
+ except MeshRefusal as exc:
190
+ raise SceneRefusal(str(exc)) from exc
191
+ if suffix == ".dae":
192
+ try:
193
+ from lockwire.mesh_dae import read_dae # I2 lands this in parallel
194
+ except ImportError as exc:
195
+ raise SceneRefusal(
196
+ f"{source}: mesh {resolved.name} is DAE, and DAE support is not in this build."
197
+ ) from exc
198
+ return list(read_dae(resolved).meshes)
199
+ raise SceneRefusal(
200
+ f"{source}: mesh {resolved.name} has unsupported extension {resolved.suffix!r} "
201
+ "(convert to STL, OBJ, DAE or glTF)."
202
+ )
203
+
204
+
205
+ def _find_package_dir(root: Path, pkg: str, max_depth: int) -> Path | None:
206
+ def walk(d: Path, depth: int) -> Path | None:
207
+ if depth > max_depth or not d.is_dir():
208
+ return None
209
+ if d.name == pkg:
210
+ return d
211
+ pkg_xml = d / "package.xml"
212
+ if pkg_xml.is_file():
213
+ try:
214
+ xml_root = ET.fromstring(pkg_xml.read_bytes())
215
+ except ET.ParseError:
216
+ xml_root = None
217
+ if xml_root is not None:
218
+ name_el = xml_root.find("name")
219
+ if name_el is not None and (name_el.text or "").strip() == pkg:
220
+ return d
221
+ if depth == max_depth:
222
+ return None
223
+ try:
224
+ children = sorted(p for p in d.iterdir() if p.is_dir())
225
+ except OSError:
226
+ return None
227
+ for child in children:
228
+ found = walk(child, depth + 1)
229
+ if found is not None:
230
+ return found
231
+ return None
232
+
233
+ return walk(root, 0)
234
+
235
+
236
+ def package_root(source: Path) -> Path | None:
237
+ """The robot's package folder: the nearest ancestor of the model file holding package.xml."""
238
+ source = source.resolve()
239
+ return next((d for d in source.parents if (d / "package.xml").is_file()), None)
240
+
241
+
242
+ def mesh_root(source: Path, contract_dir: Path) -> Path:
243
+ """The one folder a declared mesh must sit inside: the robot's
244
+ package, else the lockfile's folder. `external_assets` paths are relative to it."""
245
+ return package_root(source) or contract_dir.resolve()
246
+
247
+
248
+ def _resolve_package(
249
+ pkg: str, rel: str, base_dir: Path, package_roots: tuple[Path, ...]
250
+ ) -> Path | None:
251
+ ament = os.environ.get("AMENT_PREFIX_PATH")
252
+ if ament:
253
+ for d in ament.split(os.pathsep):
254
+ if not d:
255
+ continue
256
+ candidate = Path(d) / "share" / pkg / rel
257
+ if candidate.is_file():
258
+ return candidate
259
+ ros_pkg_path = os.environ.get("ROS_PACKAGE_PATH")
260
+ if ros_pkg_path:
261
+ for d in ros_pkg_path.split(os.pathsep):
262
+ if not d:
263
+ continue
264
+ candidate = Path(d) / pkg / rel
265
+ if candidate.is_file():
266
+ return candidate
267
+ for root in package_roots:
268
+ found = _find_package_dir(Path(root), pkg, max_depth=3)
269
+ if found is not None:
270
+ candidate = found / rel
271
+ if candidate.is_file():
272
+ return candidate
273
+ for candidate in (base_dir / pkg / rel, base_dir / rel):
274
+ if candidate.is_file():
275
+ return candidate
276
+ return None
277
+
278
+
279
+ def _resolve_mesh_path(
280
+ uri: str, base_dir: Path, source: Path, package_roots: tuple[Path, ...]
281
+ ) -> Path:
282
+ if uri.startswith(("http://", "https://")):
283
+ raise SceneRefusal(
284
+ f"{source}: mesh URI {uri!r} is a network URL; only local files are accepted."
285
+ )
286
+ if uri.startswith("package://") or uri.startswith("model://"):
287
+ scheme, _, rest = uri.partition("://")
288
+ pkg, sep, rel = rest.partition("/")
289
+ if not sep:
290
+ raise SceneRefusal(f"{source}: {scheme}:// URI {uri!r} has no path after the package.")
291
+ resolved = _resolve_package(pkg, rel, base_dir, package_roots)
292
+ if resolved is None:
293
+ raise SceneRefusal(
294
+ f"{source}: cannot resolve {scheme}://{pkg} — set --package-root DIR "
295
+ "(a folder containing the package) or source your ROS workspace."
296
+ )
297
+ return resolved
298
+ p = Path(uri)
299
+ if p.is_absolute():
300
+ raise SceneRefusal(
301
+ f"{source}: mesh path {uri!r} is absolute; use a relative path or package://."
302
+ )
303
+ resolved = base_dir / uri
304
+ if not resolved.is_file():
305
+ raise SceneRefusal(f"{source}: mesh file {uri} does not exist next to {source.name}.")
306
+ return resolved
307
+
308
+
309
+ def _verify_mesh_identity(
310
+ resolved: Path, source: Path, external_assets: Mapping[Path, str] | None
311
+ ) -> None:
312
+ """Refuse a selected model's mesh unless its declared bytes match."""
313
+
314
+ if external_assets is None:
315
+ return
316
+ expected = external_assets.get(resolved.resolve())
317
+ if expected is None:
318
+ raise SceneRefusal(f"{source}: mesh {resolved} has no declared sha256.")
319
+ observed = hashlib.sha256(resolved.read_bytes()).hexdigest()
320
+ if observed != expected:
321
+ raise SceneRefusal(
322
+ f"{source}: mesh {resolved} sha256 does not match declared asset: "
323
+ f"observed {observed}, expected {expected}."
324
+ )
325
+
326
+
327
+ def _scale_positions(
328
+ positions: tuple[float, ...], scale: tuple[float, float, float]
329
+ ) -> tuple[float, ...]:
330
+ if scale == (1.0, 1.0, 1.0):
331
+ return positions
332
+ # ponytail: normals are not re-scaled by 1/scale (wrong under non-uniform scale) —
333
+ # upgrade if a non-uniform-scaled mesh's shading visibly breaks.
334
+ return tuple(positions[i] * scale[i % 3] for i in range(len(positions)))
335
+
336
+
337
+ # --------------------------------------------------------------------------- URDF
338
+
339
+
340
+ def _urdf_material_colors(root: ET.Element) -> dict[str, tuple[float, float, float, float]]:
341
+ colors: dict[str, tuple[float, float, float, float]] = {}
342
+ for mat in root.findall("material"):
343
+ name = mat.get("name")
344
+ color_el = mat.find("color")
345
+ if name and color_el is not None:
346
+ colors[name] = tuple( # type: ignore[assignment]
347
+ float(x) for x in color_el.get("rgba", "0.8 0.8 0.8 1").split()
348
+ )
349
+ return colors
350
+
351
+
352
+ def _urdf_visual_color(
353
+ vis_el: ET.Element, materials: dict[str, tuple[float, float, float, float]]
354
+ ) -> tuple[float, float, float, float]:
355
+ mat_el = vis_el.find("material")
356
+ if mat_el is not None:
357
+ color_el = mat_el.find("color")
358
+ if color_el is not None:
359
+ return tuple( # type: ignore[return-value]
360
+ float(x) for x in color_el.get("rgba", "0.8 0.8 0.8 1").split()
361
+ )
362
+ name = mat_el.get("name")
363
+ if name and name in materials:
364
+ return materials[name]
365
+ return _DEFAULT_COLOR
366
+
367
+
368
+ def _urdf_geometry_to_meshes(
369
+ geom: ET.Element,
370
+ base_dir: Path,
371
+ source: Path,
372
+ package_roots: tuple[Path, ...],
373
+ external_assets: Mapping[Path, str] | None,
374
+ ) -> list[tuple]:
375
+ box = geom.find("box")
376
+ if box is not None:
377
+ sx, sy, sz = (float(x) for x in box.get("size", "1 1 1").split())
378
+ return [_box_mesh(sx, sy, sz)]
379
+ cyl = geom.find("cylinder")
380
+ if cyl is not None:
381
+ return [_cylinder_mesh(float(cyl.get("radius", "1")), float(cyl.get("length", "1")))]
382
+ sph = geom.find("sphere")
383
+ if sph is not None:
384
+ return [_sphere_mesh(float(sph.get("radius", "1")))]
385
+ mesh = geom.find("mesh")
386
+ if mesh is not None:
387
+ filename = mesh.get("filename")
388
+ if not filename:
389
+ raise SceneRefusal(f"{source}: <mesh> has no filename.")
390
+ scale_raw = mesh.get("scale", "1 1 1")
391
+ scale = tuple(float(x) for x in scale_raw.split()) # type: ignore[assignment]
392
+ resolved = _resolve_mesh_path(filename, base_dir, source, package_roots)
393
+ _verify_mesh_identity(resolved, source, external_assets)
394
+ submeshes = _load_mesh_file(resolved, source)
395
+ return [
396
+ (_scale_positions(positions, scale), normals, indices) # type: ignore[arg-type]
397
+ for positions, normals, indices in submeshes
398
+ ]
399
+ raise SceneRefusal(f"{source}: <geometry> has no box/cylinder/sphere/mesh child.")
400
+
401
+
402
+ def _urdf_visual_to_parts(
403
+ vis_el: ET.Element,
404
+ link_name: str,
405
+ link_pose: _Pose,
406
+ base_dir: Path,
407
+ source: Path,
408
+ package_roots: tuple[Path, ...],
409
+ external_assets: Mapping[Path, str] | None,
410
+ materials: dict[str, tuple[float, float, float, float]],
411
+ ) -> list[ScenePart]:
412
+ xyz, rpy = _urdf_xyz_rpy(vis_el.find("origin"))
413
+ visual_pose = _compose(link_pose, xyz, rpy)
414
+ geom = vis_el.find("geometry")
415
+ if geom is None:
416
+ raise SceneRefusal(f"{source}: link {link_name!r} visual has no <geometry>.")
417
+ submeshes = _urdf_geometry_to_meshes(
418
+ geom, base_dir, source, package_roots, external_assets
419
+ )
420
+ color = _urdf_visual_color(vis_el, materials)
421
+ return [
422
+ _make_part(link_name, link_pose, visual_pose, positions, normals, indices, color)
423
+ for positions, normals, indices in submeshes
424
+ ]
425
+
426
+
427
+ def _make_part(
428
+ link_name: str,
429
+ link_pose: _Pose,
430
+ visual_pose: _Pose,
431
+ positions: tuple[float, ...],
432
+ normals: tuple[float, ...] | None,
433
+ indices: tuple[int, ...],
434
+ color: tuple[float, float, float, float],
435
+ ) -> ScenePart:
436
+ world_pos: list[float] = []
437
+ for i in range(0, len(positions), 3):
438
+ x, y, z = _apply_pose(visual_pose, (positions[i], positions[i + 1], positions[i + 2]))
439
+ world_pos.extend((x, y, z))
440
+ world_norm: tuple[float, ...] | None = None
441
+ if normals is not None:
442
+ r_mat, _ = visual_pose
443
+ norm_out: list[float] = []
444
+ for i in range(0, len(normals), 3):
445
+ norm_out.extend(_apply_r(r_mat, (normals[i], normals[i + 1], normals[i + 2])))
446
+ world_norm = tuple(norm_out)
447
+ return ScenePart(
448
+ node=link_name,
449
+ extras_id=link_name,
450
+ name_chain=(link_name,),
451
+ positions=tuple(world_pos),
452
+ normals=world_norm,
453
+ indices=indices,
454
+ color=color,
455
+ translation=link_pose[1],
456
+ )
457
+
458
+
459
+ def _scene_from_urdf(
460
+ root: ET.Element,
461
+ source: Path,
462
+ package_roots: tuple[Path, ...],
463
+ external_assets: Mapping[Path, str] | None,
464
+ ) -> Scene:
465
+ if root.tag != "robot":
466
+ raise SceneRefusal(f"{source}: root element is <{root.tag}>, expected <robot>.")
467
+ base_dir = source.parent
468
+ links = {el.get("name", "?"): el for el in root.findall("link")}
469
+ materials = _urdf_material_colors(root)
470
+
471
+ children_of: dict[str, list[ET.Element]] = {}
472
+ child_links: set[str] = set()
473
+ for joint in root.findall("joint"):
474
+ parent = joint.find("parent")
475
+ child = joint.find("child")
476
+ pname = parent.get("link") if parent is not None else None
477
+ cname = child.get("link") if child is not None else None
478
+ if pname and cname:
479
+ children_of.setdefault(pname, []).append(joint)
480
+ child_links.add(cname)
481
+ roots = [name for name in links if name not in child_links]
482
+
483
+ parts: list[ScenePart] = []
484
+ visited: set[str] = set()
485
+
486
+ def walk(link_name: str, pose: _Pose) -> None:
487
+ if link_name in visited:
488
+ raise SceneRefusal(f"{source}: link {link_name!r} reached twice — cyclic joint tree.")
489
+ visited.add(link_name)
490
+ link_el = links.get(link_name)
491
+ if link_el is not None:
492
+ for vis in link_el.findall("visual"):
493
+ parts.extend(
494
+ _urdf_visual_to_parts(
495
+ vis,
496
+ link_name,
497
+ pose,
498
+ base_dir,
499
+ source,
500
+ package_roots,
501
+ external_assets,
502
+ materials,
503
+ )
504
+ )
505
+ for joint in children_of.get(link_name, []):
506
+ xyz, rpy = _urdf_xyz_rpy(joint.find("origin"))
507
+ child = joint.find("child")
508
+ cname = child.get("link") if child is not None else None
509
+ if cname is None or cname not in links:
510
+ continue
511
+ walk(cname, _compose(pose, xyz, rpy))
512
+
513
+ for root_link in roots:
514
+ walk(root_link, _IDENTITY_POSE)
515
+
516
+ return _build_scene(parts, source)
517
+
518
+
519
+ # --------------------------------------------------------------------------- SDF
520
+
521
+
522
+ def _sdf_pose(
523
+ pose_el: ET.Element | None,
524
+ ) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
525
+ text = pose_el.text if pose_el is not None and pose_el.text else "0 0 0 0 0 0"
526
+ vals = tuple(float(x) for x in text.split())
527
+ if len(vals) < 6:
528
+ vals = vals + (0.0,) * (6 - len(vals))
529
+ xyz = (vals[0], vals[1], vals[2])
530
+ rpy = (vals[3], vals[4], vals[5])
531
+ return xyz, rpy
532
+
533
+
534
+ def _sdf_geometry_to_meshes(
535
+ geom: ET.Element,
536
+ base_dir: Path,
537
+ source: Path,
538
+ package_roots: tuple[Path, ...],
539
+ external_assets: Mapping[Path, str] | None,
540
+ ) -> list[tuple]:
541
+ box = geom.find("box")
542
+ if box is not None:
543
+ size_text = box.findtext("size") or "1 1 1"
544
+ sx, sy, sz = (float(x) for x in size_text.split())
545
+ return [_box_mesh(sx, sy, sz)]
546
+ cyl = geom.find("cylinder")
547
+ if cyl is not None:
548
+ radius = float(cyl.findtext("radius") or "1")
549
+ length = float(cyl.findtext("length") or "1")
550
+ return [_cylinder_mesh(radius, length)]
551
+ sph = geom.find("sphere")
552
+ if sph is not None:
553
+ return [_sphere_mesh(float(sph.findtext("radius") or "1"))]
554
+ mesh = geom.find("mesh")
555
+ if mesh is not None:
556
+ uri = mesh.findtext("uri")
557
+ if not uri:
558
+ raise SceneRefusal(f"{source}: <mesh> has no <uri>.")
559
+ scale_text = mesh.findtext("scale")
560
+ scale = tuple(float(x) for x in scale_text.split()) if scale_text else (1.0, 1.0, 1.0)
561
+ resolved = _resolve_mesh_path(uri, base_dir, source, package_roots)
562
+ _verify_mesh_identity(resolved, source, external_assets)
563
+ submeshes = _load_mesh_file(resolved, source)
564
+ return [
565
+ (_scale_positions(positions, scale), normals, indices) # type: ignore[arg-type]
566
+ for positions, normals, indices in submeshes
567
+ ]
568
+ raise SceneRefusal(f"{source}: <geometry> has no box/cylinder/sphere/mesh child.")
569
+
570
+
571
+ def _first_model(root: ET.Element) -> ET.Element | None:
572
+ if root.tag == "model":
573
+ return root
574
+ for el in root.iter("model"):
575
+ return el
576
+ return None
577
+
578
+
579
+ def _scene_from_sdf(
580
+ root: ET.Element,
581
+ source: Path,
582
+ package_roots: tuple[Path, ...],
583
+ external_assets: Mapping[Path, str] | None,
584
+ ) -> Scene:
585
+ base_dir = source.parent
586
+ if root.tag != "sdf":
587
+ raise SceneRefusal(f"{source}: root element is <{root.tag}>, expected <sdf>.")
588
+ model = _first_model(root)
589
+ if model is None:
590
+ raise SceneRefusal(f"{source}: no <model> element found.")
591
+
592
+ parts: list[ScenePart] = []
593
+ for link in model.findall("link"):
594
+ link_name = link.get("name", "?")
595
+ link_xyz, link_rpy = _sdf_pose(link.find("pose"))
596
+ link_pose = _compose(_IDENTITY_POSE, link_xyz, link_rpy)
597
+ for vis in link.findall("visual"):
598
+ vis_xyz, vis_rpy = _sdf_pose(vis.find("pose"))
599
+ visual_pose = _compose(link_pose, vis_xyz, vis_rpy)
600
+ geom = vis.find("geometry")
601
+ if geom is None:
602
+ raise SceneRefusal(f"{source}: link {link_name!r} visual has no <geometry>.")
603
+ submeshes = _sdf_geometry_to_meshes(
604
+ geom, base_dir, source, package_roots, external_assets
605
+ )
606
+ for positions, normals, indices in submeshes:
607
+ parts.append(
608
+ _make_part(
609
+ link_name,
610
+ link_pose,
611
+ visual_pose,
612
+ positions,
613
+ normals,
614
+ indices,
615
+ _DEFAULT_COLOR,
616
+ )
617
+ )
618
+
619
+ return _build_scene(parts, source)
620
+
621
+
622
+ # --------------------------------------------------------------------------- shared / top level
623
+
624
+
625
+ def _build_scene(parts: list[ScenePart], source: Path) -> Scene:
626
+ if parts:
627
+ min_b = [math.inf, math.inf, math.inf]
628
+ max_b = [-math.inf, -math.inf, -math.inf]
629
+ for part in parts:
630
+ for i in range(0, len(part.positions), 3):
631
+ for k in range(3):
632
+ v = part.positions[i + k]
633
+ min_b[k] = min(min_b[k], v)
634
+ max_b[k] = max(max_b[k], v)
635
+ else:
636
+ min_b = [0.0, 0.0, 0.0]
637
+ max_b = [0.0, 0.0, 0.0]
638
+
639
+ nodes_with_ids = {part.extras_id: part.node for part in parts if part.extras_id}
640
+ triangles = sum(len(part.indices) // 3 for part in parts)
641
+ vertices = sum(len(part.positions) // 3 for part in parts)
642
+ return Scene(
643
+ parts=tuple(parts),
644
+ up="z",
645
+ nodes_with_ids=nodes_with_ids,
646
+ source=source.name,
647
+ bounds=(tuple(min_b), tuple(max_b)), # type: ignore[arg-type]
648
+ triangles=triangles,
649
+ vertices=vertices,
650
+ )
651
+
652
+
653
+ def _refuse_doctype(raw: bytes, source: Path) -> None:
654
+ try:
655
+ _refuse_doctype_entity(raw, source)
656
+ except ValueError as exc:
657
+ raise SceneRefusal(str(exc)) from exc
658
+
659
+
660
+ def urdf_visual_parents(path: Path) -> dict[str, str]:
661
+ """Link with no visual (inertial-only) -> its nearest ancestor link that has one, so a
662
+ value on the inertial link can light the part the robot actually draws."""
663
+ raw = Path(path).read_bytes()
664
+ _refuse_doctype(raw, Path(path))
665
+ root = ET.fromstring(raw)
666
+ drawn = {el.get("name") for el in root.findall("link") if el.find("visual") is not None}
667
+ parent = {
668
+ c.get("link"): p.get("link")
669
+ for j in root.findall("joint")
670
+ if (p := j.find("parent")) is not None and (c := j.find("child")) is not None
671
+ }
672
+ out: dict[str, str] = {}
673
+ for link in parent:
674
+ if link in drawn:
675
+ continue
676
+ up = parent.get(link)
677
+ while up is not None and up not in drawn:
678
+ up = parent.get(up)
679
+ if up is not None:
680
+ out[link] = up
681
+ return out
682
+
683
+
684
+ def _refuse_xacro(root: ET.Element, source: Path) -> None:
685
+ try:
686
+ _refuse_unresolved_xacro(root, source)
687
+ except UnresolvedXacroError as exc:
688
+ raise SceneRefusal(str(exc)) from exc
689
+
690
+
691
+ def load_robot_scene(
692
+ path: Path,
693
+ *,
694
+ package_roots: tuple[Path, ...] = (),
695
+ external_assets: Mapping[Path, str] | None = None,
696
+ ) -> Scene:
697
+ """A `.urdf`, `.sdf` or literal-XML `.xacro` file -> `Scene`."""
698
+ path = Path(path)
699
+ if not path.is_file():
700
+ raise SceneRefusal(f"{path}: file does not exist.")
701
+ raw = path.read_bytes()
702
+ suffix = path.suffix.lower()
703
+ if (own := package_root(path)) is not None:
704
+ package_roots = (*package_roots, own)
705
+
706
+ if suffix in (".urdf", ".xacro"):
707
+ _refuse_doctype(raw, path)
708
+ try:
709
+ root = ET.fromstring(raw)
710
+ except ET.ParseError as exc:
711
+ raise SceneRefusal(f"{path}: not valid XML ({exc}).") from exc
712
+ _refuse_xacro(root, path)
713
+ return replace(_scene_from_urdf(root, path, package_roots, external_assets),
714
+ asset_path=str(path.resolve()), asset_sha256=hashlib.sha256(raw).hexdigest())
715
+
716
+ if suffix == ".sdf":
717
+ _refuse_doctype(raw, path)
718
+ try:
719
+ root = ET.fromstring(raw)
720
+ except ET.ParseError as exc:
721
+ raise SceneRefusal(f"{path}: not valid XML ({exc}).") from exc
722
+ return replace(_scene_from_sdf(root, path, package_roots, external_assets),
723
+ asset_path=str(path.resolve()), asset_sha256=hashlib.sha256(raw).hexdigest())
724
+
725
+ raise SceneRefusal(
726
+ f"{path}: unsupported file extension {suffix!r} (expected .urdf, .sdf or .xacro)."
727
+ )
728
+
729
+
730
+ def load_scene_any(
731
+ path: Path,
732
+ *,
733
+ package_roots: tuple[Path, ...] = (),
734
+ external_assets: Mapping[Path, str] | None = None,
735
+ ) -> Scene:
736
+ """Dispatch a `--scene` path to the glTF reader or this URDF/SDF reader by suffix."""
737
+ from lockwire.scene import load_scene
738
+
739
+ path = Path(path)
740
+ suffix = path.suffix.lower()
741
+ if suffix in (".glb", ".gltf"):
742
+ return load_scene(path)
743
+ if suffix in (".urdf", ".sdf", ".xacro"):
744
+ return load_robot_scene(
745
+ path, package_roots=package_roots, external_assets=external_assets
746
+ )
747
+ raise SceneRefusal(
748
+ f"{path}: unsupported scene file extension {suffix!r} "
749
+ "(expected .glb, .gltf, .urdf, .sdf or .xacro)."
750
+ )