Skip to content

Solids.kt

src/jvmMain/kotlin/afield/geometry/polytope/Solids.kt · 357 lines

@file:afield.runtime.completion.CompletionCategory("Polytopes")

package afield.geometry.polytope

import afield.math.linalg.Vec3
import kotlin.math.sqrt

The rank-3 value everything in this package passes around, and the five Platonic seeds to start from.

PolyhedralObject is the aggregate — a PolytopeTopology, optional coordinates, optional per-element attributes — and Realization3 is the coordinate half, kept as a separate immutable value so that a Conway operator can replace it wholesale without the topology noticing. The seeds are tetrahedron, cube, octahedron, icosahedron and dodecahedron; each returns a fully realized, outward-wound, manifold-validated object, and each is the starting point of a Conway expression such as conway("tI").

cubeWithWindow is the sixth and the odd one out: a cube whose top face has a hole in it, which makes it a surface with boundary rather than a closed manifold. It exists so that the parts of the library that handle holes have something real to be tested and demonstrated against.

Scale is not uniform across the seeds

tetrahedron, octahedron, icosahedron and dodecahedron all put their vertices on the unit sphere. cube does not: it is the ±1 cube, so its side is 2 and its circumradius sqrt(3). If you are comparing seeds, or mixing them in one scene, normalise or apply a gauge first rather than assuming a common radius.

Seed faces are wound outward

Seed faces are declared as unoriented index cycles and then reversed where needed so that every normal points away from the vertex centroid. Which means the literal lists below need only be correct as cycles — a reader checking them does not also have to check their handedness.

class Realization3(vertexCoordinates: List<Vec3>)

Immutable ownership boundary around Field's mutable Vec3. Coordinates are indexed in exactly the same order as topology.elements(0). Public reads return copies.

