Skip to content

SolidAgreement.kt

src/jvmMain/kotlin/afield/lang/solidsl/SolidAgreement.kt · 262 lines

@file:afield.runtime.completion.CompletionCategory("Graphics")

package afield.lang.solidsl

import afield.geometry.solid.manifold.Solid
import afield.geometry.mesh.TriMesh
import afield.geometry.mesh.genus
import afield.geometry.mesh.isWatertight
import afield.geometry.mesh.marchingCubes
import afield.geometry.mesh.maxDeviation
import afield.geometry.mesh.signedVolume
import afield.geometry.mesh.surfaceArea
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.sin

The measurement this package exists for: one written shape, evaluated two completely independent ways, and the disagreement reported as a number.

val bracket = cuboid(4, 3, 1) - cylinder(2, 0.5).at(1, 0, 0)
println(bracket.crossCheck(SolidQuality(edgeLength = 0.02, segments = 256)).report())

Two implementations that share no code

Almost nothing in a graphics codebase gets to do this. The usual options are to assert against a remembered constant (which pins the current behaviour, not the correct one) or against a closed form (which only exists for a handful of shapes). Here both sides are implementations, and they share no code whatsoever:

side A side B
the shape becomes a distance field over Doubles a CSG tree over Manifold handles
the surface comes from afield.geometry.mesh.marchingCubes, in Kotlin Manifold's exact boolean kernel, in C++
the volume comes from a sum of signed tetrahedra over Float triangles Manifold's own integration, in double
the genus comes from edge/vertex adjacency in mesh_topology.kt Manifold's Euler characteristic

There is no path by which a bug in one produces the same wrong answer in the other. Five-figure agreement is therefore strong evidence that both are right, in a way that any number of assertions against constants would not be.

The same argument covers a third reading, the differentiable one in SolidGradient.kt, which is toScalarField's arithmetic replaced by afield.math.autodiff.AD and shares no line of code with it. It is measured by SolidExpr.gradientAgreement rather than folded in here, and the reason is that the two compare different objects: this file compares surfaces — volume, area, genus, Hausdorff deviation — and the differentiable reading produces a field, exactly as toScalarField does. Folding it in would have meant inventing a volume for something that has none. What the two share is the argument, so each cross-references the other.

Agreement does not converge to zero

There are two regimes:

  • Polyhedral shapes — cuboids, polygon-profile extrusions, and booleans of those. The exact lowering is the truth, so the marched one converges to it and the residual goes to zero as SolidQuality.edgeLength shrinks. Expect the relative volume error to fall roughly as h².
  • Curved shapes — anything with a sphere, a cylinder, a circular profile or a round offset. Manifold's circle is an inscribed n-gon, whose area falls short of the true one by 1 − (n/2π)·sin(2π/n) ≈ 2π²/3n². The field lowering describes the true curve. So agreement converges to that deficit and not to zero, however fine the march.

facetingDeficit computes the second one from the segment count alone, so a curved shape's residual can be checked against a predicted floor rather than a remembered one. That is the difference between "these two numbers are close" and "these two numbers are as close as the geometry permits, and here is the formula".

enum class AgreementKind

Which two things were compared — see the note on SolidAgreement about the empty bucket.

enum class AgreementKind {

Manifold's exact CSG against Field's marching cubes. The strong comparison, and the only one that belongs in an exact-agreement statistic.

    EXACT_VS_MARCHED,

Manifold's level set against Field's marching cubes — what a shape that refused the exact path gets.

Still two independent marchers on one field, so it catches a broken lowering; but both sides are approximations of the same field, so agreement here says nothing about whether that field is the shape the notation meant. Counted separately for exactly that reason: a report that folded these into an "N of N agree" would be counting the refusal path as a pass.

    MARCHED_VS_MARCHED,
}

data class SolidAgreement

What the two lowerings of one SolidExpr said, and how far apart they were.

