Skip to content

Boundary conditions

trusty.boundary_conditions holds bodies in place, moves them, loads them and joins them to each other. Clamping a face is one call in trusty.fem.

Capability: boundary_conditions.

Quick example

A bar clamped at one end and pulled by a traction on the other:

world = trusty.World(backend=backend,
                     time_stepping="static",   # solve for the equilibrium each step
                     gravity=(0.0, 0.0, 0.0))  # the load is the only force

mesh = make_mesh(size=BAR_SIZE, res=BAR_RES)
material = trusty.StableNeoHookean(youngs_modulus=1e6, poisson_ratio=0.3)
body = add_solid(world, mesh, material, density=1000.0)

trusty.fem.pin_face(world, body, axis=0, coord=0.0)  # clamp the x = 0 end

load_faces = end_faces(world, body, x=BAR_SIZE[0])   # triangles on the x = 1 end
load = trusty.boundary_conditions.attach_surface_load(
    world, body, load_faces, np.zeros((len(load_faces), 3)))  # Pa, one row per face

A bar clamped to a wall and stretched by arrows on its free end

The red bar is loaded and the grey one behind it is the same bar at rest. The orange points are the clamped nodes, and the arrows show the traction on the end face. The load starts at zero, and the script raises it step by step; see Surface loads.

Every condition works on some body types and not others:

Condition Function Works on
Clamp a face trusty.fem.pin_face solids, embedded solids, reduced solids
Spring pin pin_to_target solids, embedded solids, shells, rods
Surface load attach_surface_load solids, embedded solids, affine bodies
Stitch stitch a solid, shell or rod onto an embedded solid or affine body
Attach attach a reduced solid onto an embedded solid or affine body

Embedded solids can also pin their surface directly; see Weak pins.

Add every condition before the first world.step(). After that you can move targets and change stiffnesses and tractions, but you can't add new conditions.

Full script: python examples/boundary_conditions/fem_surface_load.py
"""Surface load: a bar clamped at one end and pulled by a traction on the other.

A hex (or tet) bar is clamped on its x = 0 face and carries a traction (force
per unit area, Pa) on its x = 1 end face. The traction is a *dead load*: it
keeps its world direction and acts on the faces' rest area, like gravity acts
on the body's mass.

`attach_surface_load` puts the load on the end faces, starting at zero, and
returns a handle to it. The run ramps the load up to its full value with
`set_tractions` on that handle, stretching the bar a little further each step. Each step solves for static equilibrium (no
inertia) with gravity off, so the stretch is the load's doing alone.

Usage:
    python examples/boundary_conditions/fem_surface_load.py              # polyscope
    python examples/boundary_conditions/fem_surface_load.py --no-viewer  # print the stretch
    python examples/boundary_conditions/fem_surface_load.py --element tet
"""

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

BAR_SIZE = (1.0, 0.1, 0.1)
BAR_RES = (20, 2, 2)
TRACTION_MAX = 1.0e5  # Pa in +x, the fully ramped end traction
SOLIDS = {"hex": (trusty.make_beam_hex_mesh, trusty.fem.add_hex_solid),
          "tet": (trusty.make_beam_tet_mesh, trusty.fem.add_tet_solid)}


def end_faces(world, body, x):
    """Indices of the body's surface triangles that lie on the plane at `x`."""
    tris = np.asarray(trusty.fem.read_surface_triangles(world, body))
    verts = np.asarray(trusty.fem.read_positions(world, body))
    on_plane = np.all(np.abs(verts[tris, 0] - x) < 1e-9, axis=1)
    return np.flatnonzero(on_plane).tolist()


def build_world(backend: str = "auto", element: str = "hex"):
    make_mesh, add_solid = SOLIDS[element]
    world = trusty.World(backend=backend,
                         time_stepping="static",   # solve for the equilibrium each step
                         gravity=(0.0, 0.0, 0.0))  # the load is the only force

    mesh = make_mesh(size=BAR_SIZE, res=BAR_RES)
    material = trusty.StableNeoHookean(youngs_modulus=1e6, poisson_ratio=0.3)
    body = add_solid(world, mesh, material, density=1000.0)

    trusty.fem.pin_face(world, body, axis=0, coord=0.0)  # clamp the x = 0 end

    load_faces = end_faces(world, body, x=BAR_SIZE[0])   # triangles on the x = 1 end
    load = trusty.boundary_conditions.attach_surface_load(
        world, body, load_faces, np.zeros((len(load_faces), 3)))  # Pa, one row per face
    return world, body, load, mesh


def traction_at(step: int, steps: int) -> float:
    return TRACTION_MAX * min(1.0, (step + 1) / max(1, steps))


def ramp(world, load, step: int, steps: int):
    """Set this step's traction on every loaded face, then advance."""
    n = trusty.boundary_conditions.num_loaded_faces(world, load)
    t = np.tile([traction_at(step, steps), 0.0, 0.0], (n, 1))
    trusty.boundary_conditions.set_tractions(world, load, t)
    world.step()


def _register(ps, world, body, mesh, element):
    x = np.asarray(trusty.fem.read_positions(world, body)).copy()
    cells = {"hexes": np.asarray(mesh.hexes)} if element == "hex" else \
        {"tets": np.asarray(mesh.tets)}
    ps_mesh = ps.register_volume_mesh("bar", x, **cells)
    ps_mesh.set_edge_width(1.0)
    return ps_mesh


def run_polyscope(world, body, load, mesh, element, steps):
    ps = init_polyscope(headless=False)
    ps_mesh = _register(ps, world, body, mesh, element)
    state = {"i": 0, "playing": False}   # play starts OFF

    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:
            ramp(world, load, state["i"], steps)
            state["i"] += 1
            ps_mesh.update_vertex_positions(trusty.fem.read_positions(world, body))
        t = traction_at(state["i"] - 1, steps) if state["i"] else 0.0
        psim.Text(f"step {state['i']} / {steps}   traction_x = {t:.0f} Pa")

    ps.set_user_callback(callback)
    ps.show()


