Skip to content

Solids

trusty.fem simulates deformable volumetric solids: a mesh, a material and a density are all a body needs.

Capability: fem (included in every build).

Quick example

A soft beam clamped at one end, sagging under gravity:

mesh = trusty.make_beam_hex_mesh(size=(1.0, 0.1, 0.1), res=(20, 2, 2))
material = trusty.StableNeoHookean(youngs_modulus=1e6, poisson_ratio=0.3)

world = trusty.World(backend=backend, timestep=1.0 / 60.0)
beam = trusty.fem.add_hex_solid(world, mesh, material, density=1000.0, order=order)
trusty.fem.pin_face(world, beam, axis=0, coord=0.0)  # clamp the x = 0 end

Each world.step() advances one timestep, and trusty.fem.read_positions(world, beam) returns the deformed nodes as an (N, 3) NumPy array.

Cantilever beam sagging under gravity

add_hex_solid (or add_tet_solid for tet meshes) adds the body and returns its id, which every other trusty.fem function takes.

Argument Meaning
mesh a HexMesh or TetMesh; see Mesh types
material a material model and its parameters; see Materials
density kg/m³
order ElementOrder.Linear (default) or a quadratic order; see Element order

The clamp comes from pin_face. Pins, moving targets and loads are covered under Boundary conditions.

Full script: python examples/cantilever_beam.py
"""Cantilevered hex beam with Stable Neo-Hookean elasticity and gravity.

Steps the simulator and, unless
``--no-viewer`` is passed, shows the deforming beam in polyscope.

Usage:
    python examples/cantilever_beam.py                # live polyscope
    python examples/cantilever_beam.py --no-viewer    # write PNG sequence
    python examples/cantilever_beam.py --steps 240    # run longer
    python examples/cantilever_beam.py --order q2     # quadratic (Q2) elements

Install polyscope with ``pip install polyscope``.
"""

from __future__ import annotations

import argparse
from pathlib import Path

import numpy as np

import trusty
from utils import init_polyscope

ORDER = trusty.fem.ElementOrder
# CLI order name -> hex element order (Q1/Q2 Lagrange/Q2 serendipity).
HEX_ORDERS = {
    "linear": ORDER.Linear,
    "q2":     ORDER.Quadratic,
    "q2s":    ORDER.QuadraticSerendipity,
}


def build_world(backend: str = "auto", order=ORDER.Linear):
    mesh = trusty.make_beam_hex_mesh(size=(1.0, 0.1, 0.1), res=(20, 2, 2))
    material = trusty.StableNeoHookean(youngs_modulus=1e6, poisson_ratio=0.3)

    world = trusty.World(backend=backend, timestep=1.0 / 60.0)
    beam = trusty.fem.add_hex_solid(world, mesh, material, density=1000.0, order=order)
    trusty.fem.pin_face(world, beam, axis=0, coord=0.0)  # clamp the x = 0 end

    return world, beam


def _register_visuals(ps, world, beam, order):
    # Linear elements render as a hex volume mesh; quadratic elements render
    # via the linear sub-triangle tessellation of their curved surface.
    if order == ORDER.Linear:
        mesh = trusty.fem.read_mesh(world, beam)
        ps_mesh = ps.register_volume_mesh(
            "beam", np.asarray(mesh.vertices).copy(), hexes=np.asarray(mesh.hexes))
        ps_mesh.set_edge_width(1.0)
        read = lambda: np.asarray(trusty.fem.read_mesh(world, beam).vertices)
    else:
        V = np.asarray(trusty.fem.read_positions(world, beam)).copy()
        F = np.asarray(trusty.fem.read_surface_triangles(world, beam))
        ps_mesh = ps.register_surface_mesh("beam", V, F)
        read = lambda: np.asarray(trusty.fem.read_positions(world, beam))
    return ps_mesh, read


def run_polyscope(world, beam, steps: int, order):
    ps = init_polyscope(headless=False)
    ps_mesh, read_verts = _register_visuals(ps, world, beam, order)

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

    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"] < steps:
                world.step()
                state["i"] += 1
                ps_mesh.update_vertex_positions(read_verts())
        psim.Text(f"step {state['i']} / {steps}")
        report = world.last_report()
        psim.Text(
            f"last solve: iters={report.iterations}  "
            f"residual={report.final_residual:.3e}  "
            f"{'converged' if report.converged else 'DIVERGED'}"
        )

    ps.set_user_callback(callback)
    ps.show()


