Note
Go to the end to download the full example code.
Facets (Triangular & Line Boundaries)#
A facet in JaxDEM is a 2D line segment or 3D triangle used to model boundaries, walls, or polygonal obstacles. Clump-based walls are bumpy because they are made of spheres. Facets give flat faces.
Internally, facets reuse the existing sphere-based broad-phase collision detection and rigid-body integration. The geometric resolution (point-triangle or segment-segment shortest distance) happens during the narrow-phase force calculation.
This guide covers:
The internal representation of a facet (spheres mapped to vertices).
Creating single facets dynamically using
add_facet().Creating whole triangular meshes using
add_mesh().Dynamic facet connectivity using
add_connected_facet().Shared-node contact surfaces on bonded deformable particles.
The “thickness” parameter (Minkowski expansion) for rounded edge-edge collisions.
Force Routing (how to trigger facet force models).
Visualizing facets in VTK.
Internal Representation#
JaxDEM represents contact facets through particle keys at their vertices:
3D Facets use 3 spheres (one at each vertex).
2D Facets use 2 spheres.
Depending on the application, facets can be:
Rigid: Vertices share the same
clump_idand belong to a rigid clump with a computed center of mass (COM) and moment of inertia.Independent vertices: Vertices have unique
clump_idvalues and may move independently. This provides deforming contact geometry, not a cohesive elastic shell: add a bonded model to enforce material elasticity.
To detect collisions with facets during broad-phase:
The standard
CellListrecomputes each facet’s current extent from its search-key vertex to all facet vertices, adds thickness, and takes the maximum with the stored_radlower bound.MultiCellListalso uses these current bounds, including every face incident on a shared node.
Each vertex stores its first face in facet_id / facet_vertices.
Shared nodes store additional faces in facet_ids_extra /
facet_vertices_extra. Forces from all incident faces accumulate on the
same physical node; no extra mass or integration degree of freedom is added.
import jax.numpy as jnp
import tempfile
from pathlib import Path
import numpy as np
import jaxdem as jdem
# Initialize an empty state
empty_state = jdem.State.create(
pos=jnp.zeros((0, 3)),
)
Creating a Single Facet#
Create a single facet with the add_facet()
method. Pass a (3, 3) array of vertices (or (2, 2) in 2D).
The method computes the center of mass, true moment of inertia, and relative
vertex offsets.
safety_factor enlarges the stored _rad lower bound established at
construction. The colliders still recompute current flexible-facet geometry;
use a factor above one when the stored bound should include extra margin.
L = 1.0
vertices = jnp.array([[L, 0.0, -L / 2], [-L, 0.0, -L / 2], [0.0, 0.0, L]])
state = jdem.State.add_facet(
empty_state,
vertices,
thickness=0.1,
mass=5.0, # Total mass of the facet
vel=jnp.array([0.0, -2.0, 0.0]), # Initial velocity (1 value per facet)
species_id=1, # Species ID used for Force Routing
rigid=True, # Rigid facet clump
safety_factor=1.2, # Safety factor to enlarge detection box
)
print(f"Number of particles in state: {state.N} (3 vertices for 1 facet)")
print(f"Shared Clump ID: {state.clump_id}")
Number of particles in state: 3 (3 vertices for 1 facet)
Shared Clump ID: [0 0 0]
Creating a Triangular Mesh#
add_mesh() builds entire boundary walls or
polyhedra from a collection of mesh vertices and face connectivity indices (faces).
Rigid vs. independent vertices:
rigid=Truegroups the mesh into one body;rigid=Falsecreates independent face vertices. Shared input coordinates are duplicated between faces; no shared-node constraint or bonded elasticity is added automatically.Solid vs. Shell: If
rigid=True, setfilled=Trueto calculate the center of mass and moment of inertia of a solid filled polyhedron, orfilled=Falsefor a hollow shell/boundary. For open meshes (uneven surfaces, sheets, or boundaries that are not closed), setfilled=False(shell mode). Only then do the center of mass and inertia calculations give physically correct values.
mesh_vertices = jnp.array(
[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
)
mesh_faces = jnp.array(
[
[0, 2, 1], # bottom face
[0, 1, 3], # front face
[0, 3, 2], # side face
[1, 2, 3], # diagonal face
]
)
# Create a rigid, solid (filled) mesh
state_rigid_mesh = jdem.State.add_mesh(
empty_state,
mesh_vertices,
mesh_faces,
thickness=0.1,
rigid=True,
filled=True,
mass=10.0,
species_id=1,
)
# Create a mesh with independently moving face vertices
state_flex_mesh = jdem.State.add_mesh(
empty_state,
mesh_vertices,
mesh_faces,
thickness=0.1,
rigid=False,
mass=10.0,
species_id=1,
)
print(f"Rigid mesh particles: {state_rigid_mesh.N} (4 faces * 3 vertices = 12)")
print(f"Flexible mesh particles: {state_flex_mesh.N}")
Rigid mesh particles: 12 (4 faces * 3 vertices = 12)
Flexible mesh particles: 12
Dynamic Facet Connection#
To construct custom boundaries incrementally from ordinary particles and new
vertices, use add_connected_facet().
You specify the vertices of the new facet using a list of vertex_specs.
A scalar integer index refers to an existing particle in the
State, including one already used by another facet.An ArrayLike position array of shape
(dim,)defines a new vertex.
Rules & Constraints:
Same Species ID: All connected existing and new vertices must share the same
species_id.No Hybrid Facets: You cannot mix rigid and flexible vertices in the same facet (i.e., you cannot connect a rigid clumped vertex to a flexible vertex).
Dynamic Clump Update: If you connect new vertices to an existing rigid clump,
add_connected_facet()updates the center of mass, moment of inertia, and relative vertex offsets of that clump.Single Clump for Rigid Connections: If the new facet shares multiple existing rigid vertices, they must already belong to the same rigid clump. You cannot connect vertices from two different rigid clumps into one facet.
Shared nodes: Existing nodes may belong to several facets. Duplicate faces or repeated vertices within one face are rejected.
# Start with one ordinary particle, then reuse it as one vertex of a triangle.
# Its radius is retained, so choose it to match the intended facet thickness.
connection_seed = jdem.State.create(
pos=jnp.array([[0.0, 0.0, 0.0]]), rad=jnp.array([0.1]), species_id=jnp.array([1])
)
state_connected = jdem.State.add_connected_facet(
connection_seed,
[0, jnp.array([1.0, 0.0, 0.0]), jnp.array([0.0, 1.0, 0.0])],
thickness=0.1,
rigid=False,
mass=2.5,
species_id=1,
)
print(f"After connection, N = {state_connected.N} (2 new vertices added)")
After connection, N = 3 (2 new vertices added)
Force Routing & Thickness (Minkowski Sum)#
Even though the broad-phase collider triggers on the inflated
vertex spheres, the chosen ForceModel
(e.g. FacetFacetSpringForce)
sets the actual collision shape.
Facets have a physical property called thickness, passed during facet/mesh creation.
The collision engine expands the thin mathematical triangle or segment by a
Minkowski sum with a sphere. The sphere radius is the vertex particle’s
radius, which stores the thickness value (i.e., \(R = \text{thickness}\)).
Because of this expansion:
Faces are flat planes shifted outwards by \(R\).
Edges are rounded cylinders (pipes) of radius \(R\).
Vertices are rounded spheres of radius \(R\).
Edge-to-edge and vertex-to-vertex collisions then resolve without sharp-corner singularities.
JaxDEM supports the following narrow-phase force models for facets:
sphere_facet_spring: For sphere-to-facet contacts.facet_facet_spring: For facet-to-facet contacts.
At zero separation these models choose a contact normal that may not be
an energy gradient. Use FIRE or another force-only optimizer for physical
relaxation. Value-based optimizers differentiate energy directly. An
intersecting contact can have a flat energy, so an energy minimum need not
be force balanced. Energy AD requires
Naive or
NeighborList; direct
CellList and
MultiCellList
traversals do not support reverse-mode differentiation.
The ForceRouter routes the contacts. In a typical simulation with normal spheres (species 0)
and boundaries/facets (species 1), you route collisions as:
(0, 0)->springorhertz(sphere-sphere contacts)(1, 0)->sphere_facet_spring(sphere-facet contacts)(1, 1)->facet_facet_spring(facet-facet contacts)
# Create a material
mat = jdem.Material.create(
"elasticfrict", young=1e3, poisson=0.3, density=1.0, mu=0.5, e=0.5, mu_r=0.1
)
mat_table = jdem.MaterialTable.from_materials([mat])
Route sphere/facet and facet/facet contacts separately.
Note that the force models take no thickness parameter: the contact
thickness comes from the facet vertices’ rad, set via the
thickness argument of add_facet() /
add_mesh().
router = jdem.ForceRouter.from_dict(
S=2,
mapping={
(0, 1): jdem.ForceModel.create("sphere_facet_spring"),
(1, 1): jdem.ForceModel.create("facet_facet_spring"),
},
)
Spatial colliders such as "cell_list" and "multi_cell_list" work with
facets and derive their search bounds from the current facet geometry.
This four-particle walkthrough uses naive search to keep compilation short.
Passing state= to create() forwards it to colliders that need it,
so there is no need for collider_kw={"state": ...}.
Hold the connected triangle fixed and add one sphere just above its face.
Its center is 0.15 above the plane, while the radius plus facet expansion
is 0.2: overlap=0.05, giving a normal spring force 1000*0.05=50.
state_connected.fixed = jnp.ones(state_connected.N, dtype=bool)
fixed_facet_state = state_connected
state_connected = jdem.State.merge(
state_connected,
jdem.State.create(pos=[[0.2, 0.2, 0.15]], rad=[0.1], species_id=[0]),
)
system = jdem.System.create(
state=state_connected,
collider_type="naive",
force_model=router,
mat_table=mat_table,
dt=1e-4,
)
# Check an actual sphere/facet interaction before advancing.
state_connected, system = jdem.System.initialize(state_connected, system)
np.testing.assert_allclose(
state_connected.force[-1], [0.0, 0.0, 50.0], rtol=2e-5, atol=1e-5
)
print("Sphere normal contact force:", state_connected.force[-1])
fixed_positions = np.asarray(state_connected.pos[:3]).copy()
state_stepped, system = system.step(state_connected, system, n=2)
np.testing.assert_allclose(state_stepped.pos[:3], fixed_positions, atol=1e-7)
assert float(state_stepped.pos[-1, 2]) > 0.15
print("Sphere moved away from the fixed facet.")
Sphere normal contact force: [ 0. 0. 50.]
Sphere moved away from the fixed facet.
Watch the sphere approach, make contact, and rebound#
Start a unit-mass sphere well above the same fixed triangle, moving toward
its interior at speed 1.5. This law is an undamped elastic spring: the
material’s friction and restitution coefficients are not used by
sphere_facet_spring. With contact stiffness 1000, the sphere stores its
incoming kinetic energy in the spring, reverses direction, then separates.
The contact begins when the center is one sphere radius plus one facet thickness from the triangle’s plane, here 0.2. Capture complete states and systems every 0.025 time units, including the initial configuration. The 41 saved frames cover one physical time unit and resolve both travel and the approximately 0.1-time-unit contact interval.
impact_state = jdem.State.merge(
fixed_facet_state,
jdem.State.create(
pos=jnp.array([[0.2, 0.2, 0.8]]),
vel=jnp.array([[0.0, 0.0, -1.5]]),
rad=jnp.array([0.1]),
mass=jnp.array([1.0]),
species_id=jnp.array([0]),
),
)
impact_system = jdem.System.create(
state=impact_state,
collider_type="naive",
force_model=router,
mat_table=mat_table,
dt=0.0005,
rotation_integrator_type=None,
)
impact_state, impact_system = jdem.System.initialize(impact_state, impact_system)
state_stepped, system, (scene_states, scene_systems) = jdem.System.trajectory_rollout(
impact_state, impact_system, strides=jnp.asarray([0] + [50] * 40)
)
scene_title = "An elastic sphere bouncing from a fixed facet"
impact_positions = np.asarray(scene_states.pos)
impact_velocities = np.asarray(scene_states.vel[:, -1, 2])
contact_overlap = np.maximum(0.2 - impact_positions[:, -1, 2], 0.0)
assert impact_positions.shape == (41, 4, 3)
assert np.isfinite(impact_positions).all()
np.testing.assert_allclose(
impact_positions[:, :3],
np.broadcast_to(fixed_positions, impact_positions[:, :3].shape),
atol=1e-7,
)
np.testing.assert_allclose(
impact_positions[:, -1, :2],
np.broadcast_to([0.2, 0.2], impact_positions[:, -1, :2].shape),
atol=1e-7,
)
assert contact_overlap.max() > 0.04
assert impact_positions[:, -1, 2].min() > 0.1
assert impact_velocities[0] < -1.4
assert impact_velocities[-1] > 1.4
np.testing.assert_allclose(impact_velocities[-1], -impact_velocities[0], rtol=2e-3)
assert contact_overlap[-1] == 0.0
system.check_overflow()
print(f"Maximum sphere/facet overlap: {contact_overlap.max():.4f}")
print(
f"Incoming/outgoing normal velocity: {impact_velocities[0]:.3f} / {impact_velocities[-1]:.3f}"
)
Maximum sphere/facet overlap: 0.0474
Incoming/outgoing normal velocity: -1.500 / 1.500
Selecting Contact Calculations#
Both facet laws enable all contact calculations by default. Their switches are static JAX configuration: changing a switch compiles a new kernel with the disabled calculations omitted. The same choices apply to force and potential energy.
For sphere_facet_spring, face, edge, and vertex select the
feature containing the closest point. For example, a face-only wall ignores
contacts whose closest point lies on an edge or vertex; it does not substitute
a farther enabled feature. In 2D, edge controls the open line segment and
vertex controls its endpoints; face has no effect.
sphere_face_only = jdem.ForceModel.create(
"sphere_facet_spring", edge=False, vertex=False
)
For facet_facet_spring, the switches select three geometry query groups:
vertex_triangle: each vertex against the opposite triangle, including its edges and vertices.edge_edge: each pair of edges, including their endpoints.edge_triangle: edge piercings through the opposite triangle.
The closest remaining candidate defines the contact. These query groups
overlap physical contact types: parallel face-face contacts, for example,
can be detected by edge-edge queries. In 2D only edge_edge is used.
The following law omits vertex-triangle and piercing queries, retaining
edge-edge distances. Use such a reduction only when those omitted queries
are unnecessary for the geometry: it can miss interior crossings or contacts
with a face away from its boundary.
facet_edges_only = jdem.ForceModel.create(
"facet_facet_spring", vertex_triangle=False, edge_triangle=False
)
# Configure each species pair separately through the usual router.
selective_router = jdem.ForceRouter.from_dict(
S=2,
mapping={
(0, 0): jdem.ForceModel.create("spring"),
(0, 1): sphere_face_only,
(1, 1): facet_edges_only,
},
)
Pass force_model=selective_router to create() to use these
choices. Disabling every applicable switch makes that law return zero
force, torque, and potential energy.
VTK Visualization#
When writing the simulation state, VTKWriter splits the output:
It writes normal spheres (non-facet particles, where
facet_id == -1) with the standardsphereswriter.It writes facet vertex spheres (where
facet_id != -1) to a separate PVD collection namedfacet_spheres.It exports the facet surfaces/triangles with the
facetswriter.
The VTK rendering has one nuance. The collision engine uses the full vertex radius (rad)
as the Minkowski expansion radius for contacts. The VTK writer draws the facets with a Minkowski radius of
state.rad / 2 (half-thickness) to show the physical sheet thickness. In ParaView you may therefore
see a small gap of size state.rad / 2 between colliding spheres
and the rendered facet surface.
tmp_dir = Path(tempfile.mkdtemp(prefix="jaxdem-facets-guide-"))
with jdem.VTKWriter(
directory=tmp_dir, writers=["facets", "spheres", "facet_spheres"]
) as writer:
for saved_state, saved_system in zip(
jdem.State.unstack(scene_states),
jdem.System.unstack(scene_systems),
strict=True,
):
writer.save(saved_state, saved_system)
print(f"Saved VTK output (check the generated {tmp_dir} directory).")
# Output is retained for ParaView inspection; remove the directory when done.

ParaView rendering of this run’s VTKWriter output.
Saved VTK output (check the generated /tmp/jaxdem-facets-guide-ylawfxj3 directory).
Total running time of the script: (0 minutes 37.675 seconds)