Skip to content

Contact

trusty.contact stops bodies from passing through each other and through static floors and walls, with optional friction. Every body type takes part, and all contacts are solved together with the bodies themselves in each step.

Capability: contact.

Quick example

A soft beam dropped at an angle onto a floor. It lands on one end, tips over and comes to rest:

world = trusty.World(backend=backend,
                     timestep=1.0 / 60.0,
                     # an impact can take more than the default 20 iterations
                     newton=trusty.NewtonConfig(max_iters=50))
# Turn contact on; dhat is the contact distance, in m.
trusty.contact.enable(world, trusty.contact.Config(dhat=1e-3))

add = trusty.fem.add_hex_solid if element == "hex" else trusty.fem.add_tet_solid
beam = add(world, mesh, material, density=1000.0, order=order)
trusty.add_floor_plane(world, z=0.0)

Contact is off by default. Turn it on with trusty.contact.enable once the World is made and before its first step, then add bodies and static geometry as usual.

Call or setting Meaning
trusty.contact.enable(world, config) turn contact on, with config's settings; config may be left out for the defaults
trusty.contact.Config(dhat=...) the contact distance, in m; see Contact distance and stiffness
trusty.add_floor_plane(world, z) a horizontal floor at height z; see Floors, planes and walls

The first touch is the hardest step to solve. The example raises the Newton iteration cap from its default of 20, so that every step converges; check world.last_report().converged if you drop things from higher or faster.

Full script: python examples/contact/beam_drop.py
"""A soft beam dropped at an angle onto a floor.

It lands on one end, tips over, and comes to rest lying flat. Contact keeps
it on the floor.

Usage:
    python examples/contact/beam_drop.py                  # polyscope
    python examples/contact/beam_drop.py --no-viewer      # headless
    python examples/contact/beam_drop.py --steps 240
    python examples/contact/beam_drop.py --element tet    # tet mesh
    python examples/contact/beam_drop.py --order quadratic
    python examples/contact/beam_drop.py --mesh foo.vtu   # your own hex mesh
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

import numpy as np

import trusty

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from utils import init_polyscope, load_hex_mesh, load_tet_mesh  # noqa: E402

ORDER = trusty.fem.ElementOrder
ORDERS = {
    "linear":      ORDER.Linear,
    "quadratic":   ORDER.Quadratic,
    "serendipity": ORDER.QuadraticSerendipity,   # hex only
}

BEAM_SIZE = (0.6, 0.2, 0.2)
BEAM_RES = (12, 4, 4)
TILT_DEG = 30.0      # about the y axis, so one end hits the floor first
DROP_HEIGHT = 0.3    # of the beam's lowest corner above the floor


def beam_vertices(V):
    """Tilt the beam about y, then lift its lowest corner to DROP_HEIGHT."""
    a = np.radians(TILT_DEG)
    R = np.array([[np.cos(a), 0.0, -np.sin(a)],
                  [0.0, 1.0, 0.0],
                  [np.sin(a), 0.0, np.cos(a)]])
    V = (V - V.mean(axis=0)) @ R.T
    V[:, 2] += DROP_HEIGHT - V[:, 2].min()
    return V


def make_mesh(element: str, mesh_path: Path | None):
    if element == "hex":
        m = (load_hex_mesh(mesh_path, target_extent=max(BEAM_SIZE)) if mesh_path
             else trusty.make_beam_hex_mesh(size=BEAM_SIZE, res=BEAM_RES))
        return trusty.make_hex_mesh(beam_vertices(np.asarray(m.vertices)),
                                    np.asarray(m.hexes))
    m = (load_tet_mesh(mesh_path, target_extent=max(BEAM_SIZE)) if mesh_path
         else trusty.make_beam_tet_mesh(size=BEAM_SIZE, res=BEAM_RES))
    return trusty.make_tet_mesh(beam_vertices(np.asarray(m.vertices)),
                                np.asarray(m.tets))


def build_world(backend: str = "auto", element: str = "hex",
                order=ORDER.Linear, mesh_path: Path | None = None):
    trusty.check_capabilities("contact")
    mesh = make_mesh(element, mesh_path)
    material = trusty.StableNeoHookean(youngs_modulus=1e5, poisson_ratio=0.3)

    world = trusty.World(backend=backend,
                         timestep=1.0 / 60.0,
                         # an impact can take more than the default 20 iterations
                         newton=trusty.NewtonConfig(max_iters=50))
    # Turn contact on; dhat is the contact distance, in m.
    trusty.contact.enable(world, trusty.contact.Config(dhat=1e-3))

    add = trusty.fem.add_hex_solid if element == "hex" else trusty.fem.add_tet_solid
    beam = add(world, mesh, material, density=1000.0, order=order)
    trusty.add_floor_plane(world, z=0.0)
    return world, beam


def surface(world, beam):
    return (np.asarray(trusty.fem.read_positions(world, beam)),
            np.asarray(trusty.fem.read_surface_triangles(world, beam)))


def run_headless(world, beam, steps: int):
    print(f"Beam drop: {steps} steps")
    lowest, unconverged = np.inf, 0
    for i in range(steps):
        world.step()
        r = world.last_report()
        unconverged += not r.converged
        z = float(np.asarray(trusty.fem.read_positions(world, beam))[:, 2].min())
        lowest = min(lowest, z)
        if (i + 1) % 20 == 0:
            print(f"  step {i + 1:4d}  lowest point z={z:+.5f}  "
                  f"iters={r.iterations}  converged={r.converged}")
    x = np.asarray(trusty.fem.read_positions(world, beam))
    print(f"Done. lowest point over the run {lowest:+.5f} m, "
          f"final height of the top {x[:, 2].max():.3f} m, "
          f"{unconverged} unconverged steps.")


def run_polyscope(world, beam, steps: int):
    ps = init_polyscope(headless=False)
    floor = np.array([[-1, -1, 0], [1, -1, 0], [1, 1, 0], [-1, 1, 0]], float)
    ps.register_surface_mesh("floor", floor, np.array([[0, 1, 2], [0, 2, 3]]),
                             color=(0.6, 0.6, 0.6))
    ps_mesh = ps.register_surface_mesh("beam", *surface(world, beam))
    ps_mesh.set_edge_width(1.0)
    state = {"i": 0, "playing": False}   # opens paused

    def callback():
        import polyscope.imgui as psim
        _, 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
            ps_mesh.update_vertex_positions(surface(world, beam)[0])
        psim.Text(f"step {state['i']} / {steps}")
        r = world.last_report()
        psim.Text(f"iters={r.iterations}  residual={r.final_residual:.2e}  "
                  f"{'converged' if r.converged else 'NOT converged'}")

    ps.set_user_callback(callback)
    ps.show()


def main():
    p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    p.add_argument("--backend", choices=["auto", "cpu", "accelerate", "cuda"], default="auto")
    p.add_argument("--element", choices=["hex", "tet"], default="hex")
    p.add_argument("--order", choices=list(ORDERS), default="linear",
                   help="element order; serendipity is for hexes only")
    p.add_argument("--steps", type=int, default=120)
    p.add_argument("--mesh", type=Path, default=None,
                   help="a hex (or, with --element tet, tet) mesh file, read with meshio")
    p.add_argument("--no-viewer", action="store_true")
    args = p.parse_args()
    if args.element == "tet" and args.order == "serendipity":
        p.error("--order serendipity needs --element hex")

    world, beam = build_world(args.backend, args.element, ORDERS[args.order], args.mesh)
    if args.no_viewer:
        run_headless(world, beam, args.steps)
    else:
        run_polyscope(world, beam, args.steps)


if __name__ == "__main__":
    main()

Contact distance and stiffness

Contact starts to push when two surfaces come closer than the contact distance dhat, and pushes harder the closer they get, so they never touch. Bodies at rest therefore sit a little less than dhat apart. kappa sets how hard the push is:

trusty.contact.enable(world, trusty.contact.Config(
    dhat=dhat,      # contact distance (m): bodies rest about this far apart
    kappa=kappa))   # contact stiffness: how hard it pushes back

Three cubes resting on a floor, with dhat = 5 mm, 2 cm and 5 cm from left to right:

Three cubes resting at different heights above a floor

The gap under a 10 cm cube at rest, as a fraction of dhat:

dhat kappa = 1e3 1e4 1e5 (default)
5 mm 0.85 0.91 0.96
2 cm 0.93 0.96 0.98
5 cm 0.95 0.98 0.99
  • Keep dhat small next to your bodies (the default is 1 mm): the gap shows when it is not.
  • Raise kappa for heavy bodies. With too low a value they sink deep into the contact distance.
  • Start every body farther than dhat from everything else. Bodies that start overlapping, or inside a floor, are not pushed apart: they fall through or stop moving.

All the settings, as keyword arguments to trusty.contact.Config:

Setting Default Meaning
dhat 1e-3 contact distance, m
kappa 1e5 contact stiffness
epsv 1e-3 sliding speed (m/s) below which contacts stick; see Friction
project_positive_definite True make the contact term's Hessian positive definite, so every Newton step goes downhill
broadphase FlatQbvh how nearby pairs are found. FlatQbvh and TwoLevel give the same results at different speeds; AffineRelative is only for scenes of near-rigid affine bodies.
prune_ccd_by_rigid_motion True skip collision checks that a body's own rigid motion cannot trigger. It speeds up near-rigid bodies.
Full script: python examples/contact/resting_gap.py
"""How far apart bodies rest, for a few contact distances and stiffnesses.

