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.
- urdf_spherizer/__init__.py +4 -0
- urdf_spherizer/_core.pyd +0 -0
- urdf_spherizer/_core.pyi +9 -0
- urdf_spherizer/cli.py +408 -0
- urdf_spherizer-0.1.0.dist-info/METADATA +43 -0
- urdf_spherizer-0.1.0.dist-info/RECORD +9 -0
- urdf_spherizer-0.1.0.dist-info/WHEEL +4 -0
- urdf_spherizer-0.1.0.dist-info/entry_points.txt +2 -0
- urdf_spherizer-0.1.0.dist-info/licenses/LICENSE +21 -0
urdf_spherizer/_core.pyd
ADDED
|
Binary file
|
urdf_spherizer/_core.pyi
ADDED
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,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.
|