Skip to content

Transforms.kt

src/jvmMain/kotlin/afield/geometry/polytope/Transforms.kt · 1336 lines

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

package afield.geometry.polytope

import afield.math.linalg.Vec3

The Conway operator algebra: thirteen named operations that rewrite a polyhedron's combinatorics, each paired with a policy for giving the result coordinates.

A transform happens in two independent halves, and keeping them apart is the whole point of this file. PolytopeTopology.transform is pure combinatorics — it consumes an incidence complex and produces a new one plus, for every output element, an ImmediateElementProvenance naming the source elements it came from and the role it plays (face-center, edge-midpoint, kis-triangle, gyro-pentagon, …). A RealizationPolicy3 then reads those roles and computes coordinates from the source's; an AttributePropagationPolicy does the same for attributes. Swap the policy and you get a different solid over identical combinatorics.

Five primitives, eight composites

d dual, a ambo, k kis, t truncate and g gyro are implemented. j n z o e s m b are defined as primitive words — j = da, b = ta, s = dgd, o = jj, m = kj — and run one primitive at a time, so they cost as many topology rebuilds as their expansion is long. PolytopeTransformResult.history is the expanded trace; notationHistory keeps the composite you actually asked for.

Beyond standard notation

k, t and g also take an ElementSelector and an ElementDoubleParameter, so one step can kis only the pentagons, or truncate every vertex by a fraction that varies with its height. Conway notation cannot say either thing; TransformDeployment records what was selected and which scalars were resolved, so such a step is still exactly replayable.

Every operator is on both receivers

All thirteen are declared on PolyhedralObject and on PolytopeTransformResult, so a chain reads the same wherever a step sits: cube().ambo().bevel().kis(). The composites take no realization policy in either position, which is the runtime rule from transform expressed in the signature instead.

What this will not do

  • Rank 3 and closed manifolds only. Every step validates its input and its output with manifold checking on, so a non-manifold complex or an inconsistently oriented vertex fan throws rather than producing a plausible-looking bad mesh. A face with a hole is refused by name: it is a surface with boundary, and the operators are not defined on one.
  • Nothing here produces a canonical shape. The default policies are cheap local constructions — a centroid, a midpoint, a fraction along an edge — so a long chain drifts away from anything symmetric. PolyhedralObject.canonicalize is the repair.

enum class ConwayOperator(val symbol: Char, val displayName: String, val primitive: Boolean)

The Conway operator vocabulary, each with the single letter Conway notation writes it as.

primitive is the flag that matters: only the five primitive operators (d, a, k, t, g) have an implementation in PolytopeTopology.transform. The other eight are words over those five and are expanded by primitiveExpansion before anything runs, which is why they cost as many topology rebuilds as their expansion is long. displayName is the lower-case English name used in error messages, not a UI label.

enum class ConwayOperator(val symbol: Char, val displayName: String, val primitive: Boolean) {
    DUAL('d', "dual", true),
    AMBO('a', "ambo", true),
    KIS('k', "kis", true),
    TRUNCATE('t', "truncate", true),
    GYRO('g', "gyro", true),
    JOIN('j', "join", false),
    NEEDLE('n', "needle", false),
    ZIP('z', "zip", false),
    ORTHO('o', "ortho", false),
    EXPAND('e', "expand", false),
    SNUB('s', "snub", false),
    META('m', "meta", false),
    BEVEL('b', "bevel", false),
}

val ConwayOperator.primitiveExpansion: List<ConwayOperator>

Primitive operations in execution order (seed outward), not written prefix order.

val ConwayOperator.primitiveExpansion: List<ConwayOperator>
    get() = when (this) {
        ConwayOperator.DUAL,
        ConwayOperator.AMBO,
        ConwayOperator.KIS,
        ConwayOperator.TRUNCATE,
        ConwayOperator.GYRO,
        -> listOf(this)
        ConwayOperator.JOIN -> listOf(ConwayOperator.AMBO, ConwayOperator.DUAL) // j = da
        ConwayOperator.NEEDLE -> listOf(ConwayOperator.DUAL, ConwayOperator.KIS) // n = kd
        ConwayOperator.ZIP -> listOf(ConwayOperator.KIS, ConwayOperator.DUAL) // z = dk
        ConwayOperator.ORTHO -> listOf(ConwayOperator.AMBO, ConwayOperator.DUAL, ConwayOperator.AMBO, ConwayOperator.DUAL) // o = jj
        ConwayOperator.EXPAND -> listOf(ConwayOperator.AMBO, ConwayOperator.AMBO) // e = aa
        ConwayOperator.SNUB -> listOf(ConwayOperator.DUAL, ConwayOperator.GYRO, ConwayOperator.DUAL) // s = dgd
        ConwayOperator.META -> listOf(ConwayOperator.AMBO, ConwayOperator.DUAL, ConwayOperator.KIS) // m = kj
        ConwayOperator.BEVEL -> listOf(ConwayOperator.AMBO, ConwayOperator.TRUNCATE) // b = ta
    }

data class ElementRef(val id: ElementId, val rank: Int)

A source element named by both id and rank, so provenance can be read without the topology in hand.

data class ImmediateElementProvenance

Direct explanation of how one output element was made from elements of the previous topology.

data class ImmediateElementProvenance(
    val role: String,
    val sources: List<ElementRef>,
    val attributeSources: List<ElementRef> = sources,
)

sealed interface ElementLineage

The complete derivation of one output element, back to the seed elements it descends from.

ImmediateElementProvenance records one step; this records the whole chain, so a vertex of bkC still knows which cube face it came from. Nodes are shared between siblings but traversal is a tree, so a shared ancestor is visited once per path — use sourceElements rather than walking it by hand when all you want is the seed set.

sealed interface ElementLineage {

data class Source(val element: ElementRef) : ElementLineage

A seed element: it was already in the object the chain started from.

data class Derived

One operator's output, naming the role the transform gave it (face-center, kis-triangle, …) and the lineages it was built from.

    data class Derived(
        val operation: ConwayOperator,
        val role: String,
        val parents: List<ElementLineage>,
    ) : ElementLineage
}

fun ElementLineage.sourceElements(): Set<ElementRef>

Every seed element this lineage descends from, deduplicated, in order of first encounter.

fun ElementLineage.sourceElements(): Set<ElementRef> = when (this) {
    is ElementLineage.Source -> setOf(element)
    is ElementLineage.Derived -> parents.flatMapTo(linkedSetOf()) { it.sourceElements() }
}

class TopologyTransform internal constructor

One primitive operator's combinatorial output: the new topology, plus why each of its elements exists.

No coordinates and no attributes — this is the half of a Conway step that a RealizationPolicy3 and an AttributePropagationPolicy then read in order to produce the other half. The topology has already been validated as a closed manifold by the time you hold one of these.

class TopologyTransform internal constructor(
    val operation: ConwayOperator,
    val topology: PolytopeTopology,
    immediateProvenance: Map<ElementId, ImmediateElementProvenance>,
) {
    private val storedProvenance = immediateProvenance.toMap()

fun provenance(element: ElementId): ImmediateElementProvenance

How element was made, in one step. Provenance is total over the output, so an unknown element throws rather than returning null — a miss means the caller has an id from a different topology.

    fun provenance(element: ElementId): ImmediateElementProvenance = storedProvenance[element]
        ?: throw IllegalArgumentException("element ${element.value} is not in this transform output")
}

