Nut bolt hydro
Mesh and SDF contacts
A nut on a bolt with hydroelastic 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_hydrouv run shelf play nut_bolt_hydro --solver xpbduv run shelf play nut_bolt_hydro --solver mujocouv run shelf play nut_bolt_hydro --solver kamino# change solver settings liveuv run shelf tune nut_bolt_hydroSource: examples/shelf/03_mesh_sdf_contacts/nut_bolt_hydro/ · ported from newton/examples/contacts/example_nut_bolt_hydro.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 |
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) 2026 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 hydroelastic contacts.## Command: python -m shelf run nut_bolt_hydro# python -m shelf run nut_bolt_hydro --solver kamino############################################################################
import argparseimport tempfilefrom pathlib import Path
import numpy as npimport trimeshimport warp as wp
import newtonimport newton.examplesfrom common import presetsfrom newton.geometry import HydroelasticSDF
# 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"
SDF_MAX_RESOLUTION = 128SDF_NARROW_BAND_RANGE = (-0.005, 0.005)SDF_CONSTRUCTION_PADDING = 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"
SHAPE_CFG = newton.ModelBuilder.ShapeConfig( margin=0.0, mu=0.01, # Hydroelastic supplies the per-contact stiffness for the nut/bolt pair, so # ``ke``/``kd`` reach only the non-hydroelastic contacts. At the 1e10 ``kh`` # default the thread contacts resolve over a ~95 ms solref time constant -- # roughly 45 substeps -- and the nut sits visibly cocked under MuJoCo. XPBD # projects positions and is insensitive to this either way. kh=1e11, # Hydroelastic contact stiffness ke=1e7, kd=1e4, gap=0.0, density=8000.0, mu_torsional=0.0, mu_rolling=0.0, is_hydroelastic=True,)
# Demonstrate the user-facing pressure-callback API for the hydroelastic# solver. The contact patch is defined as the iso-pressure surface# ``p_a == p_b``; users supply a Warp ``@wp.func`` that maps a signed depth# to a pressure value, plus a ``@wp.struct`` carrying any per-shape state it# needs. The callback must be finite for any signed depth (positive or# negative) and monotone non-increasing in ``signed_depth`` so the marching-# cubes interpolation stays continuous across the patch boundary. Do not clip# the non-contact side to zero pressure; with different shape stiffnesses, the# iso-pressure surface can pass through that thin outside region.## Here we re-implement the default linear law ``pressure = -kh * signed_depth``# explicitly so the example exercises the user pathway. Nonlinear laws should# similarly extend into ``signed_depth >= 0`` instead of flattening there.@wp.structclass LinearPressureData: shape_kh: wp.array[wp.float32]
@wp.funcdef linear_pressure(signed_depth: wp.float32, shape_idx: wp.int32, data: LinearPressureData) -> wp.float32: return -data.shape_kh[shape_idx] * signed_depth
def add_mesh_object( builder: newton.ModelBuilder, mesh: newton.Mesh, transform: wp.transform, shape_cfg: newton.ModelBuilder.ShapeConfig | None = None, key: str | None = None, center_vec: wp.vec3 | None = None, scale: float = 1.0,) -> int: """Add a mesh shape on a new body.
Args: builder: Model builder that receives the body and shape. mesh: Mesh geometry with SDF data. transform: Body transform with position [m] and orientation. shape_cfg: Optional shape configuration. key: Optional body label. center_vec: Optional mesh center offset in local coordinates [m]. scale: Uniform mesh scale [unitless].
Returns: Created body index. """ 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=key, 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, scale: float = 1.0, center_origin: bool = True,) -> tuple[newton.Mesh, wp.vec3]: """Load a triangle mesh and build an SDF.
Args: mesh_file: Mesh file path. scale: Uniform mesh scale [unitless]. center_origin: Whether to recenter mesh vertices about the AABB center.
Returns: Tuple of ``(mesh, center_vec)`` where ``center_vec`` is the recenter offset [m]. """ 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) * float(scale)
mesh = newton.Mesh(vertices, indices) mesh.build_sdf( max_resolution=SDF_MAX_RESOLUTION, narrow_band_range=SDF_NARROW_BAND_RANGE, margin=SDF_CONSTRUCTION_PADDING, scale=(scale, scale, scale), 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.collide_every = self.preset.sim.need("collide_every") # re-collide every K 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 = 8 if self.preset.name == "kamino" else 20 self.viewer = viewer self.solver_type = self.preset.name self.test_mode = args.test self.deterministic = args.deterministic self.deterministic_solver = args.deterministic_solver
# 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))
# Contact budget per world. MuJoCo's njmax/nconmax are per-world limits # while CollisionPipeline's rigid_contact_max covers every world, so both # derive from this rather than from a fixed whole-scene pool. A threading # assembly peaks near 115 contacts, so this leaves ~9x headroom. self.contacts_per_world = 1024 * self.num_per_world
# Maximum number of rigid contacts to allocate (limits memory usage) self.rigid_contact_max = self.contacts_per_world * self.world_count
# Broad phase mode: NXN (O(N²)), SAP (O(N log N)), EXPLICIT (precomputed pairs) self.broad_phase_mode = "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()
# Configure the hydroelastic pipeline with our custom (still linear) # pressure callback. ``shape_kh`` reuses the per-shape stiffness already # stored on the model. Disable the marching-cubes edge clamp because # threading dynamics on the M20 helix are sensitive to the contact- # surface vertex bias the clamp introduces. pressure_data = LinearPressureData() pressure_data.shape_kh = self.model.shape_material_kh sdf_hydroelastic_config = HydroelasticSDF.Config( pressure_func=linear_pressure, pressure_data=pressure_data, mc_edge_clamp_min=0.0, )
self.collision_pipeline = newton.CollisionPipeline( self.model, reduce_contacts=True, rigid_contact_max=self.rigid_contact_max, broad_phase=self.broad_phase_mode, sdf_hydroelastic_config=sdf_hydroelastic_config, deterministic=self.deterministic, )
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.collision_pipeline.collide(self.state_0, self.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 * 1.25 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.""" # Create solver based on user choice if self.solver_type == "xpbd": self.solver = self.preset.make_solver( self.model, deterministic=wp.DeterministicMode.RUN_TO_RUN if self.deterministic_solver else wp.DeterministicMode.NOT_GUARANTEED, ) elif self.solver_type == "mujoco": self.solver = self.preset.make_solver( self.model, njmax=self.contacts_per_world, nconmax=self.contacts_per_world, deterministic=wp.DeterministicMode.RUN_TO_RUN if self.deterministic_solver else wp.DeterministicMode.NOT_GUARANTEED, ) elif self.solver_type == "kamino": self.solver = self.preset.make_solver(self.model) elif self.solver_type == "steel": # Free-body contact rows sized per assembly; the contact law comes from the preset. self.solver = self.preset.make_solver( self.model, free_body_constraint_max=3 * 256 * self.num_per_world, ) else: raise ValueError(f"Unknown solver '{self.solver_type}'")
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, scale=self.scene_scale, center_origin=True) nut_mesh, nut_center = load_mesh_with_sdf(nut_file, scale=self.scene_scale, 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, key=f"bolt_{i}_{j}", center_vec=bolt_center, 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, key=f"nut_{i}_{j}", center_vec=nut_center, scale=self.scene_scale, ) count += 1
return world_builder
def capture(self): with wp.ScopedCapture() as capture: self.simulate() self.graph = capture.graph
def simulate(self): for sub in range(self.sim_substeps): # Refresh contacts every K substeps so contact normals stay # aligned with the threading rotation. if sub % self.collide_every == 0: self.collision_pipeline.collide(self.state_0, self.contacts) self.state_0.clear_forces()
self.viewer.apply_forces(self.state_0) self.solver.step(self.state_0, self.state_1, self.control, self.contacts, self.sim_dt)
if self.sim_substeps % 2 == 1 and sub == 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() if self.solver_type == "steel": self.solver.validate_contact_compliance()
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})" )
# The 45 degree threshold catches stalled thread engagement while # allowing observed solver/contact-count variation. min_rotation_threshold = np.radians(45.0) min_descent = 0.005 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] descent = initial_z - min_z assert descent > min_descent, ( f"Nut {i}: did not move down enough. Descent={descent:.4f} (expected > {min_descent:.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( "--deterministic", action=argparse.BooleanOptionalAction, default=False, help=( "Make contact generation and ordering reproducible across runs on the same GPU. " "Costs a few percent of step time. Needs a world count small enough to keep the " "hydroelastic face-contact buffer under the 2^20 deterministic contact-id limit." ), ) parser.add_argument( "--deterministic-solver", action=argparse.BooleanOptionalAction, default=False, help=( "Additionally make the solver bit-exact. Separate from --deterministic because " "it instruments every atomic scatter in the solver and costs ~7x step time, " "while contact ordering is what varies between runs." ), ) 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_hydro"source = "newton/examples/contacts/example_nut_bolt_hydro.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 hydroelastic mesh contacts."
[xpbd]status = "works"sim = { fps = 120, substeps = 4, collide_every = 1 }# 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 = 4, collide_every = 2 }# disable_sensors: the scene defines no sensors, and MuJoCo's tactile sensor kernel# mixes max/add reductions, which deterministic mode rejects.solver = { use_mujoco_contacts = false, disable_sensors = true, 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"# Hard contact rows on the hydroelastic contact points, 4 substeps (collide every 2) x 2 iterations,# immediate response: the fastest setting that threads every nut like MuJoCo (24 mm descent and 2160# degrees in 2.75 s, MuJoCo 23.5 mm and 2020) with lower penetration. Steel's experimental compliant# rows (contact_compliance) let 5-11 of the 20 nuts slide off their bolts in the first 0.2 s at every# substep and iteration count tried; the colored response is 10-20% slower with 20 small worlds.sim = { fps = 120, substeps = 4, collide_every = 2 }solver = { solve_mode = "matrix_free", friction_anchor_beta = 0.0, enable_restitution = false, iterations = 2, articulation_constraint_max = 32, articulated_contact_response = "immediate" }checked = "2026-11-04, newton 7565a26b1, NVIDIA GeForce RTX 3090"
[kamino]status = "works"sim = { fps = 120, substeps = 4, collide_every = 2 }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.