Summaries.kt
src/jvmMain/kotlin/afield/geometry/polytope/Summaries.kt · 292 lines
@file:afield.runtime.completion.CompletionCategory("Polytopes")
package afield.geometry.polytope
import afield.math.linalg.Vec3
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. PolytopeTopologySummary reports how many faces have each degree but not which; SymmetryDeviation reports the worst spread but not the class it came from. Go back to the underlying value for that.
- A
nullalmost always means "not measurable" rather than "no".PolytopeTopologySummary.closedManifoldis null when the check was not run, and Realization3Summary's bounds are null for an empty realization. - Names are used as keys, so same-named things collapse.
RelaxationPlanSummary.weightsis keyed by constraint family name and PolytopeTransformSummary lists operator display names, so two edge-length families with different targets, or two distinct operators printing the same name, are indistinguishable in the summary.
data class PolytopeTopologySummary
Compact, stable inspection value for a rank-aware incidence topology.
data class PolytopeTopologySummary(
val rank: Int,
/** Element count per rank, `0..rank` inclusive — so the whole-polytope element is counted here. */
val elementCounts: 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 faceDegreeCounts: Map<Int, Int>,
/** Alternating sum over the *proper* ranks; the whole-polytope rank is excluded. */
val eulerCharacteristic: 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 faceHoleCount: 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 cellularEulerCharacteristic: Int,
/** Basic incidence and boundary validation only. */
val valid: Boolean,
/** Null when the stricter every-edge-has-two-faces check was not run, not when it failed. */
val closedManifold: Boolean?,
)
data class Realization3Summary
Bounds and centre of an immutable ordinary 3D realization.
data class Realization3Summary(
val vertexCount: Int,
/** Null exactly when there are no vertices; otherwise a fresh [Vec3] the caller owns. */
val boundsMin: Vec3?,
val boundsMax: Vec3?,
/** The mean vertex position — the vertex centroid, which is not the centre of the bounding box. */
val centroid: Vec3?,
)
data class PolyhedralObjectSummary
The parts of a complete polyhedral object that are most useful at a live-coding prompt.
data class PolyhedralObjectSummary(
val topology: PolytopeTopologySummary,
/** Null when the object is still abstract — a real state, not a failure. */
val realization: Realization3Summary?,
/** How many elements carry any attribute at all, a name set to null included. */
val attributedElements: Int,
/** Every attribute name in use anywhere on the object, sorted; values are not summarised. */
val attributeKeys: Set<String>,
)
data class PolytopeValidationSummary
Structured validation output without exposing the validator's immutable wrapper internals.
data class PolytopeValidationSummary(
val valid: Boolean,
val issueCount: Int,
/** Issue class simple names to counts, sorted — the shape of the failure, not its location. */
val issueTypes: Map<String, Int>,
/** One message per issue, in the order the validator found them; these do name elements. */
val messages: List<String>,
)
data class ScalarParameterSummary
Min/max inspection of one scalar parameter resolved over a transform deployment.
data class ScalarParameterSummary(
val name: String,
/** How many elements resolved a value for [name], which need not be every selected element. */
val count: Int,
val min: Double,
val max: Double,
)
data class TransformDeploymentSummary
Compact replay information for one primitive Conway deployment.
data class TransformDeploymentSummary(
val operation: String,
/** Null means the operator's whole natural domain; a number means an explicit selection. */
val selectedCount: Int?,
/** Selected elements per rank — the evidence that a selector hit the rank you expected. */
val selectedRanks: Map<Int, Int>,
val scalarParameters: List<ScalarParameterSummary>,
)
data class PolytopeTransformSummary
Provenance-aware summary of a complete transform chain and its output.
data class PolytopeTransformSummary(
/** What the caller asked for, named composites retained: `bevel`, not its primitive expansion. */
val requestedOperations: List<String>,
/** The same chain fully expanded into primitives; this is what actually ran. */
val primitiveOperations: List<String>,
val output: PolyhedralObjectSummary,
/** One per primitive step, so this aligns with [primitiveOperations] and not with the requests. */
val deployments: List<TransformDeploymentSummary>,
)
data class RelaxationPlanSummary
What a realization run was actually asked to do: named families, weights, and normalization.
data class RelaxationPlanSummary(
/** Family names in plan order, including any weighted zero — a plan is a declaration, not a run. */
val constraints: List<String>,
/** Keyed by family name, so two families sharing a name collapse to one entry. */
val weights: Map<String, Double>,
val maxIterations: Int,
/** The gauge as `centre / scale -> target`, or `none` when the answer is left un-normalized. */
val gauge: String,
)
data class TriangulatedGeometrySummary
Rendering-derived counts while retaining the polygon-to-triangle provenance distinction.
data class TriangulatedGeometrySummary(
/** Mesh vertices, which exceed the abstract vertex count because corners are duplicated per face. */
val renderVertices: Int,
val triangles: Int,
/** Distinct polygon faces that produced triangles. */
val sourcePolygonFaces: Int,
/** Distinct abstract vertices behind those render vertices — the deduplicated figure. */
val sourceVertexElements: Int,
val hasFlatNormals: Boolean,
)
fun PolytopeTopology.summary(): PolytopeTopologySummary
Counts, face-degree histogram, Euler characteristic and validity in one value.
Runs the validator to fill PolytopeTopologySummary.valid, and a second stricter pass for
PolytopeTopologySummary.closedManifold when the topology is rank 3 or above and already valid.
That is two full validations, so this is inspection rather than something to call per frame.
fun PolytopeTopology.summary(): PolytopeTopologySummary {
val basic = validate()
return PolytopeTopologySummary(
rank = rank,
elementCounts = (0..rank).associateWith { elements(it).size },
faceDegreeCounts = elements(2)
.mapNotNull { faceCycleOrNull(it.id)?.size }
.groupingBy { it }
.eachCount()
.toSortedMap(),
eulerCharacteristic = eulerCharacteristic(),
faceHoleCount = totalFaceHoleCount(),
cellularEulerCharacteristic = cellularEulerCharacteristic(),
valid = basic.isValid,
closedManifold = if (rank >= 3 && basic.isValid) validate(checkManifold = true).isValid else null,
)
}
fun Realization3.summary(): Realization3Summary
Axis-aligned bounds and the vertex centroid, in one pass. All three are null for no vertices.
fun Realization3.summary(): Realization3Summary {
val coordinates = vertexCoordinates
if (coordinates.isEmpty()) return Realization3Summary(0, null, null, null)
val min = Vec3(coordinates.first())
val max = Vec3(coordinates.first())
val center = Vec3()
for (coordinate in coordinates) {
min.x = kotlin.math.min(min.x, coordinate.x)
min.y = kotlin.math.min(min.y, coordinate.y)
min.z = kotlin.math.min(min.z, coordinate.z)
max.x = kotlin.math.max(max.x, coordinate.x)
max.y = kotlin.math.max(max.y, coordinate.y)
max.z = kotlin.math.max(max.z, coordinate.z)
center.add(coordinate)
}
center.mul(1f / coordinates.size)
return Realization3Summary(coordinates.size, min, max, center)
}
fun PolyhedralObject.summary(): PolyhedralObjectSummary
Topology, geometry and attribute overview of a whole object — the value Field prints for a bare
polyhedron.
Inherits PolytopeTopology.summary's validation cost. Attributes are summarised by name only:
the keys tell you a field has been painted, never what is in it.
fun PolyhedralObject.summary(): PolyhedralObjectSummary {
val attributed = attributes.elementIds
return PolyhedralObjectSummary(
topology = topology.summary(),
realization = realization?.summary(),
attributedElements = attributed.size,
attributeKeys = attributed.flatMapTo(sortedSetOf()) { attributes[it].keys },
)
}
fun ValidationResult.summary(): PolytopeValidationSummary
Issue counts grouped by kind, plus every message.
Reformats an already-computed result; it does not re-validate, so it costs nothing and cannot disagree with the ValidationResult it came from.
fun ValidationResult.summary(): PolytopeValidationSummary = PolytopeValidationSummary(
valid = isValid,
issueCount = issues.size,
issueTypes = issues.groupingBy { it::class.simpleName ?: "ValidationIssue" }.eachCount().toSortedMap(),
messages = issues.map { it.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.
The min/max pair is the cheap check that a parameter field did something — a non-uniform kis
whose heightScale reports an identical min and max was uniform after all.
fun TransformDeployment.summary(): TransformDeploymentSummary {
val selected = selectedSources
val resolved = scalarParameters
val parameterNames = resolved.values.flatMapTo(sortedSetOf()) { it.keys }
return TransformDeploymentSummary(
operation = operation.displayName,
selectedCount = selected?.size,
selectedRanks = selected.orEmpty().groupingBy { it.rank }.eachCount().toSortedMap(),
scalarParameters = parameterNames.map { name ->
val values = resolved.values.mapNotNull { it[name] }
ScalarParameterSummary(name, values.size, values.minOrNull()!!, values.maxOrNull()!!)
},
)
}
fun PolytopeTransformResult.summary(): PolytopeTransformSummary
The whole chain: what was asked for, what primitives ran, what came out, and how each step was deployed.
The two operation lists differ whenever a named composite was used, and that difference is the
point — bevel appears once in the requests and as its expansion in the primitives.
fun PolytopeTransformResult.summary(): PolytopeTransformSummary = PolytopeTransformSummary(
requestedOperations = notationHistory.map { it.displayName },
primitiveOperations = history.map { it.displayName },
output = polyhedron.summary(),
deployments = deployments.map { it.summary() },
)
fun RelaxationPlan.summary(): RelaxationPlanSummary
The plan as declared — families, weights, iteration cap, gauge — and nothing about a solve.
This is what makes a realization run diffable before it is run: print two plans and the difference is the experiment. Names are the keys, so a plan carrying two families of the same name reports only one weight.
fun RelaxationPlan.summary(): RelaxationPlanSummary = RelaxationPlanSummary(
constraints = constraints.map { it.name },
weights = constraints.associate { it.name to it.weight },
maxIterations = leastSquares.maxIterations,
gauge = gauge?.let { "${it.centre} / ${it.scale} -> ${it.scaleTarget}" } ?: "none",
)
fun TriangulatedGeometry.summary(): TriangulatedGeometrySummary
Render-side counts beside the abstract ones they came from.
Reading TriangulatedGeometrySummary.renderVertices against
TriangulatedGeometrySummary.sourceVertexElements is the whole reason this summary exists: flat
shading requires a corner per face, so the mesh has many more vertices than the topology, and the
provenance maps are what tie them back.
fun TriangulatedGeometry.summary(): TriangulatedGeometrySummary {
val faceIds = triangleFaceIds
val vertexIds = vertexElementIds
return TriangulatedGeometrySummary(
renderVertices = mesh.vertexCount,
triangles = triangleCount,
sourcePolygonFaces = faceIds.toSet().size,
sourceVertexElements = vertexIds.toSet().size,
hasFlatNormals = mesh.normals != null,
)
}