Tethers and distance joints
40 · Joints and drives
A 0.4 m tether two ways, both started 0.32 m from the anchor. A DISTANCE root joint with max_distance=0.4 is simulated by Steel as a free joint: its distance limits are not enforced, even with enable_joint_limits=True, so the weight falls to the floor. A massless swivel on a ball joint plus a prismatic slider limited to [0, 0.4] m, with enable_joint_limits=True, holds the range. add_joint_distance leaves joint_q at zero, which would put the weight on the anchor, so the script sets the joint's position coordinates to the start offset.
What to look at
- DISTANCE joint: the weight ends 1.0 m from the anchor, on the floor.
- Ball + slider: the weight swings and never exceeds 0.400 m.
Key parameter
add_joint_ball + add_joint_prismatic(limit_lower=-L, limit_upper=0)A swivel and a slider with a joint limit; give both a small armature, since the swivel is massless.
Try changing
- Let the slider go slack by starting the weight at 0.2 m.
- Add a second tethered weight hanging from the first.
Concept
Tethers in reduced coordinates
What it is
A tether is a one-sided distance limit. Steel accepts DISTANCE joints only as root joints and treats them like free joints, without the distance limit. In reduced coordinates the same constraint is a swivel plus a slider whose joint limit becomes a unilateral row.
In this scene
The DISTANCE weight falls to 1.0 m from the anchor; the swivel-and-slider weight stays at or inside 0.4 m.
Considerations
- Cost
- Two joints and one limit row per tether.
- Failure modes
- A massless swivel needs armature, or its DOFs have no inertia.
- Interactions
- Needs enable_joint_limits=True; slack tethers impose nothing.
- Use it when
- Cables, leashes and safety tethers in Steel. For DISTANCE joints with limits, use SolverXPBD until the change below lands.
The script
cd tutorials && python 40_tether.py prints each variant's final metric; --record out.json also writes the replay. Every tutorial imports replay.py from the same folder.
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors# SPDX-License-Identifier: Apache-2.0
"""Tethers: Steel ignores DISTANCE joint limits; a ball joint plus a limited slider holds them."""
import math
import newtonimport numpy as npimport warp as wpfrom newton.solvers import SolverSteel
from replay import Recorder
DT = 1.0 / 240.0SUBSTEPS = 8ANCHOR = (0.0, 0.0, 1.0)LENGTH = 0.4 # m, the most the weight may hang from the anchor
def tf(x, y, z): return wp.transform((x, y, z), wp.quat_identity())
def build(kind): builder = newton.ModelBuilder() builder.add_ground_plane() builder.add_shape_sphere( -1, xform=tf(*ANCHOR), radius=0.02, cfg=newton.ModelBuilder.ShapeConfig(has_shape_collision=False), ) start = (0.3, 0.0, ANCHOR[2] - 0.1) # 0.32 m from the anchor, swinging out if kind == "distance": weight = builder.add_link(xform=tf(*start)) builder.add_shape_sphere(weight, radius=0.05) # a DISTANCE root joint acts as a free joint in Steel: max_distance is not enforced joints = [ builder.add_joint_distance(-1, weight, parent_xform=tf(*ANCHOR), max_distance=LENGTH) ] # add_joint_distance leaves joint_q at zero, which would put the weight on the anchor builder.joint_q[-7:-4] = [start[i] - ANCHOR[i] for i in range(3)] else: # a massless swivel at the anchor, then a slider out along the tether limited to [0, LENGTH] swivel = builder.add_link(xform=tf(*ANCHOR), mass=0.0) weight = builder.add_link(xform=tf(*ANCHOR)) builder.add_shape_sphere(weight, radius=0.05) joints = [ builder.add_joint_ball(-1, swivel, parent_xform=tf(*ANCHOR), armature=1e-4), builder.add_joint_prismatic( swivel, weight, axis=newton.Axis.Z, limit_lower=-LENGTH, limit_upper=0.0, armature=1e-4, ), ] # point the slider at the start position: tilt the swivel, extend the slider tilt = math.atan2(start[0], ANCHOR[2] - start[2]) rot = wp.quat_from_axis_angle(wp.vec3(0.0, 1.0, 0.0), -tilt) builder.joint_q[-5:-1] = [rot[0], rot[1], rot[2], rot[3]] builder.joint_q[-1] = -math.hypot(start[0], ANCHOR[2] - start[2]) builder.add_articulation(joints) return builder.finalize(), weight
def simulate(rec, label, kind): model, weight = build(kind) solver = SolverSteel(model, enable_joint_limits=True) pipeline = newton.CollisionPipeline(model) contacts = pipeline.contacts() state_0, state_1, control = model.state(), model.state(), model.control() newton.eval_fk(model, model.joint_q, model.joint_qd, state_0)
run = rec.variant(label, model) for _ in range(rec.frames(3.0)): p = state_0.body_q.numpy()[weight, :3] run.frame(state_0, distance_m=np.linalg.norm(p - np.array(ANCHOR)), limit_m=LENGTH) for _ in range(SUBSTEPS): pipeline.collide(state_0, contacts) solver.step(state_0, state_1, control, contacts, DT) state_0, state_1 = state_1, state_0 run.check(solver)
if __name__ == "__main__": rec = Recorder() simulate(rec, "DISTANCE joint, max 0.4 m (ignored)", "distance") simulate(rec, "ball + prismatic in [0, 0.4] m", "tether") rec.save()# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors# SPDX-License-Identifier: Apache-2.0
"""Record Steel tutorial runs as replay JSON for the tutorials page, or print a summary.
Every tutorial imports this module. ``python NN_name.py`` runs the variants and printsthe final metric of each; ``--record out.json`` also writes body poses, shapes and metricseries that the page replays in 3D."""
import argparseimport base64import jsonimport osimport subprocessimport sysimport time
import newtonimport numpy as npimport warp as wp
from source_hash import source_sha256
_RENDERED_TYPES = { newton.GeoType.PLANE, newton.GeoType.SPHERE, newton.GeoType.CAPSULE, newton.GeoType.ELLIPSOID, newton.GeoType.CYLINDER, newton.GeoType.BOX, newton.GeoType.MESH, newton.GeoType.CONE, newton.GeoType.CONVEX_MESH,}
class Recorder: """Collect the variants of one tutorial; ``fps`` is the replay frame rate."""
def __init__(self, fps: int = 30): parser = argparse.ArgumentParser() parser.add_argument( "--record", metavar="OUT.json", help="write a replay file for the tutorials page" ) parser.add_argument("--device", default=None, help="Warp device, for example cuda:0") self.args = parser.parse_args() if self.args.device: wp.set_device(self.args.device) self.fps = fps self.script = os.path.abspath(sys.argv[0]) self.variants: list[Variant] = []
def frames(self, seconds: float) -> int: """Number of replay frames in ``seconds``, including the first.""" return int(round(seconds * self.fps)) + 1
def variant( self, label: str, model: newton.Model, *, ghost: bool = False, slot: int | None = None, scene: str | None = None, ) -> "Variant": """Start one run; ``ghost`` overlays run ``slot``, ``scene`` groups runs for charts.""" variant = Variant(label, model, self.fps, ghost, slot, scene) self.variants.append(variant) return variant
def save(self) -> None: """Print a summary and, with ``--record``, write the replay file.""" for variant in self.variants: last = variant.metrics[-1] if variant.metrics else {} flag = " (rows dropped)" if variant.overflow else "" print(f"{variant.label:<48} {json.dumps(last)} {variant.wall:.2f} s{flag}") if not self.args.record: return source_sha = source_sha256(self.script) doc = { "script": os.path.basename(self.script), "script_sha256": source_sha, "newton_commit": _newton_commit(), "newton_version": newton.__version__, "warp_version": wp.config.version, "gpu": wp.get_device().name, "fps": self.fps, "variants": [v.to_json() for v in self.variants], } with open(self.args.record, "w") as f: json.dump(doc, f, separators=(",", ":")) print(f"wrote {self.args.record}")
class Variant: """One recorded run: a pose per frame for every body, plus named metric values."""
def __init__(self, label, model, fps, ghost, slot, scene): self.label, self.model, self.fps, self.ghost, self.slot = label, model, fps, ghost, slot self.scene = scene self.poses: list[np.ndarray] = [] self.metrics: list[dict] = [] self.awake: list[list[int]] = [] self.particles: list[np.ndarray] = [] self.overflow = False self.start = time.perf_counter() self.wall = 0.0
def frame(self, state: newton.State, awake=None, particles=None, **metrics) -> None: """Record ``state.body_q``, optional awake flags, particle positions and metric values.""" self.poses.append(state.body_q.numpy().astype(np.float32).copy()) if particles is not None: self.particles.append(particles.numpy().astype(np.float32).copy()) self.metrics.append({k: _round(v) for k, v in metrics.items()}) if awake is not None: self.awake.append([int(x) for x in awake.numpy()]) self.wall = time.perf_counter() - self.start
def check(self, solver) -> None: """Note whether the solver dropped rows; call once after the run.""" try: solver.check_constraint_capacity() except RuntimeError: self.overflow = True
def to_json(self) -> dict: poses = ( np.stack(self.poses) if self.poses else np.zeros((0, self.model.body_count, 7), np.float32) ) return { "label": self.label, "fps": self.fps, "ghost": self.ghost, "slot": self.slot, "scene": self.scene, "bodies": int(self.model.body_count), "frames": int(poses.shape[0]), "poses": base64.b64encode(poses.astype(np.float32).tobytes()).decode(), "shapes": _shape_table(self.model), "metric": self.metrics, "awake": self.awake or None, "particles": self._particles_json(), "wall_s": round(self.wall, 3), "overflow": self.overflow, }
def _particles_json(self): if not self.particles: return None frames = np.stack(self.particles) tris = self.model.tri_indices radius = self.model.particle_radius.numpy() return { "count": int(frames.shape[1]), "frames": base64.b64encode(frames.astype(np.float32).tobytes()).decode(), "radius": round(float(radius.mean()), 5) if radius.size else 0.0, "tris": [int(v) for v in tris.numpy().reshape(-1)] if tris is not None and tris.shape[0] else None, }
def _shape_table(model): types = model.shape_type.numpy() scales = model.shape_scale.numpy() xforms = model.shape_transform.numpy() bodies = model.shape_body.numpy() flags = model.shape_flags.numpy() shapes = [] for i, t in enumerate(types): if int(t) not in _RENDERED_TYPES or not flags[i] & int(newton.ShapeFlags.VISIBLE): continue shape = { "b": int(bodies[i]), "t": int(t), "s": [round(float(v), 5) for v in scales[i]], "x": [round(float(v), 6) for v in xforms[i]], } if int(t) in (newton.GeoType.MESH, newton.GeoType.CONVEX_MESH): mesh = model.shape_source[i] shape["v"] = [round(float(v), 5) for v in np.asarray(mesh.vertices).reshape(-1)] shape["i"] = [int(v) for v in np.asarray(mesh.indices).reshape(-1)] shapes.append(shape) return shapes
def _round(value): value = float(value) return round(value, 5) if np.isfinite(value) else None
def _newton_commit(): if os.environ.get("NEWTON_COMMIT"): return os.environ["NEWTON_COMMIT"] try: root = os.path.dirname(os.path.dirname(newton.__file__)) return subprocess.check_output(["git", "-C", root, "rev-parse", "HEAD"], text=True).strip() except (OSError, subprocess.CalledProcessError): return NoneRecorded with SolverSteel on Newton 1.9.0.dev0, Warp 1.19.0, NVIDIA RTX PRO 6000 Blackwell Server Edition. Replays read the state back every frame, so they are not a speed benchmark. Script · page text
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.