Skip to content

Conway.kt

src/jvmMain/kotlin/afield/geometry/polytope/Conway.kt · 184 lines

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

package afield.geometry.polytope

Conway prefix notation: the one-line string that names a polyhedron by how it is built.

conway("bkC") reads right to left — start from the cube, kis it, bevel that — and hands back a PolytopeTransformResult with the full lineage attached. That is the whole public surface most callers need. parseConwayExpression exposes the parse on its own, for a UI that wants to report a bad character's offset before running anything, and canonicalConway is the same evaluation followed by a canonical embedding.

The notation this accepts

A final seed letter — T C O I D for the five Platonic solids — preceded by any number of the operator letters in ConwayOperator. Whitespace is ignored anywhere. Two operators additionally accept a degree selector written as digits after the letter: k5D kises only the pentagonal faces, t3C truncates only the degree-3 vertices. That is a Field extension of the notation, built on ElementSelectors; digits after any other operator are a parse error rather than a silent ignore, and so is a digit with no operator to attach to.

What it will not do

There is no grouping, no repetition count and no seed beyond the five Platonics — no (ka)3T, no prisms, no antiprisms. Coordinates come from each operator's default realization policy, so a long expression evaluates to something combinatorially exact and geometrically drifted; reach for canonicalConway when you want a solid you would recognise.

enum class ConwaySeed(val symbol: Char)

The five Platonic seeds a Conway expression can start from, each with the capital letter the notation names it by.

These are the only seeds the parser knows. Anything else — a prism, an imported mesh, a topology you built by hand — has to enter through PolyhedralObject and the operator extension functions rather than through a notation string.

enum class ConwaySeed(val symbol: Char) {
    TETRAHEDRON('T'),
    CUBE('C'),
    OCTAHEDRON('O'),
    ICOSAHEDRON('I'),
    DODECAHEDRON('D'),
    ;

fun create(): PolyhedralObject

A fresh realized seed, from the constructors in Solids.kt. Called once per evaluation, so two evaluations of the same expression share nothing.

    fun create(): PolyhedralObject = when (this) {
        TETRAHEDRON -> tetrahedron()
        CUBE -> cube()
        OCTAHEDRON -> octahedron()
        ICOSAHEDRON -> icosahedron()
        DODECAHEDRON -> dodecahedron()
    }
}

class ConwayParseException(val offset: Int, message: String) : IllegalArgumentException

A Conway string the parser could not read, with offset pointing at the character in the original string — whitespace included — so an editor can underline it.

class ConwayParseException(val offset: Int, message: String) : IllegalArgumentException(
    "Conway parse error at offset $offset: $message",
)

data class ConwayInvocation

One operator as written, with the optional degree selector that followed it.

degree is non-null only for k and t, the two operators the parser accepts a selector for; it is the face degree (for kis) or vertex degree (for truncate) to restrict the operator to.

data class ConwayInvocation(
    val operator: ConwayOperator,
    val degree: Int? = null,
)

data class ConwayExpression

A parsed Conway expression, ready to evaluate more than once.

invocations are stored in execution order, seed outward — the reverse of how they are written — so daC parses to [ambo, dual]. source is kept verbatim so an error or a label can quote what the user typed.

data class ConwayExpression(
    val seed: ConwaySeed,
    val invocations: List<ConwayInvocation>,
    val source: String,
) {

val operators: List<ConwayOperator> get() = invocations.map { it.operator }

Just the operators, execution order, degree selectors dropped. Enough to print the expression's shape; not enough to re-evaluate it.

fun evaluate

Run the expression, seed outward, and return the chain with lineage and history attached.

A degree selector that matches nothing is not an error: k7C finds no heptagonal faces on a cube and kises none of them, so the result is the cube's combinatorics rebuilt with fresh element ids. Evaluating twice gives two independent results.

    fun evaluate(
        attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
    ): PolytopeTransformResult {
        var result = PolytopeTransformResult.seed(seed.create())
        for (invocation in invocations) {
            result = when {
                invocation.degree == null -> result.transform(invocation.operator, attributePolicy = attributePolicy)
                invocation.operator == ConwayOperator.KIS -> result.kis(
                    ElementSelectors.faceDegree(invocation.degree),
                    attributePolicy = attributePolicy,
                )
                invocation.operator == ConwayOperator.TRUNCATE -> result.truncate(
                    ElementSelectors.vertexDegree(invocation.degree),
                    attributePolicy = attributePolicy,
                )
                else -> error("indexed ${invocation.operator.displayName} reached evaluation")
            }
        }
        return result
    }
}

fun parseConwayExpression(expression: String): ConwayExpression

Parse a Platonic seed (T, C, O, I, D) with the supported Conway prefix operators.

Conway prefix notation is evaluated right-to-left: daC is d(a(C)). Whitespace is ignored.

body — 41 lines, from line 131
fun parseConwayExpression(expression: String): ConwayExpression {
    val tokens = expression.mapIndexedNotNull { index, symbol ->
        if (symbol.isWhitespace()) null else index to symbol
    }
    if (tokens.isEmpty()) throw ConwayParseException(0, "expected an expression")
    val (seedOffset, seedSymbol) = tokens.last()
    val seed = ConwaySeed.entries.firstOrNull { it.symbol == seedSymbol }
        ?: throw ConwayParseException(seedOffset, "expected final seed T, C, O, I, or D")
    val operatorTokens = tokens.dropLast(1)
    val writtenInvocations = mutableListOf<ConwayInvocation>()
    var at = 0
    while (at < operatorTokens.size) {
        val (offset, symbol) = operatorTokens[at]
        val operator = ConwayOperator.entries.firstOrNull { it.symbol == symbol }
            ?: throw ConwayParseException(
                offset,
                if (symbol.isDigit()) "degree '$symbol' is not attached to an operator"
                else "unknown operator '$symbol'; expected ${ConwayOperator.entries.joinToString(", ") { it.symbol.toString() }}",
            )
        at++
        val digits = StringBuilder()
        val degreeOffset = operatorTokens.getOrNull(at)?.first ?: offset
        while (at < operatorTokens.size && operatorTokens[at].second.isDigit()) {
            digits.append(operatorTokens[at].second)
            at++
        }
        val degree = digits.takeIf { it.isNotEmpty() }?.toString()?.toIntOrNull()
            ?: if (digits.isEmpty()) null else throw ConwayParseException(degreeOffset, "degree is too large")
        if (degree != null) {
            if (operator !in setOf(ConwayOperator.KIS, ConwayOperator.TRUNCATE)) {
                throw ConwayParseException(offset, "only kis and truncate currently accept a degree selector")
            }
            val minimum = if (operator == ConwayOperator.KIS) 3 else 2
            if (degree < minimum) {
                throw ConwayParseException(degreeOffset, "${operator.displayName} degree must be at least $minimum")
            }
        }
        writtenInvocations += ConwayInvocation(operator, degree)
    }
    return ConwayExpression(seed, writtenInvocations.reversed(), expression)
}

fun conway

Parse and evaluate a Conway expression in one call — the everyday entry point.

Throws ConwayParseException for a malformed string, before any geometry is built. canonicalConway is this followed by PolyhedralObject.canonicalize, which is usually what you want if the result is going to be looked at rather than counted.

fun conway(
    expression: String,
    attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes,
): PolytopeTransformResult = parseConwayExpression(expression).evaluate(attributePolicy)