class TransformDeployment internal constructor

Replay/debug record for how an operator was deployed over its source object.

class TransformDeployment internal constructor(
    val operation: ConwayOperator,
    selectedSources: Set<ElementRef>?,
    scalarParameters: Map<ElementRef, Map<String, Double>>,
) {
    private val storedSelectedSources = selectedSources?.toSet()
    private val storedScalarParameters = scalarParameters.mapValues { it.value.toMap() }

val selectedSources: Set<ElementRef>? get() = storedSelectedSources?.toSet()

null means the operator's complete natural domain; a set records an explicit selection.

val scalarParameters: Map<ElementRef, Map<String, Double>>

    val scalarParameters: Map<ElementRef, Map<String, Double>>
        get() = storedScalarParameters.mapValues { it.value.toMap() }

companion object

internal fun global(operation: ConwayOperator)

        internal fun global(operation: ConwayOperator) = TransformDeployment(operation, null, emptyMap())

internal fun selected

        internal fun selected(
            operation: ConwayOperator,
            sources: Collection<ElementRef>,
            parameterName: String,
            values: Map<ElementId, Double>,
        ) = TransformDeployment(
            operation,
            sources.toSet(),
            sources.associateWith { source -> mapOf(parameterName to values.getValue(source.id)) },
        )
    }
}

fun interface RealizationPolicy3

Coordinates for a transform's output, computed from the source object's coordinates and the transform's provenance roles.

Returning null means "no realization", and every built-in policy does exactly that when the source is itself abstract — so a Conway chain over a topology with no coordinates stays abstract instead of inventing some. A policy that meets a role it does not recognise should fail loudly; the built-ins call error, because a silently misplaced vertex is far harder to notice than an exception.

fun realize(source: PolyhedralObject, transform: TopologyTransform): Realization3?

fun interface AttributePropagationPolicy

Which of a source element's attributes survive onto one output element.

Called once per output element with that element's ImmediateElementProvenance; whatever map it returns is the output element's attribute map, so an empty map drops everything. PreserveCommonAttributes is the default and the conservative choice. Note that a policy is handed provenance.attributeSources, which is not always the same list as provenance.sources — kis triangles, for instance, are sourced from a face and an edge but inherit only the face's attributes.

fun attributesFor

    fun attributesFor(
        source: PolyhedralObject,
        output: Element,
        provenance: ImmediateElementProvenance,
    ): Map<String, Any?>
}

object PreserveCommonAttributes : AttributePropagationPolicy

Preserve only attributes present with equal values on every selected source element.

override fun attributesFor

    override fun attributesFor(
        source: PolyhedralObject,
        output: Element,
        provenance: ImmediateElementProvenance,
    ): Map<String, Any?> {
        val sources = provenance.attributeSources
        if (sources.isEmpty()) return emptyMap()
        val maps = sources.map { source.attributes[it.id] }
        if (maps.size == 1) return maps.single()
        val commonKeys = maps.map { it.keys }.reduce { common, keys -> common intersect keys }
        return commonKeys.mapNotNull { key ->
            val value = maps.first()[key]
            if (maps.all { it[key] == value }) key to value else null
        }.toMap()
    }
}

class PolytopeTransformResult internal constructor

A polyhedron plus the record of how it was made: the object itself, per-element lineage, and three histories.

This — not PolyhedralObject — is what a Conway chain passes along, because each step needs the previous step's lineage to keep provenance and attributes attached. history is the expanded primitive trace, notationHistory is the operators the caller actually named (so bC reads back as one bevel rather than truncate then ambo), and deployments records the element selections and resolved scalar parameters needed to replay a step exactly.

class PolytopeTransformResult internal constructor(
    val polyhedron: PolyhedralObject,
    lineage: Map<ElementId, ElementLineage>,
    /** Fully expanded primitive operations, useful for exact provenance and replay. */
    val history: List<ConwayOperator>,
    /** Operations requested by the caller, retaining named Conway composites. */
    val notationHistory: List<ConwayOperator> = history,
    /** Exact source selections and resolved scalar values for primitive replay and inspection. */
    val deployments: List<TransformDeployment> = emptyList(),
) {

internal val storedLineage = lineage.toMap()

fun lineage(element: ElementId): ElementLineage

How element came to be, back to the seed. Throws for an element this result does not contain.

    fun lineage(element: ElementId): ElementLineage = storedLineage[element]
        ?: throw IllegalArgumentException("element ${element.value} is not in this transform result")

fun sourceElements(element: ElementId): Set<ElementRef> = lineage(element).sourceElements()

The seed elements element descends from — its lineage flattened and deduplicated.

companion object

    companion object {

fun seed(polyhedron: PolyhedralObject): PolytopeTransformResult

Start a chain from an existing object: every element becomes its own ElementLineage.Source and all three histories start empty. Cheap; it copies no coordinates.

body — 152 lines, from line 283
        fun seed(polyhedron: PolyhedralObject): PolytopeTransformResult {
            val lineage = polyhedron.topology.elementsByRank.flatten().associate { element ->
                element.id to ElementLineage.Source(ElementRef(element.id, element.rank))
            }
            return PolytopeTransformResult(polyhedron, lineage, emptyList(), emptyList(), emptyList())
        }
    }
}

private sealed interface TransformVertexKey {
    data class FromElement(val element: ElementId) : TransformVertexKey
    data class FromVertexEdge(val vertex: ElementId, val edge: ElementId) : TransformVertexKey
}

private data class VertexSpec(
    val key: TransformVertexKey,
    val provenance: ImmediateElementProvenance,
)

private data class FaceSpec(
    val cycle: List<TransformVertexKey>,
    val provenance: ImmediateElementProvenance,
)

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

private fun Element.asRef() = ElementRef(id, rank)