Each setting gets its own world: a cube dropped onto a floor (a wall mesh),
stepped until it rests. The script prints the gap under the cube as a
fraction of the contact distance `dhat`. With the default stiffness the gap is
close to `dhat`; a low stiffness lets a heavy body sink further into it.

Usage:
    python examples/contact/resting_gap.py                 # polyscope
    python examples/contact/resting_gap.py --no-viewer     # print the table
    python examples/contact/resting_gap.py --density 8000  # a heavier cube
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

import numpy as np

import trusty

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from utils import init_polyscope  # noqa: E402

CUBE = 0.1
DHATS = (0.005, 0.02, 0.05)        # the viewer shows these side by side
KAPPAS = (1e3, 1e4, 1e5)           # 1e5 is the default


def build_world(dhat: float, kappa: float = 1e5, density: float = 2000.0,
                x: float = 0.0, backend: str = "auto"):
    trusty.check_capabilities("contact")
    world = trusty.World(backend=backend, timestep=0.01)
    trusty.contact.enable(world, trusty.contact.Config(
        dhat=dhat,      # contact distance (m): bodies rest about this far apart
        kappa=kappa))   # contact stiffness: how hard it pushes back

    m = trusty.make_beam_hex_mesh(size=(CUBE,) * 3, res=(3, 3, 3))
    V = np.asarray(m.vertices, dtype=np.float64)
    V = V - V.min(axis=0) + np.array([x - CUBE / 2, -CUBE / 2, 1.5 * dhat])
    cube = trusty.fem.add_hex_solid(
        world, trusty.make_hex_mesh(V, np.asarray(m.hexes)),
        trusty.StableNeoHookean(youngs_modulus=1e6, poisson_ratio=0.3), density)
    floor = np.array([[x - 0.3, -0.3, 0], [x + 0.3, -0.3, 0],
                      [x + 0.3, 0.3, 0], [x - 0.3, 0.3, 0]], dtype=np.float64)
    trusty.contact.add_wall(world, "floor", floor,
                            np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int32))
    return world, cube


def settle(world, steps: int = 100):
    for _ in range(steps):
        world.step()


def gap(world, cube) -> float:
    return float(np.asarray(trusty.fem.read_positions(world, cube))[:, 2].min())


def run_headless(density: float, backend: str):
    print(f"Resting gap / dhat, for a {CUBE} m cube of density {density:g} kg/m^3")
    print("  dhat (m)  " + "".join(f"kappa={k:<8g}" for k in KAPPAS))
    for dhat in DHATS:
        row = []
        for kappa in KAPPAS:
            world, cube = build_world(dhat, kappa, density, backend=backend)
            settle(world)
            row.append(gap(world, cube) / dhat)
        print(f"  {dhat:<8g}  " + "".join(f"{g:<14.2f}" for g in row))


def run_polyscope(density: float, backend: str):
    ps = init_polyscope(headless=False)
    for k, dhat in enumerate(DHATS):
        world, cube = build_world(dhat, density=density, x=0.25 * k, backend=backend)
        settle(world)
        ps.register_surface_mesh(
            f"dhat={dhat}", np.asarray(trusty.fem.read_positions(world, cube)),
            np.asarray(trusty.fem.read_surface_triangles(world, cube)))
    floor = np.array([[-0.15, -0.15, 0], [0.65, -0.15, 0], [0.65, 0.15, 0],
                      [-0.15, 0.15, 0]], dtype=np.float64)
    ps.register_surface_mesh("floor", floor, np.array([[0, 1, 2], [0, 2, 3]]),
                             color=(0.6, 0.6, 0.6))
    ps.show()


def main():
    p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    p.add_argument("--backend", choices=["auto", "cpu", "accelerate"], default="auto")
    p.add_argument("--density", type=float, default=2000.0)
    p.add_argument("--no-viewer", action="store_true")
    args = p.parse_args()
    if args.no_viewer:
        run_headless(args.density, args.backend)
    else:
        run_polyscope(args.density, args.backend)


if __name__ == "__main__":
    main()

Floors, planes and walls

Static geometry never moves, and bodies collide with it like with any other body. There are two kinds: infinite planes and triangle-mesh walls.

V, F = ramp_mesh()
trusty.contact.add_wall(world, "ramp", V, F,   # a static triangle mesh
                        friction_mu=mu)
trusty.add_floor_plane(world, z=0.0)           # the floor, normal +z
trusty.contact.add_plane(world,                # a backstop, facing -x
                         origin=np.array([BACKSTOP_X, 0.0, 0.0]),
                         normal=np.array([-1.0, 0.0, 0.0]))
