Skip to content

Lattices

A lattice is a periodic field you intersect a solid with, and the implicit engine carries two families of them. They are different mathematics, and the difference is what you need to know before reading a wall thickness off either:

what it isdistance fidelitywhat the parameter means
TPMS — Sdf.TpmsSheet / Sdf.TpmsSolida level set of a trigonometric polynomiala lower bound (1-Lipschitz, exact sign)the thickness is a guaranteed minimum wall
Strut — Sdf.StrutLatticea periodic union of capsulesexactthe diameter is the diameter

Lattice(pattern) intersects a solid with any of them — the additive-manufacturing infill workhorse:

var scene = new Scene(new MeshQuality { SdfResolution = 110 });
scene.Add(new Part("gyroid lattice",
Shape.Sphere(16).Lattice(Sdf.Gyroid(cellSize: 12, thickness: 1.2)),
Palette.Slate, Matrix4d.CreateTranslation((0, 0, 16))));

A sphere filled with a gyroid lattice

Any hand-written Sdf works as the pattern, and Shape.From(sdf) wraps arbitrary fields back into the modeling vocabulary — see dropping down to the engines.

Triply periodic minimal surfaces

Eight of them, each the zero level set F(p) = 0 of a trigonometric polynomial. That polynomial is the standard nodal approximation rather than the exact minimal surface — and it is what every implicit lattice in additive manufacturing is built from:

TpmsKind.SchwarzP, SchwarzD, Gyroid, Neovius, IwP, Lidinoid, FischerKochS, SplitP.

var kinds = new[]
{
TpmsKind.SchwarzP, TpmsKind.SchwarzD, TpmsKind.Gyroid, TpmsKind.Neovius,
TpmsKind.IwP, TpmsKind.Lidinoid, TpmsKind.FischerKochS, TpmsKind.SplitP,
};
var colours = new[]
{
Palette.Steel, Palette.Teal, Palette.Brass, Palette.Coral,
Palette.Sage, Palette.Plum, Palette.Sky, Palette.Rose,
};
// Every one at the SAME volume fraction, so what the picture shows is the surface
// rather than an arbitrary thickness.
var scene = new Scene(new MeshQuality { SdfResolution = 120 });
for (int i = 0; i < kinds.Length; i++)
{
var fit = Tpms.SheetForVolumeFraction(kinds[i], cellSize: 13, volumeFraction: 0.25);
scene.Add(new Part(kinds[i].ToString(),
Shape.From(fit.Field) & Shape.Box(26, 26, 26), colours[i],
Matrix4d.CreateTranslation((i % 4 * 38 - 57, i / 4 * 38 - 19, 14))));
}

Eight triply periodic minimal surfaces as thickened sheets, all at a 25% volume fraction

Sheet or network — they are different solids

Every surface comes in two variants and an engineer needs both. Sdf.TpmsSheet thickens the surface into a wall; Sdf.TpmsSolid fills one side of it, giving a network — one of the two interpenetrating labyrinths the surface separates. A gyroid network is not a gyroid sheet:

var sheet = Tpms.SheetForVolumeFraction(TpmsKind.Gyroid, cellSize: 14, volumeFraction: 0.3);
var network = Tpms.SolidForVolumeFraction(TpmsKind.Gyroid, cellSize: 14, volumeFraction: 0.3);
var scene = new Scene(new MeshQuality { SdfResolution = 140 });
scene.Add(new Part("sheet", Shape.From(sheet.Field) & Shape.Box(30, 30, 30),
Palette.Teal, Matrix4d.CreateTranslation((-25, 0, 16))));
scene.Add(new Part("network", Shape.From(network.Field) & Shape.Box(30, 30, 30),
Palette.Coral, Matrix4d.CreateTranslation((25, 0, 16))));

A gyroid sheet next to a gyroid network, both at a 30% volume fraction

The structural difference is easy to state and is measured rather than asserted: the sheet separates space into two disconnected voids, because it is the wall between the labyrinths, while the network’s complement is a single connected void — it is one labyrinth, and what is left is the other one.

Why the wall comes out thicker than you asked

This is the one thing to know before reading a dimension off a TPMS.

F is a trigonometric polynomial, so it is neither a distance nor 1-Lipschitz — its gradient magnitude varies over space. The field divides |F| by the global maximum of |grad F|, which is what makes it 1-Lipschitz and therefore a genuine lower bound on the distance to the surface, the contract meshing and the polygonizer’s cull both need. The price is that where the local gradient is smaller than that maximum, the wall is correspondingly thicker. Measured on each surface’s own level set — the factor by which the wall exceeds the nominal thickness:

surfacemedianworst
gyroid1.151.24
I-WP1.191.68
Schwarz D1.201.26
Schwarz P1.321.77
Fischer–Koch S1.412.24
Split P1.544.95
Lidinoid1.6527.4
Neovius2.3237.7

So the thickness you state is a guaranteed minimum wall — the useful direction for a printable part, and the wrong one for a mass estimate. (The last two worst cases are not a defect in the constant: their level sets pass through a near-critical point where |grad F| falls to 0.046, so the surface is genuinely nearly pinched there.)

