import importlib.util, os, subprocess, sys
_local = os.environ.get("PECLET_LOCAL_BUILD")
if _local:
for p in _local.split(os.pathsep):
sys.path.insert(0, p)
elif importlib.util.find_spec("peclet") is None:
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "peclet"], check=True)
# build_particle turns an SDF into a surface shell via marching cubes (scikit-image);
# the packing is shown as an interactive 3-D figure with plotly.
for _mod, _pip in (("skimage", "scikit-image"), ("plotly", "plotly")):
if importlib.util.find_spec(_mod) is None:
subprocess.run([sys.executable, "-m", "pip", "install", "-q", _pip], check=True)Custom-shaped particles: pack a boxful of beans (peclet.dem)
Author a particle as a signed distance function, turn it into a DEM particle — surface shell, moment of inertia and all — and pack a boxful.
Runs on a free Colab CPU runtime — the first cell installs
peclet from PyPI.
What you’ll learn
peclet.dem is not limited to spheres. A particle is a signed distance field (SDF) plus a set of surface points, and the collision solver works for any shape you can describe implicitly. In this example you will:
- Author a particle — a kidney bean — as a signed distance function.
- Turn it into a DEM particle with
peclet.dem.build_particle, which samples the SDF onto a grid, extracts a surface point shell by marching cubes, and computes the mass, centre of mass and full inertia tensor by voxel integration — returning the particle in its principal-axis frame so the solver’s inertia is exact (no hand-tuned numbers). - Pack a boxful of beans by Lubachevsky–Stillinger growth and measure the packing fraction.
Everything runs on a CPU in well under a minute.
import numpy as np
import math, time
import matplotlib.pyplot as plt
from peclet import dem
from peclet.dem import build_particle
plt.rcParams.update({"figure.dpi": 130, "font.size": 10, "axes.axisbelow": True,
"figure.facecolor": "white", "savefig.bbox": "tight"})
print("peclet.dem backend:", dem.execution_space)peclet.dem backend: Cuda
Step 2 — Build the DEM particle
build_particle does the heavy lifting: it samples the SDF on a grid, extracts a surface point shell (marching cubes, thinned to a controlled density), and integrates the mass properties. It returns the shape in its principal-axis body frame, so the diagonal inverse inertia the solver uses is exact.
shape = build_particle(
bean(),
bounds=((0.0, -0.7, -0.4), (0.95, 0.7, 0.4)), # a box enclosing the solid
resolution=72,
target_shell_points=220,
)
print(f"mass = {shape.mass:.4f}")
print(f"bounding radius = {shape.bounding_radius:.3f}")
print(f"surface points = {len(shape.shell)}")
print(f"principal inertia (unit mass) = {np.round(shape.inertia / shape.mass, 4)}")
print(f" -> anisotropy I_max / I_min = {shape.inertia.max() / shape.inertia.min():.2f}"
" (a bean tumbles differently about each axis)")mass = 0.1863
bounding radius = 0.687
surface points = 300
principal inertia (unit mass) = [0.0342 0.0991 0.1155]
-> anisotropy I_max / I_min = 3.37 (a bean tumbles differently about each axis)
The particle is fully described by its SDF (the field, whose zero level set is the surface) and the surface shell (the collision probes marched off it). The xy slice shows the kidney profile; the shell is the point cloud the solver tests:
fig = plt.figure(figsize=(9, 3.8))
ax = fig.add_subplot(121)
mid = shape.grid.shape[2] // 2
o, sp, g = shape.origin, shape.spacing, shape.grid
extent = [o[0], o[0] + sp[0]*(g.shape[0]-1), o[1], o[1] + sp[1]*(g.shape[1]-1)]
im = ax.imshow(g[:, :, mid].T, origin="lower", extent=extent, cmap="RdBu", vmin=-0.35, vmax=0.35)
ax.contour(g[:, :, mid].T, levels=[0], colors="k", linewidths=1.3, extent=extent)
ax.set_title("SDF slice (z = 0)"); ax.set_aspect("equal")
fig.colorbar(im, ax=ax, shrink=0.75, label="signed distance")
ax = fig.add_subplot(122, projection="3d")
sh = shape.shell
ax.scatter(sh[:, 0], sh[:, 1], sh[:, 2], s=5, c=sh[:, 1], cmap="viridis")
ax.set_title(f"surface point shell ({len(sh)} pts)"); ax.set_box_aspect((1, 1.3, 0.8))
fig.tight_layout(); plt.show()
Step 3 — Pack a boxful
We pack the beans by the Lubachevsky–Stillinger protocol: the particles start small (so there is no initial overlap), then grow while a thermostat keeps the assembly fluid. Growth is gated on the measured overlap, so they settle into gentle contact rather than being forced to interpenetrate. A final quench freezes the packing.
The particle’s shape, surface shell and inertia are shared by every particle via shape.apply_to(sim); per-particle position, orientation and scale are yours to set — exactly as for the built-in sphere.
N = 80
phi_target = 0.45
D = (N * shape.mass / phi_target) ** (1/3) # cube sized so full-size beans give phi_target
plen = 2 * shape.bounding_radius
sim = dem.Simulation(N)
shape.apply_to(sim) # <-- the imported bean becomes THE shape
sim.set_domain((0, 0, 0), (D, D, D))
sim.enable_periodicity(False, False, False)
for point, normal in [((0,0,0),(1,0,0)), ((D,0,0),(-1,0,0)), ((0,0,0),(0,1,0)),
((0,D,0),(0,-1,0)), ((0,0,0),(0,0,1)), ((0,0,D),(0,0,-1))]:
sim.add_plane(point, normal) # six walls
sim.set_gravity(0, 0, 0)
sim.set_material_params(0.3, 0.3, 0.0)
sim.set_solver_iterations(40, 8)
rng = np.random.default_rng(11)
p = rng.uniform(0.15*D, 0.85*D, (N, 4)).astype(np.float32); p[:, 3] = 1.0
sim.set_positions(p)
sim.set_velocities(rng.normal(0, 0.4, (N, 3)).astype(np.float32))
q = rng.normal(0, 1, (N, 4)).astype(np.float32); q /= np.linalg.norm(q, axis=1, keepdims=True)
sim.set_quaternions(q) # random initial orientations
sim.set_angular_velocities(np.zeros((N, 3), np.float32))
rate, dt, crit = 1.0, 0.005, 0.02 * plen
sim.set_scales(np.full(N, 1.0, np.float32))
sim.set_growth_params(rate, 0.06)
sim.set_thermostat(0.6, 0.004)
t0 = time.time()
for i in range(1200): # grow to full size, gated on overlap
grow = sim.get_max_overlap() < crit and float(sim.get_scales().mean()) < 0.999
sim.set_growth_params(rate if grow else 0.0, sim.get_growth_factor())
sim.step(dt)
sim.set_thermostat(0, 1); sim.set_material_params(0, 0, 0)
for i in range(300): # quench to a static packing
sim.step(dt)
phi = phi_target * float(np.mean(sim.get_scales() ** 3))
print(f"packed {N} beans in {time.time()-t0:.1f} s")
print(f"packing fraction phi ~ {phi:.3f}")
print(f"max overlap = {sim.get_max_overlap()/plen*100:.1f}% of the bean length")packed 80 beans in 3.8 s
packing fraction phi ~ 0.450
max overlap = 0.0% of the bean length
The packing — as solid beans
For a proper picture we don’t need the point shell. shape.grid is the bean’s SDF in its body frame, so its zero level set is the surface: one marching-cubes call gives a triangle mesh — the same shape.vertices / shape.faces you would write to an STL — that we place, rotate and scale for every bean. Rendered with Plotly the figure is interactive: drag to rotate, scroll to zoom.
import plotly.graph_objects as go
from skimage.measure import marching_cubes
pos = sim.get_positions().reshape(-1, 3)
quat = sim.get_quaternions().reshape(-1, 4) # (x, y, z, w)
scale = sim.get_scales().ravel()
# bean surface mesh in its body frame (coarsened for a light interactive figure; == shape.vertices/faces -> STL)
stride = 5
grid = np.ascontiguousarray(shape.grid[::stride, ::stride, ::stride])
cverts, cfaces, _, _ = marching_cubes(grid, level=0.0, spacing=tuple(np.array(shape.spacing) * stride))
cverts = cverts + shape.origin
def qrot(q, V): # rotate verts by quaternion q = (x, y, z, w)
qv = q[:3]; t = 2.0 * np.cross(np.broadcast_to(qv, V.shape), V)
return V + q[3] * t + np.cross(np.broadcast_to(qv, V.shape), t)
V, F, C, off = [], [], [], 0
for i in range(N):
w = qrot(quat[i], cverts * scale[i]) + pos[i] # place, rotate and scale this bean
V.append(w); F.append(cfaces + off); C.append(np.full(len(w), pos[i, 2])); off += len(w)
V, F, C = np.concatenate(V), np.concatenate(F), np.concatenate(C)
fig = go.Figure(go.Mesh3d(
x=V[:, 0], y=V[:, 1], z=V[:, 2], i=F[:, 0], j=F[:, 1], k=F[:, 2],
intensity=C, colorscale="Viridis", showscale=False, flatshading=True,
lighting=dict(ambient=0.5, diffuse=0.8, specular=0.2, roughness=0.6),
lightposition=dict(x=D, y=1.6 * D, z=1.3 * D)))
fig.update_layout(width=560, height=560, margin=dict(l=0, r=0, t=34, b=0),
title=f"N = {N} beans, φ ≈ {phi:.2f} — drag to rotate",
scene=dict(aspectmode="data", xaxis=dict(range=[0, D]), yaxis=dict(range=[0, D]),
zaxis=dict(range=[0, D]), xaxis_title="x", yaxis_title="y", zaxis_title="z"))
fig.show()Adapt this yourself
Change the particle. Swap
bean()for any SDF. Boolean CSG works too — a dumbbell is a smooth union of two lobes and a neck:def sphere(c, r): c = np.asarray(c); return lambda p: np.linalg.norm(p - c, axis=1) - r def smooth_union(a, b, k=0.08): def f(p): da, db = a(p), b(p); h = np.clip(0.5 + 0.5*(db - da)/k, 0, 1) return db*(1-h) + da*h - k*h*(1-h) return f dumbbell = smooth_union(sphere((-0.4,0,0), 0.32), sphere((0.4,0,0), 0.32)) shape = build_particle(dumbbell, ((-0.8,-0.4,-0.4), (0.8,0.4,0.4)), resolution=64)Convex shapes (rounded cubes, ellipsoids) resolve contacts most cleanly; gentle concavities like the bean’s inner curve are fine. For a full CSG algebra, feed
build_particlea callable fromfogleman/sdf.Real mass / density.
apply_touses unit mass by default. For a real density, callshape.apply_to(sim, unit_mass=False)and setsim.set_inv_mass(1/mass)— the builder exposesshape.massandshape.inertiaat your chosendensity=.Export the particle / packing.
shape.to_stl("bean.stl")writes the bean’s surface mesh to STL (also available asshape.vertices/shape.faces) — open it in Blender/ParaView/Ovito, or render the packing offline for a publication figure.sim.write_vtp("beans.vtp")writes particle centres/orientations for ParaView/Ovito;sim.get_sdf_grid((128,128,128))reconstructs the pore-space SDF of the whole bed — the bridge to a CFD run withpeclet.flow(see the random packed bed example).Go bigger / on a GPU. Increase
N; on a CUDA/HIP build the same script runs on the device. A distributed build addssim.init_mpi(...)/sim.step_mpi(...).
Reproduce this
# from a checkout of peclet-examples
pip install -e '.[sim]' # or: pip install peclet scikit-image
# refresh the rendered page from a local build of the suite:
PECLET_LOCAL_BUILD=/path/to/suite/dem/build_omp \
quarto render examples/sdf-particle-packing/index.qmd --execute