Skip to content

Support PickUp on articulated Rubik's cube asset - #632

Merged
Yuan-Xinyi merged 9 commits into
mainfrom
xinyi/atomic03
Sep 24, 2026
Merged

Yuan-Xinyi merged 9 commits into
mainfrom
xinyi/atomic03

Conversation

@Yuan-Xinyi

@Yuan-Xinyi Yuan-Xinyi commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Description

Adds PickUp support for the rubiks_cube_001 asset and documents the articulated-target path.

The asset is an articulation, not a rigid body: a top_turn revolute joint (Z axis, ±90°) couples top_layer to lower_two_layers. Per the agreed scope the joint is locked with a stiff position drive so the cube grasps as one body; turning it via the existing Twist skill is out of scope here.

Three constraints drove the implementation. Each was found by running the simulation, not by reading code:

  1. The USD parser rejects the asset as a rigid objectExpected exactly one rigid object ... found 0, because both bodies sit under an ArticulationRoot. MeshCfg is not viable; the cube is spawned through ArticulationCfg.
  2. ArticulationCfg fixes roots to the world by default. That suits drawers and doors but welds a graspable target in place. Left at the default, plan_success still reports success and the entire trajectory replays while the cube never moves — the first run measured lifted by = +0.00000 m. Fixed with root_props=ArticulationRootPropertiesCfg(fixed_base=False), and pinned by a test, since a regression here fails silently.
  3. make_clear_dynamics_callback takes a RigidObject. Applying it to this floating-base articulation clears the root velocity exactly as the lift begins: the grasp breaks and the cube drops back after rising ~3 mm. The articulation needs no freeze.

Articulation exposes no get_vertices() / get_triangles(), so AntipodalAffordance sampling is unavailable. The grasp pose is supplied explicitly, reusing the robot's current end-effector orientation and replacing only the translation — the same idiom as stack_blocks_two.py.

Known asset defect, compensated here

The asset carries a Scene Engine canonicalization bug: _canonicalize_articulated_usdc_bottom_center computes bottom-center with minimum[1], hardcoding Y as the up axis, while EmbodiChain stages are Z-up (this asset declares upAxis: Z and gravity (0, 0, -1)). The cube therefore sits half an edge off in Y and straddles z = 0 instead of resting on it. The tutorial compensates via CUBE_BOTTOM_CENTER_OFFSET, which lands the bottom face at z = 0.000.

This affects every articulated USDC the pipeline produces, not just this asset. It is a generator fix and is deliberately not bundled into this PR.

Dependencies: none.

Type of change

  • New feature (non-breaking change which adds functionality)
  • Documentation update

Screenshots

PickUp on the articulated Rubik's cube, top_turn locked. Grasp sampled from the lower_two_layers link by the parallel-jaw generator (recorded from the run measured below):

PickUp on an articulated Rubik's cube

Checklist

  • I have run the black . command to format the code base (black==26.3.1, clean)
  • I have made corresponding changes to the documentation (builtin_actions.md: new "Picking articulated targets" subsection under PickUp)
  • Public API changes are reflected in the API docs (python docs/scripts/check_api_docs.py -> 2069/2069; no public API added)
  • I have added tests that prove my feature works (tests/sim/test_pickup_rubiks_cube_tutorial.py, 6 passed)
  • Dependencies have been updated, if applicable (none)

Validation

Measured on an RTX 4090, --headless:

[settle]  cube center z = 0.03079 m   bottom z = 0.00199 m
[plan]    success = True
[after]   lifted by     = +0.14661 m
[after]   |top_turn| max during replay = 0.000005 rad
pytest tests/sim/test_pickup_rubiks_cube_tutorial.py   -> 6 passed
python docs/scripts/check_api_docs.py                  -> 2069/2069
black --check                                          -> clean

Note on verification: plan_success is not sufficient evidence for this skill. Both the fixed-root and the clear_dynamics failures above planned and replayed cleanly while the object stayed on the table. The numbers above come from measuring the cube's pose before and after the replay, and the recorded GIF is the same run.

