Skip to content

Affine bodies

trusty.affine simulates near-rigid bodies. Each body is a closed triangle surface that moves by one affine map, so it can translate, rotate and, by as little as its stiffness allows, stretch and shear. Bodies can be linked by joints, driven by actuators and line muscles, and collide with each other.

Capability: affine.

Quick example

A chain of five bars, hinged end to end and to the world at one end, swinging down under gravity:

bars = []
for i in range(n_links):
    cx = i * L
    V, F = make_box((cx, 0.0, z0), (L / 2, r, r))
    bars.append(trusty.affine.add_affine_body(
        world, V, F, density=1000.0, stiffness=stiffness))

# Hinge bar 0 to the world at its left end (no body_j: grounded).
trusty.affine.add_revolute_joint(
    world, bars[0], (-L / 2, -r, z0), (-L / 2, r, z0), stiffness=k)
# Hinge each bar to the one before it, at their shared end.
for i in range(1, n_links):
    x = (i - 0.5) * L
    trusty.affine.add_revolute_joint(
        world, bars[i - 1], (x, -r, z0), (x, r, z0),
        body_j=bars[i], stiffness=k)

add_affine_body adds a body and returns its id, which joints and readouts take. Its mass and inertia come from the surface and the density.

Argument Meaning
V, F the surface: vertices (n, 3) and triangles (m, 3), closed and wound outward
density kg/m³
stiffness how strongly the body resists stretching and shearing, in Pa. The default, 1e8, is effectively rigid; lower values let it deform.

Each add_revolute_joint is a hinge; see Joints.

Full script: python examples/affine/pendulum.py
"""A chain of rigid bars, hinged end to end and to the world at one end,
swinging under gravity.

Each bar is an affine body: a near-rigid box. The first bar hangs from a
revolute joint fixed in space; each following bar is hinged to the one before
it. Released horizontal, the chain swings down like a multi-link pendulum.

Usage:
    python examples/affine/pendulum.py                # polyscope
    python examples/affine/pendulum.py --no-viewer    # headless
    python examples/affine/pendulum.py --links 6 --steps 600
"""

from __future__ import annotations

import argparse

import numpy as np

import trusty


def make_box(center, half):
    h = np.asarray(half, dtype=np.float64)
    V = np.array(
        [[-h[0], -h[1], -h[2]], [h[0], -h[1], -h[2]],
         [h[0], h[1], -h[2]],   [-h[0], h[1], -h[2]],
         [-h[0], -h[1], h[2]],  [h[0], -h[1], h[2]],
         [h[0], h[1], h[2]],    [-h[0], h[1], h[2]]],
        dtype=np.float64,
    ) + np.asarray(center, dtype=np.float64)
    F = np.array(
        [[4, 5, 6], [4, 6, 7], [0, 3, 2], [0, 2, 1],
         [1, 2, 6], [1, 6, 5], [0, 4, 7], [0, 7, 3],
         [3, 7, 6], [3, 6, 2], [0, 1, 5], [0, 5, 4]],
        dtype=np.int32,
    )
    return V, F


def build_chain(n_links: int, stiffness: float, backend: str):
    trusty.check_capabilities("affine")
    L = 1.0           # bar length (x)
    r = 0.12          # bar half-thickness
    z0 = float(n_links)   # start high so the chain can swing freely
    k = stiffness         # joint stiffness (N/m); here the same number as the bodies'

    world = trusty.World(backend=backend, timestep=0.01,
                         newton=trusty.NewtonConfig(max_iters=50))
    bars = []
    for i in range(n_links):
        cx = i * L
        V, F = make_box((cx, 0.0, z0), (L / 2, r, r))
        bars.append(trusty.affine.add_affine_body(
            world, V, F, density=1000.0, stiffness=stiffness))

    # Hinge bar 0 to the world at its left end (no body_j: grounded).
    trusty.affine.add_revolute_joint(
        world, bars[0], (-L / 2, -r, z0), (-L / 2, r, z0), stiffness=k)
    # Hinge each bar to the one before it, at their shared end.
    for i in range(1, n_links):
        x = (i - 0.5) * L
        trusty.affine.add_revolute_joint(
            world, bars[i - 1], (x, -r, z0), (x, r, z0),
            body_j=bars[i], stiffness=k)

    print(f"Built a {n_links}-link affine chain (grounded hinge + "
          f"{n_links - 1} body hinges).")

    return world, bars


def run_headless(world, bars, steps: int):
    print(f"Running {steps} steps headless ({len(bars)} links)...")
    for i in range(steps):
        world.step()
        if (i + 1) % 25 == 0:
            # Tip of the last link.
            V = np.asarray(trusty.affine.surface(world, bars[-1])[0])
            tip = V[np.argmax(V[:, 0])]
            r = world.last_report()
            print(f"  step {i + 1:4d}  tip=({tip[0]:+.3f},{tip[1]:+.3f},"
                  f"{tip[2]:+.3f})  iters={r.iterations}  "
                  f"res={r.final_residual:.2e}")
    print("Done.")


def run_polyscope(world, bars, steps: int):
    try:
        import polyscope as ps
        import polyscope.imgui as psim
    except ImportError:
        print("polyscope not installed; falling back to headless.")
        run_headless(world, bars, steps)
        return

    ps.init()
    ps.set_up_dir("z_up")
    ps.set_ground_plane_mode("shadow_only")

    meshes = []
    rng = np.random.default_rng(1)
    for i, body in enumerate(bars):
        V, F = trusty.affine.surface(world, body)
        m = ps.register_surface_mesh(f"link_{i}", np.asarray(V), np.asarray(F))
        m.set_color(tuple(rng.uniform(0.3, 0.9, size=3)))
        meshes.append((body, m))

    state = {"i": 0, "playing": False}

    def advance():
        if state["i"] < steps:
            world.step()
            state["i"] += 1
            for body, m in meshes:
                m.update_vertex_positions(
                    np.asarray(trusty.affine.surface(world, body)[0]))

    def callback():
        _, state["playing"] = psim.Checkbox("play", state["playing"])
        psim.SameLine()
        if psim.Button("step"):
            advance()
        elif state["playing"]:
            advance()
        psim.Text(f"step {state['i']} / {steps}")
        r = world.last_report()
        psim.Text(f"solve: iters={r.iterations} res={r.final_residual:.2e} "
                  f"{'ok' if r.converged else 'DIVERGED'}")

    ps.set_user_callback(callback)
    ps.show()


def main():
    p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    p.add_argument("--links", type=int, default=5)
    p.add_argument("--steps", type=int, default=600)
    p.add_argument("--stiffness", type=float, default=1e8)
    p.add_argument("--backend", choices=["auto", "cpu", "accelerate", "cuda"], default="auto")
    p.add_argument("--no-viewer", action="store_true")
    args = p.parse_args()

    world, bars = build_chain(args.links, args.stiffness, args.backend)
    if args.no_viewer:
        run_headless(world, bars, args.steps)
    else:
        run_polyscope(world, bars, args.steps)


if __name__ == "__main__":
    main()

Reading and setting state

Function Does
surface(world, body) the current surface (V, F), vertices in world coordinates
center_of_mass(world, body) the centre of mass at rest, (3,)
set_velocity(world, body, v) sets the body's velocity to v (m/s) from the next step, and stops it spinning
bodies(world), num_bodies(world) every affine body in the world, as a set, and how many there are

Contact

Affine bodies collide with each other, and with contact planes and walls, once contact is on; see Contact for its settings. Friction is set per body, with add_affine_body(..., friction_mu=); see Friction.

trusty.contact.enable(world)
# Friction is per body: each block, link and the ball below is added with
# friction_mu=mu, and so is the floor.

Here a heavy cube on a chain of hinged bars swings into a wall of 64 blocks. Every body is an affine body:

A body never collides with itself, and two bodies joined by a fixed joint never collide with each other, so their surfaces may overlap.

Full script: python examples/affine/wrecking_ball.py
"""Wrecking ball: a 4-link chain of hinged affine bars with a heavy cube on
the end, released horizontal, swings down into a wall of blocks.

The chain, the ball and every block are near-rigid affine bodies, colliding
through contact with friction.

The wall is `--grid`^3 blocks, so it grows fast: the default 4 is 64 blocks
and takes under a minute headless; 8 (512 blocks) takes well over half an
hour.

Usage:
    python examples/affine/wrecking_ball.py                 # polyscope
    python examples/affine/wrecking_ball.py --no-viewer     # headless
    python examples/affine/wrecking_ball.py --grid 6
"""

from __future__ import annotations

import argparse

import numpy as np

import trusty


def make_box(center, half):
    h = np.asarray(half, dtype=np.float64)
    V = np.array(
        [[-h[0], -h[1], -h[2]], [h[0], -h[1], -h[2]],
         [h[0], h[1], -h[2]],   [-h[0], h[1], -h[2]],
         [-h[0], -h[1], h[2]],  [h[0], -h[1], h[2]],
         [h[0], h[1], h[2]],    [-h[0], h[1], h[2]]],
        dtype=np.float64,
    ) + np.asarray(center, dtype=np.float64)
    F = np.array(
        [[4, 5, 6], [4, 6, 7], [0, 3, 2], [0, 2, 1],
         [1, 2, 6], [1, 6, 5], [0, 4, 7], [0, 7, 3],
         [3, 7, 6], [3, 6, 2], [0, 1, 5], [0, 5, 4]],
        dtype=np.int32,
    )
    return V, F


# Geometry constants (metres).
BLOCK = 0.6          # wall block size
SPACING = 1.04 * BLOCK
N_LINKS = 4
LINK_LEN = 2.0       # length of each chain link
LINK_R = 0.15        # chain half-thickness
BALL = 1.5           # wrecking-ball cube size


def build_world(grid: int, mu: float, backend: str):
    trusty.check_capabilities("affine", "contact")
    wall_mid_z = grid * SPACING / 2.0
    chain_len  = N_LINKS * LINK_LEN
    z_pivot    = chain_len + BALL / 2 + wall_mid_z + 0.5
    # Wall's left face sits just past the bottom of the swing (x = 0).
    wall_cx    = grid * SPACING / 2.0 + 0.8
    base_z     = 0.55 * BLOCK

    world = trusty.World(backend=backend,
                         timestep=0.01,
                         newton=trusty.NewtonConfig(max_iters=50))
    trusty.contact.enable(world)
    # Friction is per body: each block, link and the ball below is added with
    # friction_mu=mu, and so is the floor.

    # -- The block wall: grid^3 light "wooden" blocks on the floor. --------
    n_blocks = 0
    blocks = []
    for k in range(grid):
        for j in range(grid):
            for i in range(grid):
                cx = wall_cx + (i - (grid - 1) / 2.0) * SPACING
                cy = (j - (grid - 1) / 2.0) * SPACING
                cz = base_z + k * SPACING
                V, F = make_box((cx, cy, cz), (BLOCK / 2,) * 3)
                blocks.append(trusty.affine.add_affine_body(
                    world, V, F, density=500.0, stiffness=1e8, friction_mu=mu))
                n_blocks += 1
    trusty.add_floor_plane(world, 0.0, friction_mu=mu)

    # -- The chain: links extend in -x from a grounded pivot at (0,0,z). ---
    links = []
    for i in range(N_LINKS):
        cx = -(i + 0.5) * LINK_LEN
        V, F = make_box((cx, 0.0, z_pivot), (LINK_LEN / 2, LINK_R, LINK_R))
        links.append(trusty.affine.add_affine_body(
            world, V, F, density=1000.0, stiffness=1e9, friction_mu=mu))

    # -- The dense wrecking ball welded to the chain's far end. ------------
    ball_cx = -chain_len - BALL / 2
    Vb, Fb = make_box((ball_cx, 0.0, z_pivot), (BALL / 2,) * 3)
    ball = trusty.affine.add_affine_body(
        world, Vb, Fb, density=8000.0, stiffness=1e9, friction_mu=mu)

    # Revolute joints: world pivot at link 0's near end, then shared ends
    # down the chain, then the ball to the last link.
    k = 1e9
    trusty.affine.add_revolute_joint(
        world, links[0], (0.0, -LINK_R, z_pivot), (0.0, LINK_R, z_pivot),
        stiffness=k)
    for i in range(1, N_LINKS):
        x = -i * LINK_LEN
        trusty.affine.add_revolute_joint(
            world, links[i - 1], (x, -LINK_R, z_pivot), (x, LINK_R, z_pivot),
            body_j=links[i], stiffness=k)
    x = -chain_len
    trusty.affine.add_revolute_joint(
        world, links[-1], (x, -LINK_R, z_pivot), (x, LINK_R, z_pivot),
        body_j=ball, stiffness=k)

    print(f"Built wrecking ball: {N_LINKS}-link chain + dense ball vs "
          f"{n_blocks} blocks ({grid}x{grid}x{grid}).")
    return world, ball, blocks, links


