urdf-spherizer 0.1.0__cp39-abi3-win_amd64.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.
@@ -0,0 +1,4 @@
1
+ from urdf_spherizer._core import hello_from_bin, spherize_mesh
2
+ from urdf_spherizer.cli import main
3
+
4
+ __all__ = ["hello_from_bin", "spherize_mesh", "main"]
Binary file
@@ -0,0 +1,9 @@
1
+ def hello_from_bin() -> str: ...
2
+ def spherize_mesh(
3
+ vertices,
4
+ indices,
5
+ max_spheres: int,
6
+ min_gain_ratio: float | None = ...,
7
+ epsilon: float | None = ...,
8
+ min_gain_abs: float | None = ...,
9
+ ) -> list[tuple[tuple[float, float, float], float]]: ...
urdf_spherizer/cli.py ADDED
@@ -0,0 +1,408 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import math
5
+ from pathlib import Path
6
+ from typing import Dict, Iterable, List, Optional, Tuple
7
+ import xml.etree.ElementTree as ET
8
+
9
+ import numpy as np
10
+ import trimesh
11
+
12
+ from urdf_spherizer._core import spherize_mesh
13
+
14
+
15
+ def parse_vector(text: Optional[str], count: int, default: Tuple[float, ...]) -> Tuple[float, ...]:
16
+ if text is None:
17
+ return default
18
+ parts = text.strip().split()
19
+ if len(parts) != count:
20
+ raise ValueError(f"Expected {count} values, got {len(parts)}: {text!r}")
21
+ return tuple(float(p) for p in parts)
22
+
23
+
24
+ def rpy_to_matrix(roll: float, pitch: float, yaw: float) -> np.ndarray:
25
+ cr = math.cos(roll)
26
+ sr = math.sin(roll)
27
+ cp = math.cos(pitch)
28
+ sp = math.sin(pitch)
29
+ cy = math.cos(yaw)
30
+ sy = math.sin(yaw)
31
+ return np.array(
32
+ [
33
+ [cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr],
34
+ [sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr],
35
+ [-sp, cp * sr, cp * cr],
36
+ ],
37
+ dtype=np.float32,
38
+ )
39
+
40
+
41
+ def build_transform(xyz: Tuple[float, float, float], rpy: Tuple[float, float, float]) -> np.ndarray:
42
+ rot = rpy_to_matrix(*rpy)
43
+ transform = np.eye(4, dtype=np.float32)
44
+ transform[:3, :3] = rot
45
+ transform[:3, 3] = np.array(xyz, dtype=np.float32)
46
+ return transform
47
+
48
+
49
+ def apply_transform(points: np.ndarray, transform: np.ndarray) -> np.ndarray:
50
+ if points.size == 0:
51
+ return points
52
+ rot = transform[:3, :3]
53
+ trans = transform[:3, 3]
54
+ return points @ rot.T + trans
55
+
56
+
57
+ def resolve_mesh_path(filename: str, urdf_dir: Path, package_map: Dict[str, Path]) -> Path:
58
+ if filename.startswith("package://"):
59
+ suffix = filename[len("package://") :]
60
+ if "/" not in suffix:
61
+ raise ValueError(f"package:// URL missing path: {filename}")
62
+ package, rel = suffix.split("/", 1)
63
+ if package not in package_map:
64
+ raise ValueError(
65
+ f"Package {package!r} not found. Use --package-dir {package}=PATH."
66
+ )
67
+ return package_map[package] / rel
68
+ path = Path(filename)
69
+ if path.is_absolute():
70
+ return path
71
+ return urdf_dir / path
72
+
73
+
74
+ def load_trimesh(path: Path) -> Optional[trimesh.Trimesh]:
75
+ mesh = trimesh.load(path, force="mesh")
76
+ if isinstance(mesh, trimesh.Scene):
77
+ geometries = list(mesh.geometry.values())
78
+ if not geometries:
79
+ return None
80
+ mesh = trimesh.util.concatenate(geometries)
81
+ if not isinstance(mesh, trimesh.Trimesh):
82
+ return None
83
+ if mesh.is_empty:
84
+ return None
85
+ return mesh
86
+
87
+
88
+ def build_visual_mesh(
89
+ visual_elem: ET.Element, urdf_dir: Path, package_map: Dict[str, Path]
90
+ ) -> Optional[trimesh.Trimesh]:
91
+ geometry = visual_elem.find("geometry")
92
+ if geometry is None:
93
+ return None
94
+
95
+ mesh: Optional[trimesh.Trimesh] = None
96
+ mesh_elem = geometry.find("mesh")
97
+ if mesh_elem is not None and "filename" in mesh_elem.attrib:
98
+ filename = mesh_elem.attrib["filename"]
99
+ mesh_path = resolve_mesh_path(filename, urdf_dir, package_map)
100
+ mesh = load_trimesh(mesh_path)
101
+ if mesh is None:
102
+ return None
103
+ scale = parse_vector(mesh_elem.attrib.get("scale"), 3, (1.0, 1.0, 1.0))
104
+ mesh = mesh.copy()
105
+ mesh.apply_scale(np.array(scale, dtype=np.float32))
106
+ else:
107
+ box_elem = geometry.find("box")
108
+ cylinder_elem = geometry.find("cylinder")
109
+ sphere_elem = geometry.find("sphere")
110
+ if box_elem is not None and "size" in box_elem.attrib:
111
+ size = parse_vector(box_elem.attrib.get("size"), 3, (1.0, 1.0, 1.0))
112
+ mesh = trimesh.creation.box(extents=np.array(size, dtype=np.float32))
113
+ elif cylinder_elem is not None:
114
+ radius = float(cylinder_elem.attrib.get("radius", "0"))
115
+ length = float(cylinder_elem.attrib.get("length", "0"))
116
+ if radius > 0 and length > 0:
117
+ mesh = trimesh.creation.cylinder(
118
+ radius=radius, height=length, sections=24
119
+ )
120
+ elif sphere_elem is not None:
121
+ radius = float(sphere_elem.attrib.get("radius", "0"))
122
+ if radius > 0:
123
+ mesh = trimesh.creation.icosphere(radius=radius, subdivisions=2)
124
+
125
+ if mesh is None:
126
+ return None
127
+
128
+ origin_elem = visual_elem.find("origin")
129
+ xyz = parse_vector(origin_elem.attrib.get("xyz") if origin_elem is not None else None, 3, (0.0, 0.0, 0.0))
130
+ rpy = parse_vector(origin_elem.attrib.get("rpy") if origin_elem is not None else None, 3, (0.0, 0.0, 0.0))
131
+ transform = build_transform(xyz, rpy)
132
+ mesh = mesh.copy()
133
+ mesh.apply_transform(transform)
134
+ return mesh
135
+
136
+
137
+ def build_link_mesh(
138
+ link_elem: ET.Element, urdf_dir: Path, package_map: Dict[str, Path]
139
+ ) -> Optional[Tuple[np.ndarray, np.ndarray]]:
140
+ meshes: List[trimesh.Trimesh] = []
141
+ for visual in link_elem.findall("visual"):
142
+ mesh = build_visual_mesh(visual, urdf_dir, package_map)
143
+ if mesh is not None:
144
+ meshes.append(mesh)
145
+ if not meshes:
146
+ return None
147
+ if len(meshes) == 1:
148
+ combined = meshes[0]
149
+ else:
150
+ combined = trimesh.util.concatenate(meshes)
151
+ vertices = np.asarray(combined.vertices, dtype=np.float32)
152
+ faces = np.asarray(combined.faces, dtype=np.uint32)
153
+ if vertices.size == 0 or faces.size == 0:
154
+ return None
155
+ return vertices, faces
156
+
157
+
158
+ def format_vec(values: Iterable[float]) -> str:
159
+ return " ".join(f"{v:.6g}" for v in values)
160
+
161
+
162
+ def replace_link_collisions(link_elem: ET.Element, spheres: List[Tuple[List[float], float]]) -> None:
163
+ for collision in list(link_elem.findall("collision")):
164
+ link_elem.remove(collision)
165
+ for idx, (center, radius) in enumerate(spheres):
166
+ collision = ET.SubElement(link_elem, "collision", {"name": f"sphere_{idx}"})
167
+ ET.SubElement(collision, "origin", {"xyz": format_vec(center), "rpy": "0 0 0"})
168
+ geometry = ET.SubElement(collision, "geometry")
169
+ ET.SubElement(geometry, "sphere", {"radius": f"{radius:.6g}"})
170
+
171
+
172
+ def parse_package_dirs(values: List[str]) -> Dict[str, Path]:
173
+ package_map: Dict[str, Path] = {}
174
+ for item in values:
175
+ if "=" not in item:
176
+ raise ValueError(f"Invalid --package-dir entry: {item!r} (expected NAME=PATH)")
177
+ name, path = item.split("=", 1)
178
+ package_map[name] = Path(path)
179
+ return package_map
180
+
181
+
182
+ def compute_link_transforms(root_elem: ET.Element) -> Dict[str, np.ndarray]:
183
+ parent_map: Dict[str, Tuple[str, np.ndarray]] = {}
184
+ children_map: Dict[str, List[str]] = {}
185
+ for joint in root_elem.findall("joint"):
186
+ parent_elem = joint.find("parent")
187
+ child_elem = joint.find("child")
188
+ if parent_elem is None or child_elem is None:
189
+ continue
190
+ parent = parent_elem.attrib.get("link")
191
+ child = child_elem.attrib.get("link")
192
+ if not parent or not child:
193
+ continue
194
+ origin_elem = joint.find("origin")
195
+ xyz = parse_vector(
196
+ origin_elem.attrib.get("xyz") if origin_elem is not None else None,
197
+ 3,
198
+ (0.0, 0.0, 0.0),
199
+ )
200
+ rpy = parse_vector(
201
+ origin_elem.attrib.get("rpy") if origin_elem is not None else None,
202
+ 3,
203
+ (0.0, 0.0, 0.0),
204
+ )
205
+ transform = build_transform(xyz, rpy)
206
+ parent_map[child] = (parent, transform)
207
+ children_map.setdefault(parent, []).append(child)
208
+
209
+ link_names = [link.attrib.get("name", "link") for link in root_elem.findall("link")]
210
+ child_links = set(parent_map.keys())
211
+ root_links = [name for name in link_names if name not in child_links]
212
+ if not root_links:
213
+ root_links = link_names
214
+
215
+ transforms: Dict[str, np.ndarray] = {}
216
+
217
+ def dfs(link_name: str, current: np.ndarray) -> None:
218
+ if link_name in transforms:
219
+ return
220
+ transforms[link_name] = current
221
+ for child in children_map.get(link_name, []):
222
+ parent, local_tf = parent_map[child]
223
+ if parent != link_name:
224
+ continue
225
+ dfs(child, current @ local_tf)
226
+
227
+ identity = np.eye(4, dtype=np.float32)
228
+ for root in root_links:
229
+ dfs(root, identity)
230
+ return transforms
231
+
232
+
233
+ def clamp01(value: float) -> float:
234
+ if value < 0.0:
235
+ return 0.0
236
+ if value > 1.0:
237
+ return 1.0
238
+ return value
239
+
240
+
241
+ def log_link_viz(
242
+ rr_module,
243
+ link_name: str,
244
+ vertices: np.ndarray,
245
+ faces: np.ndarray,
246
+ spheres,
247
+ mesh_alpha: float,
248
+ sphere_alpha: float,
249
+ ) -> None:
250
+ mesh_alpha = clamp01(mesh_alpha)
251
+ sphere_alpha = clamp01(sphere_alpha)
252
+ mesh_normals = None
253
+ try:
254
+ mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
255
+ if mesh.vertex_normals is not None and len(mesh.vertex_normals) == len(vertices):
256
+ mesh_normals = np.asarray(mesh.vertex_normals, dtype=np.float32)
257
+ except Exception:
258
+ mesh_normals = None
259
+ albedo_factor = np.array([0.8, 0.8, 0.8, mesh_alpha], dtype=np.float32)
260
+ rr_module.log(
261
+ f"link/{link_name}/visual",
262
+ rr_module.Mesh3D(
263
+ vertex_positions=vertices,
264
+ triangle_indices=faces,
265
+ vertex_normals=mesh_normals,
266
+ albedo_factor=albedo_factor,
267
+ ),
268
+ )
269
+ if spheres:
270
+ centers = np.asarray([s[0] for s in spheres], dtype=np.float32)
271
+ radii = np.asarray([s[1] for s in spheres], dtype=np.float32)
272
+ half_sizes = np.repeat(radii[:, None], 3, axis=1)
273
+ sphere_color = np.array([0.2, 0.6, 1.0, sphere_alpha], dtype=np.float32)
274
+ colors = np.repeat(sphere_color[None, :], centers.shape[0], axis=0)
275
+ rr_module.log(
276
+ f"link/{link_name}/spheres",
277
+ rr_module.Ellipsoids3D(
278
+ centers=centers,
279
+ half_sizes=half_sizes,
280
+ colors=colors,
281
+ fill_mode=rr_module.components.FillMode.Solid,
282
+ ),
283
+ )
284
+
285
+
286
+ def main() -> None:
287
+ parser = argparse.ArgumentParser(description="Generate sphere collisions from URDF visuals.")
288
+ parser.add_argument("input", help="Input URDF path")
289
+ parser.add_argument(
290
+ "-o",
291
+ "--output",
292
+ help="Output URDF path (default: <input>.spheres.urdf)",
293
+ )
294
+ parser.add_argument(
295
+ "--max-spheres",
296
+ type=int,
297
+ default=64,
298
+ help="Maximum spheres per link",
299
+ )
300
+ parser.add_argument(
301
+ "--min-gain-ratio",
302
+ type=float,
303
+ default=0.02,
304
+ help="Stop splitting when relative gain falls below this ratio (combined with --margin)",
305
+ )
306
+ parser.add_argument(
307
+ "--margin",
308
+ type=float,
309
+ default=0.0,
310
+ help="Stop splitting when absolute gain falls below this distance (meters)",
311
+ )
312
+ parser.add_argument(
313
+ "--epsilon",
314
+ type=float,
315
+ default=1.0e-6,
316
+ help="Radius inflation for numerical safety",
317
+ )
318
+ parser.add_argument(
319
+ "--package-dir",
320
+ action="append",
321
+ default=[],
322
+ help="Resolve package://NAME/... using NAME=PATH (can be repeated)",
323
+ )
324
+ parser.add_argument(
325
+ "--viz",
326
+ action="store_true",
327
+ help="Visualize results with rerun",
328
+ )
329
+ parser.add_argument(
330
+ "--viz-mesh-alpha",
331
+ type=float,
332
+ default=1.0,
333
+ help="Alpha for visual mesh in rerun (0.0-1.0)",
334
+ )
335
+ parser.add_argument(
336
+ "--viz-sphere-alpha",
337
+ type=float,
338
+ default=0.25,
339
+ help="Alpha for visual spheres in rerun (0.0-1.0)",
340
+ )
341
+ args = parser.parse_args()
342
+
343
+ input_path = Path(args.input)
344
+ if not input_path.exists():
345
+ raise SystemExit(f"Input URDF not found: {input_path}")
346
+ output_path = Path(args.output) if args.output else input_path.with_suffix(".spheres.urdf")
347
+ package_map = parse_package_dirs(args.package_dir)
348
+
349
+ tree = ET.parse(input_path)
350
+ root = tree.getroot()
351
+ urdf_dir = input_path.parent
352
+ link_transforms = compute_link_transforms(root)
353
+
354
+ rr_module = None
355
+ if args.viz:
356
+ import rerun as rr
357
+
358
+ rr.init("urdf-spherizer", spawn=True)
359
+ rr_module = rr
360
+
361
+ for link in root.findall("link"):
362
+ link_name = link.attrib.get("name", "link")
363
+ mesh_data = build_link_mesh(link, urdf_dir, package_map)
364
+ if mesh_data is None:
365
+ continue
366
+ vertices, faces = mesh_data
367
+ spheres = spherize_mesh(
368
+ vertices,
369
+ faces,
370
+ args.max_spheres,
371
+ min_gain_ratio=args.min_gain_ratio,
372
+ epsilon=args.epsilon,
373
+ min_gain_abs=args.margin,
374
+ )
375
+ spheres = [(list(center), float(radius)) for center, radius in spheres]
376
+ replace_link_collisions(link, spheres)
377
+ if rr_module is not None:
378
+ transform = link_transforms.get(link_name)
379
+ if transform is not None:
380
+ viz_vertices = apply_transform(vertices, transform)
381
+ if spheres:
382
+ centers = np.asarray([s[0] for s in spheres], dtype=np.float32)
383
+ centers = apply_transform(centers, transform)
384
+ radii = [s[1] for s in spheres]
385
+ viz_spheres = [
386
+ (center.tolist(), radius) for center, radius in zip(centers, radii)
387
+ ]
388
+ else:
389
+ viz_spheres = []
390
+ else:
391
+ viz_vertices = vertices
392
+ viz_spheres = spheres
393
+ log_link_viz(
394
+ rr_module,
395
+ link_name,
396
+ viz_vertices,
397
+ faces,
398
+ viz_spheres,
399
+ args.viz_mesh_alpha,
400
+ args.viz_sphere_alpha,
401
+ )
402
+
403
+ ET.indent(tree, space=" ")
404
+ tree.write(output_path, encoding="utf-8", xml_declaration=True)
405
+
406
+
407
+ if __name__ == "__main__":
408
+ main()
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: urdf-spherizer
3
+ Version: 0.1.0
4
+ Requires-Dist: numpy
5
+ Requires-Dist: rerun-sdk
6
+ Requires-Dist: scipy>=1.17.0
7
+ Requires-Dist: trimesh
8
+ License-File: LICENSE
9
+ Summary: Add your description here
10
+ Author-email: neka-nat <nekanat.stock@gmail.com>
11
+ Requires-Python: >=3.13
12
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
13
+
14
+ urdf-spherizer
15
+ ==============
16
+
17
+ Convert URDF visual geometry into conservative sphere-based collision geometry.
18
+
19
+ Usage
20
+ -----
21
+
22
+ ```bash
23
+ uvx urdf-spherizer path/to/model.urdf -o path/to/model.spheres.urdf --max-spheres 64 --margin 0.002
24
+ ```
25
+
26
+ Optional visualization with rerun:
27
+
28
+ ```bash
29
+ uvx urdf-spherizer path/to/model.urdf --viz
30
+ ```
31
+
32
+ If your URDF uses `package://` URLs, pass package roots:
33
+
34
+ ```bash
35
+ uvx urdf-spherizer path/to/model.urdf --package-dir my_robot=/path/to/my_robot
36
+ ```
37
+
38
+
39
+ Results
40
+ --------
41
+
42
+ https://github.com/user-attachments/assets/494a6be0-eeb3-41ec-a807-08dbced9c3a8
43
+
@@ -0,0 +1,9 @@
1
+ urdf_spherizer\__init__.py,sha256=ICjSnacbSIYf3GvGlJKxTXMXvypSKPfxz_kJpqVKjvk,158
2
+ urdf_spherizer\_core.pyd,sha256=8IGoEfoppTbx4z1QfjRGWQYBwGCI1T_IEI_-H1TOd_E,274432
3
+ urdf_spherizer\_core.pyi,sha256=GNJyT6BTBwyDnvWovfEqb3iHdBvOfJ8uo93xOgfm7Do,278
4
+ urdf_spherizer\cli.py,sha256=jcebaZte2_MMCeo2evNNtxI1KtbD1JgOfNN2rka7vYs,14307
5
+ urdf_spherizer-0.1.0.dist-info\METADATA,sha256=9eYx4ukuHy7sZ6VyqQHPd7tgEOCuXoL4Ahep3z5wNFo,965
6
+ urdf_spherizer-0.1.0.dist-info\WHEEL,sha256=OD0Is1kLHE07aD7XukSVDFU8ymUMI4Fdg0tKYWce3N0,95
7
+ urdf_spherizer-0.1.0.dist-info\entry_points.txt,sha256=JhVOOgsHFt2mMdx-RRI4BC3mekVvv4ARAwDiHDhE8Mk,53
8
+ urdf_spherizer-0.1.0.dist-info\licenses\LICENSE,sha256=uNElh9I6tlleQbVR61jPwjnEvrOKSqdNnYfSELWHiXU,1086
9
+ urdf_spherizer-0.1.0.dist-info\RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: maturin (1.11.5)
3
+ Root-Is-Purelib: false
4
+ Tag: cp39-abi3-win_amd64
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ urdf-spherizer=urdf_spherizer:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 k-tanaka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.