data class SolidAgreement(
    val expr: SolidExpr,
    val kind: AgreementKind,
    val edgeLength: Double,
    val segments: Int,
    val refusals: List<String>,

    /** Manifold's own integration of the exact (or level-set) solid. */
    val solidVolume: Double,

    /** `afield.geometry.mesh.signedVolume` over the marched triangles — a sum of signed tetrahedra. */
    val marchedVolume: Double,

    val solidArea: Double,
    val marchedArea: Double,

    val solidGenus: Int,

    /** `null` when the marched mesh came out open, which is itself a failure worth seeing. */
    val marchedGenus: Int?,

    val marchedIsWatertight: Boolean,

    val solidTriangles: Long,
    val marchedTriangles: Int,

    /** Symmetric vertex-sampled Hausdorff distance between the two surfaces, in model units. */
    val deviation: Double,

    /** The longest axis of the shape's bound — what [deviation] is relative to. */
    val scale: Double,
) {

val relativeVolumeError: Double

|Va − Vb| / max(|Va|, |Vb|).

    val relativeVolumeError: Double
        get() = relative(solidVolume, marchedVolume)

val relativeAreaError: Double

|Aa − Ab| / max(|Aa|, |Ab|).

    val relativeAreaError: Double
        get() = relative(solidArea, marchedArea)

val relativeDeviation: Double

deviation as a fraction of the shape's longest axis, so it compares across sizes.

    val relativeDeviation: Double
        get() = if (scale <= 0.0) 0.0 else deviation / scale

val genusAgrees: Boolean

true when both sides closed and agreed on how many holes there are.

    val genusAgrees: Boolean
        get() = marchedGenus != null && marchedGenus == solidGenus

fun line(): String

One line, for a sweep printed as a table.

    fun line(): String = "%-11s h=%-8.4g segs=%-5d volume %.6f vs %.6f (%.3e)  area %.3e  genus %s  dev %.3e"
        .format(
            if (kind == AgreementKind.EXACT_VS_MARCHED) "exact" else "levelset",
            edgeLength, segments,
            solidVolume, marchedVolume, relativeVolumeError,
            relativeAreaError,
            if (genusAgrees) "$solidGenus=" else "$solidGenus/$marchedGenus!",
            relativeDeviation,
        )

fun report(): String

The whole story, for a println in a box.

    fun report(): String = buildString {
        appendLine(expr.toString())
        appendLine(
            when (kind) {
                AgreementKind.EXACT_VS_MARCHED -> "  exact CSG vs marching cubes"
                AgreementKind.MARCHED_VS_MARCHED -> "  Manifold level set vs marching cubes (the exact path refused)"
            },
        )
        refusals.forEach { appendLine("  refused: $it") }
        appendLine("  volume   ${"%.8g".format(solidVolume)} vs ${"%.8g".format(marchedVolume)}   rel ${"%.4e".format(relativeVolumeError)}")
        appendLine("  area     ${"%.8g".format(solidArea)} vs ${"%.8g".format(marchedArea)}   rel ${"%.4e".format(relativeAreaError)}")
        appendLine("  genus    $solidGenus vs ${marchedGenus ?: "open"}   ${if (genusAgrees) "agree" else "DISAGREE"}")
        appendLine("  surfaces ${solidTriangles} vs $marchedTriangles triangles, deviation ${"%.4e".format(deviation)} (${"%.3e".format(relativeDeviation)} of ${"%.4g".format(scale)})")
    }

    private fun relative(a: Double, b: Double): Double {
        val s = max(abs(a), abs(b))
        return if (s == 0.0) 0.0 else abs(a - b) / s
    }
}

fun SolidExpr.crossCheck(quality: SolidQuality = SolidQuality()): SolidAgreement

Build this shape both ways and measure the disagreement.

The marched side uses afield.geometry.mesh.marchingCubes at a resolution derived from SolidQuality.edgeLength, over the shape's own symbolic SolidExpr.bounds with two cells of headroom — a marched surface that touches its window comes out open, which would show up as a null genus rather than as a silently wrong volume.

