@@ -319,6 +319,27 @@ def _link_index(body_id, link_name):
319319 return None
320320
321321
322+ def _compute_force (
323+ contact_pnts : typing .List ,
324+ ) -> typing .Tuple [float , typing .List [float ]]:
325+ mean_x = 0.0
326+ mean_y = 0.0
327+ mean_z = 0.0
328+ total_force = 0.0
329+ for cp in contact_pnts :
330+ # tuple fields (relevant):
331+ # cp[4]=posOnA (world xyz), cp[5]=posOnB, cp[6]=normalOnB (world xyz),
332+ # cp[7]=distance, cp[8]=normalForce
333+ posB = cp [6 ]
334+ fN = cp [9 ] # Newtons
335+ mean_x += posB [0 ]
336+ mean_y += posB [1 ]
337+ mean_z += posB [2 ]
338+ total_force += fN
339+
340+ return total_force , [mean_x , mean_y , mean_z ]
341+
342+
322343class Simulator :
323344 """
324345 Thin PyBullet wrapper for loading a robot URDF and driving it from
@@ -381,6 +402,15 @@ def __init__(self, dt, path_to_robot_urdf: Path, model, launch_gui=True):
381402 flags = pb .URDF_MERGE_FIXED_LINKS ,
382403 )
383404
405+ # Find the joint that matches the right and left foot. It will be used to compute contact forces
406+ for j in range (pb .getNumJoints (self .robot_id )):
407+ info = pb .getJointInfo (self .robot_id , j )
408+ link_name = info [12 ].decode ("ascii" )
409+ if link_name == model .get_lf_link_name ():
410+ self .rf_link_id = info [0 ]
411+ elif link_name == model .get_rf_link_name ():
412+ self .lf_link_id = info [0 ]
413+
384414 # Build mapping between joints for both velocities and position
385415 self .pb_to_pin_joint_vel = _build_pb_to_pin_joint_vel_vmap (self .robot_id , model .model )
386416 self .pb_to_pin_joints_pos = _build_pb_to_pin_joints_map (self .robot_id , model )
@@ -472,67 +502,54 @@ def update_camera_to_follow_pos(self, x: float, y: float, z: float):
472502 cameraTargetPosition = [x , y , z ],
473503 )
474504
475- def draw_contact_forces (self , color = ( 0 , 1 , 0 ), scale = 0.002 ) :
505+ def get_contact_forces (self ) -> typing . Tuple [ float , float ] :
476506 """
477- Render an averaged ground-reaction normal as a debug line .
507+ Return the net x-axis contact forces for the right and left foot .
478508
479- Parameters
480- ----------
481- color : tuple[float, float, float], default (0, 1, 0)
482- RGB color in [0,1].
483- scale : float, default 0.002
484- Visual scale factor converting Newtons to line length.
509+ The method queries PyBullet for contacts between the robot and the ground
510+ on each foot link, then uses the private helper `_compute_force(...)`
511+ to aggregate the force component along the world x-axis.
512+
513+ Requirements
514+ ------------
515+ - `self.robot_id` : int
516+ PyBullet body id of the robot.
517+ - `self.plane_id` : int
518+ PyBullet body id of the ground/plane.
519+ - `self.rf_link_id` : int
520+ Link index of the right foot.
521+ - `self.lf_link_id` : int
522+ Link index of the left foot.
523+ - `_compute_force(contacts) -> tuple[float, Any]`
524+ Helper that returns the x-axis force [N] as its first element.
525+ It should return 0.0 if `contacts` is empty.
526+
527+ Returns
528+ -------
529+ (float, float)
530+ `(Fx_right, Fx_left)` in Newtons, world frame.
485531
486532 Notes
487533 -----
488- - Aggregates all robot–plane contacts, averages contact locations,
489- sums normal forces, and draws a single arrow along the last
490- observed contact normal.
491- - Updates a persistent debug line if it already exists.
534+ - Only contacts with `bodyA=self.robot_id`, `bodyB=self.plane_id`,
535+ and `linkIndexA` equal to the corresponding foot link are considered.
536+ - If a foot has no active contacts, its returned force is expected to be 0.0.
537+ - This method assumes `_compute_force` already applies the correct
538+ Newton third-law sign convention to yield the force exerted on the robot.
492539 """
493- # iterate all foot links if you want per-foot colors
494- cps_all = []
495-
496- cps_all .extend (pb .getContactPoints (bodyA = self .robot_id , bodyB = self .plane_id ))
497-
498- mean_x = 0.0
499- mean_y = 0.0
500- mean_z = 0.0
501- total_force = 0.0
502- n_B = [0.0 , 0.0 , 0.0 ]
503- for cp in cps_all :
504- # tuple fields (relevant):
505- # cp[4]=posOnA (world xyz), cp[5]=posOnB, cp[6]=normalOnB (world xyz),
506- # cp[7]=distance, cp[8]=normalForce
507- posB = cp [6 ]
508- n_B = cp [7 ]
509- fN = cp [9 ] # Newtons
510- mean_x += posB [0 ]
511- mean_y += posB [1 ]
512- mean_z += posB [2 ]
513- total_force += fN
514-
515- if len (cps_all ) is not 0 :
516- start = [mean_x / len (cps_all ), mean_y / len (cps_all ), mean_z / len (cps_all )]
517- end = (
518- start [0 ] + n_B [0 ] * total_force * scale ,
519- start [1 ] + n_B [1 ] * total_force * scale ,
520- start [2 ] + n_B [2 ] * total_force * scale ,
540+ rf_force , _ = _compute_force (
541+ pb .getContactPoints (
542+ bodyA = self .robot_id , bodyB = self .plane_id , linkIndexA = self .rf_link_id
521543 )
544+ )
522545
523- # arrow for normal force
524- if self ._displayed_lines is None :
525- self ._displayed_lines = pb .addUserDebugLine (
526- start , end , lineColorRGB = color , lineWidth = 3
527- )
528- else :
529- pb .addUserDebugLine (
530- start ,
531- end ,
532- lineColorRGB = color ,
533- lineWidth = 3 ,
534- replaceItemUniqueId = self ._displayed_lines ,
535- )
546+ lf_force , _ = _compute_force (
547+ pb .getContactPoints (
548+ bodyA = self .robot_id , bodyB = self .plane_id , linkIndexA = self .lf_link_id
549+ )
550+ )
551+
552+ return rf_force , lf_force
536553
537554 def draw_points (
538555 self ,
0 commit comments