Skip to content

Rigid-Soft Coupling and Contact

Rigid-soft coupling does not require a special coupling object. Put ABD and FEM geometries in the same Scene, label their collision surfaces, and enable their material pair in ContactTabular. The global nonlinear solve advances both DOF systems and the IPC barrier transfers forces between them.

Complete mixed scene

This asset-free program places one deformable tetrahedron, one affine body, and an implicit plane in a single scene. The soft body has its own contact element so the rigid-soft pair can use different friction and resistance from the default rigid-rigid/rigid-ground model.

#include <cmath>
#include <filesystem>
#include <iostream>

#include <uipc/uipc.h>
#include <uipc/constitution/affine_body_constitution.h>
#include <uipc/constitution/stable_neo_hookean.h>

int main()
{
    using namespace uipc;
    using namespace uipc::constitution;
    using namespace uipc::core;
    using namespace uipc::geometry;

    const std::string workspace = "output/docs/rigid_soft_cpp";
    std::filesystem::create_directories(workspace);
    Engine engine{"cuda", workspace};
    World  world{engine};

    auto config       = Scene::default_config();
    config["dt"]      = 0.01;
    config["gravity"] = Vector3{0.0, -9.8, 0.0};
    Scene scene{config};

    auto& table         = scene.contact_tabular();
    auto  rigid_contact = table.default_element();
    auto  soft_contact  = table.create("soft");
    table.default_model(0.3, 1.0_GPa);
    table.insert(rigid_contact, soft_contact, 0.5, 300.0_MPa);

    const Float     s3       = std::sqrt(3.0) / 2.0;
    vector<Vector3> vertices = {Vector3{0.0, 1.0, 0.0},
                                Vector3{0.0, 0.0, 1.0},
                                Vector3{-s3, 0.0, -0.5},
                                Vector3{s3, 0.0, -0.5}};
    for(auto& p : vertices)
        p *= 0.3;
    vector<Vector4i> tetrahedra = {Vector4i{0, 1, 2, 3}};
    auto             base       = tetmesh(vertices, tetrahedra);
    label_surface(base);
    label_triangle_orient(base);

    auto soft = base;
    for(auto& p : view(soft.positions()))
        p += Vector3{0.0, 0.25, 0.0};
    StableNeoHookean fem;
    auto             moduli = ElasticModuli::youngs_poisson(100.0_kPa, 0.45);
    fem.apply_to(soft, moduli, 1000.0);
    soft_contact.apply_to(soft);
    scene.objects().create("soft_body")->geometries().create(soft);

    auto                   rigid = base;
    AffineBodyConstitution abd;
    abd.apply_to(rigid, 100.0_MPa, 1000.0);
    rigid_contact.apply_to(rigid);
    Transform transform         = Transform::Identity();
    transform.translation()     = Vector3{0.0, 1.0, 0.0};
    view(rigid.transforms())[0] = transform.matrix();
    scene.objects().create("rigid_body")->geometries().create(rigid);

    auto floor = ground(0.0);
    rigid_contact.apply_to(floor);
    scene.objects().create("floor")->geometries().create(floor);

    world.init(scene);
    if(!world.is_valid())
        return 1;
    while(world.frame() < 15)
    {
        world.advance();
        world.retrieve();
    }

    std::cout << "rigid-soft scene reached frame " << world.frame() << '\n';
    return world.is_valid() ? 0 : 1;
}
"""Minimal ABD/FEM coupled-contact scene used by the coupling tutorial."""

from pathlib import Path

import numpy as np

from uipc import Logger, Transform, Vector3, view
from uipc.core import Engine, Scene, World
from uipc.geometry import ground, label_surface, label_triangle_orient, tetmesh
from uipc.constitution import AffineBodyConstitution, ElasticModuli, StableNeoHookean
from uipc.unit import GPa, MPa, kPa


Logger.set_level(Logger.Level.Warn)
workspace = Path("output/docs/rigid_soft")
workspace.mkdir(parents=True, exist_ok=True)

engine = Engine("cuda", str(workspace))
world = World(engine)

config = Scene.default_config()
config["dt"] = 0.01
config["gravity"] = [[0.0], [-9.8], [0.0]]
scene = Scene(config)

table = scene.contact_tabular()
rigid_contact = table.default_element()
soft_contact = table.create("soft")
table.default_model(0.3, 1.0 * GPa)
table.insert(rigid_contact, soft_contact, 0.5, 300.0 * MPa)