class Realization3(vertexCoordinates: List<Vec3>) {
    private val coordinates = vertexCoordinates.map(::Vec3)

val size: Int get() = coordinates.size

How many vertices are realized; equal to the topology's rank-0 element count.

val vertexCoordinates: List<Vec3> get() = coordinates.map(::Vec3)

Every coordinate as a fresh Vec3 list, rebuilt on each read — a snapshot, not a view, and not free.

operator fun get(index: Int): Vec3 = Vec3(coordinates[index])

One vertex position, by index into topology.elements(0) — not by ElementId. A copy: Vec3 is mutable and Field's graphics code mutates it freely, so writing into the returned vector changes nothing here. That is the whole reason this class exists.

internal fun x(index: Int): Float = coordinates[index].x

internal fun y(index: Int): Float = coordinates[index].y

internal fun z(index: Int): Float = coordinates[index].z

data class PolyhedralObject

Abstract topology plus optional ordinary three-dimensional realization and element attributes.

The two optional halves are what make it worth having: a topology-only object is legal and useful (dualize it, then choose a realization policy), and attributes ride along through a whole Conway chain under a propagation policy. The constructor checks only the two things it can: that a realization has one coordinate per topology vertex, and that no attribute names an element outside this topology.

Note what it does not check — the rank. A rank-4 topology will construct one of these quite happily. The rank-3 assumption belongs to the operators that actually need it: the Conway surface transforms require rank 3, and the triangulator requires a realization, so that is where a mismatched topology is caught rather than here.

data class PolyhedralObject(
    /** The authoritative value; ordinarily rank 3, though nothing in this class insists on it. */
    val topology: PolytopeTopology,
    /** Coordinates, or null for a purely combinatorial object. Must have one entry per rank-0 element. */
    val realization: Realization3? = null,
    /** Per-element properties, keyed by [ElementId]; every key must be an element of [topology]. */
    val attributes: PolytopeAttributes = PolytopeAttributes.EMPTY,
) {

init

    init {
        require(realization == null || realization.size == topology.elements(0).size) {
            "realization has ${realization?.size} coordinates for ${topology.elements(0).size} topology vertices"
        }
        val topologyIds = topology.elementsByRank.flatten().mapTo(mutableSetOf()) { it.id }
        require(attributes.elementIds.all { it in topologyIds }) {
            "attributes reference elements outside this topology"
        }
    }
}

private data class EdgeKey(val a: Int, val b: Int) {
    companion object {
        fun of(a: Int, b: Int) = if (a < b) EdgeKey(a, b) else EdgeKey(b, a)
    }
}

internal fun realizedPolyhedron

body — 80 lines, from line 101
internal fun realizedPolyhedron(
    coordinates: List<Vec3>,
    unorientedFaces: List<List<Int>>,
    // Inner rings per face, keyed by index into unorientedFaces. Each comes out wound against its own
    // outer ring, and that is settled below rather than asked of the caller: it is a fact about
    // coordinates, which this function has and the caller's index lists do not.
    unorientedHoles: Map<Int, List<List<Int>>> = emptyMap(),
): PolyhedralObject {
    require(unorientedFaces.all { it.size >= 3 }) { "every face needs at least three vertices" }
    require(unorientedFaces.flatten().all { it in coordinates.indices }) { "face vertex index outside coordinate list" }
    require(unorientedHoles.keys.all { it in unorientedFaces.indices }) { "hole names a face that is not there" }
    require(unorientedHoles.values.flatten().all { it.size >= 3 }) { "every hole ring needs at least three vertices" }
    val faces = orientOutward(coordinates, unorientedFaces)
    val holes = unorientedHoles.mapValues { (face, rings) ->
        // The convention is that an inner ring runs against its outer one. That is a statement about
        // geometry, so it is settled here, with coordinates in hand, rather than asked of the caller.
        val outward = newellNormal(coordinates, faces[face])
        rings.map { ring -> if (newellNormal(coordinates, ring).dot(outward) > 0f) ring.reversed() else ring }
    }
    val builder = PolytopeTopologyBuilder(rank = 3)
    val vertices = coordinates.map { builder.addElement(0) }

    fun ringsOf(face: Int): List<List<Int>> = listOf(faces[face]) + holes[face].orEmpty()

    val edgeKeys = linkedSetOf<EdgeKey>()
    for (face in faces.indices) for (cycle in ringsOf(face)) for (i in cycle.indices) {
        edgeKeys += EdgeKey.of(cycle[i], cycle[(i + 1) % cycle.size])
    }
    val edges = linkedMapOf<EdgeKey, ElementId>()
    for (key in edgeKeys) {
        val edge = builder.addElement(1)
        edges[key] = edge
        builder.addIncidence(vertices[key.a], edge)
        builder.addIncidence(vertices[key.b], edge)
    }

    val faceElements = faces.indices.map { at ->
        val face = builder.addElement(2)
        for (cycle in ringsOf(at)) for (i in cycle.indices) {
            val key = EdgeKey.of(cycle[i], cycle[(i + 1) % cycle.size])
            builder.addIncidence(edges.getValue(key), face)
        }
        builder.setFaceCycle(face, faces[at].map { vertices[it] })
        holes[at].orEmpty().forEach { ring -> builder.addFaceHole(face, ring.map { vertices[it] }) }
        face
    }
    val whole = builder.addElement(3)
    faceElements.forEach { builder.addIncidence(it, whole) }

    // A face with a hole is a surface with boundary — its inner edges bound one face, not two — so
    // the manifold pass is exactly the check that must not run on it.
    val topology = builder.buildValidated(checkManifold = unorientedHoles.isEmpty())
    return PolyhedralObject(topology, Realization3(coordinates))
}

private fun newellNormal(coordinates: List<Vec3>, cycle: List<Int>): Vec3 {
    val normal = Vec3()
    for (i in cycle.indices) {
        val p = coordinates[cycle[i]]
        val q = coordinates[cycle[(i + 1) % cycle.size]]
        normal.add(Vec3((p.y - q.y) * (p.z + q.z), (p.z - q.z) * (p.x + q.x), (p.x - q.x) * (p.y + q.y)))
    }
    return normal
}

private fun orientOutward(coordinates: List<Vec3>, faces: List<List<Int>>): List<List<Int>> {
    val center = Vec3()
    coordinates.forEach { center.add(it) }
    center.mul(1f / coordinates.size)
    return faces.map { cycle ->
        val a = coordinates[cycle[0]]
        val b = coordinates[cycle[1]]
        val c = coordinates[cycle[2]]
        val normal = Vec3(b).sub(a).cross(Vec3(c).sub(a))
        val faceCenter = Vec3()
        cycle.forEach { faceCenter.add(coordinates[it]) }
        faceCenter.mul(1f / cycle.size).sub(center)
        if (normal.dot(faceCenter) >= 0f) cycle else cycle.reversed()
    }
}

fun tetrahedron(): PolyhedralObject

A regular tetrahedron centred at the origin, with vertices on the unit sphere.

fun tetrahedron(): PolyhedralObject {
    val s = 1f / sqrt(3f)
    return realizedPolyhedron(
        listOf(
            Vec3(s, s, s),
            Vec3(s, -s, -s),
            Vec3(-s, s, -s),
            Vec3(-s, -s, s),
        ),
        listOf(
            listOf(0, 1, 2),
            listOf(0, 2, 3),
            listOf(0, 3, 1),
            listOf(1, 3, 2),
        ),
    )
}

fun cube(): PolyhedralObject

A cube of side length 2, centred at the origin — the eight sign combinations of (±1, ±1, ±1).

The one seed in this file whose vertices are not on the unit sphere: its circumradius is sqrt(3), about 1.73.

fun cube(): PolyhedralObject = realizedPolyhedron(
    listOf(
        Vec3(-1f, -1f, -1f),
        Vec3(1f, -1f, -1f),
        Vec3(1f, 1f, -1f),
        Vec3(-1f, 1f, -1f),
        Vec3(-1f, -1f, 1f),
        Vec3(1f, -1f, 1f),
        Vec3(1f, 1f, 1f),
        Vec3(-1f, 1f, 1f),
    ),
    listOf(
        listOf(0, 1, 2, 3),
        listOf(4, 5, 6, 7),
        listOf(0, 4, 5, 1),
        listOf(1, 5, 6, 2),
        listOf(2, 6, 7, 3),
        listOf(3, 7, 4, 0),
    ),
)

fun cubeWithWindow(side: Float = 0.5f): PolyhedralObject

The ±1 cube with a square window cut through the middle of its +z face: the package's one seed whose face boundaries are not all simple.

Twelve vertices — the cube's eight, plus the window's four at (±side, ±side, 1) — six faces, and one inner ring on the top face. Everything else about it is an ordinary cube.

It is not a closed manifold, and that is the point of it rather than a defect: the window's four edges bound one face each, so validate(checkManifold = true) rejects it and the Conway operators refuse it, both with messages that say so. validate() on its own passes. Use it to exercise the parts of the library that do handle a hole — PolyhedralObject.triangulate, PolyhedralObject.toFaceFLines, cellularEulerCharacteristic (which is 1 here, not 2).

@param side half the window's width, in the cube's own coordinates, so it must be inside (0, 1).

fun cubeWithWindow(side: Float = 0.5f): PolyhedralObject {
    require(side > 0f && side < 1f) { "window half-width must be inside (0, 1), got $side" }
    return realizedPolyhedron(
        listOf(
            Vec3(-1f, -1f, -1f),
            Vec3(1f, -1f, -1f),
            Vec3(1f, 1f, -1f),
            Vec3(-1f, 1f, -1f),
            Vec3(-1f, -1f, 1f),
            Vec3(1f, -1f, 1f),
            Vec3(1f, 1f, 1f),
            Vec3(-1f, 1f, 1f),
            Vec3(-side, -side, 1f),
            Vec3(side, -side, 1f),
            Vec3(side, side, 1f),
            Vec3(-side, side, 1f),
        ),
        listOf(
            listOf(0, 1, 2, 3),
            listOf(4, 5, 6, 7),
            listOf(0, 4, 5, 1),
            listOf(1, 5, 6, 2),
            listOf(2, 6, 7, 3),
            listOf(3, 7, 4, 0),
        ),
        mapOf(1 to listOf(listOf(8, 9, 10, 11))),
    )
}

fun octahedron(): PolyhedralObject

A regular octahedron centred at the origin, with vertices on the unit sphere.

fun octahedron(): PolyhedralObject = realizedPolyhedron(
    listOf(
        Vec3(1f, 0f, 0f),
        Vec3(-1f, 0f, 0f),
        Vec3(0f, 1f, 0f),
        Vec3(0f, -1f, 0f),
        Vec3(0f, 0f, 1f),
        Vec3(0f, 0f, -1f),
    ),
    listOf(
        listOf(0, 2, 4),
        listOf(2, 1, 4),
        listOf(1, 3, 4),
        listOf(3, 0, 4),
        listOf(2, 0, 5),
        listOf(1, 2, 5),
        listOf(3, 1, 5),
        listOf(0, 3, 5),
    ),
)

fun icosahedron(): PolyhedralObject

A regular icosahedron centred at the origin, with vertices on the unit sphere.

The twelve cyclic (0, ±1, ±phi) points, normalised. Unlike the other seeds its faces are not written down but found, by testing all 220 vertex triples for a supporting plane; the search insists on finding exactly 20 triangles and throws if it does not, so a change to the coordinates cannot produce a not-quite-icosahedron unnoticed.

fun icosahedron(): PolyhedralObject {
    val phi = ((1.0 + sqrt(5.0)) / 2.0).toFloat()
    val raw = buildList {
        for (a in listOf(-1f, 1f)) for (b in listOf(-phi, phi)) add(Vec3(0f, a, b))
        for (a in listOf(-1f, 1f)) for (b in listOf(-phi, phi)) add(Vec3(a, b, 0f))
        for (a in listOf(-phi, phi)) for (b in listOf(-1f, 1f)) add(Vec3(a, 0f, b))
    }
    val coordinates = raw.map { Vec3(it).normalize() }
    return realizedPolyhedron(coordinates, convexHullTriangleFaces(coordinates))
}

fun dodecahedron(): PolyhedralObject

A regular dodecahedron, constructed as the realized dual of the regular icosahedron.

No pentagon is written down anywhere. The twenty vertices are icosahedron's face centroids, then pushed back out onto the unit sphere — a centroid is inside it — and the twelve pentagonal cycles come from walking the ordered edge ring around each icosahedron vertex. So this seed costs an icosahedron plus a dualization, and it is a live check on the dual operator rather than a table.

fun dodecahedron(): PolyhedralObject {
    val dual = icosahedron().dual().polyhedron
    val realization = dual.realization ?: error("realized icosahedron dual lost its coordinates")
    return dual.copy(realization = Realization3(realization.vertexCoordinates.map { Vec3(it).normalize() }))
}

private fun convexHullTriangleFaces(coordinates: List<Vec3>): List<List<Int>> {
    val faces = mutableListOf<List<Int>>()
    val epsilon = 1e-5f
    for (a in 0 until coordinates.size - 2) {
        for (b in a + 1 until coordinates.size - 1) {
            for (c in b + 1 until coordinates.size) {
                val normal = Vec3(coordinates[b]).sub(coordinates[a])
                    .cross(Vec3(coordinates[c]).sub(coordinates[a]))
                if (normal.lengthSquared() <= epsilon * epsilon) continue
                var positive = false
                var negative = false
                for (point in coordinates.indices) {
                    if (point == a || point == b || point == c) continue
                    val side = normal.dot(Vec3(coordinates[point]).sub(coordinates[a]))
                    if (side > epsilon) positive = true
                    if (side < -epsilon) negative = true
                    if (positive && negative) break
                }
                if (!(positive && negative)) faces += listOf(a, b, c)
            }
        }
    }
    require(faces.size == 20) { "icosahedron hull should have 20 triangular faces, got ${faces.size}" }
    return faces
}

internal fun PolyhedralObject.realizationIndex(vertex: ElementId): Int

internal fun PolyhedralObject.realizationIndex(vertex: ElementId): Int {
    return topology.vertexMap[vertex.value]
        ?: throw IllegalArgumentException("element ${vertex.value} is not a topology vertex")
}