private fun buildTransform(
    source: PolytopeTopology,
    operation: ConwayOperator,
    vertices: List<VertexSpec>,
    faces: List<FaceSpec>,
): TopologyTransform {
    val builder = PolytopeTopologyBuilder(rank = 3)
    val provenance = mutableMapOf<ElementId, ImmediateElementProvenance>()
    val vertexIds = vertices.map { spec ->
        builder.addElement(0).also { provenance[it] = spec.provenance }
    }
    val vertexIndex = vertices.mapIndexed { index, spec -> spec.key to index }.toMap()
    require(vertexIndex.size == vertices.size) { "$operation produced duplicate abstract vertices" }

    val localFaceCycles = faces.map { face -> face.cycle.map { vertexIndex.getValue(it) } }
    val edgeFaces = linkedMapOf<LocalEdge, MutableList<Int>>()
    for ((faceIndex, cycle) in localFaceCycles.withIndex()) for (i in cycle.indices) {
        edgeFaces.getOrPut(LocalEdge.of(cycle[i], cycle[(i + 1) % cycle.size])) { mutableListOf() } += faceIndex
    }
    val edgeIds = linkedMapOf<LocalEdge, ElementId>()
    for ((edge, adjacentFaces) in edgeFaces) {
        val id = builder.addElement(1)
        edgeIds[edge] = id
        builder.addIncidence(vertexIds[edge.a], id)
        builder.addIncidence(vertexIds[edge.b], id)
        val sources = linkedSetOf<ElementRef>()
        sources += vertices[edge.a].provenance.sources
        sources += vertices[edge.b].provenance.sources
        adjacentFaces.forEach { sources += faces[it].provenance.sources }
        provenance[id] = ImmediateElementProvenance("boundary-edge", sources.toList())
    }

    val faceIds = faces.mapIndexed { faceIndex, face ->
        val id = builder.addElement(2)
        val cycle = localFaceCycles[faceIndex]
        for (i in cycle.indices) {
            builder.addIncidence(edgeIds.getValue(LocalEdge.of(cycle[i], cycle[(i + 1) % cycle.size])), id)
        }
        builder.setFaceCycle(id, cycle.map { vertexIds[it] })
        provenance[id] = face.provenance
        id
    }
    val whole = builder.addElement(3)
    faceIds.forEach { builder.addIncidence(it, whole) }
    val sourceTops = source.elements(3).map(Element::asRef)
    provenance[whole] = ImmediateElementProvenance("whole", sourceTops)

    return TopologyTransform(
        operation,
        builder.buildValidated(checkManifold = true),
        provenance,
    )
}

private class SurfaceContext(val topology: PolytopeTopology) {
    private val edgeByEndpoints = topology.elements(1).associate { edge ->
        topology.incidentDown(edge.id).toSet() to edge.id
    }

    init {
        require(topology.rank == 3) { "Conway surface transforms currently require rank 3, got ${topology.rank}" }
        // Checked before validation, because a holed face fails the manifold pass as a fistful of
        // NonManifoldEdge issues and that is a diagnosis of the symptom rather than the cause.
        val holed = topology.elements(2).firstOrNull { topology.faceHasHoles(it.id) }
        if (holed != null) throw IllegalArgumentException(
            "face ${holed.id.value} has ${topology.faceHoles(holed.id).size} hole(s); Conway operators are " +
                "defined here for closed manifolds, and a holed face is a surface with boundary",
        )
        topology.validate(checkManifold = true).throwIfInvalid()
    }

    fun edge(a: ElementId, b: ElementId): ElementId = edgeByEndpoints.getValue(setOf(a.value, b.value))

    fun boundaryEdges(face: ElementId): List<ElementId> {
        val cycle = topology.faceCycle(face).map(::ElementId)
        return cycle.indices.map { i -> edge(cycle[i], cycle[(i + 1) % cycle.size]) }
    }

    /** Incident edges ordered so their realized midpoint polygon has outward winding. */
    fun outwardEdgeRing(vertex: ElementId): List<ElementId> {
        val successor = mutableMapOf<ElementId, ElementId>()
        for (faceValue in incidentFaces(vertex)) {
            val cycle = topology.faceCycle(faceValue).map(::ElementId)
            val at = cycle.indexOf(vertex)
            require(at >= 0) { "face ${faceValue.value} incidence does not contain vertex ${vertex.value}" }
            val previous = cycle[(at - 1 + cycle.size) % cycle.size]
            val next = cycle[(at + 1) % cycle.size]
            val previousEdge = edge(previous, vertex)
            val nextEdge = edge(vertex, next)
            require(successor.put(nextEdge, previousEdge) == null) {
                "vertex ${vertex.value} does not have an oriented manifold fan"
            }
        }
        val start = successor.keys.minBy { it.value }
        val ring = mutableListOf(start)
        while (true) {
            val next = successor[ring.last()]
                ?: throw IllegalArgumentException("vertex ${vertex.value} has a disconnected oriented fan")
            if (next == start) break
            require(next !in ring) { "vertex ${vertex.value} has a self-intersecting oriented fan" }
            ring += next
        }
        require(ring.size == successor.size) { "vertex ${vertex.value} has a disconnected oriented fan" }
        return ring
    }

    fun faceBetweenAtVertex(a: ElementId, b: ElementId, vertex: ElementId): ElementId {
        val aFaces = topology.incidentUp(a).map(::ElementId).toSet()
        val bFaces = topology.incidentUp(b).map(::ElementId).toSet()
        return (aFaces intersect bFaces).singleOrNull { face -> vertex.value in topology.faceCycle(face) }
            ?: throw IllegalArgumentException(
                "edges ${a.value} and ${b.value} do not bound one face at vertex ${vertex.value}",
            )
    }

    private fun incidentFaces(vertex: ElementId): Set<ElementId> =
        topology.incidentUp(vertex).flatMapTo(linkedSetOf()) { edgeValue ->
            topology.incidentUp(ElementId(edgeValue)).map(::ElementId)
        }
}

fun PolytopeTopology.transform(operation: ConwayOperator): TopologyTransform

Run one primitive operator over this complex, producing new combinatorics and their provenance.

Primitives only — a composite throws, naming the primitive word to use instead. The input must be rank 3 and a closed manifold with a consistently oriented fan at every vertex, and the output is validated the same way, so both a malformed input and an operator that produced a non-manifold result surface as an exception rather than as a bad mesh.

body — 49 lines, from line 444
fun PolytopeTopology.transform(operation: ConwayOperator): TopologyTransform {
    require(operation.primitive) {
        "${operation.displayName} is a named composite; transform a PolyhedralObject or expand ${operation.symbol} to ${operation.primitiveExpansion.joinToString("") { it.symbol.toString() }}"
    }
    val surface = SurfaceContext(this)
    return when (operation) {
        ConwayOperator.DUAL -> transformDual(surface)
        ConwayOperator.AMBO -> transformAmbo(surface)
        ConwayOperator.KIS -> transformKis(surface, elements(2).mapTo(linkedSetOf()) { it.id })
        ConwayOperator.TRUNCATE -> transformTruncate(surface, elements(0).mapTo(linkedSetOf()) { it.id })
        ConwayOperator.GYRO -> transformGyro(surface)
        else -> error("composite operator reached primitive topology transform: $operation")
    }
}

private fun PolytopeTopology.transformDual(surface: SurfaceContext): TopologyTransform {
    val vertices = elements(2).map { face ->
        VertexSpec(TransformVertexKey.FromElement(face.id), ImmediateElementProvenance("face-center", listOf(face.asRef())))
    }
    val faces = elements(0).map { vertex ->
        val ring = surface.outwardEdgeRing(vertex.id)
        val faceCycle = ring.indices.map { i ->
            val face = surface.faceBetweenAtVertex(ring[i], ring[(i + 1) % ring.size], vertex.id)
            TransformVertexKey.FromElement(face)
        }
        FaceSpec(faceCycle, ImmediateElementProvenance("vertex-face", listOf(vertex.asRef())))
    }
    return buildTransform(this, ConwayOperator.DUAL, vertices, faces)
}

