Printing auxetic lattices: generating gyroid and diamond TPMS meshes with real density control
Auxetic lattices are the structures that get wider when you stretch them - the opposite of ordinary materials. That property makes them excellent for cushioning, prosthetic liners, lightweight panels and things that need to conform under load.
The good news: the two most useful families (gyroid and diamond, both Triply Periodic Minimal Surfaces) fit in about 30 lines of Python, and print fine on a stock FDM machine. Here's how to generate them, and the density trap that most generators don't tell you about.
The math is one formula per lattice
A TPMS is defined by an implicit equation f(x, y, z) = t. Sweep t and you get nested surfaces through the volume. The two workhorses:
import numpy as np
def gyroid_field(X, Y, Z):
return (np.sin(X) * np.cos(Y) + np.sin(Y) * np.cos(Z) + np.sin(Z) * np.cos(X))
def diamond_field(X, Y, Z):
return (np.cos(X) * np.cos(Y) + np.cos(Y) * np.cos(Z) + np.cos(Z) * np.cos(X))
The solid is everything where f < t (or > t, depending on which phase you want).
From field to printable STL: marching cubes on a padded grid
from skimage import measure
import trimesh
def lattice_to_mesh(field_fn, size_mm=30.0, n=96, t=0.0):
xs = np.linspace(-np.pi * 2, np.pi * 2, n)
X, Y, Z = np.meshgrid(xs, xs, xs, indexing="ij")
solid = (field_fn(X, Y, Z) < t).astype(float)
# zero-pad: marching cubes MUST see closed boundaries or your mesh leaks
padded = np.pad(solid, 1)
spacing = size_mm / n
verts, faces, _, _ = measure.marching_cubes(padded, level=0.5, spacing=(spacing,) * 3)
mesh = trimesh.Trimesh(vertices=verts, faces=faces, process=False)
if mesh.volume < 0:
mesh.invert()
return mesh
Two details that decide whether the print works:
- Pad before marching cubes. Without the pad, surfaces touching the array edge stay open - the slicer sees a leaky shell and the print fails in ways that waste hours.
- Run fix_normals with
multibody=True. Depending ont, a TPMS has disconnected solid regions; a single-body normal fix flips the wrong walls.
The density trap: target ≠ measured
You'd think "set t so 50% of the voxels are solid, get 50% density." You don't. The isosurface cuts through the boundary voxels, and the faceted surface carves away material, so the printed mesh measures noticeably lighter than the voxel target.
I measured this on gyroid at three target fractions:
| target | measured (mesh volume) |
|---|---|
| 0.30 | ~0.27 |
| 0.50 | ~0.43 |
| 0.70 | ~0.64 |
Roughly a 3-15% loss depending on density - monotonic and predictable, which means you can calibrate: pick the target that lands where you actually want.
If a generator reports only the target fraction, it's hiding the number that matters. Measure the mesh volume, not the voxel count. (t isn't linear in density either - I pick it from the voxel histogram, scanning candidate levels until the histogram fraction matches the target.)
What to print first
A 30 mm cube of gyroid at measured ~0.43 is a good first test: prints without supports (the surfaces self-support at shallow angles almost everywhere), and you can feel the auxetic behavior by pulling opposite faces. Diamond at 0.3 is the stiff-but-light option.
If you want the pipeline without assembling it - level calibration, both lattices, Blender import, and measured-vs-target density reporting per part - that's my LatticeGen plugin (includes a Blender bridge), and PrintIQ adds bed packing and slicer time estimates on top. The pre-print mesh checks behind them are free in stl-precheck.
Start at 0.3-0.45 measured density: that's the sweet spot where auxetic behavior shows up without needing support material.
Comments
No comments yet. Start the discussion.