Basic conveyor
Basic contacts
A baggage-claim conveyor: one rotating belt mesh on a kinematic root carries bags around.
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 basic_conveyoruv run shelf play basic_conveyor --solver xpbduv run shelf play basic_conveyor --solver vbduv run shelf play basic_conveyor --solver kamino# change solver settings liveuv run shelf tune basic_conveyorSource: examples/shelf/01_basic_contacts/basic_conveyor/ · ported from newton/examples/basic/example_basic_conveyor.py @ skild-ai/newton 7565a26b1
Presets
| Preset | Solver | Status | Checked |
|---|---|---|---|
steeldefault | steel | works | 2026-11-04, newton 7565a26b1, NVIDIA GeForce RTX 3090 |
xpbd | xpbd | works | 2026-10-28, newton 7565a26b1, NVIDIA RTX A6000 |
vbd | vbd | works | 2026-10-28, newton 7565a26b1, NVIDIA RTX A6000 |
kamino | kamino | 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 Basic Conveyor## Baggage-claim style conveyor built from one rotating belt mesh attached# to a kinematic root link with a prescribed revolute joint motion. Two# static annular boundary meshes keep dynamic "bags" on the belt.## Command: uv run -m shelf run basic_conveyor# uv run -m shelf run basic_conveyor --solver kamino############################################################################
import math
import numpy as npimport warp as wp
import newtonimport newton.examplesfrom common import presets
BELT_CENTER_Z = 0.55BELT_RING_RADIUS = 1.8BELT_HALF_WIDTH = 0.24BELT_HALF_THICKNESS = 0.04BELT_MESH_SEGMENTS = 96BELT_COLLISION_SEGMENTS = 16RAIL_WALL_THICKNESS = 0.035RAIL_HEIGHT = 0.16RAIL_BASE_OVERLAP = 0.01BAG_COUNT = 18BAG_LANE_OFFSETS = (-0.12, 0.0, 0.12)BAG_DROP_CLEARANCE = 0.035BELT_SPEED = 0.75 # tangential belt speed [m/s]BELT_COLLISION_GROUP = 7RAIL_COLLISION_GROUP = 3BAG_COLLISION_GROUP_BASE = 100
def create_annular_prism_mesh( inner_radius: float, outer_radius: float, z_min: float, z_max: float, segments: int, *, color: tuple[float, float, float], roughness: float, metallic: float,) -> newton.Mesh: """Create a closed ring prism mesh centered at the origin.""" if segments < 3: raise ValueError("segments must be >= 3") if inner_radius <= 0.0 or outer_radius <= inner_radius: raise ValueError("Expected 0 < inner_radius < outer_radius") if z_max <= z_min: raise ValueError("Expected z_max > z_min")
angles = np.linspace(0.0, 2.0 * math.pi, segments, endpoint=False, dtype=np.float32) cos_theta = np.cos(angles) sin_theta = np.sin(angles)
inner_top = np.stack( ( inner_radius * cos_theta, inner_radius * sin_theta, np.full(segments, z_max, dtype=np.float32), ), axis=1, ) outer_top = np.stack( ( outer_radius * cos_theta, outer_radius * sin_theta, np.full(segments, z_max, dtype=np.float32), ), axis=1, ) inner_bottom = np.stack( ( inner_radius * cos_theta, inner_radius * sin_theta, np.full(segments, z_min, dtype=np.float32), ), axis=1, ) outer_bottom = np.stack( ( outer_radius * cos_theta, outer_radius * sin_theta, np.full(segments, z_min, dtype=np.float32), ), axis=1, )
vertices = np.vstack((inner_top, outer_top, inner_bottom, outer_bottom)).astype(np.float32)
it_offset = 0 outer_top_offset = segments ib_offset = 2 * segments ob_offset = 3 * segments
indices: list[int] = [] for i in range(segments): j = (i + 1) % segments
it_i = it_offset + i it_j = it_offset + j outer_top_i = outer_top_offset + i outer_top_j = outer_top_offset + j ib_i = ib_offset + i ib_j = ib_offset + j ob_i = ob_offset + i ob_j = ob_offset + j
# Top face (+Z) indices.extend((it_i, outer_top_i, outer_top_j, it_i, outer_top_j, it_j)) # Bottom face (-Z) indices.extend((ib_i, ib_j, ob_j, ib_i, ob_j, ob_i)) # Outer face (+radial) indices.extend((ob_i, ob_j, outer_top_j, ob_i, outer_top_j, outer_top_i)) # Inner face (-radial) indices.extend((ib_i, it_i, it_j, ib_i, it_j, ib_j))
mesh = newton.Mesh(vertices=vertices, indices=np.asarray(indices, dtype=np.int32), compute_inertia=False) mesh.color = color mesh.roughness = roughness mesh.metallic = metallic return mesh
@wp.kerneldef set_conveyor_belt_state( belt_joint_q_start: int, belt_joint_qd_start: int, sim_time: wp.array[wp.float32], belt_angular_speed: float, # outputs joint_q: wp.array[wp.float32], joint_qd: wp.array[wp.float32],): """Set prescribed state for the belt's revolute root joint.""" angle = belt_angular_speed * sim_time[0] joint_q[belt_joint_q_start] = angle joint_qd[belt_joint_qd_start] = belt_angular_speed
@wp.kerneldef set_conveyor_belt_free_state( belt_joint_q_start: int, belt_joint_qd_start: int, sim_time: wp.array[wp.float32], belt_angular_speed: float, belt_center_z: float, # outputs joint_q: wp.array[wp.float32], joint_qd: wp.array[wp.float32],): """Set the same prescribed spin through a free root joint: pose (p, quat), then velocity (v, omega).""" angle = belt_angular_speed * sim_time[0] q = wp.quat_from_axis_angle(wp.vec3(0.0, 0.0, 1.0), angle) joint_q[belt_joint_q_start + 0] = 0.0 joint_q[belt_joint_q_start + 1] = 0.0 joint_q[belt_joint_q_start + 2] = belt_center_z for k in range(4): joint_q[belt_joint_q_start + 3 + k] = q[k] for k in range(5): joint_qd[belt_joint_qd_start + k] = 0.0 joint_qd[belt_joint_qd_start + 5] = belt_angular_speed
@wp.kerneldef advance_time(sim_time: wp.array[wp.float32], dt: float): sim_time[0] = sim_time[0] + dt
class Example: def __init__(self, viewer, args=None): self.preset = presets.load(__file__) solver_type = self.preset.name self.solver_type = solver_type newton.use_coord_layout_targets = True 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
self.viewer = viewer belt_speed = float(args.belt_speed) if args is not None and hasattr(args, "belt_speed") else BELT_SPEED self.belt_angular_speed = belt_speed / BELT_RING_RADIUS
builder = newton.ModelBuilder()
ground_shape = builder.add_ground_plane()
# Visual-only center island for a baggage-claim look. island_cfg = newton.ModelBuilder.ShapeConfig( has_shape_collision=False, has_particle_collision=False, density=0.0 ) builder.add_shape_cylinder( body=-1, radius=0.9, half_height=0.08, xform=wp.transform( p=wp.vec3(0.0, 0.0, BELT_CENTER_Z - BELT_HALF_THICKNESS - 0.08), q=wp.quat_identity(), ), cfg=island_cfg, )
belt_cfg = newton.ModelBuilder.ShapeConfig( density=0.0, # mass and inertia are authored explicitly on the belt body below mu=1.2, ke=1.0e5, # vbd only kd=0.0, # vbd only collision_group=BELT_COLLISION_GROUP, ) rail_cfg = newton.ModelBuilder.ShapeConfig( mu=0.8, ke=1.0e7, # vbd only kd=0.0, # vbd only collision_group=RAIL_COLLISION_GROUP, ) bag_cfg = newton.ModelBuilder.ShapeConfig( mu=1.0, ke=1.0e7, # vbd only kd=1.0e4, # vbd only restitution=0.0, )
belt_inner_radius = BELT_RING_RADIUS - BELT_HALF_WIDTH belt_outer_radius = BELT_RING_RADIUS + BELT_HALF_WIDTH
belt_mesh = create_annular_prism_mesh( inner_radius=belt_inner_radius, outer_radius=belt_outer_radius, z_min=-BELT_HALF_THICKNESS, z_max=BELT_HALF_THICKNESS, segments=BELT_MESH_SEGMENTS, color=(0.09, 0.09, 0.09), # dark gray rubber roughness=0.94, metallic=0.02, ) rail_inner_mesh = create_annular_prism_mesh( inner_radius=belt_inner_radius - RAIL_WALL_THICKNESS, outer_radius=belt_inner_radius, z_min=BELT_HALF_THICKNESS - RAIL_BASE_OVERLAP, z_max=BELT_HALF_THICKNESS - RAIL_BASE_OVERLAP + RAIL_HEIGHT, segments=BELT_MESH_SEGMENTS, color=(0.66, 0.69, 0.74), # brushed metal roughness=0.5, metallic=0.9, ) rail_outer_mesh = create_annular_prism_mesh( inner_radius=belt_outer_radius, outer_radius=belt_outer_radius + RAIL_WALL_THICKNESS, z_min=BELT_HALF_THICKNESS - RAIL_BASE_OVERLAP, z_max=BELT_HALF_THICKNESS - RAIL_BASE_OVERLAP + RAIL_HEIGHT, segments=BELT_MESH_SEGMENTS, color=(0.66, 0.69, 0.74), # brushed metal roughness=0.5, metallic=0.9, )
# Annular-ring inertia about the belt's COM (ring axis along Z). belt_mass = 15.0 belt_radii_sum_sq = belt_inner_radius**2 + belt_outer_radius**2 belt_i_transverse = belt_mass / 12.0 * (3.0 * belt_radii_sum_sq + (2.0 * BELT_HALF_THICKNESS) ** 2) belt_i_axial = 0.5 * belt_mass * belt_radii_sum_sq self.belt_body = builder.add_link( mass=belt_mass, inertia=wp.mat33( belt_i_transverse, 0.0, 0.0, 0.0, belt_i_transverse, 0.0, 0.0, 0.0, belt_i_axial, ), is_kinematic=True, label="conveyor_belt", ) # Keep the smooth render mesh while avoiding an oversized mesh-contact system in DVI. belt_mesh_cfg = belt_cfg if solver_type == "kamino": belt_mesh_cfg = belt_cfg.copy() belt_mesh_cfg.has_shape_collision = False belt_mesh_cfg.has_particle_collision = False self.belt_shape = builder.add_shape_mesh( self.belt_body, mesh=belt_mesh, cfg=belt_mesh_cfg, label="conveyor_belt_mesh", ) self.belt_collision_shapes = [self.belt_shape] if solver_type == "kamino": belt_collision_cfg = belt_cfg.copy() belt_collision_cfg.is_visible = False collision_half_angle = math.pi / BELT_COLLISION_SEGMENTS collision_half_tangent = belt_outer_radius * math.tan(collision_half_angle) # Include the inner-arc sagitta so adjacent finite patches do not leave collision gaps. collision_half_radial = BELT_HALF_WIDTH + belt_inner_radius * (1.0 - math.cos(collision_half_angle)) self.belt_collision_shapes = [] for segment in range(BELT_COLLISION_SEGMENTS): angle = 2.0 * math.pi * segment / BELT_COLLISION_SEGMENTS self.belt_collision_shapes.append( builder.add_shape_plane( body=self.belt_body, xform=wp.transform( p=wp.vec3( BELT_RING_RADIUS * math.cos(angle), BELT_RING_RADIUS * math.sin(angle), BELT_HALF_THICKNESS, ), q=wp.quat_from_axis_angle(wp.vec3(0.0, 0.0, 1.0), angle + 0.5 * math.pi), ), width=collision_half_tangent, length=collision_half_radial, cfg=belt_collision_cfg, label=f"conveyor_belt_collision_plane_{segment}", ) ) # Steel spins the belt through a kinematic free root instead of a revolute joint: a kinematic # free body has no contact response, so the bag rows on the belt do not serialize Steel's # colored contact sweep (a kinematic joint keeps its tree response). Same prescribed motion. self.belt_free_root = solver_type == "steel" if self.belt_free_root: self.belt_joint = builder.add_joint_free(self.belt_body, label="conveyor_belt_joint") # free coordinates are the belt's world pose (p, quat) and velocity (v, omega) q_start = builder.joint_q_start[self.belt_joint] builder.joint_q[q_start : q_start + 7] = [0.0, 0.0, BELT_CENTER_Z, 0.0, 0.0, 0.0, 1.0] qd_start = builder.joint_qd_start[self.belt_joint] builder.joint_qd[qd_start + 5] = self.belt_angular_speed else: self.belt_joint = builder.add_joint_revolute( parent=-1, child=self.belt_body, axis=newton.Axis.Z, parent_xform=wp.transform(p=wp.vec3(0.0, 0.0, BELT_CENTER_Z), q=wp.quat_identity()), label="conveyor_belt_joint", ) qd_start = builder.joint_qd_start[self.belt_joint] builder.joint_qd[qd_start] = self.belt_angular_speed builder.add_articulation([self.belt_joint], label="conveyor_belt")
for rail_mesh, rail_label in ( (rail_inner_mesh, "conveyor_rail_inner"), (rail_outer_mesh, "conveyor_rail_outer"), ): rail_shape = builder.add_shape_mesh( body=-1, xform=wp.transform(p=wp.vec3(0.0, 0.0, BELT_CENTER_Z), q=wp.quat_identity()), mesh=rail_mesh, cfg=rail_cfg, label=rail_label, ) for belt_collision_shape in self.belt_collision_shapes: builder.add_shape_collision_filter_pair(belt_collision_shape, rail_shape) # Belt should only interact with dynamic rigid bags. for belt_collision_shape in self.belt_collision_shapes: builder.add_shape_collision_filter_pair(belt_collision_shape, ground_shape)
self.bag_bodies = [] belt_top_z = BELT_CENTER_Z + BELT_HALF_THICKNESS bag_angles = np.linspace(0.0, 2.0 * math.pi, BAG_COUNT, endpoint=False, dtype=np.float32)
for i, angle in enumerate(bag_angles): lane_idx = i % len(BAG_LANE_OFFSETS) radial_offset = BAG_LANE_OFFSETS[lane_idx] radius = BELT_RING_RADIUS + radial_offset bag_x = radius * math.cos(angle) bag_y = radius * math.sin(angle) bag_yaw = angle + 0.5 * math.pi
shape_type = i % 3 if shape_type == 0: bag_vertical_extent = 0.08 # box hz elif shape_type == 1: bag_vertical_extent = 0.08 # horizontal capsule radius else: bag_vertical_extent = 0.11 # sphere radius
# Spawn just above the belt to avoid initial interpenetration and large bounces. bag_z = belt_top_z + bag_vertical_extent + BAG_DROP_CLEARANCE bag_body = builder.add_link( xform=wp.transform( p=wp.vec3(bag_x, bag_y, bag_z), q=wp.quat_from_axis_angle(wp.vec3(0.0, 0.0, 1.0), bag_yaw), ), mass=2.8 + 0.1 * i, label=f"bag_{i}", ) # Important: negative groups collide with everything except the exact same # negative group. Use distinct groups per bag so bag-bag collisions are enabled. bag_shape_cfg = bag_cfg.copy() bag_shape_cfg.collision_group = -(BAG_COLLISION_GROUP_BASE + i)
if shape_type == 0: builder.add_shape_box(bag_body, hx=0.18, hy=0.12, hz=0.08, cfg=bag_shape_cfg) elif shape_type == 1: builder.add_shape_capsule( bag_body, radius=0.08, half_height=0.15, xform=wp.transform(q=wp.quat_from_axis_angle(wp.vec3(0.0, 1.0, 0.0), 0.5 * wp.pi)), cfg=bag_shape_cfg, ) else: builder.add_shape_sphere(bag_body, radius=0.11, cfg=bag_shape_cfg)
builder.add_articulation([builder.add_joint_free(bag_body)], label=f"bag_{i}") self.bag_bodies.append(bag_body)
if solver_type == "kamino": newton.solvers.SolverKamino.register_custom_attributes(builder)
builder.color() self.model = builder.finalize()
self.build_solver()
self.state_0 = self.model.state() self.state_1 = self.model.state() self.control = self.model.control()
# Ensure body state is initialized from model joint buffers. newton.eval_fk(self.model, self.model.joint_q, self.model.joint_qd, self.state_0)
q_starts = self.model.joint_q_start.numpy() qd_starts = self.model.joint_qd_start.numpy() self.belt_joint_q_start = int(q_starts[self.belt_joint]) self.belt_joint_qd_start = int(qd_starts[self.belt_joint]) self.sim_time_wp = wp.zeros(1, dtype=wp.float32, device=self.model.device)
self.viewer.set_model(self.model) self.viewer.set_camera(wp.vec3(2.7, -1.3, 5.0), -60.0, -200.0) self.capture()
def build_solver(self): """Build the solver and everything bound to its instance; the tuner calls this again to swap it.""" self.solver = self.preset.make_solver(self.model) if self.solver_type == "kamino": self.collision_pipeline = None self.contacts = newton.Contacts( self.model.rigid_contact_max, 0, device=self.model.device, requested_attributes=self.model.get_requested_contact_attributes(), ) else: self.collision_pipeline = newton.CollisionPipeline(self.model) self.contacts = self.collision_pipeline.contacts()
def capture(self): if wp.get_device().is_cuda and not wp.config.verify_cuda: with wp.ScopedCapture() as capture: self.simulate() self.graph = capture.graph else: self.graph = None
def simulate(self): contacts = None if self.solver_type == "kamino" else self.contacts for substep in range(self.sim_substeps): self.state_0.clear_forces() self.viewer.apply_forces(self.state_0)
if self.belt_free_root: wp.launch( set_conveyor_belt_free_state, dim=1, inputs=[ self.belt_joint_q_start, self.belt_joint_qd_start, self.sim_time_wp, self.belt_angular_speed, BELT_CENTER_Z, ], outputs=[self.state_0.joint_q, self.state_0.joint_qd], device=self.model.device, ) else: wp.launch( set_conveyor_belt_state, dim=1, inputs=[ self.belt_joint_q_start, self.belt_joint_qd_start, self.sim_time_wp, self.belt_angular_speed, ], outputs=[self.state_0.joint_q, self.state_0.joint_qd], device=self.model.device, )
# Only update maximal coordinates of the kinematic bodies (the conveyor belt) newton.eval_fk( self.model, self.state_0.joint_q, self.state_0.joint_qd, self.state_0, body_flag_filter=newton.BodyFlags.KINEMATIC, )
if self.collision_pipeline is not None: self.collision_pipeline.collide(self.state_0, 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
wp.launch(advance_time, dim=1, inputs=[self.sim_time_wp, self.sim_dt], device=self.model.device)
def step(self): if self.graph: wp.capture_launch(self.graph) else: self.simulate() self.sim_time += self.frame_dt
def render(self): self.viewer.begin_frame(self.sim_time) self.viewer.log_state(self.state_0) if self.contacts is not None: if self.solver_type == "kamino" and self.viewer.show_contacts: self.solver.update_contacts(self.contacts, self.state_0) self.viewer.log_contacts(self.contacts, self.state_0) self.viewer.end_frame()
def test_final(self): body_q = self.state_0.body_q.numpy() shape_scale = self.model.shape_scale.numpy() belt_z = float(body_q[self.belt_body][2]) assert abs(belt_z - BELT_CENTER_Z) < 0.15, f"Belt body drifted off the conveyor plane: z={belt_z:.4f}" for shape_idx in self.belt_collision_shapes: assert shape_scale[shape_idx][0] > 0.0 and shape_scale[shape_idx][1] > 0.0, ( f"Belt collision shape {shape_idx} must have finite extents." )
for body_idx in self.bag_bodies: x = float(body_q[body_idx][0]) y = float(body_q[body_idx][1]) z = float(body_q[body_idx][2]) assert np.isfinite(x) and np.isfinite(y) and np.isfinite(z), f"Bag {body_idx} has non-finite pose values." assert z > BELT_CENTER_Z, f"Bag body {body_idx} fell through the conveyor: z={z:.4f}" radius = math.hypot(x, y) assert BELT_RING_RADIUS - 0.5 < radius < BELT_RING_RADIUS + 0.5, ( f"Bag body {body_idx} left the conveyor annulus: radius={radius:.3f}" )
if __name__ == "__main__": parser = newton.examples.create_parser() parser.add_argument( "--belt-speed", type=float, default=BELT_SPEED, help="Conveyor tangential speed [m/s].", ) viewer, args = newton.examples.init(parser) newton.examples.run(Example(viewer, args), args)example = "basic_conveyor"source = "newton/examples/basic/example_basic_conveyor.py @ skild-ai/newton 7565a26b1"default = "steel"
[check]# Arguments `python -m shelf check` passes in test mode (from Newton's test_examples.py).args = ["--num-frames", "100"]
[gallery]short = "A baggage-claim conveyor: one rotating belt mesh on a kinematic root carries bags around."frame = 300
[xpbd]status = "works"sim = { fps = 100, substeps = 10 }checked = "2026-10-28, newton 7565a26b1, NVIDIA RTX A6000"
[vbd]status = "works"sim = { fps = 100, substeps = 10 }solver = { iterations = 1, rigid_body_contact_buffer_size = 512 }checked = "2026-10-28, newton 7565a26b1, NVIDIA RTX A6000"
[mujoco]status = "untested"
[featherstone]status = "untested"
[semi_implicit]status = "untested"
[feather_pgs]status = "untested"
[steel]status = "works"# 2 substeps x 3 iterations with baumgarte = 1.0: the fastest setting whose penetration (peak, mean and# per-frame max) and bag transport match or beat XPBD's. At baumgarte 0.2 the bags' landing sinks 2.6 mm;# odd substep counts lose a substep per frame in this example's captured graph. The immediate response# overflows its rows and is 3x slower. The example gives Steel's belt a kinematic free root (see example.py).sim = { fps = 100, substeps = 2 }# propagation_constraint_max: the bags' belt and rail contacts need up to ~830 rowssolver = { solve_mode = "matrix_free", iterations = 3, baumgarte = 1.0, articulated_contact_response = "propagation_colored", propagation_constraint_max = 4096 }checked = "2026-11-04, newton 7565a26b1, NVIDIA GeForce RTX 3090"
[kamino]status = "works"sim = { fps = 100, substeps = 4 }# collision_detector: the full bag set can briefly exceed 256 contacts at segment seams.solver = { dynamics_solver = "dvi", sparse_dynamics = true, sparse_jacobian = true, use_collision_detector = true, integrator = "moreau", collision_detector = { max_contacts_per_world = 384, max_contacts_per_pair = 4, max_triangle_pairs = 16384 }, dvi = { max_alternating_iterations = 1, bilateral_solve_interval = 1 } }checked = "2026-10-28, newton 7565a26b1, NVIDIA RTX A6000"
[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.