Realize.kt
src/jvmMain/kotlin/afield/geometry/polytope/Realize.kt · 409 lines
@file:afield.runtime.completion.CompletionCategory("Polytopes")
package afield.geometry.polytope
import afield.math.linalg.Vec3
import kotlin.math.abs
import kotlin.math.sqrt
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.
fun interface RealizationConstruction
Construct coordinates for an abstract topology according to a declared policy.
This is the first of the library's three realization verbs. It differs from RealizationPolicy3, which realizes the output of one Conway transform from its source's coordinates: a RealizationConstruction sees only combinatorics and must invent an embedding from nothing.
fun interface RealizationConstruction {
fun construct(topology: PolytopeTopology): Realization3
Invent coordinates for topology. Must return one point per rank-0 element, in elements(0) order.
fun PolytopeTopology.realize(policy: RealizationConstruction): PolyhedralObject
Give an abstract topology coordinates. The result carries no attributes.
fun PolytopeTopology.realize(policy: RealizationConstruction): PolyhedralObject =
PolyhedralObject(this, policy.construct(this))
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.realize(policy: RealizationConstruction): PolyhedralObject =
copy(realization = policy.construct(topology))
data class SpectralRealization
Spectral (Laplacian eigenvector) embedding: the classic "draw the graph with its low modes" construction.
Coordinates are the three eigenvectors of the vertex graph Laplacian with the smallest non-zero
eigenvalues. For a vertex-transitive polyhedral graph those three modes span one degenerate
eigenspace and the embedding is the symmetric solid, up to an arbitrary rotation inside that
eigenspace and an overall scale. For a graph whose fourth-smallest eigenvalue is not degenerate
with the second and third — many Conway outputs — the embedding is a good, symmetry-respecting
starting point and not a finished solid; follow it with PolyhedralObject.canonicalize.
What this does not claim: that the result is planar-faced, convex, or free of
self-intersection. Check PolyhedralObject.geometryDiagnostics and
PolyhedralObject.canonicalDiagnostics rather than trusting the construction.
@param projectToSphere scale every vertex to unit length, which removes the wildly different eigenvector magnitudes that otherwise flatten a non-degenerate embedding.
data class SpectralRealization(
/**
* 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 projectToSphere: Boolean = true,
/**
* 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.
*/
val sweeps: Int = 64,
) : RealizationConstruction {
init
init {
require(sweeps > 0) { "spectral realization needs at least one Jacobi sweep" }
}
override fun construct(topology: PolytopeTopology): Realization3
body — 41 lines, from line 95
override fun construct(topology: PolytopeTopology): Realization3 {
val vertices = topology.elements(0)
require(vertices.size >= 4) { "spectral realization needs at least four vertices" }
val index = vertices.mapIndexed { at, vertex -> vertex.id.value to at }.toMap()
val n = vertices.size
val laplacian = Array(n) { DoubleArray(n) }
for (edge in topology.elements(1)) {
val ends = topology.incidentDown(edge.id)
require(ends.size == 2) { "edge ${edge.id.value} does not have exactly two endpoints" }
val a = index.getValue(ends[0])
val b = index.getValue(ends[1])
if (a == b) continue
laplacian[a][b] -= 1.0
laplacian[b][a] -= 1.0
laplacian[a][a] += 1.0
laplacian[b][b] += 1.0
}
val (values, vectors) = symmetricEigen(laplacian, sweeps)
// Skip the constant mode(s): a connected polyhedral graph has exactly one zero eigenvalue.
val tolerance = 1e-8 * (values.lastOrNull()?.let { abs(it) } ?: 1.0).coerceAtLeast(1.0)
var first = 0
while (first < n && values[first] <= tolerance) first++
require(first + 2 < n) { "vertex graph is disconnected or too small for a spectral embedding" }
val coordinates = (0 until n).map { vertex ->
Vec3(
vectors[vertex][first].toFloat(),
vectors[vertex][first + 1].toFloat(),
vectors[vertex][first + 2].toFloat(),
)
}
if (!projectToSphere) return Realization3(coordinates)
return Realization3(
coordinates.map { point ->
val length = point.length()
if (length <= 1e-9f) Vec3(0f, 0f, 1f) else Vec3(point).mul(1f / length)
},
)
}
}
Explicit removal of the rigid-motion and scale null spaces
enum class GaugeCentre
Which point of the embedding is moved to the origin.
VERTEX_CENTROID is cheap and unambiguous. EDGE_TANGENCY_CENTROID is the canonical form's own
centre and costs a short fixed-point iteration, because the tangency points are defined relative to
the origin and therefore move whenever the object does. NONE leaves the position alone.
enum class GaugeCentre {
NONE,
VERTEX_CENTROID,
The mean of every edge's nearest point to the origin — the canonical form's own centre.
EDGE_TANGENCY_CENTROID,
}
enum class GaugeScale
Which measured length is driven to the gauge's scale target.
MEAN_EDGE_LENGTH is the everyday choice and the default. MEAN_VERTEX_RADIUS and
MAX_VERTEX_RADIUS size the object by its circumsphere instead, which is what you want when
comparing two objects side by side. MEAN_EDGE_TANGENCY_RADIUS is the canonical form's own scale,
so it is the one that makes a canonicalized solid's diagnostics read as relative errors. NONE
leaves the size alone.
enum class GaugeScale {
NONE,
MEAN_EDGE_LENGTH,
MEAN_VERTEX_RADIUS,
MAX_VERTEX_RADIUS,
MEAN_EDGE_TANGENCY_RADIUS,
}
data class RigidGauge
The explicit normalization that makes a realization a representative rather than a member of a seven-dimensional family.
Every constraint family here is invariant under rigid motion and most are invariant under scale,
so a solver's answer is only defined up to those seven degrees of freedom. Leaving that to a weak
proximity term makes results depend on solver damping. Instead: translate a declared centre to
the origin, scale a declared measured length to scaleTarget, and — when alignToReference and
a reference embedding are given — apply the single rotation that best matches the reference
(Kabsch/Horn). The rotation gauge is anchored to a reference rather than to the
inertia tensor, whose principal axes are degenerate for exactly the symmetric solids this
library produces.
data class RigidGauge(
/**
* 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 centre: GaugeCentre = GaugeCentre.VERTEX_CENTROID,
/**
* 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 scale: GaugeScale = GaugeScale.MEAN_EDGE_LENGTH,
/**
* What that measured length becomes. Must be finite and positive.
*/
val scaleTarget: Double = 1.0,
/**
* 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.
*/
val alignToReference: Boolean = true,
) {
init
init {
require(scaleTarget.isFinite() && scaleTarget > 0.0) { "gauge scale target must be positive" }
}
companion object
companion object {
val CANONICAL
Centre and size the way the canonical form defines them.
val CANONICAL = RigidGauge(GaugeCentre.EDGE_TANGENCY_CENTROID, GaugeScale.MEAN_EDGE_TANGENCY_RADIUS)
val CENTRE_AND_SCALE = RigidGauge(alignToReference = false)
Translation and scale only; leave orientation exactly as the caller supplied it.
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.
body — 45 lines, from line 225
fun PolyhedralObject.normalize(gauge: RigidGauge, reference: Realization3? = null): PolyhedralObject {
realization ?: throw IllegalStateException("normalization requires a 3D realization")
val x = coordinateVector()
val problem = RealizationProblem.of(this)
when (gauge.centre) {
GaugeCentre.NONE -> Unit
GaugeCentre.VERTEX_CENTROID -> translate(x, centroidOf(x))
// Tangency points are defined relative to the origin, so they move when the object does:
// one subtraction does not put their centroid at the origin. Iterate to the fixed point.
GaugeCentre.EDGE_TANGENCY_CENTROID -> repeat(TANGENCY_CENTRING_STEPS) {
val centre = tangencyCentroid(problem, x)
translate(x, centre)
if (sqrt(centre[0] * centre[0] + centre[1] * centre[1] + centre[2] * centre[2]) <= 1e-14) return@repeat
}
}
val measured = when (gauge.scale) {
GaugeScale.NONE -> gauge.scaleTarget
GaugeScale.MEAN_EDGE_LENGTH -> meanEdgeLength(problem, x)
GaugeScale.MEAN_VERTEX_RADIUS -> meanVertexRadius(x)
GaugeScale.MAX_VERTEX_RADIUS -> maxVertexRadius(x)
GaugeScale.MEAN_EDGE_TANGENCY_RADIUS -> meanTangencyRadius(problem, x)
}
if (measured > 1e-12) {
val factor = gauge.scaleTarget / measured
for (i in x.indices) x[i] *= factor
}
if (gauge.alignToReference && reference != null) {
require(reference.size == problem.vertexCount) {
"alignment reference has ${reference.size} vertices for ${problem.vertexCount} coordinates"
}
val target = DoubleArray(x.size)
for (i in 0 until reference.size) {
target[3 * i] = reference.x(i).toDouble()
target[3 * i + 1] = reference.y(i).toDouble()
target[3 * i + 2] = reference.z(i).toDouble()
}
val targetCentre = centroidOf(target)
for (vertex in 0 until problem.vertexCount) for (axis in 0..2) target[3 * vertex + axis] -= targetCentre[axis]
applyRotation(x, optimalRotation(x, target))
}
return withCoordinateVector(x)
}
Iterations of the tangency-centroid fixed point; convergence is geometric and fast.
source — 42 lines, from line 272
private const val TANGENCY_CENTRING_STEPS = 256
private fun translate(x: DoubleArray, by: DoubleArray) {
for (vertex in 0 until x.size / 3) for (axis in 0..2) x[3 * vertex + axis] -= by[axis]
}
private fun tangencyCentroid(problem: RealizationProblem, x: DoubleArray): DoubleArray {
val sum = DoubleArray(3)
if (problem.edgeCount == 0) return sum
val point = DoubleArray(3)
for (edge in 0 until problem.edgeCount) {
edgeTangentPoint(x, problem.edgeA(edge), problem.edgeB(edge), point)
for (axis in 0..2) sum[axis] += point[axis]
}
for (axis in 0..2) sum[axis] /= problem.edgeCount
return sum
}
private fun meanEdgeLength(problem: RealizationProblem, x: DoubleArray): Double =
if (problem.edgeCount == 0) 0.0
else (0 until problem.edgeCount).sumOf { distanceBetween(x, problem.edgeA(it), problem.edgeB(it)) } / problem.edgeCount
private fun meanVertexRadius(x: DoubleArray): Double {
val count = x.size / 3
if (count == 0) return 0.0
val origin = DoubleArray(3)
return (0 until count).sumOf { distanceToPoint(x, it, origin) } / count
}
private fun maxVertexRadius(x: DoubleArray): Double {
val origin = DoubleArray(3)
return (0 until x.size / 3).maxOfOrNull { distanceToPoint(x, it, origin) } ?: 0.0
}
private fun meanTangencyRadius(problem: RealizationProblem, x: DoubleArray): Double {
if (problem.edgeCount == 0) return 0.0
val point = DoubleArray(3)
return (0 until problem.edgeCount).sumOf { edge ->
edgeTangentPoint(x, problem.edgeA(edge), problem.edgeB(edge), point)
sqrt(point[0] * point[0] + point[1] * point[1] + point[2] * point[2])
} / problem.edgeCount
}
Horn's quaternion solution of the orthogonal Procrustes problem: the rotation carrying from
onto to, both already centred. Reflections are excluded by construction, which is what a
gauge needs — a mirrored polyhedron is a different object, not a better fit.
private fun optimalRotation(from: DoubleArray, to: DoubleArray): Array<DoubleArray> {
val s = Array(3) { DoubleArray(3) }
for (vertex in 0 until from.size / 3) {
for (r in 0..2) for (c in 0..2) s[r][c] += from[3 * vertex + r] * to[3 * vertex + c]
}
val n = arrayOf(
doubleArrayOf(s[0][0] + s[1][1] + s[2][2], s[1][2] - s[2][1], s[2][0] - s[0][2], s[0][1] - s[1][0]),
doubleArrayOf(s[1][2] - s[2][1], s[0][0] - s[1][1] - s[2][2], s[0][1] + s[1][0], s[2][0] + s[0][2]),
doubleArrayOf(s[2][0] - s[0][2], s[0][1] + s[1][0], -s[0][0] + s[1][1] - s[2][2], s[1][2] + s[2][1]),
doubleArrayOf(s[0][1] - s[1][0], s[2][0] + s[0][2], s[1][2] + s[2][1], -s[0][0] - s[1][1] + s[2][2]),
)
val (values, vectors) = symmetricEigen(n, sweeps = 64)
val best = values.indices.maxBy { values[it] }
var q0 = vectors[0][best]
var q1 = vectors[1][best]
var q2 = vectors[2][best]
var q3 = vectors[3][best]
val norm = sqrt(q0 * q0 + q1 * q1 + q2 * q2 + q3 * q3)
if (norm <= 1e-12) return arrayOf(doubleArrayOf(1.0, 0.0, 0.0), doubleArrayOf(0.0, 1.0, 0.0), doubleArrayOf(0.0, 0.0, 1.0))
q0 /= norm
q1 /= norm
q2 /= norm
q3 /= norm
return arrayOf(
doubleArrayOf(q0 * q0 + q1 * q1 - q2 * q2 - q3 * q3, 2 * (q1 * q2 - q0 * q3), 2 * (q1 * q3 + q0 * q2)),
doubleArrayOf(2 * (q2 * q1 + q0 * q3), q0 * q0 - q1 * q1 + q2 * q2 - q3 * q3, 2 * (q2 * q3 - q0 * q1)),
doubleArrayOf(2 * (q3 * q1 - q0 * q2), 2 * (q3 * q2 + q0 * q1), q0 * q0 - q1 * q1 - q2 * q2 + q3 * q3),
)
}
private fun applyRotation(x: DoubleArray, rotation: Array<DoubleArray>) {
for (vertex in 0 until x.size / 3) {
val px = x[3 * vertex]
val py = x[3 * vertex + 1]
val pz = x[3 * vertex + 2]
for (axis in 0..2) {
x[3 * vertex + axis] = rotation[axis][0] * px + rotation[axis][1] * py + rotation[axis][2] * pz
}
}
}
internal fun symmetricEigen(matrix: Array<DoubleArray>, sweeps: Int = 64): Pair<DoubleArray, Array<DoubleArray>>
Cyclic Jacobi eigendecomposition of a small symmetric matrix. Eigenvalues ascending;
vectors[row](../../../../../../reference/source/jvmMain/afield/math/statistics/clustering.md#L394) is component row of eigenvector k.
Kept package-local rather than pulled from afield.media.image.imaging_geometry, whose
smallestEigenvector returns only one vector, and to keep this package's dependency surface to
Vec3, FLine and the least-squares solver.
internal fun symmetricEigen(matrix: Array<DoubleArray>, sweeps: Int = 64): Pair<DoubleArray, Array<DoubleArray>> {
val n = matrix.size
require(matrix.all { it.size == n }) { "eigendecomposition needs a square matrix" }
val a = Array(n) { matrix[it].copyOf() }
val v = Array(n) { r -> DoubleArray(n) { c -> if (r == c) 1.0 else 0.0 } }
repeat(sweeps) {
var off = 0.0
for (p in 0 until n) for (q in p + 1 until n) off += a[p][q] * a[p][q]
if (off < 1e-26) return@repeat
for (p in 0 until n) for (q in p + 1 until n) {
if (abs(a[p][q]) < 1e-18) continue
val theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q])
val t = (if (theta >= 0) 1.0 else -1.0) / (abs(theta) + sqrt(theta * theta + 1.0))
val c = 1.0 / sqrt(t * t + 1.0)
val s = t * c
for (k in 0 until n) {
val akp = a[k][p]
val akq = a[k][q]
a[k][p] = c * akp - s * akq
a[k][q] = s * akp + c * akq
}
for (k in 0 until n) {
val apk = a[p][k]
val aqk = a[q][k]
a[p][k] = c * apk - s * aqk
a[q][k] = s * apk + c * aqk
}
for (k in 0 until n) {
val vkp = v[k][p]
val vkq = v[k][q]
v[k][p] = c * vkp - s * vkq
v[k][q] = s * vkp + c * vkq
}
}
}
val order = (0 until n).sortedBy { a[it][it] }
val values = DoubleArray(n) { a[order[it]][order[it]] }
val vectors = Array(n) { row -> DoubleArray(n) { k -> v[row][order[k]] } }
return values to vectors
}