def run_headless(world, ball, steps: int):
    print(f"Running {steps} steps headless...")
    unconverged = 0
    for i in range(steps):
        world.step()
        r = world.last_report()
        unconverged += not r.converged
        if (i + 1) % 10 == 0 or not r.converged:
            c = np.asarray(trusty.affine.surface(world, ball)[0]).mean(0)
            print(f"  step {i + 1:4d}  ball=({c[0]:+.2f},{c[1]:+.2f},{c[2]:+.2f}) "
                  f"iters={r.iterations} res={r.final_residual:.2e} "
                  f"{'ok' if r.converged else 'NOT CONVERGED'}")
    print(f"Done. {unconverged} of {steps} steps did not converge.")


def run_polyscope(world, ball, blocks, links, steps: int):
    try:
        import polyscope as ps
        import polyscope.imgui as psim
    except ImportError:
        print("polyscope not installed; falling back to headless.")
        run_headless(world, ball, steps)
        return

    ps.init()
    ps.set_up_dir("z_up")

    meshes = []
    rng = np.random.default_rng(0)
    # Chain + ball in red; blocks in earthy tones.
    for i, body in enumerate(blocks):
        V, F = trusty.affine.surface(world, body)
        m = ps.register_surface_mesh(f"block_{i}", np.asarray(V), np.asarray(F))
        m.set_color(tuple(rng.uniform(0.45, 0.8, size=3)))
        meshes.append((body, m))
    for i, body in enumerate(links + [ball]):
        V, F = trusty.affine.surface(world, body)
        m = ps.register_surface_mesh(f"chain_{i}", np.asarray(V), np.asarray(F))
        m.set_color((0.85, 0.2, 0.15))
        meshes.append((body, m))

    state = {"i": 0, "playing": False}

    def advance():
        if state["i"] < steps:
            world.step()
            state["i"] += 1
            for body, m in meshes:
                m.update_vertex_positions(
                    np.asarray(trusty.affine.surface(world, body)[0]))

    def callback():
        _, state["playing"] = psim.Checkbox("play", state["playing"])
        psim.SameLine()
        if psim.Button("step"):
            advance()
        elif state["playing"]:
            advance()
        psim.Text(f"step {state['i']} / {steps}")
        r = world.last_report()
        psim.Text(f"solve: iters={r.iterations} res={r.final_residual:.2e} "
                  f"{'ok' if r.converged else 'DIVERGED'}")

    ps.set_user_callback(callback)
    ps.show()


def main():
    p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    p.add_argument("--grid", type=int, default=4,
                   help="blocks per axis (grid^3 total; default 4 -> 64)")
    p.add_argument("--steps", type=int, default=400)
    p.add_argument("--mu", type=float, default=0.3)
    p.add_argument("--backend", choices=["auto", "cpu", "accelerate", "cuda"], default="auto")
    p.add_argument("--no-viewer", action="store_true")
    args = p.parse_args()

    world, ball, blocks, links = build_world(args.grid, args.mu, args.backend)
    if args.no_viewer:
        run_headless(world, ball, args.steps)
    else:
        run_polyscope(world, ball, blocks, links, args.steps)


if __name__ == "__main__":
    main()

Joints

A joint holds points of two bodies together, or of one body and the world: leave out body_j and body_i is joined to the world. Points are given in world coordinates, where the bodies are when they are added.

Joint Call Free motion
Revolute (hinge) add_revolute_joint(world, body_i, axis_p0, axis_p1, body_j) turning about the axis through axis_p0 and axis_p1
Prismatic (slider) add_prismatic_joint(world, body_i, anchor, axis) sliding along axis; always joins body_i to the world
Spherical (ball) add_spherical_joint(world, body_i, anchor, body_j) turning freely about anchor
Fixed (weld) add_fixed_joint(world, body_i, origin, axis_u, axis_v, body_j) none

Every joint returns an id, which actuators and limits take, and has a stiffness (N/m): how hard it pulls its points back together. Here one of each moving type makes a three-link arm:

# Prismatic: the base slides along world z through its centre.
rail = trusty.affine.add_prismatic_joint(
    world, base, (0.0, 0.0, z0), (0.0, 0.0, 1.0), stiffness=k_joint)
# Revolute: link1 hinges about y at the base's right face (x = 0.30).
hinge = trusty.affine.add_revolute_joint(
    world, base, (0.30, -0.30, z0), (0.30, 0.30, z0),
    body_j=link1, stiffness=k_joint)
# Spherical: link2 ball-joints to link1 at their shared end (x = 1.30).
ball = trusty.affine.add_spherical_joint(
    world, link1, (1.30, 0.0, z0), body_j=link2, stiffness=k_joint)

A block on a vertical rail, a hinged bar and a ball-jointed bar

The grey line is the base's rail, the orange one the hinge's axis, and the orange dot the ball joint.

A revolute joint's angle is body_i's rotation relative to body_j (or to the world), right-handed about the axis from axis_p0 to axis_p1, and zero where the bodies were added. A prismatic joint's slide is body_i's displacement along axis. Actuators, limits and readouts all use these coordinates. To measure a child link relative to its parent, pass the child as body_i.

Fixed joints

A fixed joint locks the relative position and orientation of two bodies. The frame origin, origin + axis_u, origin + axis_v is held together, so axis_u and axis_v must be two offsets that are not parallel. Each body still deforms by its own stiffness, so a soft body welded to a stiff one stays in place and still gives. Here a soft foot is welded under a stiff shin:

shin = trusty.affine.add_affine_body(world, Vs, Fs, density=1000.0, stiffness=1e7)
foot = trusty.affine.add_affine_body(world, Vf, Ff, density=400.0,  stiffness=1e5)

# Weld the foot under the shin: a frame at the shin tip, spanned by two
# non-collinear offsets in the shin's bottom face.
weld_origin = shin_tip
trusty.affine.add_fixed_joint(
    world, foot, tuple(weld_origin),
    axis_u=(0.05, 0.0, 0.0), axis_v=(0.0, 0.05, 0.0),
    body_j=shin)

The pair as built (left), and at the moment it lands on the floor (right): the foot squashes, and the weld keeps it under the shin.

A stiff shin with a soft foot welded under it, at rest and on impact

Full script: python examples/affine/fixed_joint.py
"""Fixed (weld) joint: a soft foot welded under a stiff shin.

A weld locks the relative position and orientation of two affine bodies, but
each body can still deform by its own stiffness. So a soft body welded under a
stiff one stays in place and still squashes: here, a soft ball of a foot under
a stiff shin, which gives a leg a compliant contact without a separate soft
body.

  * The weld frame (`origin`, `origin + axis_u`, `origin + axis_v`) locks all
    six relative degrees of freedom: the foot can neither slide nor turn
    against the shin (a spherical joint would let it dangle, a revolute one
    would let it spin).
  * The welded pair never collides with itself, so the shin and foot meshes
    may overlap.

The shin and foot drop onto a frictionless floor together. The shin stays
rigid, the foot squashes on impact and springs back, and the weld holds the
foot under the shin throughout.

Headless mode checks this: the run stays finite, the foot stays welded to the
shin, the pair rests on the floor, and the foot compresses on impact. It
prints PASS or FAIL. Polyscope mode shows the drop.

Usage:
    python examples/affine/fixed_joint.py                # polyscope
    python examples/affine/fixed_joint.py --no-viewer    # headless test
"""

from __future__ import annotations

import argparse

import numpy as np

import trusty


def make_box(center, half):
    h = np.asarray(half, dtype=np.float64)
    V = np.array(
        [[-h[0], -h[1], -h[2]], [h[0], -h[1], -h[2]],
         [h[0], h[1], -h[2]],   [-h[0], h[1], -h[2]],
         [-h[0], -h[1], h[2]],  [h[0], -h[1], h[2]],
         [h[0], h[1], h[2]],    [-h[0], h[1], h[2]]],
        dtype=np.float64,
    ) + np.asarray(center, dtype=np.float64)
    F = np.array(
        [[4, 5, 6], [4, 6, 7], [0, 3, 2], [0, 2, 1],
         [1, 2, 6], [1, 6, 5], [0, 4, 7], [0, 7, 3],
         [3, 7, 6], [3, 6, 2], [0, 1, 5], [0, 5, 4]],
        dtype=np.int32,
    )
    return V, F


def icosphere(r, center=(0.0, 0.0, 0.0), subdiv=1):
    """Geodesic sphere (subdivided icosahedron): faceted ball, no UV poles."""
    t = (1.0 + 5.0 ** 0.5) / 2.0
    V = [[-1, t, 0], [1, t, 0], [-1, -t, 0], [1, -t, 0],
         [0, -1, t], [0, 1, t], [0, -1, -t], [0, 1, -t],
         [t, 0, -1], [t, 0, 1], [-t, 0, -1], [-t, 0, 1]]
    F = [(0, 11, 5), (0, 5, 1), (0, 1, 7), (0, 7, 10), (0, 10, 11),
         (1, 5, 9), (5, 11, 4), (11, 10, 2), (10, 7, 6), (7, 1, 8),
         (3, 9, 4), (3, 4, 2), (3, 2, 6), (3, 6, 8), (3, 8, 9),
         (4, 9, 5), (2, 4, 11), (6, 2, 10), (8, 6, 7), (9, 8, 1)]
    for _ in range(subdiv):
        mid = {}

        def midpoint(a, b):
            key = (min(a, b), max(a, b))
            if key not in mid:
                mid[key] = len(V)
                V.append([(V[a][k] + V[b][k]) * 0.5 for k in range(3)])
            return mid[key]

        F2 = []
        for a, b, c in F:
            ab, bc, ca = midpoint(a, b), midpoint(b, c), midpoint(c, a)
            F2 += [(a, ab, ca), (b, bc, ab), (c, ca, bc), (ab, bc, ca)]
        F = F2
    Vn = np.asarray(V, float)
    Vn = Vn / np.linalg.norm(Vn, axis=1, keepdims=True) * r + np.asarray(center, float)
    return Vn, np.asarray(F, dtype=np.int32)


# Geometry: a stiff shin box with a soft icosphere foot welded under its tip.
SHIN_HALF = (0.06, 0.06, 0.22)          # upright shin, long axis +z
FOOT_R    = 0.07                         # soft foot radius
DROP_Z    = 0.30                         # clearance of the foot bottom above the floor


