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