Skip to content

Joint limits

32 · Joints and drives

A pendulum with ±30° limits released at 80°. Steel does not enforce joint limits by default. With enable_joint_limits=True each finite limit becomes a unilateral row that is speculative while open, so the pendulum stops at the bound without overshooting.

0.00 s

What to look at

  • Default: the pendulum swings through ±80° and the limits are ignored.
  • Enabled: it is pushed back inside and bounces between −30° and 30°.

Key parameter

enable_joint_limits

Off by default. joint_limit_activation_gap decides how close to a bound a row is created (default: speed-aware).

Try changing

  • Start the pendulum inside the limits.
  • Raise baumgarte and watch the recovery from 80°.

Concept

Joint limit rows

What it is

With enable_joint_limits, each finite limit becomes a unilateral row that is speculative while open: it lets the joint approach the bound but not cross it in the step. By default rows are made only within 0.05 + 2h|v| of a bound.

In this scene

The pendulum swings to ±80° without limit rows and stays within ±30° with them.

Considerations

Cost
Rows count against articulation_constraint_max; the speed-aware gate keeps most of them out.
Failure modes
Off by default, so imported limits are ignored until you enable them.
Use it when
Always for robots with hard stops.

The script

cd tutorials && python 32_joint_limits.py prints each variant's final metric; --record out.json also writes the replay. Every tutorial imports replay.py from the same folder.

tutorials/32_joint_limits.py
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors
# SPDX-License-Identifier: Apache-2.0
"""Joint position limits are off by default; enable_joint_limits turns them into unilateral rows."""
import math
import newton
import warp as wp
from newton.solvers import SolverSteel
from replay import Recorder
DT = 1.0 / 240.0
SUBSTEPS = 8
def tf(x, y, z):
return wp.transform((x, y, z), wp.quat_identity())
def build():
builder = newton.ModelBuilder()
link = builder.add_link(xform=tf(0.0, 0.0, 0.8))
builder.add_shape_capsule(
link,
xform=tf(0.0, 0.0, -0.2),
radius=0.02,
half_height=0.18,
cfg=newton.ModelBuilder.ShapeConfig(density=400.0),
)
builder.add_shape_sphere(
link,
xform=tf(0.0, 0.0, -0.4),
radius=0.05,
cfg=newton.ModelBuilder.ShapeConfig(density=2000.0),
)
limit = math.radians(30.0)
j = builder.add_joint_revolute(
-1,
link,
axis=newton.Axis.Y,
parent_xform=tf(0.0, 0.0, 0.8),
limit_lower=-limit,
limit_upper=limit,
)
builder.add_articulation([j])
model = builder.finalize()
model.joint_q.fill_(math.radians(80.0)) # released outside the limit
return model
def simulate(rec, label, **options):
model = build()
solver = SolverSteel(model, **options)
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(4.0)):
run.frame(state_0, angle_deg=math.degrees(state_0.joint_q.numpy()[0]))
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, "enable_joint_limits=False (default)")
simulate(rec, "enable_joint_limits=True", enable_joint_limits=True)
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