Cross-checked by polygonization: a gyroid sheet asked for 1.2 mm of wall on a 10 mm cell measures 1.144× the nominal surface-area-times-thickness, which is the median factor above.

No choice of divisor fixes this, which is why it is worth stating rather than apologising for. A sheet is a band of a level set, and a band’s width at a surface point is 2L/|grad F| — so it varies wherever the gradient does. Dividing by a different constant only rescales the whole distribution; dividing by the local gradient would make the wall first-order uniform and would cost the 1-Lipschitz contract the field exists to keep (twenty-odd times steeper at Lidinoid’s near-critical point, so the polygonizer’s cull would widen by that and buy nothing).

Asking for the wall you want

So the wall is reported instead. Tpms.WallThickness measures what a nominal thickness produces, and Tpms.SheetForWallThickness inverts it:

// What a 1 mm nominal thickness actually gives you.
var wall = Tpms.WallThickness(TpmsKind.Neovius, cellSize: 8, thickness: 1.0);
if (wall.Minimum < 1.0 - 1e-6)
throw new Exception("the nominal thickness is supposed to be a floor");
if (wall.MedianExcess < 2)
throw new Exception($"Neovius should read about 2.3x, not {wall.MedianExcess}");
// Or state the wall and let it solve the thickness.
var solved = Tpms.SheetForWallThickness(TpmsKind.Neovius, cellSize: 8, wall: 1.0);
if (Math.Abs(solved.Wall.Median - 1.0) > 1e-6)
throw new Exception($"asked for a 1 mm median wall, got {solved.Wall.Median}");
if (solved.Wall.Nominal > 0.5) // about 0.43: the excess factor, undone
throw new Exception("the solve barely moved the thickness");
var sheet = Shape.From(solved.Field) & Shape.Box(24, 24, 24);

SheetWall.Maximum is an upper bound and deliberately over-states near a pinch — read it as how non-uniform is this surface, not as a length to plan around.

Volume fraction is the parameter you actually want

Tpms.SheetForVolumeFraction and Tpms.SolidForVolumeFraction take the fraction of space the material should occupy and solve for the thickness or the level. Both report what they achieved rather than echoing the request — the fraction is solved as a quantile of a sampled unit cell and then re-measured on a second grid sharing no sample with the first, so the reported value is an independent measurement:

var fit = Tpms.SheetForVolumeFraction(TpmsKind.Gyroid, cellSize: 10, volumeFraction: 0.3);
// The thickness that got there, and the fraction that was measured on the result.
if (Math.Abs(fit.VolumeFraction - 0.3) > 0.02)
throw new Exception($"asked for 0.3, measured {fit.VolumeFraction}");
if (fit.Parameter <= 0)
throw new Exception("a sheet needs a positive thickness");
// Struts work the same way, and there the parameter IS the diameter.
var octet = StrutLattices.ForVolumeFraction(StrutLatticeKind.Octet, cellSize: 10, volumeFraction: 0.25);
if (Math.Abs(octet.VolumeFraction - 0.25) > 0.02)
throw new Exception($"asked for 0.25, measured {octet.VolumeFraction}");

Which level splits space evenly

Sdf.TpmsSolid’s default level of 0 is the surface itself. For the five surfaces whose polynomial has an antisymmetry that level splits space exactly in half; the other three measurably do not, and quoting 0.5 for them would be repeating literature the geometry contradicts:

surfacefraction at level 0
Schwarz P, Schwarz D, gyroid, Neovius, Fischer–Koch S0.500
Split P0.510
I-WP0.469
Lidinoid0.385

The first row is verified two ways — by counting samples of the field, and by polygonizing the network inside a block of whole cells and integrating its mesh volume, which lands on 0.5000.

Strut lattices

Six of them: StrutLatticeKind.SimpleCubic, BodyCentredCubic, FaceCentredCubic, Octet, Diamond, Kelvin.

var kinds = new[]
{
StrutLatticeKind.SimpleCubic, StrutLatticeKind.BodyCentredCubic, StrutLatticeKind.FaceCentredCubic,
StrutLatticeKind.Octet, StrutLatticeKind.Diamond, StrutLatticeKind.Kelvin,
};
var colours = new[]
{
Palette.Steel, Palette.Teal, Palette.Brass, Palette.Coral, Palette.Sage, Palette.Plum,
};
var scene = new Scene(new MeshQuality { SdfResolution = 130 });
for (int i = 0; i < kinds.Length; i++)
{
var fit = StrutLattices.ForVolumeFraction(kinds[i], cellSize: 14, volumeFraction: 0.2);
scene.Add(new Part(kinds[i].ToString(),
Shape.From(fit.Field) & Shape.Box(28, 28, 28), colours[i],
Matrix4d.CreateTranslation((i % 3 * 36 - 36, i / 3 * 36 - 18, 15))));
}

Six strut lattices — simple cubic, BCC, FCC, octet, diamond and Kelvin — all at a 20% volume fraction

