Skip to content

SimulationGeometry.kt

src/jvmMain/kotlin/afield/simulation/kratos/SimulationGeometry.kt · 352 lines

package afield.simulation.kratos

import afield.geometry.solid.manifold.Solid
import afield.geometry.mesh.TriMesh
import afield.math.linalg.Vec3
import afield.geometry.mesh.bvh
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.abs
import kotlin.math.floor

What a session is told about shape, and the discretisation that shape gets solved on.

Every SimulationGeometry answers two questions, for two different readers. surface() returns a TriMesh for Field to draw, and Manifold builds that in every case. workerData(domain) returns what goes over the wire, and the implementations answer it in two different currencies.

Symbolic, or sampled

A PrimitiveGeometry sends expression(): a small tree of boxes, spheres, cylinders and boolean operations that the worker evaluates as an exact distance at each node it cares about. A ManifoldGeometry sends the distances themselves, one double per node of the domain, measured against Field's own BVH over the triangulated solid and negated on the way out because Kratos counts distance positive inside.

That difference decides what a display setting can and cannot affect. A primitive's tessellation is a property of the picture only: refining a sphere's segments changes no node's classification, because the worker never sees the triangles. For a ManifoldGeometry the triangles are the input, so its payload is both sized to one particular domain's node count and only as good as the mesh it was snapshotted from.

Identity is authored, not derived

The id on a geometry is chosen by whoever built it and is independent of the triangle indices Manifold happens to produce, which is what lets a boundary condition or a material keep naming the same region across a re-solve: a formulation derives its region keys from that id. SimulationGroup requires its operands' ids to be distinct for the same reason. Bounds are metres, and coefficients are SI throughout, kelvin included.

The domain is two different things behind one type

BackgroundDomain is the node and cell arrays a result is indexed against, and its resolution tells its two kinds apart. Positive means the worker built a structured Freudenthal grid from SimulationBounds, six tetrahedra to a cube, which is what makes sampling a closed-form index calculation and what lets an embedded formulation reclassify new geometry without remeshing. Zero means Field meshed a body itself and sent it, in which case there is no (i, j, k) to compute from and the index-based views are unavailable.

data class SimulationBounds(val minX: Double, val minY: Double, val minZ: Double, val maxX: Double, val maxY: Double, val maxZ: Double)

Bounds are metres in schema 1; all simulation coefficients use SI, including kelvin.

init { require(wire().all(Double::isFinite) && minX < maxX && minY < maxY && minZ < maxZ) }

internal fun wire(): List<Double> = listOf(minX, minY, minZ, maxX, maxY, maxZ)

sealed interface SimulationGeometry

Stable authored identity, independent of Manifold's temporary triangle indices.

sealed interface SimulationGeometry {

val id: String

The authored name a formulation's region keys are derived from. Must not be blank.

fun surface(): TriMesh

The triangles Field draws. Manifold builds these in every case, including for a primitive.

fun workerData(domain: BackgroundDomain): Map<String, Any?>

What the worker is told about this shape, for a domain it has already built.

A primitive answers with an expression tree and a mesh answers with one distance per node, so what a caller gets back differs in size and in kind between the two implementations.

sealed interface PrimitiveGeometry : SimulationGeometry

Analytic CSG classification; Manifold remains the implementation of the renderable surface.

sealed interface PrimitiveGeometry : SimulationGeometry {

fun expression(): Map<String, Any?>

The shape as a wire node the worker evaluates as an exact distance: an op tag, an id, and whatever that op needs. Boolean ops nest their operands' expressions inside their own.

fun solid(): Solid

The same shape as a freshly built Manifold solid. The caller owns it, and closes it itself unless the call is inside a Solid.scope.

override fun surface(): TriMesh = Solid.scope { solid().checked().toTriMesh() }

Triangulates solid inside a Solid.scope, which releases the solid and everything built to make it as soon as the triangles are out, rather than leaving them to a Cleaner.

override fun workerData(domain: BackgroundDomain): Map<String, Any?>

Sends expression, not triangles, so the payload is the same whatever domain it is asked about and a change to a display tessellation cannot reach the solve.

    override fun workerData(domain: BackgroundDomain): Map<String, Any?> = mapOf("geometry" to expression())
}

data class SimulationBox(override val id: String, val bounds: SimulationBounds) : PrimitiveGeometry

An axis-aligned box, given by the two corners of its SimulationBounds.

init { require(id.isNotBlank()) }

override fun expression(): Map<String, Any?>

The box node: the minimum corner and the maximum corner, in metres.