Function Adds
trusty.add_floor_plane(world, z=0.0) a horizontal plane at height z, facing up
trusty.contact.add_plane(world, origin, normal) a plane through origin; bodies stay on the side normal points to
trusty.contact.add_wall(world, name, V, F) a static triangle mesh: vertices V (n, 3) and triangles F (m, 3), of any shape

Each also takes friction_mu=; see Friction. Each returns the new entity. Here the ramp is a wall, the run-out is a floor plane, and a second plane stands at the far end as a backstop:

Blocks on a ramp, a floor and a backstop

A wall must not touch a plane exactly: where the ramp meets the floor, the ramp continues a little below it.

Friction

Friction is off by default. Every body, wall and plane has its own Coulomb coefficient, friction_mu, given when it is added; it is 0, frictionless, unless you pass one. A contact holds while the sideways force is below its coefficient times the force pressing the surfaces together, and a contact uses the average of its two sides' coefficients. Here the three blocks and the ramp all get the same coefficient:

trusty.contact.enable(world, trusty.contact.Config(
    epsv=1e-5))     # below this sliding speed (m/s), contacts stick

material = trusty.StableNeoHookean(youngs_modulus=1e6, poisson_ratio=0.3)
blocks = {}
for name, y in LANES.items():
    blocks[name] = trusty.fem.add_hex_solid(world, block_mesh(y), material,
                                            density=1000.0, friction_mu=mu)

set_body_friction changes a body's coefficient afterwards, before the first step:

trusty.contact.set_body_friction(world, blocks["slippery"], 0.0)
trusty.contact.set_body_friction(world, blocks["grippy"], 0.8)

Every function that adds a body takes friction_mu=: fem.add_hex_solid, fem.add_tet_solid, embedded.add_embedded_solid, affine.add_affine_body, shells.add_shell, rods.add_rod, mpm.add_mpm_particles, subspace.add_hex_body and subspace.add_tet_body, as do add_wall, add_plane and add_floor_plane.

The ramp is at 25ยฐ, so a contact holds when its coefficient is above tan 25ยฐ โ‰ˆ 0.47. Here, every quarter-second of the first second:

Three blocks on a ramp, drawn every quarter-second: one races down, one creeps, one stays

Block Its coefficient With the ramp (0.4) Result
teal 0.0 0.2 slides down fast, then across the floor into the backstop
orange 0.4 0.4 slides slowly
red 0.8 0.6 stays put

The floor plane here is frictionless, the default, so the teal block keeps sliding once it leaves the ramp. Friction runs on the cpu and accelerate backends; on cuda, every coefficient must be 0. If a body that friction should hold creeps slowly, lower epsv, as this example does.

Full script: python examples/contact/ramp.py
"""Three blocks on a ramp, each with its own friction.

The ramp is a static wall mesh, the run-out is a floor plane, and a second
plane stands at its far end as a backstop. Friction is per body: the ramp and
the middle block share `--mu`, and a contact uses the average of its two
sides' coefficients. The slippery block slides down
and across the floor into the backstop, the middle one creeps, and the grippy
one stays put.

Usage:
    python examples/contact/ramp.py                # polyscope
    python examples/contact/ramp.py --no-viewer    # headless
    python examples/contact/ramp.py --steps 300
    python examples/contact/ramp.py --mu 0.6       # ramp and middle block friction
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

import numpy as np

import trusty

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from utils import init_polyscope  # noqa: E402

SLOPE_DEG = 25.0
RAMP_LENGTH = 1.2          # along the slope, m
RAMP_WIDTH = 0.9
BACKSTOP_X = 0.6           # the backstop plane, past the foot of the ramp
BLOCK = 0.1                # block edge, m
# Each block's lane across the ramp (y).
LANES = {"slippery": 0.0, "default": 0.28, "grippy": -0.28}
COLORS = {"slippery": (0.35, 0.75, 0.70), "default": (0.98, 0.70, 0.30),
          "grippy": (0.937, 0.325, 0.314)}


def slope_frame():
    """Rotation taking ramp coordinates (x down the slope, z out of it) to the
    world. The foot of the ramp is at the world origin."""
    a = np.radians(SLOPE_DEG)
    return np.array([[np.cos(a), 0.0, np.sin(a)],
                     [0.0, 1.0, 0.0],
                     [-np.sin(a), 0.0, np.cos(a)]])


def ramp_mesh():
    """The ramp's top face: one quad, from the top edge to just below the
    floor. The ramp must not touch the floor exactly, so its foot goes through."""
    R = slope_frame()
    w, foot = RAMP_WIDTH / 2, 0.03
    corners = np.array([[-RAMP_LENGTH, -w, 0.0], [foot, -w, 0.0],
                        [foot, w, 0.0], [-RAMP_LENGTH, w, 0.0]])
    return corners @ R.T, np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int32)


def block_mesh(y):
    """A hex block resting near the top of the ramp, in lane `y`."""
    m = trusty.make_beam_hex_mesh(size=(BLOCK,) * 3, res=(2, 2, 2))
    V = np.asarray(m.vertices, dtype=np.float64)
    V = V - V.mean(axis=0) + np.array([-RAMP_LENGTH + 0.12, y, BLOCK / 2 + 2e-3])
    return trusty.make_hex_mesh(V @ slope_frame().T, np.asarray(m.hexes))


def build_world(backend: str = "auto", mu: float = 0.4):
    trusty.check_capabilities("contact")
    world = trusty.World(backend=backend, timestep=0.01,
                         newton=trusty.NewtonConfig(max_iters=50))
    trusty.contact.enable(world, trusty.contact.Config(
        epsv=1e-5))     # below this sliding speed (m/s), contacts stick

    material = trusty.StableNeoHookean(youngs_modulus=1e6, poisson_ratio=0.3)
    blocks = {}
    for name, y in LANES.items():
        blocks[name] = trusty.fem.add_hex_solid(world, block_mesh(y), material,
                                                density=1000.0, friction_mu=mu)
    trusty.contact.set_body_friction(world, blocks["slippery"], 0.0)
    trusty.contact.set_body_friction(world, blocks["grippy"], 0.8)

    V, F = ramp_mesh()
    trusty.contact.add_wall(world, "ramp", V, F,   # a static triangle mesh
                            friction_mu=mu)
    trusty.add_floor_plane(world, z=0.0)           # the floor, normal +z
    trusty.contact.add_plane(world,                # a backstop, facing -x
                             origin=np.array([BACKSTOP_X, 0.0, 0.0]),
                             normal=np.array([-1.0, 0.0, 0.0]))
    return world, blocks


def centre(world, body):
    return np.asarray(trusty.fem.read_positions(world, body)).mean(axis=0)


def run_headless(world, blocks, steps: int):
    print(f"Ramp: {steps} steps")
    start = {n: centre(world, b) for n, b in blocks.items()}
    for i in range(steps):
        world.step()
        if (i + 1) % 50 == 0:
            moved = "  ".join(f"{n} {np.linalg.norm(centre(world, b) - start[n]):.3f}"
                              for n, b in blocks.items())
            r = world.last_report()
            print(f"  step {i + 1:4d}  distance moved (m): {moved}  "
                  f"converged={r.converged}")
    for n, b in blocks.items():
        x = np.asarray(trusty.fem.read_positions(world, b))
        print(f"Done. {n}: centre x={x[:, 0].mean():+.3f} m, lowest z={x[:, 2].min():+.4f} m")


def run_polyscope(world, blocks, steps: int):
    ps = init_polyscope(headless=False)
    V, F = ramp_mesh()
    ps.register_surface_mesh("ramp", V, F, color=(0.6, 0.6, 0.6))
    floor = np.array([[0, -0.6, 0], [BACKSTOP_X, -0.6, 0],
                      [BACKSTOP_X, 0.6, 0], [0, 0.6, 0]], float)
    ps.register_surface_mesh("floor", floor, np.array([[0, 1, 2], [0, 2, 3]]),
                             color=(0.6, 0.6, 0.6))
    meshes = {}
    for n, b in blocks.items():
        meshes[n] = ps.register_surface_mesh(
            n, np.asarray(trusty.fem.read_positions(world, b)),
            np.asarray(trusty.fem.read_surface_triangles(world, b)), color=COLORS[n])
    state = {"i": 0, "playing": False}   # opens paused

    def callback():
        import polyscope.imgui as psim
        _, 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 n, b in blocks.items():
                meshes[n].update_vertex_positions(
                    np.asarray(trusty.fem.read_positions(world, b)))
        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("--backend", choices=["auto", "cpu", "accelerate"], default="auto")
    p.add_argument("--steps", type=int, default=200)
    p.add_argument("--mu", type=float, default=0.4,
                   help="friction coefficient of the ramp and the middle block")
    p.add_argument("--no-viewer", action="store_true")
    args = p.parse_args()
    world, blocks = build_world(args.backend, args.mu)
    if args.no_viewer:
        run_headless(world, blocks, args.steps)
    else:
        run_polyscope(world, blocks, args.steps)


if __name__ == "__main__":
    main()

Excluding bodies

Contact can be switched off for one pair of bodies, or for one body altogether:

trusty.contact.exclude_body_pair(world, cubes["passes the shelf"], shelf)
trusty.contact.exclude_body_from_contact(world, cubes["passes everything"])
Function Does
exclude_body_pair(world, body_a, body_b) the two bodies pass through each other; both still collide with everything else
exclude_body_from_contact(world, body) the body collides with nothing, floors and walls included

Both take bodies (not walls or planes) and must be called before the first step. Three cubes dropped onto a shelf: the first lands on it, the second is excluded from the shelf and lands on the floor, and the third is excluded from contact and is half-way through the floor:

Three cubes and a shelf: one on the shelf, one on the floor, one sinking through the floor

Full script: python examples/contact/exclude.py
"""Three cubes dropped onto a shelf, with contact switched off selectively.

