Fourbar split solvers
Coupling
A four-bar linkage split between Kamino and MuJoCo or Steel.
kamino_steel preset (the default), recordedon an NVIDIA RTX PRO 6000 Blackwell.Run it
cd examples# the default preset (kamino_steel), then the othersuv run shelf play fourbar_split_solversuv run shelf play fourbar_split_solvers --solver kamino_mujoco# change solver settings liveuv run shelf tune fourbar_split_solversSource: examples/shelf/09_coupling/fourbar_split_solvers/ · ported from newton/examples/multiphysics/example_kamino_mujoco_admm_solver.py @ skild-ai/newton 7565a26b1
Presets
| Preset | Solver | Status | Checked |
|---|---|---|---|
kamino_steeldefault | coupled | works | 2026-11-04, newton 7565a26b1, NVIDIA GeForce RTX 3090 |
kamino_mujoco | coupled | works | 2026-10-28, newton 7565a26b1, NVIDIA RTX A6000 |
Solvers with no row are untested on this example. The full settings are inpresets.toml.
Code
# SPDX-FileCopyrightText: Copyright (c) 2026 The Newton Developers# SPDX-License-Identifier: Apache-2.0# Modified for Steel Shelf: preset-driven execution and shared runner integration.
############################################################################ Example Kamino-MuJoCo ADMM Coupled Solver## A closed four-bar linkage is split across two rigid solvers: two links are# owned by Kamino and two links are owned by MuJoCo (or Steel).# SolverCoupledADMM detects the model revolute joints that connect bodies owned# by different solvers and turns them into ADMM attachments.## Command: python -m shelf run fourbar_split_solvers# python -m shelf run fourbar_split_solvers --solver kamino_steel############################################################################
from __future__ import annotations
from collections.abc import Callable
import numpy as npimport warp as wpfrom newton.solvers.experimental.coupled import ModelView, SolverCoupled, SolverCoupledADMM
import newtonimport newton.examplesfrom newton.solvers import SolverKaminofrom common import presets
def _configure_kamino_rigid_view(view: ModelView) -> None: # Kamino normalizes world ids and re-expresses body-local frames during # conversion. Keep those writes local to this mixed Kamino/MuJoCo view. for name in ( "body_world", "body_world_start", "joint_world", "joint_world_start", "shape_world", "shape_world_start", "body_com", "body_inertia", "shape_transform", "joint_X_p", "joint_X_c", ): setattr(view, name, wp.clone(getattr(view, name)))
def _make_kamino_config(settings: dict[str, object]) -> SolverKamino.Config: # Start from SolverKamino.Config() (not Config.from_model) and apply the preset's # settings; nested tables set fields of the nested configs. config = SolverKamino.Config() for key, value in settings.items(): if isinstance(value, dict): for sub_key, sub_value in value.items(): setattr(getattr(config, key), sub_key, sub_value) else: setattr(config, key, value) return config
def _capture_frame_graph(model: newton.Model, simulate: Callable[[], None], *, enabled: bool = True): if not enabled: return None
with wp.ScopedDevice(model.device): with wp.ScopedCapture() as capture: simulate()
if capture.graph is None: raise RuntimeError(f"Graph capture failed on device {model.device}") return capture.graph
def _launch_frame_graph(model: newton.Model, graph) -> bool: if graph is None: return False
with wp.ScopedDevice(model.device): wp.capture_launch(graph) return True
def _quat_rotate(q: np.ndarray, v: np.ndarray) -> np.ndarray: qv = q[:3] qw = q[3] return v + 2.0 * np.cross(qv, np.cross(qv, v) + qw * v)
def _transform_point(body_q: np.ndarray, body: int, point: np.ndarray) -> np.ndarray: return body_q[body, :3] + _quat_rotate(body_q[body, 3:7], point)
def _quat_from_x_axis(direction: np.ndarray) -> wp.quat: direction = np.asarray(direction, dtype=np.float32) direction = direction / np.linalg.norm(direction) source = np.array([1.0, 0.0, 0.0], dtype=np.float32) axis = np.cross(source, direction) w = 1.0 + float(np.dot(source, direction)) if w < 1.0e-6: axis = np.array([0.0, 1.0, 0.0], dtype=np.float32) w = 0.0 quat = np.array([axis[0], axis[1], axis[2], w], dtype=np.float32) quat /= np.linalg.norm(quat) return wp.quat(float(quat[0]), float(quat[1]), float(quat[2]), float(quat[3]))
class Example: def __init__(self, viewer, args): self.preset = presets.load(__file__) self.viewer = viewer self.sim_time = 0.0 self.fps = self.preset.fps self.frame_dt = 1.0 / self.fps self.sim_substeps = self.preset.substeps self.sim_dt = self.frame_dt / self.sim_substeps self.use_graph = bool(args.graph_capture) self.world_count = max(1, int(args.world_count))
template = newton.ModelBuilder(gravity=(0.0, 0.0, -9.81)) SolverKamino.register_custom_attributes(template) self._joint_checks: list[tuple[int, np.ndarray, int, np.ndarray]] = [] self._emit_four_bar(template)
bodies_per_world = template.body_count joints_per_world = template.joint_count
builder = newton.ModelBuilder(gravity=(0.0, 0.0, -9.81)) builder.replicate(template, world_count=self.world_count) builder.add_ground_plane() self._expand_world_indices(bodies_per_world, joints_per_world)
builder.color() self.model = builder.finalize() self.model.rigid_contact_max = 96
self.device = self.model.device
self.build_solver()
self.state_0 = self.model.state() self.state_1 = self.model.state() self.collision_pipeline = newton.CollisionPipeline(self.model) self.contacts = self.collision_pipeline.contacts() self.control = self.model.control()
newton.examples.configure_coupled_view(self, args) self.viewer.set_world_offsets((1.35, 1.35, 0.0)) if isinstance(self.viewer, newton.viewer.ViewerGL): scale = max(1.0, float(np.sqrt(self.world_count))) self.viewer.set_camera(pos=wp.vec3(0.55 * scale, -2.4 * scale, 1.45 * scale), pitch=-14.0, yaw=105.0) if hasattr(self.viewer.camera, "look_at"): self.viewer.camera.look_at(wp.vec3(0.05, 0.0, 1.25))
newton.eval_fk(self.model, self.model.joint_q, self.model.joint_qd, self.state_0) newton.eval_fk(self.model, self.model.joint_q, self.model.joint_qd, self.state_1)
self.capture()
def build_solver(self): """Build the solver and everything bound to its instance; the tuner calls this again to swap it.""" kamino_config = _make_kamino_config(self.preset.entry("kamino").solver)
# Presets are named "kamino_<rigid>"; the rigid solver owns the ground link and the coupler. self.rigid_solver = self.preset.name.removeprefix("kamino_") rigid_entry = self.preset.entry(self.rigid_solver) rigid_name = "steel" if self.rigid_solver == "steel" else "mjc"
def rigid_factory(v): return rigid_entry.make_solver(v)
self.solver = SolverCoupledADMM( model=self.model, entries=[ SolverCoupled.Entry( name="kamino", solver=lambda v: SolverKamino(model=v, config=kamino_config), bodies=self.kamino_bodies, joints=self.kamino_joints, configure_view=_configure_kamino_rigid_view, ), SolverCoupled.Entry( name=rigid_name, solver=rigid_factory, bodies=self.mujoco_bodies, joints=self.mujoco_joints, ), ], coupling=SolverCoupledADMM.Config( **self.preset.solver, # Steel warns on and ignores the revolute joint proxies, so only Kamino carries them. joint_proximal_destination_entries=("kamino",) if self.rigid_solver == "steel" else None, ), )
def _emit_four_bar(self, builder: newton.ModelBuilder) -> None: left_base = np.array([-0.48, 0.0, 1.05], dtype=np.float32) right_base = np.array([0.48, 0.0, 1.05], dtype=np.float32) left_top = np.array([-0.30, 0.0, 1.55], dtype=np.float32) right_top = np.array([0.34, 0.0, 1.46], dtype=np.float32) ground_center = 0.5 * (left_base + right_base) ground_quat = _quat_from_x_axis(right_base - left_base)
self.mujoco_ground, ground_left, ground_right = self._add_bar( builder, left_base, right_base, label="mujoco_ground_link", color=(0.45, 0.48, 0.54), mass=0.0, ) self.mujoco_ground_joint = builder.add_joint_fixed( parent=-1, child=self.mujoco_ground, parent_xform=wp.transform(p=wp.vec3(*ground_center), q=ground_quat), label="mujoco_ground_fixed", ) builder.add_articulation([self.mujoco_ground_joint], label="mujoco_ground")
self.kamino_crank, crank_base, crank_top = self._add_bar( builder, left_base, left_top, label="kamino_crank", color=(0.16, 0.43, 0.86), mass=0.35, ) self.kamino_crank_joint = builder.add_joint_free(child=self.kamino_crank, label="kamino_crank_free") builder.add_articulation([self.kamino_crank_joint], label="kamino_crank_articulation")
self.mujoco_coupler, coupler_left, coupler_right = self._add_bar( builder, left_top, right_top, label="mujoco_coupler", color=(0.91, 0.47, 0.20), mass=0.42, ) self.mujoco_coupler_joint = builder.add_joint_free(child=self.mujoco_coupler, label="mujoco_coupler_free") builder.add_articulation([self.mujoco_coupler_joint], label="mujoco_coupler_articulation")
self.kamino_rocker, rocker_base, rocker_top = self._add_bar( builder, right_base, right_top, label="kamino_rocker", color=(0.08, 0.58, 0.72), mass=0.36, ) self.kamino_rocker_joint = builder.add_joint_free(child=self.kamino_rocker, label="kamino_rocker_free") builder.add_articulation([self.kamino_rocker_joint], label="kamino_rocker_articulation")
self._add_cross_joint(builder, self.mujoco_ground, ground_left, self.kamino_crank, crank_base, "left_base") self._add_cross_joint(builder, self.kamino_crank, crank_top, self.mujoco_coupler, coupler_left, "left_top") self._add_cross_joint(builder, self.mujoco_coupler, coupler_right, self.kamino_rocker, rocker_top, "right_top") self._add_cross_joint(builder, self.kamino_rocker, rocker_base, self.mujoco_ground, ground_right, "right_base")
self.kamino_bodies = [self.kamino_crank, self.kamino_rocker] self.kamino_joints = [self.kamino_crank_joint, self.kamino_rocker_joint] self.mujoco_bodies = [self.mujoco_ground, self.mujoco_coupler] self.mujoco_joints = [self.mujoco_ground_joint, self.mujoco_coupler_joint]
def _expand_world_indices(self, bodies_per_world: int, joints_per_world: int) -> None: """Expand one-world body/joint ids to all replicated worlds."""
def expand(ids: list[int], stride: int) -> list[int]: return [world * stride + id_ for world in range(self.world_count) for id_ in ids]
self.kamino_bodies = expand(self.kamino_bodies, bodies_per_world) self.kamino_joints = expand(self.kamino_joints, joints_per_world) self.mujoco_bodies = expand(self.mujoco_bodies, bodies_per_world) self.mujoco_joints = expand(self.mujoco_joints, joints_per_world) self._joint_checks = [ (world * bodies_per_world + parent, parent_point, world * bodies_per_world + child, child_point) for world in range(self.world_count) for parent, parent_point, child, child_point in self._joint_checks ]
def _add_bar( self, builder: newton.ModelBuilder, point_a: np.ndarray, point_b: np.ndarray, *, label: str, color: tuple[float, float, float], mass: float, ) -> tuple[int, np.ndarray, np.ndarray]: axis = np.asarray(point_b - point_a, dtype=np.float32) length = float(np.linalg.norm(axis)) half_length = 0.5 * length center = 0.5 * (point_a + point_b) inertia_scale = max(mass, 1.0e-3) * length * length / 12.0 inertia = wp.mat33(np.eye(3, dtype=np.float32) * inertia_scale) body = builder.add_link( xform=wp.transform(p=wp.vec3(*center), q=_quat_from_x_axis(axis)), mass=mass, inertia=inertia, label=label, ) builder.add_shape_box(body, hx=half_length, hy=0.035, hz=0.035, color=color) return ( body, np.array([-half_length, 0.0, 0.0], dtype=np.float32), np.array([half_length, 0.0, 0.0], dtype=np.float32), )
def _add_cross_joint( self, builder: newton.ModelBuilder, parent: int, parent_point: np.ndarray, child: int, child_point: np.ndarray, label: str, ) -> None: builder.add_joint_revolute( parent=parent, child=child, parent_xform=wp.transform(p=wp.vec3(*parent_point), q=wp.quat_identity()), child_xform=wp.transform(p=wp.vec3(*child_point), q=wp.quat_identity()), axis=wp.vec3(0.0, 1.0, 0.0), friction=0.001, label=f"kamino_mujoco_four_bar_{label}", collision_filter_parent=True, ) self._joint_checks.append((parent, parent_point, child, child_point))
def capture(self): self.graph = _capture_frame_graph(self.model, self.simulate, enabled=self.use_graph)
def simulate(self): need_state_copy = self.use_graph and self.sim_substeps % 2 == 1
for i in range(self.sim_substeps): self.state_0.clear_forces() newton.examples.apply_coupled_viewer_forces(self, self.state_0) self.solver.step(self.state_0, self.state_1, self.control, self.contacts, self.sim_dt) if need_state_copy and i == self.sim_substeps - 1: self.state_0.assign(self.state_1) else: self.state_0, self.state_1 = self.state_1, self.state_0
def step(self): if not _launch_frame_graph(self.model, self.graph): self.simulate() self.sim_time += self.frame_dt
def test_final(self): body_q = self.state_0.body_q.numpy() body_qd = self.state_0.body_qd.numpy() assert np.all(np.isfinite(body_q)), "Body positions contain NaN or inf values" assert np.all(np.isfinite(body_qd)), "Body velocities contain NaN or inf values"
max_gap = 0.0 for parent_body, parent_point, child_body, child_point in self._joint_checks: parent = _transform_point(body_q, parent_body, parent_point) child = _transform_point(body_q, child_body, child_point) max_gap = max(max_gap, float(np.linalg.norm(parent - child))) assert max_gap < 0.12, f"Kamino-MuJoCo four-bar ADMM joints drifted too far: gap={max_gap:.3f}" if self.use_graph: assert self.graph is not None, "Graph capture was requested but no graph was captured"
def render(self): self.viewer.begin_frame(self.sim_time) newton.examples.log_coupled_view(self, self.contacts) self.viewer.end_frame()
@staticmethod def create_parser(): parser = newton.examples.create_parser() newton.examples.add_coupled_view_args(parser) newton.examples.add_world_count_arg(parser) parser.set_defaults(world_count=4) parser.add_argument( "--no-graph-capture", action="store_false", dest="graph_capture", default=True, help="Disable graph capture.", ) return parser
if __name__ == "__main__": parser = Example.create_parser() viewer, args = newton.examples.init(parser) example = Example(viewer, args) newton.examples.run(example, args)example = "fourbar_split_solvers"source = "newton/examples/multiphysics/example_kamino_mujoco_admm_solver.py @ skild-ai/newton 7565a26b1"default = "kamino_steel"
[check]# Arguments `python -m shelf check` passes in test mode (from Newton's test_examples.py).args = ["--num-frames", "30", "--world-count", "4"]
[gallery]short = "A four-bar linkage split between Kamino and MuJoCo or Steel."
[xpbd]status = "untested"
[vbd]status = "untested"
[mujoco]status = "untested"
[featherstone]status = "untested"
[semi_implicit]status = "untested"
[feather_pgs]status = "untested"
[steel]status = "untested"
[kamino]status = "untested"
[implicit_mpm]status = "untested"
[style3d]status = "untested"
[kamino_mujoco]status = "works"sim = { fps = 60, substeps = 3 }solver = { iterations = 2, rho = 50.0, gamma = 0.1, baumgarte = 0.02, joint_stiffness = 2.0e4, joint_angular_stiffness = 2.0e4, joint_damping = 1.0, joint_angular_damping = 1.0 }entries.kamino.solver = { use_collision_detector = false, use_fk_solver = false, dynamics = { preconditioning = true }, padmm = { max_iterations = 40, primal_tolerance = 1.0e-5, dual_tolerance = 1.0e-5, compl_tolerance = 1.0e-5, rho_0 = 0.1, use_acceleration = true, warmstart_mode = "containers" } }entries.mujoco.solver = { use_mujoco_contacts = false, njmax = 64, nconmax = 64 }checked = "2026-10-28, newton 7565a26b1, NVIDIA RTX A6000"
[kamino_steel]status = "works"sim = { fps = 60, substeps = 3 }solver = { iterations = 2, rho = 50.0, gamma = 0.1, baumgarte = 0.02, joint_stiffness = 2.0e4, joint_angular_stiffness = 2.0e4, joint_damping = 1.0, joint_angular_damping = 1.0 }entries.kamino.solver = { use_collision_detector = false, use_fk_solver = false, dynamics = { preconditioning = true }, padmm = { max_iterations = 40, primal_tolerance = 1.0e-5, dual_tolerance = 1.0e-5, compl_tolerance = 1.0e-5, rho_0 = 0.1, use_acceleration = true, warmstart_mode = "containers" } }# The two Steel links carry no Steel constraint rows (the four-bar closes through ADMM attachments; no contacts or# joint limits), so iterations 1, 4 and 12 give identical joints; 4 measured fastest (6.60 ms/frame vs Kamino-MuJoCo 7.18).# 3 substeps x 2 ADMM iterations stay: the ADMM joint gap is 1.14 mm peak / 0.33 mm mean vs Kamino-MuJoCo's# 0.31 / 0.22; more ADMM iterations (3: 0.75 / 0.31 mm) or substeps (4: 0.88 / 0.24 mm) narrow it but cost 9.0-9.7 ms.entries.steel.solver = { solve_mode = "matrix_free", iterations = 4 }checked = "2026-11-04, newton 7565a26b1, NVIDIA GeForce RTX 3090"Steel docs and tutorials are licensed under Apache-2.0. Third-party notices. "Skild", "Skild AI" and the Skild logo are trademarks of Skild AI and are not licensed under Apache-2.0.