diff --git a/CHANGELOG.md b/CHANGELOG.md index c79661d2..2ce1ebcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ trajectory so breaking changes can land in any minor release until the 1.0 stabi ### Added +- **`SimulationCfg` rigid-solver options** (ROADMAP M3.7) — eight optional fields exposing + Genesis `RigidOptions`: `enable_self_collision`, `enable_joint_limit`, `max_collision_pairs` + (contact); `solver_iterations`, `ls_iterations`, `solver_tolerance`, `integrator` (solver); + `constraint_timeconst` (constraint stiffness/damping). All default `None` ("use Genesis + default"); `SimulationCfg.rigid_options_kwargs()` maps the set ones to `RigidOptions` kwargs + and `InteractiveScene` passes `rigid_options` to `gs.Scene` **only when at least one is set** + — so existing configs are byte-for-byte unchanged. `integrator` is a string resolved to + `gs.integrator.` in the scene (keeps `configs` Genesis-free, invariant #5). (Genesis + exposes no continuous-collision-detection / CCD knob, so it is not surfaced.) Tested in + `tests/test_configs.py`. + - **`ForceTorqueSensor`** (ROADMAP M3.5) — a joint force-torque sensor reporting each selected joint's internal reaction force/torque from Genesis `get_dofs_force` (the total internal DoF force, distinct from the commanded `applied_torque`). Select joints diff --git a/src/genelab/configs.py b/src/genelab/configs.py index 5f876243..25c61564 100644 --- a/src/genelab/configs.py +++ b/src/genelab/configs.py @@ -37,6 +37,44 @@ class SimulationCfg: # raise ``decimation`` if that's not what you want. render_fps: int | None = 60 + # --- Genesis rigid-solver options (ROADMAP M3.7) --------------------------------------- + # All optional; ``None`` = "use the Genesis default". Mapped to ``gs.options.RigidOptions`` + # by :class:`~genelab.scene.InteractiveScene`, which only passes ``rigid_options`` to + # ``gs.Scene`` when at least one is set — so an unset config keeps the historic behaviour + # byte-for-byte. (Genesis exposes no continuous-collision-detection / CCD knob.) + enable_self_collision: bool | None = None # RigidOptions.enable_self_collision + enable_joint_limit: bool | None = None # RigidOptions.enable_joint_limit + max_collision_pairs: int | None = None # RigidOptions.max_collision_pairs + solver_iterations: int | None = None # RigidOptions.iterations (constraint solver) + ls_iterations: int | None = None # RigidOptions.ls_iterations (line search) + solver_tolerance: float | None = None # RigidOptions.tolerance + constraint_timeconst: float | None = ( + None # RigidOptions.constraint_timeconst (stiffness/damping) + ) + integrator: str | None = ( + None # gs.integrator.: Euler / implicitfast / approximate_implicitfast + ) + + def rigid_options_kwargs(self) -> dict[str, Any]: + """Map the *set* rigid-solver fields to ``gs.options.RigidOptions`` kwargs. + + Returns only the fields the user set (skips ``None``), keyed by the Genesis + ``RigidOptions`` parameter names. ``integrator`` stays a string here — the scene + resolves it to ``gs.integrator.`` so ``configs`` stays Genesis-free (invariant + #5). An empty dict means the scene won't pass ``rigid_options`` at all. + """ + mapping: dict[str, Any] = { + "enable_self_collision": self.enable_self_collision, + "enable_joint_limit": self.enable_joint_limit, + "max_collision_pairs": self.max_collision_pairs, + "iterations": self.solver_iterations, + "ls_iterations": self.ls_iterations, + "tolerance": self.solver_tolerance, + "constraint_timeconst": self.constraint_timeconst, + "integrator": self.integrator, + } + return {k: v for k, v in mapping.items() if v is not None} + @staticmethod def play_retargeted_keys() -> tuple[str, ...]: """Override paths the CLI rewrites ``env.`` → ``play_env.`` in play mode. diff --git a/src/genelab/scene/interactive_scene.py b/src/genelab/scene/interactive_scene.py index 88d9c54c..c088edbc 100644 --- a/src/genelab/scene/interactive_scene.py +++ b/src/genelab/scene/interactive_scene.py @@ -161,6 +161,17 @@ def build(self) -> None: dt=float(self._sim_cfg.dt), substeps=int(self._sim_cfg.substeps), ) + # Optional rigid-solver tuning (ROADMAP M3.7). The cfg keeps ``integrator`` as a + # string (so ``configs`` stays Genesis-free); resolve it to ``gs.integrator.`` + # here. ``rigid_options`` is only passed when the user set at least one field, so an + # untouched config leaves Genesis's defaults exactly as before. + rigid_kwargs = self._sim_cfg.rigid_options_kwargs() + if "integrator" in rigid_kwargs: + name = rigid_kwargs["integrator"] + integrator = getattr(gs.integrator, name, None) + if integrator is None: + raise ValueError(f"SimulationCfg.integrator={name!r} is not a gs.integrator member") + rigid_kwargs["integrator"] = integrator # ``batch_render=True`` swaps in Genesis's BatchRenderer so attached cameras can # emit per-env RGB-D tensors. Default ``None`` keeps the historic Rasterizer path. renderer = ( @@ -186,6 +197,8 @@ def build(self) -> None: renderer=renderer, show_viewer=self._sim_cfg.vis, ) + if rigid_kwargs: + scene_kwargs["rigid_options"] = gs.options.RigidOptions(**rigid_kwargs) if viewer_options is not None: scene_kwargs["viewer_options"] = viewer_options self._gs_scene = gs.Scene(**scene_kwargs) diff --git a/tests/test_configs.py b/tests/test_configs.py index 800eb4d0..354556aa 100644 --- a/tests/test_configs.py +++ b/tests/test_configs.py @@ -34,3 +34,37 @@ def test_play_retargeted_keys_target_real_simulation_fields() -> None: def test_play_retargeted_keys_callable_on_class_and_instance() -> None: """Static method — same result whether called on the class or an instance.""" assert SimulationCfg.play_retargeted_keys() == SimulationCfg().play_retargeted_keys() + + +def test_rigid_options_kwargs_empty_by_default() -> None: + """An untouched SimulationCfg yields no rigid_options kwargs (Genesis defaults preserved).""" + assert SimulationCfg().rigid_options_kwargs() == {} + + +def test_rigid_options_kwargs_maps_set_fields_to_genesis_names() -> None: + cfg = SimulationCfg( + enable_self_collision=False, + enable_joint_limit=True, + max_collision_pairs=200, + solver_iterations=80, + ls_iterations=20, + solver_tolerance=1e-6, + constraint_timeconst=0.02, + integrator="implicitfast", + ) + assert cfg.rigid_options_kwargs() == { + "enable_self_collision": False, + "enable_joint_limit": True, + "max_collision_pairs": 200, + "iterations": 80, # solver_iterations → RigidOptions.iterations + "ls_iterations": 20, + "tolerance": 1e-6, # solver_tolerance → RigidOptions.tolerance + "constraint_timeconst": 0.02, + "integrator": "implicitfast", # stays a string; the scene resolves to gs.integrator + } + + +def test_rigid_options_kwargs_skips_none_only() -> None: + # enable_self_collision=False must survive (it's a real value, not "unset"). + cfg = SimulationCfg(enable_self_collision=False, solver_iterations=50) + assert cfg.rigid_options_kwargs() == {"enable_self_collision": False, "iterations": 50}