The first cube lands on the shelf. The second is excluded from contact with
the shelf only, so it falls through it and lands on the floor. The third is
excluded from contact entirely and falls through everything.

Usage:
    python examples/contact/exclude.py                # polyscope
    python examples/contact/exclude.py --no-viewer    # headless
    python examples/contact/exclude.py --steps 60
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

import numpy as np

import trusty

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from utils import init_polyscope  # noqa: E402

SHELF = 0.6                # shelf length along x, clamped to a wall at x = 0
DECK = (0.34, 0.40)        # shelf bottom and top, z
CUBE = 0.1
DROP_Z = 0.55              # cube bottoms start here
CUBE_X = {"lands": 0.15, "passes the shelf": 0.3, "passes everything": 0.45}


def offset_hex_mesh(size, res, lo):
    m = trusty.make_beam_hex_mesh(size=size, res=res)
    V = np.asarray(m.vertices, dtype=np.float64)
    return trusty.make_hex_mesh(V - V.min(axis=0) + np.asarray(lo), np.asarray(m.hexes))


def build_world(backend: str = "auto"):
    trusty.check_capabilities("contact")
    world = trusty.World(backend=backend, timestep=0.01,
                         newton=trusty.NewtonConfig(max_iters=50))
    trusty.contact.enable(world)

    shelf = trusty.fem.add_hex_solid(
        world, offset_hex_mesh((SHELF, 0.25, DECK[1] - DECK[0]), (6, 3, 1),
                               (0.0, -0.125, DECK[0])),
        trusty.StableNeoHookean(youngs_modulus=1e8, poisson_ratio=0.3), density=1000.0)
    trusty.fem.pin_face(world, shelf, axis=0, coord=0.0)

    soft = trusty.StableNeoHookean(youngs_modulus=1e5, poisson_ratio=0.3)
    cubes = {name: trusty.fem.add_hex_solid(
                 world, offset_hex_mesh((CUBE,) * 3, (2, 2, 2),
                                        (x - CUBE / 2, -CUBE / 2, DROP_Z)),
                 soft, density=1000.0)
             for name, x in CUBE_X.items()}
    trusty.add_floor_plane(world, z=0.0)

    trusty.contact.exclude_body_pair(world, cubes["passes the shelf"], shelf)
    trusty.contact.exclude_body_from_contact(world, cubes["passes everything"])
    return world, shelf, cubes


def lowest(world, body):
    return float(np.asarray(trusty.fem.read_positions(world, body))[:, 2].min())


def run_headless(world, cubes, steps: int):
    print(f"Exclusions: {steps} steps")
    for i in range(steps):
        world.step()
        if (i + 1) % 10 == 0:
            zs = "  ".join(f"{n}: {lowest(world, b):+.3f}" for n, b in cubes.items())
            print(f"  step {i + 1:3d}  lowest z  {zs}  "
                  f"converged={world.last_report().converged}")


def run_polyscope(world, shelf, cubes, steps: int):
    ps = init_polyscope(headless=False)
    floor = np.array([[-0.2, -0.4, 0], [1.0, -0.4, 0], [1.0, 0.4, 0], [-0.2, 0.4, 0]], float)
    ps.register_surface_mesh("floor", floor, np.array([[0, 1, 2], [0, 2, 3]]),
                             color=(0.6, 0.6, 0.6))
    bodies = {"shelf": shelf, **cubes}
    meshes = {n: ps.register_surface_mesh(
                  n, np.asarray(trusty.fem.read_positions(world, b)),
                  np.asarray(trusty.fem.read_surface_triangles(world, b)))
              for n, b in bodies.items()}
    state = {"i": 0, "playing": False}   # opens paused

    def callback():
        import polyscope.imgui as psim
        _, 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 n, b in bodies.items():
                meshes[n].update_vertex_positions(
                    np.asarray(trusty.fem.read_positions(world, b)))
        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("--backend", choices=["auto", "cpu", "accelerate"], default="auto")
    p.add_argument("--steps", type=int, default=60)
    p.add_argument("--no-viewer", action="store_true")
    args = p.parse_args()
    world, shelf, cubes = build_world(args.backend)
    if args.no_viewer:
        run_headless(world, cubes, args.steps)
    else:
        run_polyscope(world, shelf, cubes, args.steps)


if __name__ == "__main__":
    main()

Contact between body types

Every body type collides with every other: solids, embedded solids, affine bodies, shells, MPM particles and subspace bodies. Turning contact on is all it takes:

