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
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
Which iteration drives the embedding toward the canonical conditions.
Constants: RECIPROCAL, LOCAL_RELAXATION, RECIPROCAL_THEN_LOCAL.
CanonicalizeOptions
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
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
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
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
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
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:
EdgeLengthConstraintandEdgeTangencyConstraint— one per edge.CommonRadiusConstraint— one per vertex.FacePlanarityConstraint— one per face corner, so it is usually the largest block.ProximityConstraint— three per vertex.CentroidConstraintandTangencyCentroidConstraint— three, whatever the object's size.
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
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
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
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
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
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
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
Unweighted magnitudes of one constraint family at one embedding.
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
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
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
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
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
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
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
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
A predicate over one element in its context. Pure, called once per candidate, never memoised.
| Declaration | Summary |
|---|---|
fun matches(context: ElementSelectionContext): Boolean |
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
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
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
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
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
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
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
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
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
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
Which point of the embedding is moved to the origin.
Constants: NONE, VERTEX_CENTROID, EDGE_TANGENCY_CENTROID.
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
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
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.summaryruns the validator, twice for a valid rank-3 topology. - Counts of things are exact; the identity of things is gone.
PolytopeTopologySummaryreports how many faces have each degree but not which;SymmetryDeviationreports the worst spread but not the class it came from. Go back to the underlying value for that. - A
nullalmost always means "not measurable" rather than "no".PolytopeTopologySummary.closedManifoldis null when the check was not run, andRealization3Summary's bounds are null for an empty realization. - Names are used as keys, so same-named things collapse.
RelaxationPlanSummary.weightsis keyed by constraint family name andPolytopeTransformSummarylists 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
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
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
The parts of a complete polyhedral object that are most useful at a live-coding prompt.
PolytopeValidationSummary
Structured validation output without exposing the validator's immutable wrapper internals.
ScalarParameterSummary
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
Compact replay information for one primitive Conway deployment.
PolytopeTransformSummary
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
What a realization run was actually asked to do: named families, weights, and normalization.
TriangulatedGeometrySummary
Rendering-derived counts while retaining the polygon-to-triangle provenance distinction.
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
CombinatorialOrbits
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
Compact inspection value: the class sizes on their own, short enough to read at a live-coding prompt.
SymmetryOrbitConstraint
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
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".
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
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
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
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
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
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
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
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
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.canonicalizeis the repair.
src/jvmMain/kotlin/afield/geometry/polytope/Transforms.kt — read the source
Used in: Polytopes: topology first, coordinates later
Top-level functions
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
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
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
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
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
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
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
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? |