private fun PolytopeTopology.transformAmbo(surface: SurfaceContext): TopologyTransform {
    val vertices = elements(1).map { edge ->
        VertexSpec(TransformVertexKey.FromElement(edge.id), ImmediateElementProvenance("edge-midpoint", listOf(edge.asRef())))
    }
    val faces = mutableListOf<FaceSpec>()
    for (face in elements(2)) {
        faces += FaceSpec(
            surface.boundaryEdges(face.id).map { TransformVertexKey.FromElement(it) },
            ImmediateElementProvenance("original-face", listOf(face.asRef())),
        )
    }
    for (vertex in elements(0)) {
        faces += FaceSpec(
            surface.outwardEdgeRing(vertex.id).map { TransformVertexKey.FromElement(it) },
            ImmediateElementProvenance("vertex-face", listOf(vertex.asRef())),
        )
    }
    return buildTransform(this, ConwayOperator.AMBO, vertices, faces)
}

fun PolytopeTopology.kis(selectedFaces: Collection<ElementId>): TopologyTransform

Kis only the named faces: each grows an apex, every other face is copied unchanged.

The selection must be rank-2 elements of this topology; anything else throws. Combinatorics only — PolytopeTransformResult.kis is the version that also places the apexes.

body — 46 lines, from line 500
fun PolytopeTopology.kis(selectedFaces: Collection<ElementId>): TopologyTransform {
    val selected = validateSelection(selectedFaces, expectedRank = 2, label = "kis faces")
    return transformKis(SurfaceContext(this), selected)
}

private fun PolytopeTopology.transformKis(
    surface: SurfaceContext,
    selectedFaces: Set<ElementId>,
): TopologyTransform {
    val vertices = buildList {
        elements(0).forEach { vertex ->
            add(VertexSpec(TransformVertexKey.FromElement(vertex.id), ImmediateElementProvenance("original-vertex", listOf(vertex.asRef()))))
        }
        elements(2).filter { it.id in selectedFaces }.forEach { face ->
            add(VertexSpec(TransformVertexKey.FromElement(face.id), ImmediateElementProvenance("face-apex", listOf(face.asRef()))))
        }
    }
    val faces = mutableListOf<FaceSpec>()
    for (face in elements(2)) {
        val cycle = faceCycle(face.id).map(::ElementId)
        if (face.id !in selectedFaces) {
            faces += FaceSpec(
                cycle.map { TransformVertexKey.FromElement(it) },
                ImmediateElementProvenance("original-face", listOf(face.asRef())),
            )
            continue
        }
        val edges = surface.boundaryEdges(face.id)
        for (i in cycle.indices) {
            val edge = element(edges[i]) ?: error("missing edge ${edges[i].value}")
            faces += FaceSpec(
                listOf(
                    TransformVertexKey.FromElement(cycle[i]),
                    TransformVertexKey.FromElement(cycle[(i + 1) % cycle.size]),
                    TransformVertexKey.FromElement(face.id),
                ),
                ImmediateElementProvenance(
                    "kis-triangle",
                    listOf(face.asRef(), edge.asRef()),
                    attributeSources = listOf(face.asRef()),
                ),
            )
        }
    }
    return buildTransform(this, ConwayOperator.KIS, vertices, faces)
}

fun PolytopeTopology.truncate(selectedVertices: Collection<ElementId>): TopologyTransform

Truncate only the named vertices: each is replaced by a face, its neighbours left alone.

The selection must be rank-0 elements of this topology; anything else throws. A face with only some of its corners selected is kept and grows the corresponding pairs of points, recorded with the role partially-truncated-face — which is a shape standard Conway notation cannot describe.

body — 80 lines, from line 554
fun PolytopeTopology.truncate(selectedVertices: Collection<ElementId>): TopologyTransform {
    val selected = validateSelection(selectedVertices, expectedRank = 0, label = "truncate vertices")
    return transformTruncate(SurfaceContext(this), selected)
}

private fun PolytopeTopology.transformTruncate(
    surface: SurfaceContext,
    selectedVertices: Set<ElementId>,
): TopologyTransform {
    val vertices = buildList {
        elements(0).filter { it.id !in selectedVertices }.forEach { vertex ->
            add(VertexSpec(TransformVertexKey.FromElement(vertex.id), ImmediateElementProvenance("original-vertex", listOf(vertex.asRef()))))
        }
        elements(1).forEach { edge ->
            incidentDown(edge.id).map(::ElementId).filter { it in selectedVertices }.forEach { vertexId ->
                val vertex = element(vertexId) ?: error("missing vertex ${vertexId.value}")
                add(
                    VertexSpec(
                        TransformVertexKey.FromVertexEdge(vertexId, edge.id),
                        ImmediateElementProvenance(
                            "vertex-edge-point",
                            listOf(vertex.asRef(), edge.asRef()),
                            attributeSources = listOf(vertex.asRef()),
                        ),
                    ),
                )
            }
        }
    }
    val faces = mutableListOf<FaceSpec>()
    for (face in elements(2)) {
        val cycle = faceCycle(face.id).map(::ElementId)
        val edgeCycle = surface.boundaryEdges(face.id)
        val outputCycle = buildList {
            for (i in cycle.indices) {
                val vertex = cycle[i]
                if (vertex in selectedVertices) {
                    val previousEdge = edgeCycle[(i - 1 + edgeCycle.size) % edgeCycle.size]
                    val nextEdge = edgeCycle[i]
                    add(TransformVertexKey.FromVertexEdge(vertex, previousEdge))
                    add(TransformVertexKey.FromVertexEdge(vertex, nextEdge))
                } else {
                    add(TransformVertexKey.FromElement(vertex))
                }
            }
        }
        val selectedOnFace = cycle.count { it in selectedVertices }
        faces += FaceSpec(
            outputCycle,
            ImmediateElementProvenance(
                when (selectedOnFace) {
                    0 -> "original-face"
                    cycle.size -> "truncated-face"
                    else -> "partially-truncated-face"
                },
                listOf(face.asRef()),
            ),
        )
    }
    for (vertex in elements(0).filter { it.id in selectedVertices }) {
        faces += FaceSpec(
            surface.outwardEdgeRing(vertex.id).map { edge -> TransformVertexKey.FromVertexEdge(vertex.id, edge) },
            ImmediateElementProvenance("vertex-face", listOf(vertex.asRef())),
        )
    }
    return buildTransform(this, ConwayOperator.TRUNCATE, vertices, faces)
}

private fun PolytopeTopology.validateSelection(
    selected: Collection<ElementId>,
    expectedRank: Int,
    label: String,
): Set<ElementId> = selected.toCollection(linkedSetOf()).also { ids ->
    for (id in ids) {
        val element = element(id) ?: throw IllegalArgumentException("$label contains unknown element ${id.value}")
        require(element.rank == expectedRank) {
            "$label contains rank-${element.rank} element ${id.value}; expected rank $expectedRank"
        }
    }
}

Conway gyro. Each original edge receives two directed one-third points; every oriented source half-edge becomes one pentagon. This is the combinatorial operation, independent of geometry.

