Skip to content

Commit 06712a3

Browse files
authored
Add joint axis visualization to PyrenderViewer (#612)
This adds the ability to visualize joint positions and axes in PyrenderViewer. Press 'j' key to toggle the display of all joint axes for added robots. Joint positions are shown as blue spheres and axes as red cylinders.
1 parent d36bc17 commit 06712a3

File tree

2 files changed

+232
-0
lines changed

2 files changed

+232
-0
lines changed

docs/source/reference/viewers.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ The PyrenderViewer provides extensive keyboard controls for interactive manipula
130130
- Toggle shadow rendering (may impact performance)
131131
* - ``i``
132132
- Cycle through axis display modes (none → world → mesh → all)
133+
* - ``j``
134+
- **Toggle joint axes display** (shows/hides joint positions and axes for all robots)
133135
* - ``l``
134136
- Cycle lighting modes (scene → Raymond → direct)
135137
* - ``m``

skrobot/viewers/_pyrender.py

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ class PyrenderViewer(pyrender.Viewer):
8787
8888
Keyboard Controls
8989
-----------------
90+
j : Toggle joint axes display (shows/hides joint positions and axes)
91+
Joint positions are displayed as blue spheres
92+
Joint axes are displayed as red cylinders
9093
v : Toggle between visual and collision meshes (if enable_collision_toggle=True)
9194
Collision meshes are displayed in orange/transparent color
9295
"""
@@ -103,6 +106,11 @@ def __init__(self, resolution=None, update_interval=1.0,
103106

104107
self.thread = None
105108
self._visual_mesh_map = collections.OrderedDict()
109+
self._joint_axis_map = collections.OrderedDict()
110+
111+
# Joint axis toggle functionality
112+
self._stored_robots = []
113+
self.show_joint_axes = False
106114

107115
# Collision toggle functionality
108116
self.enable_collision_toggle = enable_collision_toggle
@@ -226,6 +234,51 @@ def on_draw(self):
226234
link._visual_mesh_changed = False
227235
else:
228236
node.matrix = transform
237+
238+
# update joint axis transforms
239+
for joint_id, (sphere_node, axis_node, joint) in self._joint_axis_map.items():
240+
# Update joint position and axis
241+
position = joint.world_position
242+
axis = joint.world_axis
243+
244+
# Update sphere position
245+
sphere_transform = np.eye(4)
246+
sphere_transform[:3, 3] = position
247+
sphere_node.matrix = sphere_transform
248+
249+
# Update axis cylinder position and orientation
250+
if axis_node is not None and axis is not None:
251+
# Calculate rotation matrix to align cylinder with axis
252+
# Default cylinder is along Z-axis, need to rotate to align with joint axis
253+
z_axis = np.array([0, 0, 1])
254+
axis_normalized = axis / np.linalg.norm(axis)
255+
256+
# Calculate rotation axis and angle
257+
rotation_axis = np.cross(z_axis, axis_normalized)
258+
rotation_axis_norm = np.linalg.norm(rotation_axis)
259+
260+
if rotation_axis_norm > 1e-6:
261+
rotation_axis = rotation_axis / rotation_axis_norm
262+
angle = np.arccos(np.clip(np.dot(z_axis, axis_normalized), -1.0, 1.0))
263+
# Create rotation matrix using Rodrigues' formula
264+
K = np.array([
265+
[0, -rotation_axis[2], rotation_axis[1]],
266+
[rotation_axis[2], 0, -rotation_axis[0]],
267+
[-rotation_axis[1], rotation_axis[0], 0]
268+
])
269+
rotation_matrix = np.eye(3) + np.sin(angle) * K + (1 - np.cos(angle)) * (K @ K)
270+
else:
271+
# Axis is already aligned with z-axis or opposite
272+
if np.dot(z_axis, axis_normalized) > 0:
273+
rotation_matrix = np.eye(3)
274+
else:
275+
rotation_matrix = np.array([[-1, 0, 0], [0, -1, 0], [0, 0, -1]])
276+
277+
axis_transform = np.eye(4)
278+
axis_transform[:3, :3] = rotation_matrix
279+
axis_transform[:3, 3] = position
280+
axis_node.matrix = axis_transform
281+
229282
super(PyrenderViewer, self).on_draw()
230283

231284
self._redraw = False
@@ -260,6 +313,19 @@ def on_key_press(self, symbol, modifiers, *args, **kwargs):
260313
self._redraw = True
261314
return True
262315

316+
# Handle 'j' key for joint axis toggle
317+
from pyglet.window import key
318+
if symbol == key.J:
319+
# Toggle joint axis display mode
320+
self.show_joint_axes = not self.show_joint_axes
321+
self._toggle_joint_axes()
322+
323+
mode_text = "on" if self.show_joint_axes else "off"
324+
print(f"Joint axes display: {mode_text}")
325+
326+
self._redraw = True
327+
return True
328+
263329
self._redraw = True
264330
return super(PyrenderViewer, self).on_key_press(symbol, modifiers, *args, **kwargs)
265331

@@ -311,6 +377,9 @@ def add(self, geometry):
311377
links = [geometry]
312378
elif isinstance(geometry, model_module.CascadedLink):
313379
links = geometry.link_list
380+
# Store robot for joint axis toggle
381+
if geometry not in self._stored_robots:
382+
self._stored_robots.append(geometry)
314383
else:
315384
raise TypeError('geometry must be Link or CascadedLink')
316385

@@ -345,6 +414,167 @@ def delete(self, geometry):
345414
all_links.extend(link.child_links)
346415
self._redraw = True
347416

417+
def add_joint_axis(self, joint, sphere_radius=0.01, axis_length=0.1,
418+
axis_radius=0.003, axis_color=None):
419+
"""Add joint axis visualization to the scene.
420+
421+
Visualizes the joint position (world_position) as a sphere and
422+
the joint axis (world_axis) as a cylinder.
423+
424+
Parameters
425+
----------
426+
joint : Joint
427+
Joint object to visualize
428+
sphere_radius : float, optional
429+
Radius of the sphere representing the joint position.
430+
Default is 0.01.
431+
axis_length : float, optional
432+
Length of the cylinder representing the joint axis.
433+
Default is 0.1.
434+
axis_radius : float, optional
435+
Radius of the cylinder representing the joint axis.
436+
Default is 0.003.
437+
axis_color : array-like, optional
438+
RGBA color for the axis cylinder. Default is [1.0, 0.0, 0.0, 1.0] (red).
439+
440+
Returns
441+
-------
442+
None
443+
444+
Examples
445+
--------
446+
>>> from skrobot.viewers import PyrenderViewer
447+
>>> from skrobot.models import PR2
448+
>>> viewer = PyrenderViewer()
449+
>>> robot = PR2()
450+
>>> viewer.add(robot)
451+
>>> viewer.add_joint_axis(robot.r_shoulder_pan_joint)
452+
>>> viewer.show()
453+
"""
454+
from skrobot.model import Joint
455+
456+
if not isinstance(joint, Joint):
457+
raise TypeError('joint must be a Joint object')
458+
459+
if axis_color is None:
460+
axis_color = [1.0, 0.0, 0.0, 1.0]
461+
462+
with self._render_lock:
463+
joint_id = str(id(joint))
464+
position = joint.world_position
465+
axis = joint.world_axis
466+
467+
# Create sphere for joint position
468+
sphere_mesh = trimesh.creation.uv_sphere(radius=sphere_radius)
469+
sphere_mesh.visual.vertex_colors = [100, 100, 255, 255] # Blue color
470+
pyrender_sphere = pyrender.Mesh.from_trimesh(sphere_mesh, smooth=False)
471+
472+
sphere_transform = np.eye(4)
473+
sphere_transform[:3, 3] = position
474+
sphere_node = self.scene.add(pyrender_sphere, pose=sphere_transform)
475+
476+
# Create cylinder for joint axis
477+
axis_node = None
478+
if axis is not None:
479+
cylinder_mesh = trimesh.creation.cylinder(
480+
radius=axis_radius,
481+
height=axis_length,
482+
sections=16
483+
)
484+
cylinder_mesh.visual.vertex_colors = [
485+
int(axis_color[0] * 255),
486+
int(axis_color[1] * 255),
487+
int(axis_color[2] * 255),
488+
int(axis_color[3] * 255)
489+
]
490+
pyrender_cylinder = pyrender.Mesh.from_trimesh(cylinder_mesh, smooth=False)
491+
492+
# Calculate rotation matrix to align cylinder with axis
493+
z_axis = np.array([0, 0, 1])
494+
axis_normalized = axis / np.linalg.norm(axis)
495+
496+
rotation_axis = np.cross(z_axis, axis_normalized)
497+
rotation_axis_norm = np.linalg.norm(rotation_axis)
498+
499+
if rotation_axis_norm > 1e-6:
500+
rotation_axis = rotation_axis / rotation_axis_norm
501+
angle = np.arccos(np.clip(np.dot(z_axis, axis_normalized), -1.0, 1.0))
502+
K = np.array([
503+
[0, -rotation_axis[2], rotation_axis[1]],
504+
[rotation_axis[2], 0, -rotation_axis[0]],
505+
[-rotation_axis[1], rotation_axis[0], 0]
506+
])
507+
rotation_matrix = np.eye(3) + np.sin(angle) * K + (1 - np.cos(angle)) * (K @ K)
508+
else:
509+
if np.dot(z_axis, axis_normalized) > 0:
510+
rotation_matrix = np.eye(3)
511+
else:
512+
rotation_matrix = np.array([[-1, 0, 0], [0, -1, 0], [0, 0, -1]])
513+
514+
axis_transform = np.eye(4)
515+
axis_transform[:3, :3] = rotation_matrix
516+
axis_transform[:3, 3] = position
517+
axis_node = self.scene.add(pyrender_cylinder, pose=axis_transform)
518+
519+
# Store in joint axis map
520+
self._joint_axis_map[joint_id] = (sphere_node, axis_node, joint)
521+
522+
self._redraw = True
523+
524+
def delete_joint_axis(self, joint):
525+
"""Delete joint axis visualization from the scene.
526+
527+
Parameters
528+
----------
529+
joint : Joint
530+
Joint object whose axis visualization should be deleted
531+
532+
Returns
533+
-------
534+
None
535+
536+
Examples
537+
--------
538+
>>> from skrobot.viewers import PyrenderViewer
539+
>>> from skrobot.models import PR2
540+
>>> viewer = PyrenderViewer()
541+
>>> robot = PR2()
542+
>>> viewer.add(robot)
543+
>>> viewer.add_joint_axis(robot.r_shoulder_pan_joint)
544+
>>> viewer.show()
545+
>>> viewer.delete_joint_axis(robot.r_shoulder_pan_joint)
546+
"""
547+
with self._render_lock:
548+
joint_id = str(id(joint))
549+
if joint_id in self._joint_axis_map:
550+
sphere_node, axis_node, _ = self._joint_axis_map[joint_id]
551+
self.scene.remove_node(sphere_node)
552+
if axis_node is not None:
553+
self.scene.remove_node(axis_node)
554+
self._joint_axis_map.pop(joint_id)
555+
self._redraw = True
556+
557+
def _toggle_joint_axes(self):
558+
"""Toggle joint axes display for all stored robots."""
559+
if self.show_joint_axes:
560+
# Add joint axes for all robots
561+
for robot in self._stored_robots:
562+
for joint in robot.joint_list:
563+
# Skip if already added
564+
if str(id(joint)) not in self._joint_axis_map:
565+
self.add_joint_axis(
566+
joint,
567+
sphere_radius=0.015,
568+
axis_length=0.2,
569+
axis_radius=0.005,
570+
axis_color=[1.0, 0.0, 0.0, 1.0]
571+
)
572+
else:
573+
# Remove all joint axes
574+
joints_to_remove = list(self._joint_axis_map.values())
575+
for sphere_node, axis_node, joint in joints_to_remove:
576+
self.delete_joint_axis(joint)
577+
348578
def set_camera(self, angles=None, distance=None, center=None,
349579
resolution=None, fov=None, coords_or_transform=None):
350580
if angles is None and coords_or_transform is None:

0 commit comments

Comments
 (0)