Skip to content

Parameters.kt

src/jvmMain/kotlin/afield/geometry/polytope/Parameters.kt · 270 lines

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

package afield.geometry.polytope

Naming a subset of elements, and attaching a number to each one.

This is the small algebra that turns a Conway operator from a whole-object rewrite into something applied there, by that much. ElementSelector says which elements take part; ElementDoubleParameter says how much, for each of them independently. The selective kis, truncate and gyro overloads take both, evaluate them once per candidate element, and record what they resolved in a TransformDeployment — so a non-uniform operation stays inspectable and replayable after the fact rather than being a number you typed once.

Evaluating a selector

Both interfaces are single-method predicates over an ElementSelectionContext: the object, one element, and that element's lineage when there is one. They are called once per candidate element, in topology.elements(rank) order, with no caching and no batching — so keep them cheap and free of side effects.

The operator chooses the rank, not the selector. kis(selector, …) offers the selector only elements(2), truncate(selector, …) only elements(0), and gyro takes no selector at all and evaluates its parameter over every edge. ElementSelectors.rank therefore filters within the operator's rank rather than redirecting it: kis(ElementSelectors.rank(0)) matches nothing and kis-es nothing, legally and silently.

// Raise an apex over the pentagons only, by an amount painted onto each face beforehand.
val result = solid.kis(ElementSelectors.faceDegree(5), ElementParameters.attribute("spike"))

Lineage is not always there

ElementSelectors.provenanceRole is the one selector that needs transform history. PolyhedralObject.selectElements builds a context without lineage, so a provenance test there throws rather than answering false — which ! would have turned into a vacuous true. Ask a PolytopeTransformResult when a role matters.

data class ElementSelectionContext

Everything a selector or parameter field may inspect without coupling topology to rendering.

data class ElementSelectionContext(
    val polyhedron: PolyhedralObject,
    val element: Element,
    /** Null on a bare [PolyhedralObject]; a [PolytopeTransformResult] supplies it. */
    val lineage: ElementLineage? = null,
) {

val topology: PolytopeTopology get() = polyhedron.topology

val attributes: Map<String, Any?> get() = polyhedron.attributes[element.id]

This element's own attributes, copied on every read; empty — never null — when it has none.

val degree: Int

Local valence, defined per rank: incident edges for a vertex, boundary length for a face, and the sum of the up and down incidences for anything else.

Recomputed on every read, and it throws for a rank-2 element with no stored ordered boundary rather than reporting the raw edge incidence count.

    val degree: Int
        get() = when (element.rank) {
            0 -> topology.incidentUp(element.id).size
            2 -> topology.faceCycle(element.id).size
            else -> topology.incidentUp(element.id).size + topology.incidentDown(element.id).size
        }
}

fun interface ElementSelector

A predicate over one element in its context. Pure, called once per candidate, never memoised.

Written as a fun interface so a lambda is a selector: ElementSelector { it.degree == 5 }. ElementSelectors supplies the stock ones and they compose with and, or and !.

fun matches(context: ElementSelectionContext): Boolean

infix fun ElementSelector.and(other: ElementSelector): ElementSelector

Both predicates, short-circuiting on the left — so put the cheap test first.

infix fun ElementSelector.and(other: ElementSelector): ElementSelector = ElementSelector {
    matches(it) && other.matches(it)
}

infix fun ElementSelector.or(other: ElementSelector): ElementSelector

Either predicate, short-circuiting on the left.

and and or are infix functions of equal precedence and so associate left to right: a and b or c is (a and b) or c. Parenthesise when you meant otherwise.

infix fun ElementSelector.or(other: ElementSelector): ElementSelector = ElementSelector {
    matches(it) || other.matches(it)
}

operator fun ElementSelector.not(): ElementSelector = ElementSelector { !matches(it) }

Logical negation, so !selector reads as it should.

A selector that cannot be evaluated in its context throws instead of answering, so there is no vacuous result for this to invert: !ElementSelectors.provenanceRole("face-apex") on a bare PolyhedralObject refuses, as the unnegated one does.

object ElementSelectors

The stock selectors.

None of these chooses its own domain — see the file overview: the operator offers one rank and these filter within it. PolyhedralObject.selectElements is the exception and walks every rank, the single whole-polytope element at the top included, unless you pass a rank.

val all = ElementSelector { true }

fun rank(rank: Int) = ElementSelector { it.element.rank == rank }

Elements of exactly this rank: 0 vertices, 1 edges, 2 faces, topology.rank the whole body.

fun ids(ids: Collection<ElementId>)

Exactly these elements, by identity. ElementId values are globally scoped, so no rank test is needed or applied; the set is snapshotted here, not re-read per element.

    fun ids(ids: Collection<ElementId>) = ids.mapTo(linkedSetOf()) { it.value }.let { values ->
        ElementSelector { it.element.id.value in values }
    }

fun id(id: ElementId) = ids(listOf(id))

fun faceDegree(degree: Int): ElementSelector

Faces with exactly degree corners — "the pentagons", in one call. Rejects a degree below 3 at construction, and reads the stored ordered boundary, so it throws on a rank-2 element that has none.

    fun faceDegree(degree: Int): ElementSelector {
        require(degree >= 3) { "face degree must be at least 3" }
        return ElementSelector { it.element.rank == 2 && it.topology.faceCycle(it.element.id).size == degree }
    }

fun vertexDegree(degree: Int): ElementSelector

Vertices with exactly degree incident edges. Rejects a degree below 2 at construction.

    fun vertexDegree(degree: Int): ElementSelector {
        require(degree >= 2) { "vertex degree must be at least 2" }
        return ElementSelector { it.element.rank == 0 && it.topology.incidentUp(it.element.id).size == degree }
    }