source — 47 lines, from line 639
private fun PolytopeTopology.transformGyro(surface: SurfaceContext): TopologyTransform {
    val vertices = buildList {
        elements(0).forEach { vertex ->
            add(VertexSpec(TransformVertexKey.FromElement(vertex.id), ImmediateElementProvenance("original-vertex", listOf(vertex.asRef()))))
        }
        elements(2).forEach { face ->
            add(VertexSpec(TransformVertexKey.FromElement(face.id), ImmediateElementProvenance("face-center", listOf(face.asRef()))))
        }
        elements(1).forEach { edge ->
            incidentDown(edge.id).map(::ElementId).forEach { vertexId ->
                val vertex = element(vertexId) ?: error("missing vertex ${vertexId.value}")
                add(
                    VertexSpec(
                        TransformVertexKey.FromVertexEdge(vertexId, edge.id),
                        ImmediateElementProvenance("directed-edge-point", listOf(vertex.asRef(), edge.asRef())),
                    ),
                )
            }
        }
    }
    val faces = mutableListOf<FaceSpec>()
    for (face in elements(2)) {
        val cycle = faceCycle(face.id).map(::ElementId)
        val edges = surface.boundaryEdges(face.id)
        for (i in cycle.indices) {
            val u = cycle[i]
            val v = cycle[(i + 1) % cycle.size]
            val nextEdge = edges[(i + 1) % edges.size]
            val edge = element(edges[i]) ?: error("missing edge ${edges[i].value}")
            faces += FaceSpec(
                listOf(
                    TransformVertexKey.FromVertexEdge(u, edges[i]),
                    TransformVertexKey.FromVertexEdge(v, edges[i]),
                    TransformVertexKey.FromElement(v),
                    TransformVertexKey.FromVertexEdge(v, nextEdge),
                    TransformVertexKey.FromElement(face.id),
                ),
                ImmediateElementProvenance(
                    "gyro-pentagon",
                    listOf(face.asRef(), edge.asRef()),
                    attributeSources = listOf(face.asRef()),
                ),
            )
        }
    }
    return buildTransform(this, ConwayOperator.GYRO, vertices, faces)
}

object DualCentroidRealization : RealizationPolicy3

Every dual vertex sits at the centroid of the face it replaces.

Cheap, and exactly right for a face-transitive seed. For anything else the dual's faces are not planar, because a dual face's vertices are the centroids of the faces around one original vertex and those centroids need not be coplanar — and dd therefore returns a shrunken, distorted copy rather than the original. Follow with PolyhedralObject.canonicalize when that matters.

override fun realize(source: PolyhedralObject, transform: TopologyTransform): Realization3?

    override fun realize(source: PolyhedralObject, transform: TopologyTransform): Realization3? {
        if (source.realization == null) return null
        return realizeVertices(transform) { provenance -> source.centroid(provenance.sources.single().id) }
    }
}

object AmboMidpointRealization : RealizationPolicy3

Every ambo vertex sits at its source edge's midpoint — the rectification you would draw by hand.

The faces inherited from original faces are planar whenever those were; the new vertex-faces are planar only when the edge midpoints around a vertex happen to be coplanar, which for a Conway output is usually not true.

override fun realize(source: PolyhedralObject, transform: TopologyTransform): Realization3?

    override fun realize(source: PolyhedralObject, transform: TopologyTransform): Realization3? {
        if (source.realization == null) return null
        return realizeVertices(transform) { provenance -> source.edgePoint(provenance.sources.single().id, 0.5f) }
    }
}

class KisFaceNormalRealization private constructor

Raise each selected face's apex along the face normal, by a multiple of that face's own mean edge length.

heightScale is therefore relative rather than a distance: the same number gives visually comparable spikes on a triangle and on a decagon, and on an object of any overall size. Must be finite and positive — 0 would leave the apex in the face plane and produce degenerate triangles, so it is rejected rather than tolerated.

class KisFaceNormalRealization private constructor(
    val heightScale: Double,
    private val perFaceHeightScale: Map<ElementId, Double>,
) : RealizationPolicy3 {

constructor(heightScale: Double = 0.5) : this(heightScale, emptyMap())

init

    init {
        require(heightScale.isFinite() && heightScale > 0.0) { "kis heightScale must be finite and positive" }
        require(perFaceHeightScale.values.all { it.isFinite() && it > 0.0 }) {
            "all kis height scales must be finite and positive"
        }
    }

override fun realize(source: PolyhedralObject, transform: TopologyTransform): Realization3?

    override fun realize(source: PolyhedralObject, transform: TopologyTransform): Realization3? {
        if (source.realization == null) return null
        return realizeVertices(transform) { provenance ->
            val sourceElement = provenance.sources.single()
            when (provenance.role) {
                "original-vertex" -> source.vertexCoordinate(sourceElement.id)
                "face-apex" -> {
                    val localHeightScale = perFaceHeightScale[sourceElement.id] ?: heightScale
                    val center = source.centroid(sourceElement.id)
                    val cycle = source.topology.faceCycle(sourceElement.id).map(::ElementId)
                    var meanEdgeLength = 0f
                    for (i in cycle.indices) {
                        meanEdgeLength += source.vertexCoordinate(cycle[i]).distance(
                            source.vertexCoordinate(cycle[(i + 1) % cycle.size]),
                        )
                    }
                    meanEdgeLength /= cycle.size
                    center.add(source.faceNormal(sourceElement.id).mul((meanEdgeLength * localHeightScale).toFloat()))
                }
                else -> error("unexpected kis vertex role ${provenance.role}")
            }
        }
    }

companion object

internal fun varying(heightScaleByFace: Map<ElementId, Double>)

        internal fun varying(heightScaleByFace: Map<ElementId, Double>) =
            KisFaceNormalRealization(0.5, heightScaleByFace.toMap())
    }
}

class TruncateAlongEdgesRealization private constructor

Cut each selected vertex off at fraction of the way along every edge leaving it.

Strictly between 0 and 0.5, and that bound is not conservative: at 0.5 the two cut points on a shared edge coincide and the edge collapses. The default 1/3 is not the value that makes a truncated Platonic exactly Archimedean — it is close enough to read as one, and PolyhedralObject.canonicalize is how you get the exact solid.

class TruncateAlongEdgesRealization private constructor(
    val fraction: Double,
    private val perVertexFraction: Map<ElementId, Double>,
) : RealizationPolicy3 {

constructor(fraction: Double = 1.0 / 3.0) : this(fraction, emptyMap())

init

    init {
        require(fraction.isFinite() && fraction > 0.0 && fraction < 0.5) {
            "truncate fraction must be finite and strictly between 0 and 0.5"
        }
        require(perVertexFraction.values.all { it.isFinite() && it > 0.0 && it < 0.5 }) {
            "all truncate fractions must be finite and strictly between 0 and 0.5"
        }
    }

override fun realize(source: PolyhedralObject, transform: TopologyTransform): Realization3?

    override fun realize(source: PolyhedralObject, transform: TopologyTransform): Realization3? {
        if (source.realization == null) return null
        return realizeVertices(transform) { provenance ->
            when (provenance.role) {
                "original-vertex" -> source.vertexCoordinate(provenance.sources.single().id)
                "vertex-edge-point" -> {
                    val vertex = provenance.sources.single { it.rank == 0 }.id
                    val edge = provenance.sources.single { it.rank == 1 }.id
                    val endpoints = source.topology.incidentDown(edge).map(::ElementId)
                    val other = endpoints.single { it != vertex }
                    val localFraction = perVertexFraction[vertex] ?: fraction
                    val a = source.vertexCoordinate(vertex)
                    val b = source.vertexCoordinate(other)
                    Vec3(a).mul((1.0 - localFraction).toFloat()).add(Vec3(b).mul(localFraction.toFloat()))
                }
                else -> error("unexpected truncate vertex role ${provenance.role}")
            }
        }
    }

companion object

internal fun varying(fractionByVertex: Map<ElementId, Double>)

        internal fun varying(fractionByVertex: Map<ElementId, Double>) =
            TruncateAlongEdgesRealization(1.0 / 3.0, fractionByVertex.toMap())
    }
}

