722 lines
24 KiB
Python
722 lines
24 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Visualize URDF collision geometry and optional visual meshes in MeshCat."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
from dataclasses import dataclass
|
||
|
|
import math
|
||
|
|
import os
|
||
|
|
from pathlib import Path
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
import traceback
|
||
|
|
import webbrowser
|
||
|
|
import xml.etree.ElementTree as ET
|
||
|
|
|
||
|
|
import meshcat
|
||
|
|
import meshcat.geometry as geometry
|
||
|
|
import numpy as np
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class Joint:
|
||
|
|
name: str
|
||
|
|
kind: str
|
||
|
|
parent: str
|
||
|
|
child: str
|
||
|
|
origin: np.ndarray
|
||
|
|
axis: np.ndarray
|
||
|
|
mimic: tuple[str, float, float] | None
|
||
|
|
|
||
|
|
|
||
|
|
def parse_vector(
|
||
|
|
value: str | None, size: int, default: tuple[float, ...]
|
||
|
|
) -> np.ndarray:
|
||
|
|
if value is None:
|
||
|
|
return np.asarray(default, dtype=np.float64)
|
||
|
|
parts = value.split()
|
||
|
|
if len(parts) != size:
|
||
|
|
raise ValueError(f"expected {size} values, got {value!r}")
|
||
|
|
result = np.asarray([float(part) for part in parts], dtype=np.float64)
|
||
|
|
if not np.isfinite(result).all():
|
||
|
|
raise ValueError(f"values contain NaN or infinity: {value!r}")
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def rpy_rotation(rpy: np.ndarray) -> np.ndarray:
|
||
|
|
roll, pitch, yaw = rpy
|
||
|
|
cr, sr = math.cos(roll), math.sin(roll)
|
||
|
|
cp, sp = math.cos(pitch), math.sin(pitch)
|
||
|
|
cy, sy = math.cos(yaw), math.sin(yaw)
|
||
|
|
return np.array(
|
||
|
|
[
|
||
|
|
[cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr],
|
||
|
|
[sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr],
|
||
|
|
[-sp, cp * sr, cp * cr],
|
||
|
|
],
|
||
|
|
dtype=np.float64,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def origin_transform(origin: ET.Element | None) -> np.ndarray:
|
||
|
|
transform = np.eye(4)
|
||
|
|
if origin is None:
|
||
|
|
return transform
|
||
|
|
transform[:3, :3] = rpy_rotation(
|
||
|
|
parse_vector(origin.get("rpy"), 3, (0.0, 0.0, 0.0))
|
||
|
|
)
|
||
|
|
transform[:3, 3] = parse_vector(
|
||
|
|
origin.get("xyz"), 3, (0.0, 0.0, 0.0)
|
||
|
|
)
|
||
|
|
return transform
|
||
|
|
|
||
|
|
|
||
|
|
def axis_angle_transform(axis: np.ndarray, angle: float) -> np.ndarray:
|
||
|
|
norm = float(np.linalg.norm(axis))
|
||
|
|
if norm < 1e-12:
|
||
|
|
raise ValueError("joint axis must not be zero")
|
||
|
|
x, y, z = axis / norm
|
||
|
|
c, s = math.cos(angle), math.sin(angle)
|
||
|
|
one_minus_c = 1.0 - c
|
||
|
|
transform = np.eye(4)
|
||
|
|
transform[:3, :3] = np.array(
|
||
|
|
[
|
||
|
|
[c + x * x * one_minus_c, x * y * one_minus_c - z * s, x * z * one_minus_c + y * s],
|
||
|
|
[y * x * one_minus_c + z * s, c + y * y * one_minus_c, y * z * one_minus_c - x * s],
|
||
|
|
[z * x * one_minus_c - y * s, z * y * one_minus_c + x * s, c + z * z * one_minus_c],
|
||
|
|
],
|
||
|
|
dtype=np.float64,
|
||
|
|
)
|
||
|
|
return transform
|
||
|
|
|
||
|
|
|
||
|
|
def translation_transform(offset: np.ndarray) -> np.ndarray:
|
||
|
|
transform = np.eye(4)
|
||
|
|
transform[:3, 3] = offset
|
||
|
|
return transform
|
||
|
|
|
||
|
|
|
||
|
|
def parse_joint_values(values: list[str]) -> dict[str, float]:
|
||
|
|
result: dict[str, float] = {}
|
||
|
|
for assignment in values:
|
||
|
|
name, separator, raw_value = assignment.partition("=")
|
||
|
|
if not separator or not name or not raw_value:
|
||
|
|
raise ValueError(
|
||
|
|
f"invalid --joint value {assignment!r}; expected NAME=VALUE"
|
||
|
|
)
|
||
|
|
if name in result:
|
||
|
|
raise ValueError(f"joint value specified more than once: {name}")
|
||
|
|
value = float(raw_value)
|
||
|
|
if not math.isfinite(value):
|
||
|
|
raise ValueError(f"joint value must be finite: {assignment!r}")
|
||
|
|
result[name] = value
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def parse_joints(root: ET.Element) -> dict[str, Joint]:
|
||
|
|
joints: dict[str, Joint] = {}
|
||
|
|
children: set[str] = set()
|
||
|
|
for element in root.findall("joint"):
|
||
|
|
name = element.get("name")
|
||
|
|
kind = element.get("type")
|
||
|
|
parent_element = element.find("parent")
|
||
|
|
child_element = element.find("child")
|
||
|
|
if not name or not kind or parent_element is None or child_element is None:
|
||
|
|
raise ValueError("every joint needs name, type, parent, and child")
|
||
|
|
parent = parent_element.get("link")
|
||
|
|
child = child_element.get("link")
|
||
|
|
if not parent or not child:
|
||
|
|
raise ValueError(f"joint {name!r} has an empty parent or child")
|
||
|
|
if name in joints:
|
||
|
|
raise ValueError(f"duplicate joint name: {name}")
|
||
|
|
if child in children:
|
||
|
|
raise ValueError(f"link {child!r} has more than one parent joint")
|
||
|
|
axis_element = element.find("axis")
|
||
|
|
axis = parse_vector(
|
||
|
|
axis_element.get("xyz") if axis_element is not None else None,
|
||
|
|
3,
|
||
|
|
(1.0, 0.0, 0.0),
|
||
|
|
)
|
||
|
|
mimic_element = element.find("mimic")
|
||
|
|
mimic = None
|
||
|
|
if mimic_element is not None:
|
||
|
|
source = mimic_element.get("joint")
|
||
|
|
if not source:
|
||
|
|
raise ValueError(f"mimic joint {name!r} has no source joint")
|
||
|
|
mimic = (
|
||
|
|
source,
|
||
|
|
float(mimic_element.get("multiplier", "1")),
|
||
|
|
float(mimic_element.get("offset", "0")),
|
||
|
|
)
|
||
|
|
joints[name] = Joint(
|
||
|
|
name=name,
|
||
|
|
kind=kind,
|
||
|
|
parent=parent,
|
||
|
|
child=child,
|
||
|
|
origin=origin_transform(element.find("origin")),
|
||
|
|
axis=axis,
|
||
|
|
mimic=mimic,
|
||
|
|
)
|
||
|
|
children.add(child)
|
||
|
|
return joints
|
||
|
|
|
||
|
|
|
||
|
|
def resolve_joint_values(
|
||
|
|
joints: dict[str, Joint], requested: dict[str, float]
|
||
|
|
) -> dict[str, float]:
|
||
|
|
unknown = set(requested) - set(joints)
|
||
|
|
if unknown:
|
||
|
|
raise ValueError(f"unknown joints: {sorted(unknown)}")
|
||
|
|
fixed = [name for name in requested if joints[name].kind == "fixed"]
|
||
|
|
if fixed:
|
||
|
|
raise ValueError(f"fixed joints cannot be assigned: {sorted(fixed)}")
|
||
|
|
|
||
|
|
resolved: dict[str, float] = {}
|
||
|
|
|
||
|
|
def resolve(name: str, stack: set[str]) -> float:
|
||
|
|
if name in resolved:
|
||
|
|
return resolved[name]
|
||
|
|
if name in stack:
|
||
|
|
raise ValueError(f"mimic joint cycle contains {name!r}")
|
||
|
|
joint = joints[name]
|
||
|
|
if name in requested:
|
||
|
|
value = requested[name]
|
||
|
|
elif joint.mimic is not None:
|
||
|
|
source, multiplier, offset = joint.mimic
|
||
|
|
if source not in joints:
|
||
|
|
raise ValueError(
|
||
|
|
f"mimic joint {name!r} references unknown joint {source!r}"
|
||
|
|
)
|
||
|
|
value = multiplier * resolve(source, stack | {name}) + offset
|
||
|
|
else:
|
||
|
|
value = 0.0
|
||
|
|
resolved[name] = value
|
||
|
|
return value
|
||
|
|
|
||
|
|
for joint_name in joints:
|
||
|
|
resolve(joint_name, set())
|
||
|
|
return resolved
|
||
|
|
|
||
|
|
|
||
|
|
def joint_motion(joint: Joint, value: float) -> np.ndarray:
|
||
|
|
if joint.kind == "fixed":
|
||
|
|
return np.eye(4)
|
||
|
|
if joint.kind in ("revolute", "continuous"):
|
||
|
|
return axis_angle_transform(joint.axis, value)
|
||
|
|
if joint.kind == "prismatic":
|
||
|
|
norm = float(np.linalg.norm(joint.axis))
|
||
|
|
if norm < 1e-12:
|
||
|
|
raise ValueError(f"joint {joint.name!r} axis must not be zero")
|
||
|
|
axis = joint.axis / norm
|
||
|
|
return translation_transform(axis * value)
|
||
|
|
raise ValueError(
|
||
|
|
f"joint {joint.name!r} uses unsupported type {joint.kind!r}; "
|
||
|
|
"supported types are fixed, revolute, continuous, and prismatic"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def compute_link_transforms(
|
||
|
|
root: ET.Element, joints: dict[str, Joint], values: dict[str, float]
|
||
|
|
) -> dict[str, np.ndarray]:
|
||
|
|
links = {element.get("name") for element in root.findall("link")}
|
||
|
|
if None in links:
|
||
|
|
raise ValueError("every link needs a name")
|
||
|
|
children = {joint.child for joint in joints.values()}
|
||
|
|
roots = links - children
|
||
|
|
if not roots:
|
||
|
|
raise ValueError("URDF has no root link")
|
||
|
|
|
||
|
|
transforms = {name: np.eye(4) for name in roots}
|
||
|
|
pending = list(joints.values())
|
||
|
|
while pending:
|
||
|
|
unresolved: list[Joint] = []
|
||
|
|
for joint in pending:
|
||
|
|
if joint.parent not in links or joint.child not in links:
|
||
|
|
raise ValueError(
|
||
|
|
f"joint {joint.name!r} references a missing parent or child link"
|
||
|
|
)
|
||
|
|
if joint.parent not in transforms:
|
||
|
|
unresolved.append(joint)
|
||
|
|
continue
|
||
|
|
transforms[joint.child] = (
|
||
|
|
transforms[joint.parent]
|
||
|
|
@ joint.origin
|
||
|
|
@ joint_motion(joint, values[joint.name])
|
||
|
|
)
|
||
|
|
if len(unresolved) == len(pending):
|
||
|
|
names = [joint.name for joint in unresolved]
|
||
|
|
raise ValueError(f"joint graph is cyclic or disconnected: {names}")
|
||
|
|
pending = unresolved
|
||
|
|
return transforms
|
||
|
|
|
||
|
|
|
||
|
|
def resolve_mesh_path(filename: str, urdf_path: Path) -> Path:
|
||
|
|
if filename.startswith("file://"):
|
||
|
|
path = Path(filename.removeprefix("file://"))
|
||
|
|
elif filename.startswith("package://"):
|
||
|
|
package_path = Path(filename.removeprefix("package://"))
|
||
|
|
if len(package_path.parts) < 2:
|
||
|
|
raise ValueError(f"invalid package URI: {filename}")
|
||
|
|
package_name, relative_parts = package_path.parts[0], package_path.parts[1:]
|
||
|
|
candidates = [
|
||
|
|
parent / package_name / Path(*relative_parts)
|
||
|
|
for parent in (urdf_path.parent, *urdf_path.parents)
|
||
|
|
]
|
||
|
|
candidates.extend(
|
||
|
|
parent / Path(*relative_parts)
|
||
|
|
for parent in urdf_path.parents
|
||
|
|
if parent.name == package_name
|
||
|
|
)
|
||
|
|
for candidate in candidates:
|
||
|
|
if candidate.is_file():
|
||
|
|
return candidate.resolve()
|
||
|
|
raise FileNotFoundError(
|
||
|
|
f"could not resolve {filename!r} relative to {urdf_path}"
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
path = Path(filename)
|
||
|
|
if not path.is_absolute():
|
||
|
|
path = urdf_path.parent / path
|
||
|
|
path = path.resolve()
|
||
|
|
if not path.is_file():
|
||
|
|
raise FileNotFoundError(path)
|
||
|
|
return path
|
||
|
|
|
||
|
|
|
||
|
|
def load_mesh(path: Path) -> geometry.Geometry:
|
||
|
|
suffix = path.suffix.lower()
|
||
|
|
if suffix == ".stl":
|
||
|
|
return geometry.StlMeshGeometry.from_file(str(path))
|
||
|
|
if suffix == ".obj":
|
||
|
|
return geometry.ObjMeshGeometry.from_file(str(path))
|
||
|
|
if suffix == ".dae":
|
||
|
|
return geometry.DaeMeshGeometry.from_file(str(path))
|
||
|
|
raise ValueError(
|
||
|
|
f"unsupported mesh format {path.suffix!r}: {path}; "
|
||
|
|
"MeshCat viewer supports STL, OBJ, and DAE"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def cylinder_dimensions(cylinder: ET.Element) -> tuple[float, float]:
|
||
|
|
radius = float(cylinder.get("radius", "nan"))
|
||
|
|
length = float(cylinder.get("length", "nan"))
|
||
|
|
if (
|
||
|
|
not math.isfinite(radius)
|
||
|
|
or not math.isfinite(length)
|
||
|
|
or radius <= 0.0
|
||
|
|
or length <= 0.0
|
||
|
|
):
|
||
|
|
raise ValueError(
|
||
|
|
f"cylinder radius and length must be positive: {radius}, {length}"
|
||
|
|
)
|
||
|
|
return radius, length
|
||
|
|
|
||
|
|
|
||
|
|
def cylinder_correction() -> np.ndarray:
|
||
|
|
correction = np.eye(4)
|
||
|
|
# Three.js cylinders use local Y; URDF cylinders use local Z.
|
||
|
|
correction[:3, :3] = rpy_rotation(np.array([math.pi / 2.0, 0.0, 0.0]))
|
||
|
|
return correction
|
||
|
|
|
||
|
|
|
||
|
|
def cylinder_wireframe(
|
||
|
|
radius: float,
|
||
|
|
length: float,
|
||
|
|
generator_count: int,
|
||
|
|
color: int,
|
||
|
|
opacity_value: float,
|
||
|
|
ring_segments: int = 64,
|
||
|
|
) -> geometry.LineSegments:
|
||
|
|
vertices: list[tuple[float, float, float]] = []
|
||
|
|
half_length = length / 2.0
|
||
|
|
|
||
|
|
# Smooth top and bottom rings, without cap triangulation spokes.
|
||
|
|
for y in (-half_length, half_length):
|
||
|
|
for index in range(ring_segments):
|
||
|
|
first = 2.0 * math.pi * index / ring_segments
|
||
|
|
second = 2.0 * math.pi * (index + 1) / ring_segments
|
||
|
|
vertices.extend(
|
||
|
|
[
|
||
|
|
(radius * math.cos(first), y, radius * math.sin(first)),
|
||
|
|
(radius * math.cos(second), y, radius * math.sin(second)),
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
# Sparse axial generator lines; six means one line every 60 degrees.
|
||
|
|
for index in range(generator_count):
|
||
|
|
angle = 2.0 * math.pi * index / generator_count
|
||
|
|
x = radius * math.cos(angle)
|
||
|
|
z = radius * math.sin(angle)
|
||
|
|
vertices.extend([(x, -half_length, z), (x, half_length, z)])
|
||
|
|
|
||
|
|
points = np.asarray(vertices, dtype=np.float32).T
|
||
|
|
material = geometry.LineBasicMaterial(
|
||
|
|
color=color,
|
||
|
|
transparent=opacity_value < 1.0,
|
||
|
|
opacity=opacity_value,
|
||
|
|
)
|
||
|
|
return geometry.LineSegments(geometry.PointsGeometry(points), material)
|
||
|
|
|
||
|
|
|
||
|
|
def geometry_object(
|
||
|
|
geometry_element: ET.Element, urdf_path: Path
|
||
|
|
) -> tuple[geometry.Geometry, np.ndarray]:
|
||
|
|
box = geometry_element.find("box")
|
||
|
|
sphere = geometry_element.find("sphere")
|
||
|
|
cylinder = geometry_element.find("cylinder")
|
||
|
|
mesh = geometry_element.find("mesh")
|
||
|
|
correction = np.eye(4)
|
||
|
|
|
||
|
|
if box is not None:
|
||
|
|
size = parse_vector(box.get("size"), 3, ())
|
||
|
|
if (size <= 0.0).any():
|
||
|
|
raise ValueError(f"box size must be positive: {size}")
|
||
|
|
return geometry.Box(size), correction
|
||
|
|
if sphere is not None:
|
||
|
|
radius = float(sphere.get("radius", "nan"))
|
||
|
|
if not math.isfinite(radius) or radius <= 0.0:
|
||
|
|
raise ValueError(f"sphere radius must be positive: {radius}")
|
||
|
|
return geometry.Sphere(radius), correction
|
||
|
|
if cylinder is not None:
|
||
|
|
radius, length = cylinder_dimensions(cylinder)
|
||
|
|
return geometry.Cylinder(length, radius), cylinder_correction()
|
||
|
|
if mesh is not None:
|
||
|
|
filename = mesh.get("filename")
|
||
|
|
if not filename:
|
||
|
|
raise ValueError("mesh geometry has no filename")
|
||
|
|
scale = parse_vector(mesh.get("scale"), 3, (1.0, 1.0, 1.0))
|
||
|
|
correction[:3, :3] = np.diag(scale)
|
||
|
|
return load_mesh(resolve_mesh_path(filename, urdf_path)), correction
|
||
|
|
raise ValueError("geometry must contain box, sphere, cylinder, or mesh")
|
||
|
|
|
||
|
|
|
||
|
|
def parse_rgba(value: str) -> tuple[float, float, float, float]:
|
||
|
|
rgba = parse_vector(value, 4, ())
|
||
|
|
if ((rgba < 0.0) | (rgba > 1.0)).any():
|
||
|
|
raise ValueError(f"RGBA values must be between 0 and 1: {value!r}")
|
||
|
|
return tuple(float(component) for component in rgba)
|
||
|
|
|
||
|
|
|
||
|
|
def rgb_integer(rgb: tuple[float, float, float]) -> int:
|
||
|
|
red, green, blue = (round(component * 255.0) for component in rgb)
|
||
|
|
return (red << 16) | (green << 8) | blue
|
||
|
|
|
||
|
|
|
||
|
|
def visual_rgba(
|
||
|
|
visual: ET.Element,
|
||
|
|
named_materials: dict[str, tuple[float, float, float, float]],
|
||
|
|
) -> tuple[float, float, float, float]:
|
||
|
|
material = visual.find("material")
|
||
|
|
if material is None:
|
||
|
|
return (0.65, 0.68, 0.72, 1.0)
|
||
|
|
color = material.find("color")
|
||
|
|
if color is not None and color.get("rgba"):
|
||
|
|
return parse_rgba(color.get("rgba", ""))
|
||
|
|
name = material.get("name")
|
||
|
|
if name and name in named_materials:
|
||
|
|
return named_materials[name]
|
||
|
|
return (0.65, 0.68, 0.72, 1.0)
|
||
|
|
|
||
|
|
|
||
|
|
def named_materials(
|
||
|
|
root: ET.Element,
|
||
|
|
) -> dict[str, tuple[float, float, float, float]]:
|
||
|
|
result: dict[str, tuple[float, float, float, float]] = {}
|
||
|
|
for material in root.findall("material"):
|
||
|
|
name = material.get("name")
|
||
|
|
color = material.find("color")
|
||
|
|
if name and color is not None and color.get("rgba"):
|
||
|
|
result[name] = parse_rgba(color.get("rgba", ""))
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def parse_color(value: str) -> int:
|
||
|
|
normalized = value.removeprefix("#").removeprefix("0x")
|
||
|
|
if len(normalized) != 6:
|
||
|
|
raise argparse.ArgumentTypeError("color must use RRGGBB format")
|
||
|
|
try:
|
||
|
|
result = int(normalized, 16)
|
||
|
|
except ValueError as error:
|
||
|
|
raise argparse.ArgumentTypeError("color must use RRGGBB format") from error
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def opacity(value: str) -> float:
|
||
|
|
result = float(value)
|
||
|
|
if not 0.0 <= result <= 1.0:
|
||
|
|
raise argparse.ArgumentTypeError("opacity must be between 0 and 1")
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def cylinder_lines(value: str) -> int:
|
||
|
|
result = int(value)
|
||
|
|
if result < 3:
|
||
|
|
raise argparse.ArgumentTypeError("cylinder line count must be at least 3")
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def safe_name(value: str) -> str:
|
||
|
|
return value.replace("/", "_")
|
||
|
|
|
||
|
|
|
||
|
|
def render_urdf(
|
||
|
|
viewer: meshcat.Visualizer,
|
||
|
|
root: ET.Element,
|
||
|
|
urdf_path: Path,
|
||
|
|
link_transforms: dict[str, np.ndarray],
|
||
|
|
*,
|
||
|
|
collision_only: bool,
|
||
|
|
visual_opacity: float,
|
||
|
|
collision_opacity: float,
|
||
|
|
collision_color: int,
|
||
|
|
wireframe: bool,
|
||
|
|
collision_cylinder_lines: int,
|
||
|
|
) -> tuple[int, int]:
|
||
|
|
viewer.delete()
|
||
|
|
materials = named_materials(root)
|
||
|
|
visual_count = 0
|
||
|
|
collision_count = 0
|
||
|
|
collision_material = geometry.MeshPhongMaterial(
|
||
|
|
color=collision_color,
|
||
|
|
transparent=collision_opacity < 1.0,
|
||
|
|
opacity=collision_opacity,
|
||
|
|
wireframe=wireframe,
|
||
|
|
)
|
||
|
|
|
||
|
|
for link in root.findall("link"):
|
||
|
|
link_name = link.get("name", "")
|
||
|
|
link_transform = link_transforms[link_name]
|
||
|
|
if not collision_only:
|
||
|
|
for index, visual in enumerate(link.findall("visual")):
|
||
|
|
geometry_element = visual.find("geometry")
|
||
|
|
if geometry_element is None:
|
||
|
|
raise ValueError(f"visual geometry missing on link {link_name!r}")
|
||
|
|
shape, correction = geometry_object(geometry_element, urdf_path)
|
||
|
|
rgba = visual_rgba(visual, materials)
|
||
|
|
alpha = visual_opacity * rgba[3]
|
||
|
|
material = geometry.MeshPhongMaterial(
|
||
|
|
color=rgb_integer(rgba[:3]),
|
||
|
|
transparent=alpha < 1.0,
|
||
|
|
opacity=alpha,
|
||
|
|
)
|
||
|
|
node = viewer[
|
||
|
|
f"robot/visual/{safe_name(link_name)}/visual_{index}"
|
||
|
|
]
|
||
|
|
node.set_object(shape, material)
|
||
|
|
node.set_transform(
|
||
|
|
link_transform
|
||
|
|
@ origin_transform(visual.find("origin"))
|
||
|
|
@ correction
|
||
|
|
)
|
||
|
|
visual_count += 1
|
||
|
|
|
||
|
|
for index, collision in enumerate(link.findall("collision")):
|
||
|
|
geometry_element = collision.find("geometry")
|
||
|
|
if geometry_element is None:
|
||
|
|
raise ValueError(f"collision geometry missing on link {link_name!r}")
|
||
|
|
cylinder = geometry_element.find("cylinder")
|
||
|
|
if wireframe and cylinder is not None:
|
||
|
|
radius, length = cylinder_dimensions(cylinder)
|
||
|
|
shape = cylinder_wireframe(
|
||
|
|
radius,
|
||
|
|
length,
|
||
|
|
collision_cylinder_lines,
|
||
|
|
collision_color,
|
||
|
|
collision_opacity,
|
||
|
|
)
|
||
|
|
correction = cylinder_correction()
|
||
|
|
custom_line_object = True
|
||
|
|
else:
|
||
|
|
shape, correction = geometry_object(geometry_element, urdf_path)
|
||
|
|
custom_line_object = False
|
||
|
|
node = viewer[
|
||
|
|
f"robot/collision/{safe_name(link_name)}/collision_{index}"
|
||
|
|
]
|
||
|
|
if custom_line_object:
|
||
|
|
node.set_object(shape)
|
||
|
|
else:
|
||
|
|
node.set_object(shape, collision_material)
|
||
|
|
node.set_transform(
|
||
|
|
link_transform
|
||
|
|
@ origin_transform(collision.find("origin"))
|
||
|
|
@ correction
|
||
|
|
)
|
||
|
|
collision_count += 1
|
||
|
|
return visual_count, collision_count
|
||
|
|
|
||
|
|
|
||
|
|
def close_viewer(viewer: meshcat.Visualizer) -> None:
|
||
|
|
"""Close MeshCat 0.3.x without relying on its broken Visualizer.close()."""
|
||
|
|
window = viewer.window
|
||
|
|
window.zmq_socket.close(linger=0)
|
||
|
|
server_process = window.server_proc
|
||
|
|
if server_process is None or server_process.poll() is not None:
|
||
|
|
return
|
||
|
|
server_process.terminate()
|
||
|
|
try:
|
||
|
|
server_process.wait(timeout=3.0)
|
||
|
|
except subprocess.TimeoutExpired:
|
||
|
|
server_process.kill()
|
||
|
|
server_process.wait(timeout=3.0)
|
||
|
|
|
||
|
|
|
||
|
|
def export_static_html(viewer: meshcat.Visualizer, output: Path) -> None:
|
||
|
|
output = output.resolve()
|
||
|
|
if output.suffix.lower() != ".html":
|
||
|
|
raise ValueError(f"MeshCat snapshot must use an .html extension: {output}")
|
||
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
temporary = output.with_name(f".{output.stem}.tmp{output.suffix}")
|
||
|
|
temporary.write_text(viewer.static_html(), encoding="utf-8")
|
||
|
|
os.replace(temporary, output)
|
||
|
|
|
||
|
|
|
||
|
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
parser.add_argument("--input", required=True, type=Path, help="URDF to display")
|
||
|
|
parser.add_argument(
|
||
|
|
"--joint",
|
||
|
|
action="append",
|
||
|
|
default=[],
|
||
|
|
metavar="NAME=VALUE",
|
||
|
|
help="joint position in radians, or meters for prismatic joints",
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--collision-only",
|
||
|
|
action="store_true",
|
||
|
|
help="hide visual geometry and display only collisions",
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--visual-opacity",
|
||
|
|
type=opacity,
|
||
|
|
default=1.0,
|
||
|
|
help="visual geometry opacity (default: 1.0)",
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--collision-opacity",
|
||
|
|
type=opacity,
|
||
|
|
default=1.0,
|
||
|
|
help="collision geometry opacity (default: 1.0)",
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--collision-color",
|
||
|
|
type=parse_color,
|
||
|
|
default=parse_color("00ff00"),
|
||
|
|
metavar="RRGGBB",
|
||
|
|
help="collision color in hexadecimal (default: 00ff00)",
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--collision-cylinder-lines",
|
||
|
|
"--collision-cylinder-segments",
|
||
|
|
dest="collision_cylinder_lines",
|
||
|
|
type=cylinder_lines,
|
||
|
|
default=12,
|
||
|
|
metavar="COUNT",
|
||
|
|
help="axial lines on collision cylinders (default: 12, every 30 degrees)",
|
||
|
|
)
|
||
|
|
collision_style = parser.add_mutually_exclusive_group()
|
||
|
|
collision_style.add_argument(
|
||
|
|
"--wireframe",
|
||
|
|
dest="wireframe",
|
||
|
|
action="store_true",
|
||
|
|
default=True,
|
||
|
|
help="draw collision geometry as wireframe (default)",
|
||
|
|
)
|
||
|
|
collision_style.add_argument(
|
||
|
|
"--solid-collisions",
|
||
|
|
dest="wireframe",
|
||
|
|
action="store_false",
|
||
|
|
help="draw collision geometry as translucent solids",
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--no-browser", action="store_true", help="do not automatically open a browser"
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--export-html",
|
||
|
|
type=Path,
|
||
|
|
help="write a standalone MeshCat HTML snapshot and exit",
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--exit-after-load",
|
||
|
|
action="store_true",
|
||
|
|
help=argparse.SUPPRESS,
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--zmq-url", help="connect to an existing MeshCat ZMQ server"
|
||
|
|
)
|
||
|
|
return parser.parse_args(argv)
|
||
|
|
|
||
|
|
|
||
|
|
def run(args: argparse.Namespace) -> int:
|
||
|
|
urdf_path = args.input.resolve()
|
||
|
|
if not urdf_path.is_file():
|
||
|
|
raise FileNotFoundError(urdf_path)
|
||
|
|
if urdf_path.suffix.lower() != ".urdf":
|
||
|
|
raise ValueError(f"input must be a .urdf file: {urdf_path}")
|
||
|
|
root = ET.parse(urdf_path).getroot()
|
||
|
|
if root.tag != "robot":
|
||
|
|
raise ValueError(f"URDF root must be <robot>, found <{root.tag}>")
|
||
|
|
|
||
|
|
joints = parse_joints(root)
|
||
|
|
requested = parse_joint_values(args.joint)
|
||
|
|
values = resolve_joint_values(joints, requested)
|
||
|
|
link_transforms = compute_link_transforms(root, joints, values)
|
||
|
|
|
||
|
|
viewer = meshcat.Visualizer(zmq_url=args.zmq_url)
|
||
|
|
visual_count, collision_count = render_urdf(
|
||
|
|
viewer,
|
||
|
|
root,
|
||
|
|
urdf_path,
|
||
|
|
link_transforms,
|
||
|
|
collision_only=args.collision_only,
|
||
|
|
visual_opacity=args.visual_opacity,
|
||
|
|
collision_opacity=args.collision_opacity,
|
||
|
|
collision_color=args.collision_color,
|
||
|
|
wireframe=args.wireframe,
|
||
|
|
collision_cylinder_lines=args.collision_cylinder_lines,
|
||
|
|
)
|
||
|
|
if collision_count == 0:
|
||
|
|
raise RuntimeError(f"URDF contains no <collision> elements: {urdf_path}")
|
||
|
|
|
||
|
|
url = viewer.url()
|
||
|
|
print(f"Loaded: {urdf_path}")
|
||
|
|
print(f"Visual geometry: {visual_count}")
|
||
|
|
print(f"Collision geometry: {collision_count}")
|
||
|
|
print(f"MeshCat URL: {url}", flush=True)
|
||
|
|
if args.export_html is not None:
|
||
|
|
export_static_html(viewer, args.export_html)
|
||
|
|
snapshot_uri = args.export_html.resolve().as_uri()
|
||
|
|
print(f"Standalone snapshot: {snapshot_uri}", flush=True)
|
||
|
|
if not args.no_browser:
|
||
|
|
webbrowser.open(snapshot_uri, new=2)
|
||
|
|
close_viewer(viewer)
|
||
|
|
return 0
|
||
|
|
if not args.no_browser:
|
||
|
|
viewer.open()
|
||
|
|
if args.exit_after_load:
|
||
|
|
close_viewer(viewer)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
print("Press Ctrl+C to stop the viewer.", flush=True)
|
||
|
|
try:
|
||
|
|
while True:
|
||
|
|
time.sleep(1.0)
|
||
|
|
except KeyboardInterrupt:
|
||
|
|
print("Stopping MeshCat viewer.")
|
||
|
|
finally:
|
||
|
|
close_viewer(viewer)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
def main(argv: list[str] | None = None) -> int:
|
||
|
|
try:
|
||
|
|
return run(parse_args(sys.argv[1:] if argv is None else argv))
|
||
|
|
except Exception:
|
||
|
|
traceback.print_exc()
|
||
|
|
return 1
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(main())
|