[FEATURE] IPC Coupler Basic Logic#2931
Conversation
…Main coupler.py (drift remains)
…erials/rigid, array_class, kinematic_solver, mesh, test_ipc done; rigid_solver/rigid_entity/forward_dynamics/options/solvers pending)
…rigid_solver, kernels added to forward_dynamics, utils.py from Genesis-Embodied-AI#2502
…egation rename
- Pin self.options.{joint_strength_ratio, enable_fem_fem_friction,
before_ipc_world_init, verbose_ipc_log} to safe defaults — these are
post-Genesis-Embodied-AI#2502 IPCCouplerOptions fields not in Genesis-Embodied-AI#2502.
- Replace entity.delegation (post-Genesis-Embodied-AI#2502 RigidEntity attribute) with
entity.material.coup_type == 'ipc_only' (Genesis-Embodied-AI#2502-equivalent test).
- Rename find_abd_merge_target -> find_target_link_for_fixed_merge to
match Genesis-Embodied-AI#2502's utils.py.
10 IPC tests passing (single_joint two_way_soft_constraint, needs_coup,
link_filter); 8 failing (ext_art, momentum, ground_clearance) — those
are likely RealMain post-Genesis-Embodied-AI#2502 logic still leaking, to investigate.
…I#2502's 'fixed-base ext_art transforms is None' test invariant The Genesis-Embodied-AI#2502 design had ipc_transforms=None for fixed-base ext_art because set_qpos for ext_art wasn't supported. RealMain extends set_qpos to all ABD coup types, which requires ipc_transforms to be populated uniformly. Drop the now-meaningless 'if fixed: assert is None' test branch. Define JOINT_STRENGTH_RATIO constant (was self.options.joint_strength_ratio in RealMain; Genesis-Embodied-AI#2502's IPCCouplerOptions doesn't have this field).
Without this, Genesis rigid solver integrates ipc_only entities under gravity (they have 6 DOFs from the implicit FREE joint), causing them to fall through IPC ground geometry. RealMain's _create_joints override zeros out DOFs for ipc_only's FREE joint so the constraint solver contributes nothing and IPC fully controls the dynamics. Fixes test_rigid_ground_sliding[0/2]. test_ipc_rigid_ground_clearance[0/2] still fails hairline numerical (4.78e-5 vs tol 5e-5).
- test_set_object_qpos[ipc_only]: use set_pos/set_quat (no DOFs after FIXED)
- test_find_target_links[two_way_soft_constraint]: pass coup_links=('link7',)
- test_robot_grasp_abd: use get_pos() not get_dofs_position() (0 DOFs)
- coupler._mark_ipc_abd_updated: accept int/slice for links_idx (set_pos
zerocopy path passes a single int)
RealMain defines apply_restitution_velocity but never calls it from rigid_solver.substep_post_coupling. Genesis-Embodied-AI#2502 wired it up to apply restitution velocity corrections after step_2, but those corrections caused the rigid body to bounce back harder than physically correct (-1.04 m/s vs -0.71 m/s in 4 m/s incident collision test). Removing the call brings cube-bounce velocity to within 3% of RealMain's output. The method is kept in place (matches RealMain's coupler content) but unused — future cleanup PR can decide whether to remove it. Effect on test_momentum_conservation: still fails by the same hairline margin (test sets restitution=0.0 which bypasses the accumulator anyway). The remaining ~0.001 momentum drift comes from a different source.
Drops apply_restitution_velocity + _accumulate_restitution_base_link method definitions + RESTITUTION_CONTACT_THRESHOLD + accumulator state + all callsites. The accumulator was wired by Genesis-Embodied-AI#2502 but RealMain disconnected it (defines but never calls), and the previous commit removed the rigid_solver.substep_post_coupling call site. Net effect: restitution=1.0 default is now a no-op for IPC; users relying on it for two-way coupling should rely on IPC's native contact resolution (which conserves momentum cleanly in test_momentum_conservation even with restitution=0.0).
…-frame log - test_cloth_uniform_biaxial_stretching: pre-compute phase-0/1/2 DOF targets per box (free-joint 6-DOF axis-angle, identity rotation), removing the per-phase `get_dofs_position()` reads. - coupler.couple: demote per-frame `[IPC] frame ...` log to debug. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
restitution: float = 1.0 contradicted the docstring's "Defaults to 0.0" and every test that explicitly sets it. The 1.0 value silently turned contacts perfectly elastic in any user code that constructed IPCCouplerOptions() without naming restitution. Match the docstring. ipc_momentum.py, ipc_objects_falling.py, ipc_robot_cloth_teleop.py and ipc_robot_grasp_cube.py still referenced removed IPCCouplerOptions fields (constraint_strength_translation/rotation, two_way_coupling). Pydantic strict-mode now rejects them. Move the soft-constraint strengths onto each entity's `gs.materials.Rigid(soft_constraint_strength=...)` and drop the obsolete `two_way_coupling=True` (coup_type is per-entity). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Correctness: - mark_abd_updated: route envs_idx through Scene._sanitize_envs_idx so torch bool masks / scalar ints / iterables all normalize to a torch int tensor. Short-circuit n_envs==0. Previously a bool mask flagged wrong envs as dirty (set(int(i) for i in bool_tensor) gives 0/1 not indices), and a scalar envs_idx=0 raised TypeError in the zero-copy fast path. - _post_advance_write_qpos Step 2b: cast ipc_qpos to qpos_tc.dtype as well as device. .to(device) alone left dtype unspecified on torch>=2.x. - cache_pre_prediction_transforms: drop the leftover DEBUG warning that fired every set_qpos/set_dofs_position (spammed teleop control loops). Cleanup / altitude: - Remove dead IPCBeforeWorldInitCallback machinery (~30 lines + 8 unused imports). The callback variable was assigned None then tested for None. - Add COUPLING_TYPE.auto_infer(entity) / .resolve(entity) classmethods; use them from IPCCoupler._setup_coupling_config and RigidCollider's self-collision filter. Previously the auto-infer rule lived in two source-of-truth copies that had to stay in lockstep. - Pin libuipc integrator to BDF1 in build_ipc_scene_config. The coupler's velocity back-compute and teleport-sync logic assume BDF1; this guard prevents a future libuipc default flip from silently breaking them. - Step 2a back-compute: rename child_quat_pre/pos_pre to child_cur_quat0 /child_cur_pos0 (and qloc/rotvec/axis/angle_ipc to child_cur_q/_rot/ /child_axis/_qaxis). The "_pre" suffix read as "previous timestep" but actually meant "child orientation/position with the joint at qpos==qpos0" — i.e. parent_current composed with the URDF/MJCF static offset. New names make the cancellation against FK's `qpos - qpos0` formula explicit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three small cleanups surfaced while answering code-review follow-ups: * Remove _sync_rigid_fk() and its sole caller. The call was introduced in RealMain commit 9fbff06 "primary fix" alongside a debug surface exporter (_export_genesis_surface) that needed an FK refresh before reading link/geom transforms. Our basic-logic slice stripped the exporter but left the FK call orphaned. rigid_solver.substep_post_coupling runs kernel_step_2 -> func_update_cartesian_space (= FK) immediately after, so this call was double work. 54 test_ipc.py tests still pass. * Replace the module-level _link_is_fixed_for_ipc(link) helper with an is_fixed: bool field on ABDLinkData, computed inline where the libuipc is_fixed attr is written and stored alongside the link's other ABD data. Drops a free function and three repeated dict-lookup-by-key patterns at the call sites. * Rename local variables _n_enabled / _n_disabled (contact pair counters in _setup_contact_pairs) to n_enabled / n_disabled. Leading-underscore on a local variable conventionally signals "intentionally unused", but these are read on the next line; the underscore was just noise. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous FIXME described the COM pass-through blocker as a libuipc-side
"frame alignment SIGABRT". Deeper investigation found the actual issue:
- Genesis honors URDF `<mass>` (URDF wins over rho * mesh volume in
rigid_link.py:717-769 when authored), so `link.inertial_mass` follows
the URDF, not the material default rho.
- The coupler passes `material.rho` (default RHO_ROBOT=1500) to libuipc's
`apply_to(mesh, kappa, mass_density=rho)`, which integrates volume*rho
and stores that as the body's mass.
- When URDF mass != rho * volume, Genesis and libuipc simulate different
physical bodies for the same link. Concrete: two_cube_revolute.urdf
authors mass=1.0, default RHO_ROBOT=1500 for 0.1m³ → Genesis stores
1.0kg, libuipc stores 1.5kg. 50% mass disagreement, silent.
The principled fix (build the 12x12 ABD mass matrix via
`uipc.geometry.affine_body.from_rigid_body(mass, com, inertia)` and use
`apply_to(sc, kappa, mass=abd_M12, volume)` overload-2) is verified correct
against libuipc tests 75_abd_mass_properties and 84_proxy_abd_gravity.
The blocker is asset-level: shipping the fix makes libuipc honor the URDF,
which changes simulated dynamics for any URDF authored with
mass != default rho * volume. test_single_joint[revolute-two_way_soft_constraint-False]
regresses on this. Resolving requires test re-calibration or an opt-in
material flag; tracked for a focused follow-up PR.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The moving link's <inertial><origin> in both URDFs was shifted from `0 0 0` to `0.1 0 0` via the b5cb90e CHECKPOINT (originally introduced by RealMain commit 9fbff06 "primary fix" to avoid base/moving cube overlap at qpos=0 under that branch's IPC setup). These are shared test assets — tests/test_kinematic.py, tests/test_ipc.py, and tests/test_rigid_physics.py all consume them — so the PR shouldn't carry asset-level modifications. Verified all 54 test_ipc.py tests + test_kinematic test_setters pass with the upstream URDFs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This PR's bfc9041 ("CHECKPOINT: Genesis-Embodied-AI#2502 patches") rewired the SAP-coupler branch in rigid_solver.substep_post_coupling to call kernel_restore_integrate(restore_qpos=False) instead of update_qacc_from_qvel_delta. The replacement is byte-identical math (both compute `acc = (vel - vel_prev) / dt; vel = vel_prev` with the same hibernation / parallelization structure — phase 1 of kernel_restore_integrate is skipped when restore_qpos=False, leaving only phase 2 which mirrors the original kernel exactly). `update_qacc_from_qvel_delta` was left orphaned by that rewire — defined but no longer called anywhere. Removing it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Same pattern as the prior commit's update_qacc_from_qvel_delta deletion. Upstream's substep_pre_coupling SAP-coupler branch called update_qvel (`vel_prev = vel; vel = vel + acc * dt` — predict next velocity). This PR's bfc9041 ("CHECKPOINT: Genesis-Embodied-AI#2502 patches") rewired that branch to call kernel_predict_integrate(update_qpos=False) instead. Phase 1 of kernel_predict_integrate is byte-identical math to update_qvel; Phase 2 (qpos integration) is skipped when update_qpos=False. Result: SAP behavior preserved, update_qvel orphaned. Removing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
7 test functions (test_link_filter_strict, test_robot_grasp_fem, test_robot_grasp_abd, test_collision_delegation_ipc_vs_rigid, test_cloth_corner_drag, test_set_robot_qpos, test_coup_collision_links) had a blank line right after the closing docstring """, before the first code statement. Inconsistent with the rest of the test suite (2/121 across test_rigid_physics + test_fem + test_kinematic — essentially noise level) and with the standard Python style. Tightening to match. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: acdb7b2d05
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if qs_idx is not None: | ||
| if isinstance(qs_idx, slice): | ||
| qs_idx = range(*qs_idx.indices(len(self._q_to_abd_link))) | ||
| for qi in qs_idx: |
There was a problem hiding this comment.
Handle scalar qpos indices when marking IPC state dirty
When using IPCCoupler, a direct RigidSolver.set_qpos(..., qs_idx=3) call reaches this branch with qs_idx still as an int; iterating it raises TypeError: 'int' object is not iterable before the ABD state can be marked dirty. The link index branch below already normalizes scalar integers, so scalar qpos/dof indices need the same treatment to keep single-DOF updates working under IPC.
Useful? React with 👍 / 👎.
| self._show_ipc_gui = bool(_show_ipc_gui) | ||
| enable_rigid_ground_contact: bool = True | ||
| enable_rigid_rigid_contact: bool = True | ||
| restitution: float = 0.0 |
There was a problem hiding this comment.
Implement the advertised restitution option
This new option is documented and tests explicitly set it to 0.0, but a repo-wide search shows no read of options.restitution/self.options.restitution, so any nonzero value is silently ignored. Users enabling IPC restitution for bounces or impact experiments will still get the inelastic IPC result, contrary to the API contract added here.
Useful? React with 👍 / 👎.
The same `centroid + (1.0 - 1e-3) * (verts - centroid)` pattern was duplicated in two places — `genesis/engine/solvers/rigid/collider/collider.py` (rigid self-collision filter) and `genesis/engine/couplers/ipc_coupler/coupler.py` (IPC merged-mesh cache used for neutral overlap detection). The coupler's comment even said "to match rigid collider's neutral overlap check," tracking the duplication explicitly. Extract a small helper next to `are_meshes_overlapping` (the related neutral- overlap check). Uses `axis=-2, keepdims=True` so the same helper works for both the collider's batched `(N_envs, N_verts, 3)` input and the coupler's flat `(N_verts, 3)` input. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- coupler.py mark_abd_updated: normalize scalar qs_idx / dofs_idx the same way scalar links_idx is normalized; without this, set_qpos(qs_idx=3) reaches the IPC dirty-marking branch as a plain int and raises TypeError: 'int' object is not iterable. - solvers.py IPCCouplerOptions: drop the documented-but-unimplemented `restitution` field (no reader exists in the tree; option silently accepted any value). Remove the orphaned _post_advance_write_qpos docstring paragraph and the two test sites that set it to 0.0. - test_ipc.py test_cloth_uniform_biaxial_stretching: stop pointing at a hardcoded /tmp/grid_c4_parity.obj (which CI cannot find) and pull the same mesh from the HF assets dataset as the other cloth tests. - tests/utils.py: bump HUGGINGFACE_ASSETS_REVISION to 6fb2318, the commit that adds IPC/grid21x21_c4.obj. The bump also pulls in a microwave URDF/handle update; test_urdf_parsing was re-run and still passes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
# Conflicts: # tests/utils.py
| if gs.logger.level <= logging.DEBUG: | ||
| uipc.Logger.set_level(uipc.Logger.Level.Info) | ||
| uipc.Timer.enable_all() | ||
| else: | ||
| uipc.Logger.set_level(uipc.Logger.Level.Error) | ||
| # TODO: revert to Error after profiling | ||
| uipc.Logger.set_level(uipc.Logger.Level.Info) | ||
| uipc.Timer.disable_all() | ||
|
|
||
| # Create workspace directory for IPC output, named after scene UID. | ||
| workspace = os.path.join(tempfile.gettempdir(), f"genesis_ipc_{self.sim.scene.uid.full()}") | ||
| os.makedirs(workspace, exist_ok=False) |
There was a problem hiding this comment.
Bug: exist_ok=False will crash on re-run or after unclean shutdown
The workspace path is deterministic (genesis_ipc_{scene_uid}). If a previous run crashed without cleaning the temp directory, or if the user re-creates a scene with the same UID, this line raises FileExistsError and the coupler cannot initialize.
Questions:
- Was
exist_ok=Falseintentional here (e.g. to detect stale state)? If so, should there be a try/except with a clear error message guiding the user to clean up? - Would
shutil.rmtree+os.makedirsbe preferable toexist_ok=True, to ensure stale IPC output files from a previous session don't corrupt the new one? - There is also no cleanup at shutdown -- the workspace directory accumulates on disk across runs. Would it make sense to register an
atexithandler or add aclose()method that callsshutil.rmtree(self._ipc_workspace)?
| # Apply geom transform to vertices (geom-local → link-local) | ||
| geom_verts = gu.transform_by_trans_quat(geom.init_verts, geom.init_pos, geom.init_quat) | ||
| # If from a merged child, transform child-local → parent-local | ||
| if frame_pos is not None and np.any(frame_pos) or np.any(frame_quat[1:]): |
There was a problem hiding this comment.
Bug: Operator precedence -- and binds tighter than or
This expression parses as:
(frame_pos is not None and np.any(frame_pos)) or np.any(frame_quat[1:])rather than the likely intended:
frame_pos is not None and (np.any(frame_pos) or np.any(frame_quat[1:]))Additionally, frame_pos is initialized on line 452 as a numpy array -- it is never None here, so the is not None check is dead code.
The current expression happens to produce correct results because when frame_pos is all-zero, np.any(frame_pos) is False, so the or falls through to np.any(frame_quat[1:]), which matches the correct logic. But this is a fragile coincidence.
Questions:
- Can you confirm the intended semantics? I believe this should simply be:
if np.any(frame_pos) or np.any(frame_quat[1:]):
- Is there any code path where
frame_poscould actually beNoneat this point? If not, theis not Noneguard should be removed to avoid misleading future readers.
| I_j = [i_j, i_b] if qd.static(static_rigid_sim_config.batch_joints_info) else i_j | ||
| joint_type = joints_info.type[I_j] | ||
|
|
||
| dt = rigid_global_info.substep_dt[None] |
There was a problem hiding this comment.
Question: Division by dt without zero-guard + interaction with func_implicit_damping
Two related concerns about kernel_restore_integrate:
1. No zero-guard on dt division
Lines 99, 107, 129, 139-140, and 179 all divide by substep_dt without any floor/clamp. A zero or near-zero dt would produce inf/NaN that silently propagates through acc, vel, and into kernel_step_2. The existing func_implicit_damping in this same file uses EPS-clamped denominators as a defensive pattern. Should the same guard be applied here, e.g. dt = qd.max(rigid_global_info.substep_dt[None], EPS)?
2. func_implicit_damping can overwrite IPC-derived acceleration
After kernel_restore_integrate, dofs_state.acc holds the IPC-derived value (vel_ipc - vel_prev) / dt. However, kernel_step_2 subsequently calls func_implicit_damping, which -- for entities with non-zero dofs_info.damping -- solves acc = M_damped^{-1} * force, overwriting the IPC correction. This means if a user's IPC entity has joint damping enabled, IPC's contact resolution is silently discarded.
I understand this is low-risk in practice (IPC scenes typically use free-floating bodies without joint damping), but there is no validation or documentation of this constraint.
Questions:
- Is
substep_dt == 0considered unreachable in all configurations? If so, is it worth adding a build-time assertion? - Would it be appropriate to add an assertion or warning (either in the coupler's
_setup_coupling_configor at thekernel_restore_integratecall site) that damped DOFs are not used with IPC coupling?
Summary
Add IPC (Incremental Potential Contact) as a coupler option in Genesis, sitting alongside the existing SAP/Legacy couplers. Wraps
libuipc's C++ ABD + FEM solver and orchestrates a per-step Rigid↔FEM↔IPC handshake so that Genesis's rigid-body dynamics and FEM dynamics share a single contact-resolution pass.Coupling modes (per
gs.materials.Rigid(coup_type=...))coup_typetwo_way_soft_constraintSoftTransformConstraint; back-coupling force returned to GenesisRigidSolverand coupled with FEM (not recommended to be used in articulations because it ignores constraints)external_articulationExternalArticulationConstraintcorrects each joint'sdelta_thetaafter contactipc_onlyFEM entities (tet meshes via
gs.materials.FEM.*) and Cloth entities (thin-shell viags.materials.FEM.Cloth) are always owned by libuipc and contact-coupled with all ABD bodies above.Per-step flow
For each
scene.step()substep, the order is:Key invariant: for
two_way_soft_constraintandexternal_articulation, Genesis remains the authority forqpos; IPC only adjusts qpos in response to contact. Foripc_only, libuipc is fully authoritative for the body's pose — Genesis just reads the result.Velocity back-compute (BDF1 assumption)
After IPC writes corrected
qpos,kernel_restore_integratederives velocity asvel = (qpos − qpos_prev) / dt— BDF1. The libuipc integrator is pinned tobdf1inbuild_ipc_scene_configso this stays consistent. Switching to BDF2 would require also resettingq_{n−1}/v_{n−1}on teleport, which libuipc doesn't currently expose. Pin + FIXME tracked inutils.py.Self-collision filtering
IPC's contact tabular respects the same self-collision policy as the rigid solver:
enable_self_collision— disable all same-entity pairsenable_adjacent_collision— disable parent↔child link pairs in the same articulationenable_neutral_collision— disable pairs whose merged meshes already overlap at qpos₀ (auto-detect via 99.9%-shrunk trimesh intersection)coup_linkson the material limits which links participate in IPC at all;coup_collision_linksfurther limits which links get contact-active (vs no-collision).ABD body construction
apply_to(mesh, kappa=100 MPa, mass_density=material.rho)— libuipc derives mass/COM/inertia from mesh volumetric integration. Caveat: Genesis-sidelink.inertial_massmay follow the URDF's explicit<mass>, which can differ frommaterial.rho × mesh_volume. The two solvers can silently disagree for URDFs that author non-default<inertial>blocks. Tracked as a FIXME incoupler.py; principled fix (overload-2 with explicit 12×12 ABD matrix) is verified mechanically correct against libuipc tests but requires test re-calibration before shipping.create_proxy(mass, mass_center, inertia, volume)using Genesis URDF inertials directly — no mass disagreement in this path.Fixed-link merging
find_target_link_for_fixed_mergewalks up the kinematic tree acrossFIXEDjoints, merging child geometry into the first non-fixed ancestor's ABD body. E.g., Franka'shand(FIXED joint tolink7) merges intolink7's ABD body. Reduces ABD body count and avoids the merged children's collision-detection ambiguity.Scope
genesis/engine/couplers/ipc_coupler/{coupler,data,utils}.pygenesis/options/solvers.py(IPCCouplerOptions)genesis/engine/materials/rigid.pycoup_type,coup_links,coup_collision_links,soft_constraint_strength,contact_resistancerigid_solver.py,abd/forward_dynamics.pykernel_predict_integrate,kernel_restore_integratesolvers/rigid/collider/collider.pytests/test_ipc.pyExcludes (separate PRs): FEM Paper/Rope materials, plastic discrete-shell bending, per-entity
contact_d_hatoverride, COM/inertia pass-through to libuipc (the principled fix for the mass-disagreement caveat above).Test plan
pytest tests/test_ipc.py— 54/54 pass on RTX 5090 Linuxpytest tests/test_fem.py— 9 pass + 1 pre-existing upstream xfail unrelated to IPCpytest tests/test_rigid_physics.py— passes with the IPC delegation/filter changes (re-run with the post-review cleanups recommended)examples/IPC_Solver/(4 scripts) ported to the new API