trusty.contact.enable(world, trusty.contact.Config(dhat=2e-3, kappa=1e6))

A tower of five body types settling, from the bottom: an affine cube (blue), a solid (red), an embedded solid (teal), a subspace body (purple) and a shell cube with a cloth sheet over it (yellow):

The examples/coupling/ folder has one example per pair:

Bodies Script
solid and solid fem_fem.py
solid and affine body fem_affine.py
solid and embedded solid fem_embedded.py
solid and MPM particles fem_mpm.py
affine body and affine body affine_affine.py
affine body and embedded solid affine_embedded.py
affine body and shell affine_shell.py
embedded solid and MPM fluid, in a box of walls embedded_drop_in_fluid.py
all five at once all_bodies.py

MPM particles collide with bodies and walls, but not with each other: the MPM material handles that.

Full script: python examples/coupling/all_bodies.py (slow: a minute or more)
"""All five body types coupled in ONE simulation.

affine (near-rigid) + fem (deformable hex) + embedded (voxelized) + shell +
subspace (reduced) -- all in one world, stacked into a single centered tower on
a shared floor with contact on, so every layer presses on the one
below. Every pair of body types interacts through the same contact model, in
one implicit solve.

The soft solids (fem / embedded / subspace) share E = 1e5 and at least 100
degrees of freedom each; the affine cube stays stiff (1e9) as the base of the stack.


Usage:
    python examples/coupling/all_bodies.py                       # viewer (opens paused)
    python examples/coupling/all_bodies.py --no-viewer           # headless
    python examples/coupling/all_bodies.py --backend accelerate  # Apple solver
"""

from __future__ import annotations

import argparse
import tempfile
from pathlib import Path

import numpy as np

import trusty

FRICTION_MU = 0.5   # Coulomb coefficient of every body and the floor


def box_tris(center, half, n=1):
    """Axis-aligned cube surface, each face split into n x n quads with welded
    shared edges/corners and outward winding. n=1 is the plain 8-vertex /
    12-triangle box; larger n gives more surface samples so an embedded body's
    coupled surface can follow the hex deformation instead of staying faceted.
    """
    h = float(half)
    verts: list = []
    index: dict = {}

    def vid(i, j, k):
        key = (i, j, k)
        if key not in index:
            index[key] = len(verts)
            verts.append([-h + 2.0 * h * i / n,
                          -h + 2.0 * h * j / n,
                          -h + 2.0 * h * k / n])
        return index[key]

    tris: list = []

    def cell(p0, p1, p2, p3, outward):
        a, b, d = np.asarray(verts[p0]), np.asarray(verts[p1]), np.asarray(verts[p2])
        if np.dot(np.cross(b - a, d - a), outward) < 0.0:
            p1, p3 = p3, p1            # flip winding so the normal points out
        tris.append((p0, p1, p2))
        tris.append((p0, p2, p3))

    for axis in range(3):
        for side in (0, n):
            outward = np.zeros(3)
            outward[axis] = 1.0 if side == n else -1.0
            u, v = (a for a in range(3) if a != axis)
            for s in range(n):
                for t in range(n):
                    def g(su, sv, _a=axis, _s=side, _u=u, _v=v):
                        c = [0, 0, 0]
                        c[_a] = _s
                        c[_u] = su
                        c[_v] = sv
                        return vid(*c)
                    cell(g(s, t), g(s + 1, t), g(s + 1, t + 1), g(s, t + 1), outward)

    V = np.asarray(verts, dtype=np.float64) + np.asarray(center, dtype=np.float64)
    F = np.asarray(tris, dtype=np.int32)
    return V, F


def shifted_hex(size, res, translate):
    m = trusty.make_beam_hex_mesh(size=size, res=res)
    V = np.asarray(m.vertices).copy()
    V = V - V.min(axis=0) + np.asarray(translate, dtype=np.float64)
    return trusty.make_hex_mesh(V, np.asarray(m.hexes))


def square_sheet(side, res, center):
    cx, cy, cz = center
    xs = np.linspace(-side / 2, side / 2, res)
    V  = np.array([[x + cx, y + cy, cz] for y in xs for x in xs], dtype=np.float64)
    F  = []
    for j in range(res - 1):
        for i in range(res - 1):
            a = j * res + i
            F.append((a, a + 1, a + res + 1))
            F.append((a, a + res + 1, a + res))
    return V, np.asarray(F, dtype=np.int32)


def build_subspace_basis(tmp: Path, size, res, num_handles):
    from trusty.subspace.precompute import build_basis as bb, pack
    mesh  = trusty.make_beam_hex_mesh(size=size, res=res)
    mat   = trusty.StableNeoHookean(youngs_modulus=1e5, poisson_ratio=0.40)
    world = trusty.World()
    trusty.fem.add_hex_solid(world, mesh, mat, density=1000.0)
    rv    = np.asarray(mesh.vertices, dtype=np.float64)
    hexes = np.asarray(mesh.hexes)
    basis = bb.build_skinning_eigenmodes(
        world=world, num_nodes=rv.shape[0], rest_positions=rv,
        num_handles=num_handles, mesh_hash=bb.mesh_hash_sha256(rv, hexes))
    path = tmp / "sub.basis"
    pack.save(path, basis)
    return trusty.subspace.load(str(path))