def run_headless(world, body, load, mesh, steps):
    rest = np.asarray(mesh.vertices)
    for i in range(steps):
        ramp(world, load, i, steps)
        if not world.last_report().converged:
            raise SystemExit(f"step {i}: the solve did not converge")
    x = np.asarray(trusty.fem.read_positions(world, body))
    tractions = trusty.boundary_conditions.tractions(world, load)

    def mean_dx(xref):
        on = np.abs(rest[:, 0] - xref) < 1e-9
        return float((x[on, 0] - rest[on, 0]).mean())

    area = BAR_SIZE[1] * BAR_SIZE[2]
    print(f"end traction {tractions[0, 0]:.0f} Pa on {len(tractions)} faces "
          f"= {tractions[0, 0] * area:.0f} N in total")
    print(f"loaded (x = 1) end moved {mean_dx(BAR_SIZE[0]):+.4f} m")
    print(f"clamped (x = 0) end moved {mean_dx(0.0):+.4f} m")
    if mean_dx(BAR_SIZE[0]) < 1e-3 or abs(mean_dx(0.0)) > 1e-9:
        raise SystemExit("the bar did not stretch, or the clamp moved")
    print("OK: the bar stretched under the end traction; the clamp held.")


def main():
    parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    parser.add_argument("--backend", choices=["auto", "cpu", "accelerate", "cuda"], default="auto")
    parser.add_argument("--element", choices=list(SOLIDS), default="hex")
    parser.add_argument("--steps", type=int, default=20,
                        help="load increments to reach the full traction")
    parser.add_argument("--no-viewer", action="store_true",
                        help="headless: ramp the load, then print the stretch")
    args = parser.parse_args()

    world, body, load, mesh = build_world(args.backend, args.element)
    if args.no_viewer:
        run_headless(world, body, load, mesh, args.steps)
    else:
        run_polyscope(world, body, load, mesh, args.element, args.steps)


if __name__ == "__main__":
    main()

Clamping a face

trusty.fem.pin_face(world, body, axis, coord, tol=1e-6) fixes every node that lies on a plane at its rest position. The clamp is exact: the nodes do not move at all.

Argument Meaning
axis the axis the plane is normal to: 0, 1 or 2 for x, y or z
coord where the plane crosses that axis, in the units of the mesh
tol how far off the plane a node may be and still be clamped

Shells and rods have clamps of their own: trusty.shells.pin_face and pin_vertices on shells, and trusty.rods.pin_vertices on rods.

The nodes of an embedded solid are its hex grid's nodes, which need not lie on the surface. Widen tol so the clamp catches the grid nodes near the face:

# Clamp the hex-grid nodes within `tol` of the post's back face: the grid
# need not line up with the surface, so allow up to about half a hex.
trusty.fem.pin_face(world, post, axis=0, coord=POST_ORIGIN[0], tol=0.06)

A cube with its back face clamped and a soft flag hanging from it

The orange points are the clamped grid nodes. The soft flag is stitched to the cube.

A few things to know:

  • A plane with no nodes on it clamps nothing, and there is no error. Check that coord is on the mesh.
  • A clamp reports no force. To measure how hard a face is held, pin it with a stiff spring pin instead.
  • A clamp holds its nodes even if something presses into them. Where the pinned part may touch something, use a spring pin, which gives way to contact. See Moving targets.
Full script: python examples/boundary_conditions/fem_embedded_flag.py
"""Soft FEM flag stitched to a clamped embedded post.

An embedded (voxelized) cube acts as a stiff post: its back face is clamped to
the world. A soft FEM beam ("flag") reaches out in +x; its root cap sits
inside the post's hex grid and is *stitched* onto it, so the root vertices
move exactly with the post, as if they were points of it. Under gravity the
flag cantilevers off the anchored post and sags.

Usage:
    python examples/boundary_conditions/fem_embedded_flag.py                # polyscope
    python examples/boundary_conditions/fem_embedded_flag.py --no-viewer    # headless
    python examples/boundary_conditions/fem_embedded_flag.py --steps 400
"""

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, make_bar_trimesh  # noqa: E402

# Post: an axis-aligned cube whose cage straddles x = 0 so the flag root
# (x = 0) lands inside it.
POST_SIZE   = (0.4, 0.4, 0.4)
POST_ORIGIN = (-0.3, -0.2, -0.1)
VOXEL_SIZE  = 0.1
FLAG_SIZE   = (1.0, 0.15, 0.15)
FLAG_RES    = (12, 2, 2)


def build_world(backend: str = "auto"):
    trusty.check_capabilities("embedded")

    world    = trusty.World(backend=backend,
                            timestep=1.0 / 60.0,
                            newton=trusty.NewtonConfig(max_iters=50))
    material = trusty.StableNeoHookean(youngs_modulus=1e5, poisson_ratio=0.3)

    # -- Anchor: stiff embedded post, back (x = min) face clamped. ---------
    stiff  = trusty.StableNeoHookean(youngs_modulus=1e6, poisson_ratio=0.3)
    pV, pF = make_bar_trimesh(POST_SIZE, res=(4, 4, 4), origin=POST_ORIGIN)
    post   = trusty.embedded.add_embedded_solid(
        world, pV, pF, VOXEL_SIZE, stiff, density=1000.0)
    # Clamp the hex-grid nodes within `tol` of the post's back face: the grid
    # need not line up with the surface, so allow up to about half a hex.
    trusty.fem.pin_face(world, post, axis=0, coord=POST_ORIGIN[0], tol=0.06)

    # -- Follower: soft FEM flag reaching out in +x, root at x = 0. ----------
    tm = trusty.make_beam_tet_mesh(size=FLAG_SIZE, res=FLAG_RES)
    V  = np.asarray(tm.vertices).copy()
    # Centre the flag's y/z on the post so its root cap sits inside the cage.
    V[:, 1] += -FLAG_SIZE[1] / 2
    V[:, 2] += (POST_ORIGIN[2] + POST_SIZE[2] / 2) - FLAG_SIZE[2] / 2
    tm   = trusty.make_tet_mesh(V, np.asarray(tm.tets))
    flag = trusty.fem.add_tet_solid(world, tm, material, density=1000.0)

    # Root cap = flag vertices on the x = 0 face; bond them to the post.
    root = np.where(np.abs(V[:, 0]) < 1e-9)[0].tolist()
    trusty.boundary_conditions.stitch(world, flag, root, post)

    print(f"Embedded post + soft FEM flag, {len(root)} root verts stitched.")
    return world, post, flag