These are exact distance fields. A strut is a capsule, whose distance is exact, and the exact distance to a union is the minimum over its members — so strutDiameter means exactly what it says, Sdf.LipschitzBound stays 1, and nothing comes out thicker than you asked for. That is the whole contrast with the TPMS family above, and it is why the two live behind different factories rather than one Lattice(kind) that would hide it.

StrutLattices.UnitCell(kind, cellSize) reports the struts a cell is made of, if you want to draw or measure them yourself.

Why Repeat cannot build one

Sdf.Repeat looks like the obvious way to tile a unit cell, and it refuses — correctly. A lattice’s struts span the whole cell, which is what makes them join into a lattice at all, so a capsule’s bounds overhang the cell by the strut radius on every side. Repeat’s two-cells-per-axis window is sound only while the child fits inside one cell: outside that a query point can be nearest to an instance the evaluation never visits, and the sign would be wrong. Shortening the axes so the solids fit would make consecutive copies meet at a single tangent point instead of joining — a pinched lattice rather than a lattice.

So a strut lattice folds the query point itself and visits a three-wide neighbourhood. Doing that per query is what makes a lattice unusable rather than merely slow (measured 0.9–4.7 µs a sample), so the pruning is done once, at construction: the cell is divided into sub-cells, each keeping the struts that can be nearest to a point inside it. A query is then a fold, an index and a short scan — 132–429 ns a sample, the same order as a gyroid’s 75 ns. In a batch the points are partitioned by sub-cell first, so a whole register’s worth of them share one candidate list and the struts broadcast as constants: measured 2.0–2.8× over the six kinds, bit-identical to the scalar path.

Graded lattices

A real lattice is rarely uniform — you want stiffness where the stress is and porosity where the flow is. LatticeGrading is a parameter that varies over space, and every lattice factory takes one:

// A gyroid sheet, thin at the bottom and thick at the top.
var thickness = LatticeGrading.Along(direction: (0, 0, 1), start: -18, end: 18,
atStart: 0.35, atEnd: 1.35);
// ... and an octet lattice graded by VOLUME FRACTION, which is the parameter you state:
// dense where the load goes in, open where it does not.
var fraction = LatticeGrading.Along(direction: (0, 0, 1), start: -18, end: 18,
atStart: 0.42, atEnd: 0.06);
var scene = new Scene(new MeshQuality { SdfResolution = 200 });
scene.Add(new Part("graded sheet",
Shape.From(Sdf.TpmsSheet(TpmsKind.Gyroid, cellSize: 9, thickness))
& Shape.Box(26, 26, 36),
Palette.Teal, Matrix4d.CreateTranslation((-20, 0, 19))));
scene.Add(new Part("graded struts",
Shape.From(StrutLattices.GradedForVolumeFraction(StrutLatticeKind.Octet, cellSize: 9, fraction))
& Shape.Box(26, 26, 36),
Palette.Coral, Matrix4d.CreateTranslation((20, 0, 19))));

A gyroid sheet whose wall thickens from bottom to top, beside an octet lattice whose struts thin from bottom to top

What is graded is the parameter, never the cell size. Grading the thickness leaves the structure underneath exactly as it was — the polynomial is still periodic, and a strut lattice’s fold and candidate lists are statements about the strut axes, which do not move — so the exact sign and everything else the uniform field guarantees carries over unchanged. The only cost is the reported Sdf.LipschitzBound, which becomes 1 + L where L is the grading’s own constant, so the polygonizer’s cull widens by exactly the right amount.

A grading states its own Lipschitz constant, and the factories that can derive it do:

factoryconstant
LatticeGrading.Constant(v)0
LatticeGrading.Along(direction, start, end, atStart, atEnd)|atEnd − atStart| / |end − start|, exact
LatticeGrading.Radial(centre, r0, r1, atInner, atOuter)|atOuter − atInner| / |r1 − r0|, exact
LatticeGrading.FromFunction(f, lipschitzConstant, min, max)yours to state

Nothing here can measure the constant for you — sampling a function proves nothing about it between the samples — and a constant that is too small makes the cull drop geometry silently, so FromFunction asks. Values are clamped into the stated range, which is what makes that range a guarantee. A grading of Constant reproduces the uniform field bit for bit.

// Volume-fraction grading: 12% at one end, 40% at the other.
var grading = LatticeGrading.Along((0, 0, 1), start: -4, end: 4, atStart: 0.12, atEnd: 0.40);
var field = Tpms.GradedSheetForVolumeFraction(TpmsKind.Gyroid, cellSize: 4, grading);
// The bound is 1 + the grading's own, so a cull knows how far to widen.
double bound = field.LipschitzBound(new Aabb((-10, -10, -10), (10, 10, 10)));
if (bound <= 1 || bound > 1.5)
throw new Exception($"unexpected Lipschitz bound {bound}");

Note what a graded lattice gives up: it is no longer periodic, so there is no “sample one cell” to measure an achieved volume fraction from. What the grading states is the fraction the local cell would carry.

A conformal lattice — one following a curved body — is already expressible by composing Twist, Bend or Taper over any of these fields; each reports its own Lipschitz factor, so the cull stays sound through them.