nova_rerun_bridge.robot_visualizer
1import re 2 3import numpy as np 4import rerun as rr 5import trimesh 6from scipy.spatial.transform import Rotation 7 8from nova.api import models 9from nova_rerun_bridge import colors 10from nova_rerun_bridge.conversion_helpers import normalize_pose 11from nova_rerun_bridge.dh_robot import DHRobot 12from nova_rerun_bridge.helper_scripts.download_models import get_project_root 13from nova_rerun_bridge.hull_visualizer import HullVisualizer 14 15 16def get_model_path(model_name: str) -> str: 17 """Get absolute path to model file in project directory""" 18 return str(get_project_root() / "models" / f"{model_name}.glb") 19 20 21class RobotVisualizer: 22 def __init__( 23 self, 24 robot: DHRobot, 25 robot_model_geometries: list[models.RobotLinkGeometry], 26 tcp_geometries: list[models.Geometry], 27 static_transform: bool = True, 28 base_entity_path: str = "robot", 29 albedo_factor: list = [255, 255, 255], 30 collision_link_chain=None, 31 collision_tcp=None, 32 model_from_controller="", 33 show_collision_link_chain: bool = False, 34 show_safety_link_chain: bool = True, 35 ): 36 """ 37 :param robot: DHRobot instance 38 :param robot_model_geometries: List of geometries for each link 39 :param tcp_geometries: TCP geometries (similar structure to link geometries) 40 :param static_transform: If True, transforms are logged as static, else temporal. 41 :param base_entity_path: A base path prefix for logging the entities (e.g. motion group name) 42 :param albedo_factor: A list representing the RGB values [R, G, B] to apply as the albedo factor. 43 :param collision_link_chain: Collision link chain geometries for the robot 44 :param collision_tcp: Collision TCP geometries 45 :param model_from_controller: Model name from controller for loading robot mesh 46 :param show_collision_link_chain: Whether to render robot collision mesh geometry 47 :param show_safety_link_chain: Whether to render robot safety geometry (from controller) 48 """ 49 self.robot = robot 50 self.link_geometries: dict[int, list[models.Geometry]] = {} 51 self.tcp_geometries: list[models.Geometry] = tcp_geometries 52 self.logged_meshes: set[str] = set() 53 self.static_transform = static_transform 54 self.base_entity_path = base_entity_path.rstrip("/") 55 self.albedo_factor = albedo_factor 56 self.mesh_loaded = False 57 self.collision_link_geometries = {} 58 self.collision_tcp_geometries = collision_tcp 59 self.show_collision_link_chain = show_collision_link_chain 60 self.show_safety_link_chain = show_safety_link_chain 61 62 # This will hold the names of discovered joints (e.g. ["robot_J00", "robot_J01", ...]) 63 self.joint_names: list[str] = [] 64 self.layer_nodes_dict: dict[str, list[str]] = {} 65 self.parent_nodes_dict: dict[str, str] = {} 66 67 # load mesh 68 try: 69 glb_path = get_model_path(model_from_controller) 70 self.scene = trimesh.load_scene(glb_path, file_type="glb") 71 self.mesh_loaded = True 72 self.edge_data = self.scene.graph.transforms.edge_data 73 74 # After loading, auto-discover any child nodes that match *_J0n 75 self.discover_joints() 76 except Exception as e: 77 print(f"Failed to load mesh: {e}") 78 79 # Group geometries by link 80 for gm in robot_model_geometries: 81 self.link_geometries.setdefault(gm.link_index, []).append(gm.geometry) 82 83 # Group geometries by link 84 self.collision_link_geometries = collision_link_chain 85 86 def discover_joints(self): 87 """ 88 Find all child node names that contain '_J0' followed by digits or '_FLG'. 89 Store joints with their parent nodes and print layer information. 90 """ 91 joint_pattern = re.compile(r"_J0(\d+)") 92 flg_pattern = re.compile(r"_FLG") 93 matches = [] 94 flg_nodes = [] 95 joint_parents = {} # Store parent for each joint/FLG 96 97 for (parent, child), data in self.edge_data.items(): 98 # Check for joints 99 joint_match = joint_pattern.search(child) 100 if joint_match: 101 j_idx = int(joint_match.group(1)) 102 matches.append((j_idx, child)) 103 joint_parents[child] = parent 104 105 # Check for FLG 106 flg_match = flg_pattern.search(child) 107 if flg_match: 108 flg_nodes.append(child) 109 joint_parents[child] = parent 110 111 matches.sort(key=lambda x: x[0]) 112 self.joint_names = [name for _, name in matches] + flg_nodes 113 114 # print("Discovered nodes:", self.joint_names) 115 # Print layer information for each joint 116 for joint in self.joint_names: 117 self.get_nodes_on_same_layer(joint_parents[joint], joint) 118 # print(f"\nNodes on same layer as {joint}:") 119 # print(f"Parent node: {joint_parents[joint]}") 120 # print(f"Layer nodes: {same_layer_nodes}") 121 122 def get_nodes_on_same_layer(self, parent_node, joint): 123 """ 124 Find nodes on same layer and only add descendants of link nodes. 125 """ 126 same_layer = [] 127 # First get immediate layer nodes 128 for (parent, child), data in self.edge_data.items(): 129 if parent == parent_node: 130 if child == joint: 131 continue 132 if "geometry" in data: 133 same_layer.append(data["geometry"]) 134 self.parent_nodes_dict[data["geometry"]] = child 135 136 # Get all descendants for this link 137 parentChild = child 138 stack = [child] 139 while stack: 140 current = stack.pop() 141 for (p, c), data in self.edge_data.items(): 142 if p == current: 143 if "geometry" in data: 144 same_layer.append(data["geometry"]) 145 self.parent_nodes_dict[data["geometry"]] = parentChild 146 stack.append(c) 147 148 self.layer_nodes_dict[joint] = same_layer 149 return same_layer 150 151 def geometry_pose_to_matrix(self, init_pose: models.PlannerPose): 152 return self.robot.pose_to_matrix(init_pose) 153 154 def compute_forward_kinematics(self, joint_values): 155 """Compute link transforms using the robot's methods.""" 156 accumulated = self.robot.pose_to_matrix(self.robot.mounting) 157 transforms = [accumulated.copy()] 158 for dh_param, joint_rot in zip(self.robot.dh_parameters, joint_values.joints, strict=False): 159 transform = self.robot.dh_transform(dh_param, joint_rot) 160 accumulated = accumulated @ transform 161 transforms.append(accumulated.copy()) 162 return transforms 163 164 def rotation_matrix_to_axis_angle(self, Rm): 165 """Use scipy for cleaner axis-angle extraction.""" 166 rot = Rotation.from_matrix(Rm) 167 angle = rot.magnitude() 168 axis = rot.as_rotvec() / angle if angle > 1e-8 else np.array([1.0, 0.0, 0.0]) 169 return axis, angle 170 171 def gamma_lift_single_color(self, color: np.ndarray, gamma: float = 0.8) -> np.ndarray: 172 """ 173 Apply gamma correction to a single RGBA color in-place. 174 color: shape (4,) with [R, G, B, A] in 0..255, dtype=uint8 175 gamma: < 1.0 brightens midtones, > 1.0 darkens them. 176 """ 177 rgb_float = color[:3].astype(np.float32) / 255.0 178 rgb_float = np.power(rgb_float, gamma) 179 color[:3] = (rgb_float * 255.0).astype(np.uint8) 180 181 return color 182 183 def get_transform_matrix(self): 184 """ 185 Creates a transformation matrix that converts from glTF's right-handed Y-up 186 coordinate system to Rerun's right-handed Z-up coordinate system. 187 188 Returns: 189 np.ndarray: A 4x4 transformation matrix 190 """ 191 # Convert from glTF's Y-up to Rerun's Z-up coordinate system 192 return np.array( 193 [ 194 [1.0, 0.0, 0.0, 0.0], # X stays the same 195 [0.0, 0.0, -1.0, 0.0], # Y becomes -Z 196 [0.0, 1.0, 0.0, 0.0], # Z becomes Y 197 [0.0, 0.0, 0.0, 1.0], # Homogeneous coordinate 198 ] 199 ) 200 201 def init_mesh(self, entity_path: str, geom, joint_name): 202 """Generic method to log a single geometry, either capsule or box.""" 203 204 if entity_path not in self.logged_meshes: 205 if geom.metadata.get("node") not in self.parent_nodes_dict: 206 return 207 208 base_transform = np.eye(4) 209 # if the dh parameters are not at 0,0,0 from the mesh we have to move the first mesh joint 210 if "J00" in joint_name: 211 base_transform_, _ = self.scene.graph.get(frame_to=joint_name) 212 base_transform = base_transform_.copy() 213 base_transform[:3, 3] *= 1000 214 215 # if the mesh has the pivot not in the center, we need to adjust the transform 216 cumulative_transform, _ = self.scene.graph.get( 217 frame_to=self.parent_nodes_dict[geom.metadata.get("node")] 218 ) 219 ctransform = cumulative_transform.copy() 220 221 # scale positions to mm 222 ctransform[:3, 3] *= 1000 223 224 # scale mesh to mm 225 transform = base_transform @ ctransform 226 mesh_scale_matrix = np.eye(4) 227 mesh_scale_matrix[:3, :3] *= 1000 228 transform = transform @ mesh_scale_matrix 229 transformed_mesh = geom.copy() 230 231 transformed_mesh.apply_transform(transform) 232 233 if transformed_mesh.visual is not None: 234 transformed_mesh.visual = transformed_mesh.visual.to_color() 235 236 vertex_colors = None 237 if transformed_mesh.visual and hasattr(transformed_mesh.visual, "vertex_colors"): 238 vertex_colors = transformed_mesh.visual.vertex_colors 239 240 rr.log( 241 entity_path, 242 rr.Mesh3D( 243 vertex_positions=transformed_mesh.vertices, 244 triangle_indices=transformed_mesh.faces, 245 vertex_normals=getattr(transformed_mesh, "vertex_normals", None), 246 albedo_factor=self.gamma_lift_single_color(vertex_colors, gamma=0.5) 247 if vertex_colors is not None 248 else None, 249 ), 250 ) 251 252 self.logged_meshes.add(entity_path) 253 254 def init_collision_geometry( 255 self, entity_path: str, collider: models.Collider, pose: models.PlannerPose 256 ): 257 if entity_path in self.logged_meshes: 258 return 259 260 if isinstance(collider.shape.actual_instance, models.Sphere2): 261 rr.log( 262 f"{entity_path}", 263 rr.Ellipsoids3D( 264 radii=[ 265 collider.shape.actual_instance.radius, 266 collider.shape.actual_instance.radius, 267 collider.shape.actual_instance.radius, 268 ], 269 centers=[[pose.position.x, pose.position.y, pose.position.z]] 270 if pose.position 271 else [0, 0, 0], 272 colors=[(221, 193, 193, 255)], 273 ), 274 ) 275 276 elif isinstance(collider.shape.actual_instance, models.Box2): 277 rr.log( 278 f"{entity_path}", 279 rr.Boxes3D( 280 centers=[[pose.position.x, pose.position.y, pose.position.z]] 281 if pose.position 282 else [0, 0, 0], 283 sizes=[ 284 collider.shape.actual_instance.size_x, 285 collider.shape.actual_instance.size_y, 286 collider.shape.actual_instance.size_z, 287 ], 288 colors=[(221, 193, 193, 255)], 289 ), 290 ) 291 292 elif isinstance(collider.shape.actual_instance, models.Capsule2): 293 height = collider.shape.actual_instance.cylinder_height 294 radius = collider.shape.actual_instance.radius 295 296 # Generate trimesh capsule 297 capsule = trimesh.creation.capsule(height=height, radius=radius, count=[6, 8]) 298 299 # Extract vertices and faces for solid visualization 300 vertices = np.array(capsule.vertices) 301 302 # Transform vertices to world position 303 transform = np.eye(4) 304 if pose.position: 305 transform[:3, 3] = [pose.position.x, pose.position.y, pose.position.z - height / 2] 306 else: 307 transform[:3, 3] = [0, 0, -height / 2] 308 309 if collider.pose and collider.pose.orientation: 310 rot_mat = Rotation.from_quat( 311 [ 312 collider.pose.orientation[0], 313 collider.pose.orientation[1], 314 collider.pose.orientation[2], 315 collider.pose.orientation[3], 316 ] 317 ) 318 transform[:3, :3] = rot_mat.as_matrix() 319 320 vertices = np.array([transform @ np.append(v, 1) for v in vertices])[:, :3] 321 322 polygons = HullVisualizer.compute_hull_outlines_from_points(vertices) 323 324 if polygons: 325 line_segments = [p.tolist() for p in polygons] 326 rr.log( 327 f"{entity_path}", 328 rr.LineStrips3D( 329 line_segments, 330 radii=rr.Radius.ui_points(0.75), 331 colors=[[221, 193, 193, 255]], 332 ), 333 static=True, 334 ) 335 336 elif isinstance(collider.shape.actual_instance, models.ConvexHull2): 337 polygons = HullVisualizer.compute_hull_outlines_from_points( 338 collider.shape.actual_instance.vertices 339 ) 340 341 if polygons: 342 line_segments = [p.tolist() for p in polygons] 343 rr.log( 344 f"{entity_path}", 345 rr.LineStrips3D( 346 line_segments, radii=rr.Radius.ui_points(1.5), colors=[colors.colors[2]] 347 ), 348 static=True, 349 ) 350 351 vertices, triangles, normals = HullVisualizer.compute_hull_mesh(polygons) 352 353 rr.log( 354 f"{entity_path}", 355 rr.Mesh3D( 356 vertex_positions=vertices, 357 triangle_indices=triangles, 358 vertex_normals=normals, 359 albedo_factor=colors.colors[0], 360 ), 361 static=True, 362 ) 363 364 self.logged_meshes.add(entity_path) 365 366 def init_geometry(self, entity_path: str, geometry: models.Geometry): 367 """Generic method to log a single geometry, either capsule or box.""" 368 369 if entity_path in self.logged_meshes: 370 return 371 372 # Sphere geometry 373 if geometry.sphere: 374 radius = geometry.sphere.radius 375 rr.log( 376 entity_path, 377 rr.Ellipsoids3D( 378 radii=[radius, radius, radius], 379 centers=[0, 0, 0], 380 fill_mode=rr.components.FillMode.Solid, 381 colors=[(221, 193, 193, 255) if self.static_transform else self.albedo_factor], 382 ), 383 ) 384 385 # Box geometry 386 elif geometry.box: 387 rr.log( 388 entity_path, 389 rr.Boxes3D( 390 centers=[0, 0, 0], 391 fill_mode=rr.components.FillMode.Solid, 392 sizes=[geometry.box.size_x, geometry.box.size_y, geometry.box.size_z], 393 colors=[(221, 193, 193, 255) if self.static_transform else self.albedo_factor], 394 ), 395 ) 396 397 # Rectangle geometry 398 elif geometry.rectangle: 399 # Create a flat box with minimal height 400 rr.log( 401 entity_path, 402 rr.Boxes3D( 403 fill_mode=rr.components.FillMode.Solid, 404 centers=[0, 0, 0], 405 sizes=[ 406 geometry.rectangle.size_x, 407 geometry.rectangle.size_y, 408 1.0, # Minimal height for visibility 409 ], 410 colors=[(221, 193, 193, 255) if self.static_transform else self.albedo_factor], 411 ), 412 ) 413 414 # Cylinder geometry 415 elif geometry.cylinder: 416 radius = geometry.cylinder.radius 417 height = geometry.cylinder.height 418 419 # Create cylinder mesh 420 cylinder = trimesh.creation.cylinder(radius=radius, height=height, sections=16) 421 vertex_normals = cylinder.vertex_normals.tolist() 422 423 rr.log( 424 entity_path, 425 rr.Mesh3D( 426 vertex_positions=cylinder.vertices.tolist(), 427 triangle_indices=cylinder.faces.tolist(), 428 vertex_normals=vertex_normals, 429 albedo_factor=self.albedo_factor, 430 ), 431 ) 432 433 # Convex hull geometry 434 elif geometry.convex_hull: 435 polygons = HullVisualizer.compute_hull_outlines_from_points( 436 [[v.x, v.y, v.z] for v in geometry.convex_hull.vertices] 437 ) 438 439 if polygons: 440 # First log wireframe outline 441 line_segments = [p.tolist() for p in polygons] 442 rr.log( 443 f"{entity_path}_wireframe", 444 rr.LineStrips3D( 445 line_segments, 446 radii=rr.Radius.ui_points(1.0), 447 colors=[colors.colors[2] if self.static_transform else self.albedo_factor], 448 ), 449 static=self.static_transform, 450 ) 451 452 # Then log solid mesh 453 vertices, triangles, normals = HullVisualizer.compute_hull_mesh(polygons) 454 455 rr.log( 456 entity_path, 457 rr.Mesh3D( 458 vertex_positions=vertices, 459 triangle_indices=triangles, 460 vertex_normals=normals, 461 albedo_factor=self.albedo_factor, 462 ), 463 static=self.static_transform, 464 ) 465 466 # Capsule geometry 467 elif geometry.capsule: 468 radius = geometry.capsule.radius 469 height = geometry.capsule.cylinder_height 470 471 # Slightly shrink the capsule if static to reduce z-fighting 472 if self.static_transform: 473 radius *= 0.99 474 height *= 0.99 475 476 # Create capsule and retrieve normals 477 cap_mesh = trimesh.creation.capsule(radius=radius, height=height) 478 vertex_normals = cap_mesh.vertex_normals.tolist() 479 480 rr.log( 481 entity_path, 482 rr.Mesh3D( 483 vertex_positions=cap_mesh.vertices.tolist(), 484 triangle_indices=cap_mesh.faces.tolist(), 485 vertex_normals=vertex_normals, 486 albedo_factor=self.albedo_factor, 487 ), 488 ) 489 490 # Rectangular capsule geometry 491 elif geometry.rectangular_capsule: 492 radius = geometry.rectangular_capsule.radius 493 distance_x = geometry.rectangular_capsule.sphere_center_distance_x 494 distance_y = geometry.rectangular_capsule.sphere_center_distance_y 495 496 # Create a rectangular capsule from its definition - a hull around 4 spheres 497 # First, create the four spheres at the corners 498 sphere_centers = [ 499 [distance_x, distance_y, 0], 500 [distance_x, -distance_y, 0], 501 [-distance_x, distance_y, 0], 502 [-distance_x, -distance_y, 0], 503 ] 504 505 # Generate points to create a convex hull 506 all_points = [] 507 for center in sphere_centers: 508 # Generate points for each sphere (simplified with key points on the sphere) 509 for dx, dy, dz in [ 510 (1, 0, 0), 511 (-1, 0, 0), 512 (0, 1, 0), 513 (0, -1, 0), 514 (0, 0, 1), 515 (0, 0, -1), 516 ]: 517 all_points.append( 518 [center[0] + radius * dx, center[1] + radius * dy, center[2] + radius * dz] 519 ) 520 521 # Use our hull visualizer to create outlines 522 polygons = HullVisualizer.compute_hull_outlines_from_points(all_points) 523 524 if polygons: 525 # Log wireframe outline 526 line_segments = [p.tolist() for p in polygons] 527 rr.log( 528 f"{entity_path}_wireframe", 529 rr.LineStrips3D( 530 line_segments, 531 radii=rr.Radius.ui_points(1.0), 532 colors=[ 533 (221, 193, 193, 255) if self.static_transform else self.albedo_factor 534 ], 535 ), 536 static=self.static_transform, 537 ) 538 539 # Log solid mesh 540 vertices, triangles, normals = HullVisualizer.compute_hull_mesh(polygons) 541 542 rr.log( 543 entity_path, 544 rr.Mesh3D( 545 vertex_positions=vertices, 546 triangle_indices=triangles, 547 vertex_normals=normals, 548 albedo_factor=self.albedo_factor, 549 ), 550 static=self.static_transform, 551 ) 552 553 # Plane geometry (simplified as a large, thin rectangle) 554 elif geometry.plane: 555 # Create a large, thin rectangle to represent an infinite plane 556 size = 5000 # Large enough to seem infinite in the visualization 557 rr.log( 558 entity_path, 559 rr.Boxes3D( 560 centers=[0, 0, 0], 561 sizes=[size, size, 1.0], # Very thin in z direction 562 colors=[(200, 200, 220, 100) if self.static_transform else self.albedo_factor], 563 ), 564 ) 565 566 # Compound geometry - recursively process child geometries 567 elif geometry.compound and geometry.compound.child_geometries: 568 for i, child_geom in enumerate(geometry.compound.child_geometries): 569 child_path = f"{entity_path}/child_{i}" 570 self.init_geometry(child_path, child_geom) 571 572 # Default fallback for unsupported geometry types 573 else: 574 # Fallback to a box 575 rr.log( 576 entity_path, 577 rr.Boxes3D( 578 half_sizes=[[50, 50, 50]], 579 colors=[(255, 0, 0, 128)], # Red, semi-transparent to indicate unknown type 580 ), 581 ) 582 583 self.logged_meshes.add(entity_path) 584 585 def log_robot_geometry(self, joint_position): 586 transforms = self.compute_forward_kinematics(joint_position) 587 588 def log_geometry(entity_path, transform): 589 translation = transform[:3, 3] 590 Rm = transform[:3, :3] 591 axis, angle = self.rotation_matrix_to_axis_angle(Rm) 592 rr.log( 593 entity_path, 594 rr.InstancePoses3D( 595 translations=[translation.tolist()], 596 rotation_axis_angles=[ 597 rr.RotationAxisAngle(axis=axis.tolist(), angle=float(angle)) 598 ], 599 ), 600 static=self.static_transform, 601 ) 602 603 # Log robot joint geometries 604 if self.mesh_loaded: 605 for link_index, joint_name in enumerate(self.joint_names): 606 link_transform = transforms[link_index] 607 608 # Get nodes on same layer using dictionary 609 same_layer_nodes = self.layer_nodes_dict.get(joint_name) 610 if not same_layer_nodes: 611 continue 612 613 filtered_geoms = [] 614 for node_name in same_layer_nodes: 615 if node_name in self.scene.geometry: 616 geom = self.scene.geometry[node_name] 617 # Add metadata that would normally come from dump 618 geom.metadata = {"node": node_name} 619 filtered_geoms.append(geom) 620 621 for geom in filtered_geoms: 622 entity_path = f"{self.base_entity_path}/visual/links/link_{link_index}/mesh/{geom.metadata.get('node')}" 623 624 # calculate the inverse transform to get the mesh in the correct position 625 cumulative_transform, _ = self.scene.graph.get(frame_to=joint_name) 626 ctransform = cumulative_transform.copy() 627 inverse_transform = np.linalg.inv(ctransform) 628 629 # DH theta is rotated, rotate mesh around z in direction of theta 630 rotation_matrix_z_4x4 = np.eye(4) 631 if len(self.robot.dh_parameters) > link_index: 632 rotation_z_minus_90 = Rotation.from_euler( 633 "z", self.robot.dh_parameters[link_index].theta, degrees=False 634 ).as_matrix() 635 rotation_matrix_z_4x4[:3, :3] = rotation_z_minus_90 636 637 # scale positions to mm 638 inverse_transform[:3, 3] *= 1000 639 640 root_transform = self.get_transform_matrix() 641 642 transform = root_transform @ inverse_transform 643 644 final_transform = link_transform @ rotation_matrix_z_4x4 @ transform 645 646 self.init_mesh(entity_path, geom, joint_name) 647 log_geometry(entity_path, final_transform) 648 649 # Log link geometries (safety from controller) 650 if self.show_safety_link_chain: 651 for link_index, geometries in self.link_geometries.items(): 652 link_transform = transforms[link_index] 653 for i, geom in enumerate(geometries): 654 entity_path = f"{self.base_entity_path}/safety_from_controller/links/link_{link_index}/geometry_{i}" 655 final_transform = link_transform @ self.geometry_pose_to_matrix(geom.init_pose) 656 657 self.init_geometry(entity_path, geom) 658 log_geometry(entity_path, final_transform) 659 660 # Log TCP geometries (safety from controller) 661 if self.show_safety_link_chain and self.tcp_geometries: 662 tcp_transform = transforms[-1] # the final frame transform 663 for i, geom in enumerate(self.tcp_geometries): 664 entity_path = f"{self.base_entity_path}/safety_from_controller/tcp/geometry_{i}" 665 final_transform = tcp_transform @ self.geometry_pose_to_matrix(geom.init_pose) 666 667 self.init_geometry(entity_path, geom) 668 log_geometry(entity_path, final_transform) 669 670 def log_robot_geometries(self, trajectory: list[models.TrajectorySample], times_column): 671 """ 672 Log the robot geometries for each link and TCP as separate entities. 673 674 Args: 675 trajectory (List[wb.models.TrajectorySample]): The list of trajectory sample points. 676 times_column (rr.TimeColumn): The time column associated with the trajectory points. 677 """ 678 link_positions = {} 679 link_rotations = {} 680 681 def collect_geometry_data(entity_path, transform): 682 """Helper to collect geometry data for a given entity.""" 683 translation = transform[:3, 3].tolist() 684 Rm = transform[:3, :3] 685 axis, angle = self.rotation_matrix_to_axis_angle(Rm) 686 if entity_path not in link_positions: 687 link_positions[entity_path] = [] 688 link_rotations[entity_path] = [] 689 link_positions[entity_path].append(translation) 690 link_rotations[entity_path].append(rr.RotationAxisAngle(axis=axis, angle=angle)) 691 692 for point in trajectory: 693 transforms = self.compute_forward_kinematics(point.joint_position) 694 695 # Log robot joint geometries 696 if self.mesh_loaded: 697 for link_index, joint_name in enumerate(self.joint_names): 698 if link_index >= len(transforms): 699 break 700 link_transform = transforms[link_index] 701 702 # Get nodes on same layer using dictionary 703 same_layer_nodes = self.layer_nodes_dict.get(joint_name) 704 if not same_layer_nodes: 705 continue 706 707 filtered_geoms = [] 708 for node_name in same_layer_nodes: 709 if node_name in self.scene.geometry: 710 geom = self.scene.geometry[node_name] 711 # Add metadata that would normally come from dump 712 geom.metadata = {"node": node_name} 713 filtered_geoms.append(geom) 714 715 for geom in filtered_geoms: 716 entity_path = f"{self.base_entity_path}/visual/links/link_{link_index}/mesh/{geom.metadata.get('node')}" 717 718 # calculate the inverse transform to get the mesh in the correct position 719 cumulative_transform, _ = self.scene.graph.get(frame_to=joint_name) 720 ctransform = cumulative_transform.copy() 721 inverse_transform = np.linalg.inv(ctransform) 722 723 # DH theta is rotated, rotate mesh around z in direction of theta 724 rotation_matrix_z_4x4 = np.eye(4) 725 if len(self.robot.dh_parameters) > link_index: 726 rotation_z_minus_90 = Rotation.from_euler( 727 "z", self.robot.dh_parameters[link_index].theta, degrees=False 728 ).as_matrix() 729 rotation_matrix_z_4x4[:3, :3] = rotation_z_minus_90 730 731 # scale positions to mm 732 inverse_transform[:3, 3] *= 1000 733 734 root_transform = self.get_transform_matrix() 735 736 transform = root_transform @ inverse_transform 737 738 final_transform = link_transform @ rotation_matrix_z_4x4 @ transform 739 740 self.init_mesh(entity_path, geom, joint_name) 741 collect_geometry_data(entity_path, final_transform) 742 743 # Collect data for link geometries (safety from controller) 744 if self.show_safety_link_chain: 745 for link_index, geometries in self.link_geometries.items(): 746 link_transform = transforms[link_index] 747 for i, geom in enumerate(geometries): 748 entity_path = f"{self.base_entity_path}/safety_from_controller/links/link_{link_index}/geometry_{i}" 749 final_transform = link_transform @ self.geometry_pose_to_matrix( 750 geom.init_pose 751 ) 752 self.init_geometry(entity_path, geom) 753 collect_geometry_data(entity_path, final_transform) 754 755 # Collect data for TCP geometries (safety from controller) 756 if self.show_safety_link_chain and self.tcp_geometries: 757 tcp_transform = transforms[-1] # End-effector transform 758 for i, geom in enumerate(self.tcp_geometries): 759 entity_path = f"{self.base_entity_path}/safety_from_controller/tcp/geometry_{i}" 760 final_transform = tcp_transform @ self.geometry_pose_to_matrix(geom.init_pose) 761 self.init_geometry(entity_path, geom) 762 collect_geometry_data(entity_path, final_transform) 763 764 # Collect data for collision link geometries (only if enabled) 765 if self.show_collision_link_chain and self.collision_link_geometries: 766 for link_index, geometries in enumerate(self.collision_link_geometries): 767 link_transform = transforms[link_index] 768 for i, geom_id in enumerate(geometries): 769 entity_path = f"{self.base_entity_path}/collision/links/link_{link_index}/geometry_{geom_id}" 770 771 pose = normalize_pose(geometries[geom_id].pose) 772 773 final_transform = link_transform @ self.geometry_pose_to_matrix(pose) 774 self.init_collision_geometry(entity_path, geometries[geom_id], pose) 775 collect_geometry_data(entity_path, final_transform) 776 777 # Collect data for collision TCP geometries (only if enabled) 778 if self.show_collision_link_chain and self.collision_tcp_geometries: 779 tcp_transform = transforms[-1] # End-effector transform 780 for i, geom_id in enumerate(self.collision_tcp_geometries): 781 entity_path = f"{self.base_entity_path}/collision/tcp/geometry_{geom_id}" 782 783 pose = normalize_pose(self.collision_tcp_geometries[geom_id].pose) 784 final_transform = tcp_transform @ self.geometry_pose_to_matrix(pose) 785 786 # tcp collision geometries are defined in flange frame 787 identity_pose = models.PlannerPose( 788 position=models.Vector3d(x=0, y=0, z=0), 789 orientation=models.Quaternion(x=0, y=0, z=0, w=1), 790 ) 791 self.init_collision_geometry( 792 entity_path, self.collision_tcp_geometries[geom_id], identity_pose 793 ) 794 collect_geometry_data(entity_path, final_transform) 795 796 # Send collected columns for all geometries 797 for entity_path, positions in link_positions.items(): 798 rr.send_columns( 799 entity_path, 800 indexes=[times_column], 801 columns=[ 802 *rr.Transform3D.columns( 803 translation=positions, rotation_axis_angle=link_rotations[entity_path] 804 ) 805 ], 806 )
17def get_model_path(model_name: str) -> str: 18 """Get absolute path to model file in project directory""" 19 return str(get_project_root() / "models" / f"{model_name}.glb")
Get absolute path to model file in project directory
22class RobotVisualizer: 23 def __init__( 24 self, 25 robot: DHRobot, 26 robot_model_geometries: list[models.RobotLinkGeometry], 27 tcp_geometries: list[models.Geometry], 28 static_transform: bool = True, 29 base_entity_path: str = "robot", 30 albedo_factor: list = [255, 255, 255], 31 collision_link_chain=None, 32 collision_tcp=None, 33 model_from_controller="", 34 show_collision_link_chain: bool = False, 35 show_safety_link_chain: bool = True, 36 ): 37 """ 38 :param robot: DHRobot instance 39 :param robot_model_geometries: List of geometries for each link 40 :param tcp_geometries: TCP geometries (similar structure to link geometries) 41 :param static_transform: If True, transforms are logged as static, else temporal. 42 :param base_entity_path: A base path prefix for logging the entities (e.g. motion group name) 43 :param albedo_factor: A list representing the RGB values [R, G, B] to apply as the albedo factor. 44 :param collision_link_chain: Collision link chain geometries for the robot 45 :param collision_tcp: Collision TCP geometries 46 :param model_from_controller: Model name from controller for loading robot mesh 47 :param show_collision_link_chain: Whether to render robot collision mesh geometry 48 :param show_safety_link_chain: Whether to render robot safety geometry (from controller) 49 """ 50 self.robot = robot 51 self.link_geometries: dict[int, list[models.Geometry]] = {} 52 self.tcp_geometries: list[models.Geometry] = tcp_geometries 53 self.logged_meshes: set[str] = set() 54 self.static_transform = static_transform 55 self.base_entity_path = base_entity_path.rstrip("/") 56 self.albedo_factor = albedo_factor 57 self.mesh_loaded = False 58 self.collision_link_geometries = {} 59 self.collision_tcp_geometries = collision_tcp 60 self.show_collision_link_chain = show_collision_link_chain 61 self.show_safety_link_chain = show_safety_link_chain 62 63 # This will hold the names of discovered joints (e.g. ["robot_J00", "robot_J01", ...]) 64 self.joint_names: list[str] = [] 65 self.layer_nodes_dict: dict[str, list[str]] = {} 66 self.parent_nodes_dict: dict[str, str] = {} 67 68 # load mesh 69 try: 70 glb_path = get_model_path(model_from_controller) 71 self.scene = trimesh.load_scene(glb_path, file_type="glb") 72 self.mesh_loaded = True 73 self.edge_data = self.scene.graph.transforms.edge_data 74 75 # After loading, auto-discover any child nodes that match *_J0n 76 self.discover_joints() 77 except Exception as e: 78 print(f"Failed to load mesh: {e}") 79 80 # Group geometries by link 81 for gm in robot_model_geometries: 82 self.link_geometries.setdefault(gm.link_index, []).append(gm.geometry) 83 84 # Group geometries by link 85 self.collision_link_geometries = collision_link_chain 86 87 def discover_joints(self): 88 """ 89 Find all child node names that contain '_J0' followed by digits or '_FLG'. 90 Store joints with their parent nodes and print layer information. 91 """ 92 joint_pattern = re.compile(r"_J0(\d+)") 93 flg_pattern = re.compile(r"_FLG") 94 matches = [] 95 flg_nodes = [] 96 joint_parents = {} # Store parent for each joint/FLG 97 98 for (parent, child), data in self.edge_data.items(): 99 # Check for joints 100 joint_match = joint_pattern.search(child) 101 if joint_match: 102 j_idx = int(joint_match.group(1)) 103 matches.append((j_idx, child)) 104 joint_parents[child] = parent 105 106 # Check for FLG 107 flg_match = flg_pattern.search(child) 108 if flg_match: 109 flg_nodes.append(child) 110 joint_parents[child] = parent 111 112 matches.sort(key=lambda x: x[0]) 113 self.joint_names = [name for _, name in matches] + flg_nodes 114 115 # print("Discovered nodes:", self.joint_names) 116 # Print layer information for each joint 117 for joint in self.joint_names: 118 self.get_nodes_on_same_layer(joint_parents[joint], joint) 119 # print(f"\nNodes on same layer as {joint}:") 120 # print(f"Parent node: {joint_parents[joint]}") 121 # print(f"Layer nodes: {same_layer_nodes}") 122 123 def get_nodes_on_same_layer(self, parent_node, joint): 124 """ 125 Find nodes on same layer and only add descendants of link nodes. 126 """ 127 same_layer = [] 128 # First get immediate layer nodes 129 for (parent, child), data in self.edge_data.items(): 130 if parent == parent_node: 131 if child == joint: 132 continue 133 if "geometry" in data: 134 same_layer.append(data["geometry"]) 135 self.parent_nodes_dict[data["geometry"]] = child 136 137 # Get all descendants for this link 138 parentChild = child 139 stack = [child] 140 while stack: 141 current = stack.pop() 142 for (p, c), data in self.edge_data.items(): 143 if p == current: 144 if "geometry" in data: 145 same_layer.append(data["geometry"]) 146 self.parent_nodes_dict[data["geometry"]] = parentChild 147 stack.append(c) 148 149 self.layer_nodes_dict[joint] = same_layer 150 return same_layer 151 152 def geometry_pose_to_matrix(self, init_pose: models.PlannerPose): 153 return self.robot.pose_to_matrix(init_pose) 154 155 def compute_forward_kinematics(self, joint_values): 156 """Compute link transforms using the robot's methods.""" 157 accumulated = self.robot.pose_to_matrix(self.robot.mounting) 158 transforms = [accumulated.copy()] 159 for dh_param, joint_rot in zip(self.robot.dh_parameters, joint_values.joints, strict=False): 160 transform = self.robot.dh_transform(dh_param, joint_rot) 161 accumulated = accumulated @ transform 162 transforms.append(accumulated.copy()) 163 return transforms 164 165 def rotation_matrix_to_axis_angle(self, Rm): 166 """Use scipy for cleaner axis-angle extraction.""" 167 rot = Rotation.from_matrix(Rm) 168 angle = rot.magnitude() 169 axis = rot.as_rotvec() / angle if angle > 1e-8 else np.array([1.0, 0.0, 0.0]) 170 return axis, angle 171 172 def gamma_lift_single_color(self, color: np.ndarray, gamma: float = 0.8) -> np.ndarray: 173 """ 174 Apply gamma correction to a single RGBA color in-place. 175 color: shape (4,) with [R, G, B, A] in 0..255, dtype=uint8 176 gamma: < 1.0 brightens midtones, > 1.0 darkens them. 177 """ 178 rgb_float = color[:3].astype(np.float32) / 255.0 179 rgb_float = np.power(rgb_float, gamma) 180 color[:3] = (rgb_float * 255.0).astype(np.uint8) 181 182 return color 183 184 def get_transform_matrix(self): 185 """ 186 Creates a transformation matrix that converts from glTF's right-handed Y-up 187 coordinate system to Rerun's right-handed Z-up coordinate system. 188 189 Returns: 190 np.ndarray: A 4x4 transformation matrix 191 """ 192 # Convert from glTF's Y-up to Rerun's Z-up coordinate system 193 return np.array( 194 [ 195 [1.0, 0.0, 0.0, 0.0], # X stays the same 196 [0.0, 0.0, -1.0, 0.0], # Y becomes -Z 197 [0.0, 1.0, 0.0, 0.0], # Z becomes Y 198 [0.0, 0.0, 0.0, 1.0], # Homogeneous coordinate 199 ] 200 ) 201 202 def init_mesh(self, entity_path: str, geom, joint_name): 203 """Generic method to log a single geometry, either capsule or box.""" 204 205 if entity_path not in self.logged_meshes: 206 if geom.metadata.get("node") not in self.parent_nodes_dict: 207 return 208 209 base_transform = np.eye(4) 210 # if the dh parameters are not at 0,0,0 from the mesh we have to move the first mesh joint 211 if "J00" in joint_name: 212 base_transform_, _ = self.scene.graph.get(frame_to=joint_name) 213 base_transform = base_transform_.copy() 214 base_transform[:3, 3] *= 1000 215 216 # if the mesh has the pivot not in the center, we need to adjust the transform 217 cumulative_transform, _ = self.scene.graph.get( 218 frame_to=self.parent_nodes_dict[geom.metadata.get("node")] 219 ) 220 ctransform = cumulative_transform.copy() 221 222 # scale positions to mm 223 ctransform[:3, 3] *= 1000 224 225 # scale mesh to mm 226 transform = base_transform @ ctransform 227 mesh_scale_matrix = np.eye(4) 228 mesh_scale_matrix[:3, :3] *= 1000 229 transform = transform @ mesh_scale_matrix 230 transformed_mesh = geom.copy() 231 232 transformed_mesh.apply_transform(transform) 233 234 if transformed_mesh.visual is not None: 235 transformed_mesh.visual = transformed_mesh.visual.to_color() 236 237 vertex_colors = None 238 if transformed_mesh.visual and hasattr(transformed_mesh.visual, "vertex_colors"): 239 vertex_colors = transformed_mesh.visual.vertex_colors 240 241 rr.log( 242 entity_path, 243 rr.Mesh3D( 244 vertex_positions=transformed_mesh.vertices, 245 triangle_indices=transformed_mesh.faces, 246 vertex_normals=getattr(transformed_mesh, "vertex_normals", None), 247 albedo_factor=self.gamma_lift_single_color(vertex_colors, gamma=0.5) 248 if vertex_colors is not None 249 else None, 250 ), 251 ) 252 253 self.logged_meshes.add(entity_path) 254 255 def init_collision_geometry( 256 self, entity_path: str, collider: models.Collider, pose: models.PlannerPose 257 ): 258 if entity_path in self.logged_meshes: 259 return 260 261 if isinstance(collider.shape.actual_instance, models.Sphere2): 262 rr.log( 263 f"{entity_path}", 264 rr.Ellipsoids3D( 265 radii=[ 266 collider.shape.actual_instance.radius, 267 collider.shape.actual_instance.radius, 268 collider.shape.actual_instance.radius, 269 ], 270 centers=[[pose.position.x, pose.position.y, pose.position.z]] 271 if pose.position 272 else [0, 0, 0], 273 colors=[(221, 193, 193, 255)], 274 ), 275 ) 276 277 elif isinstance(collider.shape.actual_instance, models.Box2): 278 rr.log( 279 f"{entity_path}", 280 rr.Boxes3D( 281 centers=[[pose.position.x, pose.position.y, pose.position.z]] 282 if pose.position 283 else [0, 0, 0], 284 sizes=[ 285 collider.shape.actual_instance.size_x, 286 collider.shape.actual_instance.size_y, 287 collider.shape.actual_instance.size_z, 288 ], 289 colors=[(221, 193, 193, 255)], 290 ), 291 ) 292 293 elif isinstance(collider.shape.actual_instance, models.Capsule2): 294 height = collider.shape.actual_instance.cylinder_height 295 radius = collider.shape.actual_instance.radius 296 297 # Generate trimesh capsule 298 capsule = trimesh.creation.capsule(height=height, radius=radius, count=[6, 8]) 299 300 # Extract vertices and faces for solid visualization 301 vertices = np.array(capsule.vertices) 302 303 # Transform vertices to world position 304 transform = np.eye(4) 305 if pose.position: 306 transform[:3, 3] = [pose.position.x, pose.position.y, pose.position.z - height / 2] 307 else: 308 transform[:3, 3] = [0, 0, -height / 2] 309 310 if collider.pose and collider.pose.orientation: 311 rot_mat = Rotation.from_quat( 312 [ 313 collider.pose.orientation[0], 314 collider.pose.orientation[1], 315 collider.pose.orientation[2], 316 collider.pose.orientation[3], 317 ] 318 ) 319 transform[:3, :3] = rot_mat.as_matrix() 320 321 vertices = np.array([transform @ np.append(v, 1) for v in vertices])[:, :3] 322 323 polygons = HullVisualizer.compute_hull_outlines_from_points(vertices) 324 325 if polygons: 326 line_segments = [p.tolist() for p in polygons] 327 rr.log( 328 f"{entity_path}", 329 rr.LineStrips3D( 330 line_segments, 331 radii=rr.Radius.ui_points(0.75), 332 colors=[[221, 193, 193, 255]], 333 ), 334 static=True, 335 ) 336 337 elif isinstance(collider.shape.actual_instance, models.ConvexHull2): 338 polygons = HullVisualizer.compute_hull_outlines_from_points( 339 collider.shape.actual_instance.vertices 340 ) 341 342 if polygons: 343 line_segments = [p.tolist() for p in polygons] 344 rr.log( 345 f"{entity_path}", 346 rr.LineStrips3D( 347 line_segments, radii=rr.Radius.ui_points(1.5), colors=[colors.colors[2]] 348 ), 349 static=True, 350 ) 351 352 vertices, triangles, normals = HullVisualizer.compute_hull_mesh(polygons) 353 354 rr.log( 355 f"{entity_path}", 356 rr.Mesh3D( 357 vertex_positions=vertices, 358 triangle_indices=triangles, 359 vertex_normals=normals, 360 albedo_factor=colors.colors[0], 361 ), 362 static=True, 363 ) 364 365 self.logged_meshes.add(entity_path) 366 367 def init_geometry(self, entity_path: str, geometry: models.Geometry): 368 """Generic method to log a single geometry, either capsule or box.""" 369 370 if entity_path in self.logged_meshes: 371 return 372 373 # Sphere geometry 374 if geometry.sphere: 375 radius = geometry.sphere.radius 376 rr.log( 377 entity_path, 378 rr.Ellipsoids3D( 379 radii=[radius, radius, radius], 380 centers=[0, 0, 0], 381 fill_mode=rr.components.FillMode.Solid, 382 colors=[(221, 193, 193, 255) if self.static_transform else self.albedo_factor], 383 ), 384 ) 385 386 # Box geometry 387 elif geometry.box: 388 rr.log( 389 entity_path, 390 rr.Boxes3D( 391 centers=[0, 0, 0], 392 fill_mode=rr.components.FillMode.Solid, 393 sizes=[geometry.box.size_x, geometry.box.size_y, geometry.box.size_z], 394 colors=[(221, 193, 193, 255) if self.static_transform else self.albedo_factor], 395 ), 396 ) 397 398 # Rectangle geometry 399 elif geometry.rectangle: 400 # Create a flat box with minimal height 401 rr.log( 402 entity_path, 403 rr.Boxes3D( 404 fill_mode=rr.components.FillMode.Solid, 405 centers=[0, 0, 0], 406 sizes=[ 407 geometry.rectangle.size_x, 408 geometry.rectangle.size_y, 409 1.0, # Minimal height for visibility 410 ], 411 colors=[(221, 193, 193, 255) if self.static_transform else self.albedo_factor], 412 ), 413 ) 414 415 # Cylinder geometry 416 elif geometry.cylinder: 417 radius = geometry.cylinder.radius 418 height = geometry.cylinder.height 419 420 # Create cylinder mesh 421 cylinder = trimesh.creation.cylinder(radius=radius, height=height, sections=16) 422 vertex_normals = cylinder.vertex_normals.tolist() 423 424 rr.log( 425 entity_path, 426 rr.Mesh3D( 427 vertex_positions=cylinder.vertices.tolist(), 428 triangle_indices=cylinder.faces.tolist(), 429 vertex_normals=vertex_normals, 430 albedo_factor=self.albedo_factor, 431 ), 432 ) 433 434 # Convex hull geometry 435 elif geometry.convex_hull: 436 polygons = HullVisualizer.compute_hull_outlines_from_points( 437 [[v.x, v.y, v.z] for v in geometry.convex_hull.vertices] 438 ) 439 440 if polygons: 441 # First log wireframe outline 442 line_segments = [p.tolist() for p in polygons] 443 rr.log( 444 f"{entity_path}_wireframe", 445 rr.LineStrips3D( 446 line_segments, 447 radii=rr.Radius.ui_points(1.0), 448 colors=[colors.colors[2] if self.static_transform else self.albedo_factor], 449 ), 450 static=self.static_transform, 451 ) 452 453 # Then log solid mesh 454 vertices, triangles, normals = HullVisualizer.compute_hull_mesh(polygons) 455 456 rr.log( 457 entity_path, 458 rr.Mesh3D( 459 vertex_positions=vertices, 460 triangle_indices=triangles, 461 vertex_normals=normals, 462 albedo_factor=self.albedo_factor, 463 ), 464 static=self.static_transform, 465 ) 466 467 # Capsule geometry 468 elif geometry.capsule: 469 radius = geometry.capsule.radius 470 height = geometry.capsule.cylinder_height 471 472 # Slightly shrink the capsule if static to reduce z-fighting 473 if self.static_transform: 474 radius *= 0.99 475 height *= 0.99 476 477 # Create capsule and retrieve normals 478 cap_mesh = trimesh.creation.capsule(radius=radius, height=height) 479 vertex_normals = cap_mesh.vertex_normals.tolist() 480 481 rr.log( 482 entity_path, 483 rr.Mesh3D( 484 vertex_positions=cap_mesh.vertices.tolist(), 485 triangle_indices=cap_mesh.faces.tolist(), 486 vertex_normals=vertex_normals, 487 albedo_factor=self.albedo_factor, 488 ), 489 ) 490 491 # Rectangular capsule geometry 492 elif geometry.rectangular_capsule: 493 radius = geometry.rectangular_capsule.radius 494 distance_x = geometry.rectangular_capsule.sphere_center_distance_x 495 distance_y = geometry.rectangular_capsule.sphere_center_distance_y 496 497 # Create a rectangular capsule from its definition - a hull around 4 spheres 498 # First, create the four spheres at the corners 499 sphere_centers = [ 500 [distance_x, distance_y, 0], 501 [distance_x, -distance_y, 0], 502 [-distance_x, distance_y, 0], 503 [-distance_x, -distance_y, 0], 504 ] 505 506 # Generate points to create a convex hull 507 all_points = [] 508 for center in sphere_centers: 509 # Generate points for each sphere (simplified with key points on the sphere) 510 for dx, dy, dz in [ 511 (1, 0, 0), 512 (-1, 0, 0), 513 (0, 1, 0), 514 (0, -1, 0), 515 (0, 0, 1), 516 (0, 0, -1), 517 ]: 518 all_points.append( 519 [center[0] + radius * dx, center[1] + radius * dy, center[2] + radius * dz] 520 ) 521 522 # Use our hull visualizer to create outlines 523 polygons = HullVisualizer.compute_hull_outlines_from_points(all_points) 524 525 if polygons: 526 # Log wireframe outline 527 line_segments = [p.tolist() for p in polygons] 528 rr.log( 529 f"{entity_path}_wireframe", 530 rr.LineStrips3D( 531 line_segments, 532 radii=rr.Radius.ui_points(1.0), 533 colors=[ 534 (221, 193, 193, 255) if self.static_transform else self.albedo_factor 535 ], 536 ), 537 static=self.static_transform, 538 ) 539 540 # Log solid mesh 541 vertices, triangles, normals = HullVisualizer.compute_hull_mesh(polygons) 542 543 rr.log( 544 entity_path, 545 rr.Mesh3D( 546 vertex_positions=vertices, 547 triangle_indices=triangles, 548 vertex_normals=normals, 549 albedo_factor=self.albedo_factor, 550 ), 551 static=self.static_transform, 552 ) 553 554 # Plane geometry (simplified as a large, thin rectangle) 555 elif geometry.plane: 556 # Create a large, thin rectangle to represent an infinite plane 557 size = 5000 # Large enough to seem infinite in the visualization 558 rr.log( 559 entity_path, 560 rr.Boxes3D( 561 centers=[0, 0, 0], 562 sizes=[size, size, 1.0], # Very thin in z direction 563 colors=[(200, 200, 220, 100) if self.static_transform else self.albedo_factor], 564 ), 565 ) 566 567 # Compound geometry - recursively process child geometries 568 elif geometry.compound and geometry.compound.child_geometries: 569 for i, child_geom in enumerate(geometry.compound.child_geometries): 570 child_path = f"{entity_path}/child_{i}" 571 self.init_geometry(child_path, child_geom) 572 573 # Default fallback for unsupported geometry types 574 else: 575 # Fallback to a box 576 rr.log( 577 entity_path, 578 rr.Boxes3D( 579 half_sizes=[[50, 50, 50]], 580 colors=[(255, 0, 0, 128)], # Red, semi-transparent to indicate unknown type 581 ), 582 ) 583 584 self.logged_meshes.add(entity_path) 585 586 def log_robot_geometry(self, joint_position): 587 transforms = self.compute_forward_kinematics(joint_position) 588 589 def log_geometry(entity_path, transform): 590 translation = transform[:3, 3] 591 Rm = transform[:3, :3] 592 axis, angle = self.rotation_matrix_to_axis_angle(Rm) 593 rr.log( 594 entity_path, 595 rr.InstancePoses3D( 596 translations=[translation.tolist()], 597 rotation_axis_angles=[ 598 rr.RotationAxisAngle(axis=axis.tolist(), angle=float(angle)) 599 ], 600 ), 601 static=self.static_transform, 602 ) 603 604 # Log robot joint geometries 605 if self.mesh_loaded: 606 for link_index, joint_name in enumerate(self.joint_names): 607 link_transform = transforms[link_index] 608 609 # Get nodes on same layer using dictionary 610 same_layer_nodes = self.layer_nodes_dict.get(joint_name) 611 if not same_layer_nodes: 612 continue 613 614 filtered_geoms = [] 615 for node_name in same_layer_nodes: 616 if node_name in self.scene.geometry: 617 geom = self.scene.geometry[node_name] 618 # Add metadata that would normally come from dump 619 geom.metadata = {"node": node_name} 620 filtered_geoms.append(geom) 621 622 for geom in filtered_geoms: 623 entity_path = f"{self.base_entity_path}/visual/links/link_{link_index}/mesh/{geom.metadata.get('node')}" 624 625 # calculate the inverse transform to get the mesh in the correct position 626 cumulative_transform, _ = self.scene.graph.get(frame_to=joint_name) 627 ctransform = cumulative_transform.copy() 628 inverse_transform = np.linalg.inv(ctransform) 629 630 # DH theta is rotated, rotate mesh around z in direction of theta 631 rotation_matrix_z_4x4 = np.eye(4) 632 if len(self.robot.dh_parameters) > link_index: 633 rotation_z_minus_90 = Rotation.from_euler( 634 "z", self.robot.dh_parameters[link_index].theta, degrees=False 635 ).as_matrix() 636 rotation_matrix_z_4x4[:3, :3] = rotation_z_minus_90 637 638 # scale positions to mm 639 inverse_transform[:3, 3] *= 1000 640 641 root_transform = self.get_transform_matrix() 642 643 transform = root_transform @ inverse_transform 644 645 final_transform = link_transform @ rotation_matrix_z_4x4 @ transform 646 647 self.init_mesh(entity_path, geom, joint_name) 648 log_geometry(entity_path, final_transform) 649 650 # Log link geometries (safety from controller) 651 if self.show_safety_link_chain: 652 for link_index, geometries in self.link_geometries.items(): 653 link_transform = transforms[link_index] 654 for i, geom in enumerate(geometries): 655 entity_path = f"{self.base_entity_path}/safety_from_controller/links/link_{link_index}/geometry_{i}" 656 final_transform = link_transform @ self.geometry_pose_to_matrix(geom.init_pose) 657 658 self.init_geometry(entity_path, geom) 659 log_geometry(entity_path, final_transform) 660 661 # Log TCP geometries (safety from controller) 662 if self.show_safety_link_chain and self.tcp_geometries: 663 tcp_transform = transforms[-1] # the final frame transform 664 for i, geom in enumerate(self.tcp_geometries): 665 entity_path = f"{self.base_entity_path}/safety_from_controller/tcp/geometry_{i}" 666 final_transform = tcp_transform @ self.geometry_pose_to_matrix(geom.init_pose) 667 668 self.init_geometry(entity_path, geom) 669 log_geometry(entity_path, final_transform) 670 671 def log_robot_geometries(self, trajectory: list[models.TrajectorySample], times_column): 672 """ 673 Log the robot geometries for each link and TCP as separate entities. 674 675 Args: 676 trajectory (List[wb.models.TrajectorySample]): The list of trajectory sample points. 677 times_column (rr.TimeColumn): The time column associated with the trajectory points. 678 """ 679 link_positions = {} 680 link_rotations = {} 681 682 def collect_geometry_data(entity_path, transform): 683 """Helper to collect geometry data for a given entity.""" 684 translation = transform[:3, 3].tolist() 685 Rm = transform[:3, :3] 686 axis, angle = self.rotation_matrix_to_axis_angle(Rm) 687 if entity_path not in link_positions: 688 link_positions[entity_path] = [] 689 link_rotations[entity_path] = [] 690 link_positions[entity_path].append(translation) 691 link_rotations[entity_path].append(rr.RotationAxisAngle(axis=axis, angle=angle)) 692 693 for point in trajectory: 694 transforms = self.compute_forward_kinematics(point.joint_position) 695 696 # Log robot joint geometries 697 if self.mesh_loaded: 698 for link_index, joint_name in enumerate(self.joint_names): 699 if link_index >= len(transforms): 700 break 701 link_transform = transforms[link_index] 702 703 # Get nodes on same layer using dictionary 704 same_layer_nodes = self.layer_nodes_dict.get(joint_name) 705 if not same_layer_nodes: 706 continue 707 708 filtered_geoms = [] 709 for node_name in same_layer_nodes: 710 if node_name in self.scene.geometry: 711 geom = self.scene.geometry[node_name] 712 # Add metadata that would normally come from dump 713 geom.metadata = {"node": node_name} 714 filtered_geoms.append(geom) 715 716 for geom in filtered_geoms: 717 entity_path = f"{self.base_entity_path}/visual/links/link_{link_index}/mesh/{geom.metadata.get('node')}" 718 719 # calculate the inverse transform to get the mesh in the correct position 720 cumulative_transform, _ = self.scene.graph.get(frame_to=joint_name) 721 ctransform = cumulative_transform.copy() 722 inverse_transform = np.linalg.inv(ctransform) 723 724 # DH theta is rotated, rotate mesh around z in direction of theta 725 rotation_matrix_z_4x4 = np.eye(4) 726 if len(self.robot.dh_parameters) > link_index: 727 rotation_z_minus_90 = Rotation.from_euler( 728 "z", self.robot.dh_parameters[link_index].theta, degrees=False 729 ).as_matrix() 730 rotation_matrix_z_4x4[:3, :3] = rotation_z_minus_90 731 732 # scale positions to mm 733 inverse_transform[:3, 3] *= 1000 734 735 root_transform = self.get_transform_matrix() 736 737 transform = root_transform @ inverse_transform 738 739 final_transform = link_transform @ rotation_matrix_z_4x4 @ transform 740 741 self.init_mesh(entity_path, geom, joint_name) 742 collect_geometry_data(entity_path, final_transform) 743 744 # Collect data for link geometries (safety from controller) 745 if self.show_safety_link_chain: 746 for link_index, geometries in self.link_geometries.items(): 747 link_transform = transforms[link_index] 748 for i, geom in enumerate(geometries): 749 entity_path = f"{self.base_entity_path}/safety_from_controller/links/link_{link_index}/geometry_{i}" 750 final_transform = link_transform @ self.geometry_pose_to_matrix( 751 geom.init_pose 752 ) 753 self.init_geometry(entity_path, geom) 754 collect_geometry_data(entity_path, final_transform) 755 756 # Collect data for TCP geometries (safety from controller) 757 if self.show_safety_link_chain and self.tcp_geometries: 758 tcp_transform = transforms[-1] # End-effector transform 759 for i, geom in enumerate(self.tcp_geometries): 760 entity_path = f"{self.base_entity_path}/safety_from_controller/tcp/geometry_{i}" 761 final_transform = tcp_transform @ self.geometry_pose_to_matrix(geom.init_pose) 762 self.init_geometry(entity_path, geom) 763 collect_geometry_data(entity_path, final_transform) 764 765 # Collect data for collision link geometries (only if enabled) 766 if self.show_collision_link_chain and self.collision_link_geometries: 767 for link_index, geometries in enumerate(self.collision_link_geometries): 768 link_transform = transforms[link_index] 769 for i, geom_id in enumerate(geometries): 770 entity_path = f"{self.base_entity_path}/collision/links/link_{link_index}/geometry_{geom_id}" 771 772 pose = normalize_pose(geometries[geom_id].pose) 773 774 final_transform = link_transform @ self.geometry_pose_to_matrix(pose) 775 self.init_collision_geometry(entity_path, geometries[geom_id], pose) 776 collect_geometry_data(entity_path, final_transform) 777 778 # Collect data for collision TCP geometries (only if enabled) 779 if self.show_collision_link_chain and self.collision_tcp_geometries: 780 tcp_transform = transforms[-1] # End-effector transform 781 for i, geom_id in enumerate(self.collision_tcp_geometries): 782 entity_path = f"{self.base_entity_path}/collision/tcp/geometry_{geom_id}" 783 784 pose = normalize_pose(self.collision_tcp_geometries[geom_id].pose) 785 final_transform = tcp_transform @ self.geometry_pose_to_matrix(pose) 786 787 # tcp collision geometries are defined in flange frame 788 identity_pose = models.PlannerPose( 789 position=models.Vector3d(x=0, y=0, z=0), 790 orientation=models.Quaternion(x=0, y=0, z=0, w=1), 791 ) 792 self.init_collision_geometry( 793 entity_path, self.collision_tcp_geometries[geom_id], identity_pose 794 ) 795 collect_geometry_data(entity_path, final_transform) 796 797 # Send collected columns for all geometries 798 for entity_path, positions in link_positions.items(): 799 rr.send_columns( 800 entity_path, 801 indexes=[times_column], 802 columns=[ 803 *rr.Transform3D.columns( 804 translation=positions, rotation_axis_angle=link_rotations[entity_path] 805 ) 806 ], 807 )
23 def __init__( 24 self, 25 robot: DHRobot, 26 robot_model_geometries: list[models.RobotLinkGeometry], 27 tcp_geometries: list[models.Geometry], 28 static_transform: bool = True, 29 base_entity_path: str = "robot", 30 albedo_factor: list = [255, 255, 255], 31 collision_link_chain=None, 32 collision_tcp=None, 33 model_from_controller="", 34 show_collision_link_chain: bool = False, 35 show_safety_link_chain: bool = True, 36 ): 37 """ 38 :param robot: DHRobot instance 39 :param robot_model_geometries: List of geometries for each link 40 :param tcp_geometries: TCP geometries (similar structure to link geometries) 41 :param static_transform: If True, transforms are logged as static, else temporal. 42 :param base_entity_path: A base path prefix for logging the entities (e.g. motion group name) 43 :param albedo_factor: A list representing the RGB values [R, G, B] to apply as the albedo factor. 44 :param collision_link_chain: Collision link chain geometries for the robot 45 :param collision_tcp: Collision TCP geometries 46 :param model_from_controller: Model name from controller for loading robot mesh 47 :param show_collision_link_chain: Whether to render robot collision mesh geometry 48 :param show_safety_link_chain: Whether to render robot safety geometry (from controller) 49 """ 50 self.robot = robot 51 self.link_geometries: dict[int, list[models.Geometry]] = {} 52 self.tcp_geometries: list[models.Geometry] = tcp_geometries 53 self.logged_meshes: set[str] = set() 54 self.static_transform = static_transform 55 self.base_entity_path = base_entity_path.rstrip("/") 56 self.albedo_factor = albedo_factor 57 self.mesh_loaded = False 58 self.collision_link_geometries = {} 59 self.collision_tcp_geometries = collision_tcp 60 self.show_collision_link_chain = show_collision_link_chain 61 self.show_safety_link_chain = show_safety_link_chain 62 63 # This will hold the names of discovered joints (e.g. ["robot_J00", "robot_J01", ...]) 64 self.joint_names: list[str] = [] 65 self.layer_nodes_dict: dict[str, list[str]] = {} 66 self.parent_nodes_dict: dict[str, str] = {} 67 68 # load mesh 69 try: 70 glb_path = get_model_path(model_from_controller) 71 self.scene = trimesh.load_scene(glb_path, file_type="glb") 72 self.mesh_loaded = True 73 self.edge_data = self.scene.graph.transforms.edge_data 74 75 # After loading, auto-discover any child nodes that match *_J0n 76 self.discover_joints() 77 except Exception as e: 78 print(f"Failed to load mesh: {e}") 79 80 # Group geometries by link 81 for gm in robot_model_geometries: 82 self.link_geometries.setdefault(gm.link_index, []).append(gm.geometry) 83 84 # Group geometries by link 85 self.collision_link_geometries = collision_link_chain
Parameters
- robot: DHRobot instance
- robot_model_geometries: List of geometries for each link
- tcp_geometries: TCP geometries (similar structure to link geometries)
- static_transform: If True, transforms are logged as static, else temporal.
- base_entity_path: A base path prefix for logging the entities (e.g. motion group name)
- albedo_factor: A list representing the RGB values [R, G, B] to apply as the albedo factor.
- collision_link_chain: Collision link chain geometries for the robot
- collision_tcp: Collision TCP geometries
- model_from_controller: Model name from controller for loading robot mesh
- show_collision_link_chain: Whether to render robot collision mesh geometry
- show_safety_link_chain: Whether to render robot safety geometry (from controller)
87 def discover_joints(self): 88 """ 89 Find all child node names that contain '_J0' followed by digits or '_FLG'. 90 Store joints with their parent nodes and print layer information. 91 """ 92 joint_pattern = re.compile(r"_J0(\d+)") 93 flg_pattern = re.compile(r"_FLG") 94 matches = [] 95 flg_nodes = [] 96 joint_parents = {} # Store parent for each joint/FLG 97 98 for (parent, child), data in self.edge_data.items(): 99 # Check for joints 100 joint_match = joint_pattern.search(child) 101 if joint_match: 102 j_idx = int(joint_match.group(1)) 103 matches.append((j_idx, child)) 104 joint_parents[child] = parent 105 106 # Check for FLG 107 flg_match = flg_pattern.search(child) 108 if flg_match: 109 flg_nodes.append(child) 110 joint_parents[child] = parent 111 112 matches.sort(key=lambda x: x[0]) 113 self.joint_names = [name for _, name in matches] + flg_nodes 114 115 # print("Discovered nodes:", self.joint_names) 116 # Print layer information for each joint 117 for joint in self.joint_names: 118 self.get_nodes_on_same_layer(joint_parents[joint], joint) 119 # print(f"\nNodes on same layer as {joint}:") 120 # print(f"Parent node: {joint_parents[joint]}") 121 # print(f"Layer nodes: {same_layer_nodes}")
Find all child node names that contain '_J0' followed by digits or '_FLG'. Store joints with their parent nodes and print layer information.
123 def get_nodes_on_same_layer(self, parent_node, joint): 124 """ 125 Find nodes on same layer and only add descendants of link nodes. 126 """ 127 same_layer = [] 128 # First get immediate layer nodes 129 for (parent, child), data in self.edge_data.items(): 130 if parent == parent_node: 131 if child == joint: 132 continue 133 if "geometry" in data: 134 same_layer.append(data["geometry"]) 135 self.parent_nodes_dict[data["geometry"]] = child 136 137 # Get all descendants for this link 138 parentChild = child 139 stack = [child] 140 while stack: 141 current = stack.pop() 142 for (p, c), data in self.edge_data.items(): 143 if p == current: 144 if "geometry" in data: 145 same_layer.append(data["geometry"]) 146 self.parent_nodes_dict[data["geometry"]] = parentChild 147 stack.append(c) 148 149 self.layer_nodes_dict[joint] = same_layer 150 return same_layer
Find nodes on same layer and only add descendants of link nodes.
155 def compute_forward_kinematics(self, joint_values): 156 """Compute link transforms using the robot's methods.""" 157 accumulated = self.robot.pose_to_matrix(self.robot.mounting) 158 transforms = [accumulated.copy()] 159 for dh_param, joint_rot in zip(self.robot.dh_parameters, joint_values.joints, strict=False): 160 transform = self.robot.dh_transform(dh_param, joint_rot) 161 accumulated = accumulated @ transform 162 transforms.append(accumulated.copy()) 163 return transforms
Compute link transforms using the robot's methods.
165 def rotation_matrix_to_axis_angle(self, Rm): 166 """Use scipy for cleaner axis-angle extraction.""" 167 rot = Rotation.from_matrix(Rm) 168 angle = rot.magnitude() 169 axis = rot.as_rotvec() / angle if angle > 1e-8 else np.array([1.0, 0.0, 0.0]) 170 return axis, angle
Use scipy for cleaner axis-angle extraction.
172 def gamma_lift_single_color(self, color: np.ndarray, gamma: float = 0.8) -> np.ndarray: 173 """ 174 Apply gamma correction to a single RGBA color in-place. 175 color: shape (4,) with [R, G, B, A] in 0..255, dtype=uint8 176 gamma: < 1.0 brightens midtones, > 1.0 darkens them. 177 """ 178 rgb_float = color[:3].astype(np.float32) / 255.0 179 rgb_float = np.power(rgb_float, gamma) 180 color[:3] = (rgb_float * 255.0).astype(np.uint8) 181 182 return color
Apply gamma correction to a single RGBA color in-place. color: shape (4,) with [R, G, B, A] in 0..255, dtype=uint8 gamma: < 1.0 brightens midtones, > 1.0 darkens them.
184 def get_transform_matrix(self): 185 """ 186 Creates a transformation matrix that converts from glTF's right-handed Y-up 187 coordinate system to Rerun's right-handed Z-up coordinate system. 188 189 Returns: 190 np.ndarray: A 4x4 transformation matrix 191 """ 192 # Convert from glTF's Y-up to Rerun's Z-up coordinate system 193 return np.array( 194 [ 195 [1.0, 0.0, 0.0, 0.0], # X stays the same 196 [0.0, 0.0, -1.0, 0.0], # Y becomes -Z 197 [0.0, 1.0, 0.0, 0.0], # Z becomes Y 198 [0.0, 0.0, 0.0, 1.0], # Homogeneous coordinate 199 ] 200 )
Creates a transformation matrix that converts from glTF's right-handed Y-up coordinate system to Rerun's right-handed Z-up coordinate system.
Returns:
np.ndarray: A 4x4 transformation matrix
202 def init_mesh(self, entity_path: str, geom, joint_name): 203 """Generic method to log a single geometry, either capsule or box.""" 204 205 if entity_path not in self.logged_meshes: 206 if geom.metadata.get("node") not in self.parent_nodes_dict: 207 return 208 209 base_transform = np.eye(4) 210 # if the dh parameters are not at 0,0,0 from the mesh we have to move the first mesh joint 211 if "J00" in joint_name: 212 base_transform_, _ = self.scene.graph.get(frame_to=joint_name) 213 base_transform = base_transform_.copy() 214 base_transform[:3, 3] *= 1000 215 216 # if the mesh has the pivot not in the center, we need to adjust the transform 217 cumulative_transform, _ = self.scene.graph.get( 218 frame_to=self.parent_nodes_dict[geom.metadata.get("node")] 219 ) 220 ctransform = cumulative_transform.copy() 221 222 # scale positions to mm 223 ctransform[:3, 3] *= 1000 224 225 # scale mesh to mm 226 transform = base_transform @ ctransform 227 mesh_scale_matrix = np.eye(4) 228 mesh_scale_matrix[:3, :3] *= 1000 229 transform = transform @ mesh_scale_matrix 230 transformed_mesh = geom.copy() 231 232 transformed_mesh.apply_transform(transform) 233 234 if transformed_mesh.visual is not None: 235 transformed_mesh.visual = transformed_mesh.visual.to_color() 236 237 vertex_colors = None 238 if transformed_mesh.visual and hasattr(transformed_mesh.visual, "vertex_colors"): 239 vertex_colors = transformed_mesh.visual.vertex_colors 240 241 rr.log( 242 entity_path, 243 rr.Mesh3D( 244 vertex_positions=transformed_mesh.vertices, 245 triangle_indices=transformed_mesh.faces, 246 vertex_normals=getattr(transformed_mesh, "vertex_normals", None), 247 albedo_factor=self.gamma_lift_single_color(vertex_colors, gamma=0.5) 248 if vertex_colors is not None 249 else None, 250 ), 251 ) 252 253 self.logged_meshes.add(entity_path)
Generic method to log a single geometry, either capsule or box.
255 def init_collision_geometry( 256 self, entity_path: str, collider: models.Collider, pose: models.PlannerPose 257 ): 258 if entity_path in self.logged_meshes: 259 return 260 261 if isinstance(collider.shape.actual_instance, models.Sphere2): 262 rr.log( 263 f"{entity_path}", 264 rr.Ellipsoids3D( 265 radii=[ 266 collider.shape.actual_instance.radius, 267 collider.shape.actual_instance.radius, 268 collider.shape.actual_instance.radius, 269 ], 270 centers=[[pose.position.x, pose.position.y, pose.position.z]] 271 if pose.position 272 else [0, 0, 0], 273 colors=[(221, 193, 193, 255)], 274 ), 275 ) 276 277 elif isinstance(collider.shape.actual_instance, models.Box2): 278 rr.log( 279 f"{entity_path}", 280 rr.Boxes3D( 281 centers=[[pose.position.x, pose.position.y, pose.position.z]] 282 if pose.position 283 else [0, 0, 0], 284 sizes=[ 285 collider.shape.actual_instance.size_x, 286 collider.shape.actual_instance.size_y, 287 collider.shape.actual_instance.size_z, 288 ], 289 colors=[(221, 193, 193, 255)], 290 ), 291 ) 292 293 elif isinstance(collider.shape.actual_instance, models.Capsule2): 294 height = collider.shape.actual_instance.cylinder_height 295 radius = collider.shape.actual_instance.radius 296 297 # Generate trimesh capsule 298 capsule = trimesh.creation.capsule(height=height, radius=radius, count=[6, 8]) 299 300 # Extract vertices and faces for solid visualization 301 vertices = np.array(capsule.vertices) 302 303 # Transform vertices to world position 304 transform = np.eye(4) 305 if pose.position: 306 transform[:3, 3] = [pose.position.x, pose.position.y, pose.position.z - height / 2] 307 else: 308 transform[:3, 3] = [0, 0, -height / 2] 309 310 if collider.pose and collider.pose.orientation: 311 rot_mat = Rotation.from_quat( 312 [ 313 collider.pose.orientation[0], 314 collider.pose.orientation[1], 315 collider.pose.orientation[2], 316 collider.pose.orientation[3], 317 ] 318 ) 319 transform[:3, :3] = rot_mat.as_matrix() 320 321 vertices = np.array([transform @ np.append(v, 1) for v in vertices])[:, :3] 322 323 polygons = HullVisualizer.compute_hull_outlines_from_points(vertices) 324 325 if polygons: 326 line_segments = [p.tolist() for p in polygons] 327 rr.log( 328 f"{entity_path}", 329 rr.LineStrips3D( 330 line_segments, 331 radii=rr.Radius.ui_points(0.75), 332 colors=[[221, 193, 193, 255]], 333 ), 334 static=True, 335 ) 336 337 elif isinstance(collider.shape.actual_instance, models.ConvexHull2): 338 polygons = HullVisualizer.compute_hull_outlines_from_points( 339 collider.shape.actual_instance.vertices 340 ) 341 342 if polygons: 343 line_segments = [p.tolist() for p in polygons] 344 rr.log( 345 f"{entity_path}", 346 rr.LineStrips3D( 347 line_segments, radii=rr.Radius.ui_points(1.5), colors=[colors.colors[2]] 348 ), 349 static=True, 350 ) 351 352 vertices, triangles, normals = HullVisualizer.compute_hull_mesh(polygons) 353 354 rr.log( 355 f"{entity_path}", 356 rr.Mesh3D( 357 vertex_positions=vertices, 358 triangle_indices=triangles, 359 vertex_normals=normals, 360 albedo_factor=colors.colors[0], 361 ), 362 static=True, 363 ) 364 365 self.logged_meshes.add(entity_path)
367 def init_geometry(self, entity_path: str, geometry: models.Geometry): 368 """Generic method to log a single geometry, either capsule or box.""" 369 370 if entity_path in self.logged_meshes: 371 return 372 373 # Sphere geometry 374 if geometry.sphere: 375 radius = geometry.sphere.radius 376 rr.log( 377 entity_path, 378 rr.Ellipsoids3D( 379 radii=[radius, radius, radius], 380 centers=[0, 0, 0], 381 fill_mode=rr.components.FillMode.Solid, 382 colors=[(221, 193, 193, 255) if self.static_transform else self.albedo_factor], 383 ), 384 ) 385 386 # Box geometry 387 elif geometry.box: 388 rr.log( 389 entity_path, 390 rr.Boxes3D( 391 centers=[0, 0, 0], 392 fill_mode=rr.components.FillMode.Solid, 393 sizes=[geometry.box.size_x, geometry.box.size_y, geometry.box.size_z], 394 colors=[(221, 193, 193, 255) if self.static_transform else self.albedo_factor], 395 ), 396 ) 397 398 # Rectangle geometry 399 elif geometry.rectangle: 400 # Create a flat box with minimal height 401 rr.log( 402 entity_path, 403 rr.Boxes3D( 404 fill_mode=rr.components.FillMode.Solid, 405 centers=[0, 0, 0], 406 sizes=[ 407 geometry.rectangle.size_x, 408 geometry.rectangle.size_y, 409 1.0, # Minimal height for visibility 410 ], 411 colors=[(221, 193, 193, 255) if self.static_transform else self.albedo_factor], 412 ), 413 ) 414 415 # Cylinder geometry 416 elif geometry.cylinder: 417 radius = geometry.cylinder.radius 418 height = geometry.cylinder.height 419 420 # Create cylinder mesh 421 cylinder = trimesh.creation.cylinder(radius=radius, height=height, sections=16) 422 vertex_normals = cylinder.vertex_normals.tolist() 423 424 rr.log( 425 entity_path, 426 rr.Mesh3D( 427 vertex_positions=cylinder.vertices.tolist(), 428 triangle_indices=cylinder.faces.tolist(), 429 vertex_normals=vertex_normals, 430 albedo_factor=self.albedo_factor, 431 ), 432 ) 433 434 # Convex hull geometry 435 elif geometry.convex_hull: 436 polygons = HullVisualizer.compute_hull_outlines_from_points( 437 [[v.x, v.y, v.z] for v in geometry.convex_hull.vertices] 438 ) 439 440 if polygons: 441 # First log wireframe outline 442 line_segments = [p.tolist() for p in polygons] 443 rr.log( 444 f"{entity_path}_wireframe", 445 rr.LineStrips3D( 446 line_segments, 447 radii=rr.Radius.ui_points(1.0), 448 colors=[colors.colors[2] if self.static_transform else self.albedo_factor], 449 ), 450 static=self.static_transform, 451 ) 452 453 # Then log solid mesh 454 vertices, triangles, normals = HullVisualizer.compute_hull_mesh(polygons) 455 456 rr.log( 457 entity_path, 458 rr.Mesh3D( 459 vertex_positions=vertices, 460 triangle_indices=triangles, 461 vertex_normals=normals, 462 albedo_factor=self.albedo_factor, 463 ), 464 static=self.static_transform, 465 ) 466 467 # Capsule geometry 468 elif geometry.capsule: 469 radius = geometry.capsule.radius 470 height = geometry.capsule.cylinder_height 471 472 # Slightly shrink the capsule if static to reduce z-fighting 473 if self.static_transform: 474 radius *= 0.99 475 height *= 0.99 476 477 # Create capsule and retrieve normals 478 cap_mesh = trimesh.creation.capsule(radius=radius, height=height) 479 vertex_normals = cap_mesh.vertex_normals.tolist() 480 481 rr.log( 482 entity_path, 483 rr.Mesh3D( 484 vertex_positions=cap_mesh.vertices.tolist(), 485 triangle_indices=cap_mesh.faces.tolist(), 486 vertex_normals=vertex_normals, 487 albedo_factor=self.albedo_factor, 488 ), 489 ) 490 491 # Rectangular capsule geometry 492 elif geometry.rectangular_capsule: 493 radius = geometry.rectangular_capsule.radius 494 distance_x = geometry.rectangular_capsule.sphere_center_distance_x 495 distance_y = geometry.rectangular_capsule.sphere_center_distance_y 496 497 # Create a rectangular capsule from its definition - a hull around 4 spheres 498 # First, create the four spheres at the corners 499 sphere_centers = [ 500 [distance_x, distance_y, 0], 501 [distance_x, -distance_y, 0], 502 [-distance_x, distance_y, 0], 503 [-distance_x, -distance_y, 0], 504 ] 505 506 # Generate points to create a convex hull 507 all_points = [] 508 for center in sphere_centers: 509 # Generate points for each sphere (simplified with key points on the sphere) 510 for dx, dy, dz in [ 511 (1, 0, 0), 512 (-1, 0, 0), 513 (0, 1, 0), 514 (0, -1, 0), 515 (0, 0, 1), 516 (0, 0, -1), 517 ]: 518 all_points.append( 519 [center[0] + radius * dx, center[1] + radius * dy, center[2] + radius * dz] 520 ) 521 522 # Use our hull visualizer to create outlines 523 polygons = HullVisualizer.compute_hull_outlines_from_points(all_points) 524 525 if polygons: 526 # Log wireframe outline 527 line_segments = [p.tolist() for p in polygons] 528 rr.log( 529 f"{entity_path}_wireframe", 530 rr.LineStrips3D( 531 line_segments, 532 radii=rr.Radius.ui_points(1.0), 533 colors=[ 534 (221, 193, 193, 255) if self.static_transform else self.albedo_factor 535 ], 536 ), 537 static=self.static_transform, 538 ) 539 540 # Log solid mesh 541 vertices, triangles, normals = HullVisualizer.compute_hull_mesh(polygons) 542 543 rr.log( 544 entity_path, 545 rr.Mesh3D( 546 vertex_positions=vertices, 547 triangle_indices=triangles, 548 vertex_normals=normals, 549 albedo_factor=self.albedo_factor, 550 ), 551 static=self.static_transform, 552 ) 553 554 # Plane geometry (simplified as a large, thin rectangle) 555 elif geometry.plane: 556 # Create a large, thin rectangle to represent an infinite plane 557 size = 5000 # Large enough to seem infinite in the visualization 558 rr.log( 559 entity_path, 560 rr.Boxes3D( 561 centers=[0, 0, 0], 562 sizes=[size, size, 1.0], # Very thin in z direction 563 colors=[(200, 200, 220, 100) if self.static_transform else self.albedo_factor], 564 ), 565 ) 566 567 # Compound geometry - recursively process child geometries 568 elif geometry.compound and geometry.compound.child_geometries: 569 for i, child_geom in enumerate(geometry.compound.child_geometries): 570 child_path = f"{entity_path}/child_{i}" 571 self.init_geometry(child_path, child_geom) 572 573 # Default fallback for unsupported geometry types 574 else: 575 # Fallback to a box 576 rr.log( 577 entity_path, 578 rr.Boxes3D( 579 half_sizes=[[50, 50, 50]], 580 colors=[(255, 0, 0, 128)], # Red, semi-transparent to indicate unknown type 581 ), 582 ) 583 584 self.logged_meshes.add(entity_path)
Generic method to log a single geometry, either capsule or box.
586 def log_robot_geometry(self, joint_position): 587 transforms = self.compute_forward_kinematics(joint_position) 588 589 def log_geometry(entity_path, transform): 590 translation = transform[:3, 3] 591 Rm = transform[:3, :3] 592 axis, angle = self.rotation_matrix_to_axis_angle(Rm) 593 rr.log( 594 entity_path, 595 rr.InstancePoses3D( 596 translations=[translation.tolist()], 597 rotation_axis_angles=[ 598 rr.RotationAxisAngle(axis=axis.tolist(), angle=float(angle)) 599 ], 600 ), 601 static=self.static_transform, 602 ) 603 604 # Log robot joint geometries 605 if self.mesh_loaded: 606 for link_index, joint_name in enumerate(self.joint_names): 607 link_transform = transforms[link_index] 608 609 # Get nodes on same layer using dictionary 610 same_layer_nodes = self.layer_nodes_dict.get(joint_name) 611 if not same_layer_nodes: 612 continue 613 614 filtered_geoms = [] 615 for node_name in same_layer_nodes: 616 if node_name in self.scene.geometry: 617 geom = self.scene.geometry[node_name] 618 # Add metadata that would normally come from dump 619 geom.metadata = {"node": node_name} 620 filtered_geoms.append(geom) 621 622 for geom in filtered_geoms: 623 entity_path = f"{self.base_entity_path}/visual/links/link_{link_index}/mesh/{geom.metadata.get('node')}" 624 625 # calculate the inverse transform to get the mesh in the correct position 626 cumulative_transform, _ = self.scene.graph.get(frame_to=joint_name) 627 ctransform = cumulative_transform.copy() 628 inverse_transform = np.linalg.inv(ctransform) 629 630 # DH theta is rotated, rotate mesh around z in direction of theta 631 rotation_matrix_z_4x4 = np.eye(4) 632 if len(self.robot.dh_parameters) > link_index: 633 rotation_z_minus_90 = Rotation.from_euler( 634 "z", self.robot.dh_parameters[link_index].theta, degrees=False 635 ).as_matrix() 636 rotation_matrix_z_4x4[:3, :3] = rotation_z_minus_90 637 638 # scale positions to mm 639 inverse_transform[:3, 3] *= 1000 640 641 root_transform = self.get_transform_matrix() 642 643 transform = root_transform @ inverse_transform 644 645 final_transform = link_transform @ rotation_matrix_z_4x4 @ transform 646 647 self.init_mesh(entity_path, geom, joint_name) 648 log_geometry(entity_path, final_transform) 649 650 # Log link geometries (safety from controller) 651 if self.show_safety_link_chain: 652 for link_index, geometries in self.link_geometries.items(): 653 link_transform = transforms[link_index] 654 for i, geom in enumerate(geometries): 655 entity_path = f"{self.base_entity_path}/safety_from_controller/links/link_{link_index}/geometry_{i}" 656 final_transform = link_transform @ self.geometry_pose_to_matrix(geom.init_pose) 657 658 self.init_geometry(entity_path, geom) 659 log_geometry(entity_path, final_transform) 660 661 # Log TCP geometries (safety from controller) 662 if self.show_safety_link_chain and self.tcp_geometries: 663 tcp_transform = transforms[-1] # the final frame transform 664 for i, geom in enumerate(self.tcp_geometries): 665 entity_path = f"{self.base_entity_path}/safety_from_controller/tcp/geometry_{i}" 666 final_transform = tcp_transform @ self.geometry_pose_to_matrix(geom.init_pose) 667 668 self.init_geometry(entity_path, geom) 669 log_geometry(entity_path, final_transform)
671 def log_robot_geometries(self, trajectory: list[models.TrajectorySample], times_column): 672 """ 673 Log the robot geometries for each link and TCP as separate entities. 674 675 Args: 676 trajectory (List[wb.models.TrajectorySample]): The list of trajectory sample points. 677 times_column (rr.TimeColumn): The time column associated with the trajectory points. 678 """ 679 link_positions = {} 680 link_rotations = {} 681 682 def collect_geometry_data(entity_path, transform): 683 """Helper to collect geometry data for a given entity.""" 684 translation = transform[:3, 3].tolist() 685 Rm = transform[:3, :3] 686 axis, angle = self.rotation_matrix_to_axis_angle(Rm) 687 if entity_path not in link_positions: 688 link_positions[entity_path] = [] 689 link_rotations[entity_path] = [] 690 link_positions[entity_path].append(translation) 691 link_rotations[entity_path].append(rr.RotationAxisAngle(axis=axis, angle=angle)) 692 693 for point in trajectory: 694 transforms = self.compute_forward_kinematics(point.joint_position) 695 696 # Log robot joint geometries 697 if self.mesh_loaded: 698 for link_index, joint_name in enumerate(self.joint_names): 699 if link_index >= len(transforms): 700 break 701 link_transform = transforms[link_index] 702 703 # Get nodes on same layer using dictionary 704 same_layer_nodes = self.layer_nodes_dict.get(joint_name) 705 if not same_layer_nodes: 706 continue 707 708 filtered_geoms = [] 709 for node_name in same_layer_nodes: 710 if node_name in self.scene.geometry: 711 geom = self.scene.geometry[node_name] 712 # Add metadata that would normally come from dump 713 geom.metadata = {"node": node_name} 714 filtered_geoms.append(geom) 715 716 for geom in filtered_geoms: 717 entity_path = f"{self.base_entity_path}/visual/links/link_{link_index}/mesh/{geom.metadata.get('node')}" 718 719 # calculate the inverse transform to get the mesh in the correct position 720 cumulative_transform, _ = self.scene.graph.get(frame_to=joint_name) 721 ctransform = cumulative_transform.copy() 722 inverse_transform = np.linalg.inv(ctransform) 723 724 # DH theta is rotated, rotate mesh around z in direction of theta 725 rotation_matrix_z_4x4 = np.eye(4) 726 if len(self.robot.dh_parameters) > link_index: 727 rotation_z_minus_90 = Rotation.from_euler( 728 "z", self.robot.dh_parameters[link_index].theta, degrees=False 729 ).as_matrix() 730 rotation_matrix_z_4x4[:3, :3] = rotation_z_minus_90 731 732 # scale positions to mm 733 inverse_transform[:3, 3] *= 1000 734 735 root_transform = self.get_transform_matrix() 736 737 transform = root_transform @ inverse_transform 738 739 final_transform = link_transform @ rotation_matrix_z_4x4 @ transform 740 741 self.init_mesh(entity_path, geom, joint_name) 742 collect_geometry_data(entity_path, final_transform) 743 744 # Collect data for link geometries (safety from controller) 745 if self.show_safety_link_chain: 746 for link_index, geometries in self.link_geometries.items(): 747 link_transform = transforms[link_index] 748 for i, geom in enumerate(geometries): 749 entity_path = f"{self.base_entity_path}/safety_from_controller/links/link_{link_index}/geometry_{i}" 750 final_transform = link_transform @ self.geometry_pose_to_matrix( 751 geom.init_pose 752 ) 753 self.init_geometry(entity_path, geom) 754 collect_geometry_data(entity_path, final_transform) 755 756 # Collect data for TCP geometries (safety from controller) 757 if self.show_safety_link_chain and self.tcp_geometries: 758 tcp_transform = transforms[-1] # End-effector transform 759 for i, geom in enumerate(self.tcp_geometries): 760 entity_path = f"{self.base_entity_path}/safety_from_controller/tcp/geometry_{i}" 761 final_transform = tcp_transform @ self.geometry_pose_to_matrix(geom.init_pose) 762 self.init_geometry(entity_path, geom) 763 collect_geometry_data(entity_path, final_transform) 764 765 # Collect data for collision link geometries (only if enabled) 766 if self.show_collision_link_chain and self.collision_link_geometries: 767 for link_index, geometries in enumerate(self.collision_link_geometries): 768 link_transform = transforms[link_index] 769 for i, geom_id in enumerate(geometries): 770 entity_path = f"{self.base_entity_path}/collision/links/link_{link_index}/geometry_{geom_id}" 771 772 pose = normalize_pose(geometries[geom_id].pose) 773 774 final_transform = link_transform @ self.geometry_pose_to_matrix(pose) 775 self.init_collision_geometry(entity_path, geometries[geom_id], pose) 776 collect_geometry_data(entity_path, final_transform) 777 778 # Collect data for collision TCP geometries (only if enabled) 779 if self.show_collision_link_chain and self.collision_tcp_geometries: 780 tcp_transform = transforms[-1] # End-effector transform 781 for i, geom_id in enumerate(self.collision_tcp_geometries): 782 entity_path = f"{self.base_entity_path}/collision/tcp/geometry_{geom_id}" 783 784 pose = normalize_pose(self.collision_tcp_geometries[geom_id].pose) 785 final_transform = tcp_transform @ self.geometry_pose_to_matrix(pose) 786 787 # tcp collision geometries are defined in flange frame 788 identity_pose = models.PlannerPose( 789 position=models.Vector3d(x=0, y=0, z=0), 790 orientation=models.Quaternion(x=0, y=0, z=0, w=1), 791 ) 792 self.init_collision_geometry( 793 entity_path, self.collision_tcp_geometries[geom_id], identity_pose 794 ) 795 collect_geometry_data(entity_path, final_transform) 796 797 # Send collected columns for all geometries 798 for entity_path, positions in link_positions.items(): 799 rr.send_columns( 800 entity_path, 801 indexes=[times_column], 802 columns=[ 803 *rr.Transform3D.columns( 804 translation=positions, rotation_axis_angle=link_rotations[entity_path] 805 ) 806 ], 807 )
Log the robot geometries for each link and TCP as separate entities.
Arguments:
- trajectory (List[wb.models.TrajectorySample]): The list of trajectory sample points.
- times_column (rr.TimeColumn): The time column associated with the trajectory points.