Skip to content

mesh_field.kt

src/jvmMain/kotlin/afield/geometry/mesh/mesh_field.kt · 1026 lines

package and imports — 104 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Graphics")

package afield.geometry.mesh

import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.times
import afield.data.graph.main
import afield.document.context.Why
import afield.document.context.why
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.document.workspace.path
import afield.geometry.spatial.Voronoi3d
import afield.geometry.spatial.VoronoiFace
import afield.geometry.spatial.voronoi3d
import afield.graphics.render.pointed
import afield.graphics.render.rangeTo
import afield.graphics.shader.compute
import afield.graphics.shader.intersection
import afield.graphics.text.layout
import afield.io.osc.main
import afield.lang.stacksl.evolution.render
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.product
import afield.math.autodiff.round
import afield.math.autodiff.times
import afield.math.control.main
import afield.math.linalg.bar
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.times
import afield.math.linalg.vec
import afield.math.statistics.main
import afield.math.statistics.noise
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.Img
import afield.media.image.close
import afield.media.image.open
import afield.media.image.rotate
import afield.media.image.threshold
import afield.media.video.image
import afield.runtime.execution.go
import afield.runtime.execution.start
import afield.runtime.lifecycle.once
import afield.simulation.agents.step
import afield.simulation.automata.main
import afield.simulation.automata.step
import afield.study.ui.numbers

import afield.audio.synth.*
import afield.data.collections.r
import afield.data.collections.s
import afield.geometry.spatial.VoronoiCell3
import afield.geometry.spatial.VoronoiLattice
import afield.graphics.render.color
import afield.graphics.render.label
import afield.graphics.shader.mesh
import afield.graphics.shader.vertex
import afield.graphics.vulkan.buffers.add
import afield.math.autodiff.asin
import afield.math.autodiff.atan2
import afield.math.autodiff.cos
import afield.math.autodiff.dot
import afield.math.autodiff.min
import afield.math.autodiff.sqrt
import afield.math.autodiff.sum
import afield.math.functions.asin
import afield.math.functions.atan2
import afield.math.functions.cos
import afield.math.linalg.Vec3
import afield.math.linalg.Vec4
import afield.math.linalg.d
import afield.math.linalg.i
import afield.math.linalg.ia
import afield.math.linalg.l
import afield.math.linalg.len
import afield.math.linalg.set
import afield.math.linalg.x
import afield.media.image.channel
import afield.media.image.colormap
import afield.runtime.execution.cell
import afield.runtime.macros.LabelsArgumentsFromSource
import afield.document.graph.Box
import afield.geometry.path.FLine
import afield.graphics.visionos.VISION_METAL_VERTEX_FLOATS
import afield.graphics.visionos.VisionGeometryPreview
import afield.geometry.path.polylineFrom
import afield.geometry.path.samplePoints
import afield.graphics.three.three
import afield.graphics.three.threeGrid
import afield.graphics.three.threeTurntable
import java.util.Base64
import kotlin.math.max
import afield.ui.output.FieldMarkdown
import afield.ui.visualization.facts

What makes the mesh family Field's rather than a generic geometry library: the bridges out of TriMesh and into the things Field already draws, renders and displays.

Four bridges:

  1. Mesh → FLine. TriMesh.toFLines gives you the wireframe, the silhouette-free edge set, the open boundary loops or the normals as drawable lines, so a marched iso-surface goes straight into the same drawing / animation / export machinery as anything hand-drawn — and is visible in a Field document today, with no renderer set up.
  2. Mesh → pixels. TriMesh.renderPng rasterises the mesh — perspective, z-buffer, perspective-correct attributes, two-light lambert, depth-tested wireframe — by packing it for VisionGeometryPreview's renderer, which already had all of that. Box.showMesh pushes the result to a box output and TriMesh.toHTML uses it for the inline value thumbnail, so evaluating a mesh in a box shows the shape.
  3. 3D Voronoi → mesh. VoronoiCell3.toTriMesh and VoronoiLattice.toTriMesh triangulate the polygonal cells voronoi.kt already computes by half-space clipping. Field could compute a 3D Voronoi diagram and not turn it into geometry; now it can.
  4. Field lines → mesh. tubeMesh on an FLine, so a drawn stroke becomes a solid.
val cells = voronoi3d(List(24) { vec(rand(-1, 1), rand(-1, 1), rand(-1, 1)) })
val solid = cells.toTriMesh(shrink = 0.9)   // every cell, shrunk toward its own site
showMesh(solid, caption = "${solid.triangleCount} triangles from 24 sites")

Mesh -> FLine

enum class MeshLines

Which lines TriMesh.toFLines should produce.

enum class MeshLines {

Every unique edge, as its own two-point line. The whole wireframe.

    EDGES,

Only edges where the two incident faces disagree by more than the crease angle, plus every boundary edge — the "feature lines" a technical drawing would show. Far fewer lines than EDGES on a smooth mesh, and much more readable.

    CREASES,

Only the open boundary, as closed rings — one line per hole.

    BOUNDARY,

One short line per vertex, along its normal. A normal-field debugger.

    NORMALS,

One closed triangle per face. Draws each edge twice, but keeps faces distinguishable.

    FACES,
}

fun TriMesh.toFLines

This mesh as drawable FLines — the fastest way to see a mesh in a Field document without involving a renderer.

@param mode which lines to produce; see MeshLines. @param creaseAngleDegrees the threshold for MeshLines.CREASES. @param normalLength the line length for MeshLines.NORMALS, as a fraction of the mesh's bounding diagonal.

