@@ -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