def build_world(tmp: Path, backend: str = "auto"):
    trusty.check_capabilities("contact", "affine", "embedded", "subspace", "shells")
    soft  = trusty.StableNeoHookean(youngs_modulus=1e5, poisson_ratio=0.40)
    world = trusty.World(backend=backend,
                         timestep=0.01,
                         newton=trusty.NewtonConfig(max_iters=80))
    trusty.contact.enable(world, trusty.contact.Config(dhat=2e-3, kappa=1e6))
    bodies = {}

    half      = 0.08      # half-extent of each stacked box (0.16 m footprint)
    gap       = 0.006     # rest gap between layers (and above the floor)
    n_handles = 3         # subspace: 12 reduced degrees of freedom per handle

    # Everything stacks into one centered tower, bottom -> top, so each layer
    # presses on the one below. The stiff affine cube anchors the base; the
    # three soft solids sit on it; the shell drapes over the top.
    z = gap

    # affine cube (stiff base)
    cz = z + half
    Vc, Fc = box_tris((0.0, 0.0, cz), half)
    bodies["affine"] = trusty.affine.add_affine_body(
        world, Vc, Fc, density=1000.0, stiffness=1e9)
    z = cz + half + gap

    # fem box (E=1e5, res 3 -> 4*4*4 = 64 nodes)
    fem_mesh = shifted_hex((2 * half, 2 * half, 2 * half), (3, 3, 3), (-half, -half, z))
    bodies["fem"] = trusty.fem.add_hex_solid(world, fem_mesh, soft, density=1000.0)
    z += 2 * half + gap

    # embedded box (E=1e5). The hex grid (voxel_size) carries the deformation;
    # the subdivided box surface (n=4) samples that field so the coupled
    # surface bulges instead of staying faceted between 8 corners.
    ez = z + half
    Ve, Fe = box_tris((0.0, 0.0, ez), half, n=4)
    bodies["embedded"] = trusty.embedded.add_embedded_solid(
        world, Ve, Fe, voxel_size=0.05, material=soft, density=1000.0)
    z = ez + half + gap

    # subspace (reduced) box (E=1e5, 3 handles)
    sub_size = (2 * half, 2 * half, 0.12)
    sub_res  = (4, 4, 3)            # 5*5*4 = 100 nodes
    basis    = build_subspace_basis(tmp, sub_size, sub_res, n_handles)
    sub_mesh = shifted_hex(sub_size, sub_res, (-half, -half, z))
    bodies["subspace"] = trusty.subspace.add_hex_body(
        world, sub_mesh, soft, density=1000.0, basis=basis)
    z += 0.12 + gap

    # Shell bodies: a closed thin-shell CUBE sitting on the stack, plus the
    # flat cloth sheet draped over it, given as ONE shell body (one shared
    # ShellConfig) with two disconnected components
    # (cube + sheet) -- cross-component contact lets the cloth drape over the
    # cube, and each component relaxes toward its own rest shape (the cube's
    # 90-degree edge dihedrals keep it boxy).
    Vcube, Fcube   = box_tris((0.0, 0.0, z + half), half, n=4)   # ~98 boundary verts
    z += 2 * half + gap
    Vsheet, Fsheet = square_sheet(4 * half, 7, (0.0, 0.0, z + 0.01))
    Vshell = np.vstack([Vcube, Vsheet])
    Fshell = np.vstack([Fcube, Fsheet + Vcube.shape[0]]).astype(np.int32)
    sh_cfg = trusty.shells.ShellConfig()
    sh_cfg.youngs_modulus = 5.0e5
    sh_cfg.poisson_ratio  = 0.3
    sh_cfg.thickness      = 1.5e-3
    sh_cfg.density        = 1.0e3
    bodies["shell"] = trusty.shells.add_shell(world, Vshell, Fshell, sh_cfg)

    # Friction keeps the stack standing. It is set per body (a contact uses
    # the average of the two sides' coefficients), and the floor carries its own.
    for body in bodies.values():
        trusty.contact.set_body_friction(world, body, FRICTION_MU)
    trusty.add_floor_plane(world, 0.0, friction_mu=FRICTION_MU)

    n_fem = np.asarray(fem_mesh.vertices).shape[0]
    n_emb = np.asarray(trusty.fem.read_mesh(world, bodies["embedded"]).vertices).shape[0]
    print(f"degrees of freedom per body: affine=12  fem={3 * n_fem}  embedded={3 * n_emb}  "
          f"subspace={12 * n_handles} (reduced)  shell={3 * Vshell.shape[0]} "
          f"(cube+cloth)")


    return world, bodies, sub_mesh


def run_headless(world, bodies, steps: int):
    print("All five body types in one simulation "
          "(affine + fem + embedded + shell + subspace).")
    diverged = 0
    for i in range(steps):
        world.step()
        r = world.last_report()
        diverged += (not r.converged)
        if (i + 1) % 20 == 0:
            print(f"  step {i + 1:4d}  iters={r.iterations}  "
                  f"res={r.final_residual:.2e}  converged={r.converged}")
    print(f"Done. {diverged} non-converged steps over {steps}.")
    assert diverged == 0, f"{diverged} steps did not converge"
    centres = {
        "affine": np.asarray(trusty.affine.surface(world, bodies["affine"])[0]),
        "fem": np.asarray(trusty.fem.read_positions(world, bodies["fem"])),
        "embedded": np.asarray(trusty.embedded.read_embedded_surface(world, bodies["embedded"])),
        "subspace": np.asarray(trusty.subspace.deformed_positions(world, body=bodies["subspace"])),
    }
    off = {n: float(np.linalg.norm(x[:, :2].mean(axis=0))) for n, x in centres.items()}
    print("sideways offset of each layer (m): "
          + "  ".join(f"{n}={d:.3f}" for n, d in off.items()))
    assert max(off.values()) < 0.02, "the tower did not stay stacked"
    print("OK: affine + fem + embedded + shell + subspace coupled in one solve.")


def run_polyscope(world, bodies, sub_mesh, steps: int):
    try:
        import polyscope as ps
    except ImportError:
        print("polyscope not installed; running headless.")
        run_headless(world, bodies, steps)
        return

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

    # Floor at z = 0 (matches trusty.add_floor_plane).
    fx, fy = 1.2, 0.6
    floor_v = np.array(
        [[-fx, -fy, 0.0], [fx, -fy, 0.0], [fx, fy, 0.0], [-fx, fy, 0.0]],
        dtype=np.float64)
    floor_f = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int32)
    ps.register_surface_mesh("floor", floor_v, floor_f, color=(0.85, 0.85, 0.85))

    # One renderable per body type, each read back through its own surface/mesh API.
    Va, Fa = trusty.affine.surface(world, bodies["affine"])
    ps_aff = ps.register_surface_mesh(
        "affine", np.asarray(Va), np.asarray(Fa), color=(0.85, 0.35, 0.30))

    fem_mesh = trusty.fem.read_mesh(world, bodies["fem"])
    ps_fem = ps.register_volume_mesh(
        "fem", np.asarray(fem_mesh.vertices).copy(),
        hexes=np.asarray(fem_mesh.hexes), color=(0.30, 0.55, 0.85))

    emb = bodies["embedded"]
    ps_emb = ps.register_surface_mesh(
        "embedded", np.asarray(trusty.embedded.read_embedded_surface(world, emb)),
        np.asarray(trusty.embedded.surface_triangles(world, emb)),
        color=(0.40, 0.75, 0.45), smooth_shade=False)
    # The hex grid behind the embedded surface (what deforms), shown by
    # default like the other embedded examples.
    emb_hex = trusty.fem.read_mesh(world, emb)
    ps_emb_hex = ps.register_volume_mesh(
        "embedded hex", np.asarray(emb_hex.vertices).copy(),
        hexes=np.asarray(emb_hex.hexes), color=(0.20, 0.50, 0.30))

    shell = bodies["shell"]
    ps_shell = ps.register_surface_mesh(
        "shell", np.asarray(trusty.shells.read_positions(world, shell)),
        np.asarray(trusty.shells.read_triangles(world, shell)),
        color=(0.90, 0.75, 0.25), smooth_shade=False)

    ps_sub = ps.register_volume_mesh(
        "subspace", np.asarray(sub_mesh.vertices).copy(),
        hexes=np.asarray(sub_mesh.hexes), color=(0.60, 0.45, 0.80))

    state = {"i": 0, "playing": False}  # opens paused -- step/play to advance

    def cb():
        import polyscope.imgui as psim
        _, state["playing"] = psim.Checkbox("play", state["playing"])
        if (psim.Button("step") or state["playing"]) and state["i"] < steps:
            world.step()
            state["i"] += 1
            ps_aff.update_vertex_positions(np.asarray(trusty.affine.surface(world, bodies["affine"])[0]))
            ps_fem.update_vertex_positions(
                np.asarray(trusty.fem.read_mesh(world, bodies["fem"]).vertices))
            ps_emb.update_vertex_positions(
                np.asarray(trusty.embedded.read_embedded_surface(world, emb)))
            ps_emb_hex.update_vertex_positions(
                np.asarray(trusty.fem.read_mesh(world, emb).vertices))
            ps_shell.update_vertex_positions(
                np.asarray(trusty.shells.read_positions(world, shell)))
            ps_sub.update_vertex_positions(
                np.asarray(trusty.subspace.deformed_positions(world, body=bodies["subspace"])))
        psim.Text(f"step {state['i']} / {steps}")

    ps.set_user_callback(cb)
    ps.show()


