Hoberman sphere
Looped linkages
A Hoberman sphere of 120 bars and 240 hinges opens and closes from one driven hub.
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 loop_hoberman_sphereuv run shelf play loop_hoberman_sphere --solver mujocouv run shelf play loop_hoberman_sphere --solver kaminouv run shelf play loop_hoberman_sphere --solver mujoco_alluv run shelf play loop_hoberman_sphere --solver steel_alluv run shelf play loop_hoberman_sphere --solver kamino_all# change solver settings liveuv run shelf tune loop_hoberman_sphereSource: examples/shelf/05_looped_linkages/loop_hoberman_sphere/ · ported from loop_hoberman_sphere/example.py (sphere, floating, one hub driven)
Presets
| Preset | Solver | Status | Checked |
|---|---|---|---|
steeldefault | steel | works | 2026-11-10, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition |
mujoco | mujoco | works | 2026-11-10, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition |
kamino | kamino | works | 2026-11-10, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition |
mujoco_all | mujoco variant | works | 2026-11-10, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition |
steel_all | steel variant | works | 2026-11-10, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition |
kamino_all | kamino variant | works | 2026-11-10, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition |
Solvers with no row are untested on this example. The full settings are inpresets.toml.
Code
# SPDX-FileCopyrightText: Copyright (c) 2026 Skild AI and the Steel contributors# SPDX-License-Identifier: Apache-2.0
############################################################################ Example Hoberman Sphere## A Hoberman sphere: six rings of ten scissor units on the great circles of an icosidodecahedron, 120 angulated# bars and 30 hub connectors joined by 240 revolute joints. It has one degree of freedom and 91 independent loops.# A drive on one hub opens it from a scissor angle of 120 degrees to 30 and closes it again every 4 s, and the# whole sphere has to follow. The hub radius has a closed form, a cos(phi / 2) / sin(pi / 10).## For Steel the joint graph becomes a free root, a spanning tree grown from one connector (the driven hub joint# kept in the tree) and the other 91 hinges as REVOLUTE loop closures. Zero gravity and no contact, so all forces# are internal and momentum must stay zero. MuJoCo gets the same tree, with each loop hinge as two CONNECT# equality constraints along its axis (add_loop_closures_mujoco). (A variant on the ground is left out: the# one-hub drive of 2 N m/rad cannot open the sphere against gravity and floor friction, and the contact rows cost ~4x.)## Kamino takes the same model unchanged: it assumes no tree, so the free root adds no rows and the tree hinges and# the loop closures are all the same revolute constraints. The hub drives are the same implicit PD (POSITION mode,# same gains and targets), which Kamino solves as a compliant row of the joint.## The geometry and the exact ground truth (ground_truth.py) live in this folder. check bounds the joint gaps, the hub-radius error against the# closed form, the drive tracking and the momentum.## Command: python -m shelf run loop_hoberman_sphere############################################################################
import mathimport types
import numpy as npimport warp as wp
import newtonimport newton.examplesfrom common import presets
# the sphere's parameters: arm length, scissor range, drive and bar sizesPARAMS = types.SimpleNamespace( shape="sphere", segments=10, arm=0.05, # [m] hub to the end of a bar phi_min=30.0, # [deg] scissor angle when open phi_max=120.0, # [deg] when closed (the build pose) start="closed", period=4.0, # [s] closed -> open -> closed ke=2.0, # [N m/rad] drive stiffness kd=0.05, # [N m s/rad] density=1200.0, # [kg/m^3] bars and connectors bar_radius=0.0025, connector_radius=0.005, mu=0.5,)
def ring_nodes(n: int, a: float, phi: float): """Hub, outer and inner node positions of one ring in its plane (2D), for scissor angle ``phi`` [rad].""" al = 2.0 * math.pi / n s, c = math.sin(al / 2), math.cos(al / 2) rho = a * math.cos(phi / 2) / s d = a * math.sin(phi / 2) # half of the U-V diagonal H, U, V = [], [], [] for k in range(n): th = k * al H.append(rho * np.array([math.cos(th), math.sin(th)])) dm = np.array([math.cos(th + al / 2), math.sin(th + al / 2)]) U.append((rho * c + d) * dm) V.append((rho * c - d) * dm) return np.array(H), np.array(U), np.array(V), rho
def bar_angles(n: int, a: float, phi: float): """In-plane orientation [rad] of each bar's right arm: A_k points at U_k, B_k at V_k.""" H, U, V, _ = ring_nodes(n, a, phi) pa = np.arctan2(U[:, 1] - H[:, 1], U[:, 0] - H[:, 0]) pb = np.arctan2(V[:, 1] - H[:, 1], V[:, 0] - H[:, 0]) return pa, pb
def icosidodecahedron_rings(): """Six great circles (unit normal, ordered unit hub directions, global vertex ids) of an icosidodecahedron.""" g = (1 + 5**0.5) / 2 ico = [] for s1 in (-1, 1): for s2 in (-1, 1): ico += [(0, s1, s2 * g), (s1, s2 * g, 0), (s2 * g, 0, s1)] ico = np.array(ico, dtype=float) ico /= np.linalg.norm(ico, axis=1, keepdims=True) verts = [] for i in range(12): for j in range(i + 1, 12): if abs(np.dot(ico[i], ico[j]) - 1 / 5**0.5) < 1e-6: # icosahedron edge m = ico[i] + ico[j] verts.append(m / np.linalg.norm(m)) verts = np.array(verts) assert len(verts) == 30 axes = [ ico[i] for i in range(12) if all(np.dot(ico[i], x) < 1 - 1e-6 for x in ico[:i]) and all(np.dot(ico[i], -x) < 1 - 1e-6 for x in ico[:i]) ] rings = [] for nrm in axes[:6]: ids = [i for i in range(30) if abs(np.dot(verts[i], nrm)) < 1e-6] assert len(ids) == 10, len(ids) u = verts[ids[0]] v = np.cross(nrm, u) ids.sort(key=lambda i: (math.atan2(np.dot(verts[i], v), np.dot(verts[i], u)) + 1e-9) % (2 * math.pi)) u = verts[ids[0]] # the reference vertex must be hub 0 after sorting v = np.cross(nrm, u) rings.append((nrm, u, v, ids)) count = np.zeros(30, int) for r in rings: count[r[3]] += 1 assert (count == 2).all(), count return verts, rings
def mat_to_quat(R: np.ndarray): """Rotation matrix with columns as frame axes -> warp quaternion (x, y, z, w).""" return wp.quat_from_matrix(wp.mat33(*R.flatten().tolist()))
# ----------------------------------------------------------------------------------------------- joint graph
class Graph: """Bodies and revolute joints of the mechanism at the build pose (scissor angle ``phi0``)."""
def __init__(self, args): self.args = args a, phi0 = args.arm, math.radians(args.phi_max if args.start == "closed" else args.phi_min) self.phi0 = phi0 self.bodies = [] # dict(pos, R, kind, shapes=[...]) self.joints = [] # dict(p, c, anchor_world, axis_world, R_frame, kind) self.hub_points = [] # per hub: (body, local point) used to measure hub radius self.scissors = [] # per hub: (bar A body, bar B body, local right-arm vectors) to measure phi if args.shape == "ring": n = args.segments u, v = np.array([1.0, 0.0, 0.0]), np.array([0.0, 0.0, 1.0]) # vertical ring in the x-z plane nrm = np.cross(u, v) # right-handed ring frame self.n = n self._add_ring(n, a, phi0, nrm, u, v, ring_index=0, connectors=None) else: n = 10 self.n = n verts, rings = icosidodecahedron_rings() rho0 = a * math.cos(phi0 / 2) / math.sin(math.pi / n) conn = [] for i in range(30): conn.append(self._add_body(rho0 * verts[i], np.eye(3), "connector")) self.hub_points.append((conn[-1], np.zeros(3))) self.verts = verts for r, (nrm, u, v, ids) in enumerate(rings): self._add_ring(n, a, phi0, nrm, u, v, ring_index=r, connectors=[conn[i] for i in ids]) self.center0 = np.mean([self.bodies[b]["pos"] + self.bodies[b]["R"] @ p for b, p in self.hub_points], axis=0)
def _add_body(self, pos, R, kind, shapes=None): self.bodies.append(dict(pos=np.asarray(pos, float), R=np.asarray(R, float), kind=kind, shapes=shapes or [])) return len(self.bodies) - 1
def _add_joint(self, p, c, point_world, R_frame, kind): self.joints.append( dict(p=p, c=c, point=np.asarray(point_world, float), R=np.asarray(R_frame, float), kind=kind) ) return len(self.joints) - 1
def _add_ring(self, n, a, phi0, nrm, u, v, ring_index, connectors): R = np.column_stack([u, v, nrm]) # ring frame: x = u, y = v, z = normal H2, U2, V2, rho = ring_nodes(n, a, phi0) to3 = lambda p2: p2[0] * u + p2[1] * v H = [to3(p) for p in H2] U = [to3(p) for p in U2] V = [to3(p) for p in V2] A, B = [], [] for k in range(n): # body frame = ring frame at the hub; arms as capsules from the hub to each end (local coordinates) arms_a = [R.T @ (U[k] - H[k]), R.T @ (V[(k - 1) % n] - H[k])] arms_b = [R.T @ (V[k] - H[k]), R.T @ (U[(k - 1) % n] - H[k])] A.append(self._add_body(H[k], R, "bar", shapes=arms_a)) B.append(self._add_body(H[k], R, "bar", shapes=arms_b)) for k in range(n): if connectors is None: self.joint_hub = self._add_joint(A[k], B[k], H[k], R, "hub") self.hub_points.append((A[k], np.zeros(3))) else: self._add_joint(connectors[k], A[k], H[k], R, "hub") self._add_joint(connectors[k], B[k], H[k], R, "hub") self.scissors.append((A[k], B[k], R.T @ (U[k] - H[k]), R.T @ (V[k] - H[k]))) self._add_joint(A[k], B[(k + 1) % n], U[k], R, "outer") self._add_joint(B[k], A[(k + 1) % n], V[k], R, "inner") self.ring_bodies = getattr(self, "ring_bodies", []) + [(A, B)] self.ring_frames = getattr(self, "ring_frames", []) + [R]
def analytic_body_q(self, phi: float) -> np.ndarray: """Exact pose (x, y, z, qx, qy, qz, qw) of every body at scissor angle phi, centred like the build pose.""" n, a = self.n, self.args.arm al = 2 * math.pi / n rho = a * math.cos(phi / 2) / math.sin(al / 2) pa, pb = bar_angles(n, a, phi) pa0, pb0 = bar_angles(n, a, self.phi0) da, db = pa[0] - pa0[0], pb[0] - pb0[0] out = np.zeros((len(self.bodies), 7)) for i, B in enumerate(self.bodies): # connectors: slide along their ray, no rotation if B["kind"] == "connector": out[i, :3] = rho * B["pos"] / np.linalg.norm(B["pos"]) out[i, 6] = 1.0 for (A, Bb), R in zip(self.ring_bodies, self.ring_frames): for k in range(n): hub = rho * (math.cos(k * al) * R[:, 0] + math.sin(k * al) * R[:, 1]) for body, d in ((A[k], da), (Bb[k], db)): Rz = np.array([[math.cos(d), -math.sin(d), 0.0], [math.sin(d), math.cos(d), 0.0], [0.0, 0.0, 1.0]]) q = mat_to_quat(R @ Rz) out[body, :3] = hub out[body, 3:] = [q[0], q[1], q[2], q[3]] return out
def driven_joints(self, mode): """Hub joints that carry a drive: the first hub joint, or every hub joint of the graph.""" hubs = [j for j, J in enumerate(self.joints) if J["kind"] == "hub"] if mode == "one": return hubs[:1] if self.args.shape == "sphere": return [j for j in hubs if self._is_a(self.joints[j]["c"])] # connector -> A bar of every hub return hubs
def joint_q_of_phi(self, j, phi): """Coordinate of hub joint ``j`` (child relative to parent about the ring normal) at scissor angle ``phi``.""" n, a = self.n, self.args.arm pa, pb = bar_angles(n, a, phi) pa0, pb0 = bar_angles(n, a, self.phi0) da, db = pa[0] - pa0[0], pb[0] - pb0[0] # every A bar rotates by da, every B bar by db (symmetry) J = self.joints[j] pk, ck = self.bodies[J["p"]]["kind"], self.bodies[J["c"]]["kind"] if pk == "connector": # connector -> A (or B) bar return da if self._is_a(J["c"]) else db return db - da # ring hub joint A -> B
def _is_a(self, b): for A, B in self.ring_bodies: if b in A: return True if b in B: return False raise KeyError(b)
def shape_cfg() -> newton.ModelBuilder.ShapeConfig: """Bars and connectors carry mass (from their density) but take no contact.""" cfg = newton.ModelBuilder.ShapeConfig(density=PARAMS.density, mu=PARAMS.mu, margin=1e-6, gap=1e-2) cfg.has_shape_collision = False cfg.has_particle_collision = False return cfg
def add_loop_closures_mujoco(b: newton.ModelBuilder, G: Graph, loops, ids, model_cfg): """MuJoCo: each loop hinge as two CONNECT equalities at +-``hinge_half_span`` along its axis.
SolverMuJoCo turns a REVOLUTE joint outside the articulation into the same CONNECT pair by itself, but with MuJoCo's default solref / solimp (0.02 s, impedance 0.9-0.95), soft enough that the 91 loops open by 2 mm at 4 substeps and fly apart at fewer. Explicit equalities take the preset's ``eq_solref`` / ``eq_solimp``. """ from newton.solvers import mujoco as mjc
half = float(model_cfg.need("hinge_half_span")) solref, solimp = model_cfg.need("eq_solref"), model_cfg.need("eq_solimp") for j in loops: J = G.joints[j] Bp = G.bodies[J["p"]] anchor = Bp["R"].T @ (J["point"] - Bp["pos"]) # hinge point and axis in the parent body's frame axis = Bp["R"].T @ J["R"][:, 2] for side in (-1.0, 1.0): mjc.add_equality_connect( b, ids[J["p"]], ids[J["c"]], anchor=wp.vec3(*(anchor + side * half * axis)), label=f"j{j}_{J['kind']}_loop_{'a' if side < 0 else 'b'}", solref=solref, solimp=solimp, )
def build_model(G: Graph, drive_mode: str, preset=None): """Free root + spanning tree (driven joints first) + revolute loop closures: the ``tree`` form.
The loop closures are REVOLUTE joints outside the articulation, except for MuJoCo (``preset.kind``), which gets CONNECT equalities from :func:`add_loop_closures_mujoco`. """ b = newton.ModelBuilder(up_axis=newton.Axis.Z, gravity=(0.0, 0.0, 0.0)) drive = set(G.driven_joints(drive_mode)) driven_bodies = {G.joints[j]["c"] for j in drive} | {G.joints[j]["p"] for j in drive} ring_of = {b_: r for r, (A, Bb) in enumerate(G.ring_bodies) for b_ in A + Bb} palette = [ (0.23, 0.49, 0.63), (0.75, 0.38, 0.23), (0.42, 0.60, 0.24), (0.54, 0.36, 0.69), (0.77, 0.60, 0.17), (0.23, 0.60, 0.56), ] amber = (1.0, 0.62, 0.11) ids = [] for B in G.bodies: bi = b.add_link(xform=wp.transform(wp.vec3(*B["pos"]), mat_to_quat(B["R"])), label=f"{B['kind']}_{len(ids)}") ids.append(bi) if B["kind"] == "connector": on = len(ids) - 1 in driven_bodies b.add_shape_sphere( bi, radius=PARAMS.connector_radius * (1.5 if on else 1.0), cfg=shape_cfg(), color=amber if on else (0.55, 0.58, 0.61), ) continue for arm in B["shapes"]: L = float(np.linalg.norm(arm)) zhat = arm / L axis = np.cross([0.0, 0.0, 1.0], zhat) ang = math.acos(max(-1.0, min(1.0, zhat[2]))) qa = wp.quat_from_axis_angle( wp.vec3(*(axis / np.linalg.norm(axis) if np.linalg.norm(axis) > 1e-9 else [1.0, 0.0, 0.0])), ang ) color = amber if len(ids) - 1 in driven_bodies else palette[ring_of.get(len(ids) - 1, 0) % 6] b.add_shape_capsule( bi, xform=wp.transform(wp.vec3(*(arm / 2)), qa), radius=PARAMS.bar_radius, half_height=L / 2, cfg=shape_cfg(), color=color, )
def frames(j, flip): J = G.joints[j] p, c = (J["c"], J["p"]) if flip else (J["p"], J["c"]) Bp, Bc = G.bodies[p], G.bodies[c] xp = wp.transform(wp.vec3(*(Bp["R"].T @ (J["point"] - Bp["pos"]))), mat_to_quat(Bp["R"].T @ J["R"])) xc = wp.transform(wp.vec3(*(Bc["R"].T @ (J["point"] - Bc["pos"]))), mat_to_quat(Bc["R"].T @ J["R"])) return p, c, xp, xc
def add_rev(j, flip, loop=False): p, c, xp, xc = frames(j, flip) driven = j in drive and not loop kw = dict( target_pos=0.0, target_ke=PARAMS.ke if driven else 0.0, target_kd=PARAMS.kd if driven else 0.0, armature=0.0, label=f"j{j}_{G.joints[j]['kind']}{'_loop' if loop else ''}", ) if driven: kw["actuator_mode"] = newton.JointTargetMode.POSITION return b.add_joint_revolute( parent=ids[p], child=ids[c], parent_xform=xp, child_xform=xc, axis=(0.0, 0.0, 1.0), **kw )
nb = len(G.bodies) in_tree, used, order = {0}, set(), [] while len(in_tree) < nb: # grow the tree greedily, driven joints first, so drives stay tree joints frontier = [j for j, J in enumerate(G.joints) if j not in used and ((J["p"] in in_tree) != (J["c"] in in_tree))] frontier.sort(key=lambda j: (j not in drive, j)) j = frontier[0] used.add(j) J = G.joints[j] flip = J["c"] in in_tree in_tree.add(J["p"] if flip else J["c"]) order.append((j, flip)) sign, coord_joint = {}, {} jid = [b.add_joint_free(child=ids[0], label="root")] for j, flip in order: coord_joint[j] = add_rev(j, flip) jid.append(coord_joint[j]) sign[j] = -1.0 if flip else 1.0 b.add_articulation(jid, label="hoberman") loops = [j for j in range(len(G.joints)) if j not in used] if preset is not None and preset.kind == "mujoco": add_loop_closures_mujoco(b, G, loops, ids, preset.model) else: for j in loops: add_rev(j, False, loop=True) model = b.finalize() return model, dict(sign=sign, coord_joint=coord_joint, loops=loops, drive=sorted(drive))
def quat_rot(q, v): u, w = q[..., :3], q[..., 3:4] t = 2.0 * np.cross(u, v) return v + w * t + np.cross(u, t)
class Metrics: """Per frame, from body poses: worst joint gap [mm] and axis misalignment [deg] over every hinge, the driven scissor angle, and the hub radii against the closed form at the measured angle; momentum from body velocities."""
def __init__(self, G: Graph, model): self.jp = np.array([J["p"] for J in G.joints]) self.jc = np.array([J["c"] for J in G.joints]) P = [G.bodies[J["p"]] for J in G.joints] C = [G.bodies[J["c"]] for J in G.joints] self.ap = np.array([Bp["R"].T @ (J["point"] - Bp["pos"]) for J, Bp in zip(G.joints, P, strict=True)]) self.ac = np.array([Bc["R"].T @ (J["point"] - Bc["pos"]) for J, Bc in zip(G.joints, C, strict=True)]) self.xp = np.array([Bp["R"].T @ J["R"][:, 2] for J, Bp in zip(G.joints, P, strict=True)]) self.xc = np.array([Bc["R"].T @ J["R"][:, 2] for J, Bc in zip(G.joints, C, strict=True)]) self.hub_b = np.array([h[0] for h in G.hub_points]) self.hub_p = np.array([h[1] for h in G.hub_points]) self.sa = np.array([s[0] for s in G.scissors]) self.sb = np.array([s[1] for s in G.scissors]) self.va = np.array([s[2] for s in G.scissors]) self.vb = np.array([s[3] for s in G.scissors]) self.mass = model.body_mass.numpy().astype(np.float64) self.com = model.body_com.numpy().astype(np.float64) self.s_half = math.sin(math.pi / G.n)
def __call__(self, bq, bqd): bq = bq.astype(np.float64) pos, q = bq[:, :3], bq[:, 3:7] gap = 1e3 * np.linalg.norm( pos[self.jp] + quat_rot(q[self.jp], self.ap) - pos[self.jc] - quat_rot(q[self.jc], self.ac), axis=1 ) xa, xb = quat_rot(q[self.jp], self.xp), quat_rot(q[self.jc], self.xc) mis = np.degrees(np.arccos(np.clip(np.sum(xa * xb, axis=1), -1, 1))) hubs = pos[self.hub_b] + quat_rot(q[self.hub_b], self.hub_p) r = np.linalg.norm(hubs - hubs.mean(axis=0), axis=1) wa, wb = quat_rot(q[self.sa], self.va), quat_rot(q[self.sb], self.vb) phi = np.arccos( np.clip(np.sum(wa * wb, axis=1) / (np.linalg.norm(wa, axis=1) * np.linalg.norm(wb, axis=1)), -1, 1) ) rho = PARAMS.arm * np.cos(phi[0] / 2) / self.s_half momentum = float(np.linalg.norm((self.mass[:, None] * bqd[:, :3].astype(np.float64)).sum(axis=0))) return dict( gap_mm=float(gap.max()), mis_deg=float(mis.max()), phi_deg=float(np.degrees(phi[0])), r_err_mm=float(1e3 * np.abs(r - rho).max()), momentum=momentum, )
@wp.kernel(enable_backward=False)def _set_targets( coords: wp.array[int], q_open: wp.array[float], q_closed: wp.array[float], tbuf: wp.array[float], dt: float, period: float, target_q: wp.array[float],): i = wp.tid() t = tbuf[0] + dt # the target at the end of this substep s = 0.5 - 0.5 * wp.cos(2.0 * wp.pi * t / period) # 0 (closed) at t = 0 -> 1 (open) at half a period -> 0 target_q[coords[i]] = q_closed[i] + s * (q_open[i] - q_closed[i])
@wp.kernel(enable_backward=False)def _advance(tbuf: wp.array[float], dt: float, period: float): t = tbuf[0] + dt if t >= period: t = t - period tbuf[0] = t
class Example: def __init__(self, viewer, args=None): self.preset = presets.load(__file__) self.viewer = viewer self.test_mode = bool(getattr(args, "test", False)) self.fps = self.preset.fps self.frame_dt = 1.0 / self.fps self.sim_time = 0.0 self.sim_substeps = self.preset.substeps self.sim_dt = self.frame_dt / self.sim_substeps
self.graph_desc = Graph(PARAMS) self.drive = getattr(args, "drive", "one") self.model, self.info = build_model(self.graph_desc, self.drive, self.preset) 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)
G, info = self.graph_desc, self.info q_start = self.model.joint_q_start.numpy() phi_closed, phi_open = math.radians(PARAMS.phi_max), math.radians(PARAMS.phi_min) device = self.model.device self._coords = wp.array([int(q_start[info["coord_joint"][j]]) for j in info["drive"]], dtype=int, device=device) self._q_closed = wp.array( [info["sign"][j] * G.joint_q_of_phi(j, phi_closed) for j in info["drive"]], dtype=float, device=device ) self._q_open = wp.array( [info["sign"][j] * G.joint_q_of_phi(j, phi_open) for j in info["drive"]], dtype=float, device=device ) self._time = wp.zeros(1, dtype=float, device=device)
self.metrics = Metrics(G, self.model) self._worst = {"gap_mm": 0.0, "mis_deg": 0.0, "r_err_mm": 0.0, "momentum": 0.0, "phi_err_deg": 0.0} self._frame = 0 self.body_q_frames = [] # test mode: body_q after every frame, scored against ground_truth.py
self.viewer.set_model(self.model) # the open sphere is 0.32 m across self.viewer.set_camera(pos=wp.vec3(-0.42, -0.42, 0.18), pitch=-15.0, yaw=45.0) self.capture()
def build_solver(self): """Build the solver and everything bound to its instance; the tuner calls this again to swap it.""" self.solver = self.preset.make_solver(self.model)
def capture(self): if wp.get_device().is_cuda and not wp.config.verify_cuda: with wp.ScopedCapture() as capture: self.simulate() self.graph = capture.graph else: self.graph = None
def simulate(self): for substep in range(self.sim_substeps): wp.launch( _set_targets, dim=len(self.info["drive"]), inputs=[self._coords, self._q_open, self._q_closed, self._time, self.sim_dt, PARAMS.period], outputs=[self.control.joint_target_q], device=self.model.device, ) self.state_0.clear_forces() self.viewer.apply_forces(self.state_0) self.solver.step(self.state_0, self.state_1, self.control, None, self.sim_dt) wp.launch(_advance, dim=1, inputs=[self._time, self.sim_dt, PARAMS.period], device=self.model.device) if self.sim_substeps % 2 == 1 and substep == self.sim_substeps - 1: # the graph starts from state_0 every frame: with an odd count, copy the last result back 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() self.sim_time += self.frame_dt self._frame += 1 if self.test_mode: self.body_q_frames.append(self.state_0.body_q.numpy()) m = self.metrics(self.body_q_frames[-1], self.state_0.body_qd.numpy()) s = 0.5 - 0.5 * math.cos(2.0 * math.pi * self.sim_time / PARAMS.period) m["phi_err_deg"] = abs(m["phi_deg"] - (PARAMS.phi_max + s * (PARAMS.phi_min - PARAMS.phi_max))) for k in self._worst: self._worst[k] = max(self._worst[k], m[k])
def render(self): self.viewer.begin_frame(self.sim_time) self.viewer.log_state(self.state_0) self.viewer.end_frame()
def test_final(self): """Finite state; every hinge closed; the hubs on the closed-form radius; the drive tracked; momentum zero.""" for name in ("body_q", "body_qd", "joint_q", "joint_qd"): assert np.isfinite(getattr(self.state_0, name).numpy()).all(), f"{name} has non-finite entries" w = self._worst assert w["gap_mm"] < 0.5, f"worst joint gap {w['gap_mm']:.3f} mm" assert w["mis_deg"] < 1.0, f"worst hinge-axis misalignment {w['mis_deg']:.3f} deg" assert w["r_err_mm"] < 1.0, f"hub radius {w['r_err_mm']:.3f} mm from a cos(phi/2)/sin(pi/10)" assert w["phi_err_deg"] < 5.0, f"driven scissor angle {w['phi_err_deg']:.2f} deg from its target" assert w["momentum"] < 1e-3, f"linear momentum {w['momentum']:.2e} kg m/s with no external force" gt = self.ground_truth_score() if gt is not None: print("ground truth: " + ", ".join(f"{k} {v:.4g}" for k, v in gt.items())) # the mechanism against the exact trajectory; the free-body drift (orient/com) is reported, not bounded assert gt["phi_max_err_deg"] < 1.0, f"scissor angle {gt['phi_max_err_deg']:.3f} deg from the exact phi(t)" assert gt["shape_pos_max_mm"] < 2.0, f"shape {gt['shape_pos_max_mm']:.3f} mm from the exact one"
def ground_truth_score(self): """The recorded frames scored against the exact trajectory (ground_truth.py); None at another frame rate.""" import ground_truth # next to this file, which the runner puts on sys.path
if not self.body_q_frames or self.fps != ground_truth.FPS: return None return ground_truth.score(np.array(self.body_q_frames[: ground_truth.FRAMES]), self.drive)
@staticmethod def create_parser(): parser = newton.examples.create_parser() parser.add_argument("--drive", choices=["one", "all"], default="one", help="drive one hub joint, or every hub") return parser
if __name__ == "__main__": parser = Example.create_parser() viewer, args = newton.examples.init(parser) newton.examples.run(Example(viewer, args), args)example = "loop_hoberman_sphere"source = "loop_hoberman_sphere/example.py (sphere, floating, one hub driven)"default = "steel"
[check]# Arguments `python -m shelf check` passes in test mode: one full open-close cycle (4 s).args = ["--num-frames", "200"]
[gallery]short = "A Hoberman sphere of 120 bars and 240 hinges opens and closes from one driven hub."title = "Hoberman sphere"frame = 50
[xpbd]status = "untested"
[vbd]status = "untested"
[mujoco]status = "works"# The 91 loop hinges are CONNECT equality pairs, +-hinge_half_span along each axis (add_loop_closures_mujoco):# SolverMuJoCo makes the same pairs from loop joints, but at MuJoCo's default solimp (0.9-0.95) they leak: the# loops open by 2 mm at 4 substeps. "euler", not the default "implicitfast": the implicit integrators fold the# drive's kd into the mass matrix after the constraint solve, so the loops are no longer satisfied (they open by# centimetres).# Explicit Euler integrates the drive's damping with first-order error, and the drive's time constant (kd/ke = 25 ms)# is close to the 20 ms frame, so the substep count is set by the drive, not the loops. Scored against# ground_truth.py over 1000 frames (RTX PRO 6000, 2026-11-07), solref = 2 dt: 1 substep (1.18 ms) puts the driven angle# 1.43 deg off the exact one (past the 1 deg check); 2 substeps: 0.71 deg, shape within 0.67 mm, 2.33 ms mean per# frame, the fastest passing setting. 4: 0.37 deg, 4.8 ms; 8: 0.20 deg, 9.4 ms; 16: 0.12 deg, 18.6 ms. The loops close# to 0.004 mm at 2 substeps. solimp 0.9999 at 1 substep goes non-finite after 200 frames; 0.99-0.999 opens the loops# by 3 mm at 1 substep. Newton's solve converges in one iteration (equality rows only), so iterations and# ls_iterations do not change the time.sim = { fps = 50, substeps = 2 }solver = { integrator = "euler", iterations = 100, ls_iterations = 50, disable_contacts = true }model = { hinge_half_span = 0.05, eq_solref = [0.02, 1.0], eq_solimp = [0.999, 0.9999, 0.001, 0.5, 2.0] }checked = "2026-11-10, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition"[mujoco_all]kind = "mujoco"status = "works"# --drive all: 30 PD drives, and "euler" integrates their kd explicitly on bars of ~2e-6 kg m^2, so the substep# count is set by that damping, not by the loops. RTX PRO 6000 sweep, 2026-11-07: 36 substeps, mean 46.7 ms per frame;# over 1000 frames the worst joint gap is 0.0006 mm, hub radius 0.0006 mm, drive error 1.8 deg, momentum 9e-6.# There is a cliff below it: at 34 substeps or fewer the sphere goes unstable as it opens (gaps of 0.1-100 mm,# momentum 0.07 and up); 35 passes 500 frames but its momentum starts to grow (1.3e-4 against 9e-6). solimp# 0.99-0.999 and solref 0.005-0.01 s are all equal here; 0.9999 goes non-finite after 200 frames.check = { args = ["--num-frames", "200", "--drive", "all"] }sim = { fps = 50, substeps = 36 }solver = { integrator = "euler", iterations = 100, ls_iterations = 50, disable_contacts = true }model = { hinge_half_span = 0.05, eq_solref = [0.01, 1.0], eq_solimp = [0.99, 0.999, 0.001, 0.5, 2.0] }checked = "2026-11-10, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition"
[featherstone]status = "untested"
[semi_implicit]status = "untested"
[feather_pgs]status = "untested"
[steel]status = "works"# loop-closing joints need the "immediate" response. 91 revolute closures are 455 rows in one articulation, so# articulation_constraint_max is raised past the default (the dense split solve, since removed, could not hold them).# 4 substeps x 64 iterations: the fastest setting (RTX PRO 6000 sweep, 2026-11-07, mean 126 ms per frame; 105.5 ms on newton# f6fc82a8b) that keeps the# sphere on its closed form: worst joint gap 0.07 mm, hub radius within 0.11 mm of a cos(phi/2)/sin(pi/10). There is# a cliff below it: at 4 x 48, 3 x 64 or 2 x 128 the loops open by 4-10 mm and the sphere twists out of shape. 8 x 32# (187 ms) is the other accurate setting. One sphere is a single articulation solved almost serially, hence the cost# (FeatherPGS took about 280 ms on the same sphere).sim = { fps = 50, substeps = 4 }solver = { solve_mode = "serial", iterations = 64, articulated_contact_response = "immediate", articulation_constraint_max = 584 }checked = "2026-11-10, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition"[steel_all]kind = "steel"status = "works"# --drive all: every hub joint driven at once (60 drives on one 1-DOF mechanism). Drive all is not repeatable run to# run, and now and then a run opens the loops past 0.5 mm, so settings were judged on many repeats (2026-11-07):# 8 x 32 passed 26 of 26 (16 on an RTX PRO 6000, 10 on a local 3090; worst joint gap 0.17 mm), mean 195 ms per# frame on the RTX PRO 6000 (166.9 ms on newton f6fc82a8b).# Faster settings fail sometimes: 6 x 48 (171 ms) passed 11 of 11 on the RTX PRO 6000 but opened to 1.06 mm in 2 of 5# local runs, 8 x 24 (180 ms) to 0.75 mm in 1 of 4 local runs, 5 x 48 and 6 x 32 (~145 ms) to 0.65 mm in 1 of 4 RTX PRO 6000# runs. Against ground_truth.py the shape stays within 0.6 mm and the driven angle within 0.2 deg, but the whole# sphere drifts as a free body (about 1 cm and 3-4 deg over 20 s), where the exact solution stays put.check = { args = ["--num-frames", "200", "--drive", "all"] }sim = { fps = 50, substeps = 8 }solver = { solve_mode = "serial", iterations = 32, articulated_contact_response = "immediate", articulation_constraint_max = 584 }checked = "2026-11-10, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition"
[kamino]status = "works"# The same model as Steel (Kamino ignores the tree: every hinge, loop closure or not, is a revolute constraint) and# the same implicit PD drive. DVI with no contacts or limits is one direct solve of the bilateral block per step, so# there are no iterations to tune; LLTBRCM with parallel_factorization factors the 1200-row block of the one world# across the GPU (LLTB, one thread block per world: 21 ms; LLTBRCM without it: 7.4 ms).# 1 substep, Baumgarte alpha = 1.0: the fastest passing setting (RTX PRO 6000 sweep, 2026-11-07, mean 2.94 ms per frame# over 3 jobs of 500 frames): worst joint gap 0.007 mm, hinge axes 0.27 deg, hub radius within 0.010 mm, drive 1.86# deg behind its target. alpha sets the gap, not the cost (0.4: 0.017 mm, 2.95 ms; the default 0.01: 0.31 mm and the# drive 4.2 deg behind); a 2nd substep doubles the cost (5.98 ms, 0.005 mm). PADMM is 70-300x slower here: it opens# the loops past 0.5 mm at 1 substep (215 ms) and passes at 4 substeps (864 ms) or 2 with alpha 0.1 (908 ms). The axis# misalignment creeps up by ~0.1 deg per 1000 frames (0.5 deg after 5000, local 3090) whatever alpha is.sim = { fps = 50, substeps = 1 }solver = { dynamics_solver = "dvi", sparse_jacobian = true, sparse_dynamics = true, constraints = { alpha = 1.0 }, dvi = { bilateral_solver_type = "LLTBRCM", bilateral_solver_kwargs = { parallel_factorization = true, block_size = 64 } } }checked = "2026-11-10, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition"
[kamino_all]kind = "kamino"status = "works"# --drive all: every hub driven at once. The same solver as [kamino]; 1 substep, alpha = 0.7: the fastest passing# setting (RTX PRO 6000 sweep, 2026-11-07, mean 3.26 ms per frame over 3 jobs of 500 frames): worst joint gap 0.009 mm,# hinge axes 0.30 deg, hub radius within 0.014 mm, drive 1.77 deg behind. alpha 0.4 and 1.0 cost the same within# noise (3.27, 3.30 ms; gap 0.017, 0.007 mm).check = { args = ["--num-frames", "200", "--drive", "all"] }sim = { fps = 50, substeps = 1 }solver = { dynamics_solver = "dvi", sparse_jacobian = true, sparse_dynamics = true, constraints = { alpha = 0.7 }, dvi = { bilateral_solver_type = "LLTBRCM", bilateral_solver_kwargs = { parallel_factorization = true, block_size = 64 } } }checked = "2026-11-10, newton f6fc82a8b, NVIDIA RTX PRO 6000 Blackwell Server Edition"
[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.