s3 = np.sqrt(3.0) / 2.0
vertices = np.array(
    [[0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [-s3, 0.0, -0.5], [s3, 0.0, -0.5]]
) * 0.3
tetrahedra = np.array([[0, 1, 2, 3]])
base = tetmesh(vertices, tetrahedra)
label_surface(base)
label_triangle_orient(base)

# Deformable body: every vertex owns three FEM degrees of freedom.
soft = base.copy()
view(soft.positions())[:] += Vector3.Values([0.0, 0.25, 0.0])
moduli = ElasticModuli.youngs_poisson(100.0 * kPa, 0.45)
StableNeoHookean().apply_to(soft, moduli, mass_density=1000.0)
soft_contact.apply_to(soft)
scene.objects().create("soft_body").geometries().create(soft)

# Affine body: the mesh is local geometry and its instance owns 12 ABD DOFs.
rigid = base.copy()
AffineBodyConstitution().apply_to(rigid, 100.0 * MPa, mass_density=1000.0)
rigid_contact.apply_to(rigid)
transform = Transform.Identity()
transform.translate(Vector3.Values([0.0, 1.0, 0.0]))
view(rigid.transforms())[0] = transform.matrix()
scene.objects().create("rigid_body").geometries().create(rigid)

floor = ground(0.0)
rigid_contact.apply_to(floor)
scene.objects().create("floor").geometries().create(floor)

world.init(scene)
assert world.is_valid()
for _ in range(15):
    world.advance()
    world.retrieve()
assert world.is_valid()
print(f"rigid-soft scene reached frame {world.frame()}")

The two bodies keep different state models:

Geometry Constitution Dynamic state Fixed state
Soft tetrahedron StableNeoHookean Three position DOFs per vertex vertices()/is_fixed
Affine body AffineBodyConstitution Twelve DOFs per instance instances()/is_fixed
Ground implicit geometry none static by construction

Grouping geometries under one object would not couple them mechanically. Contact couples them because their surfaces share an enabled table entry.

How the contact table is resolved

ContactTabular is symmetric: inserting (rigid, soft) defines both lookup directions. Every geometry has one contact-element ID. An untagged geometry uses the default element, while create(name) allocates a new element.

The example resolves these pairs:

Pair Model used
rigid-ground default: friction 0.3, resistance 1 GPa
rigid-soft explicit: friction 0.5, resistance 300 MPa
soft-ground table fallback/default behavior unless explicitly inserted

If a pair needs precise behavior, insert it explicitly rather than relying on fallback. For example:

table.insert(soft_contact, rigid_contact,
             0.0, 300.0_MPa, false); // disable this pair
table.insert(
    soft_contact,
    rigid_contact,
    friction_rate=0.0,
    resistance=300.0 * MPa,
    enable=False,
)

Apply each element to its geometry before the geometry is copied into a scene object. Changing the local mesh afterward does not retroactively retag the scene copy.

Contact setup checklist

For every expected pair, verify this sequence:

  1. config["contact"]["enable"] is true (the default).
  2. Explicit meshes have a collision surface from label_surface.
  3. Tetrahedral surfaces are oriented with label_triangle_orient.
  4. Each geometry has the intended ContactElement.
  5. The pair entry is enabled and has non-negative friction and a sensible resistance in scene units.
  6. Initial geometry is not intersecting; keep the sanity check enabled while building a scene.
  7. d_hat, thickness, mesh scale, and initial separation are mutually consistent.

Friction additionally requires contact/friction/enable = true, which is the default. A pairwise friction coefficient of zero remains frictionless.

Resistance, ABD stiffness, and FEM modulus are different

  • ABD kappa penalizes non-rigid affine deformation of one body.
  • FEM Young's modulus controls deformation inside a soft body.
  • Contact resistance controls the pairwise normal barrier.

They use stiffness-like units but act on different energies. Increasing all three to solve an overlap usually hides a scale or initialization error and makes Newton/PCG convergence harder.

The backend computes a scene-adaptive contact-stiffness corridor when possible and clamps non-negative user resistance into it. Negative contact stiffness is the opt-in marker for adaptive kappa. See the full Contact and Collision reference for the exact defaults and precedence rules.

Solver behavior in mixed scenes

ABD and FEM Hessian blocks enter one global linear system. The FEM preconditioner selector affects FEM blocks only; ABD keeps its own block preconditioner. With linear_system/fem_preconditioner = "mas", all non-empty FEM meshes are partitioned automatically using backend-compatible clusters. Use it as a measured performance option, especially for stiff deformables, not as a requirement for physical coupling.

Deeper examples and implementation evidence