def run_screenshots(world, beam, steps: int, out_dir: Path, order):
    ps = init_polyscope(headless=True)
    out_dir.mkdir(parents=True, exist_ok=True)
    ps_mesh, read_verts = _register_visuals(ps, world, beam, order)
    ps.reset_camera_to_home_view()

    def snapshot(idx: int):
        ps.screenshot(str(out_dir / f"beam_{idx:04d}.png"), transparent_bg=False)

    snapshot(0)
    for i in range(1, steps + 1):
        world.step()
        ps_mesh.update_vertex_positions(read_verts())
        snapshot(i)

    report = world.last_report()
    print(f"Wrote {steps + 1} screenshots to {out_dir}/")
    print(f"Last solve: iters={report.iterations}  "
          f"residual={report.final_residual:.3e}  "
          f"{'converged' if report.converged else 'DIVERGED'}")


def main():
    parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    parser.add_argument("--backend",
                        choices=["auto", "cpu", "cuda", "accelerate"], default="auto")
    parser.add_argument("--order", choices=list(HEX_ORDERS), default="linear",
                        help="element order: q1 linear (default), q2 Lagrange, q2s serendipity")
    parser.add_argument("--steps", type=int, default=120,
                        help="total simulation steps (default: 120 = 2 s at 1/60)")
    parser.add_argument("--no-viewer", action="store_true",
                        help="headless: write PNG screenshots instead of showing polyscope")
    parser.add_argument("--out", type=Path, default=Path("out"),
                        help="output directory for --no-viewer mode")
    args = parser.parse_args()

    order = HEX_ORDERS[args.order]
    world, beam = build_world(backend=args.backend, order=order)

    if args.no_viewer:
        run_screenshots(world, beam, args.steps, args.out, order)
    else:
        run_polyscope(world, beam, args.steps, order)


if __name__ == "__main__":
    main()

The world

trusty.World takes its settings as keyword arguments, all optional. They are fixed for the world's life, and apply to every body in it:

Argument Meaning
backend "auto" (default): the fastest backend every body type in the world supports, accelerate then cuda then cpu. Or "cpu", "accelerate", "cuda".
timestep seconds per world.step() (default 1/60)
gravity (x, y, z), m/s² (default (0, 0, -9.81))
time_stepping how each step is solved; see below
newton a trusty.NewtonConfig(max_iters=..., tolerance=...) for the solver (default 20 iterations)
time_stepping Each step
"backward_euler" (default) moves the bodies forward in time, damping motion slightly
"bdf2" the same, second order: less damping, so swinging and bouncing last longer
"quasi_static" moves forward, then drops every velocity: bodies head for rest without swinging
"static" finds the equilibrium under the current loads, with no inertia

trusty.TimeStepping.BDF2 and the other enum values work too. Contact is turned on separately; see Contact.

Materials

The material is one line. Build a different model to change how the solid responds:

material = trusty.StableNeoHookean(youngs_modulus=1e6, poisson_ratio=0.3)

Every model takes Young's modulus (Pa) and Poisson's ratio.

Model Behaviour
StableNeoHookean Neo-Hookean that stays robust under large compression and inversion. A good default.
NeohookeanBW The classical (Bonet & Wood) Neo-Hookean.
Corotational Linear elasticity in a rotating frame: cheap, accurate at small strain, stiffer when stretched far.
ARAP As-rigid-as-possible: resists shape change only and does not preserve volume.
StableFung StableNeoHookean that stiffens exponentially with stretch, like soft tissue. Extra parameters mu1 (Pa) and gamma.
QuadraticVolume Resists volume change only, with no shear stiffness.

Five columns with the same stiffness, hung from their tops, stretch by different amounts under their own weight. From left to right: StableNeoHookean, NeohookeanBW, Corotational, ARAP, StableFung.

Five hanging columns, one per material

Full script: python examples/fem/hanging_materials.py
"""One hanging column per material model, stretching under its own weight.

Five soft columns (E = 10 kPa, nu = 0.45) share a mesh and density and hang
from their top faces. At these large strains the models part ways:
``StableFung`` stiffens as it stretches and stays shortest, ``Corotational``
is next, and the neo-Hookean family (``StableNeoHookean``, ``NeohookeanBW``)
and ``ARAP`` stretch furthest. A timestep of 1 s with the velocity zeroed after
every step moves each column straight to its hanging equilibrium, so
``--steps 10`` is already settled.

Usage:
    python examples/fem/hanging_materials.py              # live polyscope
    python examples/fem/hanging_materials.py --no-viewer  # print lengths
"""

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

E, NU = 1e4, 0.45       # soft, so the columns stretch visibly
LENGTH = 1.0            # rest length (m); the columns hang from z = LENGTH
SPACING = 0.25          # x offset between columns (m)
COLORS = [(0.94, 0.33, 0.31), (0.98, 0.70, 0.30), (0.35, 0.75, 0.70),
          (0.45, 0.60, 0.90), (0.70, 0.50, 0.85)]


