Skip to content

A polytope, cut open, and traced

Let's build a polyhedron, cut it open, and render it. We'll also keep track of where its faces came from, so the finished image can colour the shape by its own history.

Three parts of Field do the work: afield.geometry.polytope builds the shape with Conway operators, afield.geometry.solid.manifold cuts the closed solid, and afield.graphics.scene.fabricated renders it with a GPU path tracer.

The shape is bD: bevel of the dodecahedron, better known as the truncated icosidodecahedron. It has 62 faces, few enough to count and enough that a mistake shows.

A path-traced truncated icosidodecahedron over a pale floor with the Venice lagoon behind it: twelve brushed-gold decagons, twenty deep blue hexagons, thirty transparent squares

The colours come from the shape's history. Each face of bD descends from exactly one element of the dodecahedron it was built from, and the rank of that one element picks the material: a face over a pentagon is gold, a face over an edge is glass, a face over a vertex is blue. So the twelve decagons, the thirty squares and the twenty hexagons in that picture are the dodecahedron's twelve faces, thirty edges and twenty vertices, and the picture is a drawing of the seed's own incidence counts.

Environment: Venice Sunset by Greg Zaal / Poly Haven (CC0), shared with the Fabricate demo assets.

What crosses each hop

hop the call what crosses what stops here
topology to triangles triangulate positions, indices, and a map from each triangle back to its polygon nothing; this is the only hop that carries provenance forwards
triangles to a solid toSolid positions and indices the triangle-to-polygon map. The binding hands Manifold three floats per vertex and nothing else
a solid back to triangles toTriMesh positions, indices, optionally split normals any correspondence with the triangles that went in. A Boolean retriangulates
triangles to a scene importTriMesh positions, normals, a material per triangle zero-area triangles, which are counted and skipped

Provenance crosses the first hop and stops at the second, so a per-face material has to be assigned before any Boolean. triangulate returns a TriangulatedGeometry, and sourceFace(t) on it names the polygon triangle t was cut from, so the material selector is a lineage lookup with nothing in between:

val chain = canonicalConway("bD")
val geometry = chain.polyhedron.triangulate(normals = NormalPolicy.Flat)

val scene = PathTraceScene()
val gold = scene.material(PathTraceMaterial.metal(1.0f, 0.73f, 0.31f, 0.32f))
val glass = scene.material(PathTraceMaterial.thinGlass(1.52f, Vector3f(0.9f, 0.97f, 0.94f)))
val plastic = scene.material(PathTraceMaterial.plastic(0.05f, 0.09f, 0.34f, 0.3f))

scene.importTriMesh(
    geometry.mesh,
    { t ->
        // Bevel sends every output face back to exactly one element of the seed, so `single()` is
        // safe here and throws the moment that stops being true.
        when (chain.sourceElements(geometry.sourceFace(t)).single().rank) {
            2 -> gold
            1 -> glass
            else -> plastic
        }
    },
)

Thin glass rather than solid glass, for the thirty squares

Solid glass measures its attenuation between surface crossings, so it assumes a closed, outward-wound, non-overlapping mesh. A single face of a closed polyhedron is a patch, not a volume, and thinGlass is the material for a patch.

Roughness 0.32 on the gold is a choice about the environment rather than about the metal. A decagon is flat and large, and a flat mirror that size returns one direction of a hazy sky, which reads as pale. Spreading the specular lobe averages the map and the gold tint comes back.

The numbers each hop owes the next

Run this to read the counts off your own build. It needs no renderer: everything up to the trace is CPU work.

:::static
import afield.geometry.solid.manifold.*

val thickness = 0.09 // wall thickness, in the polytope's own units; the shape spans about 2

val chain = canonicalConway("bD")
val geometry = chain.polyhedron.triangulate(normals = NormalPolicy.Flat)

/*
The triangulator cuts each polygon on its own, so a corner shared by three faces comes out as
three vertices. Weld before handing it over: being a manifold is a property of the indices, and
unwelded vertices describe the same surface while sharing an edge nowhere.
*/
val plain = geometry.mesh.weld(1e-6)

:::fold measure every hop, then lay the numbers out
val topology = chain.polyhedron.topology
val byRank = topology.elements(2).groupBy { chain.sourceElements(it.id).single().rank }
val censusRows =
    byRank.keys.sortedDescending().joinToString("\n") { rank ->
        val group = byRank.getValue(rank)
        val sides = group.map { topology.faceCycle(it.id).size }.distinct().sorted()
        val seed = ["a vertex", "an edge", "a pentagon"].getOrElse(rank) { "?" }
        "| rank $rank, $seed | ${group.size} | ${sides.joinToString(", ")} |"
    }