def build(backend):
    trusty.check_capabilities("affine", "contact")
    world = trusty.World(backend=backend, timestep=0.01)
    trusty.contact.enable(world)

    shin_center = np.array([0.0, 0.0, DROP_Z + FOOT_R + SHIN_HALF[2]])
    shin_tip    = shin_center - np.array([0.0, 0.0, SHIN_HALF[2]])   # bottom face center
    foot_center = shin_tip - np.array([0.0, 0.0, FOOT_R * 0.4])      # overlaps the shin tip

    Vs, Fs = make_box(shin_center, SHIN_HALF)
    Vf, Ff = icosphere(FOOT_R, center=tuple(foot_center), subdiv=1)

    shin = trusty.affine.add_affine_body(world, Vs, Fs, density=1000.0, stiffness=1e7)
    foot = trusty.affine.add_affine_body(world, Vf, Ff, density=400.0,  stiffness=1e5)

    # Weld the foot under the shin: a frame at the shin tip, spanned by two
    # non-collinear offsets in the shin's bottom face.
    weld_origin = shin_tip
    trusty.affine.add_fixed_joint(
        world, foot, tuple(weld_origin),
        axis_u=(0.05, 0.0, 0.0), axis_v=(0.0, 0.05, 0.0),
        body_j=shin)

    trusty.contact.add_plane(world, np.array([0.0, 0.0, 0.0]),
                             np.array([0.0, 0.0, 1.0]))

    # Keep the rest surfaces, to recover each body's current affine map later.
    rest = {shin: np.asarray(trusty.affine.surface(world, shin)[0]),
            foot: np.asarray(trusty.affine.surface(world, foot)[0])}
    print("Soft foot (icosphere, stiffness 1e5) welded under a stiff shin "
          "(box, 1e7); free-dropping onto a floor.")

    return world, weld_origin, rest, shin, foot


def affine_map_point(rest_V, now_V, rest_pt):
    """Map a rest-world point to its current world position via the body's exact
    affine transform, refit from rest->current verts (least squares, exact for an
    affine body: x = A x_bar + p)."""
    n = rest_V.shape[0]
    A = np.hstack([rest_V, np.ones((n, 1))])     # (n,4)
    M, *_ = np.linalg.lstsq(A, now_V, rcond=None)  # (4,3): [rest|1] @ M = now
    return np.append(np.asarray(rest_pt, float), 1.0) @ M


def foot_z_extent(world, foot):
    """z extent of the foot."""
    V = np.asarray(trusty.affine.surface(world, foot)[0])
    return float(V[:, 2].max() - V[:, 2].min())


def run_headless(world, weld_origin, rest, shin, foot, steps):
    rest_foot_extent = foot_z_extent(world, foot)
    min_foot_extent = rest_foot_extent
    finite = True
    for i in range(steps):
        world.step()
        Vf = np.asarray(trusty.affine.surface(world, foot)[0])
        Vs = np.asarray(trusty.affine.surface(world, shin)[0])
        if not (np.isfinite(Vf).all() and np.isfinite(Vs).all()):
            finite = False
            break
        min_foot_extent = min(min_foot_extent, foot_z_extent(world, foot))
        if (i + 1) % 25 == 0:
            print(f"  step {i + 1:4d}  foot bottom z={Vf[:, 2].min():+.4f}  "
                  f"foot height={foot_z_extent(world, foot):.4f} (rest {rest_foot_extent:.4f})")

    # Weld held: the frame origin maps to the same world point on both bodies.
    on_foot = affine_map_point(
        rest[foot], np.asarray(trusty.affine.surface(world, foot)[0]), weld_origin)
    on_shin = affine_map_point(
        rest[shin], np.asarray(trusty.affine.surface(world, shin)[0]), weld_origin)
    weld_gap = float(np.linalg.norm(on_foot - on_shin))

    min_z = min(np.asarray(trusty.affine.surface(world, foot)[0])[:, 2].min(),
                np.asarray(trusty.affine.surface(world, shin)[0])[:, 2].min())
    compression = 1.0 - min_foot_extent / rest_foot_extent

    print(f"\nfinite throughout: {finite}")
    print(f"weld gap (foot vs shin at frame origin): {weld_gap:.5f} m")
    print(f"assembly rests on floor (min surface z): {min_z:+.4f} m")
    print(f"soft foot peak compression: {100 * compression:.1f}%  "
          f"(stiff shin stays rigid)")

    ok = (finite and weld_gap < 5e-3 and min_z > -1e-2 and compression > 0.01)
    print("PASS: the weld held the soft foot under the stiff shin, and the foot "
          "squashed on impact." if ok else "FAIL: see metrics above.")
    return ok


def run_polyscope(world, weld_origin, rest, shin, foot, steps):
    try:
        import polyscope as ps
        import polyscope.imgui as psim
    except ImportError:
        print("polyscope not installed; falling back to headless.")
        run_headless(world, weld_origin, rest, shin, foot, steps)
        return

    ps.init()
    ps.set_up_dir("z_up")
    ps.set_ground_plane_mode("shadow_only")
    styled = [("shin (stiff)", shin, (0.55, 0.55, 0.6)),
              ("foot (soft)", foot, (0.85, 0.45, 0.3))]
    meshes = []
    for name, body, color in styled:
        V, F = trusty.affine.surface(world, body)
        m = ps.register_surface_mesh(name, np.asarray(V), np.asarray(F))
        m.set_color(color)
        meshes.append((body, m))
    state = {"i": 0, "playing": True}

    def callback():
        _, state["playing"] = psim.Checkbox("play", state["playing"])
        psim.SameLine()
        if (psim.Button("step") or state["playing"]) and state["i"] < steps:
            world.step()
            state["i"] += 1
            for body, m in meshes:
                m.update_vertex_positions(
                    np.asarray(trusty.affine.surface(world, body)[0]))
        psim.Text(f"step {state['i']}/{steps}")

    ps.set_user_callback(callback)
    ps.show()


def main():
    p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    p.add_argument("--steps", type=int, default=200)
    p.add_argument("--backend", choices=["auto", "cpu", "accelerate", "cuda"], default="auto")
    p.add_argument("--no-viewer", action="store_true")
    args = p.parse_args()

    world, weld_origin, rest, shin, foot = build(args.backend)
    if args.no_viewer:
        ok = run_headless(world, weld_origin, rest, shin, foot, args.steps)
        raise SystemExit(0 if ok else 1)
    run_polyscope(world, weld_origin, rest, shin, foot, args.steps)


if __name__ == "__main__":
    main()

Actuators

An actuator drives a joint toward a target: an angle for a revolute joint, a slide for a prismatic one, an orientation for a spherical one. Its stiffness is how hard it pulls toward the target: N·m/rad for a hinge, N/m for a slider. Add one per joint:

# One position-target actuator per joint.
act_prismatic = trusty.affine.add_actuator(world, rail,  stiffness=8e5)
act_revolute  = trusty.affine.add_actuator(world, hinge, stiffness=2e5)
act_spherical = trusty.affine.add_actuator(world, ball,  stiffness=8e4)

Then set new targets whenever you like, typically before each step. A target holds until it is changed:

a_pri, a_rev, a_sph = acts
slide, angle, twist = commands(t)
trusty.affine.set_actuator_target(world, a_pri, slide)    # metres
trusty.affine.set_actuator_target(world, a_rev, angle)    # radians
trusty.affine.set_actuator_rotation(world, a_sph, twist)  # rotation vector
Function Does
add_actuator(world, joint, stiffness) adds an actuator to a revolute, prismatic or spherical joint
set_actuator_target(world, actuator, value) the target angle (rad) or slide (m)
set_actuator_rotation(world, actuator, rotvec) a spherical joint's target: body_i's orientation relative to body_j, as a rotation vector (axis × angle, rad)
actuator_value(world, actuator) the joint's current angle or slide
actuator_target(world, actuator) the current target angle or slide
num_actuators(world) how many actuators there are

A stiffer actuator follows its target more closely against a load: in the arm above, the hinge carries both bars under gravity and lags its target by about a thousandth of a radian.

Full script: python examples/affine/actuated_arm.py
"""A 3-link arm with one joint of each moving type, each driven by an
actuator.

The chain is:

    world --prismatic (z)--> base    (slides up and down)
    base  --revolute (y)-->  link1   (hinges)
    link1 --spherical-->     link2   (ball joint, turns freely)

Each joint carries an actuator. Every step the script sets a new target for
each one (a slide in metres, an angle in radians, and an orientation as a
rotation vector), and the joints follow it against gravity.

The slider and the hinge also have joint limits. The commands sweep further
than the limits allow, so those two joints stop at their limits while the
command keeps going: watch "act" level off while "cmd" keeps moving.

Usage:
    python examples/affine/actuated_arm.py                # polyscope
    python examples/affine/actuated_arm.py --no-viewer    # headless
    python examples/affine/actuated_arm.py --steps 800
"""

from __future__ import annotations

import argparse
import math

import numpy as np

import trusty


def make_box(center, half):
    h = np.asarray(half, dtype=np.float64)
    V = np.array(
        [[-h[0], -h[1], -h[2]], [h[0], -h[1], -h[2]],
         [h[0], h[1], -h[2]],   [-h[0], h[1], -h[2]],
         [-h[0], -h[1], h[2]],  [h[0], -h[1], h[2]],
         [h[0], h[1], h[2]],    [-h[0], h[1], h[2]]],
        dtype=np.float64,
    ) + np.asarray(center, dtype=np.float64)
    F = np.array(
        [[4, 5, 6], [4, 6, 7], [0, 3, 2], [0, 2, 1],
         [1, 2, 6], [1, 6, 5], [0, 4, 7], [0, 7, 3],
         [3, 7, 6], [3, 6, 2], [0, 1, 5], [0, 5, 4]],
        dtype=np.int32,
    )
    return V, F


def build_arm(backend: str):
    trusty.check_capabilities("affine")
    z0 = 2.0
    k_joint = 1e8

    world = trusty.World(backend=backend, timestep=0.01, newton=trusty.NewtonConfig(max_iters=60))

    # Base block on a grounded vertical slider; two bars hanging off it.
    Vb, Fb = make_box((0.0, 0.0, z0), (0.30, 0.30, 0.30))
    V1, F1 = make_box((0.80, 0.0, z0), (0.50, 0.12, 0.12))
    V2, F2 = make_box((1.70, 0.0, z0), (0.40, 0.10, 0.10))
    base  = trusty.affine.add_affine_body(world, Vb, Fb, density=400.0, stiffness=k_joint)
    link1 = trusty.affine.add_affine_body(world, V1, F1, density=400.0, stiffness=k_joint)
    link2 = trusty.affine.add_affine_body(world, V2, F2, density=400.0, stiffness=k_joint)

    # Prismatic: the base slides along world z through its centre.
    rail = trusty.affine.add_prismatic_joint(
        world, base, (0.0, 0.0, z0), (0.0, 0.0, 1.0), stiffness=k_joint)
    # Revolute: link1 hinges about y at the base's right face (x = 0.30).
    hinge = trusty.affine.add_revolute_joint(
        world, base, (0.30, -0.30, z0), (0.30, 0.30, z0),
        body_j=link1, stiffness=k_joint)
    # Spherical: link2 ball-joints to link1 at their shared end (x = 1.30).
    ball = trusty.affine.add_spherical_joint(
        world, link1, (1.30, 0.0, z0), body_j=link2, stiffness=k_joint)

    # One position-target actuator per joint.
    act_prismatic = trusty.affine.add_actuator(world, rail,  stiffness=8e5)
    act_revolute  = trusty.affine.add_actuator(world, hinge, stiffness=2e5)
    act_spherical = trusty.affine.add_actuator(world, ball,  stiffness=8e4)

    # Joint limits: the commands below sweep wider than these, so the slider
    # and hinge stop at the limit instead of following the command.
    trusty.affine.add_joint_limit(world, rail,  -0.25, 0.25, margin=0.05, stiffness=2e4)
    trusty.affine.add_joint_limit(world, hinge, -0.45, 0.45, margin=0.08, stiffness=1e4)

    print(f"Built a 3-link arm: grounded prismatic base + revolute + spherical, "
          f"with {trusty.affine.num_actuators(world)} actuators.")
    return world, (act_prismatic, act_revolute, act_spherical), (base, link1, link2)


def commands(t: float):
    """Time-varying position targets, one per actuator."""
    slide  = 0.35 * math.sin(0.8 * t)                  # prismatic, metres
    angle  = 0.7 * math.sin(0.6 * t)                   # revolute, radians
    twist  = (0.6 * math.sin(0.5 * t),                 # spherical, axis-angle
              0.4 * math.sin(0.4 * t + 1.0), 0.0)
    return slide, angle, twist