class GyroSubdivisionRealization private constructor

Place gyro's two directed points on each edge at edgeFraction and one minus edgeFraction along it.

Strictly between 0 and 0.5 for the same reason as truncation: at 0.5 the pair coincides and the pentagons degenerate. 1/3 is the default and the value gyro is conventionally drawn with.

class GyroSubdivisionRealization private constructor(
    val edgeFraction: Double,
    private val perEdgeFraction: Map<ElementId, Double>,
) : RealizationPolicy3 {

constructor(edgeFraction: Double = 1.0 / 3.0) : this(edgeFraction, emptyMap())

init

    init {
        require(edgeFraction.isFinite() && edgeFraction > 0.0 && edgeFraction < 0.5) {
            "gyro edgeFraction must be finite and strictly between 0 and 0.5"
        }
        require(perEdgeFraction.values.all { it.isFinite() && it > 0.0 && it < 0.5 }) {
            "all gyro edge fractions must be finite and strictly between 0 and 0.5"
        }
    }

override fun realize(source: PolyhedralObject, transform: TopologyTransform): Realization3?

    override fun realize(source: PolyhedralObject, transform: TopologyTransform): Realization3? {
        if (source.realization == null) return null
        return realizeVertices(transform) { provenance ->
            when (provenance.role) {
                "original-vertex" -> source.vertexCoordinate(provenance.sources.single().id)
                "face-center" -> source.centroid(provenance.sources.single().id)
                "directed-edge-point" -> {
                    val vertex = provenance.sources.single { it.rank == 0 }.id
                    val edge = provenance.sources.single { it.rank == 1 }.id
                    val endpoints = source.topology.incidentDown(edge).map(::ElementId)
                    val other = endpoints.single { it != vertex }
                    val a = source.vertexCoordinate(vertex)
                    val b = source.vertexCoordinate(other)
                    val localFraction = perEdgeFraction[edge] ?: edgeFraction
                    Vec3(a).mul((1.0 - localFraction).toFloat()).add(Vec3(b).mul(localFraction.toFloat()))
                }
                else -> error("unexpected gyro vertex role ${provenance.role}")
            }
        }
    }

companion object

internal fun varying(fractionByEdge: Map<ElementId, Double>)

body — 44 lines, from line 861
        internal fun varying(fractionByEdge: Map<ElementId, Double>) =
            GyroSubdivisionRealization(1.0 / 3.0, fractionByEdge.toMap())
    }
}

private fun realizeVertices(
    transform: TopologyTransform,
    coordinate: (ImmediateElementProvenance) -> Vec3,
): Realization3 = Realization3(transform.topology.elements(0).map { coordinate(transform.provenance(it.id)) })

private fun PolyhedralObject.vertexCoordinate(vertex: ElementId): Vec3 {
    val realization = realization ?: throw IllegalStateException("realization policy requires source coordinates")
    return realization[realizationIndex(vertex)]
}

private fun PolyhedralObject.centroid(element: ElementId): Vec3 {
    val source = topology.element(element) ?: throw IllegalArgumentException("missing source element ${element.value}")
    val vertices = when (source.rank) {
        0 -> listOf(element)
        1 -> topology.incidentDown(element).map(::ElementId)
        2 -> topology.faceCycle(element).map(::ElementId)
        else -> throw IllegalArgumentException("cannot calculate centroid for rank ${source.rank}")
    }
    val center = Vec3()
    vertices.forEach { center.add(vertexCoordinate(it)) }
    return center.mul(1f / vertices.size)
}

private fun PolyhedralObject.edgePoint(edge: ElementId, fraction: Float): Vec3 {
    val endpoints = topology.incidentDown(edge).map(::ElementId)
    require(endpoints.size == 2) { "edge ${edge.value} does not have two endpoints" }
    val a = vertexCoordinate(endpoints[0])
    val b = vertexCoordinate(endpoints[1])
    return Vec3(a).mul(1f - fraction).add(Vec3(b).mul(fraction))
}

private fun defaultRealizationPolicy(operation: ConwayOperator): RealizationPolicy3 = when (operation) {
    ConwayOperator.DUAL -> DualCentroidRealization
    ConwayOperator.AMBO -> AmboMidpointRealization
    ConwayOperator.KIS -> KisFaceNormalRealization()
    ConwayOperator.TRUNCATE -> TruncateAlongEdgesRealization()
    ConwayOperator.GYRO -> GyroSubdivisionRealization()
    else -> error("${operation.displayName} is a composite and has no single-step realization policy")
}

fun PolyhedralObject.transform

Apply one Conway operator — primitive or composite — and start a chain from this object.

Equivalent to seeding a PolytopeTransformResult and transforming that. Passing a realizationPolicy for a composite throws: a composite runs as several primitive steps and one policy cannot speak for all of them; configure the primitives instead.

