Skip to content

Nut bolt sdf

Mesh and SDF contacts

A nut on a bolt with SDF mesh contacts.

0.00 s
The steel preset (the default), recordedon an NVIDIA RTX PRO 6000 Blackwell.

Run it

Terminal window
cd examples
# the default preset (steel), then the others
uv run shelf play nut_bolt_sdf
uv run shelf play nut_bolt_sdf --solver xpbd
uv run shelf play nut_bolt_sdf --solver mujoco
uv run shelf play nut_bolt_sdf --solver kamino
# change solver settings live
uv run shelf tune nut_bolt_sdf

Source: examples/shelf/03_mesh_sdf_contacts/nut_bolt_sdf/ · ported from newton/examples/contacts/example_nut_bolt_sdf.py @ skild-ai/newton 7565a26b1

Presets

PresetSolverStatusChecked
xpbdxpbdworks2026-10-28, newton 7565a26b1, NVIDIA RTX A6000
mujocomujocoworks2026-10-28, newton 7565a26b1, NVIDIA RTX A6000
kaminokaminoworks2026-10-28, newton 7565a26b1, NVIDIA RTX A6000

Solvers with no row are untested on this example. The full settings are inpresets.toml.

Code

examples/shelf/03_mesh_sdf_contacts/nut_bolt_sdf/example.py
# SPDX-FileCopyrightText: Copyright (c) 2025 The Newton Developers
# SPDX-License-Identifier: Apache-2.0
# Modified for Steel Shelf: preset-driven execution and shared runner integration.
###########################################################################
# Example SDF Mesh Collision
#
# Demonstrates nut/bolt mesh collision using SDF (Signed Distance Field).
#
# Command: python -m shelf run nut_bolt_sdf
# python -m shelf run nut_bolt_sdf --solver kamino
#
###########################################################################
import tempfile
from pathlib import Path
import numpy as np
import trimesh
import warp as wp
import newton
import newton.examples
from common import presets
# Assembly type for the nut and bolt
ASSEMBLY_STR = "m20_loose"
ISAACGYM_ENVS_REPO_URL = "https://github.com/isaac-sim/IsaacGymEnvs.git"
ISAACGYM_NUT_BOLT_FOLDER = "assets/factory/mesh/factory_nut_bolt"
SHAPE_CFG = newton.ModelBuilder.ShapeConfig(
margin=0.0,
mu=0.01,
ke=1e7, # Contact stiffness for MuJoCo solver
kd=1e4, # Contact damping
gap=0.005,
density=8000.0,
mu_torsional=0.0,
mu_rolling=0.0,
is_hydroelastic=False,
)
MESH_SDF_MAX_RESOLUTION = 512
MESH_SDF_NARROW_BAND_RANGE = (-0.005, 0.005)
# Persist cooked SDFs across runs so the (slow) cook only happens once.
# Entries are content-addressed, so leftovers from older runs are harmless.
MESH_SDF_CACHE_DIR = Path(tempfile.gettempdir()) / "newton_sdf_cache"
def add_mesh_object(
builder: newton.ModelBuilder,
mesh: newton.Mesh,
transform: wp.transform,
shape_cfg: newton.ModelBuilder.ShapeConfig | None = None,
label: str | None = None,
center_vec: wp.vec3 | None = None,
scale: float = 1.0,
) -> int:
if center_vec is not None:
center_world = wp.quat_rotate(transform.q, center_vec)
transform = wp.transform(transform.p + center_world, transform.q)
body = builder.add_body(label=label, xform=transform)
builder.add_shape_mesh(body, mesh=mesh, scale=(scale, scale, scale), cfg=shape_cfg)
return body
def load_mesh_with_sdf(
mesh_file: str,
shape_cfg: newton.ModelBuilder.ShapeConfig | None = None,
center_origin: bool = True,
) -> tuple[newton.Mesh, wp.vec3]:
mesh_data = trimesh.load(mesh_file, force="mesh")
vertices = np.array(mesh_data.vertices, dtype=np.float32)
indices = np.array(mesh_data.faces.flatten(), dtype=np.int32)
center_vec = wp.vec3(0.0, 0.0, 0.0)
if center_origin:
min_extent = vertices.min(axis=0)
max_extent = vertices.max(axis=0)
center = (min_extent + max_extent) / 2
vertices = vertices - center
center_vec = wp.vec3(center)
mesh = newton.Mesh(vertices, indices)
mesh.build_sdf(
max_resolution=MESH_SDF_MAX_RESOLUTION,
narrow_band_range=MESH_SDF_NARROW_BAND_RANGE,
margin=shape_cfg.gap if shape_cfg and shape_cfg.gap is not None else 0.05,
cache_dir=MESH_SDF_CACHE_DIR,
)
return mesh, center_vec
class Example:
def __init__(self, viewer, args):
self.preset = presets.load(__file__)
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.world_count = args.world_count
if self.world_count is None:
self.world_count = 16 if self.preset.name == "kamino" else 100
self.viewer = viewer
self.solver_type = self.preset.name
self.test_mode = args.test
# Scene scaling factor (1.0 = original size)
self.scene_scale = 1.0
# Ground plane offset (negative = below origin)
self.ground_plane_offset = -0.01
# Grid dimensions for nut/bolt scene (number of assemblies in X and Y)
self.num_per_world = args.num_per_world
self.grid_x = int(np.ceil(np.sqrt(self.num_per_world)))
self.grid_y = int(np.ceil(self.num_per_world / self.grid_x))
# Maximum number of rigid contacts to allocate (limits memory usage).
# Use a per-world budget so the batched defaults scale appropriately.
self.rigid_contact_max = 500 * self.world_count
# Broad phase mode: NXN (O(N²)), SAP (O(N log N)), EXPLICIT (precomputed pairs)
self.broad_phase = "sap"
world_builder = self._build_nut_bolt_scene()
main_scene = newton.ModelBuilder()
if self.solver_type == "kamino":
newton.solvers.SolverKamino.register_custom_attributes(main_scene)
main_scene.default_shape_cfg.gap = 0.001 * self.scene_scale
# Add ground plane at z = ground_plane_offset.
# For plane equation n·x + d = 0, with n=(0,0,1): z + d = 0, so z = -d.
# Therefore d is the negative offset, and z = offset uses d = -offset.
main_scene.add_shape_plane(
plane=(0.0, 0.0, 1.0, -self.ground_plane_offset),
width=0.0,
length=0.0,
label="ground_plane",
)
main_scene.replicate(world_builder, world_count=self.world_count)
self.model = main_scene.finalize()
# Keep model and pipeline contact capacities aligned.
self.model.rigid_contact_max = self.rigid_contact_max
self.collision_pipeline = newton.CollisionPipeline(
self.model,
reduce_contacts=True,
rigid_contact_max=self.rigid_contact_max,
broad_phase=self.broad_phase,
)
self.build_solver()
self.state_0 = self.model.state()
self.state_1 = self.model.state()
self.control = self.model.control()
newton.eval_fk(self.model, self.model.joint_q, self.model.joint_qd, self.state_0)
self.contacts = self.collision_pipeline.contacts()
self.viewer.set_model(self.model)
offset = 0.15 * self.scene_scale
self.viewer.set_world_offsets((offset, offset, 0.0))
camera_offset = np.sqrt(self.world_count) * offset
self.viewer.set_camera(pos=wp.vec3(camera_offset, -camera_offset, 0.5 * camera_offset), pitch=-15.0, yaw=135.0)
# Initialize test tracking data (only in test mode for nut_bolt scene)
self._init_test_tracking()
self.capture()
def build_solver(self):
"""Build the solver and everything bound to its instance; the tuner calls this again to swap it."""
# MuJoCo sizes its constraint buffers from the pipeline's contact budget; the others need nothing extra
if self.solver_type == "mujoco":
num_per_world = self.collision_pipeline.rigid_contact_max // self.world_count
self.solver = self.preset.make_solver(
self.model,
njmax=num_per_world,
nconmax=num_per_world,
)
else:
self.solver = self.preset.make_solver(self.model)
def _build_nut_bolt_scene(self) -> newton.ModelBuilder:
print("Downloading nut/bolt assets...")
asset_path = newton.examples.download_external_git_folder(ISAACGYM_ENVS_REPO_URL, ISAACGYM_NUT_BOLT_FOLDER, ref="aeed298638a1f7b5421b38f5f3cc2d1079b6d9c3")
print(f"Assets downloaded to: {asset_path}")
world_builder = newton.ModelBuilder()
world_builder.default_shape_cfg.gap = 0.001 * self.scene_scale
bolt_file = str(asset_path / f"factory_bolt_{ASSEMBLY_STR}.obj")
nut_file = str(asset_path / f"factory_nut_{ASSEMBLY_STR}_subdiv_3x.obj")
bolt_mesh, bolt_center = load_mesh_with_sdf(bolt_file, shape_cfg=SHAPE_CFG, center_origin=True)
nut_mesh, nut_center = load_mesh_with_sdf(nut_file, shape_cfg=SHAPE_CFG, center_origin=True)
# Spacing between assemblies in the grid
spacing = 0.1 * self.scene_scale
# Create grid of nut/bolt assemblies
count = 0
for i in range(self.grid_x):
if count >= self.num_per_world:
break
for j in range(self.grid_y):
if count >= self.num_per_world:
break
# Center the grid around origin
x_offset = (i - (self.grid_x - 1) / 2.0) * spacing
y_offset = (j - (self.grid_y - 1) / 2.0) * spacing
# Add bolt at grid position
bolt_xform = wp.transform(wp.vec3(x_offset, y_offset, 0.0 * self.scene_scale), wp.quat_identity())
add_mesh_object(
world_builder,
bolt_mesh,
bolt_xform,
SHAPE_CFG,
label=f"bolt_{i}_{j}",
center_vec=bolt_center * self.scene_scale,
scale=self.scene_scale,
)
# Add nut above bolt at grid position
nut_xform = wp.transform(
wp.vec3(x_offset, y_offset, 0.041 * self.scene_scale),
wp.quat_from_axis_angle(wp.vec3(0.0, 0.0, 1.0), np.pi / 8),
)
add_mesh_object(
world_builder,
nut_mesh,
nut_xform,
SHAPE_CFG,
label=f"nut_{i}_{j}",
center_vec=nut_center * self.scene_scale,
scale=self.scene_scale,
)
count += 1
return world_builder
def capture(self):
self.graph = None
self.use_graph = True
with wp.ScopedCapture() as capture:
self.simulate()
self.graph = capture.graph
def simulate(self):
need_state_copy = self.use_graph and self.sim_substeps % 2 == 1
self.collision_pipeline.collide(self.state_0, self.contacts)
for i in range(self.sim_substeps):
self.state_0.clear_forces()
self.viewer.apply_forces(self.state_0)
# self.collision_pipeline.collide(self.state_0, self.contacts)
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 self.graph:
wp.capture_launch(self.graph)
else:
self.simulate()
self.sim_time += self.frame_dt
# Track transforms for test validation
self._track_test_data()
def render(self):
self.viewer.begin_frame(self.sim_time)
self.viewer.log_state(self.state_0)
self.viewer.log_contacts(self.contacts, self.state_0)
self.viewer.end_frame()
def _init_test_tracking(self):
"""Initialize tracking data for test validation."""
if not self.test_mode:
self.bolt_body_indices = None
self.nut_body_indices = None
return
# Find bolt and nut body indices by key
self.bolt_body_indices = []
self.nut_body_indices = []
for i in range(self.grid_x):
for j in range(self.grid_y):
bolt_key = f"bolt_{i}_{j}"
nut_key = f"nut_{i}_{j}"
if bolt_key in self.model.body_label:
self.bolt_body_indices.append(self.model.body_label.index(bolt_key))
if nut_key in self.model.body_label:
self.nut_body_indices.append(self.model.body_label.index(nut_key))
# Store initial transforms
body_q = self.state_0.body_q.numpy()
self.bolt_initial_transforms = [body_q[idx].copy() for idx in self.bolt_body_indices]
self.nut_initial_transforms = [body_q[idx].copy() for idx in self.nut_body_indices]
# Track maximum rotation change and z displacement for nuts
self.nut_max_rotation_change = [0.0] * len(self.nut_body_indices)
self.nut_min_z = [body_q[idx][2] for idx in self.nut_body_indices]
def _track_test_data(self):
"""Track transforms for test validation (called each step in test mode)."""
if not self.test_mode:
return
body_q = self.state_0.body_q.numpy()
# Track nut rotation and z position
for i, nut_idx in enumerate(self.nut_body_indices):
current_q = body_q[nut_idx]
initial_q = self.nut_initial_transforms[i]
# Compute rotation change using quaternion dot product
# |q1 · q2| = cos(theta/2), where theta is the angle between orientations
q_current = current_q[3:7] # quaternion part (x, y, z, w)
q_initial = initial_q[3:7]
dot = abs(np.dot(q_current, q_initial))
dot = min(dot, 1.0) # Clamp for numerical stability
rotation_angle = 2.0 * np.arccos(dot)
self.nut_max_rotation_change[i] = max(self.nut_max_rotation_change[i], rotation_angle)
# Track minimum z (nuts should move down)
self.nut_min_z[i] = min(self.nut_min_z[i], current_q[2])
def test_final(self):
"""Verify simulation state after example completes.
- Bolts should stay approximately in place (limited displacement)
- Nuts should rotate (thread engagement) and move slightly downward
"""
body_q = self.state_0.body_q.numpy()
# Check bolts stayed in place
max_bolt_displacement = 0.02 # 2 cm tolerance
for i, bolt_idx in enumerate(self.bolt_body_indices):
current_pos = body_q[bolt_idx][:3]
initial_pos = self.bolt_initial_transforms[i][:3]
displacement = np.linalg.norm(current_pos - initial_pos)
assert displacement < max_bolt_displacement, (
f"Bolt {i}: displaced too much. "
f"Displacement={displacement:.4f} (max allowed={max_bolt_displacement:.4f})"
)
# Check nuts rotated and moved down
min_rotation_threshold = 0.1 # At least ~5.7 degrees of rotation
for i in range(len(self.nut_body_indices)):
# Check rotation occurred
max_rotation = self.nut_max_rotation_change[i]
assert max_rotation > min_rotation_threshold, (
f"Nut {i}: did not rotate enough. "
f"Max rotation={np.degrees(max_rotation):.2f} degrees "
f"(expected > {np.degrees(min_rotation_threshold):.2f} degrees)"
)
# Check nut moved downward (min_z should be less than initial z)
initial_z = self.nut_initial_transforms[i][2]
min_z = self.nut_min_z[i]
assert min_z < initial_z, (
f"Nut {i}: did not move downward. Initial z={initial_z:.4f}, min z reached={min_z:.4f}"
)
@staticmethod
def create_parser():
parser = newton.examples.create_parser()
newton.examples.add_world_count_arg(parser)
parser.set_defaults(world_count=None)
parser.add_argument("--num-per-world", type=int, default=1, help="Number of assemblies per world.")
return parser
if __name__ == "__main__":
parser = Example.create_parser()
viewer, args = newton.examples.init(parser)
newton.examples.run(Example(viewer, args), args)