body — 45 lines, from line 168
fun TriMesh.toFLines(
    mode: MeshLines = MeshLines.EDGES,
    creaseAngleDegrees: Double = 25.0,
    normalLength: Double = 0.03,
): List<FLine> {
    if (triangleCount == 0) return emptyList()
    return when (mode) {
        MeshLines.FACES -> (0 until triangleCount).map { t ->
            polylineFrom(listOf(position(ia(t)), position(ib(t)), position(ic(t))), closed = true)
        }
        MeshLines.EDGES -> topology().edges.map { (a, b) -> polylineFrom(listOf(position(a), position(b))) }
        MeshLines.CREASES -> {
            val topo = surfaceTopology()
            val cosLimit = kotlin.math.cos(creaseAngleDegrees.coerceIn(0.0, 180.0) * Math.PI / 180.0).toFloat()
            topo.edges.indices.mapNotNull { e ->
                val faces = topo.edgeTriangles[e]
                val keep = when (faces.size) {
                    1 -> true // a boundary edge is always a feature
                    2 -> faceNormal(faces[0]).dot(faceNormal(faces[1])) < cosLimit
                    else -> true // non-manifold: definitely worth seeing
                }
                if (!keep) null else {
                    val (a, b) = topo.edges[e]
                    polylineFrom(listOf(position(a), position(b)))
                }
            }
        }
        MeshLines.BOUNDARY -> topology().boundaryLoops.map { ring ->
            polylineFrom(ring.map { position(it) }, closed = true)
        }
        MeshLines.NORMALS -> {
            val src = if (hasNormals) this else withVertexNormals()
            val b = boundsArray()
            val diagonal = kotlin.math.sqrt(
                (b[3] - b[0]) * (b[3] - b[0]) + (b[4] - b[1]) * (b[4] - b[1]) + (b[5] - b[2]) * (b[5] - b[2])
            )
            val len = (diagonal * normalLength).toFloat()
            (0 until vertexCount).mapNotNull { v ->
                val n = src.normal(v) ?: return@mapNotNull null
                val p = position(v)
                polylineFrom(listOf(p, Vec3(p).add(n.x * len, n.y * len, n.z * len)))
            }
        }
    }
}

fun tubeMesh

A tube swept along an FLine, sampled at samples points — see tubeMesh.

fun tubeMesh(
    line: FLine,
    radius: Number = 0.05,
    sides: Int = 12,
    samples: Int = 64,
    closed: Boolean = false,
    caps: Boolean = true,
): TriMesh {
    val points = line.samplePoints(samples)
    require(points.size >= 2) { "tubeMesh needs an FLine that samples to >= 2 points, got ${points.size}" }
    return tubeMesh(points, radius, sides, closed, caps)
}

3D Voronoi -> mesh

fun VoronoiCell3.toTriMesh

One 3D Voronoi cell's polygonal faces, triangulated into a closed TriMesh.

voronoi.kt clips half-spaces into VoronoiFace polygons already wound counter-clockwise about their plane normal, and the plane normals point out of the cell, so fanning each polygon gives a consistently outward mesh with no repair pass.

Why there is still a healing step

voronoi.kt used to leave a T-junction wherever four or more bisector planes nearly met: a face claiming a corner that was not on it, so one edge on one side of a seam and two on the other, showing up here as a hairline crack in an otherwise perfect cell. That is now fixed at the source — see Voronoi3d.collectCorners and VoronoiFaceTopologyTest — and where 22 of the 36 interior cells of a 72-site lattice used to close from their faces alone, all of them now do.

The repair stays, because the fix is not total: 4 of 465 bounded cells over a 24-lattice sweep, and 12 of 447 of the wildly-conditioned clipped ones, still have an ill-conditioned corner the clipper resolves into two. VoronoiFaceTopologyTest ratchets those counts, and voronoi.kt explains what closing the gap properly would take. A caller who would rather see the raw faceted result, cracks and all, passes healToConvexHull = false.

A Voronoi cell is by construction the intersection of half-spaces, so it is convex, so its convex hull is exactly itself. That makes convexHullMesh over the same corner set an exact repair rather than an approximation. The faceted result is preferred whenever it is already correct, because it keeps the cell's real face structure.

@param shrink scale the cell toward its own site. 1.0 leaves the cells touching (so a whole lattice is one solid); 0.9 opens the seams, which is what makes an exploded Voronoi readable. @param fanFromCentroid triangulate each face from its centroid rather than from its first corner. Better-shaped triangles at the cost of one extra vertex per face; on by default because Voronoi faces are often long thin polygons whose corner fan is all slivers. @param healToConvexHull rebuild the cell as the convex hull of its corners when the fanned faces do not close. Set it false to see the raw faceted result, cracks and all.

fun VoronoiCell3.toTriMesh(
    shrink: Double = 1.0,
    fanFromCentroid: Boolean = true,
    healToConvexHull: Boolean = true,
): TriMesh {
    val s = shrink.toFloat()
    fun place(p: Vec3): Vec3 =
        if (s == 1f) Vec3(p) else Vec3(site.x + (p.x - site.x) * s, site.y + (p.y - site.y) * s, site.z + (p.z - site.z) * s)
    val faceted = triMesh {
        for (face in faces) {
            val ring = face.polygon
            if (ring.size < 3) continue
            val corners = ring.map { place(it).let { q -> vertex(q.x, q.y, q.z) } }
            if (fanFromCentroid) {
                var cx = 0f; var cy = 0f; var cz = 0f
                for (p in ring) { val q = place(p); cx += q.x; cy += q.y; cz += q.z }
                val inv = 1f / ring.size
                val hub = vertex(cx * inv, cy * inv, cz * inv)
                for (i in corners.indices) triangle(hub, corners[i], corners[(i + 1) % corners.size])
            } else {
                polygon(corners)
            }
        }
    }.removeDegenerateTriangles()
    if (faceted.triangleCount == 0) return faceted
    // The face polygons already share exact corner coordinates where they agree, so the weld tolerance
    // only has to absorb float noise: scale it to the cell rather than picking an absolute number.
    val extent = faceted.bounds().let { Vec3(it.max).sub(it.min).length().toDouble() }
    val welded = faceted.weld(extent * 1e-5)
    if (!healToConvexHull || welded.topology().isWatertight) return welded
    val hull = convexHullMesh(faces.flatMap { face -> face.polygon.map { place(it) } })
    return if (hull.triangleCount > 0) hull else welded
}