def make_materials():
    return {
        "StableNeoHookean": trusty.StableNeoHookean(youngs_modulus=E, poisson_ratio=NU),
        "NeohookeanBW":     trusty.NeohookeanBW(youngs_modulus=E, poisson_ratio=NU),
        "Corotational":     trusty.Corotational(youngs_modulus=E, poisson_ratio=NU),
        "ARAP":             trusty.ARAP(youngs_modulus=E, poisson_ratio=NU),
        "StableFung":       trusty.StableFung(youngs_modulus=E, poisson_ratio=NU,
                                              mu1=2e3, gamma=4.0),
    }


def build_world(backend: str = "auto"):
    world = trusty.World(backend=backend,
                         timestep=1.0,                  # large steps with no carried-over
                         time_stepping="quasi_static")  # velocity: each one heads for equilibrium

    columns = {}
    for k, (name, material) in enumerate(make_materials().items()):
        mesh = trusty.make_beam_hex_mesh(size=(0.1, 0.1, LENGTH), res=(4, 4, 32))
        mesh = trusty.transform_mesh(mesh, t=(SPACING * k, 0.0, 0.0))
        body = trusty.fem.add_hex_solid(world, mesh, material, density=1000.0)
        trusty.fem.pin_face(world, body, axis=2, coord=LENGTH)  # hang from the top
        columns[name] = body
    return world, columns


def hanging_length(world, body) -> float:
    z = np.asarray(trusty.fem.read_positions(world, body))[:, 2]
    return float(LENGTH - z.min())


def run_viewer(world, columns, steps: int):
    ps = init_polyscope(headless=False)
    meshes = {}
    for k, (name, body) in enumerate(columns.items()):
        m = ps.register_surface_mesh(name, trusty.fem.read_positions(world, body),
                                     trusty.fem.read_surface_triangles(world, body))
        m.set_color(COLORS[k])
        meshes[name] = (m, body)
    ps.reset_camera_to_home_view()

    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
            for m, body in meshes.values():
                m.update_vertex_positions(trusty.fem.read_positions(world, body))
        psim.Text(f"step {state['i']} / {steps}")
        for name, (_, body) in meshes.items():
            psim.Text(f"{name:<17} {hanging_length(world, body):.3f} m")

    ps.set_user_callback(callback)
    ps.show()


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--steps", type=int, default=10)
    ap.add_argument("--backend", default="auto", choices=["auto", "cpu", "accelerate", "cuda"])
    ap.add_argument("--no-viewer", action="store_true",
                    help="headless: print each column's hanging length")
    args = ap.parse_args()

    world, columns = build_world(args.backend)
    if args.no_viewer:
        for _ in range(args.steps):
            world.step()
        print(f"hanging length after {args.steps} steps (rest length {LENGTH} m)")
        for name, body in columns.items():
            print(f"  {name:<17} {hanging_length(world, body):.3f} m")
        return
    run_viewer(world, columns, args.steps)


if __name__ == "__main__":
    main()

Graded materials

A material can vary across a body. Instead of youngs_modulus and poisson_ratio, pass a grid of parameters laid over the body's rest space: LameParamsGrid for every model except StableFung, which takes a FungParamsGrid. Here one beam is stiff everywhere except a soft band in the middle:

E = youngs_along_beam()                     # one value per cell along x
pairs = [trusty.LameParams.from_young_poisson(e, NU) for e in E]
grid = trusty.LameParamsGrid(
    origin=(0.0, 0.0, 0.0),                 # the beam's rest-space corner
    spacing=(LENGTH / VOXELS, 1.0, 1.0),    # VOXELS cells along x, 1 across
    interpolation=trusty.GridInterpolation.Nearest,
    mu=np.array([p.mu for p in pairs]).reshape(VOXELS, 1, 1),
    lam=np.array([p.lam for p in pairs]).reshape(VOXELS, 1, 1),
)
material = trusty.StableNeoHookean(field=grid)

