diff --git a/agent_context/topics/simulation-system/physics-configuration.md b/agent_context/topics/simulation-system/physics-configuration.md
index 2d243afe7..48991a4c0 100644
--- a/agent_context/topics/simulation-system/physics-configuration.md
+++ b/agent_context/topics/simulation-system/physics-configuration.md
@@ -25,10 +25,36 @@ Newton `solver_cfg=None` preserves DexSim's scene-aware `AutoSolverCfg`.
The concrete solver is resolved from the complete Spawn scene, then exposed
through `NewtonPhysicsBackend.solver_type`. Do not guess a solver-specific robot
preset while it is unresolved. Gradient mode requires an explicit
-`semi_implicit` solver. Read exact solver options/defaults in
+`semi_implicit` solver or a direct/named provider supporting the requested native
+gradient mode. Functional Scene calls additionally require its functional
+dynamics service. Python callers pass DexSim's `ProviderNewtonSolverCfg` with a
+provider class/zero-argument factory directly through `solver_cfg`; this path
+does not inspect installed metadata. DexSim creates fresh instances per model
+build. Installed `dexsim.newton_solvers` names optionally pass through to DexSim's
+`RegisteredNewtonSolverCfg`; missing SDK capabilities fail at selection or
+provider loading rather than breaking ordinary backend imports. Read exact solver options/defaults in
`cfg/simulation.py` and the installed DexSim config types; do not substitute a
hardcoded fallback when an expected DexSim API is absent.
+`physics/solvers/simple.py` owns the optional spring-slider provider;
+`examples/simple_newton_solver/` demonstrates it through the public
+`SimulationManager.spawn_result` Scene. The installed SDK must include the
+direct-factory and service APIs. The example uses direct factory configuration;
+the `simple` entry point remains optional for name-based configurations.
+DexSim owns physical state, clocks and publication; the provider
+owns its model-scoped parameters and functional transition. Independent
+rollout uses `publish=False`; publishing advances the live clock once and must
+not be paired with a manager update for the same transition. Scene functional
+reset selects arena names, including the separate global `default` arena.
+
+Articulation initialization also calls `reset(clear_dynamics=False)` without a
+later coordinated clear. Preserve its position hold targets and mimic target
+propagation when the solver accepts them. Query the finalized public
+`Scene.dynamics_capabilities().supported_control_fields`; only an explicit
+exclusion of `joint_target_q` suppresses that command. Released SDKs predating
+extensions retain their unrestricted target behavior. Extension SDKs missing
+the query require an upgrade instead of guessing support from solver names.
+
`collision_cfg` owns the external Newton contact pipeline. Its
`update_interval=None` updates at the first local solver substep; positive `k`
updates at `0, k, 2k, ...`, reusing contacts between updates. `collision_cfg=None`
diff --git a/docs/source/api_reference/public_api.rst b/docs/source/api_reference/public_api.rst
index ea1f17cc6..0e1a641d3 100644
--- a/docs/source/api_reference/public_api.rst
+++ b/docs/source/api_reference/public_api.rst
@@ -1473,6 +1473,21 @@ embodichain.lab.sim.physics.newton
NewtonPhysicsBackend
+embodichain.lab.sim.physics.solvers.simple
+------------------------------------------
+
+.. currentmodule:: embodichain.lab.sim.physics.solvers.simple
+
+The optional ``simple`` entry point provides a spring-slider example of native
+and functional Newton stepping. It requires a DexSim build exporting
+``FunctionalDynamics``; ordinary backend imports do not load this provider.
+See ``examples/simple_newton_solver/README.md`` for installation, supported
+models, gradients and Scene publication/reset semantics.
+
+.. autosummary::
+
+ SimpleSolverProvider
+
embodichain.lab.sim.motion.planners.base_planner
------------------------------------------------
diff --git a/embodichain/lab/sim/cfg/physics.py b/embodichain/lab/sim/cfg/physics.py
index 314eca508..eb71ea28e 100644
--- a/embodichain/lab/sim/cfg/physics.py
+++ b/embodichain/lab/sim/cfg/physics.py
@@ -311,8 +311,10 @@ class NewtonPhysicsCfg(PhysicsBackendCfg):
requires_grad: bool = False
"""Whether to finalize the Newton model with differentiable state enabled.
- EmbodiChain currently requires the Semi-implicit solver for this mode and
- disables CUDA graph capture when gradients are enabled.
+ Requires Semi-implicit or a direct/named provider supporting the requested
+ gradient mode. The provider validates native support during model creation;
+ Scene functional calls additionally require its functional dynamics service.
+ CUDA graph capture is disabled when gradients are enabled.
"""
use_cuda_graph: bool = True
@@ -349,6 +351,17 @@ class NewtonPhysicsCfg(PhysicsBackendCfg):
parameters accepted by that DexSim solver config. If omitted, EmbodiChain
preserves DexSim's scene-aware ``AutoSolverCfg`` default. A DexSim build
exporting ``AutoSolverCfg`` is required; no concrete-solver fallback is used.
+
+ Installed ``dexsim.newton_solvers`` entry-point names are also accepted by
+ DexSim builds with the named-extension API. For example,
+ ``{"solver_type": "simple", "stiffness": 2.0}`` selects the spring-slider
+ demonstration. Extension names are exact; options pass to their provider.
+
+ Python callers can pass DexSim's ``ProviderNewtonSolverCfg`` with a
+ ``provider_factory`` class or zero-argument factory and provider ``options``.
+ That configuration passes through directly without package discovery. The
+ SDK creates a fresh provider for each model build; pass a factory rather
+ than a live provider or solver instance.
"""
collision_cfg: NewtonCollisionPipelineCfg | Mapping[str, Any] | None = field(
@@ -407,13 +420,16 @@ def to_dexsim_cfg(
solver_cfg_map=solver_cfg_map,
)
- if self.requires_grad and (
- solver_cfg is None or solver_cfg.solver_type != "semi_implicit"
+ solver_name = "auto" if solver_cfg is None else str(solver_cfg.solver_type)
+ if (
+ self.requires_grad
+ and solver_name in solver_cfg_map
+ and solver_name != "semi_implicit"
):
logger.log_error(
"Newton gradient mode requires an explicit "
- "solver_type='semi_implicit'; AutoSolver does not select a "
- "differentiable solver."
+ "solver_type='semi_implicit' or a named functional extension; "
+ "AutoSolver does not select a differentiable solver."
)
collision_pipeline_cfg = None
@@ -494,11 +510,13 @@ def _normalize_newton_solver_type(solver_type: str) -> str:
"vbdsolvercfg": "vbd",
}
if key not in aliases:
- logger.log_error(
- f"Unsupported Newton solver type '{solver_type}'. "
- "Expected one of 'auto', 'dexuni', 'mjwarp', 'xpbd', 'semi_implicit', "
- "'featherstone', or 'vbd'."
- )
+ try:
+ from dexsim.engine.newton_physics import normalize_newton_solver_name
+ except ImportError as exc:
+ raise ValueError(
+ f"Solver {solver_type!r} requires a DexSim build with named solver extensions"
+ ) from exc
+ return normalize_newton_solver_name(solver_type)
return aliases[key]
@@ -525,6 +543,10 @@ def _newton_solver_cfg_to_dexsim(
or "auto"
)
normalized_solver_type = _normalize_newton_solver_type(str(configured_solver_type))
+ if normalized_solver_type not in solver_cfg_map:
+ from dexsim.engine.newton_physics import RegisteredNewtonSolverCfg
+
+ return RegisteredNewtonSolverCfg(normalized_solver_type, solver_cfg_data)
solver_cfg_type = solver_cfg_map[normalized_solver_type]
if normalized_solver_type == "dexuni":
try:
diff --git a/embodichain/lab/sim/objects/articulation.py b/embodichain/lab/sim/objects/articulation.py
index a17c39fd6..d1354d4e3 100644
--- a/embodichain/lab/sim/objects/articulation.py
+++ b/embodichain/lab/sim/objects/articulation.py
@@ -96,6 +96,7 @@
from embodichain.lab.sim.objects.backends.newton import (
_configure_newton_mimic_compliance,
_stabilize_newton_mimic_target_write,
+ _supports_newton_position_targets,
)
from embodichain.utils.math import (
matrix_from_euler,
@@ -2797,6 +2798,11 @@ def reset(
num_instances = len(local_env_ids)
self.cfg: ArticulationCfg
+ write_hold_targets = (
+ not self.body_data.is_newton_backend
+ or _supports_newton_position_targets(self._spawn_result)
+ )
+
self.restore_visual_material(env_ids=local_env_ids)
self._restore_default_physical_properties(local_env_ids)
@@ -2845,8 +2851,11 @@ def reset(
# impulse when initialized away from their equality manifold.
qpos = self._project_mimic_qpos(qpos)
self.set_qpos(qpos, target=False, env_ids=local_env_ids)
- # Set drive target to hold position.
- self.set_qpos(qpos, target=True, env_ids=local_env_ids)
+ # Initialization uses clear_dynamics=False with no subsequent clear.
+ # Keep hold targets and mimic propagation for position-capable solvers;
+ # effort-only solvers use native reset bookkeeping without a command.
+ if write_hold_targets:
+ self.set_qpos(qpos, target=True, env_ids=local_env_ids)
if clear_dynamics:
self.clear_dynamics(env_ids=local_env_ids)
diff --git a/embodichain/lab/sim/objects/backends/newton.py b/embodichain/lab/sim/objects/backends/newton.py
index 33b164404..322557cf5 100644
--- a/embodichain/lab/sim/objects/backends/newton.py
+++ b/embodichain/lab/sim/objects/backends/newton.py
@@ -34,6 +34,23 @@ def is_newton_scene(scene: object) -> bool:
return isinstance(scene, Scene) and scene.backend == "newton"
+def _supports_newton_position_targets(scene: Scene) -> bool:
+ """Query the finalized solver contract while preserving legacy SDK support."""
+ query = getattr(scene, "dynamics_capabilities", None)
+ if query is None:
+ from dexsim.engine import newton_physics
+
+ if hasattr(newton_physics, "NewtonSolverExtension"):
+ raise RuntimeError(
+ "Newton solver extensions require a compatible DexSim SDK with "
+ "Scene.dynamics_capabilities() for articulation initialization."
+ )
+ # Released SDKs predating extensions accept the existing target channels.
+ return True
+ fields = query().supported_control_fields
+ return fields is None or "joint_target_q" in fields
+
+
def _create_newton_standalone_state_sync(
model: Any,
body_ids: Sequence[int],
diff --git a/embodichain/lab/sim/physics/solvers/__init__.py b/embodichain/lab/sim/physics/solvers/__init__.py
new file mode 100644
index 000000000..328ca6e0d
--- /dev/null
+++ b/embodichain/lab/sim/physics/solvers/__init__.py
@@ -0,0 +1,20 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+"""Optional Newton providers for direct factories or named package discovery."""
+
+from __future__ import annotations
+
+__all__: list[str] = []
diff --git a/embodichain/lab/sim/physics/solvers/simple.py b/embodichain/lab/sim/physics/solvers/simple.py
new file mode 100644
index 000000000..64c656e38
--- /dev/null
+++ b/embodichain/lab/sim/physics/solvers/simple.py
@@ -0,0 +1,340 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+"""Small spring-slider solver demonstrating the DexSim extension contract."""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Mapping
+from contextlib import nullcontext
+from typing import Any
+
+import newton
+import numpy as np
+import torch
+import warp as wp
+from newton.solvers import SolverBase
+from dexsim.engine import newton_physics as _sdk
+
+from embodichain.utils.math import quat_apply
+
+__all__ = ["SimpleSolverProvider"]
+
+# Catalogues and autodoc may inspect the provider with the released SDK.
+# Resolve annotations when available; instance creation enforces the runtime API.
+try:
+ from dexsim.engine.newton_physics import (
+ FunctionalSolverState,
+ NewtonSolverCapabilities,
+ NewtonSolverContext,
+ NewtonSolverExtension,
+ )
+except ImportError:
+ FunctionalSolverState = Any
+ NewtonSolverCapabilities = Any
+ NewtonSolverContext = Any
+ NewtonSolverExtension = Any
+
+
+def _torch_scope(device: wp.context.Device) -> Any:
+ return (
+ torch.cuda.stream(wp.stream_to_torch(wp.get_stream(device)))
+ if device.is_cuda
+ else nullcontext()
+ )
+
+
+def _tensor(array: wp.array) -> torch.Tensor:
+ # Warp cannot expose a null pointer as a Torch tensor for empty arrays.
+ if array.size == 0:
+ return torch.as_tensor(array.numpy(), device=str(array.device))
+ return wp.to_torch(array)
+
+
+class _SliderDynamics:
+ def __init__(self, model: newton.Model, options: Mapping[str, Any]) -> None:
+ unknown = set(options) - {"stiffness", "damping", "force_scale"}
+ if unknown:
+ raise ValueError(f"Unknown simple solver options: {sorted(unknown)}")
+ values = {"stiffness": 1.0, "damping": 0.1, "force_scale": 1.0}
+ values.update(options)
+ for name, value in values.items():
+ if not math.isfinite(float(value)) or (
+ name != "force_scale" and float(value) < 0
+ ):
+ raise ValueError(f"Invalid simple solver option {name}={value!r}")
+ self.model = model
+ self.stiffness = float(values["stiffness"])
+ self.damping = float(values["damping"])
+ self.force_scale = torch.tensor(
+ float(values["force_scale"]), device=str(model.device), requires_grad=True
+ )
+ self.refresh()
+
+ def refresh(self) -> None:
+ model = self.model
+ types = model.joint_type.numpy()
+ parents, children = model.joint_parent.numpy(), model.joint_child.numpy()
+ moving = np.flatnonzero(types == int(newton.JointType.PRISMATIC))
+ if (
+ not len(moving)
+ or not np.isin(
+ types, [int(newton.JointType.FIXED), int(newton.JointType.PRISMATIC)]
+ ).all()
+ or len(children) != model.body_count
+ or len(set(children.tolist())) != model.body_count
+ or any(int(children[j]) in parents for j in moving)
+ or not model.joint_enabled.numpy().all()
+ ):
+ raise ValueError(
+ "simple supports independent prismatic leaf joints with fixed ancestors only"
+ )
+ if model.particle_count or getattr(model, "constraint_mimic_count", 0):
+ raise ValueError("simple does not support particles or mimic constraints")
+ moving_shapes = np.isin(model.shape_body.numpy(), children[moving])
+ if np.any(
+ model.shape_flags.numpy()[moving_shapes]
+ & int(newton.ShapeFlags.COLLIDE_SHAPES)
+ ):
+ raise ValueError(
+ "simple does not support collision shapes on moving bodies"
+ )
+ if np.any(model.gravity.numpy()):
+ raise ValueError("simple requires zero gravity")
+ for name in ("joint_target_ke", "joint_target_kd", "joint_friction"):
+ if np.any(getattr(model, name).numpy()):
+ raise ValueError(f"simple does not support nonzero {name}")
+ self.children = torch.as_tensor(
+ children[moving], dtype=torch.long, device=str(model.device)
+ )
+ if np.any(
+ model.body_flags.numpy()[children[moving]] & int(newton.BodyFlags.KINEMATIC)
+ ):
+ raise ValueError("simple requires dynamic slider bodies")
+ mass = _tensor(model.body_mass)[self.children].detach().clone()
+ if not bool(torch.isfinite(mass).all() and (mass > 0).all()):
+ raise ValueError("simple requires finite positive slider mass")
+ self.mass = mass + _tensor(model.joint_armature).detach().clone()
+ if not bool(torch.isfinite(self.mass).all() and (self.mass > 0).all()):
+ raise ValueError("simple requires finite positive effective mass")
+ self.drag = _tensor(model.joint_damping).detach().clone() + self.damping
+ self.lower = _tensor(model.joint_limit_lower).detach().clone()
+ self.upper = _tensor(model.joint_limit_upper).detach().clone()
+ self.effort_limit = _tensor(model.joint_effort_limit).detach().clone()
+ self.velocity_limit = _tensor(model.joint_velocity_limit).detach().clone()
+ child_frames = _tensor(model.joint_X_c)[
+ torch.as_tensor(moving, device=str(model.device))
+ ]
+ self.local_axes = quat_apply(
+ child_frames[:, 3:], _tensor(model.joint_axis)
+ ).detach()
+
+ def create_state(self, native_state: newton.State) -> FunctionalSolverState:
+ return FunctionalSolverState(
+ {
+ name: _tensor(getattr(native_state, name)).detach().clone()
+ for name in ("joint_q", "joint_qd", "body_q", "body_qd", "body_qdd")
+ if getattr(native_state, name, None) is not None
+ }
+ )
+
+ def parameters(self) -> Mapping[str, torch.Tensor]:
+ return {"force_scale": self.force_scale}
+
+ def step(
+ self,
+ state: FunctionalSolverState,
+ control: torch.Tensor,
+ parameters: Mapping[str, torch.Tensor],
+ dt: float,
+ ) -> FunctionalSolverState:
+ q, qd = state.physical["joint_q"], state.physical["joint_qd"]
+ if not isinstance(control, torch.Tensor) or control.shape != q.shape:
+ raise ValueError(
+ "simple control must be a tensor shaped (joint_dof_count,)"
+ )
+ if control.device != q.device or control.dtype != q.dtype:
+ raise ValueError("simple control must match the state device and dtype")
+ if not math.isfinite(dt) or dt <= 0:
+ raise ValueError("simple dt must be positive and finite")
+ force = (parameters["force_scale"] * control).clamp(
+ -self.effort_limit, self.effort_limit
+ )
+ acceleration = (force - self.stiffness * q - self.drag * qd) / self.mass
+ velocity = (qd + dt * acceleration).clamp(
+ -self.velocity_limit, self.velocity_limit
+ )
+ proposed = q + dt * velocity
+ position = proposed.clamp(self.lower, self.upper)
+ velocity = torch.where(
+ proposed != position, torch.zeros_like(velocity), velocity
+ )
+ acceleration = (velocity - qd) / dt
+ # Advance the existing pose by the coordinate delta so explicit root
+ # placement and randomized states are preserved across transitions.
+ previous_pose = state.physical["body_q"]
+ axes = quat_apply(previous_pose[self.children, 3:], self.local_axes)
+ pose = previous_pose.index_copy(
+ 0,
+ self.children,
+ torch.cat(
+ (
+ previous_pose[self.children, :3] + axes * (position - q)[:, None],
+ previous_pose[self.children, 3:],
+ ),
+ dim=1,
+ ),
+ )
+ spatial = torch.zeros(
+ (self.model.body_count, 6), device=q.device, dtype=q.dtype
+ )
+ zeros = torch.zeros_like(axes)
+ body_qd = spatial.index_copy(
+ 0, self.children, torch.cat((axes * velocity[:, None], zeros), dim=1)
+ )
+ body_qdd = spatial.index_copy(
+ 0,
+ self.children,
+ torch.cat((axes * acceleration[:, None], zeros), dim=1),
+ )
+ return FunctionalSolverState(
+ {
+ "joint_q": position,
+ "joint_qd": velocity,
+ "body_q": pose,
+ "body_qd": body_qd,
+ "body_qdd": body_qdd,
+ }
+ )
+
+ def commit(self, state: FunctionalSolverState) -> None:
+ # There is no recurrent history. DexSim owns physical publication.
+ pass
+
+ def reset_history(
+ self,
+ state: FunctionalSolverState,
+ reset_state: FunctionalSolverState,
+ world_mask: wp.array | None,
+ ) -> tuple[Mapping[str, Any], None]:
+ return {}, None
+
+
+class _NativeSliderSolver(SolverBase):
+ def __init__(self, model: newton.Model, dynamics: _SliderDynamics) -> None:
+ super().__init__(model)
+ self.dynamics = dynamics
+
+ def step(
+ self,
+ state_in: newton.State,
+ state_out: newton.State,
+ control: newton.Control | None,
+ contacts: newton.Contacts | None,
+ dt: float,
+ *,
+ observables: SolverBase.Observables | None = None,
+ ) -> None:
+ self.validate_observables(observables, contacts)
+ with _torch_scope(self.model.device), torch.no_grad():
+ source = self.dynamics.create_state(state_in)
+ force = _tensor(self.model.joint_f if control is None else control.joint_f)
+ result = self.dynamics.step(source, force, self.dynamics.parameters(), dt)
+ for name, value in result.physical.items():
+ destination = getattr(state_out, name, None)
+ if destination is not None:
+ _tensor(destination).copy_(value)
+
+ def notify_model_changed(self, flags: newton.ModelFlags | int) -> None:
+ with _torch_scope(self.model.device):
+ self.dynamics.refresh()
+
+
+class SimpleSolverProvider:
+ """Create a minimal, differentiable spring-slider Newton extension.
+
+ Each moving body must be a dynamic prismatic leaf attached to the world or
+ fixed ancestors. Visual shapes and static ground are allowed; moving-body
+ collisions, particles, mimic
+ constraints, gravity, target drives and friction are rejected. Joint mass,
+ armature, damping and effort/velocity/position limits are respected. Limits
+ use hard clamping; gradients at their boundaries are piecewise-defined.
+
+ Options are ``stiffness`` (N/m, default 1), ``damping`` (N s/m, default 0.1)
+ and trainable ``force_scale`` (default 1). Native and functional execution
+ share semi-implicit Euler: first update velocity, then position. This is an
+ eager educational solver, not a general robot/contact solver. Parameters
+ belong to one finalized model and must be checkpointed across rebuilds.
+ """
+
+ def __init__(self) -> None:
+ """Require the extension SDK when the provider is selected.
+
+ Raises:
+ ImportError: The installed SDK lacks the functional service contract.
+ """
+ if not hasattr(_sdk, "FunctionalDynamics") or not hasattr(
+ NewtonSolverCapabilities, "native_body_accelerations"
+ ):
+ raise ImportError(
+ "The simple solver requires DexSim's FunctionalDynamics extension API "
+ "(MR !1483 service revision); install a compatible DexSim wheel."
+ )
+ self.capabilities = NewtonSolverCapabilities(
+ reset=True,
+ cuda_graph=False,
+ external_wrenches=False,
+ supported_control_fields=("joint_f",),
+ runtime_controls=False,
+ native_body_accelerations=True,
+ )
+
+ def register_custom_attributes(
+ self,
+ builder: newton.ModelBuilder,
+ options: Mapping[str, Any],
+ ) -> None:
+ """Use standard Newton attributes; no custom registration is needed.
+
+ Args:
+ builder: Builder owned by DexSim before model finalization.
+ options: Solver options validated during creation.
+ """
+
+ def create(
+ self,
+ context: NewtonSolverContext,
+ options: Mapping[str, Any],
+ ) -> NewtonSolverExtension:
+ """Validate the model and create native and functional views of one solver.
+
+ Args:
+ context: Finalized model and resources borrowed from DexSim.
+ options: Spring stiffness, damping and initial force scale.
+
+ Returns:
+ Model-scoped extension with an independent functional trajectory API.
+
+ Raises:
+ ValueError: The model or options exceed the documented supported scope.
+ """
+ with _torch_scope(context.device):
+ dynamics = _SliderDynamics(context.model, options)
+ return NewtonSolverExtension(
+ native_solver=_NativeSliderSolver(context.model, dynamics),
+ capabilities=self.capabilities,
+ functional=dynamics,
+ )
diff --git a/examples/simple_newton_solver/README.md b/examples/simple_newton_solver/README.md
new file mode 100644
index 000000000..a6cd101cb
--- /dev/null
+++ b/examples/simple_newton_solver/README.md
@@ -0,0 +1,126 @@
+# Simple Newton solver
+
+This example passes `SimpleSolverProvider` directly to DexSim, without requiring
+entry-point registration. It demonstrates an ordinary `SimulationManager.update()`,
+independent differentiable rollout, parameter/input gradients, publication into
+the live Scene, and partial functional/native reset in two environments.
+
+## Prerequisite
+
+Use Python 3.10 or 3.11 with Torch and a DexSim build containing the
+`ProviderNewtonSolverCfg`, `FunctionalDynamics` service and `Scene.dynamics_capabilities()` revision of
+[DexSim MR !1483](http://192.168.3.16/Engine/dexverse/-/merge_requests/1483).
+The released DexSim pin in EmbodiChain does not yet provide that API. Normal
+EmbodiChain installations remain supported; this optional example requires the
+integration SDK. Older callback/service revisions without the direct factory
+configuration are also insufficient.
+
+In a dedicated EmbodiChain development environment, install this checkout and
+then the matching SDK wheel. Use `--no-deps` only for the EmbodiChain checkout,
+so it does not reinstall the released SDK pin. Let the SDK wheel resolve its
+own dependencies; the qualified build requires Newton 1.7.0 and Warp 1.17.0.
+This integration environment intentionally overrides EmbodiChain's released
+SDK pin:
+
+```bash
+python -m pip install --no-deps --no-build-isolation -e .
+python -m pip install /path/to/compatible/dexsim_engine.whl
+python examples/simple_newton_solver/demo.py --device cpu
+# Optional, on a supported CUDA installation:
+python examples/simple_newton_solver/demo.py --device cuda:0
+```
+
+The command runs
+headlessly and prints a JSON result with positions and nonzero gradients. It
+checks numerical equality and state isolation before reporting success.
+
+For feature acceptance, first require the SDK capability, then run the provider
+tests. Ordinary CI using the released SDK skips this optional test module; that
+skip is not validation of the solver:
+
+```bash
+python - <<'PY'
+from dexsim.engine.newton_physics import FunctionalDynamics
+from dexsim.engine.newton_physics import ProviderNewtonSolverCfg
+from dexsim.scene import Scene
+assert callable(getattr(Scene, "dynamics_capabilities", None))
+PY
+python -m pytest tests/sim/physics/test_simple_solver.py -q --run-gpu
+```
+
+## Dynamics and supported model
+
+For each independent prismatic leaf, with coordinate `q`, velocity `v`, input
+force `u`, trainable scale `g`, and effective mass `m + armature`:
+
+```text
+f = clamp(g * u, -effort_limit, effort_limit)
+a = (f - stiffness * q - (damping + joint_damping) * v) / effective_mass
+v_next = clamp(v + dt * a, -velocity_limit, velocity_limit)
+q_next = clamp(q + dt * v_next, lower_limit, upper_limit)
+```
+
+A position-limit clamp stops the slider. Body acceleration is the realized
+velocity change divided by `dt`. Gradients through the clamps are piecewise;
+the example stays away from these boundaries. Native and functional stepping
+call the same transition implementation. Body poses use the fixed joint frames
+and world-space slider axis, preserving arena placement and body orientation.
+
+The model must have fixed ancestors and independent prismatic leaf bodies,
+zero gravity, no particles, mimic constraints, friction or position/velocity
+drives. Moving-body collisions are rejected. Static ground is allowed because
+the moving bodies have collision disabled. The demo uses an explicit collision
+overlay: URDF visual geometry can otherwise become collision geometry during
+import. Unsupported explicit control targets and external body forces remain
+errors. This eager solver does not support CUDA Graph capture.
+
+## API boundaries
+
+Python configuration passes the provider factory directly:
+
+```python
+from dexsim.engine.newton_physics import ProviderNewtonSolverCfg
+from embodichain.lab.sim.cfg import NewtonPhysicsCfg
+from embodichain.lab.sim.physics.solvers.simple import SimpleSolverProvider
+
+physics = NewtonPhysicsCfg(
+ solver_cfg=ProviderNewtonSolverCfg(
+ provider_factory=SimpleSolverProvider,
+ options={"stiffness": 2.0},
+ ),
+)
+```
+
+Pass a class or zero-argument factory, not an already-created provider/solver.
+DexSim creates a fresh provider for every model build and applies the same
+validation and lifecycle as named providers. This path does not scan installed
+entry points.
+
+The package also keeps an optional `simple` entry point for data-based configs:
+`NewtonPhysicsCfg(solver_cfg={"solver_type": "simple", "stiffness": 2.0})`.
+Only that name-based path requires installed metadata; reinstall editable
+metadata after changing entry points. No global registration call is needed.
+`requires_grad=True` enables the functional path. After `sim.prepare()`, use
+the public `sim.spawn_result` Scene:
+
+- `create_functional_state()` clones physical state.
+- `functional_parameters()` returns the model-scoped trainable `force_scale`.
+- `functional_step(..., publish=False)` computes an isolated trajectory.
+- `functional_step(..., publish=True)` also publishes detached physical state
+ and advances the live clock once. Do not also call `sim.update()` for that
+ transition. The returned tensors retain their gradient graph.
+- `reset_functional_state(..., arenas=["arena_0"])` resets only the selected
+ environment; the global `default` arena is a separate selection.
+
+The service is stateless: recurrent history is empty, history commit is a no-op,
+and DexSim owns native buffers, clocks and lifecycle. A topology rebuild creates
+a new provider; save and restore learned parameters explicitly across rebuilds.
+Training loops and optimizers remain downstream. The example adds no manager or
+registry of its own.
+
+The demonstrated gradients are with respect to effort and `force_scale`.
+Functional state includes both joint and body coordinates. To differentiate a
+Cartesian loss with respect to initial joint positions, initialize body poses
+through a differentiable FK graph consistent with those positions. Promoting
+only `joint_q` to a trainable tensor while keeping the initial `body_q` detached
+instead differentiates a redundant state with its initial body pose held fixed.
diff --git a/examples/simple_newton_solver/demo.py b/examples/simple_newton_solver/demo.py
new file mode 100644
index 000000000..04bb6b285
--- /dev/null
+++ b/examples/simple_newton_solver/demo.py
@@ -0,0 +1,137 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+"""Run ordinary and functional stepping through public EmbodiChain/Scene APIs."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+
+import torch
+from dexsim.engine.newton_physics import ProviderNewtonSolverCfg
+
+from embodichain.lab.sim import SimulationManager, SimulationManagerCfg
+from embodichain.lab.sim.cfg import (
+ ArticulationCfg,
+ CollisionPropertiesCfg,
+ NewtonPhysicsCfg,
+ RigidBodyPhysicsCfg,
+)
+from embodichain.lab.visualization import VisualizationCfg
+from embodichain.lab.sim.physics.solvers.simple import SimpleSolverProvider
+
+
+def run(device: str) -> dict[str, object]:
+ """Demonstrate direct provider creation, parity, gradients and partial reset."""
+ config = SimulationManagerCfg(
+ headless=True,
+ visualization=VisualizationCfg(backend="none"),
+ num_envs=2,
+ enable_entity_gizmo=False,
+ startup_summary="off",
+ physics_cfg=NewtonPhysicsCfg(
+ device=device,
+ gravity=(0.0, 0.0, 0.0),
+ physics_dt=0.02,
+ num_substeps=2,
+ requires_grad=True,
+ use_cuda_graph=False,
+ collision_cfg=None,
+ solver_cfg=ProviderNewtonSolverCfg(
+ provider_factory=SimpleSolverProvider,
+ options={"stiffness": 2.0, "damping": 0.2},
+ ),
+ ),
+ )
+ sim = SimulationManager(config, requires_native_renderer=False)
+ try:
+ slider = sim.add_articulation(
+ ArticulationCfg(
+ uid="slider",
+ fpath=str(Path(__file__).with_name("slider.urdf")),
+ asset_physics_mode="overlay",
+ attrs=RigidBodyPhysicsCfg(
+ collision_props=CollisionPropertiesCfg(collision_enabled=False),
+ ),
+ )
+ )
+ sim.prepare()
+ scene = sim.spawn_result
+ initial = scene.create_functional_state()
+ params = scene.functional_parameters()
+ force = torch.tensor([4.0, 6.0], device=device, requires_grad=True)
+
+ predicted = scene.functional_step(initial, force, publish=False)
+ torch.testing.assert_close(
+ slider.get_qpos().flatten(), initial.physical["joint_q"]
+ )
+ slider.set_qf(force.detach().reshape(2, 1))
+ sim.update(step=1)
+ torch.testing.assert_close(
+ slider.get_qpos().flatten(), predicted.physical["joint_q"]
+ )
+
+ trajectory = initial
+ for _ in range(6):
+ trajectory = scene.functional_step(trajectory, force, publish=False)
+ saved = trajectory.physical["joint_q"].clone()
+ published = scene.functional_step(trajectory, force, publish=True)
+ torch.testing.assert_close(
+ slider.get_qpos().flatten(), published.physical["joint_q"]
+ )
+ torch.testing.assert_close(trajectory.physical["joint_q"], saved)
+
+ reset = scene.reset_functional_state(published, arenas=["arena_0"])
+ torch.testing.assert_close(
+ reset.physical["joint_q"][0], initial.physical["joint_q"][0]
+ )
+ torch.testing.assert_close(
+ reset.physical["joint_q"][1], published.physical["joint_q"][1]
+ )
+ loss = (
+ trajectory.physical["joint_q"].square().sum()
+ + reset.physical["joint_q"].square().sum()
+ )
+ grad_force, grad_scale = torch.autograd.grad(
+ loss, (force, params["force_scale"])
+ )
+ assert torch.isfinite(grad_force).all() and (grad_force.abs() > 0).all()
+ assert torch.isfinite(grad_scale) and grad_scale.abs() > 0
+ slider.reset(env_ids=[0])
+ torch.testing.assert_close(
+ slider.get_qpos()[0].flatten(), initial.physical["joint_q"][:1]
+ )
+ return {
+ "device": device,
+ "native_functional_parity": True,
+ "published_position": published.physical["joint_q"].detach().tolist(),
+ "partial_reset_position": reset.physical["joint_q"].detach().tolist(),
+ "force_gradient": grad_force.tolist(),
+ "force_scale_gradient": grad_scale.item(),
+ }
+ finally:
+ sim.destroy(exit_process=False)
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--device", default="cpu", help="cpu or cuda:0")
+ args = parser.parse_args()
+ try:
+ print(json.dumps(run(args.device), indent=2))
+ finally:
+ SimulationManager.flush_cleanup_queue()
diff --git a/examples/simple_newton_solver/slider.urdf b/examples/simple_newton_solver/slider.urdf
new file mode 100644
index 000000000..0d6880fd5
--- /dev/null
+++ b/examples/simple_newton_solver/slider.urdf
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pyproject.toml b/pyproject.toml
index 20930f944..5559be303 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -85,6 +85,9 @@ embodichain_tasks = "embodichain_tasks"
[project.entry-points."embodichain.robots"]
official = "embodichain_robots._catalog:ROBOT_CONFIGS"
+[project.entry-points."dexsim.newton_solvers"]
+simple = "embodichain.lab.sim.physics.solvers.simple:SimpleSolverProvider"
+
[tool.uv.sources]
bpy = { index = "blender" }
diff --git a/tests/sim/objects/test_articulation.py b/tests/sim/objects/test_articulation.py
index 8b5ff0b33..eeede72a9 100644
--- a/tests/sim/objects/test_articulation.py
+++ b/tests/sim/objects/test_articulation.py
@@ -61,6 +61,53 @@ def _inertia_tensor(inertia: torch.Tensor, com_pose: torch.Tensor) -> torch.Tens
return rotation @ torch.diag_embed(inertia) @ rotation.transpose(-1, -2)
+@pytest.mark.requires_sim
+def test_newton_initial_hold_targets_for_multiple_articulations(tmp_path: Path) -> None:
+ from embodichain.lab.sim.cfg import NewtonPhysicsCfg
+
+ asset = tmp_path / "initial_slider.urdf"
+ asset.write_text("""
+
+
+
+ """)
+ sim = SimulationManager(
+ SimulationManagerCfg(
+ headless=True,
+ enable_entity_gizmo=False,
+ physics_cfg=NewtonPhysicsCfg(
+ device="cpu",
+ gravity=(0, 0, 0),
+ collision_cfg=None,
+ solver_cfg={"solver_type": "semi_implicit"},
+ ),
+ ),
+ requires_native_renderer=False,
+ )
+ articulations = []
+ art = None
+ try:
+ initial_positions = (0.2, -0.3)
+ for index, position in enumerate(initial_positions):
+ articulations.append(
+ sim.add_articulation(
+ ArticulationCfg(
+ uid=f"slider_{index}", fpath=str(asset), init_qpos=[position]
+ )
+ )
+ )
+ sim.prepare()
+ for art, position in zip(articulations, initial_positions, strict=True):
+ expected = torch.tensor([[position]])
+ torch.testing.assert_close(art.get_qpos(), expected)
+ torch.testing.assert_close(art.get_qpos(target=True), expected)
+ finally:
+ art = None
+ articulations.clear()
+ sim.destroy(exit_process=False)
+ SimulationManager.flush_cleanup_queue()
+
+
@pytest.mark.parametrize("device", ["cpu", "cuda:0"])
@pytest.mark.parametrize("env_ids", [None, [0]], ids=["all", "selected"])
@pytest.mark.parametrize("operation", ["reset", "root_pose", "qpos"])
diff --git a/tests/sim/objects/test_backend_physics_helpers.py b/tests/sim/objects/test_backend_physics_helpers.py
index b844547ad..3d86209ca 100644
--- a/tests/sim/objects/test_backend_physics_helpers.py
+++ b/tests/sim/objects/test_backend_physics_helpers.py
@@ -49,6 +49,7 @@
)
from embodichain.lab.sim.objects.backends.newton import (
_stabilize_newton_mimic_target_write,
+ _supports_newton_position_targets,
)
from embodichain.lab.sim.objects.backends.lifecycle import (
apply_rigid_initial_state,
@@ -66,6 +67,29 @@
)
+@pytest.mark.parametrize(
+ "fields, expected",
+ [(None, True), (("joint_f",), False), (("joint_target_q",), True)],
+)
+def test_newton_hold_targets_follow_declared_channels(fields, expected) -> None:
+ scene = SimpleNamespace(
+ dynamics_capabilities=lambda: SimpleNamespace(supported_control_fields=fields)
+ )
+ assert _supports_newton_position_targets(scene) is expected
+
+
+def test_newton_hold_targets_distinguish_legacy_and_incomplete_sdks(
+ monkeypatch,
+) -> None:
+ from dexsim.engine import newton_physics
+
+ monkeypatch.delattr(newton_physics, "NewtonSolverExtension", raising=False)
+ assert _supports_newton_position_targets(SimpleNamespace())
+ monkeypatch.setattr(newton_physics, "NewtonSolverExtension", object, raising=False)
+ with pytest.raises(RuntimeError, match="Scene.dynamics_capabilities"):
+ _supports_newton_position_targets(SimpleNamespace())
+
+
class _RigidEntity:
def __init__(self, handle: int = 7) -> None:
self.handle = handle
diff --git a/tests/sim/physics/test_simple_solver.py b/tests/sim/physics/test_simple_solver.py
new file mode 100644
index 000000000..147832b60
--- /dev/null
+++ b/tests/sim/physics/test_simple_solver.py
@@ -0,0 +1,250 @@
+# ----------------------------------------------------------------------------
+# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# ----------------------------------------------------------------------------
+"""Independent numerical expectations for the packaged spring-slider provider."""
+
+from __future__ import annotations
+
+import math
+
+import newton
+import numpy as np
+import pytest
+import torch
+import warp as wp
+
+from dexsim.engine import newton_physics
+
+if not hasattr(newton_physics, "ProviderNewtonSolverCfg"):
+ pytest.skip(
+ "Optional simple solver requires the DexSim direct-provider SDK; "
+ "run its integration acceptance with the matching SDK wheel",
+ allow_module_level=True,
+ )
+
+from dexsim.engine.newton_physics import (
+ NewtonSolverContext,
+ ProviderNewtonSolverCfg,
+ resolve_newton_solver,
+)
+from embodichain.lab.sim.cfg import NewtonPhysicsCfg
+from embodichain.lab.sim.physics.solvers.simple import SimpleSolverProvider
+
+
+def _model(device="cpu", *, joint="prismatic", collision=False, gravity=(0, 0, 0)):
+ builder = newton.ModelBuilder(gravity=gravity)
+ body = builder.add_link(mass=2.0, inertia=wp.mat33(np.eye(3)))
+ rotation = wp.quat_from_axis_angle(wp.vec3(0, 0, 1), math.pi / 2)
+ add_joint = getattr(builder, "add_joint_" + joint)
+ index = add_joint(
+ -1, body, axis=(1, 0, 0), parent_xform=wp.transform((1, 2, 3), rotation)
+ )
+ builder.add_articulation([index])
+ if collision:
+ builder.add_shape_sphere(body=body, radius=0.1)
+ return builder.finalize(device)
+
+
+def _extension(model, *, named=False, **options):
+ provider = resolve_newton_solver("simple" if named else SimpleSolverProvider)
+ return provider.create(
+ NewtonSolverContext(model, model.control(), None, model.device, 0.1, {}),
+ options,
+ )
+
+
+@pytest.mark.parametrize(
+ "device", ["cpu", pytest.param("cuda:0", marks=pytest.mark.gpu)]
+)
+@pytest.mark.parametrize("named", [False, True])
+def test_solver_native_parity_and_analytical_gradients(device, named):
+ model = _model(device)
+ extension = _extension(model, named=named, stiffness=0, damping=0)
+ dynamics = extension.functional
+ native = model.state()
+ newton.eval_fk(model, native.joint_q, native.joint_qd, native)
+ placed = wp.to_torch(native.body_q).clone()
+ placed[:, 0] += 5.0
+ native.body_q.assign(placed.detach().cpu().numpy())
+ initial = dynamics.create_state(native)
+ force = torch.tensor([4.0], device=device, requires_grad=True)
+ parameters = dynamics.parameters()
+ first = dynamics.step(initial, force, parameters, 0.1)
+ second = dynamics.step(first, force, parameters, 0.1)
+ # m=2, F=4: v1=.2, q1=.02; v2=.4, q2=.06.
+ torch.testing.assert_close(
+ second.physical["joint_q"], torch.tensor([0.06], device=device)
+ )
+ torch.testing.assert_close(
+ second.physical["body_q"][0, :3], torch.tensor([6.0, 2.06, 3.0], device=device)
+ )
+ grad_force, grad_scale = torch.autograd.grad(
+ second.physical["joint_q"].sum(), (force, parameters["force_scale"])
+ )
+ torch.testing.assert_close(grad_force, torch.tensor([0.015], device=device))
+ torch.testing.assert_close(grad_scale, torch.tensor(0.06, device=device))
+ control, output = model.control(), model.state()
+ control.joint_f.assign([4.0])
+ extension.native_solver.step(native, output, control, None, 0.1)
+ extension.native_solver.step(output, native, control, None, 0.1)
+ wp.synchronize_device(model.device)
+ torch.testing.assert_close(wp.to_torch(native.joint_q), second.physical["joint_q"])
+ torch.testing.assert_close(wp.to_torch(native.body_qd), second.physical["body_qd"])
+ torch.testing.assert_close(
+ initial.physical["joint_q"], torch.zeros(1, device=device)
+ )
+ torch.testing.assert_close(
+ first.physical["joint_q"], torch.tensor([0.02], device=device)
+ )
+
+
+def test_spring_damping_limits_and_mass_refresh():
+ model = _model()
+ extension = _extension(model, stiffness=2, damping=1)
+ native = model.state()
+ native.joint_q.assign([0.2])
+ native.joint_qd.assign([0.3])
+ service = extension.functional
+ state = service.create_state(native)
+ force = torch.tensor([4.0])
+ out = service.step(state, force, service.parameters(), 0.1)
+ # a=(4-2*.2-1*.3)/2=1.65; v=.465, q=.2465.
+ torch.testing.assert_close(out.physical["joint_q"], torch.tensor([0.2465]))
+ model.body_mass.assign([4.0])
+ model.joint_limit_upper.assign([0.23])
+ extension.native_solver.notify_model_changed(
+ newton.ModelFlags.BODY_INERTIAL_PROPERTIES
+ )
+ out = service.step(state, force, service.parameters(), 0.1)
+ torch.testing.assert_close(out.physical["joint_q"], torch.tensor([0.23]))
+ torch.testing.assert_close(out.physical["joint_qd"], torch.tensor([0.0]))
+ # Move the limit out again to observe the changed mass rather than only clamp.
+ model.joint_limit_upper.assign([1.0])
+ extension.native_solver.notify_model_changed(
+ newton.ModelFlags.JOINT_DOF_FORCE_PROPERTIES
+ )
+ out = service.step(state, force, service.parameters(), 0.1)
+ torch.testing.assert_close(out.physical["joint_q"], torch.tensor([0.23825]))
+
+
+@pytest.mark.parametrize(
+ "kwargs, message",
+ [
+ ({"joint": "revolute"}, "prismatic leaf"),
+ ({"collision": True}, "collision shapes"),
+ ({"gravity": (0, 0, -9.81)}, "zero gravity"),
+ ],
+)
+def test_unsupported_model_is_rejected(kwargs, message):
+ with pytest.raises(ValueError, match=message):
+ _extension(_model(**kwargs))
+
+
+def test_named_config_and_independent_provider_parameters():
+ cfg = NewtonPhysicsCfg(
+ device="cpu",
+ requires_grad=True,
+ solver_cfg={"solver_type": "simple", "stiffness": 3.0},
+ ).to_dexsim_cfg(gpu_id=0)
+ assert cfg.solver_cfg.solver_type == "simple"
+ assert cfg.solver_cfg.options == {"stiffness": 3.0}
+ first, second = _extension(_model()), _extension(_model())
+ with torch.no_grad():
+ first.functional.parameters()["force_scale"].fill_(2)
+ assert second.functional.parameters()["force_scale"].item() == 1
+
+
+def test_fixed_loop_cannot_be_treated_as_an_independent_slider():
+ builder = newton.ModelBuilder(gravity=(0, 0, 0))
+ inertia = wp.mat33(np.eye(3))
+ base = builder.add_link(mass=1.0, inertia=inertia)
+ slider = builder.add_link(mass=2.0, inertia=inertia)
+ other = builder.add_link(mass=1.0, inertia=inertia)
+ root = builder.add_joint_fixed(-1, base)
+ slide = builder.add_joint_prismatic(base, slider, axis=(1, 0, 0))
+ builder.add_articulation([root, slide])
+ other_root = builder.add_joint_fixed(-1, other)
+ builder.add_articulation([other_root])
+ builder.add_joint_fixed(other, slider, label="closed_loop_weld")
+ model = builder.finalize("cpu")
+ with pytest.raises(ValueError, match="independent prismatic leaf"):
+ _extension(model)
+
+
+@pytest.mark.requires_sim
+@pytest.mark.parametrize("owner", ["articulation", "manager"])
+@pytest.mark.parametrize("requires_grad", [False, True])
+def test_effort_only_reset_restores_selected_environment(
+ tmp_path, monkeypatch, owner, requires_grad
+):
+ from embodichain.lab.sim import SimulationManager, SimulationManagerCfg
+ from embodichain.lab.sim.cfg import ArticulationCfg
+
+ def no_discovery():
+ pytest.fail("Direct solver configuration must not discover entry points")
+
+ monkeypatch.setattr(
+ "dexsim.engine.newton_physics.solver_extensions._entry_points", no_discovery
+ )
+
+ asset = tmp_path / "slider.urdf"
+ asset.write_text("""
+
+
+
+ """)
+ sim = SimulationManager(
+ SimulationManagerCfg(
+ headless=True,
+ num_envs=2,
+ enable_entity_gizmo=False,
+ startup_summary="off",
+ physics_cfg=NewtonPhysicsCfg(
+ device="cpu",
+ gravity=(0, 0, 0),
+ requires_grad=requires_grad,
+ collision_cfg=None,
+ solver_cfg=ProviderNewtonSolverCfg(
+ provider_factory=SimpleSolverProvider
+ ),
+ ),
+ ),
+ requires_native_renderer=False,
+ )
+ art = None
+ try:
+ initial = 0.0 if requires_grad else 0.1
+ art = sim.add_articulation(
+ ArticulationCfg(uid="slider", fpath=str(asset), init_qpos=[initial])
+ )
+ sim.prepare()
+ torch.testing.assert_close(art.get_qpos(), torch.full((2, 1), initial))
+ art.set_qpos(torch.tensor([[0.2], [0.4]]), target=False)
+ art.set_qvel(torch.tensor([[0.3], [0.5]]), target=False)
+ with pytest.raises(NotImplementedError, match="joint_target_q"):
+ art.set_qpos(torch.zeros(2, 1), target=True)
+ if owner == "articulation":
+ art.reset(env_ids=[0])
+ else:
+ sim.reset_objects_state(env_ids=[0])
+ torch.testing.assert_close(art.get_qpos(), torch.tensor([[initial], [0.4]]))
+ torch.testing.assert_close(art.get_qvel(), torch.tensor([[0.0], [0.5]]))
+ torch.testing.assert_close(
+ art.get_qpos(target=True)[0], torch.tensor([initial])
+ )
+ finally:
+ art = None
+ sim.destroy(exit_process=False)
+ SimulationManager.flush_cleanup_queue()
diff --git a/tests/sim/test_sim_manager_cfg.py b/tests/sim/test_sim_manager_cfg.py
index 6adf8c294..2174a33cb 100644
--- a/tests/sim/test_sim_manager_cfg.py
+++ b/tests/sim/test_sim_manager_cfg.py
@@ -41,6 +41,25 @@ def test_simulation_manager_cfg_uses_default_physics_cfg() -> None:
assert type(cfg.physics_cfg) is DefaultPhysicsCfg
+@pytest.mark.no_sim
+def test_optional_solver_definition_is_inspectable_without_extension_sdk(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ import importlib.util
+ import inspect
+
+ from dexsim.engine import newton_physics as sdk
+
+ source = importlib.util.find_spec("embodichain.lab.sim.physics.solvers.simple")
+ monkeypatch.delattr(sdk, "FunctionalDynamics", raising=False)
+ spec = importlib.util.spec_from_file_location("_simple_sdk_probe", source.origin)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ assert inspect.isclass(module.SimpleSolverProvider)
+ with pytest.raises(ImportError, match="FunctionalDynamics"):
+ module.SimpleSolverProvider()
+
+
@pytest.mark.no_sim
def test_newton_physics_cfg_owns_backend_device_default() -> None:
"""The concrete Newton default shadows the generic backend default."""