fun VoronoiLattice.toTriMesh(shrink: Double = 0.92, boundedOnly: Boolean = false): TriMesh

Every cell of a 3D Voronoi lattice as one TriMesh, one connected component per cell.

Unbounded cells (the ones on the convex hull of the sites, which extend to infinity and are only closed by the clip box) are included by default because the clip box does close them; pass boundedOnly = true for just the interior cells, which is what you want if you are about to measure volumes or make an exploded picture.

@param shrink see VoronoiCell3.toTriMesh. 0.92 is a good exploded look.

fun VoronoiLattice.toTriMesh(shrink: Double = 0.92, boundedOnly: Boolean = false): TriMesh {
    val builder = TriMeshBuilder()
    for (cell in cells) {
        if (boundedOnly && !cell.bounded) continue
        builder.add(cell.toTriMesh(shrink))
    }
    return builder.build()
}

Mesh -> pixels

internal val DEFAULT_SURFACE = floatArrayOf(0.70f, 0.745f, 0.80f)

The neutral surface colour a mesh with no per-vertex colours is drawn in.

internal rather than private because afield.graphics.three.toThreeDrawable draws the same uncoloured mesh on the GPU and must reach for the same grey — a copied literal is a drift waiting to happen.

fun TriMesh.toCapturedMesh(label: String = "TriMesh", creaseAngleDegrees: Double = 35.0): VisionGeometryPreview.CapturedMesh

Pack this mesh into the 16-float-per-vertex layout VisionGeometryPreview's rasterizer consumes, so the whole shaded / depth-buffered / wireframed renderer applies to a TriMesh for free.

A mesh with no normals is run through TriMesh.shadedNormals first (a box stays a box, a cylinder comes out smooth-sided), because the rasterizer needs a normal per vertex and a missing one shades flat grey. A mesh with no colours gets a neutral surface tint.

fun TriMesh.toCapturedMesh(label: String = "TriMesh", creaseAngleDegrees: Double = 35.0): VisionGeometryPreview.CapturedMesh {
    check(VISION_METAL_VERTEX_FLOATS == 16) {
        "toCapturedMesh assumes a 16-float vertex; VISION_METAL_VERTEX_FLOATS is $VISION_METAL_VERTEX_FLOATS"
    }
    val ready = if (hasNormals) this else shadedNormals(creaseAngleDegrees)
    val packed = FloatArray(ready.vertexCount * VISION_METAL_VERTEX_FLOATS)
    for (v in 0 until ready.vertexCount) {
        val o = v * VISION_METAL_VERTEX_FLOATS
        packed[o] = ready.x(v); packed[o + 1] = ready.y(v); packed[o + 2] = ready.z(v); packed[o + 3] = 1f
        val n = ready.normal(v) ?: Vec3(0f, 1f, 0f)
        val l = n.length()
        // The invariant checker rejects a nearly-unit normal (the signature of an un-normalised cross
        // product), so normalise here rather than trusting the source.
        if (l > 1e-20f) n.mul(1f / l) else n.set(0f, 1f, 0f)
        packed[o + 4] = n.x; packed[o + 5] = n.y; packed[o + 6] = n.z; packed[o + 7] = 0f
        val c = ready.color(v)
        packed[o + 8] = c?.x ?: DEFAULT_SURFACE[0]
        packed[o + 9] = c?.y ?: DEFAULT_SURFACE[1]
        packed[o + 10] = c?.z ?: DEFAULT_SURFACE[2]
        packed[o + 11] = 1f
        val uv = ready.uv(v)
        packed[o + 12] = uv?.x ?: 0f
        packed[o + 13] = uv?.y ?: 0f
    }
    return VisionGeometryPreview.CapturedMesh(packed, ready.indices.copyOf(), label)
}

fun TriMesh.coloredByScalar

Per-vertex colours from a per-vertex scalar, through the same afield.graphics.color.Colormaps tables RectSL's charts and Img.colormap use — so a curvature field, a distance field and a bar chart in the same document are coloured by one source of truth.

This is what turns TriMesh.gaussianCurvature or TriMesh.meanCurvature from an array of numbers into something you can look at.

@param values one per vertex. @param colormap a name from afield.graphics.color.Colormaps.names — viridis, magma, inferno, plasma, turbo, coolwarm, grayscale. @param low the value mapped to the bottom of the ramp; defaults to the data's own minimum. @param high the value mapped to the top; defaults to the data's own maximum. @param percentile when low and high are left null, clip this fraction off each tail before choosing them. Curvature fields have long tails at creases, and one outlier vertex otherwise flattens the entire ramp; 0.02 is a good default and 0.0 disables the clip.

fun TriMesh.coloredByScalar(
    values: DoubleArray,
    colormap: String = "viridis",
    low: Double? = null,
    high: Double? = null,
    percentile: Double = 0.02,
): TriMesh {
    require(values.size == vertexCount) { "expected $vertexCount values, got ${values.size}" }
    if (vertexCount == 0) return this
    val sorted = values.filter { it.isFinite() }.sorted()
    if (sorted.isEmpty()) return withColor(0.5, 0.5, 0.5)
    val clip = percentile.coerceIn(0.0, 0.49)
    val lo = low ?: sorted[(sorted.size * clip).toInt().coerceIn(0, sorted.size - 1)]
    val hi = high ?: sorted[((sorted.size - 1) * (1.0 - clip)).toInt().coerceIn(0, sorted.size - 1)]
    val span = if (hi - lo < 1e-30) 1.0 else hi - lo
    val out = FloatArray(vertexCount * 3)
    for (v in 0 until vertexCount) {
        val t = if (values[v].isFinite()) (values[v] - lo) / span else 0.5
        val c = afield.graphics.color.Colormaps.sample(colormap, t)
        out[v * 3] = c.r.toFloat(); out[v * 3 + 1] = c.g.toFloat(); out[v * 3 + 2] = c.b.toFloat()
    }
    return withColors(out)
}

