1111
1212from __future__ import annotations
1313
14- import os
1514from dataclasses import dataclass , field
1615
1716
@@ -64,29 +63,34 @@ def joint_names(self) -> list[str]:
6463
6564
6665def _validate_kinematic_tree (robot : ProceduralRobot ) -> None :
67- """Phase-1 fail-fast guard: reject duplicate (parent, child) body edges.
68-
69- Phase-1 callers don't instantiate the articulation, so this is a no-op at
70- builder time UNLESS ``STRANDS_ISAAC_VALIDATE_KINEMATICS=1`` is set
71- (Phase-2 dev path). When the guard fires, it surfaces the defect with
72- body indices + joint names rather than letting USD/MuJoCo emit a cryptic
73- articulation error two layers down.
74-
75- See the NOTE in ``_build_unitree_g1`` for the specific defect this is
76- designed to catch (G1 legs/arms currently share a single ``(parent,
77- child)`` edge across two joint axes; USD requires intermediate massless
78- link bodies).
66+ """Fail-fast guard: reject duplicate (parent, child) body edges.
67+
68+ A USD/MuJoCo articulation requires a tree where each non-root link has
69+ exactly one inbound joint. Two joints sharing the same ``(parent_body,
70+ child_body)`` edge violate that invariant and would surface two layers
71+ down as a cryptic articulation error at instantiation time.
72+
73+ Validation runs unconditionally on every procedural builder + every
74+ URDF / MJCF / USD loader: shipping a robot we know cannot instantiate
75+ has no good use case in this package, and silently producing one is
76+ worse than failing fast at builder time. The check raises ``ValueError``
77+ with body indices + joint names so the offender is obvious from the
78+ traceback alone.
79+
80+ For 2-DOF compound joints (e.g. a hip with both roll and pitch axes),
81+ insert an intermediate massless link body between the two joints so each
82+ joint has its own ``(parent, child)`` edge. ``_build_unitree_g1`` is the
83+ canonical example -- six ``*_link`` intermediate bodies split the
84+ hip / ankle / arm 2-DOF axes.
7985 """
80- if os .environ .get ("STRANDS_ISAAC_VALIDATE_KINEMATICS" , "" ).lower () not in ("1" , "true" , "yes" ):
81- return
8286 from collections import Counter
8387
8488 edges = [(j .parent_body , j .child_body ) for j in robot .joints ]
8589 dups = {edge : count for edge , count in Counter (edges ).items () if count > 1 }
8690 if dups :
8791 offenders = {edge : [j .name for j in robot .joints if (j .parent_body , j .child_body ) == edge ] for edge in dups }
8892 raise ValueError (
89- f"{ robot .name } : duplicate parent->child body edges (Phase-2 defect) : { offenders } . "
93+ f"{ robot .name } : duplicate parent->child body edges: { offenders } . "
9094 f"Insert intermediate massless link bodies before instantiating articulation."
9195 )
9296
@@ -170,7 +174,15 @@ def _build_panda() -> ProceduralRobot:
170174
171175
172176def _build_unitree_g1 () -> ProceduralRobot :
173- """Build Unitree G1 humanoid (simplified 21-DOF) procedurally."""
177+ """Build Unitree G1 humanoid (simplified 21-DOF) procedurally.
178+
179+ The G1 has six 2-DOF compound joints (hips, ankles, shoulder/elbow on each
180+ arm). To keep the kinematic graph a valid tree -- one inbound joint per
181+ non-root link, as USD / MuJoCo articulations require -- each compound
182+ joint is split through a massless intermediate ``*_link`` body. The two
183+ axes still resolve to two actuated joints (so ``num_joints == 21``); only
184+ the topology gains the six extra fixed link bodies.
185+ """
174186 bodies = [
175187 BodyDef (name = "pelvis" , position = (0.0 , 0.0 , 0.85 ), mass = 10.0 , shape = "box" , shape_size = (0.15 , 0.1 , 0.15 )),
176188 BodyDef (name = "torso" , position = (0.0 , 0.0 , 1.1 ), mass = 8.0 , shape = "box" , shape_size = (0.12 , 0.08 , 0.3 )),
@@ -193,59 +205,60 @@ def _build_unitree_g1() -> ProceduralRobot:
193205 BodyDef (name = "r_shoulder" , position = (0.2 , 0.0 , 1.2 ), mass = 1.5 , shape = "sphere" , shape_size = (0.04 ,)),
194206 BodyDef (name = "r_upper_arm" , position = (0.35 , 0.0 , 1.2 ), mass = 1.5 , shape = "capsule" , shape_size = (0.03 , 0.12 )),
195207 BodyDef (name = "r_forearm" , position = (0.55 , 0.0 , 1.2 ), mass = 1.0 , shape = "capsule" , shape_size = (0.025 , 0.1 )),
208+ # Massless intermediate link bodies so 2-DOF compound joints (hips,
209+ # ankles, shoulder-yaw/elbow) each have a unique (parent, child) edge.
210+ # Indices 17..22.
211+ BodyDef (name = "l_hip_link" , position = (- 0.08 , 0.0 , 0.7 ), mass = 0.0 , shape = "sphere" , shape_size = (0.001 ,)),
212+ BodyDef (name = "r_hip_link" , position = (0.08 , 0.0 , 0.7 ), mass = 0.0 , shape = "sphere" , shape_size = (0.001 ,)),
213+ BodyDef (name = "l_ankle_link" , position = (- 0.08 , 0.0 , 0.1 ), mass = 0.0 , shape = "sphere" , shape_size = (0.001 ,)),
214+ BodyDef (name = "r_ankle_link" , position = (0.08 , 0.0 , 0.1 ), mass = 0.0 , shape = "sphere" , shape_size = (0.001 ,)),
215+ BodyDef (name = "l_elbow_link" , position = (- 0.45 , 0.0 , 1.2 ), mass = 0.0 , shape = "sphere" , shape_size = (0.001 ,)),
216+ BodyDef (name = "r_elbow_link" , position = (0.45 , 0.0 , 1.2 ), mass = 0.0 , shape = "sphere" , shape_size = (0.001 ,)),
196217 ]
197218
198- # Simplified joint set (21 DOF total: 1 torso + 6 left leg + 6 right leg + 4 left arm + 4 right arm).
199- # NOTE: this kinematic graph contains duplicate (parent, child) edges on each leg/arm
200- # (e.g. l_hip_roll and l_hip_pitch both map bodies 3 -> 4). A real USD/MuJoCo articulation
201- # builder requires a tree where each non-root link has exactly one inbound joint, so this
202- # topology will need intermediate massless link bodies before Phase 2 wires up the actual
203- # USD prim chain. Tracked as Phase-2 work; the Phase-1 skeleton does not instantiate the
204- # articulation, so the duplicate-edge defect is dormant on this branch.
205- #
206- # ``_validate_kinematic_tree`` (called below) is a no-op by default but raises when
207- # ``STRANDS_ISAAC_VALIDATE_KINEMATICS=1`` is set, which is the Phase-2 dev path: it
208- # surfaces this defect at builder time with body indices + joint names rather than
209- # letting USD/MuJoCo emit a cryptic articulation error two layers down.
219+ # 21 DOF total: 1 torso + 6 left leg + 6 right leg + 4 left arm + 4 right arm.
220+ # Each 2-DOF compound joint is split through a massless intermediate body
221+ # (indices 17..22) so every (parent, child) edge in the kinematic tree is
222+ # unique -- the invariant ``_validate_kinematic_tree`` enforces below.
210223 joints = [
211224 # Torso
212225 JointDef (name = "torso_yaw" , parent_body = 0 , child_body = 1 , axis = (0 , 0 , 1 ), limit_lower = - 1.0 , limit_upper = 1.0 ),
213- # Left leg (6 DOF)
226+ # Left leg (6 DOF). Hip 2-DOF split via l_hip_link (17); ankle 2-DOF split via l_ankle_link (19).
214227 JointDef (name = "l_hip_yaw" , parent_body = 0 , child_body = 3 , axis = (0 , 0 , 1 ), limit_lower = - 0.5 , limit_upper = 0.5 ),
215- JointDef (name = "l_hip_roll" , parent_body = 3 , child_body = 4 , axis = (1 , 0 , 0 ), limit_lower = - 0.5 , limit_upper = 0.5 ),
216- JointDef (name = "l_hip_pitch" , parent_body = 3 , child_body = 4 , axis = (0 , 1 , 0 ), limit_lower = - 1.5 , limit_upper = 0.5 ),
228+ JointDef (name = "l_hip_roll" , parent_body = 3 , child_body = 17 , axis = (1 , 0 , 0 ), limit_lower = - 0.5 , limit_upper = 0.5 ),
229+ JointDef (name = "l_hip_pitch" , parent_body = 17 , child_body = 4 , axis = (0 , 1 , 0 ), limit_lower = - 1.5 , limit_upper = 0.5 ),
217230 JointDef (name = "l_knee" , parent_body = 4 , child_body = 5 , axis = (0 , 1 , 0 ), limit_lower = - 0.1 , limit_upper = 2.5 ),
218- JointDef (name = "l_ankle_pitch" , parent_body = 5 , child_body = 6 , axis = (0 , 1 , 0 ), limit_lower = - 0.8 , limit_upper = 0.5 ),
219- JointDef (name = "l_ankle_roll" , parent_body = 5 , child_body = 6 , axis = (1 , 0 , 0 ), limit_lower = - 0.3 , limit_upper = 0.3 ),
220- # Right leg (6 DOF)
231+ JointDef (name = "l_ankle_pitch" , parent_body = 5 , child_body = 19 , axis = (0 , 1 , 0 ), limit_lower = - 0.8 , limit_upper = 0.5 ),
232+ JointDef (name = "l_ankle_roll" , parent_body = 19 , child_body = 6 , axis = (1 , 0 , 0 ), limit_lower = - 0.3 , limit_upper = 0.3 ),
233+ # Right leg (6 DOF). Hip 2-DOF split via r_hip_link (18); ankle 2-DOF split via r_ankle_link (20).
221234 JointDef (name = "r_hip_yaw" , parent_body = 0 , child_body = 7 , axis = (0 , 0 , 1 ), limit_lower = - 0.5 , limit_upper = 0.5 ),
222- JointDef (name = "r_hip_roll" , parent_body = 7 , child_body = 8 , axis = (1 , 0 , 0 ), limit_lower = - 0.5 , limit_upper = 0.5 ),
223- JointDef (name = "r_hip_pitch" , parent_body = 7 , child_body = 8 , axis = (0 , 1 , 0 ), limit_lower = - 1.5 , limit_upper = 0.5 ),
235+ JointDef (name = "r_hip_roll" , parent_body = 7 , child_body = 18 , axis = (1 , 0 , 0 ), limit_lower = - 0.5 , limit_upper = 0.5 ),
236+ JointDef (name = "r_hip_pitch" , parent_body = 18 , child_body = 8 , axis = (0 , 1 , 0 ), limit_lower = - 1.5 , limit_upper = 0.5 ),
224237 JointDef (name = "r_knee" , parent_body = 8 , child_body = 9 , axis = (0 , 1 , 0 ), limit_lower = - 0.1 , limit_upper = 2.5 ),
225- JointDef (name = "r_ankle_pitch" , parent_body = 9 , child_body = 10 , axis = (0 , 1 , 0 ), limit_lower = - 0.8 , limit_upper = 0.5 ),
226- JointDef (name = "r_ankle_roll" , parent_body = 9 , child_body = 10 , axis = (1 , 0 , 0 ), limit_lower = - 0.3 , limit_upper = 0.3 ),
227- # Left arm (4 DOF simplified)
238+ JointDef (name = "r_ankle_pitch" , parent_body = 9 , child_body = 20 , axis = (0 , 1 , 0 ), limit_lower = - 0.8 , limit_upper = 0.5 ),
239+ JointDef (name = "r_ankle_roll" , parent_body = 20 , child_body = 10 , axis = (1 , 0 , 0 ), limit_lower = - 0.3 , limit_upper = 0.3 ),
240+ # Left arm (4 DOF simplified). Shoulder-yaw / elbow 2-DOF split via l_elbow_link (21).
228241 JointDef (
229242 name = "l_shoulder_pitch" , parent_body = 1 , child_body = 11 , axis = (0 , 1 , 0 ), limit_lower = - 3.14 , limit_upper = 1.0
230243 ),
231244 JointDef (
232245 name = "l_shoulder_roll" , parent_body = 11 , child_body = 12 , axis = (1 , 0 , 0 ), limit_lower = - 0.3 , limit_upper = 3.14
233246 ),
234247 JointDef (
235- name = "l_shoulder_yaw" , parent_body = 12 , child_body = 13 , axis = (0 , 0 , 1 ), limit_lower = - 1.5 , limit_upper = 1.5
248+ name = "l_shoulder_yaw" , parent_body = 12 , child_body = 21 , axis = (0 , 0 , 1 ), limit_lower = - 1.5 , limit_upper = 1.5
236249 ),
237- JointDef (name = "l_elbow" , parent_body = 12 , child_body = 13 , axis = (0 , 1 , 0 ), limit_lower = - 2.5 , limit_upper = 0.0 ),
238- # Right arm (4 DOF simplified)
250+ JointDef (name = "l_elbow" , parent_body = 21 , child_body = 13 , axis = (0 , 1 , 0 ), limit_lower = - 2.5 , limit_upper = 0.0 ),
251+ # Right arm (4 DOF simplified). Shoulder-yaw / elbow 2-DOF split via r_elbow_link (22).
239252 JointDef (
240253 name = "r_shoulder_pitch" , parent_body = 1 , child_body = 14 , axis = (0 , 1 , 0 ), limit_lower = - 3.14 , limit_upper = 1.0
241254 ),
242255 JointDef (
243256 name = "r_shoulder_roll" , parent_body = 14 , child_body = 15 , axis = (1 , 0 , 0 ), limit_lower = - 3.14 , limit_upper = 0.3
244257 ),
245258 JointDef (
246- name = "r_shoulder_yaw" , parent_body = 15 , child_body = 16 , axis = (0 , 0 , 1 ), limit_lower = - 1.5 , limit_upper = 1.5
259+ name = "r_shoulder_yaw" , parent_body = 15 , child_body = 22 , axis = (0 , 0 , 1 ), limit_lower = - 1.5 , limit_upper = 1.5
247260 ),
248- JointDef (name = "r_elbow" , parent_body = 15 , child_body = 16 , axis = (0 , 1 , 0 ), limit_lower = - 2.5 , limit_upper = 0.0 ),
261+ JointDef (name = "r_elbow" , parent_body = 22 , child_body = 16 , axis = (0 , 1 , 0 ), limit_lower = - 2.5 , limit_upper = 0.0 ),
249262 ]
250263
251264 robot = ProceduralRobot (name = "unitree_g1" , bodies = bodies , joints = joints , base_position = (0.0 , 0.0 , 0.85 ))
0 commit comments