Nut bolt sdf
Mesh and SDF contacts
A nut on a bolt with SDF mesh contacts.
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 nut_bolt_sdfuv run shelf play nut_bolt_sdf --solver xpbduv run shelf play nut_bolt_sdf --solver mujocouv run shelf play nut_bolt_sdf --solver kamino# change solver settings liveuv run shelf tune nut_bolt_sdfSource: examples/shelf/03_mesh_sdf_contacts/nut_bolt_sdf/ · ported from newton/examples/contacts/example_nut_bolt_sdf.py @ skild-ai/newton 7565a26b1
Presets
| Preset | Solver | Status | Checked |
|---|---|---|---|
steeldefault | steel | works | 2026-11-11, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition |
xpbd | xpbd | works | 2026-10-28, newton 7565a26b1, NVIDIA RTX A6000 |
mujoco | mujoco | 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) 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 tempfilefrom pathlib import Path
import numpy as npimport trimeshimport warp as wp
import newtonimport newton.examplesfrom common import presets
# Assembly type for the nut and boltASSEMBLY_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 = 512MESH_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)example = "nut_bolt_sdf"source = "newton/examples/contacts/example_nut_bolt_sdf.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", "120", "--world-count", "1"]
[gallery]short = "A nut on a bolt with SDF mesh contacts."
[xpbd]status = "works"sim = { fps = 120, substeps = 5 }# rigid_contact_relaxation: XPBD contact correction (0.0 = no correction, 1.0 = full correction)solver = { iterations = 10, rigid_contact_relaxation = 0.8 }checked = "2026-10-28, newton 7565a26b1, NVIDIA RTX A6000"
[vbd]status = "untested"
[mujoco]status = "works"sim = { fps = 120, substeps = 5 }solver = { use_mujoco_contacts = false, solver = "newton", integrator = "implicitfast", cone = "elliptic", iterations = 15, ls_iterations = 100, impratio = 1.0 }checked = "2026-10-28, newton 7565a26b1, NVIDIA RTX A6000"
[featherstone]status = "untested"
[semi_implicit]status = "untested"
[feather_pgs]status = "untested"
[steel]status = "works"# 3 substeps x 6 iterations, immediate response: the fastest setting that threads the nuts at close to# MuJoCo's rate (1080 vs 910 degrees in 2.75 s, 17.0 vs 16.0 mm descent) with lower penetration. Fewer# substeps or iterations under-solve the thread friction and spin the nuts down 1.5-3x faster (and at# 2 x 4 one nut of 100 left its bolt); the colored response is 20% slower with 100 small worlds.sim = { fps = 120, substeps = 3 }# free_body_constraint_max 1024: the thread contacts need 497-511 free-body rows in one world and 516-525 at the# default 100 worlds (high-water over 10-30 s, reached by 6 s); the default 512 drops rows. 1024 is about 2x that.solver = { solve_mode = "matrix_free", iterations = 6, articulated_contact_response = "immediate", free_body_constraint_max = 1024 }# 100 worlds for 7.5 s: the rows pass 512 at 5.6-5.9 s there, so a too-small capacity fails the check.check = { args = ["--num-frames", "900", "--world-count", "100"] }checked = "2026-11-11, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition"
[kamino]status = "works"sim = { fps = 120, substeps = 3 }solver = { dynamics_solver = "dvi", sparse_dynamics = true, sparse_jacobian = true, dvi = { max_alternating_iterations = 4 } }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.