data class MeshView

How to draw a TriMesh in a preview: where the camera stands and what the frame looks like.

A thin, mesh-family-shaped wrapper over VisionGeometryPreview.Camera and VisionGeometryPreview.Style, so callers of this family do not have to import the visionOS preview's types to render a cube. distance is in multiples of the mesh's own bounding-sphere radius, so the same view frames a 2cm mesh and a 2m one.

data class MeshView(
    /** Orbit around `+y`, in degrees. `0` looks straight down `-z`. */
    val yawDegrees: Double = 28.0,
    /** Lift above the horizon, in degrees. Positive looks down on the mesh. */
    val pitchDegrees: Double = 18.0,
    /** Eye distance in multiples of the mesh's bounding-sphere radius, so it is scale-free. */
    val distance: Double = 2.7,
    /** Vertical field of view in degrees. Smaller is a longer lens and less perspective. */
    val fieldOfViewDegrees: Double = 40.0,
    /** Output width in pixels. */
    val width: Int = 720,
    /** Output height in pixels. */
    val height: Int = 540,
    /** Draw a depth-tested wireframe over the shading. On by default: it turns "a smooth blob" into "a mesh you can count the triangles in". */
    val wireframe: Boolean = true,
    /** Render at this multiple in each axis and box-filter down. `2` is a good anti-aliasing default. */
    val supersample: Int = 2,
    /** Cull back faces, as a GPU would — so a face wound the wrong way is missing here exactly as it would be there. */
    val cullBackFaces: Boolean = true,
    /** Background as packed ARGB. */
    val background: Int = 0xFF101318.toInt(),
) {

internal fun camera()

    internal fun camera() = VisionGeometryPreview.Camera(yawDegrees, pitchDegrees, distance, fieldOfViewDegrees)

internal fun style(caption: String?, title: String?)

    internal fun style(caption: String?, title: String?) = VisionGeometryPreview.Style(
        width = width,
        height = height,
        supersample = supersample,
        wireframe = wireframe,
        cullBackFaces = cullBackFaces,
        background = background,
        caption = caption ?: "",
        title = title,
    )

companion object

    companion object {

fun turntable(count: Int = 4, pitchDegrees: Double = 18.0): List<MeshView>

Four evenly spaced orbits, for a turntable contact sheet.

        fun turntable(count: Int = 4, pitchDegrees: Double = 18.0): List<MeshView> =
            (0 until count).map { MeshView(yawDegrees = 360.0 * it / count, pitchDegrees = pitchDegrees) }
    }
}

fun TriMesh.renderPng(view: MeshView = MeshView(), caption: String? = null, title: String? = null): ByteArray

This mesh rasterised to PNG bytes: perspective, z-buffer, two-light lambert shading over the per-vertex colours, and a depth-tested wireframe.

AWT-free (the encoder is afield.media.image.util.PngEncoder), so this is safe to call from inside a live Field box — which matters on macOS, where touching AWT in the server's JVM initialises AppKit in a process that already owns its main thread for GLFW and takes the whole thing down.

fun TriMesh.renderPng(view: MeshView = MeshView(), caption: String? = null, title: String? = null): ByteArray =
    VisionGeometryPreview.renderPng(toCapturedMesh(), view.camera(), view.style(caption, title))

fun TriMesh.renderPixels(view: MeshView = MeshView()): VisionGeometryPreview.Raster

This mesh's raster as ARGB ints plus its size — for compositing several meshes into one figure.

fun TriMesh.renderPixels(view: MeshView = MeshView()): VisionGeometryPreview.Raster =
    VisionGeometryPreview.renderPixels(toCapturedMesh(), view.camera(), view.style("", null))

fun TriMesh.toFigureHtml(view: MeshView = MeshView(), caption: String? = null, title: String? = null): String

A complete <figure> — this mesh rendered as an inline PNG plus its measurements as a caption — for display(…) in a box output or for the value visualizer.

fun TriMesh.toFigureHtml(view: MeshView = MeshView(), caption: String? = null, title: String? = null): String {
    val png = renderPng(view, caption = "", title = null)
    val b64 = Base64.getEncoder().encodeToString(png)
    val b = boundsArray()
    val topo = surfaceTopology()
    val facts = buildString {
        append("%d verts · %d tris · extent %.3f × %.3f × %.3f".format(
            vertexCount, triangleCount, b[3] - b[0], b[4] - b[1], b[5] - b[2],
        ))
        append(" · χ=${topo.eulerCharacteristic}")
        topo.genus?.let { append(" genus $it") }
        if (topo.isWatertight) append(" · watertight, volume %.4f".format(signedVolume()))
        else append(" · open (${topo.boundaryLoops.size} boundary loop(s))")
    }
    fun escape(s: String) = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
    return buildString {
        append("<figure style='margin:0'>")
        title?.let { append("<div style='color:#e6ecf4;font-weight:600;padding-bottom:4px'>${escape(it)}</div>") }
        append("<img src='data:image/png;base64,$b64' style='max-width:900px;height:auto;border-radius:6px;display:block'/>")
        append("<figcaption style='color:#93a4ba;font-size:12px;padding-top:6px;line-height:1.5'>")
        append("<div style='font-family:monospace'>${escape(facts)}</div>")
        caption?.let { append("<div>${escape(it)}</div>") }
        append("</figcaption></figure>")
    }
}

internal const val MESH_THUMBNAIL_BACKGROUND: Int = 0xFF101318.toInt()

The background the inline thumbnail's raster is cleared to, as packed ARGB.

internal const val MESH_THUMBNAIL_WIDTH: Int = 300

How wide and tall meshThumbnailHtml's render is, in pixels, before supersampling.

internal const val MESH_THUMBNAIL_HEIGHT: Int = 225

internal fun meshThumbnailHtml(mesh: TriMesh): String