Solid.scope {
    val solid = plain.toSolid()

    /*
    Erode by a ball, then subtract. What is left is the set of points within `thickness` of the
    surface, so the wall is that thick everywhere. Scaling the solid down instead gives a wall
    thicker at the vertices than at the face centres, which looks similar and is a different shape.
    */
    val ball = Solid.sphere(thickness, segments = 24)
    val wall = solid - solid.minkowskiDifference(ball)
    val cut = wall.trimByPlane(0.0, -1.0, 0.0, 0.0).checked("cut shell")

    val volumeGap = kotlin.math.abs(plain.signedVolume() - solid.volume)
    val ratio = wall.volume / (solid.surfaceArea * thickness)

    // Content at column zero, because `$censusRows` is several lines that begin there: an indented
    // block plus trimIndent would leave the rest indented far enough to read as code.
    markdown(
        "chain",
        """
#### `canonicalConway("bD")`, and where each face came from

| the seed element a face descends from | faces | sides each |
|---|---|---|
$censusRows

The dodecahedron has 12 faces, 30 edges and 20 vertices. The polytope arrives as
${geometry.triangleCount} triangles over ${geometry.mesh.vertexCount} vertices as the triangulator
left them, and ${plain.vertexCount} after welding.

#### The same surface, measured by two implementations that share no code

| | Manifold | Field's own mesh code |
|---|---|---|
| closed | `${solid.status}` | `${plain.isWatertight()}` |
| genus | ${solid.genus} | ${plain.genus()} |
| volume | ${solid.volume.f4} | ${plain.signedVolume().f4} |
| surface area | ${solid.surfaceArea.f4} | |

They are ${"%.2e".format(volumeGap)} apart on the volume.

#### Eroded by a ball of radius $thickness, then cut open

| | |
|---|---|
| wall volume | ${wall.volume.f4} |
| area × thickness | ${(solid.surfaceArea * thickness).f4}, so the ratio is ${ratio.f3} |
| wall genus | ${wall.genus} |
| wall triangles | ${wall.triangleCount} |
| cut open | genus ${cut.genus}, volume ${cut.volume.f4} |
""",
        width = 860,
    )
    Unit
}

chain-polytope-readout

Four of those numbers are cross-checks rather than reports.

  • 120 welded vertices. The truncated icosidodecahedron has 120 vertices, so the weld recovering exactly that number says the triangulator's duplicated corners were duplicates and not a shape.
  • Two volumes that agree. Manifold integrates the solid in double precision; Field sums signed tetrahedra over float triangles in signedVolume. They share no code, so a disagreement here points at the bridge rather than at either of them.
  • Genus 0 twice. Field's genus() reads indices and Manifold's genus reads its own half-edge structure.
  • Area times thickness. A wall of constant thickness over a convex body has volume A·t less a curvature term, so the ratio has to land a little under one. Reading exactly one would mean the erosion had not happened; reading far under it would mean the thickness was not constant.

The shell's genus is −1. Manifold reports genus from the Euler characteristic of the whole boundary; a closed shell has two boundary components, two spheres give χ = 4, and g = (2 − χ) / 2 is then −1. decompose() is how to get at components separately.

Through the Booleans, and back out to the tracer

The second render erodes bD into a shell of constant thickness, cuts it in half with a plane, and traces the lower half as a bowl.

A path-traced terracotta bowl: the lower half of a constant-thickness shell of the truncated icosidodecahedron, its rim thickness visible all the way round and its interior facets rounded

The rim is the same thickness the whole way round: erosion keeps the thickness constant. The interior facets are rounded where the exterior ones are sharp, because eroding by a ball cannot reproduce a concave corner tighter than the ball.

Two details of the last hop are worth copying:

// A 20-degree crease rather than the default 60. This surface is all facets, and at 60 the shallow
// ones average together and the polytope stops reading as one.
val shaded = cut.toTriMesh(normals = true, minSharpAngle = 20.0)

val added = scene.importTriMesh(shaded, body)
println("${added.triangleCount} triangles appended, ${added.degenerateCount} dropped")

Manifold's output of a Boolean followed by a plane trim contains a few triangles of zero area; the scene skips them, and degenerateCount says how many.

Where each piece is documented