def _register_visuals(ps, world, post, flag):
    pV = trusty.embedded.read_embedded_surface(world, post)
    pF = trusty.embedded.surface_triangles(world, post)
    ps_post = ps.register_surface_mesh("embedded_post", np.asarray(pV),
                                       np.asarray(pF), color=(0.55, 0.55, 0.6))
    tm = trusty.fem.read_tet_mesh(world, flag)
    ps_flag = ps.register_volume_mesh(
        "soft_flag", np.asarray(tm.vertices).copy(), tets=np.asarray(tm.tets))
    ps_flag.set_color((0.9, 0.5, 0.2))
    ps_flag.set_edge_width(1.0)
    return ps_post, ps_flag


def _update_visuals(ps_post, ps_flag, world, post, flag):
    ps_post.update_vertex_positions(
        np.asarray(trusty.embedded.read_embedded_surface(world, post)))
    ps_flag.update_vertex_positions(
        np.asarray(trusty.fem.read_tet_mesh(world, flag).vertices))


def run_polyscope(world, post, flag, steps: int):
    ps = init_polyscope(headless=False)
    ps_post, ps_flag = _register_visuals(ps, world, post, flag)
    state = {"i": 0, "playing": False}

    def advance():
        if state["i"] < steps:
            world.step()
            state["i"] += 1
            _update_visuals(ps_post, ps_flag, world, post, flag)

    def callback():
        import polyscope.imgui as psim
        _, 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 run_headless(world, post, flag, steps: int):
    print(f"Running {steps} steps headless...")
    V0   = np.asarray(trusty.fem.read_tet_mesh(world, flag).vertices).copy()
    root = np.where(np.abs(V0[:, 0]) < 1e-9)[0]          # bonded (stitched) verts
    tip  = np.where(np.abs(V0[:, 0] - FLAG_SIZE[0]) < 1e-9)[0]
    for i in range(steps):
        world.step()
        r = world.last_report()
        if not r.converged:
            raise SystemExit(f"step {i}: solve DIVERGED (res={r.final_residual:.2e})")
        V = np.asarray(trusty.fem.read_tet_mesh(world, flag).vertices)
        if not np.isfinite(V).all():
            raise SystemExit(f"step {i}: non-finite flag vertices")
        if (i + 1) % 50 == 0:
            print(f"  step {i + 1:4d}  iters={r.iterations}  "
                  f"res={r.final_residual:.2e}")
    V1        = np.asarray(trusty.fem.read_tet_mesh(world, flag).vertices)
    root_move = np.linalg.norm(V1[root] - V0[root], axis=1).max()
    tip_sag   = (V0[tip, 2] - V1[tip, 2]).mean()
    print(f"Done. tip sagged {tip_sag:.3f} m; bonded root moved {root_move:.4f} m.")
    # The stitch must hold the root to the (stiff, pinned) post while the free
    # portion cantilevers: a free-falling flag would move root and tip alike.
    if tip_sag < 0.05:
        raise SystemExit("flag did not sag โ€” check the setup")
    if root_move > 0.05:
        raise SystemExit("bonded root drifted โ€” the stitch did not hold")


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

    world, post, flag = build_world(backend=args.backend)
    if args.no_viewer:
        run_headless(world, post, flag, args.steps)
    else:
        run_polyscope(world, post, flag, args.steps)


if __name__ == "__main__":
    main()

Spring pins

pin_to_target(world, body, nodes, targets, stiffness) pulls each node toward its own target with a spring, and returns a pin handle. The functions below take that handle. Here a bar's left face is held at rest by a very stiff pin, and its right face is held by a softer one whose targets will move:

V = np.asarray(mesh.vertices)
left_nodes = np.where(V[:, 0] < 1e-9)[0].tolist()
right_nodes = np.where(V[:, 0] > BAR_SIZE[0] - 1e-9)[0].tolist()
left_targets = V[left_nodes].copy()
right_targets_init = V[right_nodes].copy()

trusty.boundary_conditions.pin_to_target(
    world, body, left_nodes, left_targets, stiffness=1e8)
right_pin = trusty.boundary_conditions.pin_to_target(
    world, body, right_nodes, right_targets_init, stiffness=5e6)
Argument Meaning
nodes indices of the pinned nodes: the vertices of a shell or rod, or the nodes of a solid (for an embedded solid, of its hex grid)
targets target positions, (len(nodes), 3)
stiffness spring stiffness in Pa. It is scaled by the volume each node carries, so the same value behaves the same at any mesh resolution.

A body can carry any number of pins. Pins on the same nodes add up.

Moving targets

set_pin_targets(world, pin, targets) replaces the targets, one row per pinned node, from the next step on. Here the right face's targets follow a sine wave:

phase = (2 * np.pi * step) / DRIVE_PERIOD_STEPS
dz = DRIVE_AMP * np.sin(phase)
targets = right_targets_init.copy()
targets[:, 2] += dz

Each step, set the new targets, then step:

targets, dz = prescribed_targets(step, right_targets_init)
trusty.boundary_conditions.set_pin_targets(world, right_pin, targets)
world.step()

The orange points are the right face's targets. At the bottom of the drive some are below the floor; a spring pin gives, so the bar rests on the floor instead of passing through it.

Full script: python examples/boundary_conditions/moving_pin.py
"""Spring pins with moving targets, and a floor in the way.

A soft cantilever bar:
  - its left face is pinned at its rest position with a high stiffness, so it
    acts as a clamp;
  - its right face is pinned to targets that move up and down on a sine wave;
  - a floor below stops the bar. The targets dip below the floor, but a spring
    pin gives, so the bar rests on the floor instead of passing through it.

Usage:
    python examples/boundary_conditions/moving_pin.py              # polyscope
    python examples/boundary_conditions/moving_pin.py --no-viewer  # print the drive
"""

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


BAR_SIZE = (1.0, 0.2, 0.2)
BAR_RES  = (10, 2, 2)
FLOOR_Z  = -0.2
DRIVE_AMP        = 0.27
DRIVE_PERIOD_STEPS = 60