def drive(world, acts, t: float):
    a_pri, a_rev, a_sph = acts
    slide, angle, twist = commands(t)
    trusty.affine.set_actuator_target(world, a_pri, slide)    # metres
    trusty.affine.set_actuator_target(world, a_rev, angle)    # radians
    trusty.affine.set_actuator_rotation(world, a_sph, twist)  # rotation vector
    return slide, angle


def run_headless(world, acts, steps: int):
    print(f"Running {steps} steps headless...")
    dt = 0.01
    for i in range(steps):
        t = i * dt
        slide_cmd, angle_cmd = drive(world, acts, t)
        world.step()
        if (i + 1) % 50 == 0:
            slide_act = trusty.affine.actuator_value(world, acts[0])
            angle_act = trusty.affine.actuator_value(world, acts[1])
            r = world.last_report()
            print(f"  step {i + 1:4d}  "
                  f"slide cmd={slide_cmd:+.3f} act={slide_act:+.3f} | "
                  f"angle cmd={angle_cmd:+.3f} act={angle_act:+.3f} | "
                  f"iters={r.iterations} res={r.final_residual:.1e}")
    print("Done.")


def run_polyscope(world, acts, arm_bodies, steps: int):
    try:
        import polyscope as ps
        import polyscope.imgui as psim
    except ImportError:
        print("polyscope not installed; falling back to headless.")
        run_headless(world, acts, steps)
        return

    ps.init()
    ps.set_up_dir("z_up")
    ps.set_ground_plane_mode("shadow_only")

    meshes = []
    rng = np.random.default_rng(2)
    for name, body in zip(("base", "link1", "link2"), arm_bodies):
        V, F = trusty.affine.surface(world, body)
        m = ps.register_surface_mesh(name, np.asarray(V), np.asarray(F))
        m.set_color(tuple(rng.uniform(0.3, 0.9, size=3)))
        meshes.append((body, m))

    state = {"i": 0, "playing": True}

    def advance():
        if state["i"] < steps:
            drive(world, acts, state["i"] * 0.01)
            world.step()
            state["i"] += 1
            for body, m in meshes:
                m.update_vertex_positions(
                    np.asarray(trusty.affine.surface(world, body)[0]))

    def callback():
        _, state["playing"] = psim.Checkbox("play", state["playing"])
        psim.SameLine()
        if psim.Button("step"):
            advance()
        elif state["playing"]:
            advance()
        psim.Text(f"step {state['i']} / {steps}")
        psim.Text(f"prismatic slide: {trusty.affine.actuator_value(world, acts[0]):+.3f} m")
        psim.Text(f"revolute angle:  {trusty.affine.actuator_value(world, acts[1]):+.3f} rad")
        r = world.last_report()
        psim.Text(f"solve: iters={r.iterations} res={r.final_residual:.2e} "
                  f"{'ok' if r.converged else 'DIVERGED'}")

    ps.set_user_callback(callback)
    ps.show()


def main():
    p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    p.add_argument("--steps", type=int, default=800)
    p.add_argument("--backend", choices=["auto", "cpu", "accelerate", "cuda"], default="auto")
    p.add_argument("--no-viewer", action="store_true")
    args = p.parse_args()

    world, acts, arm_bodies = build_arm(args.backend)
    if args.no_viewer:
        run_headless(world, acts, args.steps)
    else:
        run_polyscope(world, acts, arm_bodies, args.steps)


if __name__ == "__main__":
    main()

Joint limits

A joint limit keeps a revolute joint's angle or a prismatic joint's slide between lo and hi:

trusty.affine.add_joint_limit(world, hinge, lo, hi, margin=0.08,
                              stiffness=1e4)
Argument Meaning
lo, hi the range: radians, inside (−π, π), for a revolute joint; metres for a prismatic one. It must contain the joint's starting value, 0.
margin how far from lo or hi the limit starts to push back
stiffness how hard it pushes back

Two hinged bars are driven by the same target, which swings to 1.1 rad. The grey bar has no limit and follows it; the red bar is limited to ±0.5 rad (the orange lines) and waits at its limit:

Two hinged bars: one follows its target, one stops at its limit

The joint does not pass through its limit, even against an actuator far stiffer than the limit. Spherical and fixed joints take no limits.

Full script: python examples/affine/joint_limit.py
"""Joint limits: two identical hinges driven by the same swept target angle,
one with a joint limit and one without.

The target sweeps well past the limits. The free bar follows it; the limited
bar stops at its limit and waits there until the target comes back.

Polyscope mode shows the two bars side by side; headless mode prints the
target and both angles as the run progresses.

Usage:
    python examples/affine/joint_limit.py                # polyscope
    python examples/affine/joint_limit.py --no-viewer    # headless
    python examples/affine/joint_limit.py --lo -0.5 --hi 0.5
"""

from __future__ import annotations

import argparse
import math

import numpy as np

import trusty


def make_box(center, half):
    h = np.asarray(half, dtype=np.float64)
    V = np.array(
        [[-h[0], -h[1], -h[2]], [h[0], -h[1], -h[2]],
         [h[0], h[1], -h[2]],   [-h[0], h[1], -h[2]],
         [-h[0], -h[1], h[2]],  [h[0], -h[1], h[2]],
         [h[0], h[1], h[2]],    [-h[0], h[1], h[2]]],
        dtype=np.float64,
    ) + np.asarray(center, dtype=np.float64)
    F = np.array(
        [[4, 5, 6], [4, 6, 7], [0, 3, 2], [0, 2, 1],
         [1, 2, 6], [1, 6, 5], [0, 4, 7], [0, 7, 3],
         [3, 7, 6], [3, 6, 2], [0, 1, 5], [0, 5, 4]],
        dtype=np.int32,
    )
    return V, F


def add_hinged_bar(world, center, lo, hi, with_limit):
    """A bar grounded by a y-axis hinge at its left end, with an actuator and
    (optionally) a joint limit. Returns (body, actuator)."""
    L, r = 1.0, 0.12
    V, F = make_box(center, (L / 2, r, r))
    body = trusty.affine.add_affine_body(world, V, F, density=300.0, stiffness=1e8)
    cx, cy, cz = center
    hinge = trusty.affine.add_revolute_joint(
        world, body, (cx - L / 2, cy - r, cz), (cx - L / 2, cy + r, cz),
        stiffness=1e8)
    act = trusty.affine.add_actuator(world, hinge, stiffness=1e5)
    if with_limit:
        trusty.affine.add_joint_limit(world, hinge, lo, hi, margin=0.08,
                                      stiffness=1e4)
    return body, act


def build(lo, hi, backend):
    trusty.check_capabilities("affine")
    z0 = 2.0
    world = trusty.World(backend=backend,
                         timestep=0.01,
                         gravity=(0.0, 0.0, 0.0),  # actuator-only, so the contrast is clean
                         newton=trusty.NewtonConfig(max_iters=60))
    free_bar, act_free = add_hinged_bar(
        world, (0.0, -0.5, z0), lo, hi, with_limit=False)
    limited_bar, act_limited = add_hinged_bar(
        world, (0.0,  0.5, z0), lo, hi, with_limit=True)

    print(f"Two hinges, same command. One free, one limited to "
          f"[{lo:+.2f}, {hi:+.2f}] rad.")
    return world, free_bar, act_free, limited_bar, act_limited


def target(t: float, amp: float) -> float:
    """A sweep that exceeds +/- amp so the limit is exercised on both sides."""
    return amp * math.sin(0.7 * t)


def run_headless(world, act_free, act_limited, lo, hi, steps, amp):
    print(f"Running {steps} steps headless (command amplitude {amp:.2f} rad)...")
    for i in range(steps):
        cmd = target(i * 0.01, amp)
        trusty.affine.set_actuator_target(world, act_free, cmd)
        trusty.affine.set_actuator_target(world, act_limited, cmd)
        world.step()
        if (i + 1) % 50 == 0:
            free = trusty.affine.actuator_value(world, act_free)
            lim = trusty.affine.actuator_value(world, act_limited)
            clamp = "  <-- CLAMPED" if (cmd > hi + 1e-3 and lim < hi) \
                    or (cmd < lo - 1e-3 and lim > lo) else ""
            print(f"  step {i + 1:4d}  cmd={cmd:+.3f} | free={free:+.3f} "
                  f"limited={lim:+.3f}{clamp}")


def run_polyscope(world, free_bar, act_free, limited_bar, act_limited,
                  lo, hi, steps, amp):
    try:
        import polyscope as ps
        import polyscope.imgui as psim
    except ImportError:
        print("polyscope not installed; falling back to headless.")
        run_headless(world, act_free, act_limited, lo, hi, steps, amp)
        return

    ps.init()
    ps.set_up_dir("z_up")
    ps.set_ground_plane_mode("shadow_only")

    styled = [("free", free_bar, (0.85, 0.4, 0.3)),
              ("limited", limited_bar, (0.3, 0.55, 0.85))]
    meshes = []
    for name, body, color in styled:
        V, F = trusty.affine.surface(world, body)
        m = ps.register_surface_mesh(name, np.asarray(V), np.asarray(F))
        m.set_color(color)
        meshes.append((body, m))

    state = {"i": 0, "playing": True}

    def advance():
        if state["i"] < steps:
            cmd = target(state["i"] * 0.01, amp)
            trusty.affine.set_actuator_target(world, act_free, cmd)
            trusty.affine.set_actuator_target(world, act_limited, cmd)
            world.step()
            state["i"] += 1
            for body, m in meshes:
                m.update_vertex_positions(
                    np.asarray(trusty.affine.surface(world, body)[0]))

    def callback():
        _, state["playing"] = psim.Checkbox("play", state["playing"])
        psim.SameLine()
        if psim.Button("step"):
            advance()
        elif state["playing"]:
            advance()
        cmd = target(state["i"] * 0.01, amp)
        psim.Text(f"command angle:  {cmd:+.3f} rad")
        psim.Text(f"free (red):     {trusty.affine.actuator_value(world, act_free):+.3f} rad")
        psim.Text(f"limited (blue): {trusty.affine.actuator_value(world, act_limited):+.3f} "
                  f"rad   stops [{lo:+.2f}, {hi:+.2f}]")

    ps.set_user_callback(callback)
    ps.show()


def main():
    p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    p.add_argument("--lo", type=float, default=-0.5)
    p.add_argument("--hi", type=float, default=0.5)
    p.add_argument("--amp", type=float, default=1.1, help="command amplitude (rad)")
    p.add_argument("--steps", type=int, default=700)
    p.add_argument("--backend", choices=["auto", "cpu", "accelerate", "cuda"], default="auto")
    p.add_argument("--no-viewer", action="store_true")
    args = p.parse_args()

    world, free_bar, act_free, limited_bar, act_limited = build(
        args.lo, args.hi, args.backend)
    if args.no_viewer:
        run_headless(world, act_free, act_limited, args.lo, args.hi,
                     args.steps, args.amp)
        return
    run_polyscope(world, free_bar, act_free, limited_bar, act_limited,
                  args.lo, args.hi, args.steps, args.amp)


if __name__ == "__main__":
    main()

Velocity limits

A velocity limit caps how fast a revolute, prismatic or spherical joint moves: omega_max in rad/s (m/s for a slider). It is useful to keep sudden target changes from whipping a light joint around:

trusty.affine.add_joint_velocity_limit(world, hinge, omega_max,
                                       stiffness=5e6, mode=mode)

mode sets how the cap pushes back:

VelocityPenaltyMode Behaviour
Barrier (default) a hard cap: the joint never moves faster than omega_max
Quadratic a soft cap, which may overshoot omega_max a little
Cubic a softer cap, which may overshoot a little more

Both bars are given a sudden jump in target angle, and are shown every six steps after it. The grey bar has no limit and gets there within a step; the red one is limited to 3 rad/s and turns there steadily:

Snapshots of two bars turning: one at once, one at a capped speed