The inline card TriMesh.toHTML shows in the value visualizer: the mesh actually rendered, from a viewpoint above and to the right, beside the measurements you would otherwise have to type out.

What you are looking at

The image is the real mesh through the same CPU rasterizer the gallery uses — perspective, z-buffer, two-light lambert over the mesh's own per-vertex colours (a neutral blue-grey if it has none), and a depth-tested wireframe so the triangles are countable. Back faces are culled exactly as a GPU would cull them, so an open mesh wound inside-out loses the surfaces facing you and reads as holes.

A closed mesh wound inside-out does not show up in the picture, and it is worth knowing why: the silhouette is unchanged (culling the front faces only reveals the far inner wall) and the rasterizer flips an inward normal rather than shading it black, so the render looks normal. The tell is in the numbers instead — the volume line is the signed volume, so an inverted solid reports a negative one. Both halves of that are pinned by afield.geometry.mesh.MeshVisualizerPixelTest.

Nothing in the picture is colour-coded by any measured quantity; for that, colour the mesh yourself with coloredByScalar and the shading will carry it.

The facts beside it are the ones that decide whether a mesh is usable: counts, whether it is closed (and manifold, which is not the same thing), its Euler characteristic and genus, surface area, volume if it is closed, and its bounding box. All of them are computed on the mesh you actually have, even when the picture was decimated to stay cheap — which it says when it happens.

What it costs

A 300x225 render at 2x supersampling — 270k rasterized samples — plus one topology pass (edge and vertex adjacency, boundary loops, components) and one area/volume pass over the triangles. On an icosphere that is a few milliseconds. Above 40k triangles the render runs on a decimation, because QEM at 20k triangles is much cheaper than rasterizing 200k of them; the numbers are still the real mesh's, and the caption says the picture is not.

Kept out of mesh.kt so the type itself does not have to know about the rasterizer.

body — 53 lines, from line 544
internal fun meshThumbnailHtml(mesh: TriMesh): String {
    if (mesh.triangleCount == 0) {
        return "<div style=\"font-family:monospace;font-size:11px;color:#8a90a0\">" +
                "TriMesh(empty) — ${mesh.vertexCount} verts, no triangles</div>"
    }
    // A thumbnail must stay cheap: a 200k-triangle marched surface would otherwise rasterise on every
    // evaluation. Decimating for display is honest as long as the caption reports the real count.
    val shown = if (mesh.triangleCount > 40_000) mesh.decimate(20_000) else mesh
    val png = shown.renderPng(
        MeshView(
            width = MESH_THUMBNAIL_WIDTH,
            height = MESH_THUMBNAIL_HEIGHT,
            supersample = 2,
            background = MESH_THUMBNAIL_BACKGROUND,
        ),
        caption = "", title = null,
    )
    val b64 = Base64.getEncoder().encodeToString(png)
    val b = mesh.boundsArray()
    val topo = mesh.surfaceTopology()

    val rows = ArrayList<Pair<String, String>>()
    rows += "size" to "${mesh.vertexCount} verts · ${mesh.triangleCount} tris" +
            (if (topo.componentCount > 1) " · ${topo.componentCount} parts" else "")
    rows += "closed" to when {
        topo.isWatertight -> "watertight"
        topo.isManifold -> "open · ${topo.boundaryLoops.size} boundary loop(s)"
        else -> "not manifold · ${topo.boundaryLoops.size} boundary loop(s)"
    }
    rows += "topology" to buildString {
        append("χ=${topo.eulerCharacteristic}")
        topo.genus?.let { append(" · genus $it") }
    }
    rows += "area" to "%.4f".format(mesh.surfaceArea())
    if (topo.isWatertight) rows += "volume" to "%.4f".format(mesh.signedVolume())
    rows += "bounds" to "%.3f × %.3f × %.3f".format(b[3] - b[0], b[4] - b[1], b[5] - b[2])
    rows += "at" to "(%.2f, %.2f, %.2f)…(%.2f, %.2f, %.2f)".format(b[0], b[1], b[2], b[3], b[4], b[5])
    if (shown !== mesh) rows += "note" to "picture decimated to ${shown.triangleCount} tris"

    // The thumbnail beside the summary, as a `:::row` card rather than a hand-rolled flex div. The
    // <img> stays raw HTML: a base64 render is data, which is what passthrough is for.
    val summary = facts(rows)
    return FieldMarkdown.card(
        FieldMarkdown.row(
            listOf(
                """<img src="data:image/png;base64,$b64" width="$MESH_THUMBNAIL_WIDTH" """ +
                    """height="$MESH_THUMBNAIL_HEIGHT" style="border-radius:3px;display:block" """ +
                    """alt="shaded render of a ${mesh.triangleCount}-triangle mesh"/>""",
                summary.toHTML(),
            )
        )
    )
}

Box output

enum class MeshRenderer

Which renderer the showMesh family draws with.

@see MeshRenderer.GPU for why the default moved.

enum class MeshRenderer {

The Three.js viewer — orbitable, and the same renderer afield.geometry.path.FLines use.

The default since the two 3D outputs were unified. A mesh you can turn answers questions a fixed viewpoint cannot, and a mesh drawn here can share a scene with its FLine annotations.

    GPU,

The CPU z-buffer rasterizer in VisionGeometryPreview — a static PNG at a fixed camera.

Still the right tool, and not deprecated:

  • it is deterministic, so it is what the pixel-exact proofs assert on (MeshDefectRenderTest, SolidSLRenderProofTest, MeshIoTest, MeshFromDiskTest, AssimpSceneBridgeTest);
  • it needs no WebGL context, so it is what TriMesh.toHTML's inline value-visualizer thumbnail uses — an iframe per mesh in a list of values is a bad trade;
  • it renders into a pixel buffer, which is what you want when compositing several meshes into one figure by hand (TriMesh.renderPixels).
    CPU,
}

fun Box.showMesh

Push a rendered view of mesh to one of this box's output channels, with its measurements as a caption.

