Symmetry.kt
src/jvmMain/kotlin/afield/geometry/polytope/Symmetry.kt · 368 lines
@file:afield.runtime.completion.CompletionCategory("Polytopes")
package afield.geometry.polytope
import afield.math.optimization.LeastSquaresOptions
import kotlin.math.max
import kotlin.math.sqrt
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.
data class CombinatorialOrbits
A partition of the elements into classes that combinatorially indistinguishable elements share.
What this is. Iterated incidence-colour refinement (the one-dimensional Weisfeiler-Leman relabelling) over the ranked incidence structure: an element's colour is refined by the multiset of colours immediately above and below it, until the partition stops changing.
What it guarantees. Any automorphism of the topology preserves colours, so every true automorphism orbit lies inside exactly one class: the classes are unions of orbits. Constraining a class to share a measurement is therefore at least as strong as constraining an orbit to. For the Platonic seeds and their Conway images the two coincide, which is why this is a useful symmetry specification rather than an approximation to one.
What it does not guarantee. Colour refinement is not a complete isomorphism invariant. On a
topology where a class is strictly coarser than the orbits it contains, SymmetryOrbitConstraint
asks for an equality the object cannot satisfy; the solver will then report a real residual for
the symmetry-orbit family rather than silently distorting the result. Read the report.
Classes are ordered by their smallest ElementId and each class is sorted, so the value is deterministic and prints usefully.
data class CombinatorialOrbits(
/** Rank-0 classes, each sorted, the classes ordered by their smallest member. */
val vertexClasses: List<List<ElementId>>,
val edgeClasses: List<List<ElementId>>,
/** Rank-2 classes. Ranks above 2 are not refined and are absent from this value entirely. */
val faceClasses: List<List<ElementId>>,
) {
val vertexClassCount: Int get() = vertexClasses.size
val edgeClassCount: Int get() = edgeClasses.size
val faceClassCount: Int get() = faceClasses.size
val isFullyTransitive: Boolean
True when every rank has exactly one class: the hallmark of a fully transitive solid.
val isFullyTransitive: Boolean
get() = vertexClassCount == 1 && edgeClassCount == 1 && faceClassCount == 1
}
data class CombinatorialOrbitsSummary
Compact inspection value: the class sizes on their own, short enough to read at a live-coding prompt.
data class CombinatorialOrbitsSummary(
/** Sizes only, in class order. Which elements are in which class is not recoverable from here. */
val vertexClassSizes: List<Int>,
val edgeClassSizes: List<Int>,
val faceClassSizes: List<Int>,
val fullyTransitive: Boolean,
)
fun CombinatorialOrbits.summary()
Class sizes per rank — a cube reports [8], [12], [6], one class each, because it is fully
transitive.
Reading this line is the fastest way to see what a Conway expression produced: a single size at every rank means a fully transitive solid, and two vertex classes mean the operator split the vertices into kinds. Sizes only, so two genuinely different partitions of the same shape print identically.
fun CombinatorialOrbits.summary() = CombinatorialOrbitsSummary(
vertexClasses.map { it.size },
edgeClasses.map { it.size },
faceClasses.map { it.size },
isFullyTransitive,
)
fun PolytopeTopology.combinatorialOrbits(): CombinatorialOrbits
Refine the elements of ranks 0..2 into combinatorial classes. See CombinatorialOrbits for exactly what the classes do and do not promise.
Ranks above 2 take no part: on a rank-3 object the whole-body element is not classified, and its
incidence contributes the same unknown colour to every face, which is harmless because it is
uniform. The refinement stops the round the partition stops getting finer, which on the shapes this
vocabulary makes is a handful of rounds and not the elements + 2 worst case — but the answer
depends only on the topology, so cache it rather than calling it per frame.
fun PolytopeTopology.combinatorialOrbits(): CombinatorialOrbits = refineCombinatorialOrbits().orbits
internal data class CombinatorialOrbitsRefinement(val orbits: CombinatorialOrbits, val rounds: Int)
combinatorialOrbits together with the number of refinement rounds it actually ran, so a test can
assert the early stop really fires. Pass stopWhenStable = false to spend the whole elements + 2
budget, which is only useful for showing that stopping early costs nothing.
internal data class CombinatorialOrbitsRefinement(val orbits: CombinatorialOrbits, val rounds: Int)
internal fun PolytopeTopology.refineCombinatorialOrbits
body — 41 lines, from line 120
internal fun PolytopeTopology.refineCombinatorialOrbits(
stopWhenStable: Boolean = true,
): CombinatorialOrbitsRefinement {
val ranks = 0..minOf(2, rank)
val elements = ranks.flatMap { elements(it) }
// Seed on rank and local degree: two elements of different rank or valence can never be
// exchanged by an automorphism, so this only saves refinement rounds, it does not change the
// fixed point.
var colour = elements.associate { element ->
element.id to listOf(element.rank, incidentDown(element.id).size, incidentUp(element.id).size).toString()
}
var classes = colour.values.distinct().size
var rounds = 0
// Each round's colour string carries the previous round's colour as its prefix, so the partition
// can only get finer. A round that does not add a class has therefore reached the fixed point and
// every later round would just renumber it: stop.
for (round in 1..elements.size + 2) {
val next = elements.associate { element ->
val below = incidentDown(element.id).map { colour[ElementId(it)] ?: "?" }.sorted()
val above = incidentUp(element.id).map { colour[ElementId(it)] ?: "?" }.sorted()
element.id to "${colour.getValue(element.id)}|$below|$above"
}
// Renumber to keep the colour strings from growing exponentially with the round count.
val canonical = next.values.distinct().sorted().withIndex().associate { (at, value) -> value to at.toString() }
val renumbered = next.mapValues { (_, value) -> canonical.getValue(value) }
val count = renumbered.values.distinct().size
colour = renumbered
rounds = round
if (count == classes && stopWhenStable) break
classes = count
}
fun classesOf(rank: Int): List<List<ElementId>> = elements(rank)
.groupBy { colour.getValue(it.id) }
.values
.map { group -> group.map { it.id }.sortedBy { it.value } }
.sortedBy { it.first().value }
return CombinatorialOrbitsRefinement(CombinatorialOrbits(classesOf(0), classesOf(1), classesOf(2)), rounds)
}
fun PolyhedralObject.combinatorialOrbits(): CombinatorialOrbits = topology.combinatorialOrbits()
This object's combinatorial classes, which are a fact about its topology alone.
The coordinates are not consulted, so a badly distorted embedding and its canonical form report the
same classes. That is the whole point: the classes say what the object should look like, and
symmetryDeviation says how far this embedding is from it.
data class 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.
This is the explicit "symmetry-orbit equality" family. It is a soft constraint like every other member of the vocabulary — it does not project onto a symmetry group's fixed subspace, so it cannot manufacture symmetry the topology does not have, and its report says how much symmetry the final embedding actually achieved.
Build one with PolyhedralObject.symmetryOrbitConstraint rather than by hand; the class indices
must be in topology.elements(rank) order.
vertexCount + edgeCount + faceCount residuals, each a deviation from its own class's mean and so a
length in coordinate units; a class with one member contributes exactly zero. The face measurement
is the signed distance from the vertex centroid to the face's plane, not to the tangency centre.
The class lists are checked against the topology inside the residual evaluation rather than at
construction, so a constraint built for a different object constructs happily and fails on the
solver's first residual call. It also has no Ceres equivalent — a plan containing this family cannot
be handed to PolyhedralObject.relaxWithCeres.
data class SymmetryOrbitConstraint(
override val weight: Double = 1.0,
/** One non-negative class index per vertex, in `topology.elements(0)` order. Gaps are harmless. */
val vertexClass: List<Int>,
val edgeClass: List<Int>,
val faceClass: List<Int>,
) : RealizationConstraint {
override val name get() = "symmetry-orbit"
init
init {
require(weight.isFinite() && weight >= 0.0) { "symmetry-orbit weight must be finite and non-negative" }
}
override fun residualCount(problem: RealizationProblem)
override fun residualCount(problem: RealizationProblem) =
problem.vertexCount + problem.edgeCount + problem.faceCount
override fun residuals(problem: RealizationProblem, x: DoubleArray, into: DoubleArray, offset: Int)
override fun residuals(problem: RealizationProblem, x: DoubleArray, into: DoubleArray, offset: Int) {
require(vertexClass.size == problem.vertexCount && edgeClass.size == problem.edgeCount) {
"symmetry-orbit classes were built for a different topology"
}
require(faceClass.size == problem.faceCount) {
"symmetry-orbit classes were built for a different topology"
}
val centre = centroidOf(x)
val radii = DoubleArray(problem.vertexCount) { distanceToPoint(x, it, centre) }
writeClassDeviation(radii, vertexClass, into, offset)
val lengths = DoubleArray(problem.edgeCount) { distanceBetween(x, problem.edgeA(it), problem.edgeB(it)) }
writeClassDeviation(lengths, edgeClass, into, offset + problem.vertexCount)
val inradii = DoubleArray(problem.faceCount) { face ->
val plane = facePlane(x, problem.faceCycles[face])
val scale = max(plane.normalLength, 1e-10)
((plane.cx - centre[0]) * plane.nx + (plane.cy - centre[1]) * plane.ny + (plane.cz - centre[2]) * plane.nz) / scale
}
writeClassDeviation(inradii, faceClass, into, offset + problem.vertexCount + problem.edgeCount)
}
override fun withWeight(weight: Double) = copy(weight = weight)
Deviation of each measurement from its own class mean; a singleton class contributes zero.
private fun writeClassDeviation(values: DoubleArray, classes: List<Int>, into: DoubleArray, offset: Int) {
if (values.isEmpty()) return
val classCount = (classes.maxOrNull() ?: -1) + 1
val sums = DoubleArray(classCount)
val counts = IntArray(classCount)
for (i in values.indices) {
sums[classes[i]] += values[i]
counts[classes[i]]++
}
for (i in values.indices) {
val mean = sums[classes[i]] / counts[classes[i]]
into[offset + i] = weight * (values[i] - mean)
}
}
}
fun PolyhedralObject.symmetryOrbitConstraint(weight: Double = 1.0): SymmetryOrbitConstraint
The SymmetryOrbitConstraint for this object's own combinatorial classes.
Runs the colour refinement on every call and bakes the resulting element order into the constraint, so the value is tied to this topology and cannot be reused across a transform.
fun PolyhedralObject.symmetryOrbitConstraint(weight: Double = 1.0): SymmetryOrbitConstraint {
val orbits = combinatorialOrbits()
fun indices(rank: Int, classes: List<List<ElementId>>): List<Int> {
val lookup = mutableMapOf<Int, Int>()
classes.forEachIndexed { at, group -> group.forEach { lookup[it.value] = at } }
return topology.elements(rank).map { lookup.getValue(it.id.value) }
}
return SymmetryOrbitConstraint(
weight,
indices(0, orbits.vertexClasses),
indices(1, orbits.edgeClasses),
indices(2, orbits.faceClasses),
)
}
fun RelaxationPlan.Companion.symmetricCanonical
The canonical conditions plus combinatorial-orbit equality, for when the answer must look symmetric and not merely satisfy the midsphere conditions.
Reach for PolyhedralObject.canonicalize first: it is the specialised algorithm and it costs
O(iterations × elements). This plan runs a finite-difference Levenberg-Marquardt solve over
3 × vertexCount parameters, which is the right tool for tens of vertices and the wrong one for
hundreds.
The plan captures polyhedron's classes at the moment it is built, so it belongs to that topology
and no other. Because the symmetry family has no native counterpart, this is also the one stock plan
that cannot be handed to PolyhedralObject.relaxWithCeres.
fun RelaxationPlan.Companion.symmetricCanonical(
polyhedron: PolyhedralObject,
symmetryWeight: Double = 2.0,
planarityWeight: Double = 2.0,
tangencyWeight: Double = 1.0,
tangencyCentroidWeight: Double = 4.0,
radius: Double = 1.0,
leastSquares: LeastSquaresOptions = LeastSquaresOptions(maxIterations = 128),
): RelaxationPlan = RelaxationPlan(
listOf(
FacePlanarityConstraint(planarityWeight),
EdgeTangencyConstraint(tangencyWeight, radius),
TangencyCentroidConstraint(tangencyCentroidWeight),
polyhedron.symmetryOrbitConstraint(symmetryWeight),
),
leastSquares,
RigidGauge.CANONICAL,
)
data class 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".
data class SymmetryDeviation(
/** Worst `(max - min) / |mean|` of vertex-to-centroid distance within any one vertex class. */
val vertexRadiusSpread: Double,
val edgeLengthSpread: Double,
/** The same, for the signed distance from the vertex centroid to each face's plane. */
val faceDistanceSpread: Double,
val orbits: CombinatorialOrbitsSummary,
)
fun PolyhedralObject.symmetryDeviation(): SymmetryDeviation
Largest within-class relative spread of each measured quantity.
Dimensionless, so it reads as a fraction: 1e-8 is a canonical-quality solid, 3e-1 is a lump. It
takes the worst class at each rank, and skips any class with fewer than two members or a mean
within 1e-12 of zero — so 0.0 can mean "perfectly equal" or "there was nothing here to compare",
and SymmetryDeviation.orbits is what tells the two apart.
Requires a realization and a topology that validates, via RealizationProblem.of.
fun PolyhedralObject.symmetryDeviation(): SymmetryDeviation {
val problem = RealizationProblem.of(this)
val x = problem.initialCoordinates
val constraint = symmetryOrbitConstraint()
val centre = centroidOf(x)
fun spread(values: DoubleArray, classes: List<Int>): Double {
if (values.isEmpty()) return 0.0
val classCount = (classes.maxOrNull() ?: -1) + 1
var worst = 0.0
for (group in 0 until classCount) {
val members = values.indices.filter { classes[it] == group }.map { values[it] }
if (members.size < 2) continue
val mean = members.average()
if (kotlin.math.abs(mean) <= 1e-12) continue
worst = max(worst, (members.max() - members.min()) / kotlin.math.abs(mean))
}
return worst
}
val radii = DoubleArray(problem.vertexCount) { distanceToPoint(x, it, centre) }
val lengths = DoubleArray(problem.edgeCount) { distanceBetween(x, problem.edgeA(it), problem.edgeB(it)) }
val distances = DoubleArray(problem.faceCount) { face ->
val plane = facePlane(x, problem.faceCycles[face])
val scale = max(plane.normalLength, 1e-10)
((plane.cx - centre[0]) * plane.nx + (plane.cy - centre[1]) * plane.ny + (plane.cz - centre[2]) * plane.nz) / scale
}
return SymmetryDeviation(
spread(radii, constraint.vertexClass),
spread(lengths, constraint.edgeClass),
spread(distances, constraint.faceClass),
combinatorialOrbits().summary(),
)
}
internal fun rmsDeviation(values: List<Double>): Double
Root-mean-square of a class's deviation from its own mean; used by tests and reports.
internal fun rmsDeviation(values: List<Double>): Double {
if (values.isEmpty()) return 0.0
val mean = values.average()
return sqrt(values.sumOf { (it - mean) * (it - mean) } / values.size)
}