def build_world(backend: str):
    trusty.check_capabilities("contact")

    mesh = trusty.make_beam_hex_mesh(size=BAR_SIZE, res=BAR_RES)
    material = trusty.StableNeoHookean(youngs_modulus=1e5, poisson_ratio=0.3)

    world = trusty.World(backend=backend,
                         timestep=1.0 / 60.0,
                         newton=trusty.NewtonConfig(max_iters=60))
    trusty.contact.enable(world, trusty.contact.Config(dhat=5e-3, kappa=1e4))
    body = trusty.fem.add_hex_solid(world, mesh, material, density=1000.0)

    V = np.asarray(mesh.vertices)
    left_nodes = np.where(V[:, 0] < 1e-9)[0].tolist()
    right_nodes = np.where(V[:, 0] > BAR_SIZE[0] - 1e-9)[0].tolist()
    left_targets = V[left_nodes].copy()
    right_targets_init = V[right_nodes].copy()

    trusty.boundary_conditions.pin_to_target(
        world, body, left_nodes, left_targets, stiffness=1e8)
    right_pin = trusty.boundary_conditions.pin_to_target(
        world, body, right_nodes, right_targets_init, stiffness=5e6)

    trusty.add_floor_plane(world, z=FLOOR_Z)

    return world, body, right_pin, right_nodes, right_targets_init


def prescribed_targets(step: int, right_targets_init: np.ndarray):
    phase = (2 * np.pi * step) / DRIVE_PERIOD_STEPS
    dz = DRIVE_AMP * np.sin(phase)
    targets = right_targets_init.copy()
    targets[:, 2] += dz
    return targets, dz


def drive(world, right_pin, right_targets_init, step: int):
    """Move the right face's targets for this step, then advance."""
    targets, dz = prescribed_targets(step, right_targets_init)
    trusty.boundary_conditions.set_pin_targets(world, right_pin, targets)
    world.step()
    return dz


def _register_visuals(ps, world, body):
    mesh = trusty.fem.read_mesh(world, body)
    verts = np.asarray(mesh.vertices).copy()
    hexes = np.asarray(mesh.hexes)
    ps_mesh = ps.register_volume_mesh("bar", verts, hexes=hexes)
    ps_mesh.set_edge_width(1.0)

    Lx = BAR_SIZE[0]
    floor = np.array(
        [[-0.5, -0.5, FLOOR_Z], [Lx + 0.5, -0.5, FLOOR_Z],
         [Lx + 0.5, 0.5, FLOOR_Z], [-0.5, 0.5, FLOOR_Z]])
    ps.register_surface_mesh(
        "floor", floor, np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int32),
        color=(0.6, 0.6, 0.6))
    return ps_mesh


def run_polyscope(backend: str, n_steps: int):
    ps = init_polyscope(headless=False)
    world, body, right_pin, _, right_targets_init = build_world(backend)
    ps_mesh = _register_visuals(ps, world, body)

    state = {"i": 0, "playing": False}   # play starts OFF

    def callback():
        import polyscope.imgui as psim
        _, state["playing"] = psim.Checkbox("play", state["playing"])
        psim.SameLine()
        if psim.Button("step") or state["playing"]:
            if state["i"] < n_steps:
                drive(world, right_pin, right_targets_init, state["i"])
                state["i"] += 1
                ps_mesh.update_vertex_positions(
                    np.asarray(trusty.fem.read_mesh(world, body).vertices))
        psim.Text(f"step {state['i']} / {n_steps}")
        rep = world.last_report()
        psim.Text(
            f"last solve: iters={rep.iterations}  "
            f"residual={rep.final_residual:.3e}  "
            f"{'converged' if rep.converged else 'DIVERGED'}")

    ps.set_user_callback(callback)
    ps.show()


def run_headless(backend: str, n_steps: int):
    world, body, right_pin, right_nodes, right_targets_init = build_world(backend)
    print(f"{'step':>4}  {'target dz':>9}  {'face dz':>8}  {'lowest z':>8}  pin force z (N)")
    lowest = np.inf
    for i in range(n_steps):
        dz = drive(world, right_pin, right_targets_init, i)
        if not world.last_report().converged:
            raise SystemExit(f"step {i}: the solve did not converge")
        x = np.asarray(trusty.fem.read_positions(world, body))
        lowest = min(lowest, x[:, 2].min())
        if i % 10 == 0:
            face_dz = x[right_nodes, 2].mean() - right_targets_init[:, 2].mean()
            fz = trusty.boundary_conditions.pin_total_force(world, right_pin)[2]
            print(f"{i:4d}  {dz:+9.3f}  {face_dz:+8.3f}  {x[:, 2].min():+8.3f}  {fz:+.1f}")
    print(f"Lowest point reached: z = {lowest:.3f} (floor at z = {FLOOR_Z})")
    if lowest < FLOOR_Z - 1e-3:
        raise SystemExit("the bar passed through the floor")


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--backend",
                    choices=["auto", "cpu", "cuda", "accelerate"], default="auto")
    ap.add_argument("--steps", type=int, default=120)
    ap.add_argument("--no-viewer", action="store_true",
                    help="headless: print the drive and the pin force every 10 steps")
    args = ap.parse_args()

    if args.no_viewer:
        run_headless(args.backend, args.steps)
    else:
        run_polyscope(args.backend, args.steps)


if __name__ == "__main__":
    main()

Stiffness and reaction forces

A spring pin stretches under load: the pinned nodes settle away from their targets by about force / (stiffness ร— V), where V is the volume the pinned nodes carry. Here a 16 kg block hangs from a pin on its top face:

rest = np.asarray(mesh.vertices)
top = np.flatnonzero(rest[:, 2] > SIZE[2] - 1e-9)  # the top face's nodes
pin = trusty.boundary_conditions.pin_to_target(
    world, body, top.tolist(), rest[top], stiffness=STIFFNESSES[0])

set_pin_stiffness(world, pin, stiffness) changes the stiffness from the next step on. The example raises it tenfold twice, solving for the hanging block each time:

trusty.boundary_conditions.set_pin_stiffness(world, pin, stiffness)
world.step()

pin_forces returns the force on each pinned node from the last step, and pin_total_force returns their sum:

forces = trusty.boundary_conditions.pin_forces(world, pin)  # one row per pinned node
total = trusty.boundary_conditions.pin_total_force(world, pin)  # their sum (fx, fy, fz)