def main():
    p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    p.add_argument("--steps", type=int, default=150)
    p.add_argument("--backend", choices=["auto", "cpu", "accelerate", "cuda"],
                   default="auto")
    p.add_argument("--no-viewer", action="store_true",
                   help="run headless instead of launching the polyscope viewer")
    args = p.parse_args()
    with tempfile.TemporaryDirectory() as td:
        world, bodies, sub_mesh = build_world(Path(td), args.backend)
        if args.no_viewer:
            run_headless(world, bodies, args.steps)
        else:
            run_polyscope(world, bodies, sub_mesh, args.steps)


if __name__ == "__main__":
    main()

Observing contact

A contact observer is told what contact is doing at every Newton iteration: how many pairs are close, which are pushing, and how far the solver may move. Subclass trusty.contact.ContactObserver and override the hooks you need:

class ContactViewObserver(trusty.contact.ContactObserver):
    """Keeps the latest report from each hook, in a form the viewer can draw."""

    def __init__(self, want_stencils: bool = True):
        super().__init__()               # required
        self.want_stencils = want_stencils
        self.n_prepare = 0
        self.last_prepare = None
        self.last_step_size = None
        self.accepted = (None, None)     # contact pairs, as a curve network
        self.candidates = (None, None)   # pairs that are close, but not yet in contact
        self.plane_verts = None          # vertices touching a plane

    def wants_stencils(self):
        return self.want_stencils

    def on_step_size(self, report):
        self.last_step_size = report

    def on_prepare(self, report):
        self.n_prepare += 1
        self.last_prepare = report
        if np.asarray(report.stencils.X).size:
            self.keep_stencils(report.stencils)

Pass it to trusty.contact.enable:

obs = ContactViewObserver(want_stencils)
trusty.contact.enable(world, trusty.contact.Config(dhat=4e-3), observer=obs)
Hook Called with
on_prepare(report) a ContactReport, every iteration
on_step_size(report) a StepSizeReport: how far the solver may move before anything would pass through anything else, as a fraction of its step. alpha_pv for planes, alpha_min overall.
wants_stencils() return True to fill report.stencils (below); it costs a copy, so it is empty otherwise

ContactReport holds counts and the contact energy:

Field Meaning
n_surf_verts, n_surf_edges, n_surf_faces size of the combined contact surface of every body and wall
n_pt_broadphase, n_ee_broadphase, n_pv_broadphase candidate pairs found close: point-triangle, edge-edge, point-plane
n_vv_active, n_ev_active, n_ee_active, n_fv_active, n_pv_active pairs inside dhat, by kind: vertex-vertex, edge-vertex, edge-edge, face-vertex, vertex-plane
barrier_energy the contact energy
stencils the geometry of every pair, when wants_stencils() is True

report.stencils has the contact surface and the pairs as index arrays into it. The report is only valid during the call, so copy what you keep:

X = np.asarray(st.X).copy()           # contact-surface vertices
E, F = np.asarray(st.E), np.asarray(st.F)
edge_mid = X[E].mean(axis=1) if E.size else np.zeros((0, 3))
face_mid = X[F].mean(axis=1) if F.size else np.zeros((0, 3))
self.accepted = _segments([
    (np.asarray(st.active_vv), X,        X),         # vertex-vertex
    (np.asarray(st.active_ev), edge_mid, X),         # edge-vertex
    (np.asarray(st.active_ee), edge_mid, edge_mid),  # edge-edge
    (np.asarray(st.active_fv), face_mid, X)])        # face-vertex
pv = np.asarray(st.active_pv)                        # vertex indices
self.plane_verts = X[pv] if pv.size else None
Field Shape Meaning
X (n, 3) contact-surface vertex positions
E, F (e, 2), (f, 3) its edges and triangles
cand_ee, cand_fv (k, 2) candidate pairs: (edge, edge) and (face, vertex)
active_vv, active_ev, active_ee, active_fv (k, 2) pairs inside dhat, as (vertex, vertex), (edge, vertex), (edge, edge) and (face, vertex)
active_pv (k,) vertices inside dhat of a plane

One beam resting across another: the red lines join the two sides of each contact pair, and the green points are the vertices touching the floor.

Two crossed beams, with their contact pairs drawn as red lines and floor contacts as green points

Full script: python examples/contact/observer.py
"""Watch contact as it happens, with a contact observer.

One beam falls crosswise onto another, which rests on a floor plane. An
observer receives a report from the contact solver at every Newton iteration
and keeps what the viewer draws: the contact pairs in red and the vertices
touching the floor in green.

The observer's three hooks:

- `on_prepare(report)`   -- counts of candidate and active contact pairs,
                            and the contact energy.
- `on_step_size(report)` -- the step the solver may take before anything
                            would pass through anything else.
- `wants_stencils()`     -- return True to also get `report.stencils`: the
                            geometry of every pair, for drawing. It costs a
                            copy, so it is empty otherwise; the viewer's
                            checkbox turns it on and off.

Usage:
    python examples/contact/observer.py                # polyscope
    python examples/contact/observer.py --no-viewer    # headless
    python examples/contact/observer.py --steps 200
"""

from __future__ import annotations

import argparse

import numpy as np

import trusty


def _segments(groups):
    """One curve-network segment per pair, drawn from the centre of one
    primitive to the centre of the other. `groups` is a list of
    (pairs, ends_a, ends_b)."""
    ends = [np.stack([a[p[:, 0]], b[p[:, 1]]], axis=1)
            for p, a, b in groups if p.size]
    if not ends:
        return None, None
    V = np.concatenate(ends, axis=0).reshape(-1, 3)
    return V, np.arange(len(V), dtype=np.int32).reshape(-1, 2)


