Proxy joint gripper
Coupling
A palm and two cube fingers grip a VBD soft grid.
steel_vbd preset (the default), recordedon an NVIDIA RTX PRO 6000 Blackwell.Run it
cd examples# the default preset (steel_vbd), then the othersuv run shelf play proxy_joint_gripperuv run shelf play proxy_joint_gripper --solver mujoco_vbd# change solver settings liveuv run shelf tune proxy_joint_gripperSource: examples/shelf/09_coupling/proxy_joint_gripper/ · ported from newton/examples/multiphysics/example_proxy_joint_gripper.py @ skild-ai/newton 7565a26b1
Presets
| Preset | Solver | Status | Checked |
|---|---|---|---|
steel_vbddefault | coupled | works | 2026-11-04, newton 7565a26b1, NVIDIA GeForce RTX 3090 |
mujoco_vbd | 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 Proxy Joint Gripper## A MuJoCo- or Steel-driven palm and two prismatic cube fingers grip a# VBD soft grid. The VBD entry receives the gripper bodies as body proxies and# keeps the fixed/prismatic joints enabled as proxy joints whose targets track# the rigid source joint configuration.## Command: python -m shelf run proxy_joint_gripper# python -m shelf run proxy_joint_gripper --solver steel_vbd############################################################################
from __future__ import annotations
import numpy as npimport warp as wpfrom newton.solvers.experimental.coupled import SolverCoupledProxy
import newtonimport newton.examplesfrom newton.solvers import SolverMuJoCo, SolverVBDfrom common import presets
@wp.kerneldef _set_finger_targets( joint_target_q: wp.array[float], left_target_index: int, right_target_index: int, target: float,): joint_target_q[left_target_index] = target joint_target_q[right_target_index] = target
class Example: def __init__(self, viewer, args): self.preset = presets.load(__file__) self.viewer = viewer self.args = args self.fps = self.preset.fps self.frame_dt = 1.0 / self.fps self.sim_substeps = self.preset.substeps self.use_graph = bool(args.graph_capture) if self.use_graph and self.sim_substeps % 2 != 0: raise ValueError("Graph capture requires an even number of simulation substeps") self.sim_dt = self.frame_dt / float(self.sim_substeps) self.sim_time = 0.0 self.scenario = args.scenario self.close_distance = ( float(args.close_distance) if args.close_distance is not None else (0.052 if self.scenario == "harsh" else 0.04) ) self.close_time = ( float(args.close_time) if args.close_time is not None else (0.3 if self.scenario == "harsh" else 0.8) )
self.viewer.set_camera(pos=wp.vec3(0.32, -0.42, 0.34), pitch=-24.0, yaw=136.0)
builder = newton.ModelBuilder(gravity=(0.0, 0.0, 0.0)) SolverMuJoCo.register_custom_attributes(builder) SolverVBD.register_custom_attributes(builder) builder.default_particle_radius = 0.01
self.soft_particle_start = builder.particle_count self._emit_soft_object(builder) self.soft_particle_end = builder.particle_count
self.gripper_bodies, self.gripper_joints = self._emit_gripper(builder) self.fixed_joint, self.left_joint, self.right_joint = self.gripper_joints
builder.color() self.model = builder.finalize() newton.eval_fk(self.model, self.model.joint_q, self.model.joint_qd, self.model)
self.model.soft_contact_ke = 1.5e5 if self.scenario == "harsh" else 5.0e4 self.model.soft_contact_kd = 1.0e-4 self.model.soft_contact_kf = 1.0e3 self.model.soft_contact_mu = 2.0
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.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.left_target_index = int(self.model.joint_target_q_start.numpy()[self.left_joint]) self.right_target_index = int(self.model.joint_target_q_start.numpy()[self.right_joint]) self.left_q_index = int(self.model.joint_q_start.numpy()[self.left_joint]) self.right_q_index = int(self.model.joint_q_start.numpy()[self.right_joint]) self.initial_particle_bounds = self._particle_bounds()
self.build_solver() newton.examples.configure_coupled_view(self, args) self.capture()
def build_solver(self) -> None: """Build the solver and everything bound to its instance; the tuner calls this again to swap it.""" # Scenario-dependent VBD settings; the rest come from the preset's vbd entry. vbd_kwargs = { "rigid_body_particle_contact_buffer_size": 1024 if self.scenario == "harsh" else 512, "rigid_joint_linear_ke": 5.0e5 if self.scenario == "harsh" else 2.0e7, "rigid_joint_angular_ke": 5.0e5 if self.scenario == "harsh" else 2.0e6, }
# Presets are named "<rigid>_vbd"; the rigid solver drives the gripper. rigid_solver = self.preset.name.split("_")[0] rigid_name = "steel" if rigid_solver == "steel" else "mjc" rigid_entry = self.preset.entry(rigid_solver) vbd_entry = self.preset.entry("vbd") coupling = self.preset.solver
def rigid_factory(v): return rigid_entry.make_solver(v)
self.solver = SolverCoupledProxy( model=self.model, entries=[ SolverCoupledProxy.Entry( name=rigid_name, solver=rigid_factory, bodies=self.gripper_bodies, joints=self.gripper_joints, ), SolverCoupledProxy.Entry( name="vbd", solver=lambda v: vbd_entry.make_solver(v, **vbd_kwargs), particles=list(range(self.soft_particle_start, self.soft_particle_end)), ), ], coupling=SolverCoupledProxy.Config( proxies=[ SolverCoupledProxy.Proxy( source=rigid_name, destination="vbd", bodies=self.gripper_bodies, joints=self.gripper_joints if coupling.proxy_joints else (), mass_scale=coupling.mass_scale, mode=coupling.coupling_mode, proxy_relaxation=coupling.proxy_relaxation, proxy_relaxation_mode=coupling.proxy_relaxation_mode, proxy_relaxation_min=coupling.proxy_relaxation_min, proxy_relaxation_max=coupling.proxy_relaxation_max, collision_pipeline=lambda model: newton.examples.create_collision_pipeline( model, broad_phase="explicit", ), collide_interval=coupling.collide_interval, ) ], iterations=coupling.proxy_iterations, ), )
def capture(self) -> None: self.graph = None if not self.use_graph: return
with wp.ScopedDevice(self.model.device), wp.ScopedCapture() as capture: self.simulate() self.graph = capture.graph if self.graph is None: raise RuntimeError(f"Graph capture failed on device {self.model.device}")
def _emit_soft_object(self, builder: newton.ModelBuilder) -> None: size = 0.1 if self.scenario == "harsh" else 0.09 cell = size / 3.0 y_offset = 0.03 if self.scenario == "harsh" else 0.0 builder.add_soft_grid( pos=wp.vec3(0.0, y_offset - 0.5 * size, 0.12 - 0.5 * size), rot=wp.quat_identity(), vel=wp.vec3(0.0, 0.0, 0.0), dim_x=1, dim_y=3, dim_z=3, cell_x=0.045, cell_y=cell, cell_z=cell, density=450.0 if self.scenario == "harsh" else 180.0, k_mu=1.2e4 if self.scenario == "harsh" else 2.0e3, k_lambda=3.6e4 if self.scenario == "harsh" else 6.0e3, k_damp=0.25 if self.scenario == "harsh" else 0.1, particle_radius=0.014 if self.scenario == "harsh" else 0.012, label="soft_grip_block", )
def _emit_gripper(self, builder: newton.ModelBuilder) -> tuple[list[int], list[int]]: contact_cfg = newton.ModelBuilder.ShapeConfig( density=800.0, ke=1.5e5 if self.scenario == "harsh" else 8.0e4, kd=1.0e-4, kf=1.0e3, mu=0.7, ) palm_pos = wp.vec3(-0.07, 0.0, 0.12) finger_y = 0.105 palm = builder.add_link(xform=wp.transform(p=palm_pos, q=wp.quat_identity()), label="hand_palm") left = builder.add_link( xform=wp.transform(p=wp.vec3(0.0, finger_y, 0.12), q=wp.quat_identity()), label="left_finger", ) right = builder.add_link( xform=wp.transform(p=wp.vec3(0.0, -finger_y, 0.12), q=wp.quat_identity()), label="right_finger", )
builder.add_shape_box( palm, hx=0.025, hy=0.16, hz=0.055, cfg=contact_cfg, color=wp.vec3(0.22, 0.28, 0.34), label="hand_rectangle", ) builder.add_shape_box( left, hx=0.045, hy=0.035, hz=0.045, cfg=contact_cfg, color=wp.vec3(0.88, 0.48, 0.22), label="left_cube_finger", ) builder.add_shape_box( right, hx=0.045, hy=0.035, hz=0.045, cfg=contact_cfg, color=wp.vec3(0.88, 0.48, 0.22), label="right_cube_finger", )
fixed_joint = builder.add_joint_fixed( parent=-1, child=palm, parent_xform=wp.transform(p=palm_pos, q=wp.quat_identity()), child_xform=wp.transform(p=wp.vec3(0.0, 0.0, 0.0), q=wp.quat_identity()), label="hand_world_fixed", ) left_joint = builder.add_joint_prismatic( parent=palm, child=left, axis=wp.vec3(0.0, -1.0, 0.0), parent_xform=wp.transform(p=wp.vec3(0.07, finger_y, 0.0), q=wp.quat_identity()), child_xform=wp.transform(p=wp.vec3(0.0, 0.0, 0.0), q=wp.quat_identity()), target_pos=0.0, target_vel=0.0, target_ke=5.0e4 if self.scenario == "harsh" else 2.0e4, target_kd=500.0 if self.scenario == "harsh" else 250.0, limit_lower=0.0, limit_upper=0.055, limit_ke=5.0e4, limit_kd=100.0, effort_limit=500.0 if self.scenario == "harsh" else 250.0, label="left_finger_slide", ) right_joint = builder.add_joint_prismatic( parent=palm, child=right, axis=wp.vec3(0.0, 1.0, 0.0), parent_xform=wp.transform(p=wp.vec3(0.07, -finger_y, 0.0), q=wp.quat_identity()), child_xform=wp.transform(p=wp.vec3(0.0, 0.0, 0.0), q=wp.quat_identity()), target_pos=0.0, target_vel=0.0, target_ke=5.0e4 if self.scenario == "harsh" else 2.0e4, target_kd=500.0 if self.scenario == "harsh" else 250.0, limit_lower=0.0, limit_upper=0.055, limit_ke=5.0e4, limit_kd=100.0, effort_limit=500.0 if self.scenario == "harsh" else 250.0, label="right_finger_slide", ) builder.add_articulation([fixed_joint, left_joint, right_joint], label="proxy_joint_gripper") return [palm, left, right], [fixed_joint, left_joint, right_joint]
def _update_gripper_target(self) -> None: if self.control.joint_target_q is None: return alpha = min(1.0, self.sim_time / self.close_time) target = self.close_distance * alpha wp.launch( _set_finger_targets, dim=1, inputs=[ self.control.joint_target_q, self.left_target_index, self.right_target_index, target, ], device=self.model.device, )
def _particle_bounds(self) -> tuple[np.ndarray, np.ndarray]: particle_q = self.state_0.particle_q.numpy()[self.soft_particle_start : self.soft_particle_end] return np.min(particle_q, axis=0), np.max(particle_q, axis=0)
def simulate(self) -> None: self.collision_pipeline.collide(self.state_0, self.contacts) for _ in range(self.sim_substeps): self.state_0.clear_forces() self.solver.step(self.state_0, self.state_1, self.control, self.contacts, self.sim_dt) self.state_0, self.state_1 = self.state_1, self.state_0
def step(self) -> None: self._update_gripper_target() if self.graph is not None: with wp.ScopedDevice(self.model.device): wp.capture_launch(self.graph) else: self.simulate() self.sim_time += self.frame_dt
def test_final(self) -> None: joint_q = self.state_0.joint_q.numpy() left_q = float(joint_q[self.left_q_index]) right_q = float(joint_q[self.right_q_index]) assert left_q > 0.015, f"Left finger did not close enough: q={left_q:.5f}" assert right_q > 0.015, f"Right finger did not close enough: q={right_q:.5f}"
particle_q = self.state_0.particle_q.numpy()[self.soft_particle_start : self.soft_particle_end] assert np.all(np.isfinite(particle_q)), "Soft object particle positions contain non-finite values" min_pos = np.min(particle_q, axis=0) max_pos = np.max(particle_q, axis=0) initial_min, initial_max = self.initial_particle_bounds bbox_limit = 0.8 if self.scenario == "harsh" else 0.35 bbox_size = float(np.linalg.norm(max_pos - min_pos)) assert bbox_size < bbox_limit, f"Soft object expanded beyond expected bounds: size={bbox_size:.5f}"
center_z = float(np.mean(particle_q[:, 2])) initial_center_z = float(0.5 * (initial_min[2] + initial_max[2])) center_drift = abs(center_z - initial_center_z) center_drift_limit = 0.16 if self.scenario == "harsh" else 0.06 assert center_drift < center_drift_limit, ( f"Soft object drifted unexpectedly from the gripper: center_drift={center_drift:.5f}" )
def render(self) -> None: 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() parser.set_defaults(num_frames=120) parser.add_argument( "--scenario", type=str, choices=["default", "harsh"], default="default", help="Scene configuration to run.", ) newton.examples.add_coupled_view_args(parser) parser.add_argument("--close-distance", type=float, default=None, help="Final inward finger target [m].") parser.add_argument("--close-time", type=float, default=None, help="Finger closing ramp duration [s].") 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 = "proxy_joint_gripper"source = "newton/examples/multiphysics/example_proxy_joint_gripper.py @ skild-ai/newton 7565a26b1"default = "steel_vbd"
[check]# Arguments `python -m shelf check` passes in test mode (from Newton's test_examples.py).args = ["--num-frames", "120"]
[gallery]short = "A palm and two cube fingers grip a VBD soft grid."
[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"
[mujoco_vbd]status = "works"sim = { fps = 60, substeps = 6 }solver = { coupling_mode = "lagged", proxy_iterations = 1, mass_scale = 1.0, proxy_relaxation = 1.0, proxy_relaxation_mode = "fixed", proxy_relaxation_min = 0.1, proxy_relaxation_max = 1.0, proxy_joints = true, collide_interval = 1 }entries.mujoco.solver = { iterations = 20, disable_contacts = true, use_mujoco_contacts = false }# rigid_body_particle_contact_buffer_size and rigid_joint_*_ke depend on --scenario and are set in example.py.entries.vbd.solver = { iterations = 30, particle_enable_self_contact = false, particle_enable_tile_solve = false }checked = "2026-10-28, newton 7565a26b1, NVIDIA RTX A6000"
[steel_vbd]status = "works"# 2 substeps x 8 Steel iterations: 11.8 ms/frame vs MuJoCo's 37.0 at 6 (Steel at 6: 36.4). The fingers close to the# same 38.7 mm, the soft grid keeps its 128-129 mm extent and does not drift, penetration 0.01 / 0.007 mm vs MuJoCo's# 0.02 / 0.010. Odd substep counts cannot be graph-captured; iterations 4-20 measure the same.sim = { fps = 60, substeps = 2 }solver = { coupling_mode = "lagged", proxy_iterations = 1, mass_scale = 1.0, proxy_relaxation = 1.0, proxy_relaxation_mode = "fixed", proxy_relaxation_min = 0.1, proxy_relaxation_max = 1.0, proxy_joints = true, collide_interval = 1 }# Joint limits on as MuJoCo enforces them (the fingers' 0-55 mm travel); off measures the same speed and result.entries.steel.solver = { solve_mode = "matrix_free", iterations = 8, enable_joint_limits = true }# rigid_body_particle_contact_buffer_size and rigid_joint_*_ke depend on --scenario and are set in example.py.entries.vbd.solver = { iterations = 30, particle_enable_self_contact = false, particle_enable_tile_solve = false }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.