Draws in the Three.js viewer by default, so the result can be orbited; pass renderer = MeshRenderer.CPU for the static rasterized figure this used to produce. Both paths are AWT-free and safe inside a live box.

@param height iframe height in pixels on the Three.js path. Left out, it is view's height when a view was given and 430 otherwise, so a figure comes out the same shape on either renderer. Ignored by MeshRenderer.CPU, which takes its size from view's width and height.

fun Box.showMesh(
    mesh: TriMesh,
    channel: String = "main",
    view: MeshView = MeshView(),
    caption: String? = null,
    title: String? = null,
    renderer: MeshRenderer = MeshRenderer.GPU,
    height: Int? = null,
) {
    when (renderer) {
        MeshRenderer.CPU -> display(channel, mesh.toFigureHtml(view, caption, title))
        MeshRenderer.GPU -> three(
            subject = mesh,
            channel = channel,
            name = "mesh-$channel",
            height = height ?: if (view == MeshView()) 430 else view.height,
            view = view,
            title = title,
            caption = caption,
        )
    }
}

fun Box.showMeshGrid

Several meshes side by side, each rendered and captioned — the fastest way to see a whole pipeline (generated / subdivided / smoothed / decimated) side by side.

Each panel's label can be left off: showMeshGrid(cube, ball, columns = 3) labels them "cube" and "ball" from the source (LabelsArgumentsFromSource); "a torus" to ring still sets one by hand.

@param columns meshes per row. @param facts print each mesh's fact line (counts, extent, χ, genus, volume) under its panel, as showMesh does. On by default for the same reason. @param sharedScale frame every panel at the scale of the largest mesh, so sizes can be compared by eye; by default each panel zoom-to-fits its own mesh. Both apply to the GPU renderer; the CPU one always prints the facts and frames each panel itself.

body — 41 lines, from line 681
@LabelsArgumentsFromSource
fun Box.showMeshGrid(
    vararg panels: Pair<String, TriMesh>,
    channel: String = "main",
    columns: Int = 2,
    cellWidth: Int = 380,
    view: MeshView = MeshView(),
    renderer: MeshRenderer = MeshRenderer.GPU,
    facts: Boolean = true,
    sharedScale: Boolean = false,
) {
    require(panels.isNotEmpty()) { "showMeshGrid needs at least one panel" }
    if (renderer == MeshRenderer.GPU) {
        val rows = (panels.size + columns - 1) / columns
        threeGrid(
            panels = panels,
            channel = channel,
            name = "grid-$channel",
            // One WebGL context tiled into cells, so the height is the whole sheet's rather than one
            // cell's. N iframes would be N contexts, and Chrome evicts the oldest past ~16.
            height = (rows * cellWidth * 0.75).toInt().coerceAtLeast(200),
            columns = columns,
            view = view,
            facts = facts,
            cellWidth = cellWidth,
            sharedScale = sharedScale,
        )
        return
    }
    val cell = view.copy(width = cellWidth, height = (cellWidth * 0.75).toInt())
    val html = buildString {
        append("<div style='display:grid;grid-template-columns:repeat($columns,minmax(0,1fr));gap:10px'>")
        for ((label, mesh) in panels) {
            append("<div>")
            append(mesh.toFigureHtml(cell, caption = null, title = label))
            append("</div>")
        }
        append("</div>")
    }
    display(channel, html)
}

fun Box.showMeshTurntable

A turntable contact sheet of one mesh: the same shape from views, laid out in a row.

Four viewpoints is usually the difference between "that looks plausible" and "yes, that is the shape" — a silhouette can hide an inside-out face or a missing cap from any single angle.

fun Box.showMeshTurntable(
    mesh: TriMesh,
    channel: String = "main",
    views: List<MeshView> = MeshView.turntable(4),
    cellWidth: Int = 260,
    title: String? = null,
    renderer: MeshRenderer = MeshRenderer.GPU,
) {
    require(views.isNotEmpty()) { "showMeshTurntable needs at least one view" }
    if (renderer == MeshRenderer.GPU) {
        threeTurntable(
            mesh = mesh,
            channel = channel,
            name = "turntable-$channel",
            height = (cellWidth * 0.75).toInt().coerceAtLeast(180),
            views = views,
            columns = views.size,
            title = title,
            wireframe = views.first().wireframe,
        )
        return
    }
    val html = buildString {
        title?.let { append("<div style='color:#e6ecf4;font-weight:600;padding-bottom:6px'>$it</div>") }
        append("<div style='display:flex;gap:8px;flex-wrap:wrap'>")
        for (v in views) {
            val png = mesh.renderPng(v.copy(width = cellWidth, height = (cellWidth * 0.75).toInt()), caption = "", title = null)
            val b64 = Base64.getEncoder().encodeToString(png)
            append("<div><img src='data:image/png;base64,$b64' style='border-radius:4px;display:block'/>")
            append("<div style='color:#8a90a0;font-family:monospace;font-size:11px;padding-top:3px'>")
            append("yaw ${v.yawDegrees.toInt()}° pitch ${v.pitchDegrees.toInt()}°</div></div>")
        }
        append("</div>")
    }
    display(channel, html)
}

fun TriMesh.summary(): String

A one-line factual summary of a mesh: counts, extent, topology and volume — the thing to print when you want the numbers rather than the picture.

fun TriMesh.summary(): String {
    if (triangleCount == 0) return "TriMesh(empty)"
    val topo = surfaceTopology()
    val b = boundsArray()
    val q = quality()
    return buildString {
        append("$vertexCount verts, $triangleCount tris")
        append(", extent %.4f x %.4f x %.4f".format(b[3] - b[0], b[4] - b[1], b[5] - b[2]))
        append(", chi=${topo.eulerCharacteristic}")
        topo.genus?.let { append(", genus $it") }
        append(if (topo.isWatertight) ", watertight" else ", open(${topo.boundaryLoops.size})")
        append(", area %.4f".format(surfaceArea()))
        if (topo.isWatertight) append(", volume %.4f".format(signedVolume()))
        append(", minAngle %.1f deg".format(q.minAngleDegrees))
    }
}