Asset availability

rubiks_cube_001.usdc is not in the download registry — embodichain/data/assets/ entries are remote zip + MD5 datasets and cannot reference a local file. The tutorial defaults to $EMBODICHAIN_DEFAULT_DATA_ROOT/RubiksCube/rubiks_cube_001.usdc and accepts --asset_path. Publishing the asset to the DexForce index is a separate step.

🤖 Generated with Claude Code

The rubiks_cube_001 asset is an articulation, not a rigid body: a top_turn
revolute joint couples top_layer to lower_two_layers. Add a tutorial that
picks it up with the joint locked, plus focused tests and documentation of
the articulated-target path.

Three constraints drove the implementation, each verified in simulation:

* The USD parser rejects the asset as a rigid object ("Expected exactly one
  rigid object ... found 0"), because both bodies sit under an
  ArticulationRoot. It is spawned through ArticulationCfg.
* ArticulationCfg fixes roots to the world by default, which welds a
  graspable target in place. Left at the default, plan_success still reports
  success and the whole trajectory replays while the cube never moves.
* make_clear_dynamics_callback takes a RigidObject; applying it to this
  floating-base articulation clears the root velocity as the lift begins and
  breaks the grasp. The articulation needs no freeze.

Articulation exposes no mesh accessors, so antipodal sampling is unavailable
and the grasp pose is supplied explicitly, reusing the robot's current
end-effector orientation.

The asset also carries a Scene Engine canonicalization defect: its
bottom-center translate is authored on Y while stages are Z-up, so the cube
sits half an edge off in Y and straddles z=0. The tutorial compensates via
CUBE_BOTTOM_CENTER_OFFSET; the generator bug is tracked separately.

Verified on RTX 4090: cube settles on the table, lifts +0.147 m, and
|top_turn| stays under 1e-5 rad throughout the replay.

Co-Authored-By: Claude <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Sep 15, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

The implementation is close to mergeable, but the explicit repository testing requirement remains unsatisfied because no real-simulation regression proves that the cube lifts and its locked joint stays bounded.

Fix All in CodexFindings

  1. P2 Center offset ignores rotation
  2. P2 Tests skip physical behavior
Fix with agent prompt
### Issue 1
scripts/tutorials/atomic_action/pickup_rubiks_cube.py:undefined-202
`CUBE_BOTTOM_CENTER_OFFSET` is defined in the root frame, but this line adds it directly to the world translation. Because the floating articulation advances through physics before its pose is sampled, a rotated root produces an incorrect grasp center and can cause an off-center or failed grasp. Transform the offset by the root rotation before adding it.

### Issue 2
tests/sim/test_pickup_rubiks_cube_tutorial.py:undefined-23
Marking this feature test `no_sim` means its fakes only confirm that configuration calls were issued. The tests cannot detect the physical regressions this change addresses, including a fixed root, an ineffective joint lock, a broken grasp, or zero lift. This violates the repository directive that new features include focused tests proving their behavior, so a real-simulation test measuring post-replay elevation and bounding `top_turn` motion is required before merging.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

This PR adds end-to-end support for treating a locked, floating articulation as a graspable object, including root-frame grasp geometry, Task Program scene bindings, configured Rubik's-cube task resources, asset registration, policy support, documentation, and focused contract tests.

  • Validates that every articulation joint is physically held at its declared locked position before publishing immutable grasp geometry.
  • Projects rigidized articulations into Task Program object semantics while retaining articulation-specific settling behavior.
  • Registers and lazily resolves the Rubik's-cube asset for the tutorial and packaged task.
  • Preserves existing positional construction of SimulationSceneBinding while adding the new binding collections as keyword-only fields.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Asset[Rubik's-cube articulation] --> Lock[Validate joint lock]
  Lock --> Mesh[Transform grasp-link mesh into root frame]
  Mesh --> Semantics[Register articulation as SceneObjectRef]
  Semantics --> Pick[Compile and execute Pick]
  Pick --> Settle[Measure articulation body velocities]
  Settle --> Validate[Validate final root position]
Loading

Reviews (9) · Last reviewed commit: "fix(task-program): address rigidized art..."

Comment thread scripts/tutorials/atomic_action/pickup_rubiks_cube.py Outdated
offset = torch.tensor(
CUBE_BOTTOM_CENTER_OFFSET, dtype=pose.dtype, device=pose.device
)
pose[:, :3, 3] = pose[:, :3, 3] + offset

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Center offset ignores rotation

CUBE_BOTTOM_CENTER_OFFSET is defined in the root frame, but this line adds it directly to the world translation. Because the floating articulation advances through physics before its pose is sampled, a rotated root produces an incorrect grasp center and can cause an off-center or failed grasp. Transform the offset by the root rotation before adding it.

Knowledge Base Used: Simulation lab

Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/tutorials/atomic_action/pickup_rubiks_cube.py
Line: 191

Comment:
**Center offset ignores rotation**

`CUBE_BOTTOM_CENTER_OFFSET` is defined in the root frame, but this line adds it directly to the world translation. Because the floating articulation advances through physics before its pose is sampled, a rotated root produces an incorrect grasp center and can cause an off-center or failed grasp. Transform the offset by the root rotation before adding it.

**Knowledge Base Used:** [Simulation lab](https://app.greptile.com/dexforce/-/custom-context/knowledge-base/dexforce/embodichain/-/docs/simulation-lab.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code

Comment thread scripts/tutorials/atomic_action/pickup_rubiks_cube.py
from pathlib import Path
from types import ModuleType

import pytest

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests skip physical behavior

Marking this feature test no_sim means its fakes only confirm that configuration calls were issued. The tests cannot detect the physical regressions this change addresses, including a fixed root, an ineffective joint lock, a broken grasp, or zero lift. This violates the repository directive that new features include focused tests proving their behavior, so a real-simulation test measuring post-replay elevation and bounding top_turn motion is required before merging.

Context Used: CLAUDE.md (source)

Knowledge Base Used: Simulation lab

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/sim/test_pickup_rubiks_cube_tutorial.py
Line: 23

Comment:
**Tests skip physical behavior**

Marking this feature test `no_sim` means its fakes only confirm that configuration calls were issued. The tests cannot detect the physical regressions this change addresses, including a fixed root, an ineffective joint lock, a broken grasp, or zero lift. This violates the repository directive that new features include focused tests proving their behavior, so a real-simulation test measuring post-replay elevation and bounding `top_turn` motion is required before merging.

**Context Used:** CLAUDE.md ([source](https://github.com/dexforce/embodichain/blob/main/CLAUDE.md))

**Knowledge Base Used:** [Simulation lab](https://app.greptile.com/dexforce/-/custom-context/knowledge-base/dexforce/embodichain/-/docs/simulation-lab.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code

entity_id=cube.uid,
)
center_pose = cube_center_pose(cube)
grasp_pose = robot.compute_fk(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use grasp pose generator, similar as pickup example.

@@ -0,0 +1,196 @@
# ----------------------------------------------------------------------------

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently not necessary to add this unit test.

)
center_pose = cube_center_pose(cube)
grasp_pose = robot.compute_fk(
qpos=robot.get_qpos()[:, robot.get_joint_ids(name="arm")],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we use grasp pose generator for articulated object, the affordance (grasp pose) should be computed from a specified link. So we may also need to modified the Afforfance term to support sample grasp poses from link shape under this context.

Address review: use the shared parallel-jaw grasp pose generator instead of
an explicit end-effector pose, and drop the tutorial unit test.

Articulation has no whole-body get_vertices(); it publishes geometry per link
through get_link_vert_face(). Feeding the lower_two_layers mesh into
AntipodalAffordance lets the existing generator sample grasps exactly as it
does for rigid objects, which is the same source slide.py uses for its handle
affordance. No change to affordance.py was needed: AntipodalAffordance already
accepts arbitrary mesh vertices and triangles, and OpenDoorAffordance and
SlideAffordance already carry link meshes as its subclasses.

lower_two_layers is the top_turn joint's parent and carries two of the three
layers, so its link frame is the stable grasp reference while the joint is
held at zero.

Verified on RTX 4090 by instrumenting the shipped main() rather than a
replica: cube lifts +0.14647 m and |top_turn| stays at 5e-6 rad through the
replay. Demo GIF regenerated from that run.

Co-Authored-By: Claude <noreply@anthropic.com>
@Yuan-Xinyi

Copy link
Copy Markdown
Collaborator Author

Thanks @yuecideng — all three addressed in 833820f.

1. Use the grasp pose generator. Done. The tutorial now passes create_parallel_jaw_grasp_pose_generator to the engine and calls GraspGoal(semantics) with no explicit pose, matching pickup.py.

2. Drop the unit test. Removed.

3. Affordance from a specified link. Implemented, and it turned out not to need a change to affordance.py.

Articulation has no whole-body get_vertices(), but it does publish geometry per link via get_link_vert_face() (articulation.py:1480). Passing that mesh into AntipodalAffordance is enough for the existing generator to sample:

vertices, triangles = cube.get_link_vert_face("lower_two_layers")
ObjectSemantics(
    label="rubiks_cube",
    geometry={},
    affordance=AntipodalAffordance(
        mesh_vertices=torch.as_tensor(vertices),
        mesh_triangles=torch.as_tensor(triangles),
    ),
    entity_id=cube.uid,
)

This is the same source slide.py uses for its handle affordance. AntipodalAffordance already accepts arbitrary mesh vertices/triangles, and OpenDoorAffordance / SlideAffordance are already its subclasses carrying link meshes — so link-shaped sampling is an existing capability rather than a missing one. Happy to extend affordance.py if you had a specific case in mind that this does not cover.

I picked lower_two_layers as the grasp link: it is top_turn's parent and carries two of the three layers, so its frame is the stable reference while the joint is held at zero.

Verification. Measured by instrumenting the shipped main() rather than a replica — an earlier hand-written replica in this PR's history diverged and reported a lift the tutorial never performed:

[settle]  center z = 0.03079   bottom z = 0.00199   top_turn = 0.000005
[plan]    success = True
[after]   center z = 0.17726   lifted by = +0.14647 m
[trace]   |top_turn| max = 0.000005 rad

The demo GIF is regenerated from that same run.

One thing worth flagging for this skill: plan_success is not sufficient evidence. Two separate failure modes in this PR's history (fixed articulation root, and make_clear_dynamics_callback applied to a floating-base articulation) both planned and replayed cleanly while the cube stayed on the table. Only measuring the object pose caught them.

@yuecideng

Copy link
Copy Markdown
Contributor

Thanks for the detailed follow-up. I agree that this case does not require a new affordance type, but I think the frame contract needs to be made explicit before this becomes a reusable articulation path.

The supported abstraction should be:

A locked articulation is treated as one compound rigid body. The articulation root is the object identity and pose, while one selected link is only the source of grasp geometry.

The current implementation reads the mesh with get_link_vert_face(link_name), so the vertices are expressed in the selected link frame. However, ObjectSemantics.entity_id=cube.uid causes the grasp generator and scene provider to ground that geometry using the articulation root pose. This is correct only when T_root_link is identity. Locking the joint prevents future relative motion, but it does not make a non-identity root-to-link transform disappear.

I suggest the following implementation:

  1. Keep AntipodalAffordance simulator-independent and define its mesh as object-local geometry. Do not add an articulation handle or link_name to the affordance itself.
  2. Add a reusable articulation geometry adapter that takes the articulation, grasp_link, and declared locked qpos. It should read the link-local mesh, compute T_root_link through named FK at that configuration, and transform the vertices into the articulation-root frame before constructing AntipodalAffordance.
  3. Keep ObjectSemantics.entity_id equal to the articulation root UID, and keep the scene provider publishing the articulation root pose. This gives one consistent frame for grasp generation, HeldObjectState.object_to_eef, MoveHeldObject, Place, and symbolic effects.
  4. Generalize the public scene_entities type from Sequence[RigidObject] to a small structural protocol containing the actual requirements, such as uid and get_local_pose. Articulation support should be part of the declared API rather than accidental duck typing.
  5. Keep joint locking, initial qpos, fixed_base=False, and drive parameters in ArticulationCfg or physical-environment ownership. The affordance should own only root-local geometry.
  6. Validate the assumption explicitly: the selected link must be rigid relative to the root for the duration of Pick/Move/Place. If the joint can move, this compound-rigid path should reject it rather than silently using a stale mesh transform.

Conceptually, the integration API could look like:

semantics = create_rigidized_articulation_antipodal_semantics(
    cube,
    grasp_link="lower_two_layers",
    locked_qpos={"top_turn": 0.0},
    label="rubiks_cube",
)

The important part is not the helper name, but that its output mesh is in the articulation-root frame.

Please also add a focused unit test where the selected link has a non-identity rotation and translation relative to the root. That test should verify the transformed vertices or resulting world grasp pose. An identity-transform root-link case alone would not catch the current frame mismatch. Invalid link, malformed mesh, and unlocked or changing-joint cases should also be covered proportionally.

This boundary is also friendlier to Task Program. The program can continue to express Pick(rubiks_cube) using the articulation as one semantic object, while grasp_link and the locked configuration remain integration-owned metadata. A future SceneArticulationRef extension can therefore reuse the same Pick semantics without exposing simulator link details in program.yaml.

For this cube, lower_two_layers is a reasonable grasp source. The missing piece is transforming that link mesh into the root frame instead of relying on the two frames happening to coincide.

A locked articulation is one compound rigid body: the articulation root
carries object identity and pose, while one selected link is only the
source of grasp geometry. get_link_vert_face() returns vertices in the
link frame, but ObjectSemantics.entity_id names the articulation root, so
grounding applies the root pose to link-frame geometry. That is correct
only where T_root_link is identity. Locking the joint prevents future
relative motion; it does not remove a non-identity root-to-link
transform.

Add create_rigidized_articulation_antipodal_semantics(), which reads the
link mesh, evaluates the root-to-link transform at the declared locked
configuration, and transforms the vertices into the articulation-root
frame before constructing the affordance. AntipodalAffordance stays
simulator-independent and still owns only object-local geometry; no
articulation handle or link name reaches it.

The transform comes from named forward kinematics where a kinematic chain
exists. USD-backed articulations never build one (articulation.py only
builds pk_chain for URDF sources), so there the transform is read from
the link pose reported while the joints hold the declared configuration.
Both routes describe the same frame because the same root pose grounds
the entity.

Validate the compound-rigid assumption instead of trusting it: every
joint must be declared in locked_qpos and must actually be held there,
otherwise the call is rejected. A joint still free to move would leave
the mesh describing a pose the object no longer has, which fails as a
missed grasp rather than as an error. Joint locking, initial positions,
fixed_base and drive parameters remain owned by ArticulationCfg or the
physical environment.

Generalize the public scene_entities type from Sequence[RigidObject] to
the SceneEntity protocol, which declares the uid and get_local_pose()
that grounding actually requires. Articulation support becomes part of
the declared API rather than incidental duck typing.

Measured on the Rubik's cube asset: lower_two_layers has an exactly
identity root-to-link transform, so the previous code was correct for
this asset by coincidence. Rotating the joint to pi/2 and sourcing
top_layer moves a vertex by 28.3 mm on a 57.6 mm cube. The regression
test uses a non-identity rotation and translation, and fails if the
transform is removed; an identity-only case does not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Yuan-Xinyi

Copy link
Copy Markdown
Collaborator Author

You were right, and the measurement makes the case sharper than the code review alone does.

Measured on this asset. lower_two_layers has an exactly identity root-to-link transform (translation [0,0,0], |R−I|max = 0.00e+00), so the original code was correct here by coincidence, not by contract. Rotating top_turn to pi/2 and sourcing top_layer moves a single vertex by 28.3 mm on a 57.6 mm cube — half a cube, i.e. a missed grasp with no error raised. That is exactly the silent failure you described.

Implemented as specified:

  1. AntipodalAffordance untouched. It still owns only object-local geometry; no articulation handle and no link_name reach it.
  2. Articulation geometry adapter addedcreate_rigidized_articulation_antipodal_semantics(articulation, *, grasp_link, locked_qpos, label). It reads the link-local mesh, evaluates T_root_link at the declared locked configuration, and transforms the vertices into the articulation-root frame before constructing the affordance.
  3. entity_id stays the articulation root UID and the provider keeps publishing the root pose, so grasp generation, HeldObjectState.object_to_eef, MoveHeldObject, Place and symbolic effects share one frame.
  4. scene_entities generalized from Sequence[RigidObject] to a SceneEntity protocol declaring exactly what grounding uses — uid and get_local_pose(). Articulation support is now declared API rather than incidental duck typing.
  5. Locking stays with configuration. locked_qpos only declares the configuration the geometry is valid at; joint locking, initial qpos, fixed_base and drive parameters remain owned by ArticulationCfg / the physical environment.
  6. The assumption is validated, not trusted. Every joint must be declared and must actually be held at its declared value, otherwise the call is rejected rather than proceeding with a stale mesh transform.

One deviation, with a reason. You asked for T_root_link "through named FK". Articulation.compute_fk raises pk_chain is not initialized for this target: articulation.py:850 only builds the chain for URDF sources, and the cube is .usdc. The adapter therefore uses named FK where a chain exists and otherwise reads the link pose reported while the joints hold the declared configuration. Both land in the same frame, because the same root pose grounds the published entity — verified against a hand-computed root-frame mesh on the real asset (max deviation 0.0).

Teststests/sim/atomic_actions/test_rigidized_articulation_semantics.py, 10 cases. The primary one uses a non-identity rotation and translation (90° about z plus an offset) and asserts the transformed vertices. Your point that an identity-only case would not catch this is confirmed directly: removing the transform turns that test red while the other nine stay green. Also covered: root pose not leaking into object-local geometry, entity_id identity, protocol conformance, unknown link, undeclared joint, unknown declared joint, a joint away from its declared lock, and an empty link mesh.

pytest tests/sim/atomic_actions/ → 806 passed, 2 skipped. black clean, API docs 2073/2073, context map ok. The docs page now states the compound-rigid contract and shows the helper instead of the raw per-link read.

On the Task Program boundary — agreed, and that was the deciding argument for me. Keeping grasp_link and the locked configuration as integration-owned metadata means Pick(rubiks_cube) stays expressible against the articulation as one semantic object, and a future SceneArticulationRef can reuse the same Pick semantics without leaking simulator link names into program.yaml.

Comment thread embodichain/lab/sim/atomic_actions/sim_adapter.py Outdated
Comment thread embodichain/lab/sim/atomic_actions/sim_adapter.py Outdated
Yuan-Xinyi and others added 2 commits September 21, 2026 15:17
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
create_rigidized_articulation_antipodal_semantics publishes one immutable mesh
for the whole batch, but validated only arena 0 and only the joint's current
position. Two failures were therefore silent:

* An arena whose joint sat elsewhere passed validation and received grasp
  geometry that does not describe its own link pose, because the USD route also
  derived the shared root-to-link transform from row 0.
* A passive or zero-stiffness joint resting at the declared value passed, then
  moves under load while the immutable mesh keeps the old frame.

The lock check now covers every arena and every joint, and requires the joint
to be held there rather than merely resting: a position drive with non-zero
stiffness commanded to the declared value, or coincident position limits that
pin it. The live root-to-link route evaluates every arena and refuses a batch
whose arenas disagree, so one mesh is never published on arena 0's authority.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Yuan-Xinyi

Copy link
Copy Markdown
Collaborator Author

@yuecideng Rebased onto main and addressed the two remaining P1 findings on the adapter. Ready for another look.

Branch state

Merged main in at 637d24d. No conflicts, textual or semantic — but #644 (simulation-level Affordance sampling) rewrote affordance.py and pick_up.py under this PR, so I verified the overlap rather than trusting a clean merge: AntipodalAffordance(mesh_vertices=, mesh_triangles=), ObjectSemantics and the eleven tutorial_utils helpers this tutorial calls are all unchanged, and the PR's own diff is byte-identical before and after the merge.

Fixes in 8f3f55c

Both findings were the same root cause: one immutable mesh is published for the whole batch, while validation looked at a single row and a single quantity.

1. Validation covered only arena 0. _assert_link_is_rigid_to_root now checks every arena and every joint, and the error names the offending arena. The live USD route in _articulation_root_to_link evaluates the root-to-link transform per arena and refuses a batch whose arenas differ by more than link_transform_tolerance (default 1e-5), so one mesh is never published on arena 0's authority. The URDF route is unaffected: FK from the declared qpos is arena-independent.

2. Being at the declared position was treated as being locked. A passive or zero-stiffness joint can rest there and move under load while the mesh keeps the old frame. Every joint must now also be held, by either:

  • a position drive with non-zero stiffness whose target (get_qpos(target=True)) equals the declared value, or
  • coincident position limits that remove the degree of freedom outright.

I accepted the limit route deliberately — pinning through limits is a stronger lock than a drive, so requiring a drive would falsely reject it. The check reads stiffness, target and limits rather than DriveType / target mode, which keeps it portable across both backends and keeps dexsim out of the adapter.

The tutorial is unchanged: set_qpos() defaults to target=True, so lock_turn_joint already commands the drive target to 0, and get_qpos(target=True) reads fetch_joint_target_position live from the backend rather than a cached buffer.

builtin_actions.md now states the contract explicitly: resting at the value is not a lock, and the check spans the batch.

Validation

pytest tests/sim/atomic_actions/test_rigidized_articulation_semantics.py  -> 16 passed (10 + 6 new)
pytest tests/sim/atomic_actions/                                          -> 850 passed, 2 skipped
python docs/scripts/check_api_docs.py                                     -> 2180/2180 aligned
black --check .                                                           -> clean

Four of the six new tests were checked in the negative direction: reverting sim_adapter.py makes them fail with DID NOT RAISE, so they guard the regression instead of restating the new code. The other two pin the accept paths (arenas that agree, a joint pinned by its limits) so the check cannot later be tightened into a false rejection.

Still open

The two remaining greptile comments on the tutorial are about the asset rather than the adapter, and I'd rather settle them with you than guess:

  • pickup_rubiks_cube.py:201, "Center offset ignores rotation"CUBE_BOTTOM_CENTER_OFFSET compensates the Scene Engine's Y-up canonicalization bug and is added to the world translation without rotating it by the root. It is correct for this tutorial because the cube is spawned axis-aligned and settles without yawing, but it is not correct in general. Happy to rotate the offset by the root basis if you'd prefer the tutorial to be robust to a rotated spawn.
  • pickup_rubiks_cube.py:90, "Default asset is unavailable"rubiks_cube_001.usdc still isn't in the download registry, as noted in the PR description; embodichain/data/assets/ entries are remote zip + MD5 datasets and cannot point at a local file. Publishing the asset to the DexForce index is a separate step, and the tutorial raises a FileNotFoundError naming --asset_path.

@Yuan-Xinyi
Yuan-Xinyi merged commit a787c54 into main Sep 24, 2026
9 checks passed
@Yuan-Xinyi
Yuan-Xinyi deleted the xinyi/atomic03 branch September 24, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants