Skip to content

Shells

trusty.shells simulates thin sheets such as cloth, paper and sheet metal. A shell is a triangle mesh of the sheet's midsurface; its thickness is a number, not a layer of elements.

Capability: shells.

Quick example

A square curtain hanging from its two top corners:

world = trusty.World(backend=backend, timestep=1.0 / 60.0,
                     newton=trusty.NewtonConfig(max_iters=100))
config = trusty.shells.ShellConfig()
config.youngs_modulus = 1.0e4      # Pa: soft, like cloth
config.poisson_ratio = 0.3
config.thickness = 5.0e-4          # m
config.density = 500.0             # kg/m^3
config.strain_limit = strain_limit
config.strain_limit_ratio = 1.1    # stretch at most 10%
config.bending_model = BENDING[bending]     # Bac by default
config.membrane_model = MEMBRANE[membrane]  # Koiter by default

# V (n, 3) and F (m, 3): the sheet's vertices and triangles.
body = trusty.shells.add_shell(world, V, F, config)
trusty.shells.pin_vertices(world, body, held_corners(RES))  # top corners

Each world.step() advances one timestep, and trusty.shells.read_positions(world, body) returns the deformed vertices as an (n, 3) NumPy array.

add_shell adds the sheet and returns its id, which every other trusty.shells function takes. The yellow points are the two pinned corners.

Argument Meaning
V vertex positions, (n, 3), in metres. This is the rest shape.
F triangles, (m, 3), indices into V
config a ShellConfig: material, thickness and models; see Material and thickness

A world simulates one shell body. If you add a second, only the last one added moves, so give each sheet a world of its own.

Full script: python examples/shells/cloth_drape.py
"""A square cloth curtain hanging from its two top corners.

A flat square sheet starts upright, leaning slightly, and is pinned at its two
top corners; gravity drapes it into a curtain with a swag between the corners.
The cloth is soft (E = 10 kPa) but cannot stretch more than 10%: the strain
limit keeps it cloth-like instead of rubbery. ``--no-strain-limit`` shows the
difference, ``--bending qb`` and ``--membrane neohookean`` swap the bending and
membrane models. ``--no-viewer`` prints the drape's size and its largest
stretch.

Usage:
    python examples/shells/cloth_drape.py
    python examples/shells/cloth_drape.py --no-strain-limit
    python examples/shells/cloth_drape.py --bending qb
    python examples/shells/cloth_drape.py --backend accelerate
    python examples/shells/cloth_drape.py --no-viewer --steps 120
"""

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


SIDE = 1.0          # m, side length of the sheet
RES  = 32           # quads per side
TOP_Z = 1.5         # height of the pinned top edge (curtain hangs below)
TILT_DEG = -1.0     # initial lean off the vertical plane


def make_square_sheet(side: float, res: int, top_z: float, tilt_deg: float = 0.0):
    """A square sheet, upright in the y-z plane (x = 0), with its top edge at
    `top_z`. Each grid square is split into four triangles around a centre
    vertex, so the mesh has no diagonal bias and the cloth folds evenly.
    `tilt_deg` leans the sheet about its top edge: a perfectly upright sheet
    is balanced and would never start to drape."""
    ys = np.linspace(0.0, side, res + 1)              # width  (y)
    zs = np.linspace(top_z - side, top_z, res + 1)    # height (z), top at top_z
    Y, Z = np.meshgrid(ys, zs, indexing="xy")
    corners = np.stack([np.zeros(Y.size), Y.ravel(), Z.ravel()], axis=1)

    def vid(i, j):
        return j * (res + 1) + i

    # One centre vertex per quad, at the quad midpoint; appended after corners.
    yc = 0.5 * (ys[:-1] + ys[1:])
    zc = 0.5 * (zs[:-1] + zs[1:])
    Yc, Zc = np.meshgrid(yc, zc, indexing="xy")
    centers = np.stack([np.zeros(Yc.size), Yc.ravel(), Zc.ravel()], axis=1)
    base = corners.shape[0]

    def cid(i, j):
        return base + j * res + i

    V = np.concatenate([corners, centers], axis=0).astype(np.float64)

    # Lean about the horizontal top-edge axis (y at z = top_z). The pinned top
    # corners lie on this axis, so they stay at x = 0, z = top_z.
    if tilt_deg:
        t = np.radians(tilt_deg)
        x0 = V[:, 0].copy()
        z0 = V[:, 2] - top_z
        V[:, 0] = x0 * np.cos(t) + z0 * np.sin(t)
        V[:, 2] = -x0 * np.sin(t) + z0 * np.cos(t) + top_z

    tris = []
    for j in range(res):
        for i in range(res):
            a, b = vid(i, j),         vid(i + 1, j)
            c, d = vid(i + 1, j + 1), vid(i, j + 1)
            m = cid(i, j)
            tris.append((a, b, m))
            tris.append((b, c, m))
            tris.append((c, d, m))
            tris.append((d, a, m))
    F = np.asarray(tris, dtype=np.int32)
    return V, F


def held_corners(res: int):
    """The two top corners of the curtain (max height). Held fixed; the sheet
    hangs and drapes from them under gravity."""
    return [res * (res + 1), res * (res + 1) + res]   # vid(0, res), vid(res, res)


trusty.check_capabilities("shells")

BENDING = {"bac": trusty.shells.BendingModel.Bac,
           "qb": trusty.shells.BendingModel.QuadraticLagrange}
MEMBRANE = {"koiter": trusty.shells.MembraneModel.Koiter,
            "neohookean": trusty.shells.MembraneModel.NeoHookean}


def build_world(backend: str = "auto", bending: str = "bac",
                membrane: str = "koiter", strain_limit: bool = True):
    V, F = make_square_sheet(SIDE, RES, TOP_Z, TILT_DEG)

    world = trusty.World(backend=backend, timestep=1.0 / 60.0,
                         newton=trusty.NewtonConfig(max_iters=100))
    config = trusty.shells.ShellConfig()
    config.youngs_modulus = 1.0e4      # Pa: soft, like cloth
    config.poisson_ratio = 0.3
    config.thickness = 5.0e-4          # m
    config.density = 500.0             # kg/m^3
    config.strain_limit = strain_limit
    config.strain_limit_ratio = 1.1    # stretch at most 10%
    config.bending_model = BENDING[bending]     # Bac by default
    config.membrane_model = MEMBRANE[membrane]  # Koiter by default

    # V (n, 3) and F (m, 3): the sheet's vertices and triangles.
    body = trusty.shells.add_shell(world, V, F, config)
    trusty.shells.pin_vertices(world, body, held_corners(RES))  # top corners
    return world, body, V, F


def max_stretch(world, body, V, F) -> float:
    """The largest edge length now, over its rest length."""
    edges = np.concatenate([F[:, [0, 1]], F[:, [1, 2]], F[:, [2, 0]]])
    x = trusty.shells.read_positions(world, body)
    rest = np.linalg.norm(V[edges[:, 0]] - V[edges[:, 1]], axis=1)
    now = np.linalg.norm(x[edges[:, 0]] - x[edges[:, 1]], axis=1)
    return float((now / rest).max())


def _register_visuals(ps, world, body):
    V = np.asarray(trusty.shells.read_positions(world, body))
    F = np.asarray(trusty.shells.read_triangles(world, body))
    ps_mesh = ps.register_surface_mesh("cloth", V, F, smooth_shade=False)
    ps_mesh.set_edge_width(1.0)
    return ps_mesh


def run_polyscope(world, body, steps: int):
    ps = init_polyscope(headless=False)
    ps_mesh = _register_visuals(ps, world, body)
    state = {"i": 0, "playing": False}

    def advance_one():
        if state["i"] < steps:
            world.step()
            state["i"] += 1
            ps_mesh.update_vertex_positions(
                np.asarray(trusty.shells.read_positions(world, body)))

    def callback():
        import polyscope.imgui as psim
        _, state["playing"] = psim.Checkbox("play", state["playing"])
        psim.SameLine()
        if psim.Button("step"):
            advance_one()
        elif state["playing"]:
            advance_one()
        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 main():
    parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    parser.add_argument("--backend",
                        choices=["auto", "cpu", "cuda", "accelerate"], default="auto")
    parser.add_argument("--bending", choices=list(BENDING), default="bac",
                        help="bending model: bac (default) or qb, which "
                             "assumes a flat rest shape")
    parser.add_argument("--membrane", choices=list(MEMBRANE), default="koiter",
                        help="membrane model (default koiter)")
    parser.add_argument("--no-strain-limit", action="store_true",
                        help="let the cloth stretch freely")
    parser.add_argument("--steps", type=int, default=120)
    parser.add_argument("--no-viewer", action="store_true",
                        help="headless: print the drape")
    args = parser.parse_args()

    world, body, V, F = build_world(backend=args.backend, bending=args.bending,
                                    membrane=args.membrane,
                                    strain_limit=not args.no_strain_limit)
    if args.no_viewer:
        unconverged = 0
        for _ in range(args.steps):
            world.step()
            unconverged += not world.last_report().converged
        x = trusty.shells.read_positions(world, body)
        print(f"after {args.steps} steps: bottom at z = {x[:, 2].min():.3f} m "
              f"(rest {TOP_Z - SIDE:.3f} m), largest stretch "
              f"{max_stretch(world, body, V, F):.3f}, "
              f"{unconverged} step(s) not converged")
        return
    run_polyscope(world, body, args.steps)


if __name__ == "__main__":
    main()

Material and thickness

A ShellConfig holds everything about the sheet except its shape:

config = trusty.shells.ShellConfig()
config.youngs_modulus = YOUNGS     # Pa
config.poisson_ratio = POISSON
config.thickness = thickness       # m
config.density = DENSITY           # kg/m^3
plate = trusty.shells.add_shell(world, V, F, config)
Field Default Meaning
youngs_modulus 1e5 Young's modulus, Pa
poisson_ratio 0.3 Poisson's ratio
thickness 1e-3 m
density 1000 kg/mยณ
membrane_model Koiter how the sheet resists stretching; see Models
bending_model Bac how it resists bending
strain_limit False cap how far the sheet can stretch; see Strain limiting
strain_limit_ratio 1.1 the largest stretch allowed, as a ratio of rest length

Thickness sets how a sheet balances stretching against bending. A sheet's weight grows with its thickness, but its bending stiffness grows with the cube of it, so each doubling of thickness cuts the sag by about four. Stretching stiffness grows only in proportion, which is why a thin sheet bends easily but hardly stretches. Below, three 1 m plates of the same material, clamped along one edge, are 5, 10 and 20 mm thick:

Three clamped plates, 5, 10 and 20 mm thick

They sag 0.42, 0.12 and 0.03 m. --no-viewer prints each sag next to the estimate from plate theory, which it matches to within about 20% on this coarse mesh.

Full script: python examples/shells/shell_cantilever.py
"""Three plates clamped along one edge, sagging under gravity.

The plates share one material and differ only in thickness: 5, 10 and 20 mm.
A plate's weight grows with its thickness, but its bending stiffness grows with
the cube of it, so each doubling of thickness cuts the sag by about four. The
clamp holds the first two rows of vertices; one row alone would be a hinge.
Each step solves for static equilibrium, so one step gives the final, settled
shape.

``--no-viewer`` prints each plate's sag next to the small-sag plate-theory
estimate.

Usage:
    python examples/shells/shell_cantilever.py              # live polyscope
    python examples/shells/shell_cantilever.py --no-viewer  # print the sags
    python examples/shells/shell_cantilever.py --backend cuda
"""

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


SIDE = 1.0                          # m, side length of each plate
RES = 16                            # quads per side
THICKNESSES = (0.005, 0.01, 0.02)   # m
SPACING = 1.3                       # m, between plates along y
YOUNGS, POISSON, DENSITY = 1.0e9, 0.3, 1000.0
COLORS = [(0.937, 0.325, 0.314), (0.98, 0.70, 0.30), (0.35, 0.75, 0.70)]


def make_square_sheet(side: float, res: int, y0: float = 0.0):
    """A flat square in the x-y plane (z = 0), two triangles per grid quad."""
    xs = np.linspace(0.0, side, res + 1)
    ys = np.linspace(0.0, side, res + 1) + y0
    X, Y = np.meshgrid(xs, ys, indexing="xy")
    V = np.stack([X.ravel(), Y.ravel(), np.zeros(X.size)], axis=1)

    def vid(i, j):
        return j * (res + 1) + i

    tris = []
    for j in range(res):
        for i in range(res):
            tris.append((vid(i, j), vid(i + 1, j), vid(i + 1, j + 1)))
            tris.append((vid(i, j), vid(i + 1, j + 1), vid(i, j + 1)))
    return V, np.asarray(tris, dtype=np.int32)


def build_plate(thickness: float, y0: float = 0.0, backend: str = "auto"):
    """One plate, clamped along x = 0, in a world of its own."""
    trusty.check_capabilities("shells")
    world = trusty.World(backend=backend,
                         time_stepping="static",   # each step solves equilibrium
                         newton=trusty.NewtonConfig(max_iters=100))

    V, F = make_square_sheet(SIDE, RES, y0)
    config = trusty.shells.ShellConfig()
    config.youngs_modulus = YOUNGS     # Pa
    config.poisson_ratio = POISSON
    config.thickness = thickness       # m
    config.density = DENSITY           # kg/m^3
    plate = trusty.shells.add_shell(world, V, F, config)

    # Clamp the x = 0 edge: hold the first two rows of vertices. Holding
    # one row only would make a hinge, and the plate would swing down.
    h = SIDE / RES
    clamped = np.flatnonzero(V[:, 0] < 1.5 * h)
    trusty.shells.pin_vertices(world, plate, clamped.tolist())
    return world, plate, V


def build_worlds(backend: str = "auto"):
    """One world per plate: a world simulates a single shell body."""
    return {t: build_plate(t, k * SPACING, backend)
            for k, t in enumerate(THICKNESSES)}


def tip_sag(world, plate, V) -> float:
    """How far the free edge (x = SIDE at rest) has dropped, on average."""
    x = trusty.shells.read_positions(world, plate)    # (num_verts, 3)
    free_edge = V[:, 0] == SIDE                       # rows match V
    sag = -x[free_edge, 2].mean()
    return float(sag)


def plate_theory_sag(thickness: float) -> float:
    """Small-sag estimate for a square cantilever plate under its own weight,
    at the middle of the free edge: 0.127 q L^4 / D, with q the weight per
    area and D the bending stiffness. L is the free span beyond the clamp."""
    q = DENSITY * thickness * 9.81
    D = YOUNGS * thickness**3 / (12.0 * (1.0 - POISSON**2))
    span = SIDE - SIDE / RES
    return 0.127 * q * span**4 / D


def run_viewer(plates, steps: int):
    ps = init_polyscope(headless=False)
    meshes = {}
    for k, (t, (world, plate, _)) in enumerate(plates.items()):
        m = ps.register_surface_mesh(
            f"{t * 1e3:g} mm", trusty.shells.read_positions(world, plate),
            trusty.shells.read_triangles(world, plate), color=COLORS[k])
        m.set_edge_width(1.0)
        meshes[t] = m
    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:
            state["i"] += 1
            for t, (world, plate, _) in plates.items():
                world.step()
                meshes[t].update_vertex_positions(
                    trusty.shells.read_positions(world, plate))
        psim.Text(f"step {state['i']} / {steps}")
        for t, (world, plate, V) in plates.items():
            psim.Text(f"{t * 1e3:4.0f} mm: sag {tip_sag(world, plate, V):.3f} m")

    ps.set_user_callback(callback)
    ps.show()


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

    plates = build_worlds(backend=args.backend)
    if args.no_viewer:
        for t, (world, plate, V) in plates.items():
            for _ in range(args.steps):
                world.step()
            report = world.last_report()
            print(f"{t * 1e3:4.0f} mm: sag {tip_sag(world, plate, V):.3f} m   "
                  f"(small-sag plate theory {plate_theory_sag(t):.3f} m)  "
                  f"{'converged' if report.converged else 'NOT converged'}")
        return
    run_viewer(plates, args.steps)


if __name__ == "__main__":
    main()

Membrane and bending models

Two choices set the physics: the membrane model (stretching and shearing in the sheet's plane) and the bending model.

BENDING = {"bac": trusty.shells.BendingModel.Bac,
           "qb": trusty.shells.BendingModel.QuadraticLagrange}
MEMBRANE = {"koiter": trusty.shells.MembraneModel.Koiter,
            "neohookean": trusty.shells.MembraneModel.NeoHookean}
MembraneModel Behaviour
Koiter (default) St. Venantโ€“Kirchhoff: a good default for sheets that stretch little.
NeoHookean an incompressible Neo-Hookean sheet: for rubbery sheets that stretch far.
BendingModel Behaviour
Bac (default) accurate bending for any rest shape, flat or curved.
QuadraticLagrange cheaper, for flat rest shapes only; add_shell warns when the mesh is not flat. It resists bending less than Bac near free edges, so use Bac when the amount of bending matters, as in the plates above.

Set them on the config, before add_shell:

config.strain_limit = strain_limit
config.strain_limit_ratio = 1.1    # stretch at most 10%
config.bending_model = BENDING[bending]     # Bac by default
config.membrane_model = MEMBRANE[membrane]  # Koiter by default

python examples/shells/cloth_drape.py --bending qb --membrane neohookean runs the curtain with the other two models.

Strain limiting

Cloth bends easily but barely stretches. A low Young's modulus gives the easy bending, but on its own it also lets the sheet stretch like rubber. With strain_limit on, no triangle can stretch past strain_limit_ratio of its rest size, however soft the material. Below, the same soft curtain with the limit (left) and without (right); the yellow squares are the sheets' rest outlines. The limited sheet stretches at most 8%, and the free one up to 88% near the corners:

Two curtains, with and without strain limiting

python examples/shells/cloth_drape.py --no-strain-limit runs the right-hand one.

Pinning

Function Pins
pin_vertices(world, body, indices) the listed vertices
pin_face(world, body, axis, coord, tol=1e-6) every vertex whose rest coordinate along axis (0, 1, 2 for x, y, z) is within tol of coord

Pinned vertices stay exactly where they start. Two things to know:

  • One row of pinned vertices is a hinge, not a clamp. The sheet can still rotate about the row. To clamp an edge, pin two rows. pin_face selects one plane of vertices, so on a flat sheet it makes a hinge.
  • Each call replaces the previous pins. Pass every pinned vertex in one call.

The plates above are clamped like this:

# Clamp the x = 0 edge: hold the first two rows of vertices. Holding
# one row only would make a hinge, and the plate would swing down.
h = SIDE / RES
clamped = np.flatnonzero(V[:, 0] < 1.5 * h)
trusty.shells.pin_vertices(world, plate, clamped.tolist())

Soft pins with moving targets work on shells too; see Boundary conditions. Their stiffness is per unit volume, and a sheet has very little volume, so a shell needs a far stiffer pin than a solid does. Surface loads are not available for shells.

Initial positions

The V given to add_shell is the rest shape: the shape the sheet has no energy in. To start the simulation from a different shape, set the initial positions before the first step:

V, F = make_strip(LENGTH, WIDTH, RES)       # the rest shape: flat
body = trusty.shells.add_shell(world, V, F, config)
# Start rolled into a half cylinder; the rest shape stays flat.
trusty.shells.set_initial_positions(world, body, rolled(V, LENGTH))

Here a flat strip starts rolled into a half cylinder. With no gravity, it springs open, is flat again half a second later, and swings back and forth about flat:

set_initial_positions(world, body, X) takes one row per vertex of V, and only has an effect before the first step. For a sheet that is curved at rest, pass the curved shape as V instead.

Full script: python examples/shells/spring_back.py
"""A flat plate, started rolled up, springs back flat.

The mesh passed to ``add_shell`` is the rest shape: here a flat strip. Before
the first step, ``set_initial_positions`` bends it into a half cylinder, so the
simulation starts from that curved state while the rest shape stays flat.
Released without gravity, the plate unrolls and swings back and forth about
flat. ``--no-viewer`` prints how far the plate is from flat as it goes.

Usage:
    python examples/shells/spring_back.py              # live polyscope
    python examples/shells/spring_back.py --no-viewer  # print the unrolling
    python examples/shells/spring_back.py --steps 240
"""

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, WIDTH = 0.6, 0.2    # m
RES = (24, 8)               # quads along length and width


def make_strip(length: float, width: float, res):
    """A flat strip in the x-y plane (z = 0), two triangles per grid quad."""
    nx, ny = res
    X, Y = np.meshgrid(np.linspace(0.0, length, nx + 1),
                       np.linspace(0.0, width, ny + 1), indexing="xy")
    V = np.stack([X.ravel(), Y.ravel(), np.zeros(X.size)], axis=1)

    def vid(i, j):
        return j * (nx + 1) + i

    tris = []
    for j in range(ny):
        for i in range(nx):
            tris.append((vid(i, j), vid(i + 1, j), vid(i + 1, j + 1)))
            tris.append((vid(i, j), vid(i + 1, j + 1), vid(i, j + 1)))
    return V, np.asarray(tris, dtype=np.int32)


def rolled(V: np.ndarray, length: float) -> np.ndarray:
    """The flat strip bent, without stretching, into a half cylinder about
    an axis along y, with the strip's middle at the bottom."""
    radius = length / np.pi
    theta = (V[:, 0] - length / 2) / radius       # arc length -> angle
    X = V.copy()
    X[:, 0] = length / 2 + radius * np.sin(theta)
    X[:, 2] = radius * (1.0 - np.cos(theta))
    return X


def build_world(backend: str = "auto"):
    trusty.check_capabilities("shells")
    world = trusty.World(backend=backend, timestep=1.0 / 60.0,
                         gravity=(0.0, 0.0, 0.0))

    config = trusty.shells.ShellConfig()
    config.youngs_modulus = 1.0e7
    config.poisson_ratio = 0.3
    config.thickness = 2.0e-3
    config.density = 1000.0

    V, F = make_strip(LENGTH, WIDTH, RES)       # the rest shape: flat
    body = trusty.shells.add_shell(world, V, F, config)
    # Start rolled into a half cylinder; the rest shape stays flat.
    trusty.shells.set_initial_positions(world, body, rolled(V, LENGTH))
    return world, body, V


def height(world, body) -> float:
    """How far the plate is from flat: its extent along z."""
    z = trusty.shells.read_positions(world, body)[:, 2]
    return float(z.max() - z.min())


def run_viewer(world, body, steps: int):
    ps = init_polyscope(headless=False)
    m = ps.register_surface_mesh("plate", trusty.shells.read_positions(world, body),
                                 trusty.shells.read_triangles(world, body))
    m.set_edge_width(1.0)
    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.shells.read_positions(world, body))
        psim.Text(f"step {state['i']} / {steps}   height {height(world, body):.3f} m")

    ps.set_user_callback(callback)
    ps.show()


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

    world, body, _ = build_world(backend=args.backend)
    if args.no_viewer:
        print(f"step   0: height {height(world, body):.3f} m")
        for i in range(1, args.steps + 1):
            world.step()
            if i % 10 == 0:
                report = world.last_report()
                print(f"step {i:3d}: height {height(world, body):.3f} m"
                      f"{'' if report.converged else '  (NOT converged)'}")
        return
    run_viewer(world, body, args.steps)


if __name__ == "__main__":
    main()

Reading results

Function Returns
read_positions(world, body) current vertex positions, (n, 3), rows matching V
read_triangles(world, body) the triangles, (m, 3), the same as F

The rows match V, so a vertex selected on the rest mesh can be read directly. The plates' sag, measured along their free edge:

x = trusty.shells.read_positions(world, plate)    # (num_verts, 3)
free_edge = V[:, 0] == SIDE                       # rows match V
sag = -x[free_edge, 2].mean()

Contact

With contact on, shells collide with walls and with other bodies. Here a sheet drops onto a fixed sphere and drapes over it:

trusty.contact.enable(world, trusty.contact.Config(kappa=1.0e4))
sh_cfg = trusty.shells.ShellConfig()
sh_cfg.youngs_modulus = 1.0e5
sh_cfg.poisson_ratio  = 0.3
sh_cfg.thickness      = 1.0e-3
sh_cfg.density        = 1.0e3
body = trusty.shells.add_shell(world, V_sh, F_sh, sh_cfg)

V_sp, F_sp = make_uv_sphere(SPHERE_CENTER, SPHERE_RADIUS)
trusty.contact.add_wall(world, "sphere", V_sp, F_sp)

A sheet draped over a sphere

Contact settings and walls are covered under Contact. python examples/shells/shell_hex_coupled.py drops a sheet onto a soft solid beam in the same world.

Full script: python examples/shells/shell_drop.py
"""A square sheet dropped onto a fixed sphere, with contact.

A flat sheet falls under gravity and lands on a sphere, a fixed contact wall,
then drapes over it. ``--no-viewer`` prints where the sheet rests and how
far its corners hang.

Usage:
    python examples/shells/shell_drop.py
    python examples/shells/shell_drop.py --backend accelerate
    python examples/shells/shell_drop.py --no-viewer --steps 240
"""

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


SIDE = 1.0
RES  = 16
DROP_Z = 0.6
SPHERE_CENTER = (0.5, 0.5, 0.0)
SPHERE_RADIUS = 0.25


def make_square_sheet(side: float, res: int, z0: float):
    xs = np.linspace(0.0, side, res + 1)
    ys = np.linspace(0.0, side, res + 1)
    X, Y = np.meshgrid(xs, ys, indexing="xy")
    Z    = np.full_like(X, z0)
    V = np.stack([X.ravel(), Y.ravel(), Z.ravel()], axis=1).astype(np.float64)

    def vid(i, j):
        return j * (res + 1) + i

    tris = []
    for j in range(res):
        for i in range(res):
            tris.append((vid(i, j),     vid(i + 1, j),     vid(i + 1, j + 1)))
            tris.append((vid(i, j),     vid(i + 1, j + 1), vid(i,     j + 1)))
    return V, np.asarray(tris, dtype=np.int32)


def make_uv_sphere(center, radius: float, n_lat: int = 12, n_lon: int = 24):
    """Closed UV sphere triangle mesh. Outward winding."""
    cx, cy, cz = center
    verts = [(cx, cy, cz + radius)]                                     # north
    for j in range(1, n_lat):
        theta = np.pi * j / n_lat
        sin_t, cos_t = np.sin(theta), np.cos(theta)
        for i in range(n_lon):
            phi = 2.0 * np.pi * i / n_lon
            verts.append((cx + radius * sin_t * np.cos(phi),
                          cy + radius * sin_t * np.sin(phi),
                          cz + radius * cos_t))
    verts.append((cx, cy, cz - radius))                                 # south
    V = np.asarray(verts, dtype=np.float64)

    south = len(V) - 1
    tris = []
    # North cap.
    for i in range(n_lon):
        a = 1 + i
        b = 1 + (i + 1) % n_lon
        tris.append((0, a, b))
    # Middle rings.
    for j in range(n_lat - 2):
        base0 = 1 + j * n_lon
        base1 = 1 + (j + 1) * n_lon
        for i in range(n_lon):
            a = base0 + i
            b = base0 + (i + 1) % n_lon
            c = base1 + (i + 1) % n_lon
            d = base1 + i
            tris.append((a, b, c))
            tris.append((a, c, d))
    # South cap.
    base = 1 + (n_lat - 2) * n_lon
    for i in range(n_lon):
        a = base + i
        b = base + (i + 1) % n_lon
        tris.append((a, south, b))
    return V, np.asarray(tris, dtype=np.int32)


def build_world(backend: str = "auto"):
    trusty.check_capabilities("shells", "contact")

    V_sh, F_sh = make_square_sheet(SIDE, RES, DROP_Z)
    world  = trusty.World(backend=backend,
                          timestep=1.0 / 120.0,
                          newton=trusty.NewtonConfig(max_iters=50),
                          time_stepping="bdf2")
    trusty.contact.enable(world, trusty.contact.Config(kappa=1.0e4))
    sh_cfg = trusty.shells.ShellConfig()
    sh_cfg.youngs_modulus = 1.0e5
    sh_cfg.poisson_ratio  = 0.3
    sh_cfg.thickness      = 1.0e-3
    sh_cfg.density        = 1.0e3
    body = trusty.shells.add_shell(world, V_sh, F_sh, sh_cfg)

    V_sp, F_sp = make_uv_sphere(SPHERE_CENTER, SPHERE_RADIUS)
    trusty.contact.add_wall(world, "sphere", V_sp, F_sp)
    return world, body, (V_sp, F_sp)


def _register_visuals(ps, world, body, sphere):
    V = np.asarray(trusty.shells.read_positions(world, body))
    F = np.asarray(trusty.shells.read_triangles(world, body))
    ps_sheet = ps.register_surface_mesh("shell", V, F, smooth_shade=False)
    ps_sheet.set_edge_width(1.0)
    V_sp, F_sp = sphere
    ps.register_surface_mesh("sphere", V_sp, F_sp, color=(0.6, 0.6, 0.6))
    return ps_sheet


def run_polyscope(world, body, sphere, steps: int):
    ps = init_polyscope(headless=False)
    ps_mesh = _register_visuals(ps, world, body, sphere)
    state = {"i": 0, "playing": False}

    def advance_one():
        if state["i"] < steps:
            world.step()
            state["i"] += 1
            ps_mesh.update_vertex_positions(
                np.asarray(trusty.shells.read_positions(world, body)))

    def callback():
        import polyscope.imgui as psim
        _, state["playing"] = psim.Checkbox("play", state["playing"])
        psim.SameLine()
        if psim.Button("step"):
            advance_one()
        elif state["playing"]:
            advance_one()
        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 main():
    parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    parser.add_argument("--backend",
                        choices=["auto", "cpu", "cuda", "accelerate"], default="auto")
    parser.add_argument("--steps", type=int, default=180)
    parser.add_argument("--no-viewer", action="store_true",
                        help="headless: print where the sheet ends up")
    args = parser.parse_args()

    world, body, sphere = build_world(backend=args.backend)
    if args.no_viewer:
        unconverged = 0
        for _ in range(args.steps):
            world.step()
            unconverged += not world.last_report().converged
        x = trusty.shells.read_positions(world, body)
        top = SPHERE_CENTER[2] + SPHERE_RADIUS
        print(f"after {args.steps} steps: the sheet rests at z = {x[:, 2].max():.4f} m "
              f"on the sphere's top at {top:.4f} m; its corners hang down to "
              f"z = {x[:, 2].min():.3f} m. {unconverged} step(s) not converged")
    else:
        run_polyscope(world, body, sphere, args.steps)


if __name__ == "__main__":
    main()

Reference

Every function and argument: trusty.shells reference.