@param quality the two knobs; a convergence sweep is q.copy(edgeLength = h) for a few h.

fun SolidExpr.crossCheck(quality: SolidQuality = SolidQuality()): SolidAgreement {
    val raw = bounds() ?: error(
        "cannot cross-check $this: it is unbounded, so neither side has a window. " +
            "Intersect it with something bounded first.",
    )
    val edge = quality.edgeLengthFor(raw)
    val window = raw.expandedBy(2.0 * edge)
    val size = window.size
    val longest = maxOf(size.x, size.y, size.z).toDouble()
    val resolution = max(2, ceil(longest / edge).toInt())

    val marched = marchingCubes(toScalarField(), window, resolution)
    val refusals = allRefusals()

    return buildSolid(quality).use { build ->
        val solid = build.solid
        val solidMesh = solid.toTriMesh()
        SolidAgreement(
            expr = this,
            kind = if (build.isExact) AgreementKind.EXACT_VS_MARCHED else AgreementKind.MARCHED_VS_MARCHED,
            edgeLength = edge,
            segments = quality.segments,
            refusals = refusals.map { it.why },
            solidVolume = solid.volume,
            marchedVolume = marched.signedVolume(),
            solidArea = solid.surfaceArea,
            marchedArea = marched.surfaceArea(),
            solidGenus = solid.genus,
            marchedGenus = marched.genus(),
            marchedIsWatertight = marched.isWatertight(),
            solidTriangles = solid.triangleCount,
            marchedTriangles = marched.triangleCount,
            deviation = maxDeviation(solidMesh, marched),
            scale = longest,
        )
    }
}

fun facetingDeficit(n: Int): Double

The relative volume a regular inscribed n-gon loses against its circle: 1 − (n / 2π)·sin(2π / n), which is 2π²/3n² to leading order.

This is the floor a curved shape's SolidAgreement.relativeVolumeError converges to, and it is computed rather than remembered — so a test can assert both that the residual is near it and that it falls by 4x when the segment count doubles, which is the thing that proves the residual is faceting rather than a bug in either lowering.

Exact for a prism (a cylinder is a right prism over an n-gon, so its volume deficit is precisely this). For a sphere, Manifold builds a geodesic rather than a surface of revolution, so this is the right order and the right scaling but not the exact coefficient — which is why the sphere assertions in SolidSLCrossValidationTest check the scaling and bound the coefficient rather than equating it.

@param n segments in a full circle. Returns 0.0 for n <= 2, since there is no polygon to speak of.

fun facetingDeficit(n: Int): Double = if (n <= 2) 0.0 else 1.0 - (n / (2.0 * PI)) * sin(2.0 * PI / n)

fun effectiveSegments(radius: Double, requested: Int): Int

How many segments Manifold will actually give a circle of this radius under the current global quality, when a node asked for 0.

facetingDeficit needs the number that was used, not the one that was requested, and 0 — the default everywhere in this package — means "consult the global setting". Going through Manifold rather than reimplementing its rule is the only way this stays true.

fun effectiveSegments(radius: Double, requested: Int): Int =
    if (requested > 0) requested else Solid.circularSegments(radius)

fun SolidExpr.marchedMesh(quality: SolidQuality = SolidQuality()): TriMesh

TriMesh from the marched side of a cross-check, for a render or a second opinion.

fun SolidExpr.marchedMesh(quality: SolidQuality = SolidQuality()): TriMesh {
    val raw = bounds() ?: error("cannot march $this: it is unbounded")
    val edge = quality.edgeLengthFor(raw)
    val window = raw.expandedBy(2.0 * edge)
    val size = window.size
    val longest = maxOf(size.x, size.y, size.z).toDouble()
    return marchingCubes(toScalarField(), window, max(2, ceil(longest / edge).toInt()))
}