Each row is the pull of the body on its target, stiffness ร— V ร— (x โˆ’ target). The pin pushes back on the body with the opposite force. For the hanging block the total is the block's weight, pointing down, at every stiffness:

Stiffness (Pa) Gap below the targets (m) pin_total_force z (N)
1e6 0.157 โˆ’156.96
1e7 0.0157 โˆ’156.96
1e8 0.00157 โˆ’156.96

Three hanging blocks, one per stiffness, each below its targets

From left to right: stiffness 1e6, 1e7 and 1e8 Pa. The orange points are the targets, and the arrows show the direction each pinned node pulls on its target.

Function Does
pin_to_target(world, body, nodes, targets, stiffness) pin nodes to targets; returns the pin
set_pin_targets(world, pin, targets) move the targets, (len(nodes), 3)
set_pin_stiffness(world, pin, stiffness) change the stiffness (Pa)
pin_nodes(world, pin) the pinned nodes
pin_targets(world, pin) the current targets, (len(nodes), 3)
pin_stiffness(world, pin) the current stiffness
pin_forces(world, pin) the force per pinned node (N) from the last step, (len(nodes), 3); zero before the first step
pin_total_force(world, pin) the sum of pin_forces, as (fx, fy, fz)
num_pins(world, body) how many pins the body carries
Full script: python examples/boundary_conditions/hanging_block.py
"""A block hung from a soft pin: pin stiffness and the pin's reaction force.

A 16 kg block hangs from its top face, which is held by a spring pin
(`pin_to_target`) at its rest position. A spring pin gives a little under load:
the pinned face settles below its targets by about weight / (k * V), where k is
the pin stiffness (Pa) and V the volume the pinned nodes carry. Each step
solves for the hanging equilibrium, so the example raises the stiffness with
`set_pin_stiffness` and re-solves: the gap shrinks tenfold each time, while the
pin's reaction force (`pin_total_force`) stays equal to the block's weight.

Usage:
    python examples/boundary_conditions/hanging_block.py              # live polyscope
    python examples/boundary_conditions/hanging_block.py --no-viewer  # print the table
"""

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

SIZE = (0.2, 0.2, 0.4)         # m; the block hangs from its top face at z = 0.4
RES = (4, 4, 8)
DENSITY = 1000.0               # kg/m^3, so the block weighs 16 kg
STIFFNESSES = (1e6, 1e7, 1e8)  # Pa; the pin is stiffened through these


def build_world(backend: str = "auto"):
    world = trusty.World(backend=backend,
                         time_stepping="static")  # every step solves for the equilibrium

    mesh = trusty.make_beam_hex_mesh(size=SIZE, res=RES)
    material = trusty.StableNeoHookean(youngs_modulus=1e6, poisson_ratio=0.4)
    body = trusty.fem.add_hex_solid(world, mesh, material, density=DENSITY)

    rest = np.asarray(mesh.vertices)
    top = np.flatnonzero(rest[:, 2] > SIZE[2] - 1e-9)  # the top face's nodes
    pin = trusty.boundary_conditions.pin_to_target(
        world, body, top.tolist(), rest[top], stiffness=STIFFNESSES[0])
    return world, body, pin, mesh, top


def hang(world, pin, stiffness: float):
    """Set the pin's stiffness and solve for the new equilibrium."""
    trusty.boundary_conditions.set_pin_stiffness(world, pin, stiffness)
    world.step()


def measure(world, body, pin, top):
    """The pin's gap below its targets (m), and its reaction force (N)."""
    x = np.asarray(trusty.fem.read_positions(world, body))
    targets = trusty.boundary_conditions.pin_targets(world, pin)
    gap = float(np.mean(targets[:, 2] - x[top, 2]))
    forces = trusty.boundary_conditions.pin_forces(world, pin)  # one row per pinned node
    total = trusty.boundary_conditions.pin_total_force(world, pin)  # their sum (fx, fy, fz)
    return gap, forces, np.asarray(total)


def weight():
    return DENSITY * float(np.prod(SIZE)) * 9.81


def run_headless(world, body, pin, top):
    print(f"block weight: {weight():.2f} N")
    print(f"{'stiffness (Pa)':>15}  {'gap (m)':>9}  {'reaction fz (N)':>15}")
    for k in STIFFNESSES:
        hang(world, pin, k)
        if not world.last_report().converged:
            raise SystemExit(f"k={k:.0e}: the solve did not converge")
        gap, _, total = measure(world, body, pin, top)
        print(f"{k:15.0e}  {gap:9.2e}  {total[2]:15.2f}")
        if abs(-total[2] - weight()) > 1e-6 * weight():
            raise SystemExit("the reaction force does not balance the weight")
    print("OK: the reaction balances the weight; a stiffer pin sags less.")


def run_polyscope(world, body, pin, mesh, top):
    ps = init_polyscope(headless=False)
    m = ps.register_volume_mesh("block", trusty.fem.read_positions(world, body),
                                hexes=np.asarray(mesh.hexes))
    m.set_edge_width(1.0)
    ps.register_point_cloud("targets", trusty.boundary_conditions.pin_targets(world, pin),
                            radius=0.006)
    state = {"log_k": np.log10(STIFFNESSES[0])}

    def refresh():
        hang(world, pin, 10.0 ** state["log_k"])
        x = np.asarray(trusty.fem.read_positions(world, body))
        m.update_vertex_positions(x)
        _, forces, _ = measure(world, body, pin, top)
        nodes = ps.register_point_cloud("pinned nodes", x[top], radius=0.004)
        nodes.add_vector_quantity("pin force", forces, enabled=True)

    refresh()

    def callback():
        import polyscope.imgui as psim
        changed, state["log_k"] = psim.SliderFloat("log10 stiffness (Pa)", state["log_k"],
                                                   5.5, 9.0)
        if changed:
            refresh()
        gap, _, total = measure(world, body, pin, top)
        psim.Text(f"gap below targets {gap:.2e} m")
        psim.Text(f"reaction fz {total[2]:.2f} N   (weight {weight():.2f} N)")

    ps.set_user_callback(callback)
    ps.show()


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--backend", choices=["auto", "cpu", "cuda", "accelerate"], default="auto")
    ap.add_argument("--no-viewer", action="store_true",
                    help="headless: step through the stiffnesses and print the table")
    args = ap.parse_args()

    world, body, pin, mesh, top = build_world(args.backend)
    if args.no_viewer:
        run_headless(world, body, pin, top)
    else:
        run_polyscope(world, body, pin, mesh, top)