Full script: python examples/affine/joint_velocity_limit.py
"""Joint VELOCITY-limit test: two identical light grounded hinges given the SAME
step command, one with a joint velocity limit and one without.

The actuator is a position servo, so a step change in its target whips the
(light) free bar to the new angle in a couple of steps, at many rad/s -- fast
enough to make contact stiff. The velocity-limited bar instead ramps toward the
same target at no more than `omega_max`: the limit resists any joint speed above
it. Both reach the target; only the rate differs.

`--penalty-mode` picks the penalty shape; both choices here are soft caps, so
the limited bar is allowed a small overshoot.

Headless mode is a self-checking test: it asserts the limited bar's per-step
angular speed never exceeds `omega_max` (plus a small soft-cap tolerance), the
free bar's peak speed far exceeds it, and both bars reach the commanded targets.
Polyscope mode shows the two bars side by side with a live speed readout.

Usage:
    python examples/affine/joint_velocity_limit.py                # polyscope
    python examples/affine/joint_velocity_limit.py --no-viewer    # headless test
    python examples/affine/joint_velocity_limit.py --omega-max 2.0
"""

from __future__ import annotations

import argparse

import numpy as np

import trusty


def make_box(center, half):
    h = np.asarray(half, dtype=np.float64)
    V = np.array(
        [[-h[0], -h[1], -h[2]], [h[0], -h[1], -h[2]],
         [h[0], h[1], -h[2]],   [-h[0], h[1], -h[2]],
         [-h[0], -h[1], h[2]],  [h[0], -h[1], h[2]],
         [h[0], h[1], h[2]],    [-h[0], h[1], h[2]]],
        dtype=np.float64,
    ) + np.asarray(center, dtype=np.float64)
    F = np.array(
        [[4, 5, 6], [4, 6, 7], [0, 3, 2], [0, 2, 1],
         [1, 2, 6], [1, 6, 5], [0, 4, 7], [0, 7, 3],
         [3, 7, 6], [3, 6, 2], [0, 1, 5], [0, 5, 4]],
        dtype=np.int32,
    )
    return V, F


def add_hinged_bar(world, center, omega_max, with_limit, mode):
    """A light bar grounded by a y-axis hinge at its left end, with a high-gain
    servo and (optionally) a velocity limit. Returns (body, actuator)."""
    L, r = 1.0, 0.12
    V, F = make_box(center, (L / 2, r, r))
    # Light body -> a step command would otherwise whip it at many rad/s.
    body = trusty.affine.add_affine_body(world, V, F, density=50.0, stiffness=1e8)
    cx, cy, cz = center
    hinge = trusty.affine.add_revolute_joint(
        world, body, (cx - L / 2, cy - r, cz), (cx - L / 2, cy + r, cz),
        stiffness=1e8)
    act = trusty.affine.add_actuator(world, hinge, stiffness=1e5)
    if with_limit:
        trusty.affine.add_joint_velocity_limit(world, hinge, omega_max,
                                               stiffness=5e6, mode=mode)
    return body, act


def build(omega_max, backend, mode):
    z0 = 2.0
    world = trusty.World(backend=backend,
                         timestep=0.01,
                         gravity=(0.0, 0.0, 0.0),  # actuator-only, so the contrast is clean
                         newton=trusty.NewtonConfig(max_iters=60))
    free_bar, act_free = add_hinged_bar(
        world, (0.0, -0.5, z0), omega_max, with_limit=False, mode=mode)
    limited_bar, act_limited = add_hinged_bar(
        world, (0.0,  0.5, z0), omega_max, with_limit=True, mode=mode)

    print(f"Two light hinges, same step command. One free, one limited to "
          f"|theta_dot| <= {omega_max:.2f} rad/s.")

    return world, free_bar, act_free, limited_bar, act_limited


def target(t: float) -> float:
    """A step to +1.2 rad, then a reversal to -0.6 rad -- exercises the cap both
    directions. Each step is a jump the servo would reach in about one step."""
    return 1.2 if t < 1.0 else -0.6


def run_headless(world, act_free, act_limited, omega_max, steps, dt):
    print(f"Running {steps} steps headless (omega_max = {omega_max:.2f} rad/s)...")
    free_prev = trusty.affine.actuator_value(world, act_free)
    lim_prev  = trusty.affine.actuator_value(world, act_limited)
    free_peak = 0.0
    lim_peak  = 0.0
    for i in range(steps):
        t = i * dt
        cmd = target(t)
        trusty.affine.set_actuator_target(world, act_free, cmd)
        trusty.affine.set_actuator_target(world, act_limited, cmd)
        world.step()
        free = trusty.affine.actuator_value(world, act_free)
        lim  = trusty.affine.actuator_value(world, act_limited)
        free_rate = abs(free - free_prev) / dt
        lim_rate  = abs(lim - lim_prev) / dt
        free_peak = max(free_peak, free_rate)
        lim_peak  = max(lim_peak, lim_rate)
        free_prev, lim_prev = free, lim
        if (i + 1) % 50 == 0:
            print(f"  step {i + 1:4d}  cmd={cmd:+.2f} | free {free:+.3f} "
                  f"({free_rate:5.2f} rad/s)  limited {lim:+.3f} "
                  f"({lim_rate:5.2f} rad/s)")

    tol = 1.35 * omega_max   # soft cap -> allow a small overshoot
    capped = lim_peak <= tol
    whipped = free_peak > 2.0 * omega_max
    reached = abs(lim - (-0.6)) < 0.1
    print(f"\nfree peak rate    = {free_peak:6.2f} rad/s  (uncapped)")
    print(f"limited peak rate = {lim_peak:6.2f} rad/s  (cap {omega_max:.2f}, "
          f"tol {tol:.2f})")
    print(f"limited final angle = {lim:+.3f} rad  (target -0.60)")
    ok = capped and whipped and reached
    print("PASS: velocity limit capped the rate while the free bar whipped, and "
          "the limited bar still reached its target." if ok
          else "FAIL: velocity limit did not behave as expected.")
    return ok


def run_polyscope(world, free_bar, act_free, limited_bar, act_limited,
                  omega_max, steps, dt):
    try:
        import polyscope as ps
        import polyscope.imgui as psim
    except ImportError:
        print("polyscope not installed; falling back to headless.")
        run_headless(world, act_free, act_limited, omega_max, steps, dt)
        return

    ps.init()
    ps.set_up_dir("z_up")
    ps.set_ground_plane_mode("shadow_only")

    styled = [("free", free_bar, (0.85, 0.4, 0.3)),
              ("limited", limited_bar, (0.3, 0.55, 0.85))]
    meshes = []
    for name, body, color in styled:
        V, F = trusty.affine.surface(world, body)
        m = ps.register_surface_mesh(name, np.asarray(V), np.asarray(F))
        m.set_color(color)
        meshes.append((body, m))

    state = {"i": 0, "playing": False,
             "free_prev": trusty.affine.actuator_value(world, act_free),
             "lim_prev": trusty.affine.actuator_value(world, act_limited),
             "free_rate": 0.0, "lim_rate": 0.0}

    def advance():
        if state["i"] < steps:
            cmd = target(state["i"] * dt)
            trusty.affine.set_actuator_target(world, act_free, cmd)
            trusty.affine.set_actuator_target(world, act_limited, cmd)
            world.step()
            state["i"] += 1
            free = trusty.affine.actuator_value(world, act_free)
            lim  = trusty.affine.actuator_value(world, act_limited)
            state["free_rate"] = abs(free - state["free_prev"]) / dt
            state["lim_rate"]  = abs(lim - state["lim_prev"]) / dt
            state["free_prev"], state["lim_prev"] = free, lim
            for body, m in meshes:
                m.update_vertex_positions(
                    np.asarray(trusty.affine.surface(world, body)[0]))

    def callback():
        _, state["playing"] = psim.Checkbox("play", state["playing"])
        psim.SameLine()
        if psim.Button("step"):
            advance()
        elif state["playing"]:
            advance()
        cmd = target(state["i"] * dt)
        psim.Text(f"command angle:  {cmd:+.2f} rad")
        psim.Text(f"free (red):     {state['free_rate']:6.2f} rad/s")
        psim.Text(f"limited (blue): {state['lim_rate']:6.2f} rad/s   "
                  f"cap {omega_max:.2f}")

    ps.set_user_callback(callback)
    ps.show()


def main():
    trusty.check_capabilities("affine")
    p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    p.add_argument("--omega-max", type=float, default=3.0,
                   help="joint angular velocity cap (rad/s)")
    p.add_argument("--steps", type=int, default=200)
    p.add_argument("--backend", choices=["auto", "cpu", "cuda", "accelerate"], default="auto")
    p.add_argument("--penalty-mode", choices=["cubic", "quadratic"],
                   default="quadratic",
                   help="velocity-penalty shape: quadratic (default) holds the "
                        "cap more firmly")
    p.add_argument("--no-viewer", action="store_true")
    args = p.parse_args()

    mode = {"cubic": trusty.affine.VelocityPenaltyMode.Cubic,
            "quadratic": trusty.affine.VelocityPenaltyMode.Quadratic}[
                args.penalty_mode]

    dt = 0.01
    world, free_bar, act_free, limited_bar, act_limited = build(
        args.omega_max, args.backend, mode)
    if args.no_viewer:
        ok = run_headless(world, act_free, act_limited, args.omega_max, args.steps, dt)
        raise SystemExit(0 if ok else 1)
    run_polyscope(world, free_bar, act_free, limited_bar, act_limited,
                  args.omega_max, args.steps, dt)


if __name__ == "__main__":
    main()

Line muscles

A line muscle pulls along a path of points fixed to bodies, like a muscle between its attachments. Here one runs from the upper arm to the forearm, above the elbow:

points = np.array([ORIGIN_POINT, INSERTION_POINT], dtype=np.float64)
rest_length = float(np.linalg.norm(points[1] - points[0]))
# The fiber starts at its optimal length (the rest path minus the tendon),
# where it pulls hardest.
l_tendon_slack = 0.30
l_opt = rest_length - l_tendon_slack
muscle = trusty.affine.add_line_muscle(
    world, [upper, fore], points, f_max=f_max, l_opt=l_opt,
    l_tendon_slack=l_tendon_slack, pennation=0.0, activation=0.0, model=model)
Argument Meaning
bodies the body each point is fixed to, one per point; at least two different bodies
points the path, (n, 3), in world coordinates where the bodies are when added. Segments are straight.
f_max the most the muscle can pull, in N
l_opt the fibre length at which it pulls hardest, in m
l_tendon_slack the length of the tendon, in m. The fibre is the path minus the tendon.
pennation the angle of the fibres to the path, in rad
activation how active the muscle is, from 0 (relaxed) to 1 (fully on)
model the force-length law: MuscleModel.Hill (default) or MuscleModel.Polynomial

Activation does not set a position: it sets how hard the muscle pulls, and the bodies move until something balances it. Set it, step, and read back the muscle:

trusty.affine.set_muscle_activation(world, muscle, activation)
for _ in range(steps):
    world.step()
return (elbow_angle(world, fore),
        trusty.affine.muscle_length(world, muscle),   # m
        trusty.affine.muscle_force(world, muscle))    # N

Fully activated, the muscle curls the forearm up to its joint limit, and holds it there (the faint box is the forearm at rest):

A two-link arm curled up by a line muscle

