FieldAdapters.kt
src/jvmMain/kotlin/afield/geometry/polytope/FieldAdapters.kt · 535 lines
@file:afield.runtime.completion.CompletionCategory("Polytopes")
package afield.geometry.polytope
import afield.geometry.path.FLine
import afield.geometry.path.FloatEarcut
import afield.geometry.path.PlanarTessellation
import afield.geometry.mesh.TriMesh
import afield.geometry.mesh.TriMeshBuilder
import afield.math.linalg.Vec3
import kotlin.math.sqrt
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.
const val POLYTOPE_FACE_ID_PROPERTY = "polytopeFaceId"
The FLine property every adapter here stamps with the source element's raw id.
Read it back to go from a picked line to the element it came from. toEdgeFLines uses the same key
for an edge, not only a face, so pair it with POLYTOPE_FACE_RANK_PROPERTY before assuming a rank.
const val POLYTOPE_FACE_RANK_PROPERTY = "polytopeFaceRank"
The rank of the element a line came from: 2 for a face, 1 for a projected 4-polytope edge.
class DegenerateFaceGeometryException(val face: ElementId) : IllegalArgumentException
A face whose 3D realization has no usable normal — zero area, or coordinates that went non-finite.
Thrown by PolyhedralObject.faceNormal and therefore by triangulation. It almost always means an
upstream realization collapsed rather than that the topology is wrong, so check
geometryDiagnostics before suspecting the combinatorics.
class DegenerateFaceGeometryException(val face: ElementId) : IllegalArgumentException(
"face ${face.value} has zero area in its 3D realization"
)
fun PolyhedralObject.toFaceFLines
One closed, outward-wound FLine per polygonal face.
Separate lines rather than one multi-contour line, so that callers can style, select or fill faces
independently; Field's ordinary filled and stroked properties are set in configure. Each
line's properties already carry the face's own attributes plus POLYTOPE_FACE_ID_PROPERTY and
POLYTOPE_FACE_RANK_PROPERTY by the time configure runs, so configure can read them and can
also overwrite them.
A face's holes become further closed contours on that same line — one line is still one face, and
one hole is not a face. FLine.tessellate groups a line's contours into outer rings and holes
geometrically, so a filled holed face comes out with a hole in it and nothing extra to say.
Requires a realization and validates the topology first (without the manifold check), so a broken complex throws rather than drawing a subset.
fun PolyhedralObject.toFaceFLines(
configure: (face: Element, line: FLine) -> Unit = { _, _ -> },
): List<FLine> {
require(realization != null) { "FLine conversion requires a 3D realization" }
topology.validate().throwIfInvalid()
return topology.elements(2).map { face ->
val line = FLine()
topology.faceRings(face.id).forEach { ring ->
ring.forEachIndexed { i, vertexValue ->
val coordinate = realizationIndex(ElementId(vertexValue))
if (i == 0) line.moveTo(realization.x(coordinate), realization.y(coordinate), realization.z(coordinate))
else line.lineTo(realization.x(coordinate), realization.y(coordinate), realization.z(coordinate))
}
line.close()
}
line.also {
line.properties.putAll(attributes[face.id])
line.properties[POLYTOPE_FACE_ID_PROPERTY] = face.id.value
line.properties[POLYTOPE_FACE_RANK_PROPERTY] = face.rank
configure(face, line)
}
}
}
fun PolyhedralObject.faceNormal(face: ElementId): Vec3
Unit normal selected by the face's explicit winding — of its outer ring. A hole encloses no area of the face and is wound the other way, so including it would subtract from the outline's own Newell sum for no reason.
fun PolyhedralObject.faceNormal(face: ElementId): Vec3 {
require(realization != null) { "normal calculation requires a 3D realization" }
require(topology.element(face)?.rank == 2) { "element ${face.value} is not a rank-2 face" }
val cycle = topology.faceCycle(face)
var nx = 0.0
var ny = 0.0
var nz = 0.0
for (i in cycle.indices) {
val p = realizationIndex(ElementId(cycle[i]))
val q = realizationIndex(ElementId(cycle[(i + 1) % cycle.size]))
nx += (realization.y(p).toDouble() - realization.y(q)) * (realization.z(p).toDouble() + realization.z(q))
ny += (realization.z(p).toDouble() - realization.z(q)) * (realization.x(p).toDouble() + realization.x(q))
nz += (realization.x(p).toDouble() - realization.x(q)) * (realization.y(p).toDouble() + realization.y(q))
}
val length = sqrt(nx * nx + ny * ny + nz * nz)
if (!length.isFinite() || length <= 1e-12) throw DegenerateFaceGeometryException(face)
return Vec3((nx / length).toFloat(), (ny / length).toFloat(), (nz / length).toFloat())
}
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.
fun triangulate(faceVertices: IntArray, realization: Realization3): IntArray
object FanFaceTriangulator : FaceTriangulator
Deterministic convex-polygon fan. It is not a general polygon triangulator.
override fun triangulate(faceVertices: IntArray, realization: Realization3): IntArray
override fun triangulate(faceVertices: IntArray, realization: Realization3): IntArray {
require(faceVertices.size >= 3) { "fan triangulation needs at least three face vertices" }
return IntArray((faceVertices.size - 2) * 3).also { triangles ->
var out = 0
for (i in 1 until faceVertices.size - 1) {
triangles[out++] = faceVertices[0]
triangles[out++] = faceVertices[i]
triangles[out++] = faceVertices[i + 1]
}
}
}
}
interface HoleAwareFaceTriangulator : FaceTriangulator
A FaceTriangulator that can subtract hole rings from the face it fills.
This is the interface PolyhedralObject.triangulate reaches for as soon as a face has holes, and
the reason a face's inner rings are not a private arrangement between a caller and Earcut. It is
also still usable directly, for face-with-hole geometry assembled outside a PolytopeTopology.
A plain FaceTriangulator handed a holed face is an error rather than a fallback — filling a window in is a wrong picture, not a degraded one.
interface HoleAwareFaceTriangulator : FaceTriangulator {
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
override fun triangulate(faceVertices: IntArray, realization: Realization3): IntArray =
triangulate(faceVertices, emptyList(), realization)
}
object EarcutFaceTriangulator : HoleAwareFaceTriangulator
Field's Earcut tessellator over an arbitrarily oriented 3D polygon, with holes.
This is a thin adapter over afield.geometry.path.PlanarTessellation, which is also what
FLine.tessellate runs on. It used to be a second, independent implementation of the same idea,
and the two disagreed about the handedness of the projected frame: this one projected onto the
mirror of the right-handed pair for a y-dominant face and then flipped each returned triangle back
against the face's 3D normal.
That is a correct composition — a mirror flips the facing of every triangle and nothing else, so
flipping them all back arrives at the same place — and it stayed correct with holes, which the
earlier version of this note denied. FloatEarcut decides which ring is the outer one from array
position, not from any area sign (FloatEarcut.kt:19, :21, :484-492), so a mirror cannot
invert the hole-versus-outer decision; FloatEarcutOuterRingTest measures that. The reason for
having one implementation is therefore the ordinary one: two conventions for the same question, one
of which needs every caller to remember a compensating flip, and a hole ring is one more thing for a
caller to get wrong. See dev_docs/fline_fill_orientation.md.
override fun triangulate(outer: IntArray, holes: List<IntArray>, realization: Realization3): IntArray
override fun triangulate(outer: IntArray, holes: List<IntArray>, realization: Realization3): IntArray {
require(outer.size >= 3) { "Earcut triangulation needs at least three face vertices" }
holes.forEachIndexed { at, hole ->
require(hole.size >= 3) { "hole ring $at needs at least three vertices, had ${hole.size}" }
}
val cycles = ArrayList<IntArray>(1 + holes.size)
cycles.add(outer)
cycles.addAll(holes)
val rings = cycles.map { cycle ->
cycle.map { Vec3(realization.x(it), realization.y(it), realization.z(it)) }
}
// Kept in double precision, and only as a precondition: a zero-area face is a caller error
// worth a message, not something to tessellate to nothing quietly.
var nx = 0.0
var ny = 0.0
var nz = 0.0
for (i in outer.indices) {
val p = outer[i]
val q = outer[(i + 1) % outer.size]
nx += (realization.y(p) - realization.y(q)) * (realization.z(p) + realization.z(q))
ny += (realization.z(p) - realization.z(q)) * (realization.x(p) + realization.x(q))
nz += (realization.x(p) - realization.x(q)) * (realization.y(p) + realization.y(q))
}
val normalLengthSquared = nx * nx + ny * ny + nz * nz
require(normalLengthSquared.isFinite() && normalLengthSquared > 1e-20) {
"cannot triangulate a face with zero projected area"
}
val triangles = PlanarTessellation.tessellate(rings).flatMap { it.triangles }
require(triangles.size % 3 == 0 && triangles.isNotEmpty()) {
"Earcut did not produce complete triangles for a ${outer.size}-vertex face"
}
return IntArray(triangles.size) { at ->
val vertex = triangles[at]
cycles[vertex.ring][vertex.vertex]
}
}
}
class TriangulatedGeometry internal constructor
Field-native triangle geometry plus provenance lost by an ordinary triangle mesh.
mesh duplicates polygon corners between source faces. That is intentional: Field's TriMesh
stores normals per vertex, and a creased polyhedron needs a different flat normal on each side
of a shared abstract vertex. vertexElementIds maps those render vertices back to topology
vertices; triangleFaceIds maps each triangle back to its polygonal source face.
class TriangulatedGeometry internal constructor(
val mesh: TriMesh,
triangleFaceIds: IntArray,
vertexElementIds: IntArray,
triangleAttributes: List<Map<String, Any?>>,
vertexAttributes: List<Map<String, Any?>>,
) {
private val storedTriangleFaceIds = triangleFaceIds.copyOf()
private val storedVertexElementIds = vertexElementIds.copyOf()
private val storedTriangleAttributes = triangleAttributes.map { it.toMap() }
private val storedVertexAttributes = vertexAttributes.map { it.toMap() }
val triangleCount: Int get() = mesh.triangleCount
Triangles in mesh; the length of triangleFaceIds and of triangleAttributes.
val triangleFaceIds: IntArray get() = storedTriangleFaceIds.copyOf()
val vertexElementIds: IntArray get() = storedVertexElementIds.copyOf()
val triangleAttributes: List<Map<String, Any?>> get()
val triangleAttributes: List<Map<String, Any?>> get() = storedTriangleAttributes.map { it.toMap() }
val vertexAttributes: List<Map<String, Any?>> get() = storedVertexAttributes.map { it.toMap() }
fun sourceFace(triangle: Int): ElementId = ElementId(storedTriangleFaceIds[triangle])
The polygonal face a triangle was cut from. The inverse of triangulation, one triangle at a time.
fun sourceVertex(renderVertex: Int): ElementId = ElementId(storedVertexElementIds[renderVertex])
The topology vertex a render vertex duplicates. Several render vertices share one of these whenever a polyhedron is creased at that corner.
sealed interface NormalPolicy
How a render vertex gets its normal — and therefore whether an edge of the solid reads as a crease or as curvature.
The corners are duplicated per face either way (TriangulatedGeometry says why), so this changes
only what is written into those duplicates, never the buffer layout, the triangle count or the
provenance arrays. POLYTOPE_ALGEBRA.md item 4 asked for exactly that: smooth and crease-aware
normals while preserving abstract vertex provenance.
sealed interface NormalPolicy {
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.
This is the per-corner smoothing-group approximation rather than a walk of the vertex fan: each corner asks which of its vertex's faces it is nearly parallel to, and averages those. It is order-independent and needs no manifold fan, which matters here because a hole rim has no fan at all — a hole's edges bound one face, so a rim corner averages over that one face and keeps the flat normal, which is the right answer and comes out of the same rule rather than a special case.
The average is unweighted. Area or angle weighting is a better approximation of curvature on an irregular mesh and is not what this is for; say so rather than implying otherwise.
init
init {
require(creaseDegrees.isFinite() && creaseDegrees >= 0.0 && creaseDegrees <= 180.0) {
"crease angle must be a finite 0..180 degrees, got $creaseDegrees"
}
}
internal val cosineThreshold: Double = kotlin.math.cos(Math.toRadians(creaseDegrees))
Every face normal counts as parallel to itself, so a threshold of 0 still keeps the face's own.
companion object
companion object {
val Smooth: NormalPolicy = Crease(180.0)
Fully smooth: every face at a vertex contributes, whatever the dihedral.
fun PolyhedralObject.triangulate
Triangulate every ordered face and produce a TriMesh with provenance.
A face with inner rings goes to HoleAwareFaceTriangulator.triangulate with its holes; a face
without takes the single-cycle call it always took, and its output is identical vertex for vertex
and index for index. Passing a triangulator that is not hole-aware is fine right up until a face
that has holes, which throws and names both the face and the triangulator.
normals decides what goes on each duplicated corner and nothing else — with the default
NormalPolicy.Flat the result is byte-identical to what this function returned before the policy
existed, which PolytopeVertexNormalsTest measures rather than assumes.
body — 117 lines, from line 354
fun PolyhedralObject.triangulate(
triangulator: FaceTriangulator = EarcutFaceTriangulator,
normals: NormalPolicy = NormalPolicy.Flat,
): TriangulatedGeometry {
val realization = realization ?: throw IllegalStateException("triangulation requires a 3D realization")
topology.validate().throwIfInvalid()
val mesh = TriMeshBuilder()
val triangleFaces = mutableListOf<Int>()
val renderVertices = mutableListOf<Int>()
val triangleAttributes = mutableListOf<Map<String, Any?>>()
val vertexAttributes = mutableListOf<Map<String, Any?>>()
val realizationIndex = topology.elements(0).mapIndexed { index, vertex -> vertex.id.value to index }.toMap()
// Face normals once, up front, because a smoothed corner needs its neighbours' before it can be
// written. Flat asks for none of this and pays for none of it.
val faceNormals: Map<ElementId, Vec3> = when (normals) {
NormalPolicy.Flat -> emptyMap()
is NormalPolicy.Crease -> topology.elements(2).associate { it.id to faceNormal(it.id) }
}
val facesAtVertex: Map<ElementId, List<ElementId>> = when (normals) {
NormalPolicy.Flat -> emptyMap()
is NormalPolicy.Crease -> topology.elements(0).associate { vertex ->
vertex.id to topology.incidentUp(vertex.id)
.flatMap { edge -> topology.incidentUp(ElementId(edge)).toList() }
.distinct()
.map(::ElementId)
.filter { topology.element(it)?.rank == 2 }
}
}
fun normalFor(face: ElementId, faceNormal: Vec3, vertex: ElementId): Vec3 = when (normals) {
NormalPolicy.Flat -> faceNormal
is NormalPolicy.Crease -> {
val sum = Vec3(faceNormal)
var blended = 0
for (other in facesAtVertex[vertex].orEmpty()) {
if (other == face) continue
val candidate = faceNormals[other] ?: continue
if (candidate.dot(faceNormal) >= normals.cosineThreshold) {
sum.add(candidate)
blended++
}
}
when {
// Nothing to smooth against — a crease all round, or a hole rim, or a zero threshold —
// keeps the face normal *exactly*, rather than a renormalized copy of it that differs
// in the last bit. That is what makes `Crease(0)` byte-identical to `Flat`.
blended == 0 -> faceNormal
// A fan that cancels itself out — two faces exactly back to back — has no average
// worth having, so keep the corner's own flat normal rather than a zero vector.
sum.lengthSquared() <= 1e-12f -> faceNormal
else -> sum.normalize()
}
}
}
for (face in topology.elements(2)) {
// One entry for an ordinary face, so the loop below is the one that was always here; the
// outer ring is always rings[0], which is what makes the hole-free output byte-identical.
val elementRings = topology.faceRings(face.id)
val normal = faceNormals[face.id] ?: faceNormal(face.id)
val localRenderIndex = mutableMapOf<Int, Int>()
val coordinateRings = elementRings.map { elementCycle ->
val cycle = elementCycle.map { realizationIndex.getValue(it) }.toIntArray()
for (i in cycle.indices) {
val coordinateIndex = cycle[i]
val elementId = elementCycle[i]
localRenderIndex[coordinateIndex] = mesh.vertex(
realization.x(coordinateIndex),
realization.y(coordinateIndex),
realization.z(coordinateIndex),
normal = normalFor(face.id, normal, ElementId(elementId)),
)
renderVertices += elementId
vertexAttributes += attributes[ElementId(elementId)]
}
cycle
}
val cycle = coordinateRings.first()
val indices = if (coordinateRings.size == 1) {
triangulator.triangulate(cycle.copyOf(), realization)
} else {
val holeAware = triangulator as? HoleAwareFaceTriangulator
?: throw IllegalArgumentException(
"face ${face.id.value} has ${coordinateRings.size - 1} hole(s) and " +
"${triangulator.javaClass.simpleName} is not a HoleAwareFaceTriangulator",
)
holeAware.triangulate(
cycle.copyOf(),
coordinateRings.drop(1).map { it.copyOf() },
realization,
)
}
require(indices.size % 3 == 0) {
"triangulator returned ${indices.size} indices for face ${face.id.value}; expected complete triples"
}
for (i in indices.indices step 3) {
val a = localRenderIndex[indices[i]]
?: throw IllegalArgumentException("triangulator referenced vertex ${indices[i]} outside face ${face.id.value}")
val b = localRenderIndex[indices[i + 1]]
?: throw IllegalArgumentException("triangulator referenced vertex ${indices[i + 1]} outside face ${face.id.value}")
val c = localRenderIndex[indices[i + 2]]
?: throw IllegalArgumentException("triangulator referenced vertex ${indices[i + 2]} outside face ${face.id.value}")
mesh.triangle(a, b, c)
triangleFaces += face.id.value
triangleAttributes += attributes[face.id]
}
}
return TriangulatedGeometry(
mesh.build(),
triangleFaces.toIntArray(),
renderVertices.toIntArray(),
triangleAttributes,
vertexAttributes,
)
}
fun PolyhedralObject.toTriMesh
Convenience when only Field's renderable mesh is needed and provenance can be discarded.
fun PolyhedralObject.toTriMesh(
triangulator: FaceTriangulator = EarcutFaceTriangulator,
normals: NormalPolicy = NormalPolicy.Flat,
): TriMesh = triangulate(triangulator, normals).mesh
const val POLYTOPE_CELL_ID_PROPERTY = "polytopeCellId"
The FLine property Polytope4.toCellEdgeFLines stamps with the cell it was asked about.
fun Polytope4.toEdgeFLines
One open FLine per rank-1 edge of a Polytope4, under a viewing projection.
Edges rather than filled polygons is the right default for a 4-polytope: its polygons all live on the boundary of cells, several cells meet at every polygon, and a projection puts them on top of one another. A wireframe says what is there; a filled projection would imply a surface that does not exist.
fun Polytope4.toEdgeFLines(
projection: Projection4,
configure: (edge: Element, line: FLine) -> Unit = { _, _ -> },
): List<FLine> {
require(realization != null) { "FLine conversion requires a 4D realization" }
topology.validate().throwIfInvalid()
val projected = project(projection)
val index = topology.elements(0).mapIndexed { at, vertex -> vertex.id.value to at }.toMap()
return topology.elements(1).map { edge ->
val endpoints = topology.incidentDown(edge.id)
require(endpoints.size == 2) { "edge ${edge.id.value} does not have exactly two endpoints" }
val a = projected[index.getValue(endpoints[0])]
val b = projected[index.getValue(endpoints[1])]
FLine().moveTo(a.x, a.y, a.z).lineTo(b.x, b.y, b.z).also { line ->
line.properties[POLYTOPE_FACE_ID_PROPERTY] = edge.id.value
line.properties[POLYTOPE_FACE_RANK_PROPERTY] = edge.rank
configure(edge, line)
}
}
}
fun Polytope4.toCellEdgeFLines
The edges of one cell of a Polytope4, projected — the way to make one cell readable inside the projected whole. cell must be a rank-3 element.
This projects the whole polytope and then keeps the cell's edges, so it costs a full projection
per call and configure is invoked for every edge in the object, including the ones that are then
discarded. Filter inside configure on POLYTOPE_FACE_ID_PROPERTY if that matters, or call
Polytope4.toEdgeFLines once and partition the result yourself when highlighting several cells.
fun Polytope4.toCellEdgeFLines(
cell: ElementId,
projection: Projection4,
configure: (edge: Element, line: FLine) -> Unit = { _, _ -> },
): List<FLine> {
require(topology.element(cell)?.rank == 3) { "element ${cell.value} is not a rank-3 cell" }
val cellEdges = topology.incidentDown(cell)
.flatMap { polygon -> topology.incidentDown(ElementId(polygon)).toList() }
.toSet()
return toEdgeFLines(projection) { edge, line ->
line.properties[POLYTOPE_CELL_ID_PROPERTY] = cell.value
configure(edge, line)
}.filterIndexed { at, _ -> topology.elements(1)[at].id.value in cellEdges }
}