if __name__ == "__main__":
    main()

Surface loads

attach_surface_load(world, body, load_faces, tractions) applies a traction (force per unit area, Pa) to chosen triangles of a body's surface and returns a handle to the load. It is a dead load: it keeps its direction in the world and acts on the triangles' rest area, so a traction t on an area A always pulls with t ร— A. In the quick example that is 1e5 Pa on 0.01 mยฒ, or 1000 N.

load_faces are indices into the body's surface triangles:

Body Triangles from
solid trusty.fem.read_surface_triangles(world, body)
embedded solid trusty.embedded.surface_triangles(world, body), the input surface
affine body trusty.affine.surface(world, body), its second array

The quick example picks the triangles on the bar's end:

tris = np.asarray(trusty.fem.read_surface_triangles(world, body))
verts = np.asarray(trusty.fem.read_positions(world, body))
on_plane = np.all(np.abs(verts[tris, 0] - x) < 1e-9, axis=1)
return np.flatnonzero(on_plane).tolist()

tractions has one row per face in load_faces, so the load can vary across the surface. set_tractions(world, load, tractions) replaces all of them on the handle from the next step on. The example ramps the load up this way, so each solve starts close to the last:

n = trusty.boundary_conditions.num_loaded_faces(world, load)
t = np.tile([traction_at(step, steps), 0.0, 0.0], (n, 1))
trusty.boundary_conditions.set_tractions(world, load, t)
world.step()

A body can carry several surface loads, each with its own handle, so loads on different faces can be driven independently. Shells and rods don't take surface loads.

Function Does
attach_surface_load(world, body, load_faces, tractions) load surface triangles with tractions, (len(load_faces), 3), Pa; returns the load handle
set_tractions(world, load, tractions) replace the load's tractions
tractions(world, load) the load's current tractions
num_loaded_faces(world, load) how many faces the load covers

Joining bodies

To join two bodies, bond some vertices of one (the follower) to the other (the anchor). Each bonded vertex is tied to the point of the anchor where it sits at rest. There are two kinds of bond: stitch is exact and attach is a spring.

Stitching

stitch(world, follower, verts, anchor) makes the follower's verts move exactly with the anchor, as if they were points of it. Here a soft beam is stitched to the tip of a hinged affine bar:

# Root cap = the beam vertices on the x = 1 face; bond them to the bar.
root = np.where(np.abs(V[:, 0] - BAR_LEN) < 1e-9)[0].tolist()
trusty.boundary_conditions.stitch(world, beam, root, bar)

A soft beam hanging from the tip of a swinging bar

The orange points are the stitched vertices. Released level, the bar swings down and the beam whips behind it.

The follower is a solid, a shell or a rod. The anchor is an embedded solid or an affine body. The follower and its anchor don't collide with each other. There are two more examples: fem_embedded_flag.py stitches a solid to an embedded cube (the clamp image), and shell_affine_flag.py stitches a shell to an affine bar.

Full script: python examples/boundary_conditions/fem_affine_pendulum.py
"""Soft FEM beam stitched to a swinging affine bar.

A near-rigid affine bar spans x in [0, 1] and is hinged to the world at its
left end by a grounded revolute joint. A soft FEM beam extends the arm from
x = 1 to x = 2; its root cap (the x = 1 face) is *stitched* onto the affine
bar. The stitched vertices stop being unknowns of their own: they move exactly
with the bar, as if they were points of it. Released horizontal, the bar swings
down like a pendulum and the bonded soft beam whips and sags behind it.

Usage:
    python examples/boundary_conditions/fem_affine_pendulum.py                # polyscope
    python examples/boundary_conditions/fem_affine_pendulum.py --no-viewer    # headless
    python examples/boundary_conditions/fem_affine_pendulum.py --steps 400
"""

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

BAR_LEN   = 1.0     # affine bar length (x)
BAR_HALF  = 0.1     # bar half-thickness (y, z)
Z0        = 2.0     # start height so the arm can swing freely
BEAM_LEN  = 1.0     # soft FEM beam length (x), continuing past the bar tip
BEAM_RES  = (10, 2, 2)


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_world(backend: str = "auto"):
    trusty.check_capabilities("affine")

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

    # -- Anchor: near-rigid affine bar, hinged to the world at x = 0. -----
    bar_V, bar_F = make_box((BAR_LEN / 2, 0.0, Z0), (BAR_LEN / 2, BAR_HALF, BAR_HALF))
    bar = trusty.affine.add_affine_body(
        world, bar_V, bar_F, density=1000.0, stiffness=1e8)
    # Grounded revolute hinge along y at the left end (x = 0): the two points
    # define the hinge axis.
    trusty.affine.add_revolute_joint(
        world, bar, (0.0, -BAR_HALF, Z0), (0.0, BAR_HALF, Z0), stiffness=1e8)

    # -- Follower: soft FEM beam continuing the arm from x = 1 to x = 2. -----
    # Match the bar's 0.2 x 0.2 cross-section so the two align at the tip.
    tm   = trusty.make_beam_tet_mesh(
        size=(BEAM_LEN, 2 * BAR_HALF, 2 * BAR_HALF), res=BEAM_RES)
    V    = np.asarray(tm.vertices).copy()
    # Translate so the root face sits on the bar tip (x = 1), centred in y/z.
    V[:, 0] += BAR_LEN
    V[:, 1] += -BAR_HALF
    V[:, 2] += Z0 - BAR_HALF
    tm    = trusty.make_tet_mesh(V, np.asarray(tm.tets))
    soft  = trusty.StableNeoHookean(youngs_modulus=5e4, poisson_ratio=0.4)
    beam  = trusty.fem.add_tet_solid(world, tm, soft, density=1000.0)

    # Root cap = the beam vertices on the x = 1 face; bond them to the bar.
    root = np.where(np.abs(V[:, 0] - BAR_LEN) < 1e-9)[0].tolist()
    trusty.boundary_conditions.stitch(world, beam, root, bar)

    print(f"Affine bar + soft FEM beam, {len(root)} root verts stitched.")
    return world, bar, beam