    override fun expression(): Map<String, Any?> = mapOf("id" to id, "op" to "box", "min" to bounds.wire().take(3), "max" to bounds.wire().drop(3))

override fun solid(): Solid

A Solid.cube of the extents, translated to the minimum corner.

    override fun solid(): Solid = bounds.run {
        Solid.cube(maxX-minX, maxY-minY, maxZ-minZ).use { it.translated(minX, minY, minZ) }
    }
}

data class SimulationSphere(override val id: String, val x: Double, val y: Double, val z: Double,

A ball, as an exact distance and as a Manifold solid.

segments is the Manifold tessellation only. The worker never sees it: the analytic distance is the true sphere, so refining the display surface does not change a single node's classification. That asymmetry is the point of keeping a primitive symbolic rather than shipping its triangles.

data class SimulationSphere(override val id: String, val x: Double, val y: Double, val z: Double,
                            /** Radius in metres, and the Manifold segment count for the display surface only. 0 takes the global quality setting. */
                            val radius: Double, val segments: Int = 0) : PrimitiveGeometry {

init

    init {
        require(id.isNotBlank())
        require(listOf(x, y, z, radius).all(Double::isFinite) && radius > 0) { "sphere needs a finite positive radius" }
    }

override fun expression(): Map<String, Any?>

The sphere node: centre and radius, the exact ball rather than the drawn one.

    override fun expression(): Map<String, Any?> =
        mapOf("id" to id, "op" to "sphere", "centre" to listOf(x, y, z), "radius" to radius)

override fun solid(): Solid = Solid.sphere(radius, segments).use { it.translated(x, y, z) }

A Solid.sphere translated to the centre. This is the only place segments is read.

data class SimulationCylinder(override val id: String,

A capped cylinder between two points, at any orientation.

Manifold builds cylinders along +Z, so the solid is one built at the origin and pushed through a 3x4 affine whose third column is the axis direction. The two columns across it are an arbitrary orthonormal pair — a cylinder is rotationally symmetric, so which pair does not matter, and choosing them without a branch avoids the degenerate case where the axis is parallel to whatever reference vector a naive cross product picked.

data class SimulationCylinder(override val id: String,
                              /** One end cap's centre, in metres. */
                              val ax: Double, val ay: Double, val az: Double,
                              /** The other end cap's centre. The two must differ. */
                              val bx: Double, val by: Double, val bz: Double,
                              /** Radius in metres, and the Manifold segment count for the display surface only. 0 takes the global quality setting. */
                              val radius: Double, val segments: Int = 0) : PrimitiveGeometry {

init

    init {
        require(id.isNotBlank())
        require(listOf(ax, ay, az, bx, by, bz, radius).all(Double::isFinite) && radius > 0) {
            "cylinder needs finite endpoints and a positive radius"
        }
        require(axisLength() > 0) { "cylinder endpoints must differ" }
    }

    private fun axisLength(): Double = kotlin.math.sqrt(
        (bx - ax) * (bx - ax) + (by - ay) * (by - ay) + (bz - az) * (bz - az))

override fun expression(): Map<String, Any?>

The cylinder node: the two end centres and the radius. The tessellation is not sent.

    override fun expression(): Map<String, Any?> = mapOf(
        "id" to id, "op" to "cylinder", "a" to listOf(ax, ay, az), "b" to listOf(bx, by, bz), "radius" to radius)

override fun solid(): Solid

A Solid.cylinder built along +Z at the origin, then pushed through the affine below.

    override fun solid(): Solid {
        val h = axisLength()
        val (dx, dy, dz) = Triple((bx - ax) / h, (by - ay) / h, (bz - az) / h)
        // Duff et al's branchless orthonormal basis around (dx, dy, dz).
        val sign = if (dz >= 0.0) 1.0 else -1.0
        val a = -1.0 / (sign + dz)
        val b = dx * dy * a
        return Solid.cylinder(h, radius, radius, segments).use {
            it.transformed(
                1.0 + sign * dx * dx * a, sign * b, -sign * dx,
                b, sign + dy * dy * a, -dy,
                dx, dy, dz,
                ax, ay, az,
            )
        }
    }
}

enum class SimulationBoolean { UNION, DIFFERENCE, INTERSECTION }

The three boolean operations, on the wire as the lowercased name.

data class SimulationCSG(override val id: String, val a: PrimitiveGeometry, val b: PrimitiveGeometry,

One boolean operation over two operands. SimulationGroup is the n-ary spelling.

data class SimulationCSG(override val id: String, val a: PrimitiveGeometry, val b: PrimitiveGeometry,
                         /** `DIFFERENCE` reads as [a] minus [b]. */
                         val operation: SimulationBoolean = SimulationBoolean.UNION) : PrimitiveGeometry {

init { require(id.isNotBlank()) }

override fun expression(): Map<String, Any?>

The operation as the op tag, with both operands' expressions nested inside it.

    override fun expression(): Map<String, Any?> = mapOf("id" to id, "op" to operation.name.lowercase(), "a" to a.expression(), "b" to b.expression())

override fun solid(): Solid

Both operands built, combined, and closed. The result is the caller's.

    override fun solid(): Solid = a.solid().use { left -> b.solid().use { right ->
        when (operation) { SimulationBoolean.UNION -> left + right; SimulationBoolean.DIFFERENCE -> left - right; SimulationBoolean.INTERSECTION -> left and right }
    } }
}

data class SimulationGroup(override val id: String, val operation: SimulationBoolean,

The n-ary spelling of SimulationCSG: one operation over any number of operands.

Worth preferring over nested pairs for a union of many parts, for the same reason Solid.union(list) beats folding +: Manifold plans the whole batch instead of nesting n−1 operations, and on the worker side one np.maximum.reduce replaces a recursion. DIFFERENCE reads as the first operand minus every other one.

data class SimulationGroup(override val id: String, val operation: SimulationBoolean,
                           /** At least two operands, with distinct ids. `DIFFERENCE` subtracts every later one from the first. */
                           val children: List<PrimitiveGeometry>) : PrimitiveGeometry {

init

    init {
        require(id.isNotBlank())
        require(children.size >= 2) { "$operation needs at least two operands, got ${children.size}" }
        val duplicates = children.groupBy { it.id }.filterValues { it.size > 1 }.keys
        require(duplicates.isEmpty()) { "operand ids must be distinct for stable identity: $duplicates" }
    }

override fun expression(): Map<String, Any?>

One op tag over a children list, so the worker reduces the batch instead of recursing.

    override fun expression(): Map<String, Any?> =
        mapOf("id" to id, "op" to operation.name.lowercase(), "children" to children.map { it.expression() })

override fun solid(): Solid

One batched Manifold call over every operand, each of which is closed afterwards.

    override fun solid(): Solid {
        val solids = children.map { it.solid() }
        try {
            return when (operation) {
                SimulationBoolean.UNION -> Solid.union(solids)
                SimulationBoolean.INTERSECTION -> Solid.intersection(solids)
                SimulationBoolean.DIFFERENCE -> Solid.difference(solids)
            }
        } finally {
            solids.forEach { it.close() }
        }
    }
}

class ManifoldGeometry(override val id: String, solid: Solid) : SimulationGeometry

Snapshot of the existing Manifold conversion. The caller retains ownership of Solid. Field's existing BVH supplies distances (negated to Kratos' positive-inside convention). Classification runs on the session executor. Triangle provenance is not invented here.

class ManifoldGeometry(override val id: String, solid: Solid) : SimulationGeometry {
    private val mesh = solid.checked().toTriMesh()
    private val acceleration by lazy { mesh.bvh() }

init { require(id.isNotBlank() && !mesh.isEmpty) }

override fun surface(): TriMesh = mesh

The triangulation taken at construction. Later edits to the Solid are not seen.

override fun workerData(domain: BackgroundDomain): Map<String, Any?>

One little-endian double per node of domain: the distance to the surface, negated on the way out because Kratos counts distance positive inside.

Unlike a primitive's payload this is sized to one particular domain and is only as good as the mesh it was snapshotted from, so it costs a node-count pass over the BVH per call.

    override fun workerData(domain: BackgroundDomain): Map<String, Any?> {
        val data = ByteBuffer.allocate(domain.nodeCount * 8).order(ByteOrder.LITTLE_ENDIAN)
        for (i in 0 until domain.nodeCount) {
            val p = domain.positions
            data.putDouble(-acceleration.signedDistance(Vec3(p[3*i].toFloat(), p[3*i+1].toFloat(), p[3*i+2].toFloat())))
        }
        return mapOf("geometryId" to id, "distance" to data.array())
    }
}

class BackgroundDomain private constructor(val id: String, val bounds: SimulationBounds, val resolution: Int,

The discretisation a session solves on, however it was made.

Two kinds, and resolution tells them apart. A structured domain (resolution > 0) is the Freudenthal grid the worker builds from bounds; its regularity is what makes barycentric sampling a closed-form index calculation, and what lets an embedded formulation reclassify without remeshing. An unstructured domain (resolution == 0) is a body-fitted tetrahedral mesh Field produced with TetGen and sent; it has no (i, j, k), so point sampling and slicing are unavailable on it and SimulationResult.boundaryMesh is the view to use instead.

class BackgroundDomain private constructor(val id: String, val bounds: SimulationBounds, val resolution: Int,
                                          /**
                                           * Node coordinates as xyz per node in metres, cell
                                           * connectivity as [nodesPerCell] node indices per cell,
                                           * and how long the build took in milliseconds. Every
                                           * field a result carries is indexed against `positions`
                                           * in this order.
                                           */
                                          val positions: DoubleArray, val cells: IntArray, val buildMs: Double,
                                          /**
                                           * 4 for linear tetrahedra, 10 for quadratic ones. A
                                           * structured grid is always linear; a supplied mesh is
                                           * whatever [afield.geometry.mesh.tetgen.Tetgen] was asked for. Anything
                                           * reading connectivity for its *shape* wants the first
                                           * four of each group and this as the stride.
                                           */
                                          val nodesPerCell: Int = 4) {

val isStructured: Boolean get() = resolution > 0

True when this is a structured grid and so can be sampled by index.

val nodeCount: Int get() = positions.size / 3

Nodes, and so the length every nodal field a result carries has to be.

val cellCount: Int get() = cells.size / nodesPerCell

Tetrahedra: cells divided by nodesPerCell, so it is right for quadratic meshes too.

val buildCount: Int = 1

How many times this discretisation has been built, which is always 1.

A geometry change reclassifies this same grid rather than remeshing, so a parameter sweep that varies an arm length should end with the domain it started with. Several places check that: it is the evidence that geometry really is an ordinary study axis and not a rebuild in disguise.

internal fun index(x: Int, y: Int, z: Int): Int = (x * (resolution+1) + y) * (resolution+1) + z

internal companion object

fun decode(bounds: SimulationBounds, resolution: Int, payload: Map<String, Any?>): BackgroundDomain

        fun decode(bounds: SimulationBounds, resolution: Int, payload: Map<String, Any?>): BackgroundDomain {
            require(resolution in 4..32)
            val arrays = KratosArrays(payload)
            val nodes = (resolution+1)*(resolution+1)*(resolution+1)
            val cells = arrays.ints("cells", resolution*resolution*resolution*6, 4)
            require(cells.all { it in 0 until nodes }) { "Invalid background node index" }
            require(payload.number("buildCount") == 1L)
            return BackgroundDomain(payload["backgroundId"] as String, bounds, resolution,
                arrays.doubles("positions", nodes, 3), cells, (payload["domainBuildMs"] as Number).toDouble())
        }

fun ofSuppliedMesh(id: String, mesh: afield.geometry.mesh.tetgen.TetMesh, buildMs: Double): BackgroundDomain

The domain for a mesh Field meshed itself and sent, so nothing comes back over the wire except an id: the caller already has every coordinate.

        fun ofSuppliedMesh(id: String, mesh: afield.geometry.mesh.tetgen.TetMesh, buildMs: Double): BackgroundDomain {
            val p = mesh.positions
            val bounds = SimulationBounds(
                (0 until mesh.nodeCount).minOf { p[3 * it] }, (0 until mesh.nodeCount).minOf { p[3 * it + 1] },
                (0 until mesh.nodeCount).minOf { p[3 * it + 2] }, (0 until mesh.nodeCount).maxOf { p[3 * it] },
                (0 until mesh.nodeCount).maxOf { p[3 * it + 1] }, (0 until mesh.nodeCount).maxOf { p[3 * it + 2] })
            return BackgroundDomain(id, bounds, 0, mesh.positions, mesh.cells, buildMs, mesh.nodesPerCell)
        }
    }
}

data class SimulationProbe(val id: String, val x: Double, val y: Double, val z: Double)

Named spatial selection whose identity survives successive result revisions.

init { require(id.isNotBlank() && listOf(x,y,z).all(Double::isFinite)) }

data class SimulationProbeSample(val probeId: String, val revision: SimulationRevision, val value: Double?)

One probe's reading of a result's primary field, tagged with the revision it was taken from.

value is null when the point lies outside the domain or outside the solved region. A probe keeps its id across revisions, so a point the geometry has moved away from still reports, with no value.

data class SimulationProbeSample(val probeId: String, val revision: SimulationRevision, val value: Double?) {

val temperature: Double? get() = value

The schema-1 name, when the field being probed is a temperature.