Newton cradle
Basic contacts
A five-ball Newton's cradle: one ball swings in, one swings out.
steel preset (the default), recordedon an NVIDIA RTX PRO 6000 Blackwell.Run it
cd examples# the default preset (steel), then the othersuv run shelf play newton_cradleuv run shelf play newton_cradle --solver xpbduv run shelf play newton_cradle --solver vbduv run shelf play newton_cradle --solver mujocouv run shelf play newton_cradle --solver featherstoneuv run shelf play newton_cradle --solver kamino# change solver settings liveuv run shelf tune newton_cradleSource: examples/shelf/01_basic_contacts/newton_cradle/ · ported from newton/examples/contacts/example_newton_cradle.py @ skild-ai/newton 7565a26b1
Presets
| Preset | Solver | Status | Checked |
|---|---|---|---|
steeldefault | steel | works | 2026-11-13, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition |
xpbd | xpbd | works | 2026-11-13, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition |
vbd | vbd | works | 2026-11-13, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition |
mujoco | mujoco | works | 2026-11-13, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition |
featherstone | featherstone | works | 2026-11-13, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition |
kamino | kamino | works | 2026-11-13, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition |
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. The balls hang# 5 mm apart on rubbing hinges, as in MuJoCo's speculative-contact cradle (model/speculative/newton.xml).
############################################################################ Example Newton Cradle## Builds a five-ball Newton's cradle with revolute-joint pendulums and# sphere-sphere contact. Each impact is one pair of balls: the balls hang# a small clearance apart, and the contact gap lets the closing pair touch# within one substep instead of overlapping.## Command: python -m shelf run newton_cradle [--lifted-balls 2]############################################################################
from __future__ import annotations
import math
import warp as wp
import newtonimport newton.examplesfrom common import presets
NUM_BALLS = 5BALL_MASS = 1.0BALL_RADIUS = 0.05STRING_LENGTH = 1.0INITIAL_ANGLE = math.radians(45.0)GRAVITY = -9.81# Touching balls make every impact a simultaneous chain of contacts, which a velocity solver shares# out over all the balls; a clearance wider than one substep of travel resolves one pair at a time.BALL_CLEARANCE = 0.005# Coulomb hinge torque [N m]: holds a struck middle ball where it stops and slowly damps the swing.HINGE_FRICTION = 0.08
NATIVE_CONTACT_SOLVERS = {"mujoco", "kamino"}
class Example: def __init__(self, viewer, args): self.preset = presets.load(__file__) self.viewer = viewer self.solver_name = self.preset.name
self.fps = self.preset.fps self.frame_dt = 1.0 / self.fps self.sim_time = 0.0 self.sim_substeps = self.preset.substeps self.sim_dt = self.frame_dt / self.sim_substeps
builder = newton.ModelBuilder(gravity=(0.0, 0.0, GRAVITY)) builder.rigid_gap = BALL_CLEARANCE builder.default_shape_cfg.ke = 1.0e5 builder.default_shape_cfg.kd = 0.0 builder.default_shape_cfg.kf = 0.0 self.preset.apply_model(builder) # friction coefficient mu builder.default_shape_cfg.restitution = 1.0
self.lifted_balls = args.lifted_balls if args is not None else 1 pitch = 2.0 * BALL_RADIUS + BALL_CLEARANCE total_width = (NUM_BALLS - 1) * pitch x_start = -0.5 * total_width joint_indices = []
for i in range(NUM_BALLS): pivot_x = x_start + i * pitch angle = INITIAL_ANGLE if i < self.lifted_balls else 0.0 ball_pos = wp.vec3( pivot_x + STRING_LENGTH * math.sin(angle), 0.0, -STRING_LENGTH * math.cos(angle), )
inertia = 0.4 * BALL_MASS * BALL_RADIUS * BALL_RADIUS body = builder.add_link( xform=wp.transform(p=ball_pos, q=wp.quat_identity()), mass=BALL_MASS, inertia=wp.mat33(inertia, 0.0, 0.0, 0.0, inertia, 0.0, 0.0, 0.0, inertia), com=wp.vec3(0.0, 0.0, 0.0), lock_inertia=True, label=f"ball_{i}", )
color_t = i / max(NUM_BALLS - 1, 1) builder.add_shape_sphere( body, radius=BALL_RADIUS, color=wp.vec3(0.05 + 0.1 * color_t, 0.2 + 0.6 * color_t, 0.9 - 0.55 * color_t), ) builder.add_shape_capsule( body, xform=wp.transform(p=wp.vec3(0.0, 0.0, 0.5 * STRING_LENGTH), q=wp.quat_identity()), radius=0.004, half_height=0.5 * STRING_LENGTH - BALL_RADIUS, as_site=True, color=wp.vec3(0.55, 0.45, 0.35), )
joint = builder.add_joint_revolute( parent=-1, child=body, axis=wp.vec3(0.0, 1.0, 0.0), parent_xform=wp.transform(p=wp.vec3(pivot_x, 0.0, 0.0), q=wp.quat_identity()), child_xform=wp.transform(p=wp.vec3(0.0, 0.0, STRING_LENGTH), q=wp.quat_identity()), limit_lower=-math.pi, limit_upper=math.pi, limit_ke=0.0, limit_kd=0.0, friction=HINGE_FRICTION, label=f"string_{i}", ) joint_indices.append(joint)
builder.add_shape_box( -1, xform=wp.transform(p=wp.vec3(0.0, 0.0, 0.0), q=wp.quat_identity()), hx=0.5 * total_width + 0.12, hy=0.015, hz=0.015, color=wp.vec3(0.3, 0.3, 0.3), )
builder.add_articulation(joint_indices, label="cradle") builder.color() self.model = builder.finalize()
joint_q = self.model.joint_q.numpy() joint_q[: self.lifted_balls] = INITIAL_ANGLE self.model.joint_q.assign(joint_q)
# Keep model.body_q synchronized before solver construction; VBD reads # these transforms during setup. newton.eval_fk(self.model, self.model.joint_q, self.model.joint_qd, self.model)
if self.solver_name in NATIVE_CONTACT_SOLVERS: self.collision_pipeline = None self.contacts = None else: self.collision_pipeline = newton.CollisionPipeline(self.model) self.contacts = self.collision_pipeline.contacts()
self.solver = self.preset.make_solver(self.model) self.state_0 = self.model.state() self.state_1 = self.model.state() self.control = self.model.control()
self.viewer.set_model(self.model) self.viewer.set_camera(pos=wp.vec3(-0.25, -2.35, 0.25), pitch=-17.7, yaw=90.0)
self.capture()
def capture(self): if wp.get_device().is_cuda: with wp.ScopedCapture() as capture: self.simulate() self.graph = capture.graph else: self.graph = None
def simulate(self): for substep in range(self.sim_substeps): self.state_0.clear_forces() self.viewer.apply_forces(self.state_0) if self.solver_name in NATIVE_CONTACT_SOLVERS: contacts = None else: self.collision_pipeline.collide(self.state_0, self.contacts) contacts = self.contacts self.solver.step(self.state_0, self.state_1, self.control, contacts, self.sim_dt) if self.sim_substeps % 2 == 1 and substep == self.sim_substeps - 1: # an odd count under graph capture: copy the last result into state_0 instead of swapping, # so every replay of the captured graph starts from the newest state self.state_0.assign(self.state_1) else: self.state_0, self.state_1 = self.state_1, self.state_0
def step(self): if self.graph: wp.capture_launch(self.graph) else: self.simulate() self.sim_time += self.frame_dt
def test_final(self): newton.examples.test_body_state( self.model, self.state_0, "cradle remains in its planar workspace", lambda q, qd: abs(q[0]) < 1.25 and abs(q[1]) < 1.0e-3 and abs(q[2]) < 1.1, )
def render(self): self.viewer.begin_frame(self.sim_time) self.viewer.log_state(self.state_0) if self.solver_name not in NATIVE_CONTACT_SOLVERS: self.viewer.log_contacts(self.contacts, self.state_0) self.viewer.end_frame()
@staticmethod def create_parser(): parser = newton.examples.create_parser() parser.add_argument( "--lifted-balls", type=int, default=1, choices=range(1, NUM_BALLS), help="How many balls start lifted." ) return parser
if __name__ == "__main__": parser = Example.create_parser() viewer, args = newton.examples.init(parser) newton.examples.run(Example(viewer, args), args)example = "newton_cradle"source = "newton/examples/contacts/example_newton_cradle.py @ skild-ai/newton 7565a26b1"default = "steel"
[check]# Arguments `python -m shelf check` passes in test mode (from Newton's test_examples.py).args = []
[gallery]short = "A five-ball Newton's cradle: one ball swings in, one swings out."
[xpbd]status = "works"sim = { fps = 60, substeps = 10 }solver = { iterations = 10, enable_restitution = true }model = { default_shape_cfg = { mu = 0.0 } }checked = "2026-11-13, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition"
[vbd]status = "works"sim = { fps = 60, substeps = 10 }solver = { iterations = 10 }model = { default_shape_cfg = { mu = 0.0 } }checked = "2026-11-13, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition"
[mujoco]status = "works"sim = { fps = 60, substeps = 10 }solver = { njmax = 2048, nconmax = 1024, cone = "elliptic" }model = { default_shape_cfg = { mu = 2.0e-5 } }checked = "2026-11-13, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition"
[featherstone]status = "works"sim = { fps = 60, substeps = 20 }solver = { angular_damping = 0.0 }model = { default_shape_cfg = { mu = 0.0 } }checked = "2026-11-13, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition"
[semi_implicit]status = "untested"
[feather_pgs]status = "untested"
[steel]status = "works"# 16 substeps keep one substep of travel (2.5 mm at 2.4 m/s) to about half the 5 mm clearance, so impacts# stay one pair at a time. Each impact is a single contact row, so 4 iterations resolve it.sim = { fps = 60, substeps = 16 }# contact_friction_gap_max = -inf: the balls are frictionless (mu = 0 as in the other solvers), so no# contact needs friction rows. enable_joint_friction applies the hinges' Coulomb friction.solver = { solve_mode = "matrix_free", iterations = 4, articulated_contact_response = "immediate", contact_friction_gap_max = -inf, enable_joint_friction = true }model = { default_shape_cfg = { mu = 0.0 } }checked = "2026-11-13, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition"
[kamino]status = "works"sim = { fps = 60, substeps = 5 }solver = { use_collision_detector = true }model = { default_shape_cfg = { mu = 0.0 } }checked = "2026-11-13, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition"
[implicit_mpm]status = "untested"
[style3d]status = "untested"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.