Skip to content

Polytopes

Polyhedra as topology first and coordinates second. An abstract incidence complex you can build and validate; the Conway operator algebra — dual, ambo, kis, truncate, gyro and the composites written over them — that rewrites it, with per-element provenance and lineage carried through every step; and three realization verbs that hand the result coordinates: realize for a first embedding, relax for one that satisfies constraints you declare, and canonicalize for the classical edge-tangent form you would recognise as a solid. Everything is immutable and CPU-only; FieldAdapters.kt is where a polytope becomes FLines or a TriMesh. 4-polytopes, their Schlegel and slice projections, and an OFF importer live here too. Files are listed alphabetically below; a reader new to the package wants Topology.kt, then Transforms.kt, then Conway.kt, then Canonical.kt, then FieldAdapters.kt — and Polytopes: topology first, coordinates later for the same ground with pictures.

17 source file(s), 586 public symbol(s), 431 of them carrying KDoc. Everything below is the documentation that lives beside the code — the same text the completion documentation panel shows in the editor. A blank summary means the declaration has no KDoc yet, not that this page dropped it.

Attributes.kt

Untyped per-element data, carried alongside the topology and propagated through transforms.

An attribute is how a caller writes something onto one vertex, edge or face and gets it back later: a per-face height for a non-uniform kis, a colour that survives into the FLine and triangle output, a tag a selector can test with ElementSelectors.attributeEquals. What crosses a Conway operator is decided by an AttributePropagationPolicy rather than by each operator, and the default — PreserveCommonAttributes — keeps only the entries every source element agreed on.

Values are typed Any?: these are Field's ordinary property values, so a box can put an Int, a Vec3 or a lambda in one. Two consequences follow: equality is structural == on Any?, so 1 and 1.0 are different attributes, and nothing here validates a value against a schema. The two extension functions PolyhedralObject.withAttribute and PolyhedralObject.withAttributes are the intended entry points, because unlike PolytopeAttributes itself they check that the element exists.

src/jvmMain/kotlin/afield/geometry/polytope/Attributes.kt — read the source

Top-level functions

Declaration Summary
fun PolyhedralObject.withAttribute(element: ElementId, name: String, value: Any?): PolyhedralObject Write one attribute onto one element, returning a new object — the ordinary way to paint a scalar field or a tag onto a solid before deploying a selective transform.
fun PolyhedralObject.withAttributes(element: ElementId, values: Map<String, Any?>): PolyhedralObject withAttribute for several names at once; existing names not mentioned in values survive.

PolytopeAttributes

class PolytopeAttributes private constructor(values: Map<ElementId, Map<String, Any?>>)

Immutable element-keyed attributes. Attribute values follow Field's ordinary property model and are therefore typed as Any; maps are copied, while mutable values remain owned by the caller.

Declaration Summary
val elementIds: Set<ElementId> The elements that have an entry, as a fresh set. An element appears as soon as any name has been set on it — including a name set to null — and there is no way to remove it again.
operator fun get(element: ElementId): Map<String, Any?> This element's attributes, as a fresh copy.
fun with(element: ElementId, name: String, value: Any?): PolytopeAttributes One more name on one element, as a new value. A null value is stored, not treated as a removal, and it makes element appear in elementIds.
fun with(element: ElementId, attributes: Map<String, Any?>): PolytopeAttributes Merge a map over one element's existing attributes; later names win.

Canonical.kt

Canonicalization: the step that turns combinatorially-correct-but-lumpy Conway output into the solid you were picturing.

A polyhedron's canonical form is the embedding in which every edge is tangent to one common sphere, the centroid of those tangency points is the origin, and every face is planar. For a convex polyhedron it exists, and — up to Möbius transformations of the sphere, which the origin-centring fixes — it is unique. It is also determined by the combinatorics alone, which is the fact that makes this file useful: the coordinates you hand in are only a starting guess, so canonicalConway("gD") and a hand-built pentagonal hexecontahedron converge to the same solid.

val solid = canonicalConway("bkD").polyhedron        // notation straight to geometry
val result = cube().ambo().polyhedron.canonicalize() // or repair an embedding you already have

The two iterations

CanonicalMethod.RECIPROCAL alternates polar reciprocation between primal and dual and reaches the fixed point in tens of steps, but requires every face plane to stay away from the origin. CanonicalMethod.LOCAL_RELAXATION takes small bounded local steps — tangency, recentring, planarity — and is slow but robust. The default runs the first and polishes with the second. When an attempt fails, CanonicalizeOptions.reseed restarts from coordinates built from the topology alone; CanonicalizeResult.reseeded always says whether that happened.

Read the diagnostics

CanonicalizeResult.converged means the iteration stopped moving, and no more than that. CanonicalDiagnostics is the evidence, reported before and after, and a run that ends with a large tangencyRadiusSpread has failed and says so. The floor on all of it is about 1e-7 relative, because Realization3 stores single-precision coordinates.

src/jvmMain/kotlin/afield/geometry/polytope/Canonical.kt — read the source

Used in: Polytopes: topology first, coordinates later, A polytope, canonicalised, and sliced into layers, Talking to the StackSL in your shader

Top-level functions