Each element takes the parameters of the grid cell under it: Nearest gives crisp steps, and Linear (the default) blends between cells. Points outside the grid take the nearest edge cell, so make the grid cover the whole body. Under gravity the stiff halves stay straight and the beam folds at the soft band (coloured by Young's modulus):

A graded cantilever folding at its soft middle band

Full script: python examples/fem/graded_beam.py
"""One beam whose stiffness varies along its length: a soft band as a hinge.

A cantilever is stiff (E = 10 MPa) everywhere except a soft band (E = 50 kPa)
in the middle, authored as a ``LameParamsGrid`` over the beam's rest space
rather than as two bodies. Under gravity the stiff halves stay straight and the
beam folds at the band. The viewer colours each element by the Young's modulus
it was given. ``graded_material.py`` compares several grids side by side,
including what happens when the grid does not cover the body.

Usage:
    python examples/fem/graded_beam.py              # live polyscope
    python examples/fem/graded_beam.py --no-viewer  # print the sag
"""

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

LENGTH = 1.0
NU = 0.3
VOXELS = 20             # grid cells along the beam


def youngs_along_beam():
    """Young's modulus per grid cell: stiff, with a soft band in the middle."""
    E = np.full(VOXELS, 1e7)
    E[8:12] = 5e4
    return E


def graded_material():
    E = youngs_along_beam()                     # one value per cell along x
    pairs = [trusty.LameParams.from_young_poisson(e, NU) for e in E]
    grid = trusty.LameParamsGrid(
        origin=(0.0, 0.0, 0.0),                 # the beam's rest-space corner
        spacing=(LENGTH / VOXELS, 1.0, 1.0),    # VOXELS cells along x, 1 across
        interpolation=trusty.GridInterpolation.Nearest,
        mu=np.array([p.mu for p in pairs]).reshape(VOXELS, 1, 1),
        lam=np.array([p.lam for p in pairs]).reshape(VOXELS, 1, 1),
    )
    material = trusty.StableNeoHookean(field=grid)
    return material


def build_world(backend: str = "auto"):
    world = trusty.World(backend=backend,
                         time_stepping="static",   # solve equilibrium each step
                         newton=trusty.NewtonConfig(max_iters=60))

    mesh = trusty.make_beam_hex_mesh(size=(LENGTH, 0.1, 0.1), res=(40, 4, 4))
    beam = trusty.fem.add_hex_solid(world, mesh, graded_material(), density=1000.0)
    trusty.fem.pin_face(world, beam, axis=0, coord=0.0)
    return world, beam, mesh


def element_youngs(mesh):
    """The modulus each hex runs: the grid cell (nearest) under its centre."""
    centres_x = np.asarray(mesh.vertices)[np.asarray(mesh.hexes), 0].mean(axis=1)
    cell = np.clip((centres_x / (LENGTH / VOXELS)).astype(int), 0, VOXELS - 1)
    return youngs_along_beam()[cell]


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


def run_viewer(world, beam, mesh, steps: int):
    ps = init_polyscope(headless=False)
    m = ps.register_volume_mesh("beam", trusty.fem.read_positions(world, beam),
                                hexes=np.asarray(mesh.hexes))
    m.set_edge_width(1.0)
    m.add_scalar_quantity("log10 Young's modulus", np.log10(element_youngs(mesh)),
                          defined_on="cells", cmap="viridis", enabled=True)
    ps.reset_camera_to_home_view()

    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
            m.update_vertex_positions(trusty.fem.read_positions(world, beam))
        psim.Text(f"step {state['i']} / {steps}   tip sag {tip_sag(world, beam):.3f} m")

    ps.set_user_callback(callback)
    ps.show()


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

    world, beam, mesh = build_world(args.backend)
    if args.no_viewer:
        for _ in range(args.steps):
            world.step()
        report = world.last_report()
        print(f"tip sag after {args.steps} steps: {tip_sag(world, beam):.3f} m "
              f"({'converged' if report.converged else 'NOT converged'})")
        return
    run_viewer(world, beam, mesh, args.steps)


if __name__ == "__main__":
    main()

python examples/fem/graded_material.py goes further: it compares ramps running either way along the beam, and shows what goes wrong when the grid is too short.

Mesh types

Solids are built on hexahedral (HexMesh, 8-node bricks) or tetrahedral (TetMesh, 4-node tets) meshes.

Hex Tet
Structured beam make_beam_hex_mesh(size, res) make_beam_tet_mesh(size, res)
From NumPy arrays make_hex_mesh(vertices, hexes) make_tet_mesh(vertices, tets)
From a file read_tet_msh(path) (Gmsh 2.2)
Add to a world fem.add_hex_solid fem.add_tet_solid

Hexes are efficient for box-like shapes; tets fit any shape. To simulate your own geometry, tetrahedralize it with a tool such as fTetWild, TetGen or Gmsh, then load it from a .msh file or pass its arrays to make_tet_mesh. transform_mesh(mesh, A, t) places a mesh before you add it.

# Structured beams, built in: (Lx, Ly, Lz) and elements per axis.
hex_mesh = trusty.make_beam_hex_mesh(size=(1.0, 0.1, 0.1), res=(20, 2, 2))
tet_mesh = trusty.make_beam_tet_mesh(size=(1.0, 0.1, 0.1), res=(20, 2, 2))

# Your own tet mesh, from a Gmsh .msh file (format 2.2) ...
loaded = trusty.read_tet_msh(str(mesh_path))
# ... or from NumPy arrays, e.g. produced by TetGen, fTetWild or meshio.
loaded = trusty.make_tet_mesh(loaded.vertices, loaded.tets)

# Place each mesh before adding it: rotate with A, translate with t.
tet_mesh = trusty.transform_mesh(tet_mesh, t=(0.0, 0.0, -0.3))
loaded = trusty.transform_mesh(loaded, t=(1.3, 0.05, -0.1))

The sample ball is examples/data/ball.msh. Try your own mesh with python examples/fem/mesh_types.py --mesh my_mesh.msh.

Full script: python examples/fem/mesh_types.py
"""Hex and tet meshes: built-in beams, and a tet mesh loaded from a file.

Builds one world with a structured hex beam, a structured tet beam and a tet
ball read from ``data/ball.msh`` (Gmsh format 2.2), the way you would load your
own tetrahedralized geometry. Nothing is pinned, so stepping
lets all three fall under gravity.

Usage:
    python examples/fem/mesh_types.py                   # live polyscope
    python examples/fem/mesh_types.py --no-viewer       # print mesh sizes
    python examples/fem/mesh_types.py --mesh my.msh     # your own tet 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  # noqa: E402

DATA = Path(__file__).resolve().parent.parent / "data"
COLORS = {"hex beam": (0.94, 0.33, 0.31), "tet beam": (0.78, 0.79, 0.84),
          "loaded mesh": (0.35, 0.75, 0.70)}


def build_world(mesh_path: Path = DATA / "ball.msh", backend: str = "auto"):
    # Structured beams, built in: (Lx, Ly, Lz) and elements per axis.
    hex_mesh = trusty.make_beam_hex_mesh(size=(1.0, 0.1, 0.1), res=(20, 2, 2))
    tet_mesh = trusty.make_beam_tet_mesh(size=(1.0, 0.1, 0.1), res=(20, 2, 2))

    # Your own tet mesh, from a Gmsh .msh file (format 2.2) ...
    loaded = trusty.read_tet_msh(str(mesh_path))
    # ... or from NumPy arrays, e.g. produced by TetGen, fTetWild or meshio.
    loaded = trusty.make_tet_mesh(loaded.vertices, loaded.tets)

    # Place each mesh before adding it: rotate with A, translate with t.
    tet_mesh = trusty.transform_mesh(tet_mesh, t=(0.0, 0.0, -0.3))
    loaded = trusty.transform_mesh(loaded, t=(1.3, 0.05, -0.1))

    world = trusty.World(backend=backend)
    material = trusty.StableNeoHookean(youngs_modulus=1e6, poisson_ratio=0.3)
    bodies = {
        "hex beam":    trusty.fem.add_hex_solid(world, hex_mesh, material, density=1000.0),
        "tet beam":    trusty.fem.add_tet_solid(world, tet_mesh, material, density=1000.0),
        "loaded mesh": trusty.fem.add_tet_solid(world, loaded, material, density=1000.0),
    }
    return world, bodies, {"hex beam": hex_mesh, "tet beam": tet_mesh,
                           "loaded mesh": loaded}


def run_viewer(world, bodies, meshes, steps: int):
    ps = init_polyscope(headless=False)
    shown = {}
    for name, body in bodies.items():
        mesh = meshes[name]
        cells = {"hexes": np.asarray(mesh.hexes)} if hasattr(mesh, "hexes") \
            else {"tets": np.asarray(mesh.tets)}
        m = ps.register_volume_mesh(name, trusty.fem.read_positions(world, body), **cells)
        m.set_color(COLORS[name])
        m.set_edge_width(1.0)
        shown[name] = (m, body)
    ps.reset_camera_to_home_view()

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

    def callback():
        import polyscope.imgui as psim
        _, state["playing"] = psim.Checkbox("play (fall under gravity)", state["playing"])
        psim.SameLine()
        if (psim.Button("step") or state["playing"]) and state["i"] < steps:
            world.step()
            state["i"] += 1
            for m, body in shown.values():
                m.update_vertex_positions(trusty.fem.read_positions(world, body))
        psim.Text(f"step {state['i']} / {steps}")

    ps.set_user_callback(callback)
    ps.show()


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--mesh", type=Path, default=DATA / "ball.msh",
                    help="tet mesh to load (Gmsh .msh, format 2.2)")
    ap.add_argument("--steps", type=int, default=60)
    ap.add_argument("--backend", default="auto", choices=["auto", "cpu", "accelerate", "cuda"])
    ap.add_argument("--no-viewer", action="store_true",
                    help="headless: print each mesh's size")
    args = ap.parse_args()

    world, bodies, meshes = build_world(args.mesh, args.backend)
    if args.no_viewer:
        for name, mesh in meshes.items():
            print(f"{name:<12} {mesh.num_nodes:5d} nodes  {mesh.num_elems:5d} elements")
        return
    run_viewer(world, bodies, meshes, args.steps)


if __name__ == "__main__":
    main()

A hex beam, a tet beam and a tet ball loaded from a file

Element order

Both mesh types support linear and quadratic elements, set with order:

ElementOrder Hex Tet
Linear (default) Q1, 8 nodes P1, 4 nodes
Quadratic Q2, 27 nodes P2, 10 nodes
QuadraticSerendipity Q2 serendipity, 20 nodes —
if kind == "hex":
    return trusty.fem.add_hex_solid(world, mesh, material, density, order=order)
return trusty.fem.add_tet_solid(world, mesh, material, density, order=order)

Pass the ordinary linear mesh: quadratic orders add the extra nodes themselves. Linear elements are cheaper but lock in bending on coarse meshes, which makes the body too stiff. Quadratic elements bend correctly with far fewer elements. Below are five coarse beams (6 × 1 × 1 elements). From front to back: hex Q1, tet P1, tet P2, hex Q2 serendipity, hex Q2. The linear tet beam locks worst and barely sags; all three quadratic beams agree.

Five cantilevers, one per element type and order

Full script: python examples/bending_locking.py
"""Higher-order (P2/Q2) FEM demo: quadratic elements resist bending locking.

Builds one world with a cantilever beam per element family, laid out side by
side, all pinned at x=0 and sagging under gravity. Linear Q1/P1 elements lock
(under-deflect) on a coarse mesh; the quadratic P2 tet and Q2 hexes bend much
more freely. Quadratic bodies are rendered via the same linear sub-triangle
tessellation of their curved surface that contact uses
(`fem.read_surface_triangles`).

Usage:
    python examples/bending_locking.py                # live polyscope
    python examples/bending_locking.py --no-viewer    # print comparison
    python examples/bending_locking.py --res 8 1 1    # finer corner mesh
"""

from __future__ import annotations

import argparse

import numpy as np

import trusty
from utils import init_polyscope

ORDER = trusty.fem.ElementOrder

# label -> (mesh kind, element order, color)
FAMILIES = {
    "hex_q1":             ("hex", ORDER.Linear,               (0.6, 0.6, 0.6)),
    "tet_p1":             ("tet", ORDER.Linear,               (0.4, 0.5, 0.7)),
    "tet_p2":             ("tet", ORDER.Quadratic,            (0.9, 0.6, 0.2)),
    "hex_q2_serendipity": ("hex", ORDER.QuadraticSerendipity, (0.3, 0.7, 0.4)),
    "hex_q2":             ("hex", ORDER.Quadratic,            (0.8, 0.3, 0.3)),
}


def add_body(world, kind, order, mesh, material, density):
    if kind == "hex":
        return trusty.fem.add_hex_solid(world, mesh, material, density, order=order)
    return trusty.fem.add_tet_solid(world, mesh, material, density, order=order)


def build_world(size, res, backend="auto"):
    material = trusty.StableNeoHookean(youngs_modulus=1e6, poisson_ratio=0.3)
    spacing = 1.5 * size[1]

    world = trusty.World(backend=backend,
                         timestep=1.0 / 30.0,
                         time_stepping="quasi_static",
                         newton=trusty.NewtonConfig(tolerance=1e-5))
    bodies = {}
    for k, (family, (kind, order, _)) in enumerate(FAMILIES.items()):
        mesh = (trusty.make_beam_hex_mesh(size=size, res=res) if kind == "hex"
                else trusty.make_beam_tet_mesh(size=size, res=res))
        # Shift in +y only, so the x=0 pin still lands on the fixed face.
        mesh = trusty.transform_mesh(mesh, t=(0.0, k * spacing, 0.0))
        body = add_body(world, kind, order, mesh, material, 1000.0)
        trusty.fem.pin_face(world, body, axis=0, coord=0.0)
        bodies[family] = body

    return world, bodies


def tip_deflection(world, body):
    verts = np.asarray(trusty.fem.read_positions(world, body))
    tip = verts[:, 0] > verts[:, 0].max() - 1e-3
    return float(-verts[tip, 2].max())  # downward deflection (+ = down)


def main():
    parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    parser.add_argument("--res", type=int, nargs=3, default=[6, 1, 1])
    parser.add_argument("--steps", type=int, default=200)
    parser.add_argument("--backend", choices=["auto", "cpu", "accelerate", "cuda"], default="auto")
    parser.add_argument("--no-viewer", action="store_true",
                        help="headless: print the tip-deflection comparison instead of polyscope")
    args = parser.parse_args()

    size = (1.0, 0.1, 0.1)
    world, bodies = build_world(size, tuple(args.res), args.backend)

    if args.no_viewer:
        for _ in range(args.steps):
            world.step()
        print(f"Cantilever {size} at corner res {tuple(args.res)}, {args.steps} steps")
        print(f"{'family':<20} {'nodes':>7} {'tip deflection (m)':>20}")
        for family, body in bodies.items():
            n = np.asarray(trusty.fem.read_positions(world, body)).shape[0]
            print(f"{family:<20} {n:>7} {tip_deflection(world, body):>20.5e}")
        return

    run_viewer(world, bodies, args.steps)


def run_viewer(world, bodies, steps):
    ps = init_polyscope(headless=False)
    meshes = {}
    for family, body in bodies.items():
        V = np.asarray(trusty.fem.read_positions(world, body)).copy()
        F = np.asarray(trusty.fem.read_surface_triangles(world, body))
        m = ps.register_surface_mesh(family, V, F)
        m.set_color(FAMILIES[family][2])
        meshes[family] = (m, body)
    ps.reset_camera_to_home_view()

    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
            for _family, (m, body) in meshes.items():
                m.update_vertex_positions(np.asarray(trusty.fem.read_positions(world, body)))
        psim.Text(f"step {state['i']} / {steps}")
        report = world.last_report()
        psim.Text(f"last solve: iters={report.iterations}  "
                  f"residual={report.final_residual:.3e}  "
                  f"{'converged' if report.converged else 'DIVERGED'}")

    ps.set_user_callback(callback)
    ps.show()


if __name__ == "__main__":
    main()

A quadratic body runs on its consistent mass matrix by default; consistent_mass=False lumps its mass onto the nodes instead. Linear bodies always lump their mass.

Quadratic bodies have more nodes than their input mesh. read_positions returns all of them, and read_surface_triangles returns a triangulation of the curved surface, indexed into those positions, for rendering.

Visualizing stress

Function Returns
read_positions(world, body) current node positions, (num_nodes, 3)
read_surface_triangles(world, body) boundary triangles into read_positions, (F, 3)
read_von_mises(world, body) von Mises stress in Pa, per node (or per element with nodal=False)
read_strain_energy_density(world, body) strain energy density in J/m³, likewise

Both stress readouts take nodal= (a smooth per-node field, or raw per-element values) and unit_material= (evaluate with unit moduli, a stiffness-independent strain measure):

vm, psi = trusty.fem.read_von_mises, trusty.fem.read_strain_energy_density
return {
    FIELDS[0]: np.asarray(vm(world, body, nodal=True)),
    FIELDS[1]: np.asarray(psi(world, body, nodal=True)),
    FIELDS[2]: np.asarray(vm(world, body, nodal=True, unit_material=True)),
    FIELDS[3]: np.asarray(psi(world, body, nodal=True, unit_material=True)),
}

The arrays are plain NumPy, so they go straight into polyscope as scalar quantities. Below, a soft block is squeezed 30% between two clamped plates and cut open at its mid-plane: von Mises on the left, strain energy density on the right. The clamped faces cannot spread, so the block bulges. Both fields are highest in the band through the middle and lowest under the centre of each plate, where the material is squeezed almost equally from every side. Von Mises ignores that equal squeezing (pressure) and strain energy includes it. Use von Mises for yielding and strain energy when pressure matters.

A squeezed block cut open, coloured by von Mises and by strain energy

Full script: python examples/fem/visualize_stress.py
"""Von Mises vs strain energy density on a block squeezed between two plates.

A soft, nearly incompressible block (E = 500 kPa, nu = 0.45) is clamped at its
bottom face and its top face is pushed down 30% by a moving pin. The clamped
faces cannot spread sideways, so the block bulges at mid-height and the stress
varies through it: highest in the bulging middle band, lowest under the plate
centres, where the material is squeezed almost equally from every side. The
two readouts measure different things:

  * **von Mises** is *deviatoric*: it discards the hydrostatic (pressure) part
    of the stress. It is the measure for yielding and failure.
  * **strain energy density** includes the volumetric term, so it also counts
    how hard the material is being squeezed.

Both take `nodal=` (True: a smooth per-node field; False: raw per-element
values) and `unit_material=` (evaluate at mu = lam = 1, a stiffness-independent
strain measure, so soft and stiff regions are directly comparable).

Usage:
    python examples/fem/visualize_stress.py              # live polyscope
    python examples/fem/visualize_stress.py --no-viewer  # print ranges
    python examples/fem/visualize_stress.py --squeeze 0.4
"""

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                      # block edge (m)
RES = 10                        # hexes per edge
E, NU = 5.0e5, 0.45             # soft and nearly incompressible
PLATE_STIFFNESS = 1e10          # Pa; stiff enough to act as a rigid plate

FIELDS = ("von Mises (Pa)", "strain energy (J/m^3)",
          "von Mises (unit material)", "strain energy (unit material)")


def read_fields(world, body):
    """Every combination the two readouts offer, for one body."""
    vm, psi = trusty.fem.read_von_mises, trusty.fem.read_strain_energy_density
    return {
        FIELDS[0]: np.asarray(vm(world, body, nodal=True)),
        FIELDS[1]: np.asarray(psi(world, body, nodal=True)),
        FIELDS[2]: np.asarray(vm(world, body, nodal=True, unit_material=True)),
        FIELDS[3]: np.asarray(psi(world, body, nodal=True, unit_material=True)),
    }


class Press:
    """The block and the moving top plate that squeezes it."""

    def __init__(self, backend: str = "auto"):
        self.world = trusty.World(backend=backend,
                                  time_stepping="static",   # solve equilibrium each step
                                  gravity=(0.0, 0.0, 0.0),  # the plates are the only load
                                  newton=trusty.NewtonConfig(max_iters=60))

        mesh = trusty.make_beam_hex_mesh(size=(SIZE, SIZE, SIZE), res=(RES, RES, RES))
        self.mesh = mesh
        material = trusty.StableNeoHookean(youngs_modulus=E, poisson_ratio=NU)
        self.body = trusty.fem.add_hex_solid(self.world, mesh, material, density=1000.0)

        # Bottom face clamped; top face held by a pin whose targets move down.
        trusty.fem.pin_face(self.world, self.body, axis=2, coord=0.0)
        rest = np.asarray(mesh.vertices)
        self.top = np.flatnonzero(np.abs(rest[:, 2] - SIZE) < 1e-9)
        self.top_rest = rest[self.top].copy()
        self.pin = trusty.boundary_conditions.pin_to_target(
            self.world, self.body, self.top.tolist(), self.top_rest, PLATE_STIFFNESS)

    def squeeze_to(self, fraction: float):
        """Move the top plate down to `fraction` of the height and solve."""
        targets = self.top_rest.copy()
        targets[:, 2] -= fraction * SIZE
        trusty.boundary_conditions.set_pin_targets(self.world, self.pin, targets)
        self.world.step()

    def squeeze(self, fraction: float, steps: int):
        """Reach `fraction` in `steps` equal increments."""
        for k in range(1, steps + 1):
            self.squeeze_to(fraction * k / steps)


def report(press):
    width = max(len(f) for f in FIELDS)
    x = np.asarray(trusty.fem.read_positions(press.world, press.body))
    print(f"height {x[press.top, 2].mean():.3f} m (rest {SIZE} m), "
          f"width {x[:, 0].max() - x[:, 0].min():.3f} m (rest {SIZE} m)")
    for name, values in read_fields(press.world, press.body).items():
        print(f"  {name:<{width}}  min={values.min():10.4g}  max={values.max():10.4g}")


def run_viewer(press, fraction: float, steps: int):
    ps = init_polyscope(headless=False)
    m = ps.register_volume_mesh(
        "block", trusty.fem.read_positions(press.world, press.body),
        hexes=np.asarray(press.mesh.hexes))
    m.set_edge_width(1.0)

    def refresh():
        m.update_vertex_positions(trusty.fem.read_positions(press.world, press.body))
        for name, values in read_fields(press.world, press.body).items():
            m.add_scalar_quantity(name, values, defined_on="vertices",
                                  cmap="viridis", enabled=(name == FIELDS[0]))

    state = {"i": 0, "playing": False}
    refresh()
    ps.reset_camera_to_home_view()

    def callback():
        import polyscope.imgui as psim
        _, state["playing"] = psim.Checkbox("play (squeeze)", state["playing"])
        psim.SameLine()
        if (psim.Button("step") or state["playing"]) and state["i"] < steps:
            state["i"] += 1
            press.squeeze_to(fraction * state["i"] / steps)
            refresh()
        psim.Text(f"squeeze {100 * fraction * state['i'] / steps:.0f}% "
                  f"(step {state['i']} / {steps})")
        psim.Text("Switch fields under the block's Scalar Quantities.")

    ps.set_user_callback(callback)
    ps.show()


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--squeeze", type=float, default=0.3,
                    help="fraction of the height the top plate moves down")
    ap.add_argument("--steps", type=int, default=10,
                    help="load increments to reach the full squeeze")
    ap.add_argument("--backend", default="auto", choices=["auto", "cpu", "accelerate", "cuda"])
    ap.add_argument("--no-viewer", action="store_true",
                    help="headless: squeeze fully, then print the field ranges")
    args = ap.parse_args()

    press = Press(args.backend)
    if args.no_viewer:
        press.squeeze(args.squeeze, args.steps)
        report(press)
        return
    run_viewer(press, args.squeeze, args.steps)


if __name__ == "__main__":
    main()

Reference

Every function and argument: trusty.fem reference.