Function Returns
set_muscle_activation(world, muscle, activation) sets the activation; it holds until changed
muscle_activation(world, muscle) the current activation
muscle_length(world, muscle) the path length, in m
muscle_force(world, muscle) the tension, in N
muscle_path(world, muscle) the current path, (n, 3)
num_muscles(world) how many muscles there are
Full script: python examples/affine/line_muscle.py
"""Line muscle: a "biceps" curling a two-link arm.

An upper arm welded to the world and a forearm hinged to it at the elbow, with
one line muscle running from a point on the upper arm to a point on the
forearm. Both points sit above the hinge axis, so the muscle has a lever arm.
There is no actuator and no gravity: the muscle is the only thing that can
move the arm. Joint limits give the elbow a range of motion, as a real one
has.

Activation does not command a position. It sets how hard the muscle pulls,
and the arm moves until something balances that pull. Here nothing opposes
it, so the muscle curls the arm up to the flexion limit and holds it there,
and the reported tension is how hard it is pulling.

Headless mode checks this: the passive arm stays straight, activation curls
it to the flexion limit, and doing so shortens the muscle and raises its
tension. Polyscope mode draws the muscle over the two bodies, with a live
activation slider.

Usage:
    python examples/affine/line_muscle.py                # polyscope
    python examples/affine/line_muscle.py --no-viewer    # headless test
    python examples/affine/line_muscle.py --f-max 600
"""

from __future__ import annotations

import argparse

import numpy as np

import trusty

# Geometry (meters). The upper arm runs along -x from the elbow at the origin;
# the forearm runs along +x. Both attachment points sit above the hinge axis,
# which is what gives the muscle its moment arm.
ARM_HALF = (0.5, 0.12, 0.12)
UPPER_CENTER = (-0.5, 0.0, 0.0)
FORE_CENTER = (0.5, 0.0, 0.0)
ORIGIN_POINT = (-0.40, 0.0, 0.15)      # on the upper arm
INSERTION_POINT = (0.25, 0.0, 0.10)    # on the forearm

# Elbow range of motion (rad), straight arm at 0. The muscle pulls its two
# attachment points together, which curls the forearm up, so the range is
# one-sided: a hair of slack below straight, and a flexion stop above.
ELBOW_LO, ELBOW_HI = -0.1, 1.4


def make_box(center, half):
    h = np.asarray(half, dtype=np.float64)
    V = np.array(
        [[-h[0], -h[1], -h[2]], [h[0], -h[1], -h[2]],
         [h[0], h[1], -h[2]],   [-h[0], h[1], -h[2]],
         [-h[0], -h[1], h[2]],  [h[0], -h[1], h[2]],
         [h[0], h[1], h[2]],    [-h[0], h[1], h[2]]],
        dtype=np.float64,
    ) + np.asarray(center, dtype=np.float64)
    F = np.array(
        [[4, 5, 6], [4, 6, 7], [0, 3, 2], [0, 2, 1],
         [1, 2, 6], [1, 6, 5], [0, 4, 7], [0, 7, 3],
         [3, 7, 6], [3, 6, 2], [0, 1, 5], [0, 5, 4]],
        dtype=np.int32,
    )
    return V, F


def build(f_max, backend, model):
    world = trusty.World(backend=backend,
                         timestep=0.01,
                         gravity=(0.0, 0.0, 0.0),  # muscle-only, so nothing else can move the arm
                         newton=trusty.NewtonConfig(max_iters=60, tolerance=1e-4))

    Vu, Fu = make_box(UPPER_CENTER, ARM_HALF)
    upper = trusty.affine.add_affine_body(world, Vu, Fu, density=50.0, stiffness=1e8)
    Vf, Ff = make_box(FORE_CENTER, ARM_HALF)
    fore = trusty.affine.add_affine_body(world, Vf, Ff, density=50.0, stiffness=1e8)

    # Weld the upper arm to the world, then hinge the forearm to it about y
    # through the origin.
    trusty.affine.add_fixed_joint(world, upper, UPPER_CENTER,
                                  axis_u=(0.1, 0.0, 0.0), axis_v=(0.0, 0.1, 0.0),
                                  stiffness=1e8)
    elbow = trusty.affine.add_revolute_joint(world, upper, (0.0, -0.12, 0.0),
                                             (0.0, 0.12, 0.0), body_j=fore,
                                             stiffness=1e8)
    # A real elbow has a range of motion; without stops the unopposed muscle
    # would curl the forearm straight over the top.
    trusty.affine.add_joint_limit(world, elbow, lo=ELBOW_LO, hi=ELBOW_HI,
                                  margin=0.05, stiffness=1e3)

    points = np.array([ORIGIN_POINT, INSERTION_POINT], dtype=np.float64)
    rest_length = float(np.linalg.norm(points[1] - points[0]))
    # The fiber starts at its optimal length (the rest path minus the tendon),
    # where it pulls hardest.
    l_tendon_slack = 0.30
    l_opt = rest_length - l_tendon_slack
    muscle = trusty.affine.add_line_muscle(
        world, [upper, fore], points, f_max=f_max, l_opt=l_opt,
        l_tendon_slack=l_tendon_slack, pennation=0.0, activation=0.0, model=model)

    print(f"Two-link arm, one line muscle: f_max={f_max:.0f} N, "
          f"rest path {rest_length:.3f} m (l_opt {l_opt:.3f}, "
          f"tendon {l_tendon_slack:.2f}). Elbow range "
          f"[{np.rad2deg(ELBOW_LO):.0f}, {np.rad2deg(ELBOW_HI):.0f}] deg.")
    return world, muscle, upper, fore


def elbow_angle(world, fore):
    """Forearm pitch about the hinge (rad), from its surface centroid.

    The forearm's centroid starts on +x level with the hinge, so the angle it
    subtends at the origin is the elbow flexion directly: 0 straight, rising as
    the muscle curls it up."""
    V = np.asarray(trusty.affine.surface(world, fore)[0])
    c = V.mean(axis=0)
    return float(np.arctan2(c[2], c[0]))


def settle(world, muscle, fore, activation, steps):
    """Hold `activation` for `steps` steps and return the settled state."""
    trusty.affine.set_muscle_activation(world, muscle, activation)
    for _ in range(steps):
        world.step()
    return (elbow_angle(world, fore),
            trusty.affine.muscle_length(world, muscle),   # m
            trusty.affine.muscle_force(world, muscle))    # N


def run_headless(world, muscle, fore, steps):
    print(f"Settling {steps} steps per activation level...\n")
    print("  activation   elbow (deg)   length (m)   tension (N)")
    levels = [0.0, 0.25, 0.5, 0.75, 1.0]
    results = []
    for a in levels:
        ang, length, force = settle(world, muscle, fore, a, steps)
        results.append((a, ang, length, force))
        print(f"  {a:9.2f}   {np.rad2deg(ang):10.2f}   {length:9.4f}   {force:9.2f}")

    angles = [np.rad2deg(r[1]) for r in results]
    lengths = [r[2] for r in results]
    forces = [r[3] for r in results]

    # Passive, the arm stays straight; activation curls it to the flexion stop,
    # which shortens the path and raises the tension it pulls with.
    straight = abs(angles[0]) < 3.0
    curls_up = all(b > a - 0.5 for a, b in zip(angles, angles[1:]))
    flexed = angles[-1] > 0.5 * np.rad2deg(ELBOW_HI)
    shortened = lengths[-1] < lengths[0] - 1e-3
    # Each tension is read at that level's own settled pose, so the column is
    # not a force-length curve and need not rise: what it shows is that an
    # activated muscle pulls and a passive one does not. The polynomial law in
    # particular has compact support, so a fully curled fiber can sit at exactly
    # zero active force where the exponential Hill law still has a tail.
    pulled = forces[0] == 0.0 and max(forces[1:]) > 0.0
    path = np.asarray(trusty.affine.muscle_path(world, muscle))
    path_ok = path.shape == (2, 3) and np.isfinite(path).all()

    print(f"\npassive elbow angle   = {angles[0]:+.2f} deg  (straight)")
    print(f"activated elbow angle = {angles[-1]:+.2f} deg  "
          f"(stop at {np.rad2deg(ELBOW_HI):.0f} deg)")
    print(f"path length {lengths[0]:.4f} -> {lengths[-1]:.4f} m,  tension "
          f"{forces[0]:.2f} -> {forces[-1]:.2f} N")
    print(f"muscle count = {trusty.affine.num_muscles(world)}, path shape = {path.shape}")

    ok = straight and curls_up and flexed and shortened and pulled and path_ok
    print("\nPASS: the passive arm stayed straight, and activation curled it to "
          "the stop while shortening the muscle and raising its tension." if ok
          else "\nFAIL: muscle did not behave as expected.")
    return ok


def run_polyscope(world, muscle, upper, fore, steps):
    try:
        import polyscope as ps
        import polyscope.imgui as psim
    except ImportError:
        print("polyscope not installed; falling back to headless.")
        run_headless(world, muscle, fore, steps)
        return

    ps.init()
    ps.set_up_dir("z_up")
    ps.set_ground_plane_mode("shadow_only")

    meshes = []
    for name, body in (("upper arm", upper), ("forearm", fore)):
        V, F = trusty.affine.surface(world, body)
        m = ps.register_surface_mesh(name, np.asarray(V), np.asarray(F))
        m.set_color((0.75, 0.72, 0.68))
        meshes.append((body, m))

    path = np.asarray(trusty.affine.muscle_path(world, muscle))
    edges = np.array([[i, i + 1] for i in range(len(path) - 1)], dtype=np.int64)
    muscle_curve = ps.register_curve_network("muscle", path, edges)
    muscle_curve.set_radius(0.02, relative=False)
    muscle_curve.set_color((0.80, 0.25, 0.25))

    state = {"activation": 0.0, "playing": True}

    def callback():
        changed, state["activation"] = psim.SliderFloat(
            "activation", state["activation"], v_min=0.0, v_max=1.0)
        _, state["playing"] = psim.Checkbox("play", state["playing"])
        if state["playing"] or changed:
            trusty.affine.set_muscle_activation(world, muscle, state["activation"])
            world.step()
            for body, m in meshes:
                m.update_vertex_positions(
                    np.asarray(trusty.affine.surface(world, body)[0]))
            muscle_curve.update_node_positions(
                np.asarray(trusty.affine.muscle_path(world, muscle)))
        psim.Text(f"elbow    {np.rad2deg(elbow_angle(world, fore)):+7.2f} deg")
        psim.Text(f"length   {trusty.affine.muscle_length(world, muscle):7.4f} m")
        psim.Text(f"tension  {trusty.affine.muscle_force(world, muscle):7.2f} N")

    ps.set_user_callback(callback)
    ps.show()


def main():
    trusty.check_capabilities("affine")
    p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    p.add_argument("--f-max", type=float, default=300.0,
                   help="peak isometric muscle force (N)")
    p.add_argument("--steps", type=int, default=120,
                   help="settle steps per activation level (headless)")
    p.add_argument("--backend", choices=["auto", "cpu", "accelerate", "cuda"], default="auto")
    p.add_argument("--muscle-model", choices=["hill", "polynomial"], default="hill",
                   help="force-length law; polynomial is exp-free and better "
                        "conditioned under overstretch")
    p.add_argument("--no-viewer", action="store_true")
    args = p.parse_args()

    model = {"hill": trusty.affine.MuscleModel.Hill,
             "polynomial": trusty.affine.MuscleModel.Polynomial}[args.muscle_model]

    world, muscle, upper, fore = build(args.f_max, args.backend, model)
    if args.no_viewer:
        raise SystemExit(0 if run_headless(world, muscle, fore, args.steps) else 1)
    run_polyscope(world, muscle, upper, fore, args.steps)


if __name__ == "__main__":
    main()

Robots from URDF

There is no URDF loader in trusty.affine, but the example below builds one from the pieces above in plain Python. Each link becomes an affine body from its <visual> geometry, and the root link is welded to the world:

body_id, render = {}, []
lo_xyz = np.full(3, np.inf)
hi_xyz = np.full(3, -np.inf)
for name, (V, F) in links.items():
    Vw = apply_T(link_T[name], V)
    lo_xyz = np.minimum(lo_xyz, Vw.min(0))
    hi_xyz = np.maximum(hi_xyz, Vw.max(0))
    body_id[name] = trusty.affine.add_affine_body(
        world, Vw, F, density=ROBOT_DENSITY, stiffness=BODY_STIFFNESS,
        friction_mu=friction_mu)
    render.append((name, colors.get(name, [0.7, 0.7, 0.75]), body_id[name]))