fun attributeEquals(name: String, value: Any?): ElementSelector

Elements carrying name with structurally equal value.

The key must be present, so attributeEquals(name, null) matches an attribute explicitly set to null and not an element that never had one. Attribute values are Field's ordinary untyped property values, so this is == on Any? and a 1 will not match a 1.0.

    fun attributeEquals(name: String, value: Any?): ElementSelector = ElementSelector {
        name in it.attributes && it.attributes[name] == value
    }

fun provenanceRole(role: String, recursive: Boolean = false): ElementSelector

Match the most recent transform role, or any ancestor role when recursive is true.

The roles are the fixed vocabulary the primitives emit — face-center, face-apex, original-vertex, edge-midpoint, directed-edge-point and so on — which is the only way to say "the apexes kis just raised" without naming indices. Matches nothing on a seed element, whose lineage is ElementLineage.Source, and throws where there is no lineage at all (a bare PolyhedralObject), because false there would make !provenanceRole(...) match everything.

    fun provenanceRole(role: String, recursive: Boolean = false): ElementSelector = ElementSelector { context ->
        val lineage = context.lineage ?: throw IllegalStateException(
            "provenanceRole(\"$role\") needs transform history, and a bare PolyhedralObject has none; " +
                "select on the PolytopeTransformResult the transform returned instead",
        )
        val pending = ArrayDeque<ElementLineage>()
        pending.addLast(lineage)
        var found = false
        while (pending.isNotEmpty() && !found) {
            when (val lineage = pending.removeFirst()) {
                is ElementLineage.Source -> Unit
                is ElementLineage.Derived -> {
                    found = lineage.role == role
                    if (!found && recursive) lineage.parents.forEach(pending::addLast)
                }
            }
        }
        found
    }
}

fun interface ElementDoubleParameter

A scalar field over elements: one finite Double per element, evaluated where the operator asks.

The selective transforms evaluate this once per selected source element and then validate the result themselves — kis demands a positive height, truncate and gyro a fraction strictly inside (0, 0.5) — naming the offending element in the failure. Returning a value out of range is therefore an error at deployment time, not a clamp.

fun value(context: ElementSelectionContext): Double

object ElementParameters

The stock parameter fields: one number everywhere, a number read off each element, or one of two.

object ElementParameters {

fun constant(value: Double): ElementDoubleParameter

The same value for every element, checked finite once here rather than per element.

    fun constant(value: Double): ElementDoubleParameter {
        require(value.isFinite()) { "parameter value must be finite" }
        return ElementDoubleParameter { value }
    }

fun attribute(name: String, default: Double? = null): ElementDoubleParameter

Read the number off each element's own attributes — how a painted-on scalar field becomes a deployed parameter.

Any Number is accepted and widened, so an Int attribute works. An element missing the attribute takes default, or fails naming that element when there is no default; a present but non-numeric or non-finite value always fails. The failure is thrown while the operator is resolving its parameters, so a single unpainted face aborts the whole deployment.

    fun attribute(name: String, default: Double? = null): ElementDoubleParameter = ElementDoubleParameter { context ->
        val value = context.attributes[name]
        when (value) {
            is Number -> value.toDouble()
            null -> default ?: throw IllegalArgumentException(
                "element ${context.element.id.value} has no numeric '$name' attribute",
            )
            else -> throw IllegalArgumentException(
                "element ${context.element.id.value} attribute '$name' is not numeric: $value",
            )
        }.also { require(it.isFinite()) { "element ${context.element.id.value} parameter '$name' is not finite" } }
    }

fun selected

Two-valued field: selected where selector matches, otherwise elsewhere.

Useful because selection and amount are separate axes — the operator's own selector decides whether an element takes part, and this decides how much using a different test.

    fun selected(
        selector: ElementSelector,
        selected: Double,
        otherwise: Double,
    ): ElementDoubleParameter {
        require(selected.isFinite() && otherwise.isFinite()) { "parameter values must be finite" }
        return ElementDoubleParameter { if (selector.matches(it)) selected else otherwise }
    }
}

fun PolyhedralObject.selectElements

Evaluate selector over this object's elements, for inspecting a selection before deploying it.

Walks every rank in ascending order — including the single whole-polytope element at the top — unless rank narrows it. The contexts carry no lineage, so provenance selectors match nothing here; use the PolytopeTransformResult overload when that matters.

fun PolyhedralObject.selectElements(
    selector: ElementSelector,
    rank: Int? = null,
): List<Element> = topology.elementsByRank.asSequence().flatten()
    .filter { rank == null || it.rank == rank }
    .filter { selector.matches(ElementSelectionContext(this, it)) }
    .toList()

fun PolytopeTransformResult.selectElements

As the PolyhedralObject overload, but each context carries the element's lineage — so this is the receiver on which ElementSelectors.provenanceRole can actually match.

fun PolytopeTransformResult.selectElements(
    selector: ElementSelector,
    rank: Int? = null,
): List<Element> = polyhedron.topology.elementsByRank.asSequence().flatten()
    .filter { rank == null || it.rank == rank }
    .filter { selector.matches(ElementSelectionContext(polyhedron, it, lineage(it.id))) }
    .toList()

internal fun PolytopeTransformResult.selectionContext(element: Element): ElementSelectionContext

internal fun PolytopeTransformResult.selectionContext(element: Element): ElementSelectionContext =
    ElementSelectionContext(polyhedron, element, lineage(element.id))