def _register_visuals(ps, world, bar, beam):
    bar_V, bar_F = trusty.affine.surface(world, bar)
    ps_bar = ps.register_surface_mesh("affine_bar", np.asarray(bar_V),
                                      np.asarray(bar_F), color=(0.55, 0.55, 0.6))
    tm = trusty.fem.read_tet_mesh(world, beam)
    ps_beam = ps.register_volume_mesh(
        "soft_beam", np.asarray(tm.vertices).copy(), tets=np.asarray(tm.tets))
    ps_beam.set_color((0.9, 0.5, 0.2))
    ps_beam.set_edge_width(1.0)
    return ps_bar, ps_beam


def _update_visuals(ps_bar, ps_beam, world, bar, beam):
    ps_bar.update_vertex_positions(
        np.asarray(trusty.affine.surface(world, bar)[0]))
    ps_beam.update_vertex_positions(
        np.asarray(trusty.fem.read_tet_mesh(world, beam).vertices))


def run_polyscope(world, bar, beam, steps: int):
    ps = init_polyscope(headless=False)
    ps_bar, ps_beam = _register_visuals(ps, world, bar, beam)
    state = {"i": 0, "playing": False}

    def advance():
        if state["i"] < steps:
            world.step()
            state["i"] += 1
            _update_visuals(ps_bar, ps_beam, world, bar, beam)

    def callback():
        import polyscope.imgui as psim
        _, 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 _root_cap_rigidity(V, root):
    """Max relative change in the root cap's pairwise distances vs rest.

    The bonded root is the image of a near-rigid affine body, so its shape must
    be preserved; a free (unbonded) soft cap would stretch under the swing."""
    P = V[root]
    d = np.linalg.norm(P[:, None, :] - P[None, :, :], axis=-1)
    iu = np.triu_indices(len(root), k=1)
    return d[iu]


def run_headless(world, bar, beam, steps: int):
    print(f"Running {steps} steps headless...")
    V0    = np.asarray(trusty.fem.read_tet_mesh(world, beam).vertices).copy()
    root  = np.where(np.abs(V0[:, 0] - BAR_LEN) < 1e-9)[0]   # bonded verts
    rest0 = _root_cap_rigidity(V0, root)
    for i in range(steps):
        world.step()
        r = world.last_report()
        if not r.converged:
            raise SystemExit(f"step {i}: solve DIVERGED (res={r.final_residual:.2e})")
        V = np.asarray(trusty.fem.read_tet_mesh(world, beam).vertices)
        if not np.isfinite(V).all():
            raise SystemExit(f"step {i}: non-finite beam vertices")
        if (i + 1) % 50 == 0:
            print(f"  step {i + 1:4d}  iters={r.iterations}  "
                  f"res={r.final_residual:.2e}")
    V1     = np.asarray(trusty.fem.read_tet_mesh(world, beam).vertices)
    z_tip  = V1[:, 2].min()
    strain = np.abs(_root_cap_rigidity(V1, root) - rest0).max() / rest0.max()
    print(f"Done. beam tip z dropped to {z_tip:.3f} (start ~{Z0:.3f}); "
          f"bonded root cap strain {strain:.1e}.")
    if z_tip > Z0 - 0.1:
        raise SystemExit("beam did not swing/sag โ€” check the setup")
    if strain > 0.02:
        raise SystemExit("bonded root cap deformed โ€” the stitch did not hold")


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

    world, bar, beam = build_world(backend=args.backend)
    if args.no_viewer:
        run_headless(world, bar, beam, args.steps)
    else:
        run_polyscope(world, bar, beam, args.steps)


if __name__ == "__main__":
    main()

Attaching

attach(world, follower, follower_verts, anchor, stiffness=1e6) ties each vertex to the anchor with a spring. It is how a reduced solid is joined to other bodies, since a reduced solid can't be stitched. This is the same pendulum, with a reduced beam:

# Root = the first slice of beam nodes, which overlaps the bar's tip.
root = np.flatnonzero(V[:, 0] < BAR_LEN + 1e-9)
trusty.boundary_conditions.attach(world, beam, root.tolist(), bar, stiffness=stiffness)

A reduced beam attached to the tip of a swinging bar

stiffness is per vertex, in N/m. The spring stretches with the load, so the attached vertices trail the bar slightly. Over the swing, the largest gap is:

--stiffness (N/m) Largest gap (m)
1e4 2.6e-2
1e6 (default) 5.0e-4
1e8 5.0e-6

The follower is a reduced solid. The anchor is an embedded solid or an affine body.

Full script: python examples/boundary_conditions/attach_pendulum.py
"""Soft reduced beam attached to a swinging affine bar by a spring.

The `attach` twin of fem_affine_pendulum.py. A near-rigid affine bar spans
x in [0, 1] and is hinged to the world at its left end. A soft beam continues
the arm from x = 1 to x = 2; it is a reduced (subspace) body, so instead of
being stitched it is *attached*: its root nodes are tied to the bar by stiff
springs. Released horizontal, the bar swings down and the beam whips behind it.

A spring gives a little under load. The example reports how far the attached
nodes drift from the points of the bar they are tied to; raise `--stiffness`
and the gap shrinks in proportion.

Usage:
    python examples/boundary_conditions/attach_pendulum.py                # polyscope
    python examples/boundary_conditions/attach_pendulum.py --no-viewer    # headless
    python examples/boundary_conditions/attach_pendulum.py --no-viewer --stiffness 1e4
"""

from __future__ import annotations

import argparse
import sys
import tempfile
from pathlib import Path

import numpy as np

import trusty
from trusty.subspace.precompute import build_basis, pack

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

BAR_LEN   = 1.0     # affine bar length (x)
BAR_HALF  = 0.1     # bar half-thickness (y, z)
Z0        = 2.0     # start height so the arm can swing freely
BEAM_LEN  = 1.0     # soft beam length (x), continuing past the bar tip
BEAM_RES  = (10, 2, 2)
HANDLES   = 6       # size of the beam's reduced basis