# Weld the root link to the world (no body_j), so the base stays put.
Rr, tr = link_T[root_name]
trusty.affine.add_fixed_joint(
    world, body_id[root_name], tuple(tr), axis_u=tuple(0.1 * Rr[:, 0]),
    axis_v=tuple(0.1 * Rr[:, 1]), stiffness=JOINT_STIFFNESS)

Each revolute joint becomes a revolute joint, with the child link as body_i so that the angle matches the URDF's. Its <limit> becomes a joint limit, and the example adds an actuator that waves the joint back and forth:

actuators = []
for k, j in enumerate(joints):
    Rc, tc = link_T[j["child"]]               # joint pivot = child frame origin
    if j["type"] == "fixed":
        trusty.affine.add_fixed_joint(
            world, body_id[j["parent"]], tuple(tc), axis_u=tuple(0.1 * Rc[:, 0]),
            axis_v=tuple(0.1 * Rc[:, 1]), body_j=body_id[j["child"]],
            stiffness=JOINT_STIFFNESS)
        continue
    if j["type"] not in ("revolute", "continuous"):
        print(f"  skipping joint '{j['name']}' (type {j['type']})")
        continue
    axis_w = Rc @ j["axis"]
    axis_w = axis_w / (np.linalg.norm(axis_w) + 1e-12)
    p0, p1 = tuple(tc), tuple(tc + 0.1 * axis_w)
    # The joint angle is body_i's rotation relative to body_j, so pass the
    # child first: then it is the URDF's angle, the child's rotation
    # relative to its parent about the axis.
    jid = trusty.affine.add_revolute_joint(
        world, body_id[j["child"]], p0, p1,
        body_j=body_id[j["parent"]], stiffness=JOINT_STIFFNESS)

    lo, hi = j["lower"], j["upper"]
    if lo is not None and hi is not None and hi > lo:
        # Joint limits must lie inside (-pi, pi).
        lo = max(lo, -3.1)
        hi = min(hi, 3.1)
        margin = min(0.1, 0.4 * (hi - lo))
        trusty.affine.add_joint_limit(world, jid, lo, hi, margin=margin,
                                      stiffness=JOINT_LIMIT_K)
        if actuate:
            amp = 0.5 * min(abs(lo), abs(hi))   # oscillate within the stops
            actuators.append((trusty.affine.add_actuator(world, jid,
                                                         stiffness=ACTUATOR_KP),
                              amp, 0.5 * k))

The sample robot, examples/data/affine_sample.urdf, waves its arms while a pit of balls is dropped onto it:

A two-armed robot loaded from a URDF, among dropped balls

Try your own robot with python examples/affine/articulation.py --urdf my_robot.urdf. Boxes, spheres, cylinders and capsules are supported, and fixed URDF joints become fixed joints. <inertial> and <collision> are ignored, and prismatic joints between links are skipped, because a prismatic joint always joins a body to the world. With the full pit of 72 balls the example is slow, about half a second a step; --grid 3,4,2 drops fewer.

Full script: python examples/affine/articulation.py
"""Load a robot from a URDF file as a set of jointed affine bodies, and drop
a pit of balls onto it.

The URDF is read with the standard library's XML parser. Each link becomes
one near-rigid affine body, each revolute joint a revolute joint with the
URDF's `<limit lower upper>` as a joint limit, and each limited joint gets an
actuator that waves it gently back and forth.

  * Only the `<visual>` geometry is used, and it is what gets simulated:
    boxes, spheres, cylinders and capsules are meshed here, and a link's
    visuals are merged into one surface (they may overlap; a body never
    collides with itself). `<collision>` geometry is ignored.
  * `<inertial>` is ignored too: each body's mass and inertia come from its
    surface and a uniform density.
  * The root link is held in place by three spherical joints to the world,
    so the arms move against a fixed base while gravity pulls on them.
  * The robot sits in an open box (a floor and four walls) and a grid of free
    affine balls is dropped onto it; they bounce off the arms and pile up.
    `--no-contact` leaves out the box and the balls, and `--grid nx,ny,nz`
    sizes the drop.

Usage:
    python examples/affine/articulation.py                       # polyscope
    python examples/affine/articulation.py --no-viewer --grid 5,6,3
    python examples/affine/articulation.py --urdf path/to.urdf
"""

from __future__ import annotations

import argparse
import math
import xml.etree.ElementTree as ET
from pathlib import Path

import numpy as np

import trusty

DEFAULT_URDF = Path(__file__).resolve().parent.parent / "data" / "affine_sample.urdf"

# -- tunable constants, with their SI units ----------------------------------
#
#   ACTUATOR_KP   N*m/rad : actuator stiffness: the restoring torque per radian
#                           of error. A few x10^3 holds the arms to within a
#                           degree under their own weight and the balls'; the
#                           call's default, 1e6, is close to a weld.
#   JOINT_LIMIT_K N*m     : joint-limit stiffness (for a prismatic slide, N/m).
#   BODY/BALL_STIFFNESS Pa: how strongly a body resists deforming; large values
#                           are effectively rigid.
#   JOINT_STIFFNESS N/m   : joint stiffness: a spring on the gap between the two
#                           points a joint holds together.
#   *_DENSITY     kg/m^3
ACTUATOR_KP     = 5.0e3
JOINT_LIMIT_K   = 5.0e3
BODY_STIFFNESS  = 1.0e8
JOINT_STIFFNESS = 1.0e7
ROBOT_DENSITY   = 500.0
BALL_DENSITY    = 150.0
BALL_STIFFNESS  = 1.0e7


# -- small transform helpers (R is 3x3, t is 3) ------------------------------

def rpy_to_R(rpy):
    """URDF fixed-axis roll-pitch-yaw: R = Rz(yaw) Ry(pitch) Rx(roll)."""
    cr, sr = math.cos(rpy[0]), math.sin(rpy[0])
    cp, sp = math.cos(rpy[1]), math.sin(rpy[1])
    cy, sy = math.cos(rpy[2]), math.sin(rpy[2])
    Rx = np.array([[1, 0, 0], [0, cr, -sr], [0, sr, cr]])
    Ry = np.array([[cp, 0, sp], [0, 1, 0], [-sp, 0, cp]])
    Rz = np.array([[cy, -sy, 0], [sy, cy, 0], [0, 0, 1]])
    return Rz @ Ry @ Rx


def compose(A, B):
    """(R,t) of A applied after B: A o B."""
    Ra, ta = A
    Rb, tb = B
    return Ra @ Rb, Ra @ tb + ta


def apply_T(T, V):
    R, t = T
    return V @ R.T + t


# -- inline primitive meshes (closed surfaces; winding fixed to outward) ------

def _ensure_outward(V, F):
    v0, v1, v2 = V[F[:, 0]], V[F[:, 1]], V[F[:, 2]]
    vol = np.einsum("ij,ij->i", v0, np.cross(v1, v2)).sum() / 6.0
    if vol < 0:
        F = F[:, [0, 2, 1]].copy()
    return V, F


def box_mesh(size):
    h = np.asarray(size, dtype=np.float64) * 0.5
    V = np.array([[-h[0], -h[1], -h[2]], [h[0], -h[1], -h[2]],
                  [h[0], h[1], -h[2]],   [-h[0], h[1], -h[2]],
                  [-h[0], -h[1], h[2]],  [h[0], -h[1], h[2]],
                  [h[0], h[1], h[2]],    [-h[0], h[1], h[2]]])
    F = np.array([[4, 5, 6], [4, 6, 7], [0, 3, 2], [0, 2, 1],
                  [1, 2, 6], [1, 6, 5], [0, 4, 7], [0, 7, 3],
                  [3, 7, 6], [3, 6, 2], [0, 1, 5], [0, 5, 4]], dtype=np.int64)
    return _ensure_outward(V, F)


def sphere_mesh(r, nlat=8, nlon=12):
    verts = [[0, 0, r]]
    for i in range(1, nlat):
        th = math.pi * i / nlat
        for j in range(nlon):
            ph = 2 * math.pi * j / nlon
            verts.append([r * math.sin(th) * math.cos(ph),
                          r * math.sin(th) * math.sin(ph),
                          r * math.cos(th)])
    verts.append([0, 0, -r])
    V = np.array(verts)
    south = len(verts) - 1
    faces = []
    for j in range(nlon):                       # north cap
        faces.append([0, 1 + j, 1 + (j + 1) % nlon])
    for i in range(nlat - 2):                    # middle bands
        a, b = 1 + i * nlon, 1 + (i + 1) * nlon
        for j in range(nlon):
            jn = (j + 1) % nlon
            faces.append([a + j, b + j, b + jn])
            faces.append([a + j, b + jn, a + jn])
    base = 1 + (nlat - 2) * nlon
    for j in range(nlon):                        # south cap
        faces.append([south, base + (j + 1) % nlon, base + j])
    return _ensure_outward(V, np.array(faces, dtype=np.int64))


def cylinder_mesh(r, length, nseg=16):
    hz = length * 0.5
    verts = [[0, 0, -hz], [0, 0, hz]]            # bottom/top centers
    for z in (-hz, hz):
        for j in range(nseg):
            ph = 2 * math.pi * j / nseg
            verts.append([r * math.cos(ph), r * math.sin(ph), z])
    V = np.array(verts)
    b, t = 2, 2 + nseg
    faces = []
    for j in range(nseg):
        jn = (j + 1) % nseg
        faces.append([b + j, b + jn, t + jn])    # side
        faces.append([b + j, t + jn, t + j])
        faces.append([0, b + jn, b + j])         # bottom cap
        faces.append([1, t + j, t + jn])         # top cap
    return _ensure_outward(V, np.array(faces, dtype=np.int64))


def capsule_mesh(r, length, nseg=16, nring=4):
    # Cylinder body plus two hemispherical caps; approximated as a cylinder of
    # the same total length when a fuller tessellation isn't warranted.
    return cylinder_mesh(r, length + 2 * r, nseg)


def geometry_mesh(geom):
    box = geom.find("box")
    if box is not None:
        return box_mesh([float(x) for x in box.get("size").split()])
    sph = geom.find("sphere")
    if sph is not None:
        return sphere_mesh(float(sph.get("radius")))
    cyl = geom.find("cylinder")
    if cyl is not None:
        return cylinder_mesh(float(cyl.get("radius")), float(cyl.get("length")))
    cap = geom.find("capsule")
    if cap is not None:
        return capsule_mesh(float(cap.get("radius")), float(cap.get("length")))
    return None


# -- URDF parsing ------------------------------------------------------------

def _origin(elem):
    o = elem.find("origin")
    xyz_s = (o.get("xyz") if o is not None else None) or "0 0 0"
    rpy_s = (o.get("rpy") if o is not None else None) or "0 0 0"
    xyz = [float(x) for x in xyz_s.split()]
    rpy = [float(x) for x in rpy_s.split()]
    return rpy_to_R(rpy), np.array(xyz)


def parse_urdf(path):
    root = ET.parse(path).getroot()

    links = {}     # name -> (V, F): merged visual soup in the LINK frame
    colors = {}
    for link in root.findall("link"):
        name = link.get("name")
        # One affine body per link, merging ALL its <visual> geometry into a
        # single surface soup. The parts may overlap (e.g. a box + a sphere):
        # a body never collides with itself, and the merged mass is the sum.
        Vs, Fs, nv = [], [], 0
        for vis in link.findall("visual"):
            geom = vis.find("geometry")
            mesh = geometry_mesh(geom) if geom is not None else None
            if mesh is None:
                continue
            V, F = mesh
            Vs.append(apply_T(_origin(vis), V))
            Fs.append(F + nv)
            nv += len(V)
            mat = vis.find("material/color")
            if mat is not None and name not in colors:
                colors[name] = [float(c) for c in mat.get("rgba").split()][:3]
        if Vs:
            links[name] = (np.vstack(Vs), np.vstack(Fs).astype(np.int32))
        else:
            links[name] = (*box_mesh([0.05, 0.05, 0.05]),)  # visual-less placeholder
    joints = []
    for j in root.findall("joint"):
        lim = j.find("limit")
        joints.append({
            "name":   j.get("name"),
            "type":   j.get("type"),
            "parent": j.find("parent").get("link"),
            "child":  j.find("child").get("link"),
            "origin": _origin(j),
            "axis":   np.array([float(x) for x in
                                (j.find("axis").get("xyz")
                                 if j.find("axis") is not None else "0 0 1").split()]),
            "lower":  float(lim.get("lower")) if lim is not None else None,
            "upper":  float(lim.get("upper")) if lim is not None else None,
        })
    return links, joints, colors


