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            )
def get_model_path(model_name: str) -> str:
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

class RobotVisualizer:
 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            )
RobotVisualizer( robot: nova_rerun_bridge.dh_robot.DHRobot, robot_model_geometries: list[wandelbots_api_client.models.robot_link_geometry.RobotLinkGeometry], tcp_geometries: list[wandelbots_api_client.models.geometry.Geometry], static_transform: bool = True, base_entity_path: str = 'robot', albedo_factor: list = [255, 255, 255], collision_link_chain=None, collision_tcp=None, model_from_controller='', show_collision_link_chain: bool = False, show_safety_link_chain: bool = True)
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)
robot
tcp_geometries: list[wandelbots_api_client.models.geometry.Geometry]
logged_meshes: set[str]
static_transform
base_entity_path
albedo_factor
mesh_loaded
collision_tcp_geometries
joint_names: list[str]
layer_nodes_dict: dict[str, list[str]]
parent_nodes_dict: dict[str, str]
def discover_joints(self):
 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.

def get_nodes_on_same_layer(self, parent_node, joint):
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.

def geometry_pose_to_matrix( self, init_pose: wandelbots_api_client.models.planner_pose.PlannerPose):
152    def geometry_pose_to_matrix(self, init_pose: models.PlannerPose):
153        return self.robot.pose_to_matrix(init_pose)
def compute_forward_kinematics(self, joint_values):
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.

def rotation_matrix_to_axis_angle(self, Rm):
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.

def gamma_lift_single_color(self, color: numpy.ndarray, gamma: float = 0.8) -> numpy.ndarray:
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.

def get_transform_matrix(self):
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

def init_mesh(self, entity_path: str, geom, joint_name):
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.

def init_collision_geometry( self, entity_path: str, collider: wandelbots_api_client.models.collider.Collider, pose: wandelbots_api_client.models.planner_pose.PlannerPose):
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)
def init_geometry( self, entity_path: str, geometry: wandelbots_api_client.models.geometry.Geometry):
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.

def log_robot_geometry(self, joint_position):
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)
def log_robot_geometries( self, trajectory: list[wandelbots_api_client.models.trajectory_sample.TrajectorySample], times_column):
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.