class ContactViewObserver(trusty.contact.ContactObserver):
    """Keeps the latest report from each hook, in a form the viewer can draw."""

    def __init__(self, want_stencils: bool = True):
        super().__init__()               # required
        self.want_stencils = want_stencils
        self.n_prepare = 0
        self.last_prepare = None
        self.last_step_size = None
        self.accepted = (None, None)     # contact pairs, as a curve network
        self.candidates = (None, None)   # pairs that are close, but not yet in contact
        self.plane_verts = None          # vertices touching a plane

    def wants_stencils(self):
        return self.want_stencils

    def on_step_size(self, report):
        self.last_step_size = report

    def on_prepare(self, report):
        self.n_prepare += 1
        self.last_prepare = report
        if np.asarray(report.stencils.X).size:
            self.keep_stencils(report.stencils)

    def keep_stencils(self, st):
        # The report is only valid during the call, so copy what we keep.
        X = np.asarray(st.X).copy()           # contact-surface vertices
        E, F = np.asarray(st.E), np.asarray(st.F)
        edge_mid = X[E].mean(axis=1) if E.size else np.zeros((0, 3))
        face_mid = X[F].mean(axis=1) if F.size else np.zeros((0, 3))
        self.accepted = _segments([
            (np.asarray(st.active_vv), X,        X),         # vertex-vertex
            (np.asarray(st.active_ev), edge_mid, X),         # edge-vertex
            (np.asarray(st.active_ee), edge_mid, edge_mid),  # edge-edge
            (np.asarray(st.active_fv), face_mid, X)])        # face-vertex
        pv = np.asarray(st.active_pv)                        # vertex indices
        self.plane_verts = X[pv] if pv.size else None
        self.candidates = _segments([
            (np.asarray(st.cand_ee), edge_mid, edge_mid),
            (np.asarray(st.cand_fv), face_mid, X)])


def _beam(size, res, offset, crosswise: bool):
    """Hex beam centred on the origin, turned a quarter turn about z if asked,
    then moved to `offset`. A rotation, not an axis swap: swapping axes would
    turn every element inside out."""
    mesh = trusty.make_beam_hex_mesh(size=size, res=res)
    V = np.asarray(mesh.vertices, dtype=np.float64).copy()
    V -= V.mean(axis=0)
    if crosswise:
        V[:, 0], V[:, 1] = -V[:, 1].copy(), V[:, 0].copy()
    V += np.asarray(offset, dtype=np.float64)
    return trusty.make_hex_mesh(V, np.asarray(mesh.hexes, dtype=np.int32))


def build_sim(backend: str, want_stencils: bool):
    trusty.check_capabilities("contact")

    size, res = (0.6, 0.2, 0.15), (6, 2, 2)
    world = trusty.World(backend=backend,
                         timestep=1.0 / 120.0,
                         newton=trusty.NewtonConfig(max_iters=50))

    obs = ContactViewObserver(want_stencils)
    trusty.contact.enable(world, trusty.contact.Config(dhat=4e-3), observer=obs)

    material = trusty.StableNeoHookean(youngs_modulus=5e5, poisson_ratio=0.3)
    lower = trusty.fem.add_hex_solid(
        world, _beam(size, res, (0.0, 0.0, 0.20), False), material, 1000.0)
    upper = trusty.fem.add_hex_solid(
        world, _beam(size, res, (0.0, 0.0, 0.50), True), material, 1000.0)
    trusty.add_floor_plane(world, 0.0)
    return world, obs, (lower, upper)


def _counts_line(obs) -> str:
    r = obs.last_prepare
    if r is None:
        return "no report yet"
    return (f"candidates pt={r.n_pt_broadphase} ee={r.n_ee_broadphase} "
            f"pv={r.n_pv_broadphase} | active vv={r.n_vv_active} "
            f"ev={r.n_ev_active} ee={r.n_ee_active} fv={r.n_fv_active} "
            f"pv={r.n_pv_active} | energy={r.barrier_energy:.3e}")


def _step_line(obs) -> str:
    s = obs.last_step_size
    if s is None:
        return "no step-size report yet"
    return f"largest safe step: planes {s.alpha_pv:.3e}, overall {s.alpha_min:.3e}"


def run_headless(world, obs, steps: int):
    print(f"Running {steps} steps headless...")
    for i in range(steps):
        world.step()
        if (i + 1) % 20 == 0:
            print(f"  step {i + 1:4d}  {_counts_line(obs)}")
            print(f"              {_step_line(obs)}")
    n_pairs = 0 if obs.accepted[0] is None else len(obs.accepted[0]) // 2
    print(f"Done. on_prepare ran {obs.n_prepare} times; {n_pairs} contact "
          f"pairs at the last iteration.")


def run_polyscope(world, obs, bodies, steps: int):
    try:
        import polyscope as ps
        import polyscope.imgui as psim
    except ImportError:
        print("polyscope not installed; falling back to headless. "
              "(`pip install polyscope`, or use --no-viewer)")
        run_headless(world, obs, steps)
        return

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

    volumes = []
    for name, body in zip(("lower", "upper"), bodies):
        mesh = trusty.fem.read_mesh(world, body)
        v = ps.register_volume_mesh(name,
                                    np.asarray(mesh.vertices).copy(),
                                    hexes=np.asarray(mesh.hexes))
        v.set_color((0.55, 0.7, 0.9) if name == "lower" else (0.95, 0.6, 0.35))
        v.set_transparency(0.6)
        volumes.append((body, v))

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

    def refresh():
        for body, v in volumes:
            v.update_vertex_positions(
                np.asarray(trusty.fem.read_mesh(world, body).vertices))

        V, E = obs.accepted
        if V is not None:
            ps.register_curve_network("contact pairs", V, E, radius=0.0025,
                                      color=(0.95, 0.2, 0.2), enabled=True)
        elif ps.has_curve_network("contact pairs"):
            ps.get_curve_network("contact pairs").set_enabled(False)

        V, E = obs.candidates
        if state["candidates"] and V is not None:
            ps.register_curve_network("candidates", V, E, radius=0.0008,
                                      color=(0.6, 0.6, 0.6), enabled=True)
        elif ps.has_curve_network("candidates"):
            ps.get_curve_network("candidates").set_enabled(False)

        if obs.plane_verts is not None:
            ps.register_point_cloud("on the floor", obs.plane_verts,
                                    radius=0.006, color=(0.2, 0.9, 0.3),
                                    enabled=True)
        elif ps.has_point_cloud("on the floor"):
            ps.get_point_cloud("on the floor").set_enabled(False)

    def advance():
        if state["i"] < steps:
            world.step()
            state["i"] += 1
            refresh()

    def callback():
        _, state["playing"] = psim.Checkbox("play", state["playing"])
        psim.SameLine()
        if psim.Button("step"):
            advance()
        elif state["playing"]:
            advance()

        # wants_stencils() is asked every iteration, so this takes effect on
        # the next step; it costs nothing while off.
        changed, obs.want_stencils = psim.Checkbox("fill report.stencils",
                                                   obs.want_stencils)
        if changed and not obs.want_stencils:
            obs.candidates = obs.accepted = (None, None)
            obs.plane_verts = None
            refresh()
        _, state["candidates"] = psim.Checkbox("show candidate pairs",
                                               state["candidates"])

        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 'NOT converged'}")
        psim.Text(_counts_line(obs))
        psim.Text(_step_line(obs))
        psim.Text(f"on_prepare x{obs.n_prepare}")

    refresh()
    ps.set_user_callback(callback)
    ps.show()


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

    world, obs, bodies = build_sim(args.backend, not args.no_stencils)
    if args.no_viewer:
        run_headless(world, obs, args.steps)
    else:
        run_polyscope(world, obs, bodies, args.steps)


if __name__ == "__main__":
    main()

Reference

Every function and argument: trusty.contact reference.