fun TriMesh.longestEdge(): Double

Longest edge in the mesh — a useful scale for choosing a weld tolerance or a march resolution.

fun TriMesh.longestEdge(): Double {
    var worst = 0.0
    for (t in 0 until triangleCount) {
        val a = position(ia(t)); val b = position(ib(t)); val c = position(ic(t))
        worst = max(worst, max(a.distance(b).toDouble(), max(b.distance(c).toDouble(), c.distance(a).toDouble())))
    }
    return worst
}

fun TriMesh.meanEdgeLength(): Double

Mean edge length over the mesh's unique edges.

fun TriMesh.meanEdgeLength(): Double {
    val topo = surfaceTopology()
    if (topo.edgeCount == 0) return 0.0
    var sum = 0.0
    for ((a, b) in topo.edges) sum += position(a).distance(position(b)).toDouble()
    return sum / topo.edgeCount
}

fun TriMesh.normalized(size: Number = 1.0): TriMesh

This mesh recentred on its own bounding box and scaled so its longest axis is size.

fun TriMesh.normalized(size: Number = 1.0): TriMesh {
    if (vertexCount == 0) return this
    val b = boundsArray()
    val cx = (b[0] + b[3]) / 2; val cy = (b[1] + b[4]) / 2; val cz = (b[2] + b[5]) / 2
    val longest = max(b[3] - b[0], max(b[4] - b[1], b[5] - b[2]))
    val s = if (longest < 1e-12) 1.0 else size.toDouble() / longest
    return translated(-cx, -cy, -cz).scaled(s)
}

Seeing what is wrong: the diagnosis, drawn

fun TriMesh.defectFLines

The defects TriMesh.diagnose found, as coloured FLines to draw over the mesh.

Reading a count of "2782 boundary edges" tells you the mesh is broken; seeing where they are tells you what is broken — a missing cap reads completely differently from a seam that failed to weld, and no number distinguishes them. MeshLines.BOUNDARY already drew open loops; this adds the rest of the vocabulary and colours each kind so a glance separates them:

colour what
red an open boundary loop — a hole
orange a non-manifold edge: three or more faces meet here
yellow an interior edge whose two faces disagree about which way is out
magenta a figure-eight loop: two holes pinched together at one vertex
val mesh = meshFromAnyFile("scan.stl")
__.lines = mesh.toFLines(MeshLines.CREASES) + mesh.defectFLines()

@param diagnosis reuse one you already built; a fresh one is made if you do not pass it. @param includeWinding draw the winding disagreements too. Off by default: on a badly wound mesh there is one per interior edge and the picture becomes the wireframe.

fun TriMesh.defectFLines(
    diagnosis: MeshDiagnosis = diagnose(),
    includeWinding: Boolean = false,
): List<FLine> {
    val out = mutableListOf<FLine>()

    fun paint(line: FLine, r: Float, g: Float, b: Float): FLine {
        line.properties["color"] = Vec4(r, g, b, 1f)
        return line
    }

    for (loop in diagnosis.boundaryLoops) {
        val ring = polylineFrom(loop.vertices.map { position(it) }, closed = true)
        // A pinched loop is worth its own colour: it is the one a hole filler has to take apart first.
        if (loop.isSimple) paint(ring, 1f, 0.25f, 0.25f) else paint(ring, 1f, 0.2f, 0.9f)
        out.add(ring)
    }
    for ((u, v, _) in diagnosis.nonManifoldEdges) {
        out.add(paint(polylineFrom(listOf(position(u), position(v))), 1f, 0.6f, 0.1f))
    }
    if (includeWinding) {
        for ((u, v) in diagnosis.inconsistentEdges) {
            out.add(paint(polylineFrom(listOf(position(u), position(v))), 1f, 0.95f, 0.3f))
        }
    }
    return out
}

fun TriMesh.withDefectColors(diagnosis: MeshDiagnosis = diagnose()): TriMesh

This mesh painted by defect, so its rendered picture shows where the trouble is.

defectFLines is the right answer when the mesh is going into a Field document beside other drawing; this is the right answer when it is going through TriMesh.renderPng, which draws a mesh and not lines. Vertices on an open boundary go red, endpoints of a non-manifold edge orange, pinch vertices yellow, and everything sound stays a neutral blue-grey.

Any existing per-vertex colours are replaced — that is the point of asking for this one.

fun TriMesh.withDefectColors(diagnosis: MeshDiagnosis = diagnose()): TriMesh {
    val c = FloatArray(vertexCount * 3)
    for (v in 0 until vertexCount) {
        c[v * 3] = 0.42f; c[v * 3 + 1] = 0.48f; c[v * 3 + 2] = 0.58f
    }
    fun mark(v: Int, r: Float, g: Float, b: Float) {
        if (v < 0 || v >= vertexCount) return
        c[v * 3] = r; c[v * 3 + 1] = g; c[v * 3 + 2] = b
    }
    for (v in diagnosis.nonManifoldVertices) mark(v, 0.95f, 0.9f, 0.2f)
    for ((u, v, _) in diagnosis.nonManifoldEdges) { mark(u, 1f, 0.55f, 0.1f); mark(v, 1f, 0.55f, 0.1f) }
    for (loop in diagnosis.boundaryLoops) for (v in loop.vertices) mark(v, 1f, 0.2f, 0.2f)
    return withColors(c)
}

fun Box.showMeshDiagnosis

The diagnosis as a card beside the mesh: the shape with its defects painted on, and the ordered list of what is wrong and what to call about it.

This is TriMesh.diagnose made lookable-at. The render uses withDefectColors, so the red on the picture and the "N boundary edges" in the list are the same fact seen twice — which is the whole argument for a card over a println.

showMeshDiagnosis(meshFromAnyFile("scan.stl"))

The camera points at the defect, and that is not a nicety