def world_transforms(links, joints, base_T):
    """Accumulate each link's world (R,t) by walking the joint tree from the
    root (the one link that is never a child)."""
    children = {}
    is_child = set()
    for j in joints:
        children.setdefault(j["parent"], []).append(j)
        is_child.add(j["child"])
    root_name = next(n for n in links if n not in is_child)

    link_T = {root_name: base_T}
    stack = [root_name]
    while stack:
        p = stack.pop()
        for j in children.get(p, []):
            link_T[j["child"]] = compose(link_T[p], j["origin"])
            stack.append(j["child"])
    return root_name, link_T


# -- build the affine world --------------------------------------------------

def build(urdf_path, gravity, backend, actuate, contact, grid, broadphase):
    trusty.check_capabilities("affine", "contact")
    links, joints, colors = parse_urdf(urdf_path)
    base_T = (np.eye(3), np.array([0.0, 0.0, 1.3]))   # lift the robot up
    root_name, link_T = world_transforms(links, joints, base_T)

    contact_cfg = trusty.contact.Config()
    friction_mu = 0.0
    if contact:
        friction_mu = 0.3                        # a little friction so the balls pile
        contact_cfg = trusty.contact.Config(
            dhat=5e-3,
            # All bodies here are rigid affine (self-pairs excluded), so the
            # relative-frame broadphase is the fastest correct choice (default).
            broadphase={
                "affine":    trusty.contact.BroadphaseKind.AffineRelative,
                "two-level": trusty.contact.BroadphaseKind.TwoLevel,
                "flat":      trusty.contact.BroadphaseKind.FlatQbvh,
            }[broadphase],
        )

    world = trusty.World(backend=backend,
                         timestep=0.01,
                         gravity=(0.0, 0.0, -9.81) if gravity else (0.0, 0.0, 0.0),
                         newton=trusty.NewtonConfig(max_iters=200),
                         time_stepping="bdf2")
    if contact:
        trusty.contact.enable(world, contact_cfg)

    body_id, render = {}, []
    lo_xyz = np.full(3, np.inf)
    hi_xyz = np.full(3, -np.inf)
    for name, (V, F) in links.items():
        Vw = apply_T(link_T[name], V)
        lo_xyz = np.minimum(lo_xyz, Vw.min(0))
        hi_xyz = np.maximum(hi_xyz, Vw.max(0))
        body_id[name] = trusty.affine.add_affine_body(
            world, Vw, F, density=ROBOT_DENSITY, stiffness=BODY_STIFFNESS,
            friction_mu=friction_mu)
        render.append((name, colors.get(name, [0.7, 0.7, 0.75]), body_id[name]))

    # Weld the root link to the world (no body_j), so the base stays put.
    Rr, tr = link_T[root_name]
    trusty.affine.add_fixed_joint(
        world, body_id[root_name], tuple(tr), axis_u=tuple(0.1 * Rr[:, 0]),
        axis_v=tuple(0.1 * Rr[:, 1]), stiffness=JOINT_STIFFNESS)

    actuators = []
    for k, j in enumerate(joints):
        Rc, tc = link_T[j["child"]]               # joint pivot = child frame origin
        if j["type"] == "fixed":
            trusty.affine.add_fixed_joint(
                world, body_id[j["parent"]], tuple(tc), axis_u=tuple(0.1 * Rc[:, 0]),
                axis_v=tuple(0.1 * Rc[:, 1]), body_j=body_id[j["child"]],
                stiffness=JOINT_STIFFNESS)
            continue
        if j["type"] not in ("revolute", "continuous"):
            print(f"  skipping joint '{j['name']}' (type {j['type']})")
            continue
        axis_w = Rc @ j["axis"]
        axis_w = axis_w / (np.linalg.norm(axis_w) + 1e-12)
        p0, p1 = tuple(tc), tuple(tc + 0.1 * axis_w)
        # The joint angle is body_i's rotation relative to body_j, so pass the
        # child first: then it is the URDF's angle, the child's rotation
        # relative to its parent about the axis.
        jid = trusty.affine.add_revolute_joint(
            world, body_id[j["child"]], p0, p1,
            body_j=body_id[j["parent"]], stiffness=JOINT_STIFFNESS)

        lo, hi = j["lower"], j["upper"]
        if lo is not None and hi is not None and hi > lo:
            # Joint limits must lie inside (-pi, pi).
            lo = max(lo, -3.1)
            hi = min(hi, 3.1)
            margin = min(0.1, 0.4 * (hi - lo))
            trusty.affine.add_joint_limit(world, jid, lo, hi, margin=margin,
                                          stiffness=JOINT_LIMIT_K)
            if actuate:
                amp = 0.5 * min(abs(lo), abs(hi))   # oscillate within the stops
                actuators.append((trusty.affine.add_actuator(world, jid,
                                                             stiffness=ACTUATOR_KP),
                                  amp, 0.5 * k))

    # Ball pit: drop a grid of free affine spheres onto the robot, inside an
    # open-top box of static contact planes (floor + 4 inward-facing walls
    # sized to the robot's footprint plus a margin).
    n_balls = 0
    if contact:
        m = 0.18                                   # wall margin around the robot
        box_lo = lo_xyz - np.array([m, m, 0.0])
        box_hi = hi_xyz + np.array([m, m, 0.0])
        floor_z = float(lo_xyz[2] - 0.05)
        planes = [((0, 0, floor_z),        (0, 0, 1)),     # floor
                  ((box_hi[0], 0, 0),      (-1, 0, 0)),    # +x wall
                  ((box_lo[0], 0, 0),      (1, 0, 0)),     # -x wall
                  ((0, box_hi[1], 0),      (0, -1, 0)),    # +y wall
                  ((0, box_lo[1], 0),      (0, 1, 0))]     # -y wall
        for o, n in planes:
            trusty.contact.add_plane(
                world, np.array(o, float), np.array(n, float), friction_mu=friction_mu)

        r = 0.07
        nx, ny, nz = grid
        xs = np.linspace(box_lo[0] + r + 0.02, box_hi[0] - r - 0.02, nx)
        ys = np.linspace(box_lo[1] + r + 0.02, box_hi[1] - r - 0.02, ny)
        z0 = float(hi_xyz[2]) + 0.15               # release just above the robot
        palette = [[0.90, 0.30, 0.25], [0.25, 0.55, 0.85], [0.95, 0.75, 0.20],
                   [0.40, 0.75, 0.40], [0.70, 0.45, 0.80]]
        for k in range(nz):
            for ix, x in enumerate(xs):
                for iy, y in enumerate(ys):
                    c = np.array([x, y, z0 + k * 2.3 * r])
                    V, F = sphere_mesh(r, nlat=6, nlon=10)
                    ball = trusty.affine.add_affine_body(
                        world, V + c, F.astype(np.int32),
                        density=BALL_DENSITY, stiffness=BALL_STIFFNESS,
                        friction_mu=friction_mu)
                    render.append((f"ball_{n_balls}",
                                   palette[(ix + iy + k) % len(palette)], ball))
                    n_balls += 1

    print(f"Built articulation: {len(links)} links, {len(joints)} joints, "
          f"{len(actuators)} actuators, {n_balls} balls "
          f"(gravity {'on' if gravity else 'off'}, contact {'on' if contact else 'off'}).")
    return world, actuators, render


def drive(world, actuators, t):
    # Ease the amplitude in from 0 over the first 0.5 s, so the targets start
    # at the rest angle (0) instead of jumping to amp*sin(phase) and jerking
    # the arms.
    s = min(1.0, t / 0.5)
    ramp = s * s * (3.0 - 2.0 * s)
    for act, amp, phase in actuators:
        trusty.affine.set_actuator_target(world, act, ramp * amp * math.sin(2.5 * t + phase))


# -- run ---------------------------------------------------------------------

def run_headless(world, actuators, render, steps):
    balls = [body for name, _color, body in render if name.startswith("ball")]

    def ball_heights():
        return [float(np.asarray(trusty.affine.surface(world, b)[0]).mean(0)[2]) for b in balls]

    print(f"Running {steps} steps headless...")
    for i in range(steps):
        drive(world, actuators, i * 0.01)
        world.step()
        if (i + 1) % 50 == 0:
            r = world.last_report()
            h = ball_heights()
            pile = f"balls z mean={np.mean(h):+.2f} min={np.min(h):+.2f}" if h else ""
            print(f"  step {i + 1:4d}  iters={r.iterations} "
                  f"res={r.final_residual:.2e} "
                  f"{'ok' if r.converged else 'DIVERGED'}  {pile}")
    print("Done.")


def run_polyscope(world, actuators, render, steps):
    try:
        import polyscope as ps
        import polyscope.imgui as psim
    except ImportError:
        print("polyscope not installed; falling back to headless.")
        run_headless(world, actuators, render, steps)
        return

    ps.init()
    ps.set_up_dir("z_up")
    ps.set_ground_plane_mode("shadow_only")

    meshes = []
    for name, color, body in render:
        V, F = trusty.affine.surface(world, body)
        m = ps.register_surface_mesh(name, np.asarray(V), np.asarray(F))
        m.set_color(tuple(color))
        meshes.append((body, m))

    state = {"i": 0, "playing": False}

    def advance():
        if state["i"] < steps:
            drive(world, actuators, state["i"] * 0.01)
            world.step()
            state["i"] += 1
            for body, m in meshes:
                m.update_vertex_positions(
                    np.asarray(trusty.affine.surface(world, body)[0]))

    def callback():
        _, state["playing"] = psim.Checkbox("play", state["playing"])
        psim.SameLine()
        if psim.Button("step"):
            advance()
        elif state["playing"]:
            advance()
        psim.Text(f"step {state['i']} / {steps}")
        r = world.last_report()
        psim.Text(f"solve: iters={r.iterations} res={r.final_residual:.2e} "
                  f"{'ok' if r.converged else 'DIVERGED'}")

    ps.set_user_callback(callback)
    ps.show()


def main():
    p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    p.add_argument("--urdf", type=Path, default=DEFAULT_URDF)
    p.add_argument("--steps", type=int, default=800)
    p.add_argument("--backend", choices=["auto", "cpu", "accelerate", "cuda"], default="auto")
    p.add_argument("--grid", default="4,6,3",
                   help="ball-pit grid 'nx,ny,nz' dropped onto the robot")
    p.add_argument("--no-gravity", action="store_true",
                   help="turn gravity off (mostly useful with --no-contact)")
    p.add_argument("--no-actuators", action="store_true")
    p.add_argument("--no-contact", action="store_true",
                   help="turn contact off (no box, no balls)")
    p.add_argument("--no-viewer", action="store_true")
    p.add_argument("--broadphase", choices=["affine", "two-level", "flat"],
                   default="affine",
                   help="contact broadphase: affine (default, fastest when "
                        "every body is an affine body), two-level, or flat")
    args = p.parse_args()

    grid = tuple(int(v) for v in args.grid.split(","))
    world, actuators, render = build(args.urdf, not args.no_gravity, args.backend,
                                   not args.no_actuators, not args.no_contact,
                                   grid, args.broadphase)
    if args.no_viewer:
        run_headless(world, actuators, render, args.steps)
    else:
        run_polyscope(world, actuators, render, args.steps)


if __name__ == "__main__":
    main()

Reference

Every function and argument: trusty.affine reference.