Skip to content

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.

0.00 s

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.

tutorials/40_tether.py
# 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 newton
import numpy as np
import warp as wp
from newton.solvers import SolverSteel
from replay import Recorder
DT = 1.0 / 240.0
SUBSTEPS = 8
ANCHOR = (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()

Recorded 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