def make_beam_basis(mesh, material):
    """A reduced basis for the beam (see the subspace examples)."""
    rest = np.asarray(mesh.vertices)
    hexes = np.asarray(mesh.hexes)
    precompute = trusty.World()
    trusty.fem.add_hex_solid(precompute, mesh, material, density=1000.0)
    basis = build_basis.build_skinning_eigenmodes(
        world=precompute, num_nodes=len(rest), rest_positions=rest,
        num_handles=HANDLES, mesh_hash=build_basis.mesh_hash_sha256(rest, hexes))
    path = Path(tempfile.mkdtemp()) / "beam.basis"
    pack.save(path, basis)
    return trusty.subspace.load(str(path))


def build_world(stiffness: float, backend: str = "cpu"):
    trusty.check_capabilities("affine", "subspace")

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

    # -- Anchor: near-rigid affine bar, hinged to the world at x = 0. -----
    bar_V, bar_F = make_bar_trimesh((BAR_LEN, 2 * BAR_HALF, 2 * BAR_HALF), res=(1, 1, 1),
                                    origin=(0.0, -BAR_HALF, Z0 - BAR_HALF))
    bar = trusty.affine.add_affine_body(world, bar_V, bar_F, density=1000.0, stiffness=1e8)
    trusty.affine.add_revolute_joint(
        world, bar, (0.0, -BAR_HALF, Z0), (0.0, BAR_HALF, Z0), stiffness=1e8)

    # -- Follower: soft reduced beam continuing the arm from x = 1 to x = 2. --
    m = trusty.make_beam_hex_mesh(size=(BEAM_LEN, 2 * BAR_HALF, 2 * BAR_HALF), res=BEAM_RES)
    V = np.asarray(m.vertices) + (BAR_LEN, -BAR_HALF, Z0 - BAR_HALF)
    mesh = trusty.make_hex_mesh(np.ascontiguousarray(V), np.asarray(m.hexes))
    soft = trusty.StableNeoHookean(youngs_modulus=5e4, poisson_ratio=0.4)
    beam = trusty.subspace.add_hex_body(world, mesh, soft, density=1000.0,
                                        basis=make_beam_basis(mesh, soft))

    # Root = the first slice of beam nodes, which overlaps the bar's tip.
    root = np.flatnonzero(V[:, 0] < BAR_LEN + 1e-9)
    trusty.boundary_conditions.attach(world, beam, root.tolist(), bar, stiffness=stiffness)

    print(f"Affine bar + soft reduced beam, {len(root)} root nodes attached "
          f"(stiffness {stiffness:.0e}).")
    return world, bar, beam, mesh, root


def bond_gap(world, bar, bar_rest, beam_rest, beam, root):
    """Largest distance between an attached node and the bar point it is tied to."""
    bar_now = np.asarray(trusty.affine.surface(world, bar)[0])
    # The bar moves affinely: fit x_now = A x_rest + p to its surface vertices.
    X = np.hstack([bar_rest, np.ones((len(bar_rest), 1))])
    Ap, *_ = np.linalg.lstsq(X, bar_now, rcond=None)
    anchor = np.hstack([beam_rest[root], np.ones((len(root), 1))]) @ Ap
    x = np.asarray(trusty.subspace.deformed_positions(world, body=beam))
    return float(np.linalg.norm(x[root] - anchor, axis=1).max())


def run_polyscope(world, bar, beam, mesh, steps: int):
    ps = init_polyscope(headless=False)
    bar_V, bar_F = trusty.affine.surface(world, bar)
    ps_bar = ps.register_surface_mesh("affine_bar", np.asarray(bar_V), np.asarray(bar_F),
                                      color=(0.55, 0.55, 0.6))
    ps_beam = ps.register_volume_mesh("soft_beam", np.asarray(mesh.vertices).copy(),
                                      hexes=np.asarray(mesh.hexes))
    ps_beam.set_color((0.9, 0.5, 0.2))
    ps_beam.set_edge_width(1.0)
    state = {"i": 0, "playing": False}

    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_bar.update_vertex_positions(np.asarray(trusty.affine.surface(world, bar)[0]))
            ps_beam.update_vertex_positions(
                np.asarray(trusty.subspace.deformed_positions(world, body=beam)))
        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 run_headless(world, bar, beam, mesh, root, steps: int):
    print(f"Running {steps} steps headless...")
    bar_rest = np.asarray(trusty.affine.surface(world, bar)[0]).copy()
    beam_rest = np.asarray(mesh.vertices)
    worst = 0.0
    for i in range(steps):
        world.step()
        r = world.last_report()
        if not r.converged:
            raise SystemExit(f"step {i}: solve DIVERGED (res={r.final_residual:.2e})")
        worst = max(worst, bond_gap(world, bar, bar_rest, beam_rest, beam, root))
        if (i + 1) % 50 == 0:
            print(f"  step {i + 1:4d}  iters={r.iterations}  res={r.final_residual:.2e}")
    x = np.asarray(trusty.subspace.deformed_positions(world, body=beam))
    print(f"Done. beam tip z dropped to {x[:, 2].min():.3f} (start ~{Z0:.3f}); "
          f"largest bond gap {worst:.1e} m.")
    if x[:, 2].min() > Z0 - 0.1:
        raise SystemExit("beam did not swing/sag -- check the setup")


def main():
    p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    # Defaults to cpu: the stitch is not yet supported on the accelerate
    # backend, so 'auto' on a Mac raises.
    p.add_argument("--backend", choices=["auto", "cpu", "accelerate"], default="cpu")
    p.add_argument("--steps", type=int, default=300)
    p.add_argument("--stiffness", type=float, default=1e6,
                   help="spring stiffness of each attached node (N/m)")
    p.add_argument("--no-viewer", action="store_true")
    args = p.parse_args()

    world, bar, beam, mesh, root = build_world(args.stiffness, backend=args.backend)
    if args.no_viewer:
        run_headless(world, bar, beam, mesh, root, args.steps)
    else:
        run_polyscope(world, bar, beam, mesh, args.steps)


if __name__ == "__main__":
    main()

Which to use

stitch attach
Bond exact a spring, which stretches with the load
Follower solid, shell or rod reduced solid
Anchor embedded solid or affine body embedded solid or affine body
Tuning none stiffness

The follower's type decides: stitch solids, shells and rods, and attach reduced solids.

Reference

Every function and argument: trusty.boundary_conditions reference. pin_face is in the trusty.fem reference.