fun PolyhedralObject.transform(
    operation: ConwayOperator,
    realizationPolicy: RealizationPolicy3? = null,
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = PolytopeTransformResult.seed(this).transform(operation, realizationPolicy, attributePolicy)

fun PolytopeTransformResult.transform

Apply the next Conway operator to this chain.

A composite is expanded into its primitive word and run one primitive at a time with each primitive's default policy, then recorded in notationHistory as the single operator you asked for while history keeps every step. Passing a realizationPolicy alongside a composite throws.

Every operator also has a named extension on this type — bevel, needle, ortho and the rest — so this is the form to reach for when the operator is a value: iterating ConwayOperator.entries, replaying a notationHistory, or driving a chain from a UI.

body — 66 lines, from line 930
fun PolytopeTransformResult.transform(
    operation: ConwayOperator,
    realizationPolicy: RealizationPolicy3? = null,
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult {
    if (!operation.primitive) {
        require(realizationPolicy == null) {
            "a single realization policy cannot realize composite ${operation.displayName}; configure its primitive steps instead"
        }
        var result = this
        for (primitive in operation.primitiveExpansion) {
            result = result.transformPrimitive(primitive, defaultRealizationPolicy(primitive), attributePolicy, recordNotation = false)
        }
        return PolytopeTransformResult(
            result.polyhedron,
            result.storedLineage,
            result.history,
            notationHistory + operation,
            result.deployments,
        )
    }
    return transformPrimitive(operation, realizationPolicy ?: defaultRealizationPolicy(operation), attributePolicy, recordNotation = true)
}

private fun PolytopeTransformResult.transformPrimitive(
    operation: ConwayOperator,
    realizationPolicy: RealizationPolicy3,
    attributePolicy: AttributePropagationPolicy,
    recordNotation: Boolean,
): PolytopeTransformResult {
    val transformed = polyhedron.topology.transform(operation)
    return applyTransform(transformed, operation, realizationPolicy, attributePolicy, recordNotation)
}

private fun PolytopeTransformResult.applyTransform(
    transformed: TopologyTransform,
    operation: ConwayOperator,
    realizationPolicy: RealizationPolicy3,
    attributePolicy: AttributePropagationPolicy,
    recordNotation: Boolean,
    deployment: TransformDeployment = TransformDeployment.global(operation),
): PolytopeTransformResult {
    val realization = realizationPolicy.realize(polyhedron, transformed)
    var attributes = PolytopeAttributes.EMPTY
    for (element in transformed.topology.elementsByRank.flatten()) {
        attributes = attributes.with(
            element.id,
            attributePolicy.attributesFor(polyhedron, element, transformed.provenance(element.id)),
        )
    }
    val output = PolyhedralObject(transformed.topology, realization, attributes)
    val outputLineage = transformed.topology.elementsByRank.flatten().associate { element ->
        val immediate = transformed.provenance(element.id)
        val parents = immediate.sources.map { source ->
            runCatching { lineage(source.id) }.getOrElse { ElementLineage.Source(source) }
        }
        element.id to ElementLineage.Derived(operation, immediate.role, parents)
    }
    return PolytopeTransformResult(
        output,
        outputLineage,
        history + operation,
        if (recordNotation) notationHistory + operation else notationHistory,
        deployments + deployment,
    )
}

fun PolyhedralObject.dual

Conway d: faces become vertices and vertices become faces, edges staying edges.

An involution combinatorially — dd is the original complex — but not geometrically, because the default DualCentroidRealization moves every vertex to a face centroid. See DualCentroidRealization for what that costs.

fun PolyhedralObject.dual(
    realizationPolicy: RealizationPolicy3 = DualCentroidRealization,
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.DUAL, realizationPolicy, attributePolicy)

fun PolyhedralObject.ambo

Conway a: rectification. Each edge midpoint becomes a vertex, so the result has one face per original face and one per original vertex, and every vertex has degree exactly 4.

aC is the cuboctahedron; aa is the composite e (expand).

fun PolyhedralObject.ambo(
    realizationPolicy: RealizationPolicy3 = AmboMidpointRealization,
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.AMBO, realizationPolicy, attributePolicy)

fun PolyhedralObject.kis

Conway k: raise a pyramid on every face. Original vertices stay exactly where they were; a face of n sides becomes n triangles around a new apex, so the result is a triangulation.

kC is the tetrakis hexahedron. The ElementSelector overload kises only some faces, which is the direction this library extends furthest beyond standard notation.

fun PolyhedralObject.kis(
    realizationPolicy: RealizationPolicy3 = KisFaceNormalRealization(),
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.KIS, realizationPolicy, attributePolicy)

fun PolyhedralObject.truncate

Conway t: cut every vertex off and replace it with a face. A vertex of degree n becomes an n-gon; a face of m sides becomes a 2m-gon.

tC is an approximately regular truncated cube — canonicalize for the Archimedean one. The ElementSelector overload truncates only some vertices, which standard notation cannot say.

fun PolyhedralObject.truncate(
    realizationPolicy: RealizationPolicy3 = TruncateAlongEdgesRealization(),
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.TRUNCATE, realizationPolicy, attributePolicy)

fun PolytopeTransformResult.dual

dual in the middle of a chain: same operator, carrying lineage, history and deployments forward.

fun PolytopeTransformResult.dual(
    realizationPolicy: RealizationPolicy3 = DualCentroidRealization,
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.DUAL, realizationPolicy, attributePolicy)

fun PolytopeTransformResult.ambo

ambo in the middle of a chain: same operator, carrying lineage, history and deployments forward.

fun PolytopeTransformResult.ambo(
    realizationPolicy: RealizationPolicy3 = AmboMidpointRealization,
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.AMBO, realizationPolicy, attributePolicy)

fun PolytopeTransformResult.kis

kis in the middle of a chain: same operator, carrying lineage, history and deployments forward.

fun PolytopeTransformResult.kis(
    realizationPolicy: RealizationPolicy3 = KisFaceNormalRealization(),
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.KIS, realizationPolicy, attributePolicy)

fun PolytopeTransformResult.truncate

truncate in the middle of a chain: same operator, carrying lineage, history and deployments forward.

fun PolytopeTransformResult.truncate(
    realizationPolicy: RealizationPolicy3 = TruncateAlongEdgesRealization(),
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.TRUNCATE, realizationPolicy, attributePolicy)

fun PolyhedralObject.kis

Kis only selected source faces, with a height scale independently evaluated on each face.

fun PolyhedralObject.kis(
    selector: ElementSelector,
    heightScale: ElementDoubleParameter = ElementParameters.constant(0.5),
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = PolytopeTransformResult.seed(this).kis(selector, heightScale, attributePolicy)

fun PolytopeTransformResult.kis

Kis only the faces the selector matches, mid-chain, with a height scale evaluated once per selected face.

Both the selection and the resolved heights are recorded in deployments, so the step can be replayed exactly. A height that is not finite and positive throws, naming the face.

fun PolytopeTransformResult.kis(
    selector: ElementSelector,
    heightScale: ElementDoubleParameter = ElementParameters.constant(0.5),
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult {
    val faces = polyhedron.topology.elements(2).filter { selector.matches(selectionContext(it)) }
    val heights = faces.associate { face ->
        face.id to heightScale.value(selectionContext(face)).also {
            require(it.isFinite() && it > 0.0) {
                "kis height for face ${face.id.value} must be finite and positive, got $it"
            }
        }
    }
    val transformed = polyhedron.topology.kis(faces.map { it.id })
    return applyTransform(
        transformed,
        ConwayOperator.KIS,
        KisFaceNormalRealization.varying(heights),
        attributePolicy,
        recordNotation = true,
        deployment = TransformDeployment.selected(
            ConwayOperator.KIS,
            faces.map { ElementRef(it.id, it.rank) },
            "heightScale",
            heights,
        ),
    )
}

fun PolyhedralObject.truncate

Truncate only selected source vertices, with an independently evaluated cut fraction.

fun PolyhedralObject.truncate(
    selector: ElementSelector,
    fraction: ElementDoubleParameter = ElementParameters.constant(1.0 / 3.0),
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = PolytopeTransformResult.seed(this).truncate(selector, fraction, attributePolicy)

fun PolytopeTransformResult.truncate

Truncate only the vertices the selector matches, mid-chain, with a cut fraction evaluated once per selected vertex.

Both the selection and the resolved fractions are recorded in deployments. A fraction outside (0, 0.5) throws, naming the vertex.

fun PolytopeTransformResult.truncate(
    selector: ElementSelector,
    fraction: ElementDoubleParameter = ElementParameters.constant(1.0 / 3.0),
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult {
    val vertices = polyhedron.topology.elements(0).filter { selector.matches(selectionContext(it)) }
    val fractions = vertices.associate { vertex ->
        vertex.id to fraction.value(selectionContext(vertex)).also {
            require(it.isFinite() && it > 0.0 && it < 0.5) {
                "truncate fraction for vertex ${vertex.id.value} must be finite and in (0, 0.5), got $it"
            }
        }
    }
    val transformed = polyhedron.topology.truncate(vertices.map { it.id })
    return applyTransform(
        transformed,
        ConwayOperator.TRUNCATE,
        TruncateAlongEdgesRealization.varying(fractions),
        attributePolicy,
        recordNotation = true,
        deployment = TransformDeployment.selected(
            ConwayOperator.TRUNCATE,
            vertices.map { ElementRef(it.id, it.rank) },
            "fraction",
            fractions,
        ),
    )
}

fun PolyhedralObject.gyro

Apply gyro everywhere while varying its directed edge-point fraction by source edge.

fun PolyhedralObject.gyro(
    edgeFraction: ElementDoubleParameter,
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = PolytopeTransformResult.seed(this).gyro(edgeFraction, attributePolicy)

fun PolytopeTransformResult.gyro

Gyro everywhere, mid-chain, with the directed edge-point fraction evaluated once per source edge.

Unlike the selector overloads of kis and truncate this has no selection — gyro is applied to the whole object and only its parameter varies, because a partially gyrated surface has no well-defined combinatorics here.

fun PolytopeTransformResult.gyro(
    edgeFraction: ElementDoubleParameter,
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult {
    val fractions = polyhedron.topology.elements(1).associate { edge ->
        edge.id to edgeFraction.value(selectionContext(edge)).also {
            require(it.isFinite() && it > 0.0 && it < 0.5) {
                "gyro fraction for edge ${edge.id.value} must be finite and in (0, 0.5), got $it"
            }
        }
    }
    val transformed = polyhedron.topology.transform(ConwayOperator.GYRO)
    return applyTransform(
        transformed,
        ConwayOperator.GYRO,
        GyroSubdivisionRealization.varying(fractions),
        attributePolicy,
        recordNotation = true,
        deployment = TransformDeployment.selected(
            ConwayOperator.GYRO,
            polyhedron.topology.elements(1).map { ElementRef(it.id, it.rank) },
            "edgeFraction",
            fractions,
        ),
    )
}

fun PolyhedralObject.gyro

Conway g: every oriented half-edge becomes a pentagon, so an n-gon face becomes n pentagons and the result is chiral.

gD gives the combinatorics of the pentagonal hexecontahedron. s = dgd (snub) is how the snub solids are built from it.

fun PolyhedralObject.gyro(
    realizationPolicy: RealizationPolicy3 = GyroSubdivisionRealization(),
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.GYRO, realizationPolicy, attributePolicy)

fun PolytopeTransformResult.gyro

gyro in the middle of a chain: same operator, carrying lineage, history and deployments forward.

fun PolytopeTransformResult.gyro(
    realizationPolicy: RealizationPolicy3 = GyroSubdivisionRealization(),
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.GYRO, realizationPolicy, attributePolicy)

The eight composites. Each is declared on both receivers, so an expression reads the same whether it is the first step or the fifth: cube().ambo().bevel().kis(). They take no realization policy — a composite runs as several primitives and one policy cannot speak for all of them — which is the same rule transform enforces, expressed in the signature instead of at runtime. notationHistory records the composite you asked for; history records what ran.

fun PolyhedralObject.join

Conway j = da: the rhombic form. Every original edge becomes one quadrilateral spanning its two endpoints and the centres of its two faces; jC is the rhombic dodecahedron.

fun PolyhedralObject.join(
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.JOIN, attributePolicy = attributePolicy)

fun PolytopeTransformResult.join

join in the middle of a chain: same operator, carrying lineage, history and deployments forward.

fun PolytopeTransformResult.join(
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.JOIN, attributePolicy = attributePolicy)

fun PolyhedralObject.needle

Conway n = kd: needle. The dual, kised — so every face of the original becomes a vertex and the result is a triangulation whose long diagonals run between the old face centres. nC is the triakis octahedron's combinatorics.

fun PolyhedralObject.needle(
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.NEEDLE, attributePolicy = attributePolicy)

fun PolytopeTransformResult.needle

needle in the middle of a chain: same operator, carrying lineage, history and deployments forward.

fun PolytopeTransformResult.needle(
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.NEEDLE, attributePolicy = attributePolicy)

fun PolyhedralObject.zip

Conway z = dk: zip, the dual of the needle. Every original face keeps its place and every vertex becomes a face, as truncation does, but the faces come out as hexagons over a triangulation rather than as doubled polygons. zC is the truncated octahedron's combinatorics.

fun PolyhedralObject.zip(
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.ZIP, attributePolicy = attributePolicy)

fun PolytopeTransformResult.zip

zip in the middle of a chain: same operator, carrying lineage, history and deployments forward.

fun PolytopeTransformResult.zip(
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.ZIP, attributePolicy = attributePolicy)

fun PolyhedralObject.ortho

Conway o = jj: ortho. Join twice, so every original face of n sides becomes n quadrilaterals and every original edge becomes a vertex of degree 4. oC is the tetrakis hexahedron's dual form.

fun PolyhedralObject.ortho(
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.ORTHO, attributePolicy = attributePolicy)

fun PolytopeTransformResult.ortho

ortho in the middle of a chain: same operator, carrying lineage, history and deployments forward.

fun PolytopeTransformResult.ortho(
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.ORTHO, attributePolicy = attributePolicy)

fun PolyhedralObject.expand

Conway e = aa: pull every face away from the centre, filling the gaps with a quadrilateral per original edge and an n-gon per original vertex. eC is the rhombicuboctahedron.

fun PolyhedralObject.expand(
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.EXPAND, attributePolicy = attributePolicy)

fun PolytopeTransformResult.expand

expand in the middle of a chain: same operator, carrying lineage, history and deployments forward.

fun PolytopeTransformResult.expand(
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.EXPAND, attributePolicy = attributePolicy)

fun PolyhedralObject.snub

Conway s = dgd: the snub. Original faces survive, separated by bands of triangles, and the result is chiral. sC is the snub cube.

fun PolyhedralObject.snub(
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.SNUB, attributePolicy = attributePolicy)

fun PolytopeTransformResult.snub

snub in the middle of a chain: same operator, carrying lineage, history and deployments forward.

fun PolytopeTransformResult.snub(
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.SNUB, attributePolicy = attributePolicy)

fun PolyhedralObject.meta

Conway m = kj: triangulate completely by adding both a face centre and an edge midpoint, so every output face is a triangle. mC has 48 of them.

fun PolyhedralObject.meta(
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.META, attributePolicy = attributePolicy)

fun PolytopeTransformResult.meta

meta in the middle of a chain: same operator, carrying lineage, history and deployments forward.

fun PolytopeTransformResult.meta(
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.META, attributePolicy = attributePolicy)

fun PolyhedralObject.bevel

Conway b = ta: truncate the rectification, so each original face, vertex and edge becomes a face and no two of the original faces touch. bC is the truncated cuboctahedron.

fun PolyhedralObject.bevel(
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.BEVEL, attributePolicy = attributePolicy)

fun PolytopeTransformResult.bevel

bevel in the middle of a chain: same operator, carrying lineage, history and deployments forward.

fun PolytopeTransformResult.bevel(
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = transform(ConwayOperator.BEVEL, attributePolicy = attributePolicy)