Leaving view as null aims the camera at the worst defect — see defectFacingView. The default three-quarter view shows a hole only if the hole happens to face the front, and a card captioned "4 faces removed" over a picture of an unblemished sphere is worse than no picture: it actively argues that nothing is wrong. Pass an explicit MeshView when you want a particular angle and are willing to check that the defect is in it.

@param mesh the mesh to diagnose. @param view the camera. null (the default) points it at the worst defect. @param maxDefects how many rows of the list to show.

body — 53 lines, from line 921
fun Box.showMeshDiagnosis(
    mesh: TriMesh,
    channel: String = "main",
    view: MeshView? = null,
    maxDefects: Int = 8,
    title: String? = null,
) {
    val diagnosis = mesh.diagnose()
    val camera = view ?: mesh.defectFacingView(diagnosis, width = 420, height = 340)
    val png = mesh.withDefectColors(diagnosis).renderPng(camera, caption = "", title = null)
    val b64 = Base64.getEncoder().encodeToString(png)
    fun escape(s: String) = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
    val badgeColor = { s: MeshDefectSeverity ->
        when (s) {
            MeshDefectSeverity.BLOCKING -> "#ff5555"
            MeshDefectSeverity.SUSPICIOUS -> "#ffa640"
            MeshDefectSeverity.INFORMATIONAL -> "#7f8ea3"
        }
    }
    val html = buildString {
        append("<div style='display:flex;gap:14px;align-items:flex-start;flex-wrap:wrap'>")
        append("<div>")
        title?.let { append("<div style='color:#e6ecf4;font-weight:600;padding-bottom:6px'>${escape(it)}</div>") }
        append("<img src='data:image/png;base64,$b64' style='border-radius:6px;display:block'/>")
        append("<div style='color:#8a90a0;font-family:monospace;font-size:11px;padding-top:4px'>")
        append("red = open boundary · orange = non-manifold edge · yellow = pinch vertex")
        append("</div></div>")
        append("<div style='min-width:320px;flex:1'>")
        append("<div style='color:#e6ecf4;font-family:monospace;font-size:12px;padding-bottom:6px'>")
        append("${mesh.vertexCount} verts · ${mesh.triangleCount} tris · ")
        append(if (diagnosis.isSolid) "<span style='color:#69d07f'>a solid</span>"
            else "<span style='color:#ff5555'>${diagnosis.blockers.size} blocking</span>")
        append("</div>")
        if (diagnosis.defects.isEmpty()) {
            append("<div style='color:#93a4ba;font-size:12px'>No defects — closed, consistently wound, outward.</div>")
        }
        for (d in diagnosis.defects.take(maxDefects)) {
            append("<div style='padding:5px 0;border-top:1px solid #23272f'>")
            append("<div><span style='color:${badgeColor(d.severity)};font-family:monospace;font-size:11px'>")
            append("${d.severity}</span> ")
            append("<span style='color:#e6ecf4;font-family:monospace;font-size:12px'>${d.kind} ×${d.count}</span></div>")
            append("<div style='color:#93a4ba;font-size:11px;line-height:1.45'>${escape(d.detail)}</div>")
            append("<div style='color:#6f9fd0;font-size:11px;line-height:1.45'>fix: ${escape(d.fix)}</div>")
            append("</div>")
        }
        if (diagnosis.defects.size > maxDefects) {
            append("<div style='color:#6f7787;font-size:11px;padding-top:4px'>")
            append("… and ${diagnosis.defects.size - maxDefects} more</div>")
        }
        append("</div></div>")
    }
    display(channel, html)
}

fun TriMesh.defectFacingView

A camera pointed at this mesh's worst defect, so the picture shows the thing the caption is about.

The focus is the first open boundary loop's centroid, or failing that a non-manifold edge, or a pinch vertex; with none of those the plain three-quarter view comes back unchanged. Yaw and pitch are then the direction from the mesh's own centre out to that focus, so the defect faces the viewer rather than hiding round the back.

Written because two captures during this feature's development showed a perfect-looking model and asserted nothing about it — once a non-manifold fin on the far side of a sphere, once a hole. A defect visualisation whose default view can omit the defect is a visualisation you cannot trust, and "the reader will rotate it" is not true of a PNG in a document.

@param diagnosis reuse one you already built. @param width output width in pixels. @param height output height in pixels.

fun TriMesh.defectFacingView(
    diagnosis: MeshDiagnosis = diagnose(),
    width: Int = 420,
    height: Int = 340,
): MeshView {
    val plain = MeshView(width = width, height = height)
    if (vertexCount == 0) return plain

    val focus: Vec3 = when {
        diagnosis.boundaryLoops.isNotEmpty() -> {
            val ring = diagnosis.boundaryLoops.first().vertices
            var cx = 0f; var cy = 0f; var cz = 0f
            for (v in ring) { cx += x(v); cy += y(v); cz += z(v) }
            Vec3(cx / ring.size, cy / ring.size, cz / ring.size)
        }
        diagnosis.nonManifoldEdges.isNotEmpty() -> {
            val (u, v, _) = diagnosis.nonManifoldEdges.first()
            Vec3((x(u) + x(v)) / 2f, (y(u) + y(v)) / 2f, (z(u) + z(v)) / 2f)
        }
        diagnosis.nonManifoldVertices.isNotEmpty() -> position(diagnosis.nonManifoldVertices.first())
        else -> return plain
    }

    val b = boundsArray()
    val dx = focus.x - (b[0] + b[3]) / 2.0
    val dy = focus.y - (b[1] + b[4]) / 2.0
    val dz = focus.z - (b[2] + b[5]) / 2.0
    val len = kotlin.math.sqrt(dx * dx + dy * dy + dz * dz)
    if (len < 1e-9) return plain
    return plain.copy(
        yawDegrees = Math.toDegrees(kotlin.math.atan2(dx, dz)),
        pitchDegrees = Math.toDegrees(kotlin.math.asin((dy / len).coerceIn(-1.0, 1.0))),
    )
}