Skip to content

Four-bar loop closure

43 · Mimic joints and loop closures

A crank-rocker four-bar built as a three-link chain plus one closing revolute joint outside the articulation tree. The closure becomes five bilateral rows (three point rows, two hinge-axis rows) solved in the same sweep as everything else, so its residual depends on the iteration count. enable_bilateral_preelimination projects the closure rows once per step before the sweep.

0.00 s

What to look at

  • 2 iterations: the loop opens by up to 16 cm.
  • 12 iterations: under 6 mm.
  • 2 iterations with pre-elimination: under 2 mm.

Key parameter

enable_bilateral_preelimination

Eliminates mimic and loop rows through a regularized Schur complement before the sweep. Up to eight bilateral rows per articulation.

Try changing

  • Turn the crank at 30 rad/s.
  • Use a BALL closure instead of REVOLUTE.

Concept

Loop closures and pre-elimination

What it is

A joint outside the tree closes a loop with rows: three point rows, plus two axis rows for a revolute or three rotation rows for a weld. Iterating them with everything else leaves a residual. enable_bilateral_preelimination projects them once per step through a regularized Schur complement before the sweep.

In this scene

At 2 iterations the four-bar opens by up to 165 mm; 12 iterations hold it to 5.5 mm and pre-elimination at 2 iterations to 1.9 mm.

Considerations

Cost
A small dense factorization per articulation; up to eight bilateral rows each.
Interactions
Disabled with velocity iterations, a kinematic or world parent, or more than eight rows; incompatible with joint friction.
Use it when
Linkages that must stay closed at low iteration counts.

The script

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

tutorials/43_loop_closure.py
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors
# SPDX-License-Identifier: Apache-2.0
"""Loop closures: a four-bar linkage as a chain plus one closing revolute joint."""
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
CRANK, COUPLER, ROCKER, GROUND = 0.12, 0.42, 0.30, 0.36
def joints_xz():
"""Pivot points A, B, C, D of the linkage with the crank at 60 degrees."""
a, d = np.array([0.0, 0.0, 0.6]), np.array([GROUND, 0.0, 0.6])
b = a + CRANK * np.array([math.cos(math.radians(60)), 0.0, math.sin(math.radians(60))])
dist = np.linalg.norm(d - b)
along = (COUPLER**2 - ROCKER**2 + dist**2) / (2 * dist)
e = (d - b) / dist
c = b + along * e + math.sqrt(COUPLER**2 - along**2) * np.array([-e[2], 0.0, e[0]])
return a, b, c, d
def bar(p0, p1):
"""Center, orientation (x along the bar) and half length of a bar from p0 to p1."""
v = p1 - p0
return (
tuple((p0 + p1) / 2),
wp.quat_from_axis_angle(wp.vec3(0.0, -1.0, 0.0), math.atan2(v[2], v[0])),
float(np.linalg.norm(v)) / 2,
)
def build():
builder = newton.ModelBuilder()
a, b, c, d = joints_xz()
cfg = newton.ModelBuilder.ShapeConfig(density=600.0)
base = builder.add_link()
builder.add_shape_box(
base,
xform=wp.transform((GROUND / 2, 0.0, 0.6), wp.quat_identity()),
hx=GROUND / 2 + 0.03,
hy=0.005,
hz=0.01,
cfg=newton.ModelBuilder.ShapeConfig(density=0.0, has_shape_collision=False),
)
links = []
for p0, p1, width in ((a, b, 0.012), (b, c, 0.009), (d, c, 0.012)):
center, rot, half = bar(p0, p1)
link = builder.add_link(xform=wp.transform(center, rot))
builder.add_shape_box(link, hx=half, hy=width, hz=width, cfg=cfg)
links.append((link, rot, half))
(crank, qa, ha), (coupler, qb, hb), (rocker, qc, hc) = links
builder.add_shape_sphere(
rocker,
xform=wp.transform((hc, 0.0, 0.0), wp.quat_identity()),
radius=0.04,
cfg=newton.ModelBuilder.ShapeConfig(density=6000.0, has_shape_collision=False),
)
tree = [
builder.add_joint_fixed(-1, base),
builder.add_joint_revolute(
base,
crank,
axis=newton.Axis.Y,
parent_xform=wp.transform(tuple(a), qa),
child_xform=wp.transform((-ha, 0.0, 0.0), wp.quat_identity()),
target_qd=15.0,
target_kd=200.0,
),
builder.add_joint_revolute(
crank,
coupler,
axis=newton.Axis.Y,
parent_xform=wp.transform((ha, 0.0, 0.0), wp.mul(wp.quat_inverse(qa), qb)),
child_xform=wp.transform((-hb, 0.0, 0.0), wp.quat_identity()),
),
builder.add_joint_revolute(
coupler,
rocker,
axis=newton.Axis.Y,
parent_xform=wp.transform((hb, 0.0, 0.0), wp.mul(wp.quat_inverse(qb), qc)),
child_xform=wp.transform((hc, 0.0, 0.0), wp.quat_identity()),
),
]
builder.add_articulation(tree)
# the closing joint is not in the articulation: Steel solves it as five bilateral rows
builder.add_joint_revolute(
base,
rocker,
axis=newton.Axis.Y,
parent_xform=wp.transform(tuple(d), qc),
child_xform=wp.transform((-hc, 0.0, 0.0), wp.quat_identity()),
)
return builder.finalize(), rocker, hc, d
def simulate(rec, label, **options):
model, rocker, half, d = build()
solver = SolverSteel(model, **options)
pipeline = newton.CollisionPipeline(model)
contacts = pipeline.contacts()
state_0, state_1, control = model.state(), model.state(), model.control()
run = rec.variant(label, model)
for _ in range(rec.frames(4.0)):
pose = state_0.body_q.numpy()[rocker]
end = wp.transform_point(
wp.transform(wp.vec3(*pose[:3]), wp.quat(*pose[3:])), wp.vec3(-half, 0.0, 0.0)
)
run.frame(state_0, gap_mm=1000.0 * np.linalg.norm(np.array(end) - d))
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, "iterations=2", iterations=2)
simulate(rec, "iterations=12 (default)")
simulate(
rec,
"iterations=2, enable_bilateral_preelimination=True",
iterations=2,
enable_bilateral_preelimination=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