Declaration Summary
fun PolyhedralObject.canonicalize(options: CanonicalizeOptions = CanonicalizeOptions()): CanonicalizeResult Seek the canonical, symmetry-respecting representative of this embedding.
fun PolytopeTransformResult.canonicalize(options: CanonicalizeOptions = CanonicalizeOptions()): PolytopeTransformResult The same Conway chain with a canonical embedding. Lineage, history and deployments are kept.
fun canonicalConway(expression: String, options: CanonicalizeOptions = CanonicalizeOptions(), attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult Evaluate a Conway prefix expression and canonicalize its realization — the everyday path from notation to a solid you would recognise.
fun PolyhedralObject.canonicalDiagnostics(): CanonicalDiagnostics Measure the three canonical conditions plus a dihedral summary, without changing anything.

Top-level values

Declaration Summary
val PolyhedralObject.faceCentroids: List<Vec3> Face centroids, in topology.elements(2) order. Handy when inspecting a canonical result.

CanonicalDiagnostics

data class CanonicalDiagnostics(val tangencyRadiusMin: Double, val tangencyRadiusMax: Double, val tangencyRadiusMean: Double, val tangencyRadiusSpread: Double, val tangencyCentroidOffset: Double, val maxFacePlanarityError: Double, val facePlanarityRmsError: Double, val dihedralAngleMin: Double, val dihedralAngleMax: Double, val dihedralAngleMean: Double, val dihedralAngleSpread: Double, val degenerateFaces: List<ElementId>, val signedVolume: Double = 0.0, val selfIntersectingFacePairs: Int = 0)

How close an embedding is to the canonical form, in the canonical form's own terms.

Declaration Summary
val tangencyRadiusMin: Double Smallest distance from the origin to any edge's nearest point. Zero when there are no edges.
val tangencyRadiusMax: Double Largest such distance. Compare with tangencyRadiusMin through tangencyRadiusSpread rather than directly, since both scale with the object.
val tangencyRadiusMean: Double Mean tangency radius — the midsphere's radius, once the spread is small enough for the word to mean anything. canonicalize normalises this to 1.
val tangencyRadiusSpread: Double (max - min) / mean: zero exactly when every edge touches one common sphere.
val tangencyCentroidOffset: Double Distance from the origin to the centroid of the edge tangency points.
val maxFacePlanarityError: Double The worst single corner-to-plane distance, over every corner of every face, against that face's own best-fit plane. A length, so read it against tangencyRadiusMean.
val facePlanarityRmsError: Double Root-mean-square of the same corner-to-plane distances. Says whether the worst corner is an outlier or the whole object is bent.
val dihedralAngleMin: Double Smallest interior dihedral angle, in radians, measured between adjacent faces' unit normals.
val dihedralAngleMax: Double Largest interior dihedral angle, in radians.
val dihedralAngleMean: Double Mean interior dihedral angle, in radians, over the measured edges.
val dihedralAngleSpread: Double max - min over edges, in radians. Small means one Archimedean-looking solid, not a lump.
val degenerateFaces: List<ElementId> Faces whose best-fit plane has no usable normal — collapsed, or all corners colinear.
val signedVolume: Double Signed volume enclosed by the faces, by the divergence theorem over a centroid fan of each face. Negative means the embedding has turned inside out.
val selfIntersectingFacePairs: Int Pairs of faces whose surfaces cross, counted between triangles of the faces' centroid fans that share no corner. Non-zero means the surface passes through itself: tangency and planarity can still all be satisfied, and a Solid made from it still reports NO_ERROR.
val isEmbedded: Boolean Whether this is an embedding of a solid at all: positive volume and a surface that does not pass through itself. Read it before the canonical figures, which can all be excellent when it is false.

CanonicalMethod

enum class CanonicalMethod

Which iteration drives the embedding toward the canonical conditions.

Constants: RECIPROCAL, LOCAL_RELAXATION, RECIPROCAL_THEN_LOCAL.

CanonicalizeOptions

data class CanonicalizeOptions(val method: CanonicalMethod = CanonicalMethod.RECIPROCAL_THEN_LOCAL, val reciprocalIterations: Int = 600, val maxIterations: Int = 2000, val polishIterations: Int = 64, val stability: Double = 0.1, val movementTolerance: Double = 1e-10, val planarize: Boolean = true, val tangentify: Boolean = true, val reseed: RealizationConstruction? = SpectralRealization(), val acceptableSpread: Double = 1e-5, val gauge: RigidGauge? = RigidGauge.CANONICAL)

Configuration for PolyhedralObject.canonicalize.

Declaration Summary
val method: CanonicalMethod Which iteration to run. Changing this is the first thing to try on an input that will not converge.
val reciprocalIterations: Int Cap on the reciprocal phase. Generous: convergence is usually two orders of magnitude faster, and a run that needs all 600 has almost certainly diverged and been rolled back.
val maxIterations: Int Cap on the local relaxation phase when it is doing the real work rather than polishing.
val polishIterations: Int Local steps run after a converged reciprocal phase, purely to polish planarity.
val stability: Double Fraction of each local correction applied per relaxation step, in (0, 1]. See this class's own note: larger is faster and oscillates on high-degree Conway outputs.
val movementTolerance: Double Convergence test on the largest single-vertex movement in one iteration.
val planarize: Boolean Include the step that pulls each face's corners onto that face's best-fit plane. Turning both this and tangentify off is rejected, since it would leave nothing to iterate.
val tangentify: Boolean Include the step that drives every edge's tangency radius toward one, and the recentring that goes with it.
val reseed: RealizationConstruction? The canonical form of a polyhedron is determined by its combinatorics — the supplied coordinates are only a starting point. So when the supplied embedding cannot be canonicalized (the Conway realization policies happily produce coplanar adjacent faces, which have no distinct dual vertices to reciprocate through, and deep kis chains produce spikes the local relaxation walks off), restart from coordinates built from the topology alone.
val acceptableSpread: Double Above this tangency-radius spread an attempt counts as failed and triggers reseed.
val gauge: RigidGauge? Applied once at the end; the canonical gauge is what makes the answer a representative.

CanonicalizeResult

data class CanonicalizeResult(val polyhedron: PolyhedralObject, val initialDiagnostics: CanonicalDiagnostics, val finalDiagnostics: CanonicalDiagnostics, val iterations: Int, val converged: Boolean, val finalMovement: Double, val reason: String, val reciprocalIterations: Int = 0, val relaxationIterations: Int = 0, val reseeded: Boolean = false)

The outcome of a canonicalization run, including the before/after evidence.

Declaration Summary
val polyhedron: PolyhedralObject The answer: same topology, same attributes, new coordinates, gauged if a gauge was asked for.
val initialDiagnostics: CanonicalDiagnostics
val finalDiagnostics: CanonicalDiagnostics
val iterations: Int reciprocalIterations plus relaxationIterations — total steps actually taken, across both phases and for whichever attempt was kept.
val converged: Boolean The iteration stopped moving. Not a claim that the result is canonical — read finalDiagnostics for that.
val finalMovement: Double Largest single-vertex movement in the final iteration.
val reason: String Why the iteration stopped, in words, for a log or an error message. Not a stable enumeration; do not branch on it.
val reciprocalIterations: Int Steps spent reciprocating. Zero for CanonicalMethod.LOCAL_RELAXATION, and zero when the topology offered no dual structure to reciprocate through.
val relaxationIterations: Int Steps spent in local relaxation — including the short polish pass after a converged reciprocal phase.
val reseeded: Boolean True when the supplied embedding failed and the run restarted from the topology alone.

CeresRealization.kt

Optional bridge: run a RelaxationPlan on the native Ceres solver instead of the built-in one.

It needs the native library

Everything in this file requires the ken-ceres wrapper to be built and loaded — there is no pure-Kotlin path here, and nothing is committed. Build it once with ./gradlew buildCeresRedistributable; see dev_docs/CERES.md. ceresRealizationAvailable answers whether this JVM has one, and PolyhedralObject.relaxWithCeres throws a description of what is missing rather than falling back to PolyhedralObject.relax, so a result reported as Ceres's was solved by Ceres.

What it buys

The same objective, solved properly. PolyhedralObject.relax takes a central finite-difference Jacobian and forms a dense 3V x 3V normal matrix every iteration; Ceres differentiates each residual analytically over only the parameters it touches and can factor the sparse normal equations, so the per-iteration cost tracks the number of incidences rather than the square of the vertex count. On kkC and kkD that decides whether there is an answer at all: both reach a tangency spread of 1e-8 in 12 iterations and about 6 ms, where relax takes 1507 ms and does not converge. Plus a real termination report in CeresSolveSummary.

A second implementation of the same declared objective, running alongside the built-in one: same plan in, the same RealizationSolveResult out, the same RigidGauge applied afterwards, so the two can be run on one plan and diffed. Three places where they genuinely differ — the plane parameterisation, the weight convention and the robust loss — are set out on CeresRealization, and the fourth is CeresRelaxationOptions.minEdgeLengthFraction, which guards a defect in the objective that both solvers share.

Not everything crosses over. CeresRealization.unsupported names the families with no native equivalent; in practice that means a custom RealizationConstraint or SymmetryOrbitConstraint, so RelaxationPlan.Companion.symmetricCanonical cannot be solved here.

src/jvmMain/kotlin/afield/geometry/polytope/CeresRealization.kt — read the source

Used in: Polytopes: topology first, coordinates later, A polytope, canonicalised, and sliced into layers

Top-level functions

Declaration Summary
fun ceresRealizationAvailable(): Boolean True when this JVM can load the native wrapper at all, and so can run relaxWithCeres.
fun PolyhedralObject.relaxWithCeres(plan: RelaxationPlan, options: CeresRelaxationOptions = CeresRelaxationOptions(), backend: CeresSolverBackend = FfmCeresBackend()): CeresRelaxationResult relax, with Ceres doing the minimisation.

CeresRealization

object CeresRealization

The same RelaxationPlan the Kotlin solver runs, handed to Ceres instead.

Declaration Summary
fun unsupported(plan: RelaxationPlan): List<String> Families with no Ceres equivalent yet; a plan containing one cannot cross over.
fun program(plan: RelaxationPlan, problem: RealizationProblem, planes: PlaneFormulation = PlaneFormulation.HESSE, gaugeRotationWeight: Double = 0.0, minEdgeLengthFraction: Double = 0.0, minEdgeLengthWeight: Double = 1.0): CeresProgram Compile plan against problem's topology.

PlaneFormulation

enum class PlaneFormulation

How a Ceres program should say "this face is planar". They minimise the same thing; they are not equally well conditioned.

Constants: ORIGIN_NORMAL, HESSE, NEWELL_FIT, HESSE_EXPRESSION.

CeresRelaxationOptions

data class CeresRelaxationOptions(val maxIterations: Int = 200, val linearSolver: CeresLinearSolver = CeresLinearSolver.SPARSE_NORMAL_CHOLESKY, val threads: Int = 1, val functionTolerance: Double = 1e-10, val gradientTolerance: Double = 1e-14, val parameterTolerance: Double = 1e-12, val planes: PlaneFormulation = PlaneFormulation.HESSE, val gaugeRotationWeight: Double = 1.0, val nonmonotonicSteps: Boolean = false, val minEdgeLengthFraction: Double = 0.0)

Solver knobs for relaxWithCeres that have no counterpart in LeastSquaresOptions.

Declaration Summary
val maxIterations: Int
val linearSolver: CeresLinearSolver CeresLinearSolver.SPARSE_NORMAL_CHOLESKY is the right default here — a realization problem is structurally sparse — but it needs a Ceres built with a sparse backend, and Ceres silently falls back to a dense one when there is none. CeresRelaxationResult.summary reports what was actually used.
val threads: Int Residual and Jacobian evaluation threads, passed straight through to Ceres.
val functionTolerance: Double
val gradientTolerance: Double
val parameterTolerance: Double
val planes: PlaneFormulation See PlaneFormulation. The default is the well-conditioned one, not the historic one.
val gaugeRotationWeight: Double Weight on a afield.math.optimization.ceres.GaugeRotation term, or zero for none.
val nonmonotonicSteps: Boolean Let the trust region accept an occasional worse step. Helps on the spiky kis-family solids whose canonical form sits past a ridge; costs nothing when the problem is already easy.
val minEdgeLengthFraction: Double A floor on edge length, as a one-sided max(0, floor - length) barrier. Zero disables it.

CeresRelaxationResult

data class CeresRelaxationResult(val realization: RealizationSolveResult, val summary: CeresSolveSummary)

RealizationSolveResult plus what Ceres reported about the solve that produced it.

Declaration Summary
val realization: RealizationSolveResult
val summary: CeresSolveSummary Iterations, timings, termination type, and which linear solver Ceres actually used.

Constraints.kt

The constraint vocabulary a realization run is written in.

A RealizationConstraint is one named, immutable family of residuals — "every edge the same length", "every face planar", "the tangency centroid at the origin". A RelaxationPlan is a list of them, so a realization run is data: it prints, it diffs, it reweights with RelaxationPlan.withWeight, and it can be handed to a second minimiser and the answers compared. RealizationProblem is the boundary that makes that cheap: it derives the edge endpoints, the face cycles and the initial coordinates once per solve, and hands a constraint nothing but flat DoubleArray coordinates and integer indices — no ElementId bookkeeping, no FLines, no renderer.

Units and weights

Every residual in this file is a length in the units of the coordinates, and every one is written as weight * (measured - goal). So the families are directly comparable to each other, and a weight is a plain multiplier that enters the least-squares cost as its square.

What a weight is not is a share of influence, because the families contribute very different numbers of residuals for the same object:

On a cube: 12 edge-length residuals, 8 common-radius, 24 face-planarity, 24 proximity, and 3 for either centring family. The default blends in RelaxationPlan.regularize and RelaxationPlan.canonicalConditions give the three-residual centring families a much larger weight for exactly this reason; read a weight as calibration against a residual count, not as a priority.

Reporting

RealizationConstraint.residualCount may not depend on the coordinates, which is what makes a run's residual vector sliceable per family after the fact. report goes one step further and re-evaluates each family at weight 1, so a RealizationSolveResult can state each family's error in the units of the thing constrained rather than in solver units.

Writing your own

Implement the interface: it is four small members and nothing in this file is privileged. Two rules that are not obvious — write exactly RealizationConstraint.residualCount entries starting at the offset you are given, and make withWeight(1.0) genuinely unweighted, since that is what report relies on. A custom family has no Ceres equivalent, so a plan containing one cannot cross over to PolyhedralObject.relaxWithCeres; CeresRealization.unsupported names it.

src/jvmMain/kotlin/afield/geometry/polytope/Constraints.kt — read the source

Used in: Polytopes: topology first, coordinates later

Top-level functions

Declaration Summary
fun List<RealizationConstraint>.report(problem: RealizationProblem, x: DoubleArray = problem.initialCoordinates): List<ConstraintResidualReport> Evaluate every family at x and report its unweighted geometric error, so a reader sees the residual in the units of the thing being constrained rather than in solver units.

RealizationProblem

class RealizationProblem private constructor(val polyhedron: PolyhedralObject, internal val edgeEndpoints: IntArray, internal val faceCycles: List<IntArray>, internal val initial: DoubleArray)

The topology-derived facts every realization constraint needs, computed once per solve.

Declaration Summary
val polyhedron: PolyhedralObject The object the indices were derived from. Its own coordinates are a starting point, not the ones being solved.
val vertexCount: Int
val edgeCount: Int
val faceCount: Int Rank-2 elements, all of which are guaranteed to have an ordered boundary here.
val initialCoordinates: DoubleArray A defensive copy of the embedding the problem was built from.
val initialMeanEdgeLength: Double by lazy Mean edge length of the initial embedding; the natural default for an unscaled target.
val initialMeanVertexRadius: Double by lazy Mean distance from the initial vertex centroid to the initial vertices.
val initialMeanTangencyRadius: Double by lazy Mean distance from the origin to each initial edge's nearest point (the midsphere radius).
fun initialCentroid(): DoubleArray The initial vertex centroid, as a fresh three-element array.

RealizationConstraint

interface RealizationConstraint

One named residual family.

Declaration Summary
val name: String Stable, human-readable family name. Used by ConstraintResidualReport.
val weight: Double Residual multiplier. Zero contributes zero residuals; it does not change residualCount.
fun residualCount(problem: RealizationProblem): Int How many residuals this family contributes for problem. Independent of the coordinates.
fun residuals(problem: RealizationProblem, x: DoubleArray, into: DoubleArray, offset: Int) Write exactly residualCount weighted residuals into into starting at offset.
fun withWeight(weight: Double): RealizationConstraint The same family at a different weight; used to report unweighted geometric error.

EdgeLengthConstraint

data class EdgeLengthConstraint(override val weight: Double = 1.0, val target: Double? = null) : RealizationConstraint

Every edge the same length. target defaults to the initial embedding's mean edge length.

Declaration Summary
override val weight: Double
val target: Double? An absolute length. Rejected at construction unless positive; null means the initial mean.
override fun residualCount(problem: RealizationProblem)
override fun residuals(problem: RealizationProblem, x: DoubleArray, into: DoubleArray, offset: Int)
override fun withWeight(weight: Double)

FacePlanarityConstraint

data class FacePlanarityConstraint(override val weight: Double = 2.0) : RealizationConstraint

Every face corner on that face's own best-fit plane: one residual per face corner.

Declaration Summary
override val weight: Double
override fun residualCount(problem: RealizationProblem)
override fun residuals(problem: RealizationProblem, x: DoubleArray, into: DoubleArray, offset: Int)
override fun withWeight(weight: Double)

CentroidConstraint

data class CentroidConstraint(override val weight: Double = 4.0, val target: List<Double>? = null) : RealizationConstraint

Pin the vertex centroid. target defaults to the initial centroid, which holds the object still rather than moving it; use AT_ORIGIN to also recentre it.

Declaration Summary
override val weight: Double
val target: List<Double>? Three components exactly, or null for the initial centroid.
override fun residualCount(problem: RealizationProblem)
override fun residuals(problem: RealizationProblem, x: DoubleArray, into: DoubleArray, offset: Int)
override fun withWeight(weight: Double)

CommonRadiusConstraint

data class CommonRadiusConstraint(override val weight: Double = 0.2, val target: Double? = null) : RealizationConstraint

Every vertex the same distance from the current vertex centroid — a circumsphere.

Declaration Summary
override val weight: Double
val target: Double? An absolute radius. Rejected at construction unless positive; null means the initial mean.
override fun residualCount(problem: RealizationProblem)
override fun residuals(problem: RealizationProblem, x: DoubleArray, into: DoubleArray, offset: Int)
override fun withWeight(weight: Double)

ProximityConstraint

data class ProximityConstraint(override val weight: Double = 1e-3) : RealizationConstraint

Stay near the embedding the problem started from. A small weight is a regulariser that picks one representative out of the solution's rigid-motion null space; a large weight is a repair.

Declaration Summary
override val weight: Double
override fun residualCount(problem: RealizationProblem)
override fun residuals(problem: RealizationProblem, x: DoubleArray, into: DoubleArray, offset: Int)
override fun withWeight(weight: Double)

EdgeTangencyConstraint

data class EdgeTangencyConstraint(override val weight: Double = 1.0, val radius: Double? = null) : RealizationConstraint

Every edge tangent to one sphere about the origin — the midsphere condition of the canonical form. The residual is the distance from the origin to the edge line's nearest point, minus radius; null uses the initial embedding's mean tangency radius, so the family constrains the spread rather than the scale.

Declaration Summary
override val weight: Double
val radius: Double? An absolute midsphere radius, or null for the initial mean — which constrains the spread of the tangency radii and leaves their common value alone.
override fun residualCount(problem: RealizationProblem)
override fun residuals(problem: RealizationProblem, x: DoubleArray, into: DoubleArray, offset: Int)
override fun withWeight(weight: Double)

MinEdgeLengthConstraint

data class MinEdgeLengthConstraint(override val weight: Double = 1.0, val fraction: Double = 0.01) : RealizationConstraint

A floor under edge length: one one-sided residual per edge, max(0, floor - length).

Declaration Summary
override val weight: Double
val fraction: Double Floor as a fraction of the initial mean edge length.
override fun residualCount(problem: RealizationProblem)
override fun residuals(problem: RealizationProblem, x: DoubleArray, into: DoubleArray, offset: Int)
override fun withWeight(weight: Double)

TangencyCentroidConstraint

data class TangencyCentroidConstraint(override val weight: Double = 4.0) : RealizationConstraint

The centroid of every edge's tangency point at the origin: the canonical form's centring.

Declaration Summary
override val weight: Double
override fun residualCount(problem: RealizationProblem)
override fun residuals(problem: RealizationProblem, x: DoubleArray, into: DoubleArray, offset: Int)
override fun withWeight(weight: Double)

ConstraintResidualReport

data class ConstraintResidualReport(val name: String, val weight: Double, val count: Int, val rms: Double, val max: Double)

Unweighted magnitudes of one constraint family at one embedding.

Declaration Summary
val name: String
val weight: Double The weight the family declared — the errors below are measured with it set to 1.
val count: Int Residuals in this family. Zero means the family had nothing to say about this topology.
val rms: Double Root-mean-square error over the family, in the units of the quantity it constrains.
val max: Double Largest absolute residual in the family; zero for an empty family.

Conway.kt

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.

src/jvmMain/kotlin/afield/geometry/polytope/Conway.kt — read the source

Used in: Polytopes: topology first, coordinates later

Top-level functions

Declaration Summary
fun parseConwayExpression(expression: String): ConwayExpression Parse a Platonic seed (T, C, O, I, D) with the supported Conway prefix operators.
fun conway(expression: String, attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult Parse and evaluate a Conway expression in one call — the everyday entry point.

ConwaySeed

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.

Constants: TETRAHEDRON, CUBE, OCTAHEDRON, ICOSAHEDRON, DODECAHEDRON.

Declaration Summary
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.

ConwayParseException

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

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.

ConwayInvocation

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

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

Declaration Summary
val operator: ConwayOperator
val degree: Int?

ConwayExpression

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

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

Declaration Summary
val seed: ConwaySeed
val invocations: List<ConwayInvocation>
val source: String
val operators: List<ConwayOperator> Just the operators, execution order, degree selectors dropped. Enough to print the expression's shape; not enough to re-evaluate it.
fun evaluate(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult Run the expression, seed outward, and return the chain with lineage and history attached.

FieldAdapters.kt

The bridge from a polytope to the things Field can actually draw: FLines and a TriMesh.

Three adapters, and the choice between them is about what you want to do with the result rather than how it looks. PolyhedralObject.toFaceFLines gives one closed line per face, which is what you want if faces are going to be styled, picked or filled independently. PolyhedralObject.triangulate gives a flat-normal TriMesh with the provenance arrays needed to get from a triangle or a render vertex back to the element it came from; PolyhedralObject.toTriMesh is the same thing when that provenance can be thrown away. Polytope4.toEdgeFLines projects a 4-polytope to a wireframe.

Attributes become FLine properties

Every face line receives its face's attributes copied straight into line.properties, plus POLYTOPE_FACE_ID_PROPERTY and POLYTOPE_FACE_RANK_PROPERTY. So an attribute named color set on a face during a Conway chain arrives as Field's own color property with no translation step — which is the point of PolytopeAttributes being typed as Any, and also means a badly-named attribute can overwrite a drawing property with no warning.

What these will not do

Every entry point requires a realization and validates the topology first, so an abstract polytope throws rather than drawing nothing. A face with zero area throws DegenerateFaceGeometryException rather than emitting a triangle with a garbage normal.

Faces with holes

A face's inner rings (topology.faceHoles) travel the whole way through both adapters. PolyhedralObject.triangulate hands them to a HoleAwareFaceTriangulator — the default EarcutFaceTriangulator is one — and a face with holes given a triangulator that is not hole-aware throws rather than filling the hole in. PolyhedralObject.toFaceFLines emits each hole as a further closed contour on the same FLine, which is exactly what FLine.tessellate and PlanarTessellation already understand, so a filled holed face fills with a hole in it and a stroked one is drawn with its inner outline.

Holes change nothing about a face that has none: positions, indices, normals and both provenance arrays match the hole-unaware triangulation exactly, over the five Platonic seeds, bC and a windowed cube with its ring dropped (PolytopeFaceHolesTest).

PolyhedralObject.faceNormal reads the outer ring only: a hole does not contribute area, and its opposite winding would cancel some of the outline's if it did.

Flat, smooth, or creased

NormalPolicy decides what normal each duplicated corner carries. The default is NormalPolicy.Flat — one face normal per face, which is what a Conway solid wants, every edge of one being a real crease. NormalPolicy.Crease averages the faces at a vertex that are within an angle of this one, so a subdivided sphere reads as curved and a cube still reads as a cube. Nothing else about the output changes: same vertices, same triangles, same provenance arrays.

src/jvmMain/kotlin/afield/geometry/polytope/FieldAdapters.kt — read the source

Used in: Polytopes: topology first, coordinates later, A polytope, cut open, and traced, Agent transcript gallery

Top-level functions

Declaration Summary
fun PolyhedralObject.toFaceFLines(configure: (face: Element, line: FLine) -> Unit = { _, _ -> }): List<FLine> One closed, outward-wound FLine per polygonal face.
fun PolyhedralObject.faceNormal(face: ElementId): Vec3 Unit normal selected by the face's explicit winding — of its outer ring. A hole encloses no area of the face and is wound the other way, so including it would subtract from the outline's own Newell sum for no reason.
fun PolyhedralObject.triangulate(triangulator: FaceTriangulator = EarcutFaceTriangulator, normals: NormalPolicy = NormalPolicy.Flat): TriangulatedGeometry Triangulate every ordered face and produce a TriMesh with provenance.
fun PolyhedralObject.toTriMesh(triangulator: FaceTriangulator = EarcutFaceTriangulator, normals: NormalPolicy = NormalPolicy.Flat): TriMesh Convenience when only Field's renderable mesh is needed and provenance can be discarded.
fun Polytope4.toEdgeFLines(projection: Projection4, configure: (edge: Element, line: FLine) -> Unit = { _, _ -> }): List<FLine> One open FLine per rank-1 edge of a Polytope4, under a viewing projection.
fun Polytope4.toCellEdgeFLines(cell: ElementId, projection: Projection4, configure: (edge: Element, line: FLine) -> Unit = { _, _ -> }): List<FLine> The edges of one cell of a Polytope4, projected — the way to make one cell readable inside the projected whole. cell must be a rank-3 element.

Top-level values

Declaration Summary
const val POLYTOPE_FACE_ID_PROPERTY The FLine property every adapter here stamps with the source element's raw id.
const val POLYTOPE_FACE_RANK_PROPERTY The rank of the element a line came from: 2 for a face, 1 for a projected 4-polytope edge.
const val POLYTOPE_CELL_ID_PROPERTY The FLine property Polytope4.toCellEdgeFLines stamps with the cell it was asked about.

DegenerateFaceGeometryException

class DegenerateFaceGeometryException(val face: ElementId) : IllegalArgumentException("face ${face.value} has zero area in its 3D realization")

A face whose 3D realization has no usable normal — zero area, or coordinates that went non-finite.

FaceTriangulator

fun interface FaceTriangulator

Polygon triangulation strategy. Input and output values are indices into Realization3, not global ElementId values or render-buffer offsets. Implementations must return consecutive triples and may only reference vertices present in the supplied face.

Declaration Summary
fun triangulate(faceVertices: IntArray, realization: Realization3): IntArray

FanFaceTriangulator

object FanFaceTriangulator : FaceTriangulator

Deterministic convex-polygon fan. It is not a general polygon triangulator.

Declaration Summary
override fun triangulate(faceVertices: IntArray, realization: Realization3): IntArray

HoleAwareFaceTriangulator

interface HoleAwareFaceTriangulator : FaceTriangulator

A FaceTriangulator that can subtract hole rings from the face it fills.

Declaration Summary
fun triangulate(outer: IntArray, holes: List<IntArray>, realization: Realization3): IntArray outer and each ring of holes are ordered cycles of Realization3 indices. Rings may be wound either way. A hole ring that is not coplanar with outer is not treated as a hole — see afield.geometry.path.PlanarTessellation.
override fun triangulate(faceVertices: IntArray, realization: Realization3): IntArray

EarcutFaceTriangulator

object EarcutFaceTriangulator : HoleAwareFaceTriangulator

Field's Earcut tessellator over an arbitrarily oriented 3D polygon, with holes.

Declaration Summary
override fun triangulate(outer: IntArray, holes: List<IntArray>, realization: Realization3): IntArray

TriangulatedGeometry

class TriangulatedGeometry internal constructor(val mesh: TriMesh, triangleFaceIds: IntArray, vertexElementIds: IntArray, triangleAttributes: List<Map<String, Any?>>, vertexAttributes: List<Map<String, Any?>>)

Field-native triangle geometry plus provenance lost by an ordinary triangle mesh.

Declaration Summary
val mesh: TriMesh
val triangleCount: Int Triangles in mesh; the length of triangleFaceIds and of triangleAttributes.
val triangleFaceIds: IntArray
val vertexElementIds: IntArray
val triangleAttributes: List<Map<String, Any?>>
val vertexAttributes: List<Map<String, Any?>>
fun sourceFace(triangle: Int): ElementId The polygonal face a triangle was cut from. The inverse of triangulation, one triangle at a time.
fun sourceVertex(renderVertex: Int): ElementId The topology vertex a render vertex duplicates. Several render vertices share one of these whenever a polyhedron is creased at that corner.

NormalPolicy

sealed interface NormalPolicy

How a render vertex gets its normal — and therefore whether an edge of the solid reads as a crease or as curvature.

Declaration Summary
object Flat : NormalPolicy One flat face normal on every corner of that face. The default, and what a Platonic or Conway solid wants — every edge of one is a real crease.
data class Crease(val creaseDegrees: Double = 30.0) : NormalPolicy Average the face normals meeting at a topology vertex, but only the ones within creaseDegrees of this face's own normal; a sharper join keeps this face's flat normal on this corner. So 180.0 is fully smooth and 0.0 is Flat.

Off.kt

The one-way bridge to OFF, the plain-text polygon interchange format that Geomview and most polyhedron tooling read.

One function and no reader. Export exists so that a solid can be opened in something that was not written here — an independent second opinion when a test disagrees with a picture — and so that a bug report can carry the actual object. Nothing else in this package goes through it, and no part of the topology is shaped by what OFF can express.

The format itself is lossy for this library's purposes. Element identity, rank structure above 2, attributes and provenance all disappear; what survives is coordinates and polygons.

src/jvmMain/kotlin/afield/geometry/polytope/Off.kt — read the source

Top-level functions

Declaration Summary
fun PolyhedralObject.toOff(): String Minimal polygonal OFF export for diagnostics and independent test inspection.

Parameters.kt

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.

src/jvmMain/kotlin/afield/geometry/polytope/Parameters.kt — read the source

Used in: Polytopes: topology first, coordinates later

Top-level functions

Declaration Summary
infix fun ElementSelector.and(other: ElementSelector): ElementSelector Both predicates, short-circuiting on the left — so put the cheap test first.
infix fun ElementSelector.or(other: ElementSelector): ElementSelector Either predicate, short-circuiting on the left.
operator fun ElementSelector.not(): ElementSelector Logical negation, so !selector reads as it should.
fun PolyhedralObject.selectElements(selector: ElementSelector, rank: Int? = null): List<Element> Evaluate selector over this object's elements, for inspecting a selection before deploying it.
fun PolytopeTransformResult.selectElements(selector: ElementSelector, rank: Int? = null): List<Element> As the PolyhedralObject overload, but each context carries the element's lineage — so this is the receiver on which ElementSelectors.provenanceRole can actually match.

ElementSelectionContext

data class ElementSelectionContext(val polyhedron: PolyhedralObject, val element: Element, val lineage: ElementLineage? = null)

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

Declaration Summary
val polyhedron: PolyhedralObject
val element: Element
val lineage: ElementLineage? Null on a bare PolyhedralObject; a PolytopeTransformResult supplies it.
val topology: PolytopeTopology
val attributes: Map<String, Any?> 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.

ElementSelector

fun interface ElementSelector

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

Declaration Summary
fun matches(context: ElementSelectionContext): Boolean

ElementSelectors

object ElementSelectors

The stock selectors.

Declaration Summary
val all
fun rank(rank: Int) 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 id(id: ElementId)
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 vertexDegree(degree: Int): ElementSelector Vertices with exactly degree incident edges. Rejects a degree below 2 at construction.
fun attributeEquals(name: String, value: Any?): ElementSelector Elements carrying name with structurally equal value.
fun provenanceRole(role: String, recursive: Boolean = false): ElementSelector Match the most recent transform role, or any ancestor role when recursive is true.

ElementDoubleParameter

fun interface ElementDoubleParameter

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

Declaration Summary
fun value(context: ElementSelectionContext): Double

ElementParameters

object ElementParameters

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

Declaration Summary
fun constant(value: Double): ElementDoubleParameter The same value for every element, checked finite once here rather than per element.
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.
fun selected(selector: ElementSelector, selected: Double, otherwise: Double): ElementDoubleParameter Two-valued field: selected where selector matches, otherwise elsewhere.

Polytope4.kt

Rank 4: the seeds, the coordinates and the viewing policy that exercise the incidence core at a rank a polyhedron library never reaches.

Four regular seeds — fiveCell, tesseract, sixteenCell and the self-dual twentyFourCell — plus Realization4 for coordinates, an abstract Polytope4.dual, and Projection4 for getting a picture out. Everything a rank-3 reader would assume is wrong here: there are four proper ranks rather than three, an edge bounds three or more polygons rather than exactly two (so validate(checkManifold = true) must reject a tesseract, and does), and there is no triangulate(), because a projected 4-polytope's polygons do not bound a surface and filling them would claim they did.

Deriving the face lattice

convexPolytope4 is the one call the seeds are built from. You give it vertex coordinates and outward facet normals; it works out cells, polygons, boundary orders and edges itself. So a wrong derivation shows up as a wrong published count — 24 cells, 96 edges — rather than as a shape nobody checked. The trade is in the name: the criteria it uses are true of convex polytopes and only of convex polytopes.

Coordinates are DoubleArray

Field's graphics vocabulary has no Vec4 and should not grow one for this, so Realization4 stores plain DoubleArrays and every public read hands back a copy. That also means rank 4 is the double-precision half of this package while Realization3 is single-precision.

Projection is a camera, not geometry

val t = tesseract()
val points = t.project(RotatedProjection4(0, 3, angle = 0.6))   // cube inside a cube, turning

A Projection4 lives above realization: swapping one for another changes the picture and not the object, and nothing in the topology moves when you do it.

src/jvmMain/kotlin/afield/geometry/polytope/Polytope4.kt — read the source

Used in: Polytopes: topology first, coordinates later

Top-level functions

Declaration Summary
fun Polytope4.summary(): Polytope4Summary Reduce a Polytope4 to the counts worth reading at a prompt, and the ones the tests assert.
fun fiveCell(): Polytope4 The regular 5-cell (4-simplex): 5 vertices, 10 edges, 10 triangles, 5 tetrahedral cells.
fun tesseract(): Polytope4 The tesseract (8-cell): 16 vertices, 32 edges, 24 squares, 8 cubic cells.
fun sixteenCell(): Polytope4 The 16-cell (4-orthoplex): 8 vertices, 24 edges, 32 triangles, 16 tetrahedral cells.
fun twentyFourCell(): Polytope4 The 24-cell: 24 vertices, 96 edges, 96 triangles, 24 octahedral cells. It is self-dual and has no three-dimensional analogue, which makes it the real test that the core is rank-general and not a polyhedron library wearing a hat.
fun convexPolytope4(coordinates: List<DoubleArray>, facetNormals: List<DoubleArray>, tolerance: Double = 1e-9): Polytope4 Build a rank-4 incidence topology from vertex coordinates and outward facet normals.
fun Polytope4.project(projection: Projection4): List<Vec3> Project every vertex, in topology.elements(0) order — so the result is index-parallel to Polytope4.vertices, and a face cycle's element ids have to be looked up in that list rather than used as indices into this one.

Realization4

class Realization4(vertexCoordinates: List<DoubleArray>)

Immutable ownership boundary around four-dimensional vertex coordinates.

Declaration Summary
val size: Int How many vertices are realized; equal to the topology's rank-0 element count.
val vertexCoordinates: List<DoubleArray> Every coordinate, freshly copied on each read — so this is a snapshot, not a view, and not free.
operator fun get(index: Int): DoubleArray The four components of one vertex, in topology.elements(0) order. A copy: writing into the returned array changes nothing, which is the whole point of this class existing.

Polytope4

data class Polytope4(val topology: PolytopeTopology, val realization: Realization4? = null)

A rank-4 abstract polytope with an optional four-dimensional realization.

Declaration Summary
val topology: PolytopeTopology Must be rank 4; the constructor rejects a rank-3 topology rather than treating it as degenerate.
val realization: Realization4? Optional coordinates, in topology.elements(0) order. Null means a purely combinatorial object.
val vertices: List<Element> Rank-0 elements, in the order every realization and projection result is indexed by.
val edges: List<Element> Rank-1 elements. Each belongs to three or more polygons here, never to exactly two.
val polygons: List<Element> Rank-2 elements: the flat polygonal faces, each with its own ordered boundary cycle.
val cells: List<Element> Rank-3 elements: the polyhedral facets. The rank a reader arriving from rank 3 does not expect.
fun dual(): Polytope4 The abstract dual. Vertices become cells and polygons become edges.

Polytope4Summary

data class Polytope4Summary(val vertices: Int, val edges: Int, val polygons: Int, val cells: Int, val polygonDegrees: Map<Int, Int>, val cellPolygonCounts: Map<Int, Int>, val eulerCharacteristic: Int, val valid: Boolean, val realized: Boolean)

Compact inspection value; the counts and the Euler relation are what matter at a prompt.

Declaration Summary
val vertices: Int
val edges: Int
val polygons: Int
val cells: Int
val polygonDegrees: Map<Int, Int> Polygon side count to how many polygons have it: {3=96} for the 24-cell, all triangles.
val cellPolygonCounts: Map<Int, Int> Polygons-per-cell to how many cells have that many: {8=24} for the 24-cell's octahedra.
val eulerCharacteristic: Int V - E + F - C, which is zero for every convex 4-polytope.
val valid: Boolean The ordinary validation verdict. Never the manifold one, which no 4-polytope can pass.
val realized: Boolean Whether coordinates are attached at all — false after a Polytope4.dual.

Projection4

fun interface Projection4

How a four-dimensional point becomes a three-dimensional one for viewing.

Declaration Summary
fun project(point: DoubleArray): Vec3 Map one four-component point to a viewing position. Called once per vertex, so it should be cheap; it may throw to reject a point it cannot see, as PerspectiveProjection4 does.

OrthographicProjection4

data class OrthographicProjection4(val axis: Int = 3) : Projection4

Drop one axis. Cheap, exact, and usually unreadable — every cell folds onto every other.

Declaration Summary
override fun project(point: DoubleArray): Vec3

PerspectiveProjection4

data class PerspectiveProjection4(val eye: Double = 3.0, val axis: Int = 3) : Projection4

Perspective from a viewpoint on one axis — the projection that makes a tesseract look like a cube inside a cube and a 24-cell readable at all. eye must be outside the object on that axis; the nearer it is, the more extreme the nesting.

Declaration Summary
val eye: Double Viewpoint distance along axis; smaller exaggerates the nesting, and must exceed the object's extent.
val axis: Int Which of the four coordinates is depth. The other three become x, y, z in ascending order.
override fun project(point: DoubleArray): Vec3

RotatedProjection4

data class RotatedProjection4(val a: Int, val b: Int, val angle: Double, val then: Projection4 = PerspectiveProjection4()) : Projection4

Rotate in the (a, b) coordinate plane, then project. Rotations of 4-space are the reason a projected 4-polytope reads as one object rather than a tangle: the inner cell turns itself inside out as the angle advances.

Declaration Summary
val a: Int First axis of the rotation plane, 0..3.
val b: Int Second axis of the rotation plane, 0..3 and distinct from a.
val angle: Double Rotation in radians, applied before then. Animate this, not the object.
val then: Projection4 What turns the rotated 4D point into a viewing position; nesting these composes rotations.
override fun project(point: DoubleArray): Vec3

PolytopeVisualizers.kt

The single place this package knows that Field has a screen — and it is one function long.

Each printer registered here reduces its value to one of the plain summary data classes in Summaries.kt — PolytopeTopologySummary, Polytope4Summary, and so on — and hands that to Field's ordinary reflective value visualizer. So a topology, a transform result or a canonicalization plan left as a box's last expression prints something useful, while nothing in the topology depends on FLine, meshes or a renderer, and println(x.summary()) shows the same facts because it is the same value.

src/jvmMain/kotlin/afield/geometry/polytope/PolytopeVisualizers.kt — read the source

Top-level functions

Declaration Summary
fun registerPolytopeVisualizers() Register a value printer for each polytope type that has a summary, so live boxes print something useful without importing a visualizer.

RealizationSolver.kt

The middle realization verb: improve an embedding you already have.

realize(policy) builds coordinates for a topology that has none, PolyhedralObject.canonicalize seeks the symmetry-respecting representative, and PolyhedralObject.relax — this file — takes an embedding and a declared set of constraints and moves the vertices until the constraints are as satisfied as a local descent can make them. A run is a RelaxationPlan: a list of named RealizationConstraint values, an afield.math.optimization.LeastSquaresOptions, and an optional RigidGauge. The answer is a RealizationSolveResult carrying every family's unweighted geometric error before and after, so the reader can see which constraint gave way rather than only how small the blended cost got.

val result = cube().relax(RelaxationPlan.regularize())
result.finalConstraints.forEach { println("${it.name}  rms ${it.rms}  max ${it.max}") }

What is minimised

afield.math.optimization.leastSquares runs Levenberg-Marquardt on half the squared norm of every active family's residuals concatenated. A weight is a plain multiplier on its family's residuals, so it enters the cost as its square; and because families contribute wildly different residual counts — three for a centroid, one per face corner for planarity — equal weights are not equal influence. See Constraints.kt for the counts.

The iteration is local and descent-only. It stops at a stationary point of that one sum and claims nothing about a global minimum, uniqueness, convexity, or the absence of self-intersection; RealizationSolveResult.converged means a tolerance was met, not that the answer is the one you wanted. Where the objective itself has a degenerate minimum — the canonical conditions do, because a collapsed edge satisfies EdgeTangencyConstraint for free — a converged run can be nonsense, and only the diagnostics will say so.

Cost per iteration

The Jacobian is central finite differences, so an iteration costs 2 * 3 * vertexCount residual evaluations and builds a dense 3V x 3V normal matrix. That is the right tool for exact control over tens of vertices. For hundreds use PolyhedralObject.canonicalize; to hand the same declared plan to a sparse second-order solver, CeresRealization.kt — which needs a native library.

src/jvmMain/kotlin/afield/geometry/polytope/RealizationSolver.kt — read the source

Used in: Polytopes: topology first, coordinates later

Top-level functions

Declaration Summary
fun PolyhedralObject.relax(plan: RelaxationPlan): RealizationSolveResult Improve an existing embedding under a declared set of constraints.
fun PolyhedralObject.solveRealization(options: RealizationSolverOptions = RealizationSolverOptions()): RealizationSolveResult Improve a supplied embedding without changing its authoritative topology or attributes.
fun PolyhedralObject.geometryDiagnostics(): RealizationDiagnostics Measure this embedding's edge lengths, face planarity, centroid and degenerate faces, with no plan involved — the "how bad is it, actually" call.

RelaxationPlan

data class RelaxationPlan(val constraints: List<RealizationConstraint>, val leastSquares: LeastSquaresOptions = LeastSquaresOptions(maxIterations = 96), val gauge: RigidGauge? = null)

A declared set of realization constraints plus how to solve and how to normalize.

Declaration Summary
val constraints: List<RealizationConstraint> Residual blocks in list order; a family weighted zero is skipped entirely, not scaled to nothing.
val leastSquares: LeastSquaresOptions Iteration cap and the three convergence tolerances. The Jacobian is finite-difference.
val gauge: RigidGauge? Applied after the solve; null leaves the answer wherever in its null space the minimiser left it.
fun withWeight(name: String, weight: Double): RelaxationPlan The same plan with every family of name reweighted; unknown names are a no-op.

RealizationSolverOptions

data class RealizationSolverOptions(val edgeLengthWeight: Double = 1.0, val facePlanarityWeight: Double = 2.0, val centroidWeight: Double = 4.0, val radiusWeight: Double = 0.2, val referenceWeight: Double = 1e-3, val targetEdgeLength: Double? = null, val leastSquares: LeastSquaresOptions = LeastSquaresOptions(maxIterations = 96))

Weights are residual multipliers; set a term to zero to disable that constraint family.

Declaration Summary
val edgeLengthWeight: Double
val facePlanarityWeight: Double
val centroidWeight: Double
val radiusWeight: Double
val referenceWeight: Double The weak pull back toward the input embedding; small, because it only picks a representative.
val targetEdgeLength: Double? Null keeps the object's own size by targeting the initial mean edge length.
val leastSquares: LeastSquaresOptions
fun toPlan(): RelaxationPlan The same request expressed in the composable vocabulary.

RealizationDiagnostics

data class RealizationDiagnostics(val edgeLengthMean: Double, val edgeLengthRmsDeviation: Double, val maxFacePlanarityError: Double, val facePlanarityRmsError: Double, val centroid: Vec3, val degenerateFaces: List<ElementId>)

Unconditional geometric measurements of one embedding, in the units of its own coordinates.

Declaration Summary
val edgeLengthMean: Double Zero when the topology has no edges, which is also what a perfectly collapsed object reports.
val edgeLengthRmsDeviation: Double Rms deviation of edge length about edgeLengthMean — an absolute length, not a relative figure.
val maxFacePlanarityError: Double Worst absolute distance from any face corner to that face's own Newell best-fit plane.
val facePlanarityRmsError: Double The same distances as an rms over every face corner; a triangle contributes zero by construction.
val centroid: Vec3 The vertex centroid, as a fresh mutable Vec3 the caller owns.
val degenerateFaces: List<ElementId> Faces whose Newell normal is shorter than 1e-10. That length is twice the polygon's area, so this is an absolute area test and it moves with the object's scale: shrink an embedding far enough and every face is reported degenerate.

RealizationSolveResult

data class RealizationSolveResult(val polyhedron: PolyhedralObject, val initialDiagnostics: RealizationDiagnostics, val finalDiagnostics: RealizationDiagnostics, val converged: Boolean, val iterations: Int, val rmsResidual: Double, val reason: String, val initialConstraints: List<ConstraintResidualReport> = emptyList(), val finalConstraints: List<ConstraintResidualReport> = emptyList())

What one realization run did, and enough evidence to judge it.

Declaration Summary
val polyhedron: PolyhedralObject The gauged result: same topology and attributes, new coordinates.
val initialDiagnostics: RealizationDiagnostics
val finalDiagnostics: RealizationDiagnostics Measured on polyhedron, so after the gauge.
val converged: Boolean One of the minimiser's tolerances was met — not "the answer is right". False means it exhausted its iterations or could not find a downhill step; reason says which.
val iterations: Int
val rmsResidual: Double Root-mean-square of the weighted residuals: solver units, not a geometric error, and comparable only between runs of the same plan.
val reason: String Why the loop stopped. From relax, one of gradient, step, improvement, no accepted step or max iterations; from PolyhedralObject.relaxWithCeres, a lowercased Ceres termination type.
val initialConstraints: List<ConstraintResidualReport> Unweighted per-family error before the solve, in the units of each constrained quantity.
val finalConstraints: List<ConstraintResidualReport> The same families after the solve; the pair says which constraint actually gave way.

Realize.kt

Coordinates from combinatorics alone, and the gauge that makes an answer a representative.

Two unrelated jobs, kept in one file because the second is what makes the first reproducible.

PolytopeTopology.realize is the first of the library's three realization verbs. It differs from RealizationPolicy3, which computes a Conway output's coordinates from its source's: a RealizationConstruction sees only the incidence structure and must invent an embedding from nothing. SpectralRealization is the one built in — the low Laplacian modes of the vertex graph, which is exactly the symmetric solid for a vertex-transitive graph and a good symmetry-respecting starting point otherwise.

RigidGauge is the other half. Every constraint family in this package is invariant under rigid motion, and most under scale, so a solver's answer is only defined up to seven degrees of freedom. Leaving that to a weak proximity term makes results depend on solver damping; instead PolyhedralObject.normalize translates a declared centre to the origin, scales a declared measured length to a target, and — given a reference embedding — applies the one rotation that best matches it. The rotation gauge is anchored to a reference rather than to the inertia tensor, because the principal axes of a symmetric solid are degenerate, so an inertia gauge would spin.

A construction can return a self-intersecting embedding with non-planar faces and this file will not notice; that is what PolyhedralObject.canonicalDiagnostics and PolyhedralObject.geometryDiagnostics are for.

src/jvmMain/kotlin/afield/geometry/polytope/Realize.kt — read the source

Used in: Polytopes: topology first, coordinates later

Top-level functions

Declaration Summary
fun PolytopeTopology.realize(policy: RealizationConstruction): PolyhedralObject Give an abstract topology coordinates. The result carries no attributes.
fun PolyhedralObject.realize(policy: RealizationConstruction): PolyhedralObject Replace this object's coordinates with ones constructed from its topology alone, keeping the topology and attributes. Useful after an abstract PolytopeTopology.dual, whose combinatorics are exact but whose coordinates either do not exist or came from a centroid heuristic.
fun PolyhedralObject.normalize(gauge: RigidGauge, reference: Realization3? = null): PolyhedralObject Apply gauge to this object's coordinates. reference supplies the orientation to align to; when it is null the orientation is left alone, which is still deterministic because the input orientation is.

RealizationConstruction

fun interface RealizationConstruction

Construct coordinates for an abstract topology according to a declared policy.

Declaration Summary
fun construct(topology: PolytopeTopology): Realization3 Invent coordinates for topology. Must return one point per rank-0 element, in elements(0) order.

SpectralRealization

data class SpectralRealization(val projectToSphere: Boolean = true, val sweeps: Int = 64) : RealizationConstruction

Spectral (Laplacian eigenvector) embedding: the classic "draw the graph with its low modes" construction.

Declaration Summary
val projectToSphere: Boolean Scale every vertex to unit length. On by default because the three eigenvectors have wildly different magnitudes whenever the modes are not degenerate, which flattens the embedding into a ribbon; the projection trades that for a shape that is at least round.
val sweeps: Int Cyclic Jacobi sweeps allowed for the eigendecomposition. The iteration exits early once the off-diagonal mass is negligible, so this is a ceiling rather than a cost.
override fun construct(topology: PolytopeTopology): Realization3

GaugeCentre

enum class GaugeCentre

Which point of the embedding is moved to the origin.

Constants: NONE, VERTEX_CENTROID, EDGE_TANGENCY_CENTROID.

GaugeScale

enum class GaugeScale

Which measured length is driven to the gauge's scale target.

Constants: NONE, MEAN_EDGE_LENGTH, MEAN_VERTEX_RADIUS, MAX_VERTEX_RADIUS, MEAN_EDGE_TANGENCY_RADIUS.

RigidGauge

data class RigidGauge(val centre: GaugeCentre = GaugeCentre.VERTEX_CENTROID, val scale: GaugeScale = GaugeScale.MEAN_EDGE_LENGTH, val scaleTarget: Double = 1.0, val alignToReference: Boolean = true)

The explicit normalization that makes a realization a representative rather than a member of a seven-dimensional family.

Declaration Summary
val centre: GaugeCentre Which point of the embedding is moved to the origin. GaugeCentre.EDGE_TANGENCY_CENTROID is not a single subtraction — tangency points move when the object does, so it is iterated to a fixed point.
val scale: GaugeScale Which measured length is driven to scaleTarget. A measurement smaller than 1e-12 is left alone rather than dividing by it, so a collapsed object survives normalization unscaled.
val scaleTarget: Double What that measured length becomes. Must be finite and positive.
val alignToReference: Boolean Rotate to best match the reference embedding, when one is supplied. With no reference this does nothing and the input orientation is kept — still deterministic, just not canonical.

Solids.kt

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.

src/jvmMain/kotlin/afield/geometry/polytope/Solids.kt — read the source

Used in: Polytopes: topology first, coordinates later, A polytope, canonicalised, and sliced into layers

Top-level functions

Declaration Summary
fun tetrahedron(): PolyhedralObject A regular tetrahedron centred at the origin, with vertices on the unit sphere.
fun cube(): PolyhedralObject A cube of side length 2, centred at the origin — the eight sign combinations of (±1, ±1, ±1).
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.
fun octahedron(): PolyhedralObject A regular octahedron centred at the origin, with vertices on the unit sphere.
fun icosahedron(): PolyhedralObject A regular icosahedron centred at the origin, with vertices on the unit sphere.
fun dodecahedron(): PolyhedralObject A regular dodecahedron, constructed as the realized dual of the regular icosahedron.

Realization3

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.

Declaration Summary
val size: Int How many vertices are realized; equal to the topology's rank-0 element count.
val vertexCoordinates: List<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 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.

PolyhedralObject

data class PolyhedralObject(val topology: PolytopeTopology, val realization: Realization3? = null, val attributes: PolytopeAttributes = PolytopeAttributes.EMPTY)

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

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

Summaries.kt

Small, plain data classes that reduce the library's big values to something printable.

The mathematical types in this package contain no HTML and know nothing about presentation. PolytopeVisualizers.kt registers a value printer for each big type that reduces it to one of these summaries and hands that to Field's ordinary reflective visualizer. So a bare polyhedron or result as a box's last expression is informative, and the same facts are available programmatically — println(result.summary()) reports exactly what the inspector shows, because it is the same call.

What to trust in them

They are lossy, and a few of the losses matter:

  • A summary is a snapshot, not a view. Every one of these is computed eagerly from the value at the moment you ask, and several are not cheap: PolytopeTopology.summary runs the validator, twice for a valid rank-3 topology.
  • Counts of things are exact; the identity of things is gone. PolytopeTopologySummary reports how many faces have each degree but not which; SymmetryDeviation reports the worst spread but not the class it came from. Go back to the underlying value for that.
  • A null almost always means "not measurable" rather than "no". PolytopeTopologySummary.closedManifold is null when the check was not run, and Realization3Summary's bounds are null for an empty realization.
  • Names are used as keys, so same-named things collapse. RelaxationPlanSummary.weights is keyed by constraint family name and PolytopeTransformSummary lists operator display names, so two edge-length families with different targets, or two distinct operators printing the same name, are indistinguishable in the summary.

src/jvmMain/kotlin/afield/geometry/polytope/Summaries.kt — read the source

Top-level functions

Declaration Summary
fun PolytopeTopology.summary(): PolytopeTopologySummary Counts, face-degree histogram, Euler characteristic and validity in one value.
fun Realization3.summary(): Realization3Summary Axis-aligned bounds and the vertex centroid, in one pass. All three are null for no vertices.
fun PolyhedralObject.summary(): PolyhedralObjectSummary Topology, geometry and attribute overview of a whole object — the value Field prints for a bare polyhedron.
fun ValidationResult.summary(): PolytopeValidationSummary Issue counts grouped by kind, plus every message.
fun TransformDeployment.summary(): TransformDeploymentSummary What one primitive operator was actually deployed over: how many sources it took, at which ranks, and the range of every scalar it resolved.
fun PolytopeTransformResult.summary(): PolytopeTransformSummary The whole chain: what was asked for, what primitives ran, what came out, and how each step was deployed.
fun RelaxationPlan.summary(): RelaxationPlanSummary The plan as declared — families, weights, iteration cap, gauge — and nothing about a solve.
fun TriangulatedGeometry.summary(): TriangulatedGeometrySummary Render-side counts beside the abstract ones they came from.

PolytopeTopologySummary

data class PolytopeTopologySummary(val rank: Int, val elementCounts: Map<Int, Int>, val faceDegreeCounts: Map<Int, Int>, val eulerCharacteristic: Int, val faceHoleCount: Int, val cellularEulerCharacteristic: Int, val valid: Boolean, val closedManifold: Boolean?)

Compact, stable inspection value for a rank-aware incidence topology.

Declaration Summary
val rank: Int
val elementCounts: Map<Int, Int> Element count per rank, 0..rank inclusive — so the whole-polytope element is counted here.
val faceDegreeCounts: Map<Int, Int> How many faces have each corner count, ordered by degree. Faces with no stored ordered boundary are silently omitted, so this can total fewer than elementCounts[2].
val eulerCharacteristic: Int Alternating sum over the proper ranks; the whole-polytope rank is excluded.
val faceHoleCount: Int Inner boundary rings summed over every face — 0 for everything the seeds and the Conway operators produce, so a non-zero number here is why closedManifold is false.
val cellularEulerCharacteristic: Int eulerCharacteristic with one subtracted per hole: a face with h holes is a disk with h disks removed. Equal to eulerCharacteristic whenever faceHoleCount is 0.
val valid: Boolean Basic incidence and boundary validation only.
val closedManifold: Boolean? Null when the stricter every-edge-has-two-faces check was not run, not when it failed.

Realization3Summary

data class Realization3Summary(val vertexCount: Int, val boundsMin: Vec3?, val boundsMax: Vec3?, val centroid: Vec3?)

Bounds and centre of an immutable ordinary 3D realization.

Declaration Summary
val vertexCount: Int
val boundsMin: Vec3? Null exactly when there are no vertices; otherwise a fresh Vec3 the caller owns.
val boundsMax: Vec3?
val centroid: Vec3? The mean vertex position — the vertex centroid, which is not the centre of the bounding box.

PolyhedralObjectSummary

data class PolyhedralObjectSummary(val topology: PolytopeTopologySummary, val realization: Realization3Summary?, val attributedElements: Int, val attributeKeys: Set<String>)

The parts of a complete polyhedral object that are most useful at a live-coding prompt.

Declaration Summary
val topology: PolytopeTopologySummary
val realization: Realization3Summary? Null when the object is still abstract — a real state, not a failure.
val attributedElements: Int How many elements carry any attribute at all, a name set to null included.
val attributeKeys: Set<String> Every attribute name in use anywhere on the object, sorted; values are not summarised.

PolytopeValidationSummary

data class PolytopeValidationSummary(val valid: Boolean, val issueCount: Int, val issueTypes: Map<String, Int>, val messages: List<String>)

Structured validation output without exposing the validator's immutable wrapper internals.

Declaration Summary
val valid: Boolean
val issueCount: Int
val issueTypes: Map<String, Int> Issue class simple names to counts, sorted — the shape of the failure, not its location.
val messages: List<String> One message per issue, in the order the validator found them; these do name elements.

ScalarParameterSummary

data class ScalarParameterSummary(val name: String, val count: Int, val min: Double, val max: Double)

Min/max inspection of one scalar parameter resolved over a transform deployment.

Declaration Summary
val name: String
val count: Int How many elements resolved a value for name, which need not be every selected element.
val min: Double
val max: Double

TransformDeploymentSummary

data class TransformDeploymentSummary(val operation: String, val selectedCount: Int?, val selectedRanks: Map<Int, Int>, val scalarParameters: List<ScalarParameterSummary>)

Compact replay information for one primitive Conway deployment.

Declaration Summary
val operation: String
val selectedCount: Int? Null means the operator's whole natural domain; a number means an explicit selection.
val selectedRanks: Map<Int, Int> Selected elements per rank — the evidence that a selector hit the rank you expected.
val scalarParameters: List<ScalarParameterSummary>

PolytopeTransformSummary

data class PolytopeTransformSummary(val requestedOperations: List<String>, val primitiveOperations: List<String>, val output: PolyhedralObjectSummary, val deployments: List<TransformDeploymentSummary>)

Provenance-aware summary of a complete transform chain and its output.

Declaration Summary
val requestedOperations: List<String> What the caller asked for, named composites retained: bevel, not its primitive expansion.
val primitiveOperations: List<String> The same chain fully expanded into primitives; this is what actually ran.
val output: PolyhedralObjectSummary
val deployments: List<TransformDeploymentSummary> One per primitive step, so this aligns with primitiveOperations and not with the requests.

RelaxationPlanSummary

data class RelaxationPlanSummary(val constraints: List<String>, val weights: Map<String, Double>, val maxIterations: Int, val gauge: String)

What a realization run was actually asked to do: named families, weights, and normalization.

Declaration Summary
val constraints: List<String> Family names in plan order, including any weighted zero — a plan is a declaration, not a run.
val weights: Map<String, Double> Keyed by family name, so two families sharing a name collapse to one entry.
val maxIterations: Int
val gauge: String The gauge as centre / scale -> target, or none when the answer is left un-normalized.

TriangulatedGeometrySummary

data class TriangulatedGeometrySummary(val renderVertices: Int, val triangles: Int, val sourcePolygonFaces: Int, val sourceVertexElements: Int, val hasFlatNormals: Boolean)

Rendering-derived counts while retaining the polygon-to-triangle provenance distinction.

Declaration Summary
val renderVertices: Int Mesh vertices, which exceed the abstract vertex count because corners are duplicated per face.
val triangles: Int
val sourcePolygonFaces: Int Distinct polygon faces that produced triangles.
val sourceVertexElements: Int Distinct abstract vertices behind those render vertices — the deduplicated figure.
val hasFlatNormals: Boolean

Symmetry.kt

Symmetry as a combinatorial fact, and as something a solver can be asked for.

The library never computes a symmetry group. It computes a partition — combinatorialOrbits — of the elements into classes that no automorphism of the topology can tell apart, by iterated incidence-colour refinement over the ranked incidence structure. Coordinates play no part, so the classes of a lumpy embedding and of its canonical form are identical: they are a property of the combinatorics.

Two things are built on that. SymmetryOrbitConstraint turns the partition into residuals — same edge length within an edge class, same vertex radius within a vertex class, same face-plane distance within a face class — so a realization run can be asked to look symmetric, and RelaxationPlan.Companion.symmetricCanonical is the canonical conditions plus that family. symmetryDeviation is the readout in the other direction: how equal the geometry actually is inside each class, as a relative spread.

The caveat that runs through all of it

Colour refinement is not a complete isomorphism invariant, so a class is a union of true automorphism orbits and may be strictly coarser than them. Constraining a class is therefore at least as strong as constraining an orbit — and on a topology where it is strictly stronger, the constraint asks for an equality no embedding can deliver. That case is not detected: the symmetry-orbit family reports a real residual and the reader is expected to look. The long-term fix named in dev_docs/POLYTOPE_ALGEBRA.md is Wythoff construction, which would supply actual group orbits.

These are all soft constraints. Nothing in this file projects onto a symmetry group's fixed subspace, so nothing here can manufacture symmetry the topology does not have.

src/jvmMain/kotlin/afield/geometry/polytope/Symmetry.kt — read the source

Used in: Polytopes: topology first, coordinates later

Top-level functions

Declaration Summary
fun CombinatorialOrbits.summary() Class sizes per rank — a cube reports [8], [12], [6], one class each, because it is fully transitive.
fun PolytopeTopology.combinatorialOrbits(): CombinatorialOrbits Refine the elements of ranks 0..2 into combinatorial classes. See CombinatorialOrbits for exactly what the classes do and do not promise.
fun PolyhedralObject.combinatorialOrbits(): CombinatorialOrbits This object's combinatorial classes, which are a fact about its topology alone.
fun PolyhedralObject.symmetryOrbitConstraint(weight: Double = 1.0): SymmetryOrbitConstraint The SymmetryOrbitConstraint for this object's own combinatorial classes.
fun RelaxationPlan.Companion.symmetricCanonical(polyhedron: PolyhedralObject, symmetryWeight: Double = 2.0, planarityWeight: Double = 2.0, tangencyWeight: Double = 1.0, tangencyCentroidWeight: Double = 4.0, radius: Double = 1.0, leastSquares: LeastSquaresOptions = LeastSquaresOptions(maxIterations = 128)): RelaxationPlan The canonical conditions plus combinatorial-orbit equality, for when the answer must look symmetric and not merely satisfy the midsphere conditions.
fun PolyhedralObject.symmetryDeviation(): SymmetryDeviation Largest within-class relative spread of each measured quantity.

CombinatorialOrbits

data class CombinatorialOrbits(val vertexClasses: List<List<ElementId>>, val edgeClasses: List<List<ElementId>>, val faceClasses: List<List<ElementId>>)

A partition of the elements into classes that combinatorially indistinguishable elements share.

Declaration Summary
val vertexClasses: List<List<ElementId>> Rank-0 classes, each sorted, the classes ordered by their smallest member.
val edgeClasses: List<List<ElementId>>
val faceClasses: List<List<ElementId>> Rank-2 classes. Ranks above 2 are not refined and are absent from this value entirely.
val vertexClassCount: Int
val edgeClassCount: Int
val faceClassCount: Int
val isFullyTransitive: Boolean True when every rank has exactly one class: the hallmark of a fully transitive solid.

CombinatorialOrbitsSummary

data class CombinatorialOrbitsSummary(val vertexClassSizes: List<Int>, val edgeClassSizes: List<Int>, val faceClassSizes: List<Int>, val fullyTransitive: Boolean)

Compact inspection value: the class sizes on their own, short enough to read at a live-coding prompt.

Declaration Summary
val vertexClassSizes: List<Int> Sizes only, in class order. Which elements are in which class is not recoverable from here.
val edgeClassSizes: List<Int>
val faceClassSizes: List<Int>
val fullyTransitive: Boolean

SymmetryOrbitConstraint

data class SymmetryOrbitConstraint(override val weight: Double = 1.0, val vertexClass: List<Int>, val edgeClass: List<Int>, val faceClass: List<Int>) : RealizationConstraint

Require combinatorially equivalent elements to be geometrically equal: same edge length within an edge class, same distance from the centroid within a vertex class, same face-plane distance from the centroid within a face class.

Declaration Summary
override val weight: Double
val vertexClass: List<Int> One non-negative class index per vertex, in topology.elements(0) order. Gaps are harmless.
val edgeClass: List<Int>
val faceClass: List<Int>
override fun residualCount(problem: RealizationProblem)
override fun residuals(problem: RealizationProblem, x: DoubleArray, into: DoubleArray, offset: Int)
override fun withWeight(weight: Double)

SymmetryDeviation

data class SymmetryDeviation(val vertexRadiusSpread: Double, val edgeLengthSpread: Double, val faceDistanceSpread: Double, val orbits: CombinatorialOrbitsSummary)

How equal the geometry actually is inside each combinatorial class. Zero spread everywhere is the numeric statement of "this embedding respects the topology's symmetry".

Declaration Summary
val vertexRadiusSpread: Double Worst (max - min) / \|mean\| of vertex-to-centroid distance within any one vertex class.
val edgeLengthSpread: Double
val faceDistanceSpread: Double The same, for the signed distance from the vertex centroid to each face's plane.
val orbits: CombinatorialOrbitsSummary

Topology.kt

The abstract incidence complex — the authoritative value in afield.geometry.polytope, and the one layer that knows nothing about coordinates.

A PolytopeTopology is a set of ranked Elements with identity, incidences between adjacent ranks only, and an explicitly ordered vertex boundary per rank-2 face. Nothing in this file holds a position, an FLine, a triangle, a normal or a renderer; a Realization3 sits above it and can be replaced or thrown away without disturbing the combinatorics. That separation is what lets a Conway operator discard every coordinate, make new ones, and still answer "which face is this, and what was it before".

Ordered cycles are stored rather than derived because unoriented incidence does not choose a traversal: four edges in a ring are equally consistent with two different quadrilaterals. A cycle can be recovered from incidence where a boundary happens to be a single ring — PolytopeTopology.dual does that for its own output — but that is a construction step, not a property of the poset.

Ranks, and the element that is the whole thing

Ranks are zero-based: 0 vertices, 1 edges, 2 polygons, 3 cells. A topology's PolytopeTopology.rank is its top rank, and by convention the element or elements there stand for the whole object rather than for a geometric feature. So a polyhedron is rank 3 with one rank-3 element meaning "the solid", and its proper ranks are 0 until rank. This is why PolytopeTopology.eulerCharacteristic sums the proper ranks only: it returns 2 for a closed polyhedral surface and 0 for a convex 4-polytope, which are the numbers a reader is checking against.

Validation returns values, not exceptions

PolytopeTopologyBuilder records what it is told and checks almost nothing. Structural complaints are ValidationIssue values returned by PolytopeTopology.validate, so a tool or a test can see everything wrong with a malformed input at once, rather than the first thing. Call ValidationResult.throwIfInvalid — or PolytopeTopologyBuilder.buildValidated — where an exception is what you actually wanted.

val b = PolytopeTopologyBuilder(rank = 3)
val v = List(3) { b.addElement(0) }
val f = b.addElement(2)
b.setFaceCycle(f, v)
b.build().validate().issues.forEach { println(it.message) }   // three edges never recorded

Faces with holes

A face's boundary is an outer cycle plus zero or more inner cycles — see FaceBoundary. faceCycle returns the outer ring, which is what the twenty-odd callers of it in this package mean. A hole belongs to its face rather than being a rank-2 element in its own right, so it carries no attributes and no picking id, and casts no vote in eulerCharacteristic; cellularEulerCharacteristic is the reading that subtracts one per hole. dev_docs/POLYTOPE_ALGEBRA.md weighs that shape against the alternatives.

Winding is a convention here, not a check. Outer rings run counter-clockwise about the face normal and inner rings run clockwise. There are no coordinates at this layer, so that cannot be verified here — and nothing downstream depends on it either, because afield.geometry.path.PlanarTessellation normalises each ring's winding and decides which ring is the outer one geometrically. What validate does check is combinatorial: rings vertex-disjoint, each ring closed over the face's own edges, every incident edge used by exactly one ring, and one boundary component per ring.

A holed face is a surface with boundary — its hole edges bound one face, not two — so validate(checkManifold = true) rejects it, correctly. dual and the Conway operators require a closed manifold and therefore refuse it explicitly, rather than dropping the hole silently.

What is absent

No orientation of the complex as a whole, and no assumption that it is a closed manifold: validate(checkManifold = true) is an opt-in stricter pass, and a 4-polytope legitimately fails it because an edge there bounds three or more polygons. No incidence between non-adjacent ranks. And no general incidence-poset isomorphism — isCombinatoriallyIsomorphicTo is a rank-3 helper and says so.

src/jvmMain/kotlin/afield/geometry/polytope/Topology.kt — read the source

Used in: Polytopes: topology first, coordinates later

Top-level functions

Declaration Summary
fun PolytopeTopology.isCombinatoriallyIsomorphicTo(other: PolytopeTopology): Boolean Small rank-3 combinatorial isomorphism helper. It tries vertex bijections, then compares edge endpoint pairs and face cycles up to rotation/reversal. Its scope is rank 3, and it is not a general incidence-poset isomorphism solver.

ElementId

value class ElementId(val value: Int)

A globally unique element identifier within one PolytopeTopology.

Element

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

An element of the incidence poset: an identity and the rank it lives at, and nothing else.

Declaration Summary
val id: ElementId
val rank: Int

ValidationIssue

sealed interface ValidationIssue

One structured problem found while validating a topology, reported as a value rather than thrown.

Declaration Summary
val message: String

InvalidElementReference

data class InvalidElementReference(val context: String, val reference: ElementId) : ValidationIssue

An incidence endpoint or a face-cycle owner names an id that is not in this topology.

Declaration Summary
override val message

IncorrectRankTransition

data class IncorrectRankTransition(val lower: ElementId, val lowerRank: Int, val higher: ElementId, val higherRank: Int) : ValidationIssue

An incidence spans non-adjacent ranks. Such an incidence is also omitted from the up/down maps entirely, so the topology behaves as though it had never been recorded.

Declaration Summary
val lower: ElementId
val lowerRank: Int
val higher: ElementId
val higherRank: Int
override val message

DuplicateIncidence

data class DuplicateIncidence(val lower: ElementId, val higher: ElementId) : ValidationIssue

The same ordered pair was recorded twice; the second call added nothing.

Declaration Summary
override val message

NonReciprocalIncidence

data class NonReciprocalIncidence(val lower: ElementId, val higher: ElementId) : ValidationIssue

The up and down views of one incidence disagree. Unreachable through PolytopeTopologyBuilder, which derives both from a single list — a guard against a future construction path, not a diagnosis of anything a caller can write today.

Declaration Summary
override val message

EdgeEndpointCount

data class EdgeEndpointCount(val edge: ElementId, val count: Int) : ValidationIssue

An edge with other than two distinct vertices: a self-loop, a hyperedge, or one never joined up.

Declaration Summary
override val message

FaceTooSmall

data class FaceTooSmall(val face: ElementId, val edgeCount: Int, val vertexCount: Int) : ValidationIssue

A face with fewer than three distinct edges, or a cycle with fewer than three vertices.

Declaration Summary
override val message

MissingFaceCycle

data class MissingFaceCycle(val face: ElementId) : ValidationIssue

A rank-2 element nobody called PolytopeTopologyBuilder.setFaceCycle for.

Declaration Summary
override val message

FaceCycleOwnerWrongRank

data class FaceCycleOwnerWrongRank(val element: ElementId, val actualRank: Int) : ValidationIssue

A cycle was set on an element that is not a polygon — usually the wrong id, or an off-by-one rank.

Declaration Summary
override val message

FaceCycleWrongElement

data class FaceCycleWrongElement(val face: ElementId, val element: ElementId, val actualRank: Int?) : ValidationIssue

A cycle names something that is not a vertex of this topology; a null actualRank means missing.

Declaration Summary
override val message

DuplicateFaceCycleVertex

data class DuplicateFaceCycleVertex(val face: ElementId, val vertex: ElementId) : ValidationIssue

A cycle visits the same vertex twice — a pinched or doubled-back boundary, not a simple polygon.

Declaration Summary
override val message

FaceRingsShareVertex

data class FaceRingsShareVertex(val face: ElementId, val vertex: ElementId) : ValidationIssue

Two of a face's boundary rings meet at a vertex, or one inner ring visits a vertex twice. A hole that touches its outer ring is not a hole: the face is pinched there and its interior is not a disk with a disk removed. Split it into two faces instead.

Declaration Summary
override val message

FaceHoleTooSmall

data class FaceHoleTooSmall(val face: ElementId, val hole: Int, val vertexCount: Int) : ValidationIssue

An inner boundary ring with fewer than three vertices; hole indexes PolytopeTopology.faceHoles.

Declaration Summary
override val message

FaceHolesWithoutOuterBoundary

data class FaceHolesWithoutOuterBoundary(val face: ElementId, val holeCount: Int) : ValidationIssue

Inner rings were recorded for a face that has no outer boundary to subtract them from.

Declaration Summary
override val message

FaceCycleVertexNotIncident

data class FaceCycleVertexNotIncident(val face: ElementId, val vertex: ElementId) : ValidationIssue

The ordered boundary and the recorded edge incidences disagree about which vertices are on the face.

Declaration Summary
override val message

FaceCycleEdgeMismatch

data class FaceCycleEdgeMismatch(val face: ElementId, val a: ElementId, val b: ElementId) : ValidationIssue

Two consecutive cycle vertices with no edge of this face joining them; usually a cycle out of order.

Declaration Summary
override val message

FaceIncidenceAbsentFromCycle

data class FaceIncidenceAbsentFromCycle(val face: ElementId, val edge: ElementId) : ValidationIssue

The face claims an edge its own ordered boundary never traverses — an extra or stale incidence.

Declaration Summary
override val message

DisconnectedFaceBoundary

data class DisconnectedFaceBoundary(val face: ElementId, val expectedRings: Int = 1) : ValidationIssue

The face's edges do not form the closed rings — every vertex of degree two, one component per recorded ring — that its boundary claims: two separate polygons recorded as a single face, a figure-of-eight, a boundary with a gap in it, or a hole ring whose edges are not there.

Declaration Summary
override val message

NonManifoldEdge

data class NonManifoldEdge(val edge: ElementId, val faceCount: Int) : ValidationIssue

An edge with other than exactly two faces. Produced only under validate(checkManifold = true); every edge of a 4-polytope reports this, which is the point of the flag being opt-in.

Declaration Summary
override val message

ValidationResult

class ValidationResult(issues: List<ValidationIssue>)

An immutable report, not a control-flow exception: everything one PolytopeTopology.validate pass found, in the order it found it.

Declaration Summary
val issues: List<ValidationIssue>
val isValid: Boolean
fun throwIfInvalid() Throw TopologyValidationException carrying every issue, or return without complaint. The bridge from the report style back to ordinary Kotlin failure, used by PolytopeTopologyBuilder.buildValidated and by each Field adapter before it assumes a well-formed input.

TopologyValidationException

class TopologyValidationException(val result: ValidationResult) : IllegalArgumentException(result.issues.joinToString(prefix = "invalid polytope topology:\n", separator = "\n") { "- ${it.message}" })

The report turned into a throw. Its message is every ValidationIssue.message as a bulleted list, and result is kept, so a caller that catches this can branch on the issue classes instead of parsing that text.

FaceBoundary

class FaceBoundary internal constructor(outer: IntArray, holes: List<IntArray>)

One rank-2 face's complete oriented boundary: an outer cycle and the inner cycles cut out of it.

Declaration Summary
val outer: IntArray The outward boundary, counter-clockwise about the face normal by convention.
val holes: List<IntArray> The inner boundaries, in the order they were recorded; empty for an ordinary face.
val holeCount: Int How many inner rings this face has. Zero for every face of every seed in this package.
val rings: List<IntArray> outer first, then each ring of holes — the order a tessellator wants them in.
override fun toString(): String

PolytopeTopology

class PolytopeTopology internal constructor(val rank: Int, elementsByRank: List<List<Element>>, incidences: List<Pair<ElementId, ElementId>>, faceCycles: Map<ElementId, IntArray>, faceHoles: Map<ElementId, List<IntArray>> = emptyMap())

Immutable, rank-aware incidence topology.

Declaration Summary
val rank: Int The top rank, whose elements are the whole polytope; the proper ranks are 0 until rank.
val elementsByRank: List<List<Element>> Every element, indexed by rank: an outer list of length rank + 1, each inner list in the order the builder allocated them. Unmodifiable at both levels, and the ordering is the one every parallel array in this package — realization coordinates included — is aligned to.
fun elements(rank: Int): List<Element> The elements at one rank, or an empty list for a rank outside 0..rank. Total, so a sweep over ranks or a speculative query about a rank this object may not have needs no bounds check — and an empty result never distinguishes "no such rank" from "nothing there".
val faces: List<Element> The rank-2 elements, elements(2): what faceCycle, faceNormal and faceCentroids are asked about.
val vertexMap: Map<Int, Int> by lazy Raw vertex id value → its index in elements(0), which is also its index into Realization3.vertexCoordinates. Keyed by the raw value because that is what faceCycle hands back, so faceCycle(f).map { coordinates[vertexMap.getValue(it)] } is a face's realized ring.
fun element(id: ElementId): Element? The element with this id, or null if it belongs to some other topology. The way to learn an id's rank.
fun incidentUp(element: ElementId): IntArray IDs incident one rank above element, in stable construction order.
fun incidentDown(element: ElementId): IntArray IDs incident one rank below element, in stable construction order.
fun faceCycle(face: ElementId): IntArray The explicitly stored outer ordered vertex boundary of a rank-2 face, as a fresh array of raw ElementId values in traversal order.
fun faceCycleOrNull(face: ElementId): IntArray? faceCycle without the throw: null for any element that has no stored boundary.
fun faceHoles(face: ElementId): List<IntArray> The inner boundary rings cut out of face, in the order they were recorded, or an empty list.
fun faceHasHoles(face: ElementId): Boolean Whether face has any inner boundary ring. The one predicate the render path branches on.
fun faceRings(face: ElementId): List<IntArray> Every ring of face's boundary, outer first — the form a tessellator wants. One entry for an ordinary face, so a caller that wants to handle both cases uniformly can.
fun faceBoundary(face: ElementId): FaceBoundary face's complete oriented boundary as one value. Throws exactly when faceCycle would.
fun faceBoundaryOrNull(face: ElementId): FaceBoundary? faceBoundary without the throw: null for any element that has no stored outer cycle.
fun verticesOfFace(face: ElementId): List<Element> All vertices of face's outer boundary, in order. Throws if the cycle names an id this topology does not hold — a state validate would have reported as FaceCycleWrongElement. Hole vertices are not included; ask faceHoles for those.
fun totalFaceHoleCount(): Int Inner rings summed over every face — zero for everything this package constructs by default.
fun eulerCharacteristic(): Int Alternating count of all proper ranks; the whole-polytope rank is excluded. So it is 2 for a closed polyhedral surface and 0 for a convex 4-polytope, and either number is a cheap check that a transform did not lose or duplicate elements.
fun cellularEulerCharacteristic(): Int eulerCharacteristic corrected for holes: a face with h inner rings is a disk with h disks removed and contributes 1 - h, not 1.
fun validate(checkManifold: Boolean = false): ValidationResult Validate basic incidence and explicit polygon boundaries. Set checkManifold to additionally require every edge to have exactly two incident faces. Non-manifold structures remain legal at the core level.
fun dual(): PolytopeTopology Abstract dual. Proper ranks are reversed while the whole-polytope rank remains the whole. For rank 3 this is exactly vertices <-> faces and edges <-> edges.

PolytopeTopologyBuilder

class PolytopeTopologyBuilder(val rank: Int)

Mutable assembly surface whose build result is immutable — and the only way to make a PolytopeTopology.

Declaration Summary
val rank: Int The top rank of the topology being assembled; addElement accepts 0..rank inclusive.
fun addElement(rank: Int): ElementId Allocate a fresh element at one rank and return its new id. Ids come from a single counter shared by all ranks, so they are unique but carry no rank information.
fun addIncidence(lower: ElementId, higher: ElementId): PolytopeTopologyBuilder Record an incidence. Reference and rank errors are reported by validate after build, so a tool or a test can inspect all malformed input at once.
fun setFaceCycle(face: ElementId, vertices: IntArray): PolytopeTopologyBuilder Give a rank-2 face the ordered vertex boundary that unoriented incidence cannot supply. The array is copied, and a face has at most one cycle, so a second call replaces the first.
fun setFaceCycle(face: ElementId, vertices: List<ElementId>): PolytopeTopologyBuilder The same, spelt in ElementIds — the form construction code normally has to hand.
fun addFaceHole(face: ElementId, vertices: IntArray): PolytopeTopologyBuilder Cut one more hole out of a rank-2 face: an ordered inner boundary, clockwise about the face normal by convention, wound opposite to the cycle setFaceCycle recorded.
fun addFaceHole(face: ElementId, vertices: List<ElementId>): PolytopeTopologyBuilder The same, spelt in ElementIds.
fun setFaceHoles(face: ElementId, rings: List<IntArray>): PolytopeTopologyBuilder Replace every inner ring of face at once; an empty list removes them all.
fun build(): PolytopeTopology Materialise an immutable PolytopeTopology from whatever has been recorded so far. Always succeeds: a malformed topology is a buildable one, and inspecting it is how you learn what is wrong. The builder stays usable afterwards.
fun buildValidated(checkManifold: Boolean = false): PolytopeTopology build followed by ValidationResult.throwIfInvalid, so a bad assembly fails here rather than at the first adapter that assumed a well-formed input. Set checkManifold to also demand exactly two faces per edge — true of every rank-3 seed in this package, and of no 4-polytope.

Transforms.kt

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.

src/jvmMain/kotlin/afield/geometry/polytope/Transforms.kt — read the source

Used in: Polytopes: topology first, coordinates later

Top-level functions

Declaration Summary
fun ElementLineage.sourceElements(): Set<ElementRef> Every seed element this lineage descends from, deduplicated, in order of first encounter.
fun PolytopeTopology.transform(operation: ConwayOperator): TopologyTransform Run one primitive operator over this complex, producing new combinatorics and their provenance.
fun PolytopeTopology.kis(selectedFaces: Collection<ElementId>): TopologyTransform Kis only the named faces: each grows an apex, every other face is copied unchanged.
fun PolytopeTopology.truncate(selectedVertices: Collection<ElementId>): TopologyTransform Truncate only the named vertices: each is replaced by a face, its neighbours left alone.
fun PolyhedralObject.transform(operation: ConwayOperator, realizationPolicy: RealizationPolicy3? = null, attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult Apply one Conway operator — primitive or composite — and start a chain from this object.
fun PolytopeTransformResult.transform(operation: ConwayOperator, realizationPolicy: RealizationPolicy3? = null, attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult Apply the next Conway operator to this chain.
fun PolyhedralObject.dual(realizationPolicy: RealizationPolicy3 = DualCentroidRealization, attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult Conway d: faces become vertices and vertices become faces, edges staying edges.
fun PolyhedralObject.ambo(realizationPolicy: RealizationPolicy3 = AmboMidpointRealization, attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult 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.
fun PolyhedralObject.kis(realizationPolicy: RealizationPolicy3 = KisFaceNormalRealization(), attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult 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.
fun PolyhedralObject.truncate(realizationPolicy: RealizationPolicy3 = TruncateAlongEdgesRealization(), attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult 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.
fun PolytopeTransformResult.dual(realizationPolicy: RealizationPolicy3 = DualCentroidRealization, attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult dual in the middle of a chain: same operator, carrying lineage, history and deployments forward.
fun PolytopeTransformResult.ambo(realizationPolicy: RealizationPolicy3 = AmboMidpointRealization, attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult ambo in the middle of a chain: same operator, carrying lineage, history and deployments forward.
fun PolytopeTransformResult.kis(realizationPolicy: RealizationPolicy3 = KisFaceNormalRealization(), attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult kis in the middle of a chain: same operator, carrying lineage, history and deployments forward.
fun PolytopeTransformResult.truncate(realizationPolicy: RealizationPolicy3 = TruncateAlongEdgesRealization(), attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult truncate in the middle of a chain: same operator, carrying lineage, history and deployments forward.
fun PolyhedralObject.kis(selector: ElementSelector, heightScale: ElementDoubleParameter = ElementParameters.constant(0.5), attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult Kis only selected source faces, with a height scale independently evaluated on each face.
fun PolytopeTransformResult.kis(selector: ElementSelector, heightScale: ElementDoubleParameter = ElementParameters.constant(0.5), attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult Kis only the faces the selector matches, mid-chain, with a height scale evaluated once per selected face.
fun PolyhedralObject.truncate(selector: ElementSelector, fraction: ElementDoubleParameter = ElementParameters.constant(1.0 / 3.0), attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult Truncate only selected source vertices, with an independently evaluated cut fraction.
fun PolytopeTransformResult.truncate(selector: ElementSelector, fraction: ElementDoubleParameter = ElementParameters.constant(1.0 / 3.0), attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult Truncate only the vertices the selector matches, mid-chain, with a cut fraction evaluated once per selected vertex.
fun PolyhedralObject.gyro(edgeFraction: ElementDoubleParameter, attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult Apply gyro everywhere while varying its directed edge-point fraction by source edge.
fun PolytopeTransformResult.gyro(edgeFraction: ElementDoubleParameter, attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult Gyro everywhere, mid-chain, with the directed edge-point fraction evaluated once per source edge.
fun PolyhedralObject.gyro(realizationPolicy: RealizationPolicy3 = GyroSubdivisionRealization(), attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult Conway g: every oriented half-edge becomes a pentagon, so an n-gon face becomes n pentagons and the result is chiral.
fun PolytopeTransformResult.gyro(realizationPolicy: RealizationPolicy3 = GyroSubdivisionRealization(), attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult gyro in the middle of a chain: same operator, carrying lineage, history and deployments forward.
fun PolyhedralObject.join(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult 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 PolytopeTransformResult.join(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult join in the middle of a chain: same operator, carrying lineage, history and deployments forward.
fun PolyhedralObject.needle(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult 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 PolytopeTransformResult.needle(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult needle in the middle of a chain: same operator, carrying lineage, history and deployments forward.
fun PolyhedralObject.zip(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult 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 PolytopeTransformResult.zip(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult zip in the middle of a chain: same operator, carrying lineage, history and deployments forward.
fun PolyhedralObject.ortho(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult 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 PolytopeTransformResult.ortho(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult ortho in the middle of a chain: same operator, carrying lineage, history and deployments forward.
fun PolyhedralObject.expand(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult 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 PolytopeTransformResult.expand(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult expand in the middle of a chain: same operator, carrying lineage, history and deployments forward.
fun PolyhedralObject.snub(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult Conway s = dgd: the snub. Original faces survive, separated by bands of triangles, and the result is chiral. sC is the snub cube.
fun PolytopeTransformResult.snub(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult snub in the middle of a chain: same operator, carrying lineage, history and deployments forward.
fun PolyhedralObject.meta(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult 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 PolytopeTransformResult.meta(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult meta in the middle of a chain: same operator, carrying lineage, history and deployments forward.
fun PolyhedralObject.bevel(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult 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 PolytopeTransformResult.bevel(attributePolicy: AttributePropagationPolicy = PreserveCommonAttributes): PolytopeTransformResult bevel in the middle of a chain: same operator, carrying lineage, history and deployments forward.

Top-level values

Declaration Summary
val ConwayOperator.primitiveExpansion: List<ConwayOperator> Primitive operations in execution order (seed outward), not written prefix order.

ConwayOperator

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.

Constants: DUAL, AMBO, KIS, TRUNCATE, GYRO, JOIN, NEEDLE, ZIP, ORTHO, EXPAND, SNUB, META, BEVEL.

ElementRef

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.

ImmediateElementProvenance

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

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

Declaration Summary
val role: String
val sources: List<ElementRef>
val attributeSources: List<ElementRef>

ElementLineage

sealed interface ElementLineage

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

Declaration Summary
data class Source(val element: ElementRef) : ElementLineage A seed element: it was already in the object the chain started from.
data class Derived(val operation: ConwayOperator, val role: String, val parents: List<ElementLineage>) : ElementLineage One operator's output, naming the role the transform gave it (face-center, kis-triangle, …) and the lineages it was built from.

TopologyTransform

class TopologyTransform internal constructor(val operation: ConwayOperator, val topology: PolytopeTopology, immediateProvenance: Map<ElementId, ImmediateElementProvenance>)

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

Declaration Summary
val operation: ConwayOperator
val topology: PolytopeTopology
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.

TransformDeployment

class TransformDeployment internal constructor(val operation: ConwayOperator, selectedSources: Set<ElementRef>?, scalarParameters: Map<ElementRef, Map<String, Double>>)

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

Declaration Summary
val operation: ConwayOperator
val selectedSources: Set<ElementRef>? null means the operator's complete natural domain; a set records an explicit selection.
val scalarParameters: Map<ElementRef, Map<String, Double>>

RealizationPolicy3

fun interface RealizationPolicy3

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

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

AttributePropagationPolicy

fun interface AttributePropagationPolicy

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

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

PreserveCommonAttributes

object PreserveCommonAttributes : AttributePropagationPolicy

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

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

PolytopeTransformResult

class PolytopeTransformResult internal constructor(val polyhedron: PolyhedralObject, lineage: Map<ElementId, ElementLineage>, val history: List<ConwayOperator>, val notationHistory: List<ConwayOperator> = history, val deployments: List<TransformDeployment> = emptyList())

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

Declaration Summary
val polyhedron: PolyhedralObject
val history: List<ConwayOperator> Fully expanded primitive operations, useful for exact provenance and replay.
val notationHistory: List<ConwayOperator> Operations requested by the caller, retaining named Conway composites.
val deployments: List<TransformDeployment> Exact source selections and resolved scalar values for primitive replay and inspection.
fun lineage(element: ElementId): ElementLineage How element came to be, back to the seed. Throws for an element this result does not contain.
fun sourceElements(element: ElementId): Set<ElementRef> The seed elements element descends from — its lineage flattened and deduplicated.

DualCentroidRealization

object DualCentroidRealization : RealizationPolicy3

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

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

AmboMidpointRealization

object AmboMidpointRealization : RealizationPolicy3

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

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

KisFaceNormalRealization

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

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

Declaration Summary
val heightScale: Double
override fun realize(source: PolyhedralObject, transform: TopologyTransform): Realization3?

TruncateAlongEdgesRealization

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

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

Declaration Summary
val fraction: Double
override fun realize(source: PolyhedralObject, transform: TopologyTransform): Realization3?

GyroSubdivisionRealization

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

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

Declaration Summary
val edgeFraction: Double
override fun realize(source: PolyhedralObject, transform: TopologyTransform): Realization3?