Skip to content

Simulation

Making a shape behave. Three stacks live here and they answer different questions.

afield.simulation.physics is the one that moves. It is a CPU solver you step inside your own loop { }, at frame rate, with a person's hand on it: SimdVerlet holds some thousands of particles in flat float arrays and pulls them together with distance constraints, and on top of that sit Cloth, Strand, Wind, tearing and a collider set. JoltPhysicsWorld is the rigid-body half — boxes, spheres, meshes, joints and character controllers over the Jolt engine — and Dyn4jScene is the same idea flat, in 2D. Nothing here owns a renderer: every one of them hands back FLines and where those go is your business. Nothing here needs a GPU either. It is fast and it is approximate, and the number it gives you is not a number you would put in a report.

afield.simulation.kratos is the one that is right. It is finite elements, out of process: KratosSession starts a Python worker — a local venv, a container, or one already running — and talks to it over an authenticated socket, so nothing in Field's own startup imports a solver and a machine without the optional environment simply says so. A solve is seconds, not milliseconds, which is why the Kratos examples put it behind a button. What you get back is a SimulationResult, which describes its own fields rather than declaring them, so one class carries a scalar temperature and a velocity–pressure pair without knowing about either. The layer above it is the reason to care: a StudySpace varies the design, a measure reduces each answer to one number, and KratosStudy reports the landscape and where its optimum lies. Because the embedded formulations reclassify one fixed background grid instead of remeshing, geometry is an ordinary study axis — sweeping an arm length costs what sweeping a conductivity costs. KratosStudyFigure draws the result as pixels a box can display.

afield.geometry.mesh.tetgen is the one in between. Kratos has no embedded structural element, so stress needs a mesh whose boundary really is the part's surface, sharp edges included. Tetgen.tetrahedralize takes a closed triangle surface — a Manifold solid, usually — and fills it with tetrahedra that reproduce it exactly, optionally quadratic, in Kratos's own node order. TetMeshViews is how you look at the inside of the result rather than at its skin.

Where the neighbours are. Solvers is Ceres, which finds the shape you want by minimising residuals you declare; simulation tells you what that shape then does. A gridshell relaxed in Solvers and a bracket solved in Kratos are two halves of the same design loop, not two ways of doing one thing. Everything above returns geometry, so Graphics is where it gets drawn.

The prose, with pictures: Physics: particles, constraints and cloth for the real-time stack — read it before touching stiffness, because it and iterations are not two knobs — and Filling a solid: tetrahedral meshing with TetGen for the meshing and the route into Kratos. Four loadable Kratos documents (thermal, flow, structural and the parameter study) and the cloth starting point are in the Agent Transcript Gallery; Frames and the Animation Loop explains why an integrator wants a fixed dt. dev_docs/TETGEN.md carries the mesher comparison that chose this route.

48 source file(s), 1472 public symbol(s), 832 of them carrying KDoc. Everything below is the documentation that lives beside the code — the same text the completion documentation panel shows in the editor. A blank summary means the declaration has no KDoc yet, not that this page dropped it.

Score.kt

The canvas as a score: boxes are the events, a playhead box sweeps across them, and each box's score { } runs every frame the playhead is over it.

A box arms itself with score; the lead box starts the clock with playScore, which moves the box named playhead across a horizontal span of the canvas once per loop seconds and calls every armed box whose frame the playhead crosses. Inside score { } the receiver is a ScoreFrame: how far through the box the playhead is (ScoreFrame.localProgress, ScoreFrame.localT), whether it just arrived (ScoreFrame.justEntered), a ScoreFrame.draw list that the lead box's output panel shows as a canvas, dough sound (ScoreFrame.sound) and the box's measured level (ScoreFrame.thisRMS), and the box's own frame (ScoreFrame.box), which it may move.

This is Field's time slider (templates/timeSlider: a line box that starts the boxes it passes over) with a clock and per-frame calls instead of launches, so a box's code is compiled once, when it is run, rather than every time the playhead reaches it. The clock is AnimationClock, so a video recording of a score plays at the right speed.

src/jvmMain/kotlin/afield/animation/motion/Score.kt — read the source

Top-level functions

Declaration Summary
fun Box.score(body: ScoreFrame.() -> Unit): ScoreBehaviour Arm this box: body runs every frame that a running playScore has its playhead over this box's frame.
fun Box.playScore(loop: Double = 4.0, x: ClosedFloatingPointRange<Double> = 0.0..1600.0, y: ClosedFloatingPointRange<Double> = 0.0..600.0, width: Int = 520, height: Int = 360, playhead: String? = "playhead", channel: String = "score", tint: Boolean = true): Frames Start the score: sweep the box named playhead across x once every loop seconds, call every armed box (score) it crosses, and show what they draw in this box's output channel.

ScoreBehaviour

class ScoreBehaviour internal constructor(val box: Box, internal val body: ScoreFrame.() -> Unit)

What score arms: body, called with a fresh ScoreFrame each frame the playhead is over box.

Declaration Summary
val isArmed: Boolean False once the box has been stopped or has armed a newer behaviour.
override fun toString()

ScoreTransport

class ScoreTransport internal constructor(val owner: Box, var loopDuration: Double, val x: ClosedFloatingPointRange<Double>, val y: ClosedFloatingPointRange<Double>, val width: Int, val height: Int, private val playheadName: String?, val tintActive: Boolean = true)

The clock of a running score: where the playhead is, and the span and loop it sweeps.

Declaration Summary
val owner: Box
var loopDuration: Double
val x: ClosedFloatingPointRange<Double> The canvas x span the playhead crosses once per loop.
val y: ClosedFloatingPointRange<Double> The canvas y span the output panel shows, and the height the playhead box covers.
val width: Int Output canvas width in pixels.
val height: Int Output canvas height in pixels. x and y scale separately, as in a score whose time axis is long.
val tintActive: Boolean Whether a box is tinted (Box.tint) while the playhead is over it.
var paused Hold the clock.
var transportT Seconds of score played, across loops.
var frame Frames stepped.
var playheadX The playhead's canvas x.
override fun toString()

ScoreFrame

class ScoreFrame internal constructor(private val transport: ScoreTransport, private val behaviour: ScoreBehaviour, val draw: ScoreDraw, val t: Double, val dt: Double, val transportT: Double, val loopIndex: Long, val bounds: Rect, val localProgress: Double, val boxDuration: Double, val justEntered: Boolean, val justExited: Boolean)

One frame of one armed box: the receiver of score { }.

Declaration Summary
val draw: ScoreDraw The draw list every box under the playhead adds to this frame.
val t: Double Seconds into the current loop.
val dt: Double Seconds since the last frame, at most 0.05.
val transportT: Double Seconds played, across loops.
val loopIndex: Long Which loop this is, from 0.
val bounds: Rect This box's frame, in canvas coordinates.
val localProgress: Double How far across this box the playhead is, 0 at its left edge and 1 at its right.
val boxDuration: Double Seconds the playhead takes to cross this box.
val justEntered: Boolean True on the first frame the playhead is over this box.
val justExited: Boolean True on the one extra call made the frame after the playhead leaves this box.
val localT: Double Seconds since the playhead reached this box: localProgress times boxDuration.
val loopDuration: Double Length of a loop, in seconds.
val progress: Double How far through the loop the playhead is, 0 to 1.
val frame: Int Frames the score has stepped.
val playheadX: Double The playhead's canvas x.
val scoreLeft: Double The score's canvas span.
val scoreTop: Double
val scoreRight: Double
val scoreBottom: Double
val scoreWidth: Double
val scoreHeight: Double
val width: Double The output canvas, in pixels.
val height: Double
val scaleX: Double Output pixels per canvas unit.
val scaleY: Double
val left: Double
val top: Double
val right: Double
val bottom: Double
val cx: Double
val cy: Double
fun fromScore(x: Double, y: Double) The output-canvas point for canvas coordinates (x, y).
fun boxPoint(u: Double, v: Double) The output-canvas point at (u, v) across this box, 0..1 each way, (0, 0) its top left.
fun pt(x: Double, y: Double) An output-canvas point, for ScoreDraw.polyline.
val thisRMS: Double This box's measured dough level: afield.audio.synth.thisRMS.
fun sound(path: String, key: String = path, minInterval: Double = 0.125, force: Boolean = false) Play a dough path from this box; afield.audio.synth.sound.
fun soundNow(path: String) Play a dough path from this box regardless of the last time it played.
fun onSubdivision(divisions: Int, key: String = "div:$divisions"): Boolean True once each time the playhead crosses into another of divisions equal slices of this box, the first slice included: a pulse of divisions per pass. Keep a separate count per key.
val box: ScoreBox This box's frame on the canvas, which the box may move while it plays.
fun clamp(v: Double, lo: Double, hi: Double)
fun lerp(a: Double, b: Double, t: Double)
fun triWave(x: Double): Double A triangle wave of period 1 between -1 and 1.
fun tri01(x: Double) A triangle wave of period 1 between 0 and 1, 0 at whole numbers.
fun rand(lo: Double, hi: Double) A uniformly random double in [lo, hi).
fun randInt(lo: Int, hi: Int) A uniformly random int in lo..hi.

ScorePoint

data class ScorePoint(val x: Double, val y: Double)

A point on the output canvas, in pixels: what ScoreFrame.boxPoint and ScoreFrame.fromScore return.

ScoreBox

class ScoreBox internal constructor(private val box: Box)

A box's canvas frame, as score { } sees it: read it, set it, move it, resize it.

Declaration Summary
fun get(): Rect
fun set(x: Double = box.frame.x, y: Double = box.frame.y, w: Double = box.frame.w, h: Double = box.frame.h)
fun set(r: Rect)
fun move(dx: Double, dy: Double)
fun resize(dw: Double, dh: Double)

ScoreDraw

class ScoreDraw internal constructor()

The shapes the boxes under the playhead draw this frame, in output-canvas pixels.

Declaration Summary
val size: Int Shapes drawn so far this frame.
fun line(x0: Double, y0: Double, x1: Double, y1: Double, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0)
fun line(a: ScorePoint, b: ScorePoint, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0)
fun circle(x: Double, y: Double, r: Double, fill: String? = null, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0)
fun circle(p: ScorePoint, r: Double, fill: String? = null, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0)
fun ring(x: Double, y: Double, r: Double, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0)
fun ring(p: ScorePoint, r: Double, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0)
fun rect(x: Double, y: Double, w: Double, h: Double, fill: String? = null, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0)
fun polyline(points: List<ScorePoint>, fill: String? = null, stroke: String? = null, color: String? = null, width: Double = 1.0, alpha: Double = 1.0, closed: Boolean = false)

movement.kt

Springs, dampers, and a steerable character built out of four of them.

Two layers, and the lower one is useful alone. Springs holds the primitives as one-line functions over Float and Vector2f: exponential dampers, critically damped springs, an angle spring that takes the short way round, and the decay that hides a jump. MovementState stacks four of those into a character with a position, a velocity and a facing, driven by a desired velocity and tuned through MovementParams.

Half-life is the parameter throughout: the time a spring takes to close half the distance to its goal. That is what makes these tunable by eye, rather than by bisecting a stiffness against a damping ratio. Every step is the analytic solution of the spring's equation rather than a numerical integration, so one step of dt and ten steps of dt / 10 land in the same place and the motion does not change with frame rate.

All of it is plain arithmetic on the CPU, with no renderer and no solver underneath. Both layers follow Daniel Holden's "Spring-It-On" and "A New Movement Model".

src/jvmMain/kotlin/afield/animation/motion/movement.kt — read the source

Used in: Springs, dampers, and a character with mass

Springs

object Springs

Critically damped springs and dampers — the pieces MovementState is built from, and worth having on their own for anything that should arrive at a value without a hard edge.

Declaration Summary
const val LN2: Float Natural log of two: what turns a half-life into a decay rate.
data class Step(val x: Float, val v: Float) x and v after a spring step — position and velocity, or whatever pair is being sprung.
data class Step2(val x: Vector2f, val v: Vector2f) Two-dimensional Step: each component is sprung on its own.
data class DoubleStep(val x: Float, val v: Float, val intermediateX: Float, val intermediateV: Float) A Step for a double spring, which carries an intermediate spring along with the visible one.
fun halflifeToDamping(halflife: Float, eps: Float = 1e-5f): Float The damping coefficient that gives a critically damped spring this halflife.
fun dampingToHalflife(damping: Float, eps: Float = 1e-5f): Float The half-life of a critically damped spring with this damping coefficient.
fun halflifeToLag(halflife: Float): Float How far behind a constant-speed goal a damper of this halflife ends up trailing, in seconds.
fun lagToHalflife(lag: Float): Float The half-life whose trailing lag is lag seconds — the inverse of halflifeToLag.
fun damper(x: Float, goal: Float, halflife: Float, dt: Float, eps: Float = 1e-5f): Float Moves x a fraction of the way to goal, closing half the remaining distance every halflife.
fun damper(x: Vector2fc, goal: Vector2fc, halflife: Float, dt: Float, eps: Float = 1e-5f): Vector2f damper on both components of a vector.
fun spring(x: Float, v: Float, goal: Float, halflife: Float, dt: Float): Step Steps a critically damped spring at x moving at v toward goal.
fun spring(x: Vector2fc, v: Vector2fc, goal: Vector2fc, halflife: Float, dt: Float): Step2 spring on both components of a vector, each on its own curve.
fun springAngle(x: Float, v: Float, goal: Float, halflife: Float, dt: Float): Step spring on an angle in radians, taking the short way round.
fun doubleSpringAngle(x: Float, v: Float, intermediateX: Float, intermediateV: Float, goal: Float, halflife: Float, dt: Float): DoubleStep Two springAngles in series, each on half of halflife — the visible one chasing an intermediate one that chases goal.
fun decay(x: Float, v: Float, halflife: Float, dt: Float): Step Decays x and v toward zero with the shape of a critically damped spring.
fun wrapAngle(a: Float): Float a wrapped into (-π, π].

MovementParams

data class MovementParams(val acceleration: Float = 800f, val deceleration: Float = 1400f, val directionalAcceleration: Float = 0f, val turnStrength: Float = 1f, val accelerationHalflife: Float = 0.1f, val decelerationHalflife: Float = 0.05f, val accelerationCompensation: Float = 1f, val decelerationCompensation: Float = 0f, val rotationHalflife: Float = 0.15f, val rotationDoubleSpring: Boolean = true)

How a character's movement responds to input — the dials on MovementState.update.

Declaration Summary
val acceleration: Float
val deceleration: Float
val directionalAcceleration: Float
val turnStrength: Float
val accelerationHalflife: Float
val decelerationHalflife: Float
val accelerationCompensation: Float
val decelerationCompensation: Float
val rotationHalflife: Float
val rotationDoubleSpring: Boolean

MovementState

class MovementState

A character's position, velocity and facing, moved by a desired velocity — a game movement model that stays responsive while still looking like it has mass.

Declaration Summary
var position: Vector2f Where the character is, integrated from velocity.
var velocity: Vector2f The smoothed velocity — the one to animate from.
var acceleration: Vector2f velocity's own rate of change, carried by the smoothing spring.
var immediateVelocity: Vector2f The clamped but unsmoothed velocity that velocity is chasing.
var rotation: Float Facing, in radians. Sprung separately, and not what decides where the character goes.
var angularVelocity: Float rotation's rate of change, carried by the facing spring.
var immediateRotation: Float The intermediate facing, used only when rotationDoubleSpring is on.
var immediateAngularVelocity: Float The intermediate facing's rate of change, used only when rotationDoubleSpring is on.
val speed: Float How fast the character is actually moving.
val heading: Vector2f The unit vector rotation is pointing along.
fun update(desiredVelocity: Vector2fc, dt: Float, params: MovementParams = MovementParams.DEFAULT, desiredRotation: Float = if (desiredVelocity.lengthSquared() > 1e-16f) atan2(desiredVelocity.y(), desiredVelocity.x()) else rotation) Advances the character by dt seconds toward desiredVelocity.
fun placeAt(position: Vector2fc, facing: Float = rotation) Teleports the character, leaving it at rest and facing facing.

TetMeshViews.kt

Ways of looking at a TetMesh: its element quality, a slice of it, and the triangles that draw it.

A tetrahedral mesh has an inside, which is the whole point of it and also the reason it is awkward to look at. Rendering one naively draws the boundary and hides everything the mesher actually did. The three answers here are the three that get used: boundary for the outside, filtered with a half-space predicate for a cutaway, and toTriMesh with shrink below 1 for a shrunken-element view where each tetrahedron is visibly its own cell.

The two quality measures are kept apart because they answer different questions, and they are easily confused:

  • radiusEdgeRatios is what TetGen's -q switch bounds, and therefore the number to check when you want to know whether the mesher honoured what you asked for.
  • qualities is the radius ratio, which is what a solver's conditioning cares about. TetGen does not bound it, and it is the number that detects a sliver.

All of these read a cell's first four indices and ignore the rest, so a 10-node mesh from TetMesh.quadratic measures and draws exactly as its 4-node original does. The six midside nodes sit at the midpoints of straight edges, so the region, the boundary and every quality measure are the same. What the extra nodes change is the interpolation inside an element, which none of these views is looking at.

src/jvmMain/kotlin/afield/geometry/mesh/tetgen/TetMeshViews.kt — read the source

Used in: Filling a solid: tetrahedral meshing with TetGen

Top-level functions

Declaration Summary
fun TetMesh.qualities(): DoubleArray Every tetrahedron's radius ratio, 3 r_in / r_circ — 1.0 for a regular tetrahedron, 0 for a flat one.
fun TetMesh.radiusEdgeRatios(): DoubleArray Every tetrahedron's radius-edge ratio, r_circ / shortest edge — the quantity TetGen's -q switch, and so Tetgen.tetrahedralize's quality, actually bounds.
fun TetMesh.centroids(): DoubleArray Every tetrahedron's centroid, as xyz triples in the same order as TetMesh.cells.
fun TetMesh.filtered(keep: (Int) -> Boolean): TetMesh The tetrahedra for which keep is true, as a mesh in their own right, with the nodes nobody references dropped and the rest renumbered.
fun TetMesh.boundary(): TriMesh The surface of this mesh: the triangles belonging to exactly one tetrahedron, wound outwards.
fun TetMesh.toTriMesh(shrink: Double = 1.0, cellColors: FloatArray? = null): TriMesh Every tetrahedron drawn as its own four-triangle shell, optionally shrunk towards its centroid.

Tetgen.kt

A closed surface turned into tetrahedra, with its sharp edges intact.

This is the discretisation half of the Kratos bridge for body-fitted work. The embedded formulations (thermal.stationary, flow.embedded) never come here: they sample a level set onto a fixed background grid and cut with it. Structural mechanics has no embedded element in Kratos, so it needs a mesh whose boundary really is the part's surface — and, more to the point, whose boundary reproduces the part's edges, because that is where stress concentrates.

Why not the level-set route

dev_docs/TETGEN.md has the measurement in full. The short version, on the same body at the same element size:

MMG, level set TetGen, this
time 4669 ms 223 ms
boundary error, curved surface 0.55 h 0.024 h
boundary error, a box's edges 0.35–0.40 h, at every resolution exactly 0

Sampling an SDF onto grid nodes destroys a sharp edge before the mesher ever sees it, and refining only buys a smaller rounded radius. TetGen is handed the Manifold triangulation itself, whose edges are exact, and constrains its output to it.

Why there is no incremental meshing

A full re-tetrahedralization of a realistic body is ~0.2 s here. Against a Kratos structural solve that is noise, so a geometry edit simply re-meshes. No part of this tries to update a mesh in place.

src/jvmMain/kotlin/afield/geometry/mesh/tetgen/Tetgen.kt — read the source

Used in: Filling a solid: tetrahedral meshing with TetGen

Tetgen

object Tetgen

Entry point; use tetrahedralize to convert a closed afield.geometry.mesh.TriMesh surface into a TetMesh.

Declaration Summary
fun tetrahedralize(surface: TriMesh, quality: Double = 1.2, targetEdgeLength: Double? = null, preserveSurface: Boolean = false, order: Int = 1): TetMesh Tetrahedralize a closed triangle surface — typically Solid.toTriMesh().

TetMesh

class TetMesh(val positions: DoubleArray, val cells: IntArray, val nodesPerCell: Int = 4)

A tetrahedral mesh: positions as xyz triples, cells as groups of nodesPerCell zero-based node indices.

Declaration Summary
val nodeCount: Int
val cellCount: Int
val isQuadratic: Boolean True for 10-node tetrahedra.
fun corners(): TetMesh The same mesh seen as 4-node tetrahedra, dropping any midside nodes from the connectivity.
fun quadratic(): TetMesh The same tetrahedra promoted to 10 nodes, with one new node at the midpoint of every distinct edge. Returns this if it is already quadratic.
fun volumes(): DoubleArray Signed volume of every tetrahedron; all positive for a consistently oriented mesh.
fun volume(): Double Total volume — worth asserting against Solid.volume, which is exact.
override fun toString(): String

TetgenException

class TetgenException(message: String) : RuntimeException(message)

Thrown when TetGen refuses a surface, carrying its own explanation.

TetgenLibrary

object TetgenLibrary

Where libfield_tetgen_jvm is, and whether it is there — the same shape as afield.geometry.solid.manifold.ManifoldLibrary, so "is this installed?" is answerable from a box.

Declaration Summary
val fileName: String
val searchedPaths: List<Path> Every path resolvedPath tries, in order — quoted in the "not built" error.
val resolvedPath: Path? Not cached: building it during a live session should be picked up by the next box run.
val isAvailable: Boolean
val HOW_TO_BUILD: String
fun describe(): String One line for a box or a smoke test to print.

TileResidency.kt

Bookkeeping for a scrolling window of world-aligned tiles: TileResidency maps tile coordinates to reusable toroidal slots and reports which slots changed after a move.

It allocates no GPU resources or threads; callers use its slot arrays to decide what to regenerate.

src/jvmMain/kotlin/afield/graphics/render/TileResidency.kt — read the source

TileResidency

class TileResidency(val columns: Int, val rows: Int, val tileSize: Float)

A bounded, world-aligned rectangle of tiles, stored in reusable toroidal slots. Moving one tile replaces only the entering row/column. Teleporting replaces at most capacity tiles. No resources or threads are created here.

Declaration Summary
val capacity
val x
val y
val generation
val changed
var changedCount
var minX
var minY
var relocations
fun slot(tx: Int, ty: Int): Int
fun resident(tx: Int, ty: Int): Int -1 outside the resident rectangle, even if the modulo slot contains another tile.
fun invalidate() Drop contents at the next move, preserving generation identities.
fun moveTo(px: Double, py: Double): Int
fun pack(out: IntArray, offset: Int = 0): IntArray Four ints per slot: world tile x/y, generation, occupied. No Float precision loss.

alife_vehicles.kt

Braitenberg vehicles whose neurons have internal state: two light sensors, two motor neurons, two wheels, and between them a small dynamical system rather than a wire.

In Braitenberg's book a vehicle's wiring is its personality: crossed excitatory wires make an "aggressor" that speeds into a light, uncrossed inhibitory wires make a "lover" that slows down and parks in front of it. Those vehicles are memoryless — the wheel speed is a function of what the sensors read now. Here each motor neuron carries three kinds of memory on three timescales:

  • membrane (a few steps): a leaky integrator, so the vehicle responds to a smoothed input;
  • hysteresis (state-dependent): self-excitation, which makes a steep activation bistable, so a neuron that switched on at one input level switches off at a lower one;
  • homeostasis (hundreds of steps, NeuronParams.adaptationRate): the threshold integrates the difference between the neuron's rate and a target rate, so a vehicle held in constant light gets used to it — a parked lover eventually drives off;
  • plasticity (thousands of steps, PlasticityParams): the four sensor→motor weights learn from reward-modulated eligibility traces of the motor noise, so a randomly wired population can discover, on its own, that crossed wiring brings it into the light.

Each of these is switched off by one parameter, which is how the measurements on the docs page make their claims: same seed, one ingredient removed, a number that changes.

Mutable, like BoidFlock: VehicleWorld.step advances the population in place.

src/jvmMain/kotlin/afield/simulation/agents/alife_vehicles.kt — read the source

Used in: Braitenberg vehicles with memory, Agent transcript gallery

Top-level functions

Declaration Summary
fun hysteresisSweep(p: NeuronParams, lo: Double = -2.5, hi: Double = 2.0, steps: Int = 2000): HysteresisSweep The memoryless input→output steady state is the wrong question for a bistable neuron, so this measures what the neuron actually does: sweep the input from lo to hi and back over steps steps each way, slowly enough for the membrane to follow, and record the rate.
fun orbitBias(distance: Double, weight: Double = 2.0, light: Light = Light(0.0, 0.0)): Double The NeuronParams.bias that makes an Activation.BUMP neuron fire hardest when a sensor wired with weight reads the brightness distance away from light. Wired crossed and excitatory (VehicleWorld.AGGRESSOR), such a vehicle turns toward the light when it is farther than distance and away from it when nearer, so it circles at about that radius.

Activation

enum class Activation

The nonlinearity a motor neuron applies to its membrane potential. Each maps to roughly 0..1, so the wheel speed reads the same way whichever one is chosen.

Constants: SIGMOID, TANH, RELU, BUMP.

Declaration Summary
fun apply(x: Double): Double Apply this activation to x.

NeuronParams

data class NeuronParams(val activation: Activation = Activation.SIGMOID, val gain: Double = 6.0, val tauMembrane: Double = 4.0, val selfExcitation: Double = 0.0, val bias: Double = 0.0, val adaptationRate: Double = 0.0, val targetRate: Double = 0.35, val thresholdLimit: Double = 3.0)

The internal dynamics of one motor neuron.

Declaration Summary
val activation: Activation The nonlinearity; see Activation.
val gain: Double Slope applied before the activation.
val tauMembrane: Double Membrane time constant in steps; 1.0 is no smoothing at all.
val selfExcitation: Double Weight of the neuron's own output fed back into its membrane. For the sigmoid the neuron is bistable — and so has a hysteresis loop — once selfExcitation · gain / 4 > 1. Zero is the memoryless control.
val bias: Double A fixed offset subtracted from the membrane drive.
val adaptationRate: Double How fast the threshold integrates r − targetRate, per step. Zero switches homeostasis off; a typical value is 0.002, a time constant of a few hundred steps.
val targetRate: Double The firing rate homeostasis pulls the neuron toward.
val thresholdLimit: Double How far the threshold may wander either way.

PlasticityParams

data class PlasticityParams(val learningRate: Double = 0.0, val noise: Double = 0.15, val tauTrace: Double = 30.0, val tauBaseline: Double = 50.0, val maxWeight: Double = 3.0, val speedCost: Double = 0.0)

The slow learning level: reward-modulated plasticity of the four sensor→motor weights.

Declaration Summary
val learningRate: Double Zero is the control: the weights never move. Around 3.0 learns in ten thousand steps; much above 10 overshoots.
val noise: Double Standard deviation of the motor noise, which is both the exploration and what is learned from.
val tauTrace: Double Eligibility-trace time constant in steps: how long after a twitch its consequence still counts.
val tauBaseline: Double Reward-baseline time constant in steps.
val maxWeight: Double Weights are clamped to ±maxWeight.
val speedCost: Double Price of speed in the reward: R = light − speedCost · speed. Zero is the plain light reward; with a cost, a vehicle that holds the same light while driving slower is the better one.

VehicleParams

data class VehicleParams(val sensorAngle: Double = 0.6, val sensorDistance: Double = 3.0, val baseSpeed: Double = 0.15, val motorSpeed: Double = 1.2, val axle: Double = 2.5, val neuron: NeuronParams = NeuronParams(), val plasticity: PlasticityParams = PlasticityParams())

Body, sensors and wheels of a vehicle, plus its two NeuronParams layers of dynamics.

Declaration Summary
val sensorAngle: Double Angle of each sensor off the heading, in radians.
val sensorDistance: Double Distance of each sensor ahead of the body centre.
val baseSpeed: Double Wheel speed with the motor neuron silent.
val motorSpeed: Double Extra wheel speed at a rate of 1.
val axle: Double Distance between the wheels; smaller turns harder.
val neuron: NeuronParams Motor-neuron dynamics.
val plasticity: PlasticityParams Weight learning.

HysteresisSweep

class HysteresisSweep(val inputsUp: DoubleArray, val ratesUp: DoubleArray, val inputsDown: DoubleArray, val ratesDown: DoubleArray)

The result of hysteresisSweep: the same neuron driven up and then back down.

Declaration Summary
val inputsUp: DoubleArray Inputs of the rising sweep.
val ratesUp: DoubleArray Rates on the rising sweep.
val inputsDown: DoubleArray Inputs of the falling sweep (the rising ones reversed).
val ratesDown: DoubleArray Rates on the falling sweep.
val switchOn: Double The input at which the rising sweep first crosses a rate of 0.5, or NaN if it never does.
val switchOff: Double The input at which the falling sweep first drops below 0.5, or NaN if it never does.
val width: Double switchOn − switchOff: zero for a memoryless neuron, the loop's width for a bistable one.
val area: Double The area between the two branches, in input × rate units.
fun toImg(width: Int = 300, height: Int = 200): Img Both branches plotted into a small RGB image: rising in orange, falling in blue, input across, rate up.

Light

data class Light(val x: Double, val y: Double, val intensity: Double = 1.0, val radius: Double = 18.0, val vx: Double = 0.0, val vy: Double = 0.0)

A light: brightness intensity / (1 + d²/radius²) at distance d. A light with a velocity (vx, vy, in distance per step) drifts across the torus as the world steps.

Declaration Summary
val x: Double
val y: Double
val intensity: Double
val radius: Double
val vx: Double
val vy: Double
fun brightnessAt(d: Double): Double Brightness at distance d from this light.
val moving: Boolean Whether this light moves.
val speed: Double Speed in distance per step.

VehicleWorld

class VehicleWorld(val width: Double, val height: Double, lights: List<Light>, val x: DoubleArray, val y: DoubleArray, val heading: DoubleArray, val w: DoubleArray, val neurons: DoubleArray, val traces: DoubleArray, val baseline: DoubleArray, seed: Long = 1L)

A population of vehicles among lights on a torus.

Declaration Summary
val width: Double Domain width.
val height: Double Domain height.
val x: DoubleArray Body x positions.
val y: DoubleArray Body y positions.
val heading: DoubleArray Headings in radians.
val w: DoubleArray Sensor→motor weights, four per vehicle.
val neurons: DoubleArray Neuron state, three per motor neuron (u, r, θ), six per vehicle.
val traces: DoubleArray Eligibility traces, four per vehicle.
val baseline: DoubleArray Running reward average, one per vehicle.
var lights: List<Light> The light sources, at their current positions; lights with a velocity move every step.
val speed: DoubleArray Each vehicle's forward speed on the last step.
val count: Int How many vehicles.
var steps: Int Steps taken so far.
fun light(px: Double, py: Double): Double Brightness at a point, summed over lights under the toroidal metric.
fun nearestLightWithin(i: Int, within: Double): Int The index of the light within within of vehicle i, or -1.
fun nearestLightDistance(i: Int): Double Distance from vehicle i to the nearest light, under the toroidal metric.
fun trailAlong(within: Double = 20.0): Double How far vehicles near moving lights lag behind them: for every vehicle within within of a moving light, its offset from that light projected on the light's direction of travel, averaged. Negative is trailing, positive leading. Returns NaN when no vehicle is near a moving light. Divide by the light's speed for the lag in steps.
fun meanSpeed(): Double Mean forward speed over the population on the last step.
fun rate(i: Int, m: Int): Double The rate of motor neuron m of vehicle i.
fun threshold(i: Int, m: Int): Double The threshold of motor neuron m of vehicle i.
fun crossing(i: Int): Double How crossed-excitatory vehicle i is: (w_LR + w_RL − w_LL − w_RR) / 2. Positive steers toward light (Braitenberg's 2b), negative away (2a).
fun meanCrossing(): Double Mean crossing over the population.
fun meanLight(): Double Mean light at the vehicles' bodies right now.
fun step(p: VehicleParams) Advance every vehicle by one step.
fun stepTimes(p: VehicleParams, n: Int) step n times.
fun copy(seed: Long = 1L): VehicleWorld An independent copy with the same noise seed, so a run can be forked into two arms.
fun withLights(lights: List<Light>, seed: Long = 1L): VehicleWorld An independent copy of the vehicles (positions, wiring, neuron state) among a different set of lights — a trained population moved onto moving lamps, say.
override fun toString(): String

LightVisits

class LightVisits(world: VehicleWorld, private val within: Double = 8.0)

Counts, per vehicle, how many times it arrives at a light other than the last one it was at — the number that tells a vehicle that parks from one that forages. Call observe once per step.

Declaration Summary
val visits Arrivals so far, per vehicle.
fun observe(world: VehicleWorld) Record the current positions.
fun mean(): Double Mean arrivals per vehicle.

OrbitRadii

class OrbitRadii

Collects every vehicle's distance to its nearest light, once per observe, for the distribution of radii an orbiting population keeps.

Declaration Summary
fun observe(world: VehicleWorld) Record each vehicle's current distance to its nearest light.
val size: Int How many distances have been recorded.
fun quantile(q: Double): Double The q quantile (0..1) of the recorded distances.
fun median(): Double Median recorded distance.
fun fractionWithin(lo: Double, hi: Double): Double Fraction of recorded distances inside lo..hi.
fun distances(every: Int = 1): List<Double> The recorded distances, every everyth one, in recording order — for handing to a distribution plot such as the named violin overload, which serialises every value it is given.

VehicleCanvas

class VehicleCanvas(val world: VehicleWorld, val scale: Double = 1.5, val fade: Float = 0.992f)

A fading trail buffer for drawing a VehicleWorld: the light field as a dim backdrop, each vehicle's recent path as a streak coloured by its motor rates (red the left neuron, green the right, so a vehicle driving straight at full tilt is yellow), and the bodies on top.

Declaration Summary
val w Pixels across.
val h Pixels down.
fun record() Fade the trails and add the vehicles' current positions.
fun image(): Img Backdrop + trails + bodies.

Creatures.kt

Evolving 2D creatures: a genetic algorithm over bodies of points and springs whose every point is a foot with a cyclic contact program, and whose joints are angle motors following cyclic target curves.

CreatureEvolution.evolve (or CreatureEvolution.evolveFlow) breeds a population under an EvolutionConfig; CreatureEvolution.simulate scores one CreatureGenome; a CreatureEvaluation plays back as FLines (CreatureEvaluation.frameLines) with a per-foot score (CreatureEvaluation.notationLines).

The world is not in here. A ContactModel says what a foot receives from its surroundings, and CreatureSimulationConfig.fitness says what counts as doing well, so a water strider, a crawler on a floor and a swimmer differ only in box code. The evolving_water_bugs starting point writes two of those contact models.

One thing to know before writing a CreatureSimulationConfig.fitness: during selection it is handed an evaluation with no frames. Evolution scores its whole population with recording off and re-simulates only each generation's best under the configured recording, so a score read out of CreatureEvaluation.frames is zero for every creature that is actually being chosen between. Score from the measured totals instead — CreatureEvaluation.sustainedTravel, CreatureEvaluation.displacement and the means beside them.

src/jvmMain/kotlin/afield/simulation/creatures/Creatures.kt — read the source

Used in: Evolving Creatures

Float2

data class Float2(val x: Float, val y: Float)

A 2D point or vector of Floats, with the arithmetic the creature model needs. Every operation returns a new value; nothing here mutates.

Declaration Summary
operator fun plus(other: Float2)
operator fun minus(other: Float2)
operator fun times(scale: Float)
operator fun div(scale: Float)
fun dot(other: Float2): Float
fun perpDot(other: Float2): Float
fun length(): Float
fun lengthSquared(): Float
fun normalizedOr(fallback: Float2 = Float2(1f, 0f)): Float2
fun perpendicular(): Float2
fun lerp(other: Float2, t: Float): Float2

EdgeGene

data class EdgeGene(val a: Int, val b: Int)

One spring of the body, as the indices of the two points it joins. Its rest length is whatever the distance between those points is in CreatureGenome.points, so the rest shape and the springs are the same piece of genome.

Declaration Summary
fun normalized(): EdgeGene

ContactProgram

data class ContactProgram(val phase: Float, val duty: Float = 0.35f, val smooth: Float = 0.2f, val strength: Float = 1f)

One foot's gait, as a window on the body's phase clock: the foot is down over duty of each cycle, centred on phase, pressing with strength. Every point of a creature carries one of these, and CreatureGenome.feet is parallel to CreatureGenome.points.

Declaration Summary
val phase: Float
val duty: Float
val smooth: Float
val strength: Float
fun normalized(): ContactProgram
fun sample(currentPhase: Float): Float

JointProgram

data class JointProgram(val pivot: Int, val a: Int, val b: Int, val targets: List<Float> = List(JOINT_TARGET_KNOT_COUNT) { 0f })

An angle motor at pivot, pulling the angle between its two neighbours a and b toward a target that cycles with the phase clock. A joint is only valid where two actual edges meet at its pivot.

Declaration Summary
val pivot: Int
val a: Int
val b: Int
val targets: List<Float>
fun normalized(): JointProgram
fun sample(currentPhase: Float): Float
fun shifted(steps: Int): JointProgram
fun meanAbsSignal(sampleCount: Int = 24): Float

CreatureGenome

data class CreatureGenome(val points: List<Float2>, val edges: List<EdgeGene>, val feet: List<ContactProgram>, val joints: List<JointProgram>, val clockPeriod: Float = 1f, val massPerLength: Float = 1.1f, val basePointMass: Float = 0.3f, val springStiffness: Float = DEFAULT_SPRING_STIFFNESS, val damping: Float = 0.968f, val freeDrag: Float = 12f, val footDrag: Float = 180f, val footStickiness: Float = 640f, val jointTorqueScale: Float = 58f)

Everything evolution can change about one creature: its rest shape as points and edges, its gait as feet and joints, and the handful of physical gains below them that decide how heavy it is, how stiff its springs are and how hard its feet can hold.

Declaration Summary
val points: List<Float2>
val edges: List<EdgeGene>
val feet: List<ContactProgram>
val joints: List<JointProgram>
val clockPeriod: Float
val massPerLength: Float
val basePointMass: Float
val springStiffness: Float
val damping: Float
val freeDrag: Float
val footDrag: Float
val footStickiness: Float
val jointTorqueScale: Float
fun normalized(): CreatureGenome
fun validationIssues(): List<String>

CreatureSimulationConfig

data class CreatureSimulationConfig(val warmupCycles: Int = 1, val measuredCycles: Int = 5, val stepsPerCycle: Int = 120, val solverIterations: Int = 16, val gravity: Float = 0f, val targetDirection: Float2 = Float2(1f, 0f), val recordFrames: Boolean = true, val recordEvery: Int = 4, val maxAbsCoordinate: Float = 200f, val fitnessObjective: FitnessObjective = FitnessObjective.DIRECTIONAL_PROGRESS, val maxActuationBudgetPerCycle: Float = DEFAULT_MAX_ACTUATION_BUDGET_PER_CYCLE, val actuationEffortPenalty: Float = DEFAULT_ACTUATION_EFFORT_PENALTY, val jointTargetAngleScale: Float = DEFAULT_JOINT_TARGET_ANGLE_SCALE, val jointMotorKp: Float = DEFAULT_JOINT_MOTOR_KP, val jointMotorKd: Float = DEFAULT_JOINT_MOTOR_KD, val jointTargetFilterRate: Float = DEFAULT_JOINT_TARGET_FILTER_RATE, val maxJointImpulsePerStep: Float = DEFAULT_MAX_JOINT_IMPULSE_PER_STEP, val targetSlewPenalty: Float = DEFAULT_TARGET_SLEW_PENALTY, val maxMeanStretchError: Float = DEFAULT_MAX_MEAN_STRETCH_ERROR, val maxPeakStretchError: Float = DEFAULT_MAX_PEAK_STRETCH_ERROR, val maxCenterSpeed: Float = DEFAULT_MAX_CENTER_SPEED, val contact: ContactModel = ContactModel.Drag, val fitness: ((CreatureEvaluation) -> Float)? = null, val explodedFitness: Float = -1000f)

How one creature is run and scored: for how long, in what surroundings, against what measure of doing well, and inside what limits.

Declaration Summary
val warmupCycles: Int
val measuredCycles: Int
val stepsPerCycle: Int
val solverIterations: Int
val gravity: Float
val targetDirection: Float2
val recordFrames: Boolean
val recordEvery: Int
val maxAbsCoordinate: Float
val fitnessObjective: FitnessObjective
val maxActuationBudgetPerCycle: Float
val actuationEffortPenalty: Float
val jointTargetAngleScale: Float
val jointMotorKp: Float
val jointMotorKd: Float
val jointTargetFilterRate: Float
val maxJointImpulsePerStep: Float
val targetSlewPenalty: Float
val maxMeanStretchError: Float
val maxPeakStretchError: Float
val maxCenterSpeed: Float
val contact: ContactModel How a foot meets the world: the creature's environment. See ContactModel.
val fitness: ((CreatureEvaluation) -> Float)? Replaces fitnessObjective when set. Called with the finished evaluation (its fitness still holds the built-in objective's score); a creature that exploded scores explodedFitness without calling it.
val explodedFitness: Float The score of a creature whose simulation blew up or broke a stability limit (and of a non-finite custom fitness). Keep it below anything a living creature can score under your fitness.

FootStep

data class FootStep(val contact: Float, val position: Float2, val velocity: Float2, val genome: CreatureGenome)

What one foot is doing on one physics step: its program's contact strength in 0..1 for the current phase, where it is, how fast it moves, and the genome (for evolved gains such as CreatureGenome.footStickiness, CreatureGenome.footDrag and CreatureGenome.freeDrag).

Declaration Summary
val contact: Float
val position: Float2
val velocity: Float2
val genome: CreatureGenome

ContactModel

interface ContactModel

The environment a creature moves in, as the force each foot receives. Every point of the body is a foot (most of them evolve programs that never touch down), and each gets its own Foot from foot, so a foot can remember where it planted.

Declaration Summary
fun foot(index: Int): Foot
fun place(points: List<Float2>): List<Float2> Where the body starts, given the genome's centred rest shape. A floor model lifts it onto the floor.
interface Foot
object Drag : ContactModel A viscous medium with nothing to push against except CreatureGenome.freeDrag: no foot ever grips.

FitnessObjective

enum class FitnessObjective

Which built-in measure CreatureSimulationConfig.fitnessObjective scores with, when CreatureSimulationConfig.fitness has not replaced it.

Constants: DIRECTIONAL_PROGRESS, NET_CENTROID_DISPLACEMENT.

CreatureSnapshot

data class CreatureSnapshot(val step: Int, val phase: Float, val center: Float2, val points: List<Float2>, val down: List<Boolean>, val anchors: List<Float2?>, val jointTorques: List<Float>, val jointTargets: List<Float>)

One recorded physics step: where every point was, which feet were holding, and what each joint was doing. A run keeps one of these every CreatureSimulationConfig.recordEvery steps, and they are what CreatureEvaluation's playback draws.

Declaration Summary
val step: Int
val phase: Float
val center: Float2
val points: List<Float2>
val down: List<Boolean>
val anchors: List<Float2?>
val jointTorques: List<Float>
val jointTargets: List<Float>

CreatureEvaluation

data class CreatureEvaluation(val genome: CreatureGenome, val fitness: Float, val netCentroidMotion: Float, val displacement: Float2, val forwardTravel: Float, val forwardProgress: Float, val lateralDrift: Float, val meanStretchError: Float, val meanActuationEffort: Float, val programmedActuationBudgetPerCycle: Float, val meanTargetSlew: Float, val meanSpeed: Float, val frames: List<CreatureSnapshot>, val exploded: Boolean, val firstHalfDisplacement: Float2 = Float2(0f, 0f), val secondHalfDisplacement: Float2 = Float2(0f, 0f))

What one run of one creature came to: the score it earned, how far it actually travelled, what the gait cost it, and the frames of it that were recorded.

Declaration Summary
val genome: CreatureGenome
val fitness: Float
val netCentroidMotion: Float
val displacement: Float2 Where the centroid ended up relative to where measurement began.
val forwardTravel: Float
val forwardProgress: Float
val lateralDrift: Float
val meanStretchError: Float
val meanActuationEffort: Float
val programmedActuationBudgetPerCycle: Float
val meanTargetSlew: Float
val meanSpeed: Float
val frames: List<CreatureSnapshot>
val exploded: Boolean
val firstHalfDisplacement: Float2 How far the centroid moved in the first half of measurement, and in the second.
val secondHalfDisplacement: Float2
fun sustainedTravel(direction: Float2 = Float2(1f, 0f)): Float The smaller of the two half-run distances along direction, so a creature that topples once and then lies still scores about zero, and only one that keeps going scores well.
fun summary(): String
fun frameLines3d(index: Int, lift: Float = 0.2f, arc: Float = 0.06f, alignNetMotionToPositiveX: Boolean = true): List<FLine> Frame index as 3D FLines: x and y are the surface, z is height. Show them with three { lines(...); camera(upAxis = ThreeUpAxis.Z) } so the viewer's grid is the water.
fun frameLines(index: Int, trailLength: Int = 40, alignNetMotionToPositiveX: Boolean = true): List<FLine>
fun playback(loop: Boolean = true, trailLength: Int = 40, alignNetMotionToPositiveX: Boolean = true): Sequence<List<FLine>>
fun notationLines(index: Int, scoreWidth: Float = 6f, rowHeight: Float = 0.55f, origin: Float2? = null): List<FLine>
fun notationPlayback(loop: Boolean = true, scoreWidth: Float = 6f, rowHeight: Float = 0.55f, origin: Float2? = null): Sequence<List<FLine>>
fun playbackWithNotation(loop: Boolean = true, trailLength: Int = 40, alignNetMotionToPositiveX: Boolean = true, scoreWidth: Float = 6f, rowHeight: Float = 0.55f, notationOrigin: Float2? = null): Sequence<List<FLine>>

EvolutionConfig

data class EvolutionConfig(val populationSize: Int = 28, val generations: Int = 12, val eliteCount: Int = 4, val tournamentSize: Int = 4, val crossoverRate: Float = 0.35f, val mutationSteps: IntRange = 2..5, val immigrantsPerGeneration: Int = 1, val immigrantFraction: Float = 0.08f, val stagnationGenerations: Int = 80, val stagnationMutationBoost: IntRange = 2..4, val stagnationImmigrantFraction: Float = 0.3f, val minPoints: Int = 4, val maxPoints: Int = 9, val targetEdgeCount: Int? = null, val targetJointActivations: Int? = null, val alwaysUpFootFraction: Float = 0f, val cyclingFootFraction: Float = 0f, val simulation: CreatureSimulationConfig = CreatureSimulationConfig(), val randomSeed: Int = 1)

The genetic algorithm's settings: how big a population, for how many generations, how parents are chosen, how hard children are mutated, and the simulation each of them is scored by.

Declaration Summary
val populationSize: Int
val generations: Int
val eliteCount: Int
val tournamentSize: Int
val crossoverRate: Float
val mutationSteps: IntRange
val immigrantsPerGeneration: Int
val immigrantFraction: Float
val stagnationGenerations: Int
val stagnationMutationBoost: IntRange
val stagnationImmigrantFraction: Float
val minPoints: Int
val maxPoints: Int
val targetEdgeCount: Int?
val targetJointActivations: Int?
val alwaysUpFootFraction: Float
val cyclingFootFraction: Float
val simulation: CreatureSimulationConfig
val randomSeed: Int

EvolutionGeneration

data class EvolutionGeneration(val generation: Int, val bestFitness: Float, val meanFitness: Float, val medianFitness: Float)

The fitness spread of one finished generation, over the genomes that survived repair. A genome no repair could make valid is dropped rather than scored, so these are computed over a population that can be smaller than EvolutionConfig.populationSize.

Declaration Summary
val generation: Int
val bestFitness: Float
val meanFitness: Float
val medianFitness: Float

EvolutionResult

data class EvolutionResult(val best: CreatureEvaluation, val history: List<EvolutionGeneration>)

What a finished evolution returns: the best creature it ever scored, and a history entry per generation.

Declaration Summary
val best: CreatureEvaluation
val history: List<EvolutionGeneration>

EvolutionUpdate

data class EvolutionUpdate(val generation: EvolutionGeneration, val generationBest: CreatureEvaluation, val champion: CreatureEvaluation, val generationsSinceImprovement: Int, val stagnating: Boolean)

One generation, as it finishes: handed to CreatureEvolution.evolve's callback and emitted by CreatureEvolution.evolveFlow. This is what a box watches to draw an evolution as it happens.

Declaration Summary
val generation: EvolutionGeneration
val generationBest: CreatureEvaluation
val champion: CreatureEvaluation
val generationsSinceImprovement: Int
val stagnating: Boolean

EvolutionController

class EvolutionController

A stop button for a running evolution, safe to call from a thread other than the one evolving.

Declaration Summary
fun requestStop() Asks the evolution reading this controller to finish early and return what it has.
fun reset() Clears a previous requestStop, so this controller can be handed to another run.
fun isStopRequested(): Boolean Whether requestStop has been called and not yet reset.

CreatureEvolution

object CreatureEvolution

The genetic algorithm itself: where a creature comes from, how one is scored, and how a population is bred.

Declaration Summary
fun randomGenome(random: Random = Random.Default, minPoints: Int = 4, maxPoints: Int = 9, targetEdgeCount: Int? = null, targetJointActivations: Int? = null, alwaysUpFootFraction: Float = 0f, cyclingFootFraction: Float = 0f, maxActuationBudgetPerCycle: Float = DEFAULT_MAX_ACTUATION_BUDGET_PER_CYCLE): CreatureGenome A random starting body: points attached one at a time to a parent with a spare edge slot, then given feet, joints and gains drawn from the ranges that tend to produce a creature that moves.
fun simulate(genome: CreatureGenome, config: CreatureSimulationConfig = CreatureSimulationConfig()): CreatureEvaluation Runs one creature and scores it.
fun evolve(config: EvolutionConfig = EvolutionConfig(), controller: EvolutionController? = null, onGeneration: (EvolutionUpdate) -> Unit = {}): EvolutionResult Runs a whole evolution on the calling thread and returns the champion. onGeneration sees every generation as it finishes; controller stops it early from another thread.
suspend fun evolveSuspend(config: EvolutionConfig = EvolutionConfig(), controller: EvolutionController? = null, onGeneration: suspend (EvolutionUpdate) -> Unit = {}): EvolutionResult evolve for a coroutine: the same run, suspending instead of blocking, with a suspending onGeneration.
fun evolveFlow(config: EvolutionConfig = EvolutionConfig(), controller: EvolutionController = EvolutionController()): Flow<EvolutionUpdate> The evolution as a cold kotlinx.coroutines.flow.Flow of EvolutionUpdate, one per generation, which is usually what a box wants: collect it and redraw the champion as the generations arrive.

FlowShaders.kt

Packaged GLSL helpers (grain.glsl, strokes.glsl, motion.glsl, tiles.glsl) for flow shaders, reachable as #include "flow/...".

The include resolver in shaders.kt already falls back to FlowShaders.source for flow/ paths, so a document needs no setup; FlowShaders.install registers the root explicitly, replacing any custom flow registration. Passes, resources and artistic controls stay in editable boxes.

src/jvmMain/kotlin/afield/simulation/flow/FlowShaders.kt — read the source

FlowShaders

object FlowShaders

Small shader functions; passes, resources and artistic controls remain editable boxes.

Declaration Summary
fun source(name: String): String
fun install() Explicitly register the packaged root, replacing a custom "flow" registration. Ordinary includes already have a built-in fallback, so documents need no setup call.

TileAtlasLayout.kt

Maps tile slots of a afield.graphics.render.TileResidency onto pixel coordinates in one GPU atlas texture, with a halo border around each tile.

Each slot occupies a square of cellsPerTile + 2 * halo pixels, laid out in the residency's columns and rows. TileAtlasLayout.pixelX and TileAtlasLayout.pixelY address a cell inside a slot (negative cells and cells past the tile reach into the halo), and TileAtlasLayout.updateHeaders packs four ints per slot (world tile x and y, generation, occupied) for upload. The layout owns no textures and schedules no work. Halos are meant to be filled from resident neighbouring tiles rather than by wrapping atlas coordinates, so the layout implies no periodic world boundary.

src/jvmMain/kotlin/afield/simulation/flow/TileAtlasLayout.kt — read the source

TileAtlasLayout

class TileAtlasLayout(val residency: TileResidency, val cellsPerTile: Int, val halo: Int = 1)

GPU atlas addressing without ownership of textures or command scheduling.

Declaration Summary
val stride
val width
val height
val headers
fun updateHeaders(): IntArray
fun pixelX(slot: Int, cell: Int): Int
fun pixelY(slot: Int, cell: Int): Int

KratosDemo.kt

The two Kratos smoke runs, and the small piece of geometry they share.

Both are jvm-main entry points rather than unit tests, because both need a worker process. Each asks Kratos.probeAny for whichever launch this machine can actually run and prints a passing line naming what was missing when the answer is none, so a checkout with no Kratos installed still runs them green. Both read their startup budget off the resolved launch, since a container under x86-64 emulation takes far longer to reach its first frame than a native venv.

kratos.embedded.thermal covers the embedded route: one session over a fixed box, a committed solve of KratosDemo.body, then two preview solves with a shorter arm. What it establishes is the pair of claims the route is for — that a geometric change is a reclassification of the same BackgroundDomain instance rather than a remesh, with buildCount still 1 and the temperatures moved; and that a preview overtaken by a later edit completes with null instead of arriving late.

kratos.structural.quadratic covers the body-fitted route, and it checks physics rather than plumbing: the same cantilever meshed at both tetrahedron orders, against the Euler-Bernoulli tip deflection of a uniformly loaded beam. It skips when TetGen or Manifold is absent, because body-fitted meshing goes through both.

src/jvmMain/kotlin/afield/simulation/kratos/KratosDemo.kt — read the source

KratosDemo

object KratosDemo

Reusable cuboid assembly: resize armEnd without changing authored identity.

Declaration Summary
fun body(armEnd: Double = .71): SimulationCSG A base box with an arm reaching out of it, as one union named body.

KratosSmokeTest

object KratosSmokeTest

The embedded thermal route end to end: one background grid, three solves, two of them previews.

Declaration Summary
fun main(args: Array<String>) Solves the demo body at three arm lengths and checks the background domain survived all of them.

KratosStructuralSmokeTest

object KratosStructuralSmokeTest

The body-fitted path end to end, at both tetrahedron orders, against closed-form beam theory.

Declaration Summary
fun main(args: Array<String>) Meshes the cantilever at order 1 and order 2 and reports each tip deflection against theory.

KratosLaunch.kt

Where the Kratos worker process comes from, and which end of the socket Field owns.

The protocol above this is identical in all three cases: one authenticated frame stream, one session, the envelope described in optional/kratos/protocol.md. What differs is everything before the first frame.

who runs Python who listens who dials
LocalProcess Field, as a child process Field, on an ephemeral 127.0.0.1 port the worker
Container Field, as a container the worker, inside its namespace Field, on the published port
Attach nobody, it is already running the worker Field

Why the container case reverses the direction

LocalProcess binds 127.0.0.1 in the JVM and hands the port to the child, which connects back. A container cannot do that. Its network namespace is not the host's, so the host's loopback interface is not reachable from inside it, and on macOS there is a virtual machine in between as well. Publishing a port is the direction that does work through both, so in Container the worker binds and Field dials.

That does mean the worker binds 0.0.0.0, inside the container. Only the port the runtime publishes leaves that namespace, and it is published to 127.0.0.1 on the host, so the host's exposure is the same loopback-only socket either way. The token is checked on the first frame in both directions.

Why the container launch exists at all

Kratos publishes manylinux_2_28_x86_64 and win_amd64 wheels. There is no macOS wheel for either architecture, so on an Apple Silicon checkout uv sync --project optional/kratos fails during resolution and LocalProcess has nothing to run. The container carries the same pinned environment, resolved from the same uv.lock. Under emulation it is slow enough that the measured component times mean nothing as absolute numbers; the ratios between them still do.

src/jvmMain/kotlin/afield/simulation/kratos/KratosLaunch.kt — read the source

KratosLaunch

sealed interface KratosLaunch

One way of bringing a worker up and handing back a socket connected to it.

Declaration Summary
fun token(): String The token the worker will demand on its first frame. Generated per launch, except when attaching.
fun start(config: KratosConfig, token: String, log: Path): KratosStarted Bring up a worker and return a connected socket.
data class LocalProcess(val python: String, val worker: Path) : KratosLaunch Field owns the listener, the worker is an ordinary child process, and it connects back. The default, and the profile everything in VERIFICATION.md was measured against.
data class Container(val image: String = System.getenv("AFIELD_KRATOS_IMAGE") ?: "field-kratos:10.4.0", val runtime: String = System.getenv("AFIELD_KRATOS_RUNTIME") ?: "podman", val platform: String? = System.getenv("AFIELD_KRATOS_PLATFORM") ?: "linux/amd64", val workerDirectory: Path = Path.of("optional/kratos/python").toAbsolutePath(), val extraArgs: List<String> = emptyList(), val startupTimeoutMs: Int = 120_000) : KratosLaunch The worker runs in a container and Field dials the published port.
data class Attach(val host: String = "127.0.0.1", val port: Int, val token: String) : KratosLaunch A worker somebody else started — on a Linux box over an SSH tunnel, in a container run by hand, under a debugger.

KratosStarted

class KratosStarted(val socket: Socket, val process: Process?, val release: () -> Unit)

What a KratosLaunch hands back: the connected socket, the process Field owns (if any), and a best-effort release for whatever the launch created besides that process.

Declaration Summary
val socket: Socket
val process: Process?
val release: () -> Unit

KratosProtocol.kt

src/jvmMain/kotlin/afield/simulation/kratos/KratosProtocol.kt — read the source

KratosSession.kt

The JVM side of the Kratos link: finding a worker, opening a session on it, and asking for solves.

The physics does not run in this process. A Kratos solve runs in a Python worker reached over one authenticated socket, and nothing in this file imports native solver code, so a checkout with no Kratos installed still starts and still compiles. Whether a solve can actually happen is a runtime question, and Kratos is where it is asked: probe starts one worker, probeAny tries each launch this machine could plausibly use and reports which one answered, and howToInstall says what is missing in terms of the platform asking. Kratos publishes Linux and Windows wheels only, so on macOS there is nothing a uv sync could install and the container launch is the only route to a solve; advice that did not know the platform would be advice to run a command that cannot succeed.

The formulation is the only thing that knows any physics

SimulationParameters is the physics half of a request: a registry name the worker recognises, and the coefficients that go with it. ThermalParameters, FlowParameters and StructuralParameters are the three implemented here. Everything else in the file (the launch, the framing, the binary arrays, revisions, cancellation) never looks at which formulation is running, and SimulationResult decodes whatever field list the worker declares. Adding a physics is therefore one more implementation of this interface beside one more worker-side formulation, and no change to the transport.

Two ways to open a session, and they are not interchangeable

KratosSession.openAsync gives the worker bounds and a resolution and lets it build a structured background grid; geometry is then classified against that grid, which is what makes a geometric change cheap. KratosSession.openMeshAsync sends a tetrahedral mesh Field has already made with afield.geometry.mesh.tetgen.Tetgen, for a formulation that solves the mesh it was given. isBodyFitted reports which kind a session is, and it decides which solveAsync overload is legal: the embedded one takes a SimulationGeometry per solve, the body-fitted one refuses a geometry because changing the mesh means opening a new session.

Newest wins

A session is built for a slider. A new request immediately obsoletes the ones before it: the simulation half of SimulationRevision increments, and an obsoleted future completes null rather than handing back a field that answers a superseded question. In-flight native solves are left to finish and their results are dropped, since there is no way to interrupt Kratos mid-solve. All of it runs off Field's runloop on one thread, so withLatest is how a draw gets a result and the revision it belongs to without racing the next solve.

That policy is wrong for anything that needs every answer rather than the latest one, which is why KratosStudy drives a session strictly one solve at a time.

src/jvmMain/kotlin/afield/simulation/kratos/KratosSession.kt — read the source

Used in: Filling a solid: tetrahedral meshing with TetGen

KratosConfig

data class KratosConfig(val python: String = System.getenv("AFIELD_KRATOS_PYTHON") ?: "optional/kratos/.venv/bin/python", val worker: Path = Path.of(System.getenv("AFIELD_KRATOS_WORKER") ?: "optional/kratos/python/worker.py").toAbsolutePath(), val startupTimeoutMs: Int = 15_000, val requestTimeoutMs: Int = 60_000, val threads: Int = 2, val setupScript: Path? = null, val launch: KratosLaunch? = null)

Optional environment; neither construction nor Field startup imports any native Kratos code.

Declaration Summary
val python: String
val worker: Path
val startupTimeoutMs: Int
val requestTimeoutMs: Int
val threads: Int
val setupScript: Path?
val launch: KratosLaunch?
fun resolvedLaunch(): KratosLaunch launch, or the local child process described by python and worker.

KratosCapability

data class KratosCapability(val available: Boolean, val details: Map<String, Any?>, val message: String)

KratosAvailability

data class KratosAvailability(val config: KratosConfig, val capability: KratosCapability)

A KratosConfig and what actually happened when Kratos.probeAny started it.

Declaration Summary
val available: Boolean

Kratos

object Kratos

Cold-start probe is asynchronous, bounded, and closes its worker even when imports fail.

Declaration Summary
fun probe(config: KratosConfig = KratosConfig()): CompletableFuture<KratosCapability>
fun probeAny(configs: List<KratosConfig> = KratosConfig.candidates()): CompletableFuture<KratosAvailability> Start each of configs in turn and return the first that answers hello with available=true, or the last failure.
fun howToInstall(config: KratosConfig = KratosConfig()): String What to tell someone who has not got it, in the launch they actually asked for — and, when the platform has no wheels, the only launch that can work.

KratosState

enum class KratosState

User-visible orchestration states. Preview geometry itself remains ordinary Manifold/TriMesh data.

Constants: GEOMETRY_PREVIEW, COARSE_PREVIEW, COMMITTED_SOLVE, READY, FAILED, CLOSED.

SimulationEdit

enum class SimulationEdit

Constants: GEOMETRY, MATERIAL, BOUNDARY, LOAD.

SimulationParameters

interface SimulationParameters

The physics half of a request: which formulation the worker should run, and its coefficients.

Declaration Summary
val formulation: String Registry name, e.g. thermal.stationary. Must match the session's formulation.
fun wire(): Map<String, Any?> Coefficients, in the keys the formulation's solve accepts.
fun regions(geometryId: String): Map<String, Any?> Semantic identities that travel with the request and come back in the diagnostics.

ThermalParameters

data class ThermalParameters(val conductivity: Double = 1.0, val source: Double = 1.0, val boundaryTemperature: Double = 0.0, val penalty: Double = 10.0, val materialId: String = "body-material", val boundaryConditionId: String = "skin-temperature") : SimulationParameters

Declaration Summary
val materialId: String
override val formulation: String
override fun wire(): Map<String, Any?>
override fun regions(geometryId: String): Map<String, Any?>

FlowParameters

data class FlowParameters(val velocity: Double = 1.0, val density: Double = 1.0, val viscosity: Double = 0.05, val steps: Int = 3, val timeStep: Double = 0.08, val penalty: Double = 10.0, val maxIterations: Int = 8, val materialId: String = "fluid", val boundaryConditionId: String = "no-slip-skin") : SimulationParameters

Coefficients for flow.embedded: incompressible flow past the authored solid.

Declaration Summary
val velocity: Double
val density: Double
val viscosity: Double
val steps: Int
val timeStep: Double
val penalty: Double
val maxIterations: Int
val materialId: String
val boundaryConditionId: String
fun reynolds(length: Double): Double Reynolds number on a length you supply — the obstacle's size, not the domain's.
override val formulation: String
override fun wire(): Map<String, Any?>
override fun regions(geometryId: String): Map<String, Any?>

StructuralParameters

data class StructuralParameters(val youngModulus: Double = 2.1e11, val poissonRatio: Double = 0.3, val density: Double = 7850.0, val gravity: Triple<Double, Double, Double> = Triple(0.0, 0.0, -9.81), val clamp: SimulationBounds, val loadRegion: SimulationBounds? = null, val load: Triple<Double, Double, Double> = Triple(0.0, 0.0, 0.0), val materialId: String = "body-material", val boundaryConditionId: String = "clamp") : SimulationParameters

Coefficients for structural.bodyFitted: linear-elastic small displacement on a supplied mesh.

Declaration Summary
val youngModulus: Double
val poissonRatio: Double
val density: Double
val gravity: Triple<Double, Double, Double>
val clamp: SimulationBounds
val loadRegion: SimulationBounds?
val load: Triple<Double, Double, Double>
val materialId: String
val boundaryConditionId: String
override val formulation: String
override fun wire(): Map<String, Any?>
override fun regions(geometryId: String): Map<String, Any?>

SimulationRevision

data class SimulationRevision(val geometry: Long, val simulation: Long)

KratosSession

class KratosSession private constructor(private val connection: KratosConnection, val domain: BackgroundDomain, val capabilities: Map<String, Any?>, val formulation: String = ThermalParameters.FORMULATION) : AutoCloseable

One persistent Python process, one stable background ModelPart. All work runs off Field's runloop. New requests immediately obsolete old ones; in-flight Kratos solves finish without publishing. Read latest on the Field runloop. Never use an unguarded future callback to update a scene.

Declaration Summary
val capabilities: Map<String, Any?>
val formulation: String Registry name of the physics this background was built for.
var revision
var latest: SimulationResult?
var state
var error: String?
val isBodyFitted: Boolean by lazy True when this session solves a mesh Field sent, rather than classifying a background grid.
fun solveAsync(parameters: SimulationParameters, edit: SimulationEdit = SimulationEdit.MATERIAL, preview: Boolean = false): CompletableFuture<SimulationResult?> Solve the mesh this session was opened with. For a body-fitted formulation only.
fun solveAsync(geometry: SimulationGeometry, parameters: SimulationParameters = ThermalParameters(), edit: SimulationEdit = SimulationEdit.GEOMETRY, preview: Boolean = false, dirtyBounds: List<SimulationBounds> = emptyList()): CompletableFuture<SimulationResult?>
fun cancel() Invalidates in-flight and queued work immediately, without interrupting native solver code.
fun withLatest(draw: (SimulationResult) -> Unit) Guard publication at the moment Field draws, including after a future has already completed.
override fun close()

KratosStudy.kt

Run one Kratos session many times over a declared design space, and compare the answers.

KratosSession and the worker below it answer exactly one question: given this geometry and these coefficients, what is the field? That is a simulator. A study is the layer that turns it into a design tool. It varies the question, reduces each answer to one number, and reports the shape of the resulting landscape and where its optimum lies.

A caller supplies three pieces:

  • a StudySpace — a base design object of any type D and the StudyVariables that move it;
  • a plan, mapping a design to the StudyCase the solver runs (geometry and coefficients);
  • a measure, reducing a SimulationResult to the scalar being optimised.

Keeping plan separate from measure is what lets geometry be an ordinary axis. For an embedded formulation a geometric change is a reclassification of the same background grid, with no remesh and no new session, so a sweep over an arm length costs what a sweep over a conductivity costs and the two can be axes of one grid. The domain's buildCount stays 1 across a whole sweep, and KratosStudySmokeTest fails if it does not. A body-fitted formulation has no reclassification to do instead: evaluate refuses a StudyCase that carries a geometry there, and a geometric axis needs a session per point rather than a study.

One session solves one design at a time

KratosSession is a newest-wins preview engine: a second solveAsync increments the simulation revision and the first future then completes null rather than with a stale field. That is right for a slider and wrong for a study, which needs every answer. So evaluate awaits each solve before submitting the next, and the study's wall time is the sum of its solves. Submitting in parallel against one session does not shorten it: the session obsoletes all but the last, and each obsoleted solve arrives back here as a failed sample.

Two sessions do run two solves at once, at the cost of two workers and two background grids.

Memoisation

Every evaluation is cached on a quantised coordinate signature, twelve significant figures per coordinate, so re-running an overlapping grid (a refinement around the optimum, say) re-solves only the points it has not seen. The cache is shared with optimise, and the search's own budget accounting agrees with it because the GenomeOps implementation hands PopulationSearch that same key.

An infeasible design is a sample, not an exception

A design whose solve throws, or whose measure returns null because the probe fell outside the solved region, becomes an infeasible sample carrying the reason instead of abandoning the rest of the sweep. The StudySample holds that message in its failure, and fitnessOf scores such a sample as negative infinity, so a search treats it as the worst design it has seen rather than stopping at it.

src/jvmMain/kotlin/afield/simulation/kratos/KratosStudy.kt — read the source

KratosStudy

class KratosStudy<D>(val session: KratosSession, val space: StudySpace<D>, val objectiveName: String, val plan: (D) -> StudyCase, val measure: (D, SimulationResult) -> Double?, val goal: StudyGoal = StudyGoal.MINIMISE, val metrics: Map<String, (D, SimulationResult) -> Double> = emptyMap(), val timeoutSeconds: Long = 300, val onSample: ((StudySample) -> Unit)? = null)

Holds a session, a StudySpace, and the plan/measure pair that turns a design into a scalar.

Declaration Summary
val session: KratosSession
val space: StudySpace<D>
val objectiveName: String What the scalar is called, for the figure's caption and the summary line.
val plan: (D) -> StudyCase What a design means to the solver.
val measure: (D, SimulationResult) -> Double? The scalar to optimise, or null for "this design is infeasible".
val goal: StudyGoal
val metrics: Map<String, (D, SimulationResult) -> Double> Extra scalars recorded beside the objective — reported, never optimised.
val timeoutSeconds: Long Per-solve deadline. A container worker under x86-64 emulation is an order of magnitude slow.
val onSample: ((StudySample) -> Unit)? Called as each sample lands, cached ones included. Progress for a long sweep.
val solveCount: Int Distinct designs actually sent to the worker.
val cacheHits: Int Evaluations served from the cache instead of the solver.
val solveMs: Double Wall time spent inside KratosSession.solveAsync, milliseconds.
val meanSolveMs: Double Mean cost of one solve, milliseconds, or 0 before the first.
fun designAt(coordinates: DoubleArray): D The design at these coordinates — clamped into the space, so this never throws on range.
fun caseAt(coordinates: DoubleArray): StudyCase What the solver would be asked at these coordinates. The best design's geometry lives here.
fun evaluate(coordinates: DoubleArray): StudySample Solve one design point, or return the cached sample for it.
fun resultAt(coordinates: DoubleArray): SimulationResult Solve one design point and hand back the whole field, bypassing the cache.
fun grid(vararg steps: Int): StudyLandscape Sample the space on a regular grid, steps points along each variable, and report the landscape.
fun grid(steps: List<Int>): StudyLandscape
fun optimise(budget: Int, seed: Long = 1L, mu: Int = 4, lambda: Int = 6, sigma: Double = 0.18, control: Boolean = true, start: DoubleArray? = null, immigrantRate: Double = 0.15): StudyOptimisation<D> Search the space rather than tile it, and report the run against a random control at the same budget.
fun fitnessOf(sample: StudySample): Double Higher is better, always: the sense flip lives here and nowhere else.

StudyGoal

enum class StudyGoal

Which end of the objective the study is looking for.

Constants: MINIMISE, MAXIMISE.

StudyVariable

class StudyVariable<D>(val name: String, val range: ClosedFloatingPointRange<Double>, val unit: String = "", val apply: (D, Double) -> D)

One continuous design variable: what to call it, what it ranges over, and how it moves a design.

Declaration Summary
val name: String
val range: ClosedFloatingPointRange<Double>
val unit: String
val apply: (D, Double) -> D
fun clamp(value: Double): Double
fun samples(steps: Int): List<Double> steps values across the range, both ends included; a single step is the midpoint.
val label: String

StudySpace

class StudySpace<D>(val base: D, val variables: List<StudyVariable<D>>)

A base design and the variables that move it. D is the caller's own type; nothing here inspects it.

Declaration Summary
val dimension: Int
fun clamp(coordinates: DoubleArray): DoubleArray
fun at(coordinates: DoubleArray): D The design at these coordinates, applying every variable in declaration order.
fun centre(): DoubleArray The middle of the space — a reasonable place to start a hill climb.

StudyCase

class StudyCase(val parameters: SimulationParameters, val geometry: SimulationGeometry? = null, val edit: SimulationEdit = if (geometry == null) SimulationEdit.MATERIAL else SimulationEdit.GEOMETRY)

What one design means to the solver.

Declaration Summary
val parameters: SimulationParameters
val geometry: SimulationGeometry?
val edit: SimulationEdit

StudySample

class StudySample(val coordinates: DoubleArray, val objective: Double?, val metrics: Map<String, Double>, val solveMs: Double, val cached: Boolean, val failure: String?, val diagnostics: Map<String, Any?> = emptyMap())

One evaluated design point. objective is null when the point is infeasible; failure says why.

Declaration Summary
val coordinates: DoubleArray
val objective: Double?
val metrics: Map<String, Double>
val solveMs: Double
val cached: Boolean
val failure: String?
val diagnostics: Map<String, Any?>
val feasible: Boolean
override fun toString(): String

StudyAxis

class StudyAxis(val name: String, val unit: String, val values: List<Double>)

One axis of a StudyLandscape: what it is and the values it was sampled at.

Declaration Summary
val label: String
val size: Int

StudyLandscape

class StudyLandscape(val objectiveName: String, val goal: StudyGoal, val axes: List<StudyAxis>, val samples: List<StudySample>)

A regularly sampled design space, row-major over axes with the last axis fastest.

Declaration Summary
val objectiveName: String
val goal: StudyGoal
val axes: List<StudyAxis>
val samples: List<StudySample>
val dimension: Int
val feasible: List<StudySample>
fun at(vararg index: Int): StudySample The sample at these axis indices.
val best: StudySample? Best by goal over the feasible samples, or null when every point failed.
fun matrix(): Array<DoubleArray> [row = axis 0][col = axis 1], NaN where a point was infeasible. Two-dimensional studies only.
fun range(): Pair<Double, Double> Extremes over the feasible samples, or 0..1 when there are none.
fun summary(): String
override fun toString(): String

StudyOptimisation

class StudyOptimisation<D>(val objectiveName: String, val goal: StudyGoal, val run: SearchRun<DoubleArray>, val control: SearchRun<DoubleArray>?, val best: StudySample?, val bestDesign: D?)

What a search over the space did, and what a random draw of the same size did instead.

Declaration Summary
val objectiveName: String
val goal: StudyGoal
val run: SearchRun<DoubleArray>
val control: SearchRun<DoubleArray>?
val best: StudySample?
val bestDesign: D?
fun trace(of: SearchRun<DoubleArray>): DoubleArray The trace in the objective's own units and sense, rather than as "higher is better" fitness.
val advantage: Double? How much better the search finished than the control, in objective units; negative is worse.
fun summary(): String
override fun toString(): String

KratosStudyFigure.kt

A StudyLandscape and a StudyOptimisation, as a picture.

A sweep's result is a table of numbers and a table of numbers is easy to be wrong about. What a study is for is the shape: where the valley runs, whether the optimum is interior or pinned to an edge, whether the search found the same place the grid did. All of that is one glance and none of it is a summary line.

The raster is built on the CPU, through RasterImage, with no AWT, no window and no GPU, so the same code makes the figure inside a Field box, inside a jvm-main verification run and inside a unit test. Text is drawn from the 3x5 dot-matrix font in RectSL.kt, so the repository has one such table rather than a third copy of it. That font's alphabet is upper case, digits and a handful of punctuation; labels are upper-cased on the way in, anything outside the alphabet draws as a blank, and drawable is how a caller checks a label before the figure disappoints it.

Reading the landscape panel

Axis 0 runs up the left edge and axis 1 along the bottom, which is the row-then-column reading StudyLandscape.matrix returns. Cells are drawn as cells, with no bilinear smoothing, because a study's resolution is a real property of the answer and a smoothed picture invents a landscape between sample points that nobody solved. Infeasible points are hatched grey rather than coloured zero. The grid's best cell gets a white ring, and an optimiser's best, when one is supplied, gets a cross, so a reader can see where the two methods agree.

A study with one axis draws as a profile instead: the objective as a line across the panel, with the infeasible points marked along the floor. Three axes or more are refused, naming the rank.

src/jvmMain/kotlin/afield/simulation/kratos/KratosStudyFigure.kt — read the source

KratosStudyFigure

object KratosStudyFigure

The three figures: the landscape panel, the convergence panel, and both stacked as a sheet.

Declaration Summary
fun landscapeRaster(landscape: StudyLandscape, optimisation: StudyOptimisation<*>? = null, width: Int = 900, height: Int = 560, colormap: String = "viridis", scale: ColourScale = ColourScale.LINEAR): RasterImage The landscape alone, as pixels — the form a test can count distinct colours in.
fun landscape(landscape: StudyLandscape, optimisation: StudyOptimisation<*>? = null, width: Int = 900, height: Int = 560, colormap: String = "viridis", scale: ColourScale = ColourScale.LINEAR): ByteArray The landscape alone, as PNG bytes.
fun convergenceRaster(optimisation: StudyOptimisation<*>, gridBest: Double? = null, width: Int = 900, height: Int = 420, extra: List<StudyTrace> = emptyList()): RasterImage The convergence panel alone, as pixels.
fun convergence(optimisation: StudyOptimisation<*>, gridBest: Double? = null, width: Int = 900, height: Int = 420, extra: List<StudyTrace> = emptyList()): ByteArray The convergence panel alone, as PNG bytes.
fun sheetRaster(landscape: StudyLandscape, optimisation: StudyOptimisation<*>? = null, width: Int = 980, landscapeHeight: Int = 560, convergenceHeight: Int = 360, colormap: String = "viridis", scale: ColourScale = ColourScale.LINEAR, extra: List<StudyTrace> = emptyList()): RasterImage Both panels stacked: the landscape the grid measured, and the search that walked it.
fun sheet(landscape: StudyLandscape, optimisation: StudyOptimisation<*>? = null, width: Int = 980, landscapeHeight: Int = 560, convergenceHeight: Int = 360, colormap: String = "viridis", scale: ColourScale = ColourScale.LINEAR, extra: List<StudyTrace> = emptyList()): ByteArray sheetRaster, as PNG bytes.
fun drawable(label: String): Boolean Whether every character of label is in the dot-matrix font, ignoring case.

StudyTrace

class StudyTrace(val label: String, val values: DoubleArray, val colour: Int)

One more line for the convergence panel — a second search, a refinement pass, a baseline.

ColourScale

enum class ColourScale

How a normalised objective becomes a position on the colour ramp.

Constants: LINEAR, STRETCHED.

Declaration Summary
fun forward(t: Double): Double Normalised value in 0..1 to normalised colour position in 0..1.
fun inverse(position: Double): Double forward's inverse: what value a colour position stands for.

KratosStudyRun.kt

One parameter study over a heated part, in the three sizes a demo has to exist in.

KratosStudyDemo is the experiment as a spec: the design space, the objective, the two reported metrics and a closed-form check on the answer. It owns no runner of its own, because three things have to agree on the same experiment: the full run below, the catalog smoke beside it, and the loadable document in dev_docs/agents/transcripts/kratos_parameter_study.yaml. KratosStudyRun is that full run, driving a real Kratos worker through a coarse grid, a refinement seeded from it, a global search against a random control, and finally the fine grid the figures are drawn from. KratosStudySmokeTest is the same code path at catalog size, and is the version the test catalog runs.

The two design axes are one geometric (how far the arm extends) and one material (its conductivity), and the study treats them identically. Because thermal.stationary is embedded, moving the geometric axis reclassifies the same background grid rather than remeshing it: both the full run and the smoke assert that the grid is built exactly once across a whole sweep, so a sweep over a shape costs what a sweep over a coefficient costs.

The objective is one whose right answer is known in advance. Stationary conduction is linear in the source and inverse in the conductivity, so the designs that put a target temperature at a chosen point form a curve with a closed form, and KratosStudyDemo.verifyValley holds every column of the sampled grid against it.

src/jvmMain/kotlin/afield/simulation/kratos/KratosStudyRun.kt — read the source

KratosStudyDemo

object KratosStudyDemo

The flagship study, as a reusable spec: which arm length and which conductivity put a chosen temperature at a chosen point in the part?

Declaration Summary
val PROBE Metres. Inside the arm for every armEnd in ARM, and clear of the base, whose x ends at 0.23.
const val SOURCE W/m³, held fixed: with k on an axis, moving q too would only rescale the same landscape.
const val BOUNDARY K, the Nitsche skin temperature.
const val TARGET K, the temperature the design is asked to deliver at PROBE — one kelvin above the skin.
val ARM: ClosedFloatingPointRange<Double>
val CONDUCTIVITY: ClosedFloatingPointRange<Double>
val BOUNDS Domain and discretisation: the configuration VERIFICATION.md already measured.
const val RESOLUTION
data class Design(val armEnd: Double = 0.6, val conductivity: Double = 2.0) The design object. Two numbers, one geometric and one material, treated identically.
fun space(): StudySpace<Design>
fun plan(design: Design): StudyCase What one design means to the solver: this body, these coefficients, a geometric edit.
fun objectiveName(target: Double = TARGET): String
fun metrics(): Map<String, (Design, SimulationResult) -> Double> The two scalars recorded beside the objective. Reported, never optimised.
fun study(session: KratosSession, target: Double = TARGET, onSample: ((StudySample) -> Unit)? = null): KratosStudy<Design> The study: minimise \|T(probe) − target\|.
fun predictedConductivity(sample: StudySample, target: Double = TARGET): Double? Recover the geometric response φ from one sample and predict where that column's valley is.
fun verifyValley(landscape: StudyLandscape, target: Double = TARGET): List<String> Check every column's measured minimum against the closed form, and report the worst gap.

KratosStudyRun

object KratosStudyRun

Drive KratosStudyDemo through a real worker and write the figures.

Declaration Summary
fun main(args: Array<String>)

KratosStudySmokeTest

object KratosStudySmokeTest

The catalog-sized version of KratosStudyRun: a 3x3 grid and a budget-6 search on a real worker.

Declaration Summary
fun main(args: Array<String>)

SimulationGeometry.kt

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.

src/jvmMain/kotlin/afield/simulation/kratos/SimulationGeometry.kt — read the source

Used in: Filling a solid: tetrahedral meshing with TetGen

SimulationBounds

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.

SimulationGeometry

sealed interface SimulationGeometry

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

Declaration Summary
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.

PrimitiveGeometry

sealed interface PrimitiveGeometry : SimulationGeometry

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

Declaration Summary
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 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.

SimulationBox

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

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

Declaration Summary
override fun expression(): Map<String, Any?> The box node: the minimum corner and the maximum corner, in metres.
override fun solid(): Solid A Solid.cube of the extents, translated to the minimum corner.

SimulationSphere

data class SimulationSphere(override val id: String, val x: Double, val y: Double, val z: Double, val radius: Double, val segments: Int = 0) : PrimitiveGeometry

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

Declaration Summary
val radius: Double, val segments: Int Radius in metres, and the Manifold segment count for the display surface only. 0 takes the global quality setting.
override fun expression(): Map<String, Any?> The sphere node: centre and radius, the exact ball rather than the drawn one.
override fun solid(): Solid A Solid.sphere translated to the centre. This is the only place segments is read.

SimulationCylinder

data class SimulationCylinder(override val id: String, val ax: Double, val ay: Double, val az: Double, val bx: Double, val by: Double, val bz: Double, val radius: Double, val segments: Int = 0) : PrimitiveGeometry

A capped cylinder between two points, at any orientation.

Declaration Summary
val ax: Double, val ay: Double, val az: Double One end cap's centre, in metres.
val bx: Double, val by: Double, val bz: Double The other end cap's centre. The two must differ.
val radius: Double, val segments: Int Radius in metres, and the Manifold segment count for the display surface only. 0 takes the global quality setting.
override fun expression(): Map<String, Any?> The cylinder node: the two end centres and the radius. The tessellation is not sent.
override fun solid(): Solid A Solid.cylinder built along +Z at the origin, then pushed through the affine below.

SimulationBoolean

enum class SimulationBoolean

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

Constants: UNION, DIFFERENCE, INTERSECTION.

SimulationCSG

data class SimulationCSG(override val id: String, val a: PrimitiveGeometry, val b: PrimitiveGeometry, val operation: SimulationBoolean = SimulationBoolean.UNION) : PrimitiveGeometry

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

Declaration Summary
val operation: SimulationBoolean DIFFERENCE reads as a minus b.
override fun expression(): Map<String, Any?> The operation as the op tag, with both operands' expressions nested inside it.
override fun solid(): Solid Both operands built, combined, and closed. The result is the caller's.

SimulationGroup

data class SimulationGroup(override val id: String, val operation: SimulationBoolean, val children: List<PrimitiveGeometry>) : PrimitiveGeometry

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

Declaration Summary
val children: List<PrimitiveGeometry>) : PrimitiveGeometry At least two operands, with distinct ids. DIFFERENCE subtracts every later one from the first.
override fun expression(): Map<String, Any?> One op tag over a children list, so the worker reduces the batch instead of recursing.
override fun solid(): Solid One batched Manifold call over every operand, each of which is closed afterwards.

ManifoldGeometry

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.

Declaration Summary
override fun surface(): TriMesh 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.

BackgroundDomain

class BackgroundDomain private constructor(val id: String, val bounds: SimulationBounds, val resolution: Int, val positions: DoubleArray, val cells: IntArray, val buildMs: Double, val nodesPerCell: Int = 4)

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

Declaration Summary
val positions: DoubleArray, val cells: IntArray, val buildMs: Double 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 nodesPerCell: Int 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 isStructured: Boolean True when this is a structured grid and so can be sampled by index.
val nodeCount: Int Nodes, and so the length every nodal field a result carries has to be.
val cellCount: Int Tetrahedra: cells divided by nodesPerCell, so it is right for quadratic meshes too.
val buildCount: Int How many times this discretisation has been built, which is always 1.

SimulationProbe

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

Named spatial selection whose identity survives successive result revisions.

SimulationProbeSample

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.

Declaration Summary
val temperature: Double? The schema-1 name, when the field being probed is a temperature.

SimulationResult.kt

One solved revision: the raw fields exactly as Kratos produced them, and the views Field draws.

The result is described rather than declared. The worker sends a fields list saying which arrays it produced, whether each is nodal or per-cell, and how many components it has, and this class holds whatever arrived. That is what lets one class carry a scalar temperature and a velocity–pressure pair without knowing about either: named is the general accessor, and temperature, pressure and velocity are conveniences that fail with the list of fields that are present rather than with a null.

Two fields follow from how the formulation meets its geometry rather than from its physics, and they are direct properties for that reason. An embedded result carries distance, the signed nodal level set, positive on the side that was solved, and cellState, 0 inactive, 1 wholly inside, 2 cut. A body-fitted result carries neither: its mesh is the body, so there is no level set to report and no cell to classify, and both accessors say which fields did arrive rather than inventing zeros.

Sampling is barycentric in the grid's own tetrahedra

sample finds the containing cube, sorts the local coordinates to pick the same one of the six Freudenthal tetrahedra the worker built, and takes its four barycentric weights. It is not trilinear hexahedral interpolation and would disagree with one; it matches the discretisation the answer was computed on. A point outside the domain, or one with no active support, samples as null rather than as zero.

That index calculation is closed-form only on a structured grid, so on a body-fitted mesh point sampling is null everywhere and sliceMesh refuses outright. boundaryMesh and deformedMesh are the views to draw there.

src/jvmMain/kotlin/afield/simulation/kratos/SimulationResult.kt — read the source

SimulationResult

class SimulationResult internal constructor(val domain: BackgroundDomain, val revision: SimulationRevision, private val nodal: Map<String, DoubleArray>, private val cells: Map<String, DoubleArray>, private val components: Map<String, Int>, val diagnostics: Map<String, Any?>)

The arrays decoded from one result frame, and the sample and mesh views built over them.

Declaration Summary
val domain: BackgroundDomain
val revision: SimulationRevision
val diagnostics: Map<String, Any?>
val distance: DoubleArray Signed nodal level set, positive on the side the formulation solved.
fun isInside(index: Int): Boolean Whether node index is in the solved region — always true without a level set.
val cellState: IntArray 0 inactive, 1 wholly inside the solved region, 2 cut by the boundary.
fun cellField(name: String): DoubleArray A per-cell field by name — vonMises for a structural solve.
val vonMises: DoubleArray Per-element von Mises stress, for structural.bodyFitted.
val fieldNames: List<String> The nodal fields this result actually carries, distance included.
val primaryField: String The field sample and the mesh views use when none is named.
fun named(name: String): DoubleArray Raw values for name, components per node. Throws naming what is present.
fun componentsOf(name: String): Int How many numbers each node has for name.
val temperature: DoubleArray Nodal temperature, for thermal.stationary.
val pressure: DoubleArray Nodal pressure, for flow.embedded.
val velocity: DoubleArray Nodal velocity, xyz per node, for flow.embedded.
val speed: DoubleArray Velocity magnitude per node — the scalar worth colouring a flow picture by.
fun sample(probe: SimulationProbe): SimulationProbeSample
fun sample(x: Double, y: Double, z: Double): Double? primaryField at a point, or null outside the domain or the solved region.
fun sample(name: String, x: Double, y: Double, z: Double): Double? A named scalar field at a point, or null outside the domain or the solved region.
fun sampleVector(name: String, x: Double, y: Double, z: Double): DoubleArray? A named 3-component field at a point, or null outside the domain or the solved region.
fun streamlines(seeds: List<Vec3>, field: String = "velocity", maxSteps: Int = 400, stepFraction: Double = 0.5): List<FLine> Integrate the velocity field from a set of seed points and return the paths as FLines.
fun inletSeeds(across: Int = 5, inset: Double = 0.12): List<Vec3> A lattice of seed points just inside the low-x face, skipping any that start in the obstacle.
fun range(name: String): Pair<Double, Double> The range a colour ramp for name should span: the extremes over the solved region only.
fun surfaceMesh(skin: TriMesh, field: String = primaryField): TriMesh The authored skin, coloured by a field sampled at each of its vertices.
fun sliceMesh(z: Double = (domain.bounds.minZ+domain.bounds.maxZ)/2, samples: Int = 64, field: String = primaryField): TriMesh An axis-aligned interior slice, coloured by a field and cropped to the solved region.
fun boundaryMesh(field: String = primaryFieldOrNull() ?: "", displacementScale: Double = 0.0): TriMesh The mesh's own boundary, coloured by a field — the view a body-fitted result wants.
fun deformedMesh(displacementScale: Double = 200.0, field: String = "vonMises"): TriMesh boundaryMesh with the deflection exaggerated — the picture a structural solve is for.
fun primaryFieldOrNull(): String? primaryField, or null instead of throwing when there is no scalar to draw.

ThermalResult

typealias ThermalResult = SimulationResult

What a thermal.stationary session returns.

FlowResult

typealias FlowResult = SimulationResult

What a flow.embedded session returns. See SimulationResult.

BoidSketch.kt

Boids as a declarative spec: kinds of boid, each with a count, speed limits, a field of view, a look and a list of Reynolds steering rules, all stepped together on the GPU over a neighbour grid and drawn with tapered trails. The Field port of the boids page (~/temp/characters/boids.html); dev_docs/madp/boids.md maps its flock({...}) spec.

src/jvmMain/kotlin/afield/simulation/particles/BoidSketch.kt — read the source

Top-level functions

Declaration Summary
fun Box.showControls(flock: BoidSketch, channel: String = "controls"): Frames The page's toolbar and metrics card for flock, in this box's controls and metrics outputs: fps, steps per frame, trail, fade, pause, inspect and reset. Return it from the box (make it the last line) and the metrics card refreshes twice a second.

BoidEdges

enum class BoidEdges

What happens at the window's edges.

Constants: WRAP, BOUNCE.

BoidShape

enum class BoidShape

How a kind's body is drawn.

Constants: ARROW, KITE, DOT, LINE, NONE.

BoidBlend

enum class BoidBlend

How trails and bodies combine with what is under them.

Constants: NORMAL, LIGHTER.

BoidRule

class BoidRule internal constructor(val type: String, internal val code: Int, val radius: Double, val weight: Double, val among: Any, val fov: Boolean, internal val params: DoubleArray = DoubleArray(4), internal val point: ((Double) -> Vector2f)? = null)

One steering rule of a kind, made by the rule functions on BoidKind (separate(12, 1.7)). Its force is the Reynolds steering toward what the rule wants, times weight.

Declaration Summary
val type: String The rule's name: separate, align, chase, ...
val radius: Double How far it looks for neighbours, in pixels; 0 for a rule that does not look.
val weight: Double
val among: Any Which kinds it sees: "all", "same", "other", a kind's name, or a list of names.
val fov: Boolean Whether it only sees neighbours inside its kind's field of view.

BoidKind

class BoidKind(val name: String)

A kind of boid: how many, how fast, what they see and how they look, and their rules. The rule functions here (separate, align, cohere, flee, chase, eat, wander, seek, orbit, flow, avoidRocks, avoidEdges, mouse, cruise, and custom for its rule) are the page's, with its defaults; units are pixels and steps.

Declaration Summary
var count: Int
var maxSpeed: Double Pixels per step.
var minSpeed: Double
var maxForce: Double The largest steering force a rule can apply, per step.
var drag: Double The fraction of velocity lost every step.
var fov: Double Field of view in degrees; 360 sees all round.
var shape: BoidShape
var size: Double Body size in pixels.
var trail: Double This kind's trail, as a multiple of the flock's BoidSketchSettings.trail.
var trailAlpha: Double
var trailWidth: Double
var spawnCentre: Boolean Start in a disc at the centre ("center" on the page) rather than anywhere.
val rules The rules, in the order their forces are added.
fun rules(vararg r: BoidRule) Set the rules, replacing any before.
fun separate(radius: Number, weight: Number = 1, among: Any = "all", fov: Boolean = false) Steer away from neighbours closer than radius, as 1/d (sees all kinds by default).
fun align(radius: Number, weight: Number = 1, among: Any = "same", fov: Boolean = true) Match the average velocity of neighbours (own kind, in view, by default).
fun cohere(radius: Number, weight: Number = 1, among: Any = "same", fov: Boolean = true) Steer toward the centre of neighbours (own kind, in view, by default).
fun flee(kind: Any, radius: Number, weight: Number = 1) Run from boids of kind within radius, harder the closer they are.
fun chase(kind: Any, radius: Number, weight: Number = 1) Chase the nearest boid of kind within radius, leading its motion.
fun eat(kind: Any, reach: Number, rest: Number = 60) Eat a boid of kind within reach; it respawns at an edge. Then rest for rest steps.
fun wander(weight: Number = 1, jitter: Number = 0.3) Smooth random turning; jitter is how fast the turn drifts.
fun seek(x: Number, y: Number, weight: Number = 1) Steer toward a point given as fractions of the window.
fun seek(weight: Number = 1, at: (Double) -> Vector2f) Steer toward a moving point: fractions of the window, as a function of t in steps.
fun orbit(x: Number, y: Number, radius: Number, weight: Number = 1, dir: Number = 1) Circle a point (fractions of the window) at radius pixels; dir 1 or -1.
fun flow(weight: Number = 1) Follow the angle field the compute tab's flowAngle(uv, t) returns (declare FLOW_FIELD).
fun avoidRocks(weight: Number = 1, margin: Number = 30) Steer around the flock's rocks, starting margin pixels out.
fun avoidEdges(margin: Number, weight: Number = 1) Steer off the walls, starting margin pixels in.
fun mouse(weight: Number, radius: Number = 120) Toward the pointer (positive weight) or away from it (negative), within radius.
fun cruise(speed: Number, weight: Number = 1) Hold a preferred speed along the current heading.
fun custom(radius: Number, weight: Number = 1, among: Any = "all", fov: Boolean = true) A hand-written rule over the neighbours within radius, the page's rule(r, fn): the compute tab's customStart, customNeighbour and customForce (declare CUSTOM_RULE). Its force is used as returned, times weight. (Box code reserves the name rule for rewrite macros, hence custom.)

BoidRock

data class BoidRock(val x: Double, val y: Double, val r: Double)

A round obstacle at (x, y) as fractions of the window, r pixels across from its centre.

BoidSketchSettings

class BoidSketchSettings

A flock: the page's flock({...}) spec. Everything but the kinds' counts can change while it runs; the flock reads it every frame.

Declaration Summary
var name: String Names the buffers and passes; a second flock under the same name replaces the first.
var background: Vec4
var blend: BoidBlend
var edges: BoidEdges
val rocks
var fps: Double Frames per second; the flock steps steps times in each.
var steps: Int
var trail: Int Trail length in steps (each kind's BoidKind.trail multiplies it), at most 120.
var fade: Double How much of the picture each frame paints over with the background: 1 clears it, 0.01 keeps it.
var inspect: Boolean Draw the inspected boid's field of view, rule radii and neighbours; click a boid to pick it.
var paused: Boolean
var seed: Int
fun kind(name: String, configure: BoidKind.() -> Unit) Declare a kind; kinds are numbered from 0 in the order they are declared, for the compute tab.
fun rock(x: Number, y: Number, r: Number) A rock at (x, y) fractions of the window, r pixels in radius.

BoidTables

class BoidTables(settings: BoidSketchSettings)

The tables a flock's spec becomes on the GPU, worked out the way the page's flock() works them out: each rule's among as a bit mask over kinds, each kind's query radius (its largest rule radius) and its reach to every kind (the largest radius of its rules that see that kind), and the grid's cell size (the most populous kind's query radius, at least 16).

Declaration Summary
val kinds: List<BoidKind>
val names: List<String>
fun among(rule: BoidRule, self: Int): Int The bit mask of kinds rule of kind self sees.
val queryRadius: List<Double> The largest rule radius of each kind.
val reachSquared: List<List<Double>> reachself: how far kind self looks at kind other, squared.
val cellSize: Double Cells are sized for the most populous kind; kinds that see further scan more of them.
val first: List<Int> The first boid of each kind; boids are laid out kind by kind.
val count: Int

BoidSketch

class BoidSketch(val module: ComputeModulePointer, val renderer: Renderer = fromContext(), configure: BoidSketchSettings.() -> Unit = {})

Hosts a flock whose look is a compute shader built on #include "boids/flock": the shader defines vec4 color(Boid b) (and flowAngle or the custom-rule hooks when the spec uses them), and this class does everything else: it writes the spec into the rule and kind tables, places the boids, steps them BoidSketchSettings.steps times a frame at BoidSketchSettings.fps over a GpuNeighbourGrid, keeps a 121-step history of each, and draws rocks, trails, bodies and the inspector into a PointAccumulation faded by BoidSketchSettings.fade each frame.

Declaration Summary
val module: ComputeModulePointer The compute module holding the flock's shader, usually computeModuleHere {}.
val renderer: Renderer
val settings The spec, after configure ran; change it to steer the running flock.
val tables The spec worked out into tables, as built.
val count: Int How many boids, fixed for the life of the flock.
val latest: FrameBufferGroup The buffer holding the latest step's boids.
val size: Vector2f The world's size in window points: the page's W and H.
val steps: Long Steps taken since the flock started, the page's t.
val history: FrameBufferGroup The 121-step history of every boid, vec2s, boid by boid.
val strokes: FrameBufferGroup This frame's inspector and rock strokes, in ParticleSketch.strokes' format.
fun reset() Place every boid afresh, as the page's reset (R).
fun metrics(): BoidMetrics The metrics card, from the latest frame: polarization (how aligned the flock is, 0..1), mean speed in pixels per step, visible flockmates per boid, boids per kind, how many were eaten, and the step.

BoidMetrics

data class BoidMetrics(val boids: Int, val perKind: List<Pair<String, Int>>, val polarization: Double, val meanSpeed: Double, val visibleFlockmates: Double, val caught: Int, val step: Long, val inspected: Int)

The page's metrics card.

Declaration Summary
val boids: Int
val perKind: List<Pair<String, Int>>
val polarization: Double The length of the mean heading, 0 (every way) to 1 (all one way).
val meanSpeed: Double Pixels per step.
val visibleFlockmates: Double Flockmates of its own kind each boid sees, on average.
val caught: Int
val step: Long
val inspected: Int The inspected boid, or -1.
fun markdown(): String The card as Markdown.

BoidShaders

object BoidShaders

The packaged GLSL behind BoidSketch. #include "boids/flock" in a compute shader reaches flock.glsl, the harness with the boid struct, the tables, every rule and the step.

Declaration Summary
fun has(name: String): Boolean Whether boids/<name> names one of these sources, with or without .glsl.
fun source(name: String): String The source of name (flock, flock.glsl, ...).

IteratedSystem.kt

Iterated systems: many particles, each stepped by the same rule every frame and plotted after every step, so their visits pile up into the shape the rule keeps returning to — the chaos game behind an IFS fern, the flow of a strange attractor, a fractal flame.

src/jvmMain/kotlin/afield/simulation/particles/IteratedSystem.kt — read the source

Top-level functions

Declaration Summary
fun Box.showControls(system: IteratedSystem, channel: String = "controls") The fern demo's toolbar for system, in this box's controls output: frames per second, steps per frame, fade and exposure, each steering the running system, and a reset button. The panel opens, as the demo's toolbar is always in view.

IteratedSystemSettings

class IteratedSystemSettings : PointAccumulationSettings()

Settings for an IteratedSystem: the PointAccumulation settings (viewport, background, fade, blend, tone map, exposure) plus how many particles there are and how they are stepped. particles and maxSteps are fixed when the system is built; the rest can change while it runs.

Declaration Summary
var particles: Int How many particles. Each is one GPU invocation, so a million is fine.
var steps: Int Steps per particle per frame. Every step is plotted.
var maxSteps: Int The most steps can be raised to while running; 0 means four, or steps if that is more.
var fps: Double Frames stepped per second; 0 steps every frame the renderer draws. Lower shows the build-up.
var dt: Double The dt the shader's step is given.
var seed: Int Changes every particle's random numbers.

IteratedSystem

class IteratedSystem(val module: ComputeModulePointer, val renderer: Renderer = fromContext(), configure: IteratedSystemSettings.() -> Unit = {})

Hosts an iterated system whose rule is a compute shader built on #include "points/system": the shader defines initParticle, step, project, shouldDraw, color and pointSize, and the include's main() steps every particle IteratedSystemSettings.steps times a frame and writes a point per step. This class allocates the particle and point buffers, dispatches the shader in the box's compute module, and plots the points into a PointAccumulation it installs on the renderer.

Declaration Summary
val module: ComputeModulePointer The compute module holding the system's shader, usually computeModuleHere {}.
val renderer: Renderer The renderer to run on; by default the one this box is running in.
val settings The settings after configure ran; change them to steer the running system.
val particles: Int How many particles, fixed for the life of the system.
val maxSteps: Int The room per particle in points, fixed for the life of the system.
val state: FrameBufferGroup Two vec4s per particle: (pos, age) and data.
val points: FrameBufferGroup maxSteps AccumulatedPoint records per particle, rewritten every stepped frame.
val accumulation: PointAccumulation Where the points pile up.
val mesh: ComputeMesh The dispatch in module that runs the shader.
var frame: Int Frames stepped so far.
fun reset() Start every particle again with initParticle, and clear the picture.

ParticleSketch.kt

Particle sketching on the GPU: particles you paint into the renderer window by dragging, each moved every frame by a compute shader written against #include "particles/sketch", drawn as glowing dots with threads between them and trails behind them. The Field port of the madp Particles demo; dev_docs/madp/particles.md has the mapping from its JS hooks.

src/jvmMain/kotlin/afield/simulation/particles/ParticleSketch.kt — read the source

Top-level functions

Declaration Summary
fun Box.showControls(sketch: ParticleSketch, channel: String = "controls") madp's toolbar for sketch, in this box's controls output: the trail, a pause, and a reset that empties the world. The panel opens, as the demo's toolbar is always in view.

ParticleSketchSettings

class ParticleSketchSettings

The world a ParticleSketch runs in: madp's world object, set where madp's init() set it. Everything here except name, capacity and maxStrokes can change while the sketch runs; every frame reads it afresh.

Declaration Summary
var name: String Names the buffers, passes and pointer handler; a second sketch under the same name replaces the first.
var capacity: Int The most particles alive at once. Painting past it replaces the oldest.
var maxStrokes: Int The most links and overlay shapes drawn in a frame; past it the rest are dropped.
var gravity: Vector2f Acceleration every particle starts each frame with, in pixels per second per second (y down).
var damping: Double The fraction of its velocity a particle keeps each frame, after the forces.
var maxSpeed: Double The world's speed limit in pixels per second, applied after damping.
var background: Vec4 The ground, as an sRGB colour: hexColor("#03060a"). madp's fadeColor is the same colour.
var particleColor: Vec4 The colour a particle starts with before spawn sets one, sRGB with straight alpha.
var particleRadius: Double The radius a particle starts with before spawn sets one, in pixels.
var trail: Double madp's Trail: how much of the previous frame stays on screen, 0..1, per 60th of a second (madp's frame), whatever the renderer's frame rate. 0 clears every frame; 0.3 leaves short motion-blurred tails.
var pairRadius: Double How far apart two particles can be for eachPair to see them; 0 runs no pairs (madp's eachPair(r, ...)).
var paintSpacing: Double Pixels between the particles a drag lays down (madp's paint spacing).
var paintWithMouse: Boolean Whether dragging in the renderer window paints particles.
var fps: Double Steps per second; 0 steps on every frame the renderer draws. madp runs at the browser's 60, and its per-frame constants assume it: drag(0.02), damping and the trail are all "per frame", so at the thousands of frames a second an unthrottled window can draw they would stop everything dead. Between steps the picture holds still.
var paused: Boolean Holds the simulation still; the picture keeps drawing, and painting still adds particles.
var seed: Int Changes every random number the shader draws.

ParticleSketch

class ParticleSketch(val module: ComputeModulePointer, val renderer: Renderer = fromContext(), configure: ParticleSketchSettings.() -> Unit = {})

Hosts a particle sketch whose behaviour is a compute shader built on #include "particles/sketch". The shader defines madp's hooks as GLSL — spawn(at), eachPair(other, pair), update() and draw() — and this class runs them: it keeps the particles in two GPU buffers that swap every frame, rebuilds a GpuNeighbourGrid over them (72-pixel cells, as madp's spatial hash), steps every particle, and draws links, dots and overlays into a PointAccumulation that fades by 1 - trail each frame, shown over ParticleSketchSettings.background.

Declaration Summary
val module: ComputeModulePointer The compute module holding the sketch's shader, usually computeModuleHere {}.
val renderer: Renderer The renderer to run on; by default the one this box is running in.
val settings The world, after configure ran; change it to steer the running sketch.
val capacity: Int The most particles alive at once, fixed for the life of the sketch.
val particles: List<FrameBufferGroup> The two particle buffers; latest is the one holding the current frame.
val accumulation: PointAccumulation The picture: links, dots and overlays piling up with a fade.
val strokes: FrameBufferGroup This frame's links and overlays: a uint count, three of padding, then one Stroke (three vec4s: points, colour, shape) for each.
val grid: GpuNeighbourGrid The neighbour grid eachPair, flock, clump and neighbors search.
val count: Int How many particles are alive.
val isEmpty: Boolean True when there are no particles and none waiting to be added, as on a fresh start or after reset.
val time: Double Seconds the sketch has run, madp's t; it stops while paused.
val latest: FrameBufferGroup The buffer holding the current frame's particles.
fun reset() Empty the world and the picture; t starts again from 0.
fun paint(x: Double, y: Double) Paint one particle at (x, y), as a click in the window does.
fun paint(x0: Double, y0: Double, x1: Double, y1: Double) Paint along the line from (x0, y0) to (x1, y1) as a drag does: a particle every ParticleSketchSettings.paintSpacing pixels, the first one spacing in from the start.
fun paint(points: List<Vector2f>) Paint along a polyline of window points, as one drag through them.
val size: Vector2f The world's size in window points, as width/height read in the shader.

SpawnBrush

class SpawnBrush(var spacing: Double = 8.0)

Turns the pointer, read once a frame, into paint, as madp's canvas does with mouse events: a press inside the window lays one particle, and moving with the button held lays one every spacing pixels along the way (at least one per move). A drag that starts inside keeps painting when it leaves the window; one that starts outside paints nothing.

Declaration Summary
fun step(x: Double, y: Double, down: Boolean, inside: Boolean, paint: (Double, Double) -> Unit) One frame's pointer: where it is, whether the button is held, whether it is over the window.

ParticleShaders

object ParticleShaders

The packaged GLSL behind ParticleSketch. #include "particles/sketch" in a compute shader reaches sketch.glsl, the harness with the particle struct, the hooks and the verbs.

Declaration Summary
fun has(name: String): Boolean Whether particles/<name> names one of these sources, with or without .glsl.
fun source(name: String): String The source of name (sketch, sketch.glsl, ...).

PetriEditorExtension.kt

Serves the #petri editor extension, which highlights, checks, documents and completes Petri net regions embedded in box code.

The extension itself is src/commonMain/resources/petri.js, loaded in the browser as the AMD module bithero/petri. petriEditorExtensionJavascript returns that same file's text and petriEditorExtensionHtml wraps it in a self-installing panel for a box output.

src/jvmMain/kotlin/afield/simulation/petri/PetriEditorExtension.kt — read the source

Top-level functions

Declaration Summary
fun petriEditorExtensionJavascript(): String
fun petriEditorExtensionHtml(): String

PetriEditorExtension

object PetriEditorExtension

The #petri editor extension's id and source.

Declaration Summary
const val id: String
fun javascript(): String The Petri editor extension. The single source of truth is the static resource src/commonMain/resources/petri.js, which is served to the browser as the AMD module bithero/petri (see bootstrapMonaco.js) and read verbatim here so the node-probe smoke test exercises the exact code that ships. The file self-installs under bare eval (no AMD loader) and registers an AMD module when a Monaco loader is present.
fun html(): String

PetriLayoutSolver.kt

The force-directed layout behind PetriLayout.FORCE: a spring relaxation run many times from different random starts, each scored for legibility, with the best drawing kept.

A spring model settles into a local minimum of its own energy, which is not the same thing as a drawing you can read: thirty nodes of dining philosophers reach a respectable energy and still come out as a hairball. So the energy is not the verdict. Each restart is scored on what makes a graph legible, crossings first, then overlapping nodes, then nodes standing on an edge, and the best-scoring drawing wins. A restart with none of the three ends the search early.

Random, but repeatable. Every restart is seeded from a hash of the net's own structure, so the same net draws the same way on every machine and every run.

src/jvmMain/kotlin/afield/simulation/petri/PetriLayoutSolver.kt — read the source

PetriSmokeTest.kt

The whole stack, end to end, in one headless process: model, notation, analysis, the pattern bridge, the live runtime, the drawing, and — the part that matters most — the sound.

The unit tests in src/jvmTest pin each layer's semantics. This asserts the thing they cannot: that a net put through the real offline voice actually produces audio with the onsets the net says it should. "It plays" is measured here, not claimed. A net whose piano roll looks right and whose WAV is silent would pass every unit test in the tree and fail this one.

Nothing committed is written. Pass an output directory as args[0] (or set PETRI_SMOKE_DIR) to keep the rendered WAVs and SVGs for listening and looking at.

src/jvmMain/kotlin/afield/simulation/petri/PetriSmokeTest.kt — read the source

PetriSmokeTest

object PetriSmokeTest

Entry point for the petri.smoke test.

Declaration Summary
fun main(args: Array<String>)
fun runTests(outputDir: File? = null): Boolean

CSVReader.kt

A reader for motion-capture CSV, and a clip that samples it at an arbitrary time.

The format is one row per captured frame: a time in the first column, then three columns per joint. Joint names come out of the header, which is expected to read time,Hip.x,Hip.y,Hip.z,Knee.x,…: every third cell, cut at its first .. MotionClip.load does that parsing and is the only way to build a clip from a file. It sits on the companion object, so it has no row of its own on this page.

MotionClip sorts its frames by time on construction, and sample(t) clamps t into the clip's range, binary-searches for the bracketing pair of frames and interpolates every joint linearly between them. The vectors that come back are fresh copies, so a caller can move them around without disturbing the clip.

A clip holds no reference to a solver and nothing else in afield.simulation.physics reads one, so which particle a joint drives is a decision for the calling code. Two kinds of malformed input load without an exception. A data row with fewer fields than the header promises is skipped, so a truncated file becomes a shorter clip; and a file whose every data row is short becomes a clip of no frames, which answers sample with an empty map and throws from start, end and duration.

src/jvmMain/kotlin/afield/simulation/physics/CSVReader.kt — read the source

Top-level functions

Declaration Summary
fun main() Example usage: load motion.csv, sample it at 0.05 and print the pose a joint at a time.

Frame

data class Frame(val time: Float, val joints: Map<String, Vector3f>)

One captured frame: a timestamp, and a position per joint name.

Declaration Summary
val time: Float
val joints: Map<String, Vector3f>

MotionClip

class MotionClip(frames: List<Frame>)

A sequence of Frames, sampled by time.

Declaration Summary
val start : Float
val end : Float
val duration : Float
val bounds : Pair<Vec3, Vec3> The axis-aligned extent of every joint of every frame, as (minimum, maximum).
fun sample(time: Float): Map<String, Vector3f> Every joint's position at time, interpolated linearly between the two frames bracketing it.

Cloth.kt

A rectangular cloth patch on a SimdVerlet simulation.

Cloth.addTo (or SimdVerlet.addCloth) appends width * height particles to an existing simulation, wires their edge springs into the constraint solve, and returns a handle that knows the grid. The handle drives the whole simulation through Cloth.step or the infinite-sequence Cloth.frames, and exposes grid coordinates via Cloth.position, Cloth.pin and Cloth.grabNearest.

src/jvmMain/kotlin/afield/simulation/physics/Cloth.kt — read the source

Used in: Frames and the animation loop, Physics: particles, constraints and cloth

Top-level functions

Declaration Summary
fun SimdVerlet.addCloth(width: Int, height: Int, spacing: Float = 0.5f, stiffness: Float = 0.8f, mass: Float = 1f, curvatureStrength: Float = stiffness / 3f, position: (col: Int, row: Int) -> Vector3fc = { col, row -> vec(col * spacing, row * spacing, 0f) }, restLengthFromLayout: Boolean = false, pinned: (col: Int, row: Int) -> Boolean = { _, row -> row == height - 1 }, constraints: SimdVerlet.DirectedConstraints4? = this.constraints4, name: String? = null): Cloth Appends a cloth patch to this simulation; see Cloth.addTo.

Cloth

class Cloth private constructor(val sim: SimdVerlet, val width: Int, val height: Int, val base: Int, val spacing: Float, val stiffness: Float, val mass: Float, val constraints: SimdVerlet.DirectedConstraints4, val name: String, curvatureStrength: Float)

A rectangular cloth patch living inside somebody else's SimdVerlet.

Declaration Summary
val sim: SimdVerlet The simulation these particles live in.
val width: Int Columns; the x/col axis of the grid.
val height: Int Rows; the y/row axis of the grid. Row height - 1 is the one pinned by default.
val base: Int Index of this cloth's first particle. Its particles are base until base + count.
val spacing: Float Nominal edge length: the default layout's grid step and the default rest length.
val stiffness: Float Distance-constraint stiffness this cloth wired into its slots.
val mass: Float Particle mass used by pin when it un-pins a particle.
val constraints: SimdVerlet.DirectedConstraints4 The constraint set carrying this cloth's grid — shared with the rest of sim.
val name: String Key prefix for everything this cloth registered on sim ("$name.curvature", ...).
var curvatureStrength: Float Strength of the flattening force toward the mean of each interior particle's four axis neighbors, applied every step by the registered bulk pass. 0f disables it live.
val count: Int Number of particles in the patch.
val end: Int One past this cloth's last particle index.
fun index(col: Int, row: Int): Int Particle index of the grid node at (col, row).
operator fun get(col: Int, row: Int): Int Particle index of the grid node at (col, row).
operator fun contains(particle: Int): Boolean True if particle is one of this cloth's.
fun position(col: Int, row: Int): Vec3 Current position of the grid node at (col, row).
fun teleport(col: Int, row: Int, to: Vector3fc, keepVelocity: Boolean = false) Moves one node to to. By default the previous position moves with it, so the Verlet integrator sees a teleport rather than a one-frame velocity spike; pass keepVelocity = true to throw the node instead.
fun pin(col: Int, row: Int, pinned: Boolean = true) Pins (pinned = true, inverse mass 0 — immovable) or releases (inverse mass 1 / mass) one node. Requires the state to carry an invMass array; without one every particle has unit mass and nothing can be pinned.
fun isPinned(col: Int, row: Int): Boolean True if the node at (col, row) has zero inverse mass.
fun accumulateCurvature(state: SimdVerlet.State3, strength: Float = curvatureStrength) The curvature pass, exposed for callers who would rather run it through step's accumulateForces hook than leave it registered in SimdVerlet.bulkForces. Accumulates into state.ax/ay/az and touches only interior nodes, so a 1- or 2-wide cloth gets nothing from it.
fun suppressCurvatureAt(particle: Int) Switches the curvature stencil off at one particle, permanently.
fun isCurvatureSuppressed(particle: Int): Boolean Whether suppressCurvatureAt has switched the curvature stencil off at particle.
fun enableCurvature(strength: Float = curvatureStrength) Registers the curvature pass on sim at strength (default: whatever curvatureStrength is now), replacing it if it is already registered. Only needed for a cloth that was built with curvatureStrength = 0f; otherwise just assign the field.
fun disableCurvature() Drops the curvature pass from sim altogether. accumulateCurvature still works if you would rather run it from step's accumulateForces hook.
fun step(dt: Float = 1f / 60f, damping: Float = 0.99f, gravity: Float = 0f, iterations: Int = 8, accumulateForces: ((state: SimdVerlet.State3, dt: Float) -> Unit)? = null) Steps the whole simulation once with this cloth's constraint set, growing that set first if particles were added since the last step. accumulateForces is forwarded to SimdVerlet.step and runs alongside the registered bulk forces.
fun lines(): List<FLine> The cloth as a wireframe: one FLine per row followed by one per column, so width + height lines in all.
fun frames(dt: Float = 1f / 60f, damping: Float = 0.99f, gravity: Float = 0f, iterations: Int = 8, accumulateForces: ((state: SimdVerlet.State3, dt: Float) -> Unit)? = null): Sequence<List<FLine>> step then lines, forever — the shape a Field box's animation loop consumes.
val grabbedIndex: Int The particle currently held by grabNearest, or -1.
fun grabNearest(worldX: Float, worldY: Float, worldZ: Float = 0f, radius: Float = spacing * 2f, kp: Float = 100f, ki: Float = 0f, kd: Float = 10f, maxForce: Float = 1000f): Int Grabs this cloth's particle nearest to the given world point, provided it is within radius, and starts driving it with a PID position controller — so the grab pulls through the constraint solve instead of teleporting the particle out of the sheet. Returns the grabbed particle index, or -1 if nothing was near enough (in which case any previous grab is left alone). A successful grab replaces a previous one.
fun moveGrabbed(worldX: Float, worldY: Float, worldZ: Float = 0f): Boolean Moves the grab target. Returns false if nothing is grabbed.
fun releaseGrabbed(): Boolean Releases the grabbed particle. Returns false if nothing was grabbed.
fun remove() Detaches this cloth from sim: releases any grab, drops the curvature pass and clears the cloth's constraint slots. The particles stay (removing them would renumber the rest of the simulation); pass them to SimdVerlet.queueRemove yourself if you want them gone.

Dyn4jScene.kt

A 2D rigid-body scene: dyn4j's World behind a Kotlin surface that addresses bodies by handle, with a converter from Field's own drawing into collision geometry.

SimdVerlet has no notion of a rigid body. Its colliders push particles out of shapes, and nothing a particle touches is pushed back by it. This is the other half of that, flat: bodies with mass, rotation, friction and restitution, resolving contacts against one another. JoltPhysicsWorld is the same idea in 3D, and SimdVerletDyn4jBridge runs a particle simulation and one of these scenes in the same frame.

Handles, not bodies

addBody returns a DynBodyHandle rather than the dyn4j Body it made, and every accessor takes one. Handle ids count up from 1 and are never reused, so a handle to a removed body cannot come to mean a later one. A handle whose body has been removed resolves to nothing: getBodyState returns null, removeBody returns false, and the five mutators return without touching anything. Nothing throws on a stale handle, which suits a loop { } editing a scene it is also drawing.

world is public. Joints, raycasts, contact listeners and everything else dyn4j offers are reached through it; what this class covers is making bodies, moving them and reading them back.

Shapes

DynShapeDef has four cases. Box and Circle go straight to dyn4j primitives. Polygon must already be a convex loop, and a loop dyn4j rejects is re-reported as an error carrying dyn4j's own complaint. Concave is decomposed by Bayazit into convex parts, and each part becomes a fixture on the one body, so a concave body is several fixtures sharing a transform.

Both vertex cases are sanitised first: consecutive duplicates are dropped, a repeated closing point is removed, dyn4j's own cleanse runs, and a loop whose signed winding is negative is reversed, so every loop reaching dyn4j turns the same way whichever way it was drawn. Fewer than three distinct vertices fails a require.

Mass and rest

DynBodyKind.STATIC sets MassType.INFINITE; DynBodyKind.DYNAMIC takes its mass from DynBodyDef.density and the fixture areas. Density is floored just above zero, so asking for 0.0 gives a very light body rather than a massless one. dyn4j lets a body that has stopped moving go to rest; every mutator here clears that flag, including the two that write body.transform directly and so bypass dyn4j entirely.

DynBodyState.position is the body's centre of mass, while setBodyTransform writes the transform's translation. For a box or a circle, both of which dyn4j builds around the origin, those coincide. For a polygon given arbitrary vertices they need not, so setting a position and reading it back does not always round-trip.

Stepping

step is one dyn4j iteration of exactly the dtSeconds it is given, so the timestep belongs to the caller. Gravity defaults to zero on both axes: a scene made with no arguments is weightless, and gravity is changed afterwards through world.gravity.

FLines as collision geometry

flattenClosedFLineLoops turns a drawn FLine into vertex loops for DynShapeDef.Concave or Polygon, by way of java.awt.geom path flattening: curves become runs of line segments at the given tolerance and subdivision limit, and each subpath of three or more points comes back as a loop with any duplicate closing point removed. It reads SEG_CLOSE off the path iterator, but its final flush accepts an unterminated subpath as well, and a dyn4j polygon is closed implicitly, so a line drawn without a closing node converts to the same vertices as one drawn with it. InteriorCloseNodeTest.theDyn4jLoopFlattenerWasNeverActuallyBroken pins that.

Nothing else in the repository constructs a Dyn4jScene. These are a surface for box code to call, and that one test case is the only coverage the file has.

src/jvmMain/kotlin/afield/simulation/physics/Dyn4jScene.kt — read the source

DynBodyKind

enum class DynBodyKind

Whether dyn4j moves a body, or it stands still and is only collided against.

Constants: STATIC, DYNAMIC.

DynShapeDef

sealed interface DynShapeDef

The collision shape of a body, in the body's own coordinates.

Declaration Summary
data class Box(val width: Double, val height: Double) : DynShapeDef
data class Circle(val radius: Double) : DynShapeDef
data class Polygon(val vertices: List<Vec2>) : DynShapeDef
data class Concave(val vertices: List<Vec2>) : DynShapeDef

DynBodyDef

data class DynBodyDef(val kind: DynBodyKind, val shape: DynShapeDef, val x: Double = 0.0, val y: Double = 0.0, val angleRad: Double = 0.0, val friction: Double = 0.6, val restitution: Double = 0.0, val density: Double = 1.0, val linearDamping: Double = 0.0, val angularDamping: Double = 0.0, val bullet: Boolean = false, val vx: Double = 0.0, val vy: Double = 0.0, val angularVelocity: Double = 0.0, val userData: Any? = null)

Everything Dyn4jScene.addBody needs to make one body, with defaults that give a unit-density, non-bouncing, undamped body at rest at the origin.

Declaration Summary
val kind: DynBodyKind
val shape: DynShapeDef
val x: Double
val y: Double
val angleRad: Double
val friction: Double
val restitution: Double
val density: Double
val linearDamping: Double
val angularDamping: Double
val bullet: Boolean
val vx: Double
val vy: Double
val angularVelocity: Double
val userData: Any?

DynBodyHandle

data class DynBodyHandle(val id: Long)

An opaque reference to a body in one Dyn4jScene, counted up from 1 and never reused.

DynBodyState

data class DynBodyState(val handle: DynBodyHandle, val position: Vec2, val angleRad: Double, val linearVelocity: Vec2, val angularVelocity: Double, val kind: DynBodyKind)

A body read back out of the scene, as of the last Dyn4jScene.step.

Declaration Summary
val handle: DynBodyHandle
val position: Vec2
val angleRad: Double
val linearVelocity: Vec2
val angularVelocity: Double
val kind: DynBodyKind

Dyn4jScene

class Dyn4jScene(gravityX: Double = 0.0, gravityY: Double = 0.0)

A dyn4j World plus the handle table that keeps DynBodyHandles pointing at the right bodies.

Declaration Summary
val world: World<Body>
fun addBody(def: DynBodyDef): DynBodyHandle Builds the body def describes, adds it to world, and returns the handle it will be known by.
fun removeBody(handle: DynBodyHandle): Boolean Takes the body handle names out of world. false if this scene has no such body, and also false if it had one but dyn4j had already dropped it.
fun clearBodies() Removes every body this scene made, so it is empty and reusable.
fun getBodyState(handle: DynBodyHandle): DynBodyState? A snapshot of one body, or null if handle names nothing in this scene.
fun allBodyStates(): List<DynBodyState> Every body, in the order it was added, as a fresh list of snapshots.
fun setBodyTransform(handle: DynBodyHandle, x: Double, y: Double, angleRad: Double) Teleports a body: its transform is overwritten and its velocity left as it was.
fun setBodyVelocity(handle: DynBodyHandle, vx: Double, vy: Double, angular: Double) Overwrites a body's linear and angular velocity, in units per second and radians per second. Unlike an impulse this ignores the body's mass, and unlike a force it takes effect without a step.
fun applyForce(handle: DynBodyHandle, fx: Double, fy: Double) Adds a world-frame force to a body, to act over the next step.
fun applyImpulse(handle: DynBodyHandle, ix: Double, iy: Double) Adds a world-frame linear impulse to a body: a change of momentum, so the velocity it buys depends on the body's mass. This is the one for a kick.
fun applyTorque(handle: DynBodyHandle, torque: Double) Adds a torque about a body's centre of mass, the angular counterpart of applyForce.
fun step(dtSeconds: Double) Advances the world by exactly dtSeconds: one dyn4j iteration, with nothing accumulated between calls.

JoltCameraFrustumPlanes.kt

Walls for what the camera can see: six static Jolt planes, kept on the view frustum as it moves.

The motivation is a sketch where things are meant to stay on screen. Six plane bodies are created in a JoltPhysicsWorld, one per face and each named for its face under a shared prefix, with their normals turned to face the interior. The region they close off is then exactly the volume the camera is looking at, and a dynamic body inside it stays inside.

The frustum is derived from the camera's fov, aspect and principal-point offsets, with near and far from the camera unless overridden. It is a perspective frustum in every case: this reads nothing from the projection's orthoness, so a camera part way to orthographic gets planes that do not match its picture.

Calling update every frame is cheap when nothing has moved. A plane is rebuilt only when its normal or constant has shifted past updateEpsilon, or when its body has gone missing from the world; add is the same function under a name that reads better the first time. After any rebuild the world's dynamic bodies are woken, unless wakeDynamicBodiesOnUpdate is off, which they have to be: a body asleep against a wall does not notice the wall moving.

Nothing in the repository calls this, in src or in the docs pages.

src/jvmMain/kotlin/afield/simulation/physics/JoltCameraFrustumPlanes.kt — read the source

JoltFrustumPlane

data class JoltFrustumPlane(val name: String, val normal: Vec3, val constant: Double)

One frustum face, as a Jolt plane: a name, a normal, and the plane constant that goes with it.

Declaration Summary
val name: String The body name this face is created under: the frustum's prefix and the face's own name.
val normal: Vec3 Unit normal, pointing into the frustum.
val constant: Double The plane's constant term: its surface is where normal · x + constant comes to zero.

JoltCameraFrustumPlanes

class JoltCameraFrustumPlanes(val world: JoltPhysicsWorld, var prefix: String = "camera-frustum", var nearDistance: Double? = null, var farDistance: Double? = null, var friction: Double = 0.8, var restitution: Double = 0.0, var tags: Set<String> = setOf("frustum", "bounds"), var updateEpsilon: Double = 1.0e-5, var wakeDynamicBodiesOnUpdate: Boolean = true, var wakeIncludeTagRegex: Regex? = null, var wakeExcludeTagRegex: Regex? = null)

Keeps six static plane bodies on a camera's frustum, so that a dynamic body in shot stays in shot.

Declaration Summary
val world: JoltPhysicsWorld The world the six plane bodies are created in, and the one whose bodies get woken.
var prefix: String The stem of all six body names, which get a face name appended: left, right, top, bottom, near, far.
var nearDistance: Double? Near distance to use in place of the camera's own, or null to take the camera's zNear.
var farDistance: Double? Far distance to use in place of the camera's own, or null to take the camera's zFar.
var friction: Double Friction for all six planes. The default is high, on the assumption that a wall should hold.
var restitution: Double Restitution for all six planes. Zero, so the walls absorb rather than bounce.
var tags: Set<String> Tags given to every plane, on top of the prefix and the face's own name.
var updateEpsilon: Double How far a plane has to move before it is worth rebuilding, as a distance on both the normal and the constant.
var wakeDynamicBodiesOnUpdate: Boolean Whether to wake the world's dynamic bodies after a plane has been rebuilt.
var wakeIncludeTagRegex: Regex? Wake only bodies carrying a tag this matches; null wakes them all.
var wakeExcludeTagRegex: Regex? Never wake a body carrying a tag this matches, whatever wakeIncludeTagRegex says.
val names: List<String> The six body names this would use, in face order: left, right, top, bottom, near, far.
fun add(camera: Camera, near: Double? = null, far: Double? = null): List<String> update under a name that reads better on the first call.
fun update(camera: Camera, near: Double? = null, far: Double? = null): List<String> Brings the six planes into line with where the camera is now, and returns their names.
fun remove(): Boolean Takes all six planes out of the world, and reports whether any of them was there.
fun planes(camera: Camera, near: Double? = null, far: Double? = null): List<JoltFrustumPlane> Works out the six planes for this camera without touching the world.

JoltDebugDrawing.kt

Wireframes for the Jolt collision shapes: what JoltPhysicsWorld.debugWireLines3D is made of.

Nothing here is public, which is why this page lists no declarations for it. The file holds one builder per case of JoltShape, an axes gizmo, and the transform and colour steps that follow, all of them internal to JoltPhysicsWorld's use. Each builder draws in the shape's own local space, centred on the origin, and transformLines is what carries the result out to a body's pose.

The drawings are outlines rather than meshes, because they are there to be seen through while something else is rendered inside them. A sphere is three great circles, not a tessellation. A capsule is a ring at each end, a ring at the waist when there is a cylinder to have one, and two stadium profiles at right angles. An infinite plane becomes a square patch around the point on it nearest the origin, with a stub along the normal, which points away from the solid side.

colorForBodyName hashes the name and lifts each channel away from black, which is what keeps a body the same colour from run to run without anyone choosing one.

src/jvmMain/kotlin/afield/simulation/physics/JoltDebugDrawing.kt — read the source

JoltPhysicsBootstrap.kt

Getting the jolt-jni native library loaded, which has to happen once per JVM before any Jolt call.

The library ships inside the jar as a per-platform resource, under an os/arch directory pair followed by com/github/stephengold/ and the platform's own filename, so there is nothing to install and nothing to put on java.library.path. A JNI library cannot be loaded from inside a jar, so the resource is copied to a temporary directory first and the copy is what gets loaded. Point -Dafield.joltjni.library or the AFIELD_JOLTJNI_LIBRARY environment variable at an absolute path to skip the copy and load a library you built yourself.

Both halves of that directory pair are tables, over os.name and os.arch: macOS, Windows or Linux, on aarch64 or x86-64. A platform in neither table fails here, naming the two properties it read, rather than at the first native call.

The only caller is the constructor of JoltPhysicsWorld, which runs this on every world it builds; the second and later calls return immediately.

src/jvmMain/kotlin/afield/simulation/physics/JoltPhysicsBootstrap.kt — read the source

JoltPhysicsBootstrap

object JoltPhysicsBootstrap

One-time native jolt-jni bring-up (library load, cleaner, factory, allocator). Called lazily from the JoltPhysicsWorld constructor via ensureInitialized; see JoltPhysicsWorld.kt for the physics API this supports.

Declaration Summary
fun ensureInitialized() Loads the native library and brings Jolt up, or returns at once if that has already happened.

JoltPhysicsWorld.kt

Rigid bodies in three dimensions, from the Jolt physics engine: the 3D counterpart to Dyn4jScene, and a different tool from SimdVerlet, which has no notion of a rigid body at all.

A body here is a name. createBody takes a JoltBodyDef carrying a string name, a JoltShape and a pose, and everything afterwards addresses that body by the same string: applyImpulse("crate", …), mass("crate"), removeBody("ground"). Names are unique, and creating a body over a name that already exists removes the old one first, so re-running a box that builds a scene rebuilds it rather than stacking a second copy on top. step hands back one JoltBodyState per body, named the same way.

Nothing from the jolt-jni bindings reaches any of those signatures. Shapes, poses and velocities are Field's own Vec3 and Quat, the four cases of JoltShape are the whole shape vocabulary, and the conversions both ways are private at the bottom of this file. The native library loads itself on the first world built (JoltPhysicsBootstrap).

Two facts about what counts as static: a JoltShape.Plane is static whatever its dynamic flag says, since an infinite plane has no volume to take a mass from, and collision filtering has exactly two object layers, moving and non-moving, with the non-moving pair disabled. So "static" means either dynamic = false or a plane, and nothing static is ever tested against anything else static.

pickBodyNearPoint asks Jolt nothing. It walks the named bodies and solves point-to-surface in closed form for a cuboid, a sphere and a capsule, so the distance it reports is signed, with negative meaning the query point is inside the body. Planes are skipped, which is usually what you want: a click near the ground finds the crate on it and not the ground. pickAndBeginRigidGrab is that search and beginRigidGrab in one call, for a controller that has just closed.

The wireframes debugWireLines3D returns are built in JoltDebugDrawing.kt, one set of FLines per shape kind. Evaluating a world, a JoltBodyState or a JoltBodyPick in a box shows a formatted summary instead of a toString, which registerJoltPhysicsVisualizers installs.

The *3D-suffixed typealiases at the bottom of the file, and JoltBodyDef's x/y/z secondary constructor, are an earlier spelling of all of this and are deprecated in place; new code uses the unprefixed names and position: Vec3.

src/jvmMain/kotlin/afield/simulation/physics/JoltPhysicsWorld.kt — read the source

Top-level functions

Declaration Summary
fun registerJoltPhysicsVisualizers()

JoltShape

sealed interface JoltShape

The four collision shapes a body can have: a cuboid, a sphere, a capsule or an infinite plane.

Declaration Summary
data class Cuboid(val width: Double, val height: Double, val depth: Double) : JoltShape A box of these full extents, centred on the body's position.
data class Sphere(val radius: Double) : JoltShape A sphere of this radius, centred on the body's position.
data class Capsule(val radius: Double, val height: Double) : JoltShape A capsule of this radius, standing on the body's local Y axis.
data class Plane(val nx: Double = 0.0, val ny: Double = 1.0, val nz: Double = 0.0, val constant: Double = 0.0) : JoltShape The half-space nx * x + ny * y + nz * z + constant <= 0, whose surface is an infinite plane.

JoltBodyDef

data class JoltBodyDef(val name: String, val shape: JoltShape, val position: Vec3 = Vec3(), val rotation: Quat = Quat(), val dynamic: Boolean = true, val friction: Double = 0.6, val restitution: Double = 0.0, val linearDamping: Double = 0.0, val angularDamping: Double = 0.0, val linearVelocity: Vec3 = Vec3(), val angularVelocity: Vec3 = Vec3(), val mass: Double? = null, val density: Double? = null, val tags: Set<String> = emptySet())

Everything needed to create one named rigid body, and what createBody takes.

Declaration Summary
val name: String
val shape: JoltShape
val position: Vec3
val rotation: Quat
val dynamic: Boolean
val friction: Double
val restitution: Double
val linearDamping: Double
val angularDamping: Double
val linearVelocity: Vec3
val angularVelocity: Vec3
val mass: Double?
val density: Double?
val tags: Set<String>
val x: Double
val y: Double
val z: Double
val vx: Double
val vy: Double
val vz: Double
val wx: Double
val wy: Double
val wz: Double

JoltBodyBuilder

class JoltBodyBuilder(var name: String, var shape: JoltShape? = null)

Builds a JoltBodyDef a statement at a time, which is what the world.cuboid(…) { … } helpers configure.

Declaration Summary
var name: String
var shape: JoltShape?
var position: Vec3
var rotation: Quat
var dynamic: Boolean
var friction: Double
var restitution: Double
var linearDamping: Double
var angularDamping: Double
var velocity: Vec3
var linearVelocity: Vec3
var angularVelocity: Vec3
var mass: Double?
var density: Double?
var tags: MutableSet<String>
fun position(x: Number, y: Number, z: Number)
fun velocity(x: Number, y: Number, z: Number)
fun linearVelocity(x: Number, y: Number, z: Number)
fun angularVelocity(x: Number, y: Number, z: Number)
fun tag(vararg values: String)
fun tags(values: Iterable<String>)
fun cuboid(width: Number, height: Number, depth: Number)
fun sphere(radius: Number)
fun capsule(radius: Number, height: Number)
fun plane(normal: Vec3 = vec(0, 1, 0), constant: Number = 0.0)
fun planeComponents(nx: Number, ny: Number, nz: Number, constant: Number = 0.0)
fun build(): JoltBodyDef

JoltBodyState

data class JoltBodyState(val name: String, val position: Vec3, val rotation: Quat, val linearVelocity: Vec3, val angularVelocity: Vec3, val bodyId: Int)

One body's pose and velocity, read back out of Jolt: what step and states return, one per body.

Declaration Summary
val name: String
val position: Vec3
val rotation: Quat
val linearVelocity: Vec3
val angularVelocity: Vec3
val bodyId: Int

JoltBodyPick

data class JoltBodyPick(val name: String, val bodyId: Int, val queryPoint: Vec3, val surfacePoint: Vec3, val signedDistanceToSurface: Double)

The body pickBodyNearPoint chose, and how far outside it the query point was.

Declaration Summary
val name: String
val bodyId: Int
val queryPoint: Vec3
val surfacePoint: Vec3
val signedDistanceToSurface: Double

JoltPhysicsWorld

class JoltPhysicsWorld(gravityX: Double = 0.0, gravityY: Double = -9.8, gravityZ: Double = 0.0) : AutoCloseable

Rigid-body physics world backed by the Jolt physics engine via the stephengold jolt-jni bindings. This is the single entry point of the afield.simulation.physics Jolt integration; the whole public surface (JoltShape, JoltBodyDef, JoltBodyState, JoltBodyPick, the JoltBodyBuilder DSL and this class) lives in this one file.

Declaration Summary
val bodyIdsByName: MutableMap<String, Int> Every body in the world, name to Jolt id, in creation order.
val grabbedBodyName: String? The name of the body currently held by a rigid grab, or null if nothing is held.
fun createBody(def: JoltBodyDef): Int Creates the body def describes, replacing any body already using its name, and returns Jolt's id for it.
fun body(name: String, shape: JoltShape? = null, configure: JoltBodyBuilder.() -> Unit = {}): JoltBodyDef Describes a body through a JoltBodyBuilder block, without creating it.
fun createBody(name: String, shape: JoltShape? = null, configure: JoltBodyBuilder.() -> Unit = {}): Int
fun cuboid(name: String, width: Number, height: Number, depth: Number, configure: JoltBodyBuilder.() -> Unit = {}): Int
fun sphere(name: String, radius: Number, configure: JoltBodyBuilder.() -> Unit = {}): Int
fun capsule(name: String, radius: Number, height: Number, configure: JoltBodyBuilder.() -> Unit = {}): Int
fun plane(name: String, normal: Vec3 = vec(0, 1, 0), constant: Number = 0.0, configure: JoltBodyBuilder.() -> Unit = {}): Int
fun planeComponents(name: String, nx: Number, ny: Number, nz: Number, constant: Number = 0.0, configure: JoltBodyBuilder.() -> Unit = {}): Int
fun removeBody(name: String): Boolean Removes the body called name, and reports whether there was one.
fun isDynamic(name: String): Boolean Whether name is a body being moved by the simulation.
fun mass(name: String): Double? The mass name was given, or null if it was left to Jolt.
fun tags(name: String): Set<String> The tags name was created with, or an empty set for an untagged or unknown body.
fun activateBody(name: String): Boolean Wakes one body by name, and reports whether there was one to wake.
fun activateBodies(dynamicOnly: Boolean = true, includeTagRegex: Regex? = null, excludeTagRegex: Regex? = null): Int Wakes a chosen set of bodies and returns how many were woken.
fun clearBodies() Destroys every body, leaving an empty world that is still usable.
fun step(dt: Double): List<JoltBodyState> Advances the world by dt seconds and returns every body's new state.
fun states(): List<JoltBodyState> Reads every body's pose and velocity without advancing anything.
fun applyForce(name: String, force: Vec3) Pushes on a body, at its centre of mass, for the length of the next step.
fun applyForce(name: String, fx: Double, fy: Double, fz: Double)
fun applyImpulse(name: String, impulse: Vec3) Changes a body's momentum at once, rather than over a step.
fun applyImpulse(name: String, ix: Double, iy: Double, iz: Double)
fun applyTorque(name: String, torque: Vec3) Twists a body for the length of the next step, on the same terms as applyForce.
fun applyTorque(name: String, tx: Double, ty: Double, tz: Double)
fun setBodyTransform(name: String, position: Vec3, rotation: Quat) Teleports a body: sets its pose outright, with no motion between where it was and where it lands.
fun setBodyVelocity(name: String, linear: Vec3, angular: Vec3 = Vec3()) Sets both velocities outright, replacing whatever the body had.
fun pickBodyNearPoint(point: Vec3, maxOutsideDistance: Double = 0.08, includeStatic: Boolean = false): JoltBodyPick? Finds the body whose surface point is nearest to, or null if nothing is within maxOutsideDistance of it.
fun beginRigidGrab(name: String, controllerPosition: Vec3, controllerRotation: Quat): Boolean Attaches the dynamic body name rigidly to a controller at this pose, and reports whether it took.
fun pickAndBeginRigidGrab(queryPoint: Vec3, controllerPosition: Vec3, controllerRotation: Quat, maxOutsideDistance: Double = 0.08): JoltBodyPick? pickBodyNearPoint and beginRigidGrab in one call: grabs whatever is nearest queryPoint, and returns what it found.
fun updateRigidGrabTransform(controllerPosition: Vec3, controllerRotation: Quat): Boolean Tells a live grab where the controller is now, and reports whether there was one to tell.
fun releaseRigidGrab(): String? Lets go, returning the name of the body released, or null if nothing was held.
fun debugWireLines3D(sphereSegments: Int = 24, capsuleSegments: Int = 24, axisLength: Float = 0f, dynamicOnly: Boolean = false, includeTagRegex: Regex? = null, excludeTagRegex: Regex? = null): List<FLine> Draws the collision shapes as wireframe FLines, in world space, one colour per body name.
override fun close() Releases the native side of the world: its bodies, job threads, temporary allocator and physics system, in that order.

JoltShape3D

typealias JoltShape3D = JoltShape

JoltBodyDef3D

typealias JoltBodyDef3D = JoltBodyDef

JoltBodyState3D

typealias JoltBodyState3D = JoltBodyState

JoltBodyPick3D

typealias JoltBodyPick3D = JoltBodyPick

JoltPhysicsWorld3D

typealias JoltPhysicsWorld3D = JoltPhysicsWorld

ShapeDef3D

typealias ShapeDef3D = JoltShape

BodyDef3D

typealias BodyDef3D = JoltBodyDef

BodyState3D

typealias BodyState3D = JoltBodyState

BodyPick3D

typealias BodyPick3D = JoltBodyPick

SimplePhysics3D

typealias SimplePhysics3D = JoltPhysicsWorld

OctreeBarnesHut.kt

Approximate inverse-square forces over a particle cloud, by summarising distant groups as a single mass each.

Every particle acting on every other one is a quadratic sum. Barnes-Hut trades a bounded error for n log n: build an octree over the positions, give each node the total mass and centre of mass of everything beneath it, then walk the tree once per particle and stop at any node that subtends a small enough angle, using its summary instead of descending into it. OctreeBarnesHut is that, over separate x/y/z FloatArrays, which is the layout SimdVerlet holds its state in, though the solver itself never calls this. OctreeBarnesHutPool hands out reusable instances.

A step is two calls, build then applyForces, and the masses the second one uses are the ones the first captured. applyForces accumulates into the acceleration arrays with +=, so what it computes adds to whatever the caller has already written there.

The kernel is g * m / r² along the vector from the particle to a node's centre of mass, which makes the sign of g the choice of what the law is for. Positive attracts, and is gravity; negative repels, which is how a force-directed graph layout uses one of these to avoid comparing every pair of nodes. theta sets how rough the answer is: zero descends all the way to individual particles and is the exact pairwise sum, larger values accept more summarised nodes and do less work. softening is added in quadrature to the distance, bounding the force at short range so that a near-coincidence does not produce an arbitrarily large acceleration.

The node arrays grow by half again when they must and are never handed back, and clear only resets the node count, so a tree rebuilt every frame allocates nothing on the serial build path. The parallel path allocates its eight octant index arrays per build. Mutation, meaning build and clear, is not thread-safe. A parallel applyForces is, because each worker takes its traversal stack from a ThreadLocal; the serial path instead uses the one stack it shares with forEachCuboid.

Particles are separated by comparing a coordinate against a node's centre, so two at exactly the same position fall into the same child at every depth and subdivision has nothing to terminate on. Jitter coincident inputs before building.

src/jvmMain/kotlin/afield/simulation/physics/OctreeBarnesHut.kt — read the source

OctreeBarnesHut

class OctreeBarnesHut(initialCapacity: Int = 1024, private val allowParallelBuild: Boolean = true)

An octree with Barnes-Hut mass summaries over separate x/y/z arrays.

Declaration Summary
var nodeCount: Int How many octree nodes the last build left behind, and the number applyForces and forEachCuboid read as "is there a tree here".
fun clear() Forgets the tree without releasing any memory, leaving the instance ready to build again.
fun build(x: FloatArray, y: FloatArray, z: FloatArray, massIn: FloatArray?, invMass: FloatArray?, count: Int, buildExecutor: SimdVerlet.ForceExecutor? = null, minParallelCount: Int = 8192) Builds the octree over the first count entries of x, y and z, and computes a mass and a centre of mass for every node.
fun applyForces(x: FloatArray, y: FloatArray, z: FloatArray, ax: FloatArray, ay: FloatArray, az: FloatArray, massIn: FloatArray?, invMass: FloatArray?, count: Int, theta: Float, softening: Float, g: Float, forceExecutor: SimdVerlet.ForceExecutor? = null, minChunk: Int = 512) Walks the tree once per particle and adds the resulting acceleration into ax, ay and az.
fun forEachCuboid(depthLimit: Int = Int.MAX_VALUE, consumer: (cx: Float, cy: Float, cz: Float, half: Float) -> Unit) Hands every node's cube to consumer as a centre and a half-width, root first.
fun appendCuboidsTo(out: FLine, depthLimit: Int = Int.MAX_VALUE) forEachCuboid, drawn: appends each node's cube to out as a wireframe.

OctreeBarnesHutPool

class OctreeBarnesHutPool(private val maxTrees: Int, private val initialCapacity: Int = 1024, private val allowGrow: Boolean = true)

Reusable OctreeBarnesHut instances, so that a per-frame rebuild reuses one tree's node arrays instead of growing fresh ones.

Declaration Summary
fun acquire(): OctreeBarnesHut Takes a tree out of the pool, allocating one at initialCapacity if the pool is empty and allowGrow permits it, and failing otherwise.
fun release(tree: OctreeBarnesHut) Clears tree and returns it to the pool, dropping it instead if the pool is already at maxTrees.

PointController.kt

PointDriver -- position in, force out.

val drv = PointDriver(DriverConfig(dt = 1.0 / 120.0, worldScale = 1.0))
drv.grab(target)                     // may be called every frame; target can move
val f = drv.update(pos)              // per frame, fixed dt
drv.releaseGrab()                    // then keep calling update() -- see below

Vectors are Field's ordinary Vector3f; every scalar the controller reasons about -- gains, timescales, the identified plant -- stays Double. The file is otherwise standalone.

The whole controller state lives in the instance, so a rune-keyed slot holding one PointDriver per particle survives hot-reload with the learned plant model intact. Nothing needs re-priming except omega, which re-converges in ~1s.

What it does, in four coupled timescales

  1. ~dt model-inverting PD+I on acceleration, output through a bounded-curvature force limiter (magnitude, magnitude rate, and angular rate/acceleration on the direction).
  2. ~100ms discrete-time RLS identification of the host's own integrator: x(k+1) - 2x(k) + x(k-1) = dt^2 (b F(k) + c v(k) + d) exact under semi-implicit Euler, so b^ absorbs the host's numerical damping and dispersion, not just the mass. Gains are synthesised from (b,c,d); the only free knobs are physical.
  3. ~seconds omega hunting driven by sin(lagAngle) -- the direction-limiter's turn-rate deficit. Slow rise, fast knockback: self-organised criticality that pins omega to the capture/orbit bifurcation.
  4. ~10s winding memory in the limiter's rotation axis. Chirality flips become rare punctuated events with heavy-tailed dwell times.

Position-only input, done properly

  • Velocity comes from the host's delta-position, which is the average over the interval and therefore centred at t - dt/2. Corrected by extrapolating dt/2 forward through the identified model -- pure phase recovery, no noise amplification. Uncorrected this donates omega*dt/2 of lag straight to the limiter and lowers the bandwidth ceiling.
  • TWO velocity signals, never crossed: vHat (model observer) -> control law only vPrev (raw, prefiltered) -> identifier only Feeding the observer estimate to the identifier is circular -- it was built from the force and the current model, so the RLS would confirm whatever it already believes. That failure looks stable for minutes, then diverges.
  • Jump guard: a single-frame teleport or collision reads as enormous velocity. Rejected on a running speed estimate; the identifier skips two frames while the second-difference window refills, and the model is left untouched.
  • Identification freezes while the limiter is saturated. If the host solves springs as constraints (PBD/XPBD, fixed iterations) the effective stiffness depends on how hard we are pushing, which makes b^ a function of our own aggression -- a self-referential loop that can actually diverge. Freezing plus a slow forgetting factor breaks it.
  • Release ramps magnitude to zero and FREEZES the aim, and keeps identifying through the ringdown. Free modal data with no control input to confound it, and the retained aim couples successive grabs.

src/jvmMain/kotlin/afield/simulation/physics/PointController.kt — read the source

DriverConfig

data class DriverConfig(val dt: Double = 1.0 / 120.0, val worldScale: Double = 1.0, val zeta: Double = 0.85, val omega0: Double = 4.0, val omegaMin: Double = 0.5, val omegaMax: Double = 80.0, val integralFrac: Double = 0.08, val integralLeak: Double = 0.25, val maxForce: Double = 40.0, val magRateMax: Double = 2000.0, val angRateMax: Double = 6.0, val angAccelMax: Double = Double.POSITIVE_INFINITY, val axisMemoryTau: Double = 0.25, val antipodalGuard: Double = 0.02, val releaseTau: Double = 0.03, val lambda: Double = 0.997, val p0: Double = 1e3, val pTraceMax: Double = 1e4, val filterRatio: Double = 6.0, val deadzone: Double = 1e-3, val bMin: Double = 1e-3, val bMax: Double = 1e4, val cMax: Double = 60.0, val dMax: Double = 500.0, val freezeIdWhenSaturated: Boolean = true, val observerGain: Double = 0.4, val jumpFactor: Double = 8.0, val lagTarget: Double = 0.10, val gammaUp: Double = 0.35, val gammaDown: Double = 6.0, val muOrbit: Double = 0.0, val muDamp: Double = -0.35, val rhoMax: Double = 1.4, val rhoRate: Double = 2.0, val stallTol: Double = 0.02, val frustrationRise: Double = 0.5, val frustrationFall: Double = 1.5, val ditherFrac: Double = 0.03, val ditherTau: Double = 0.08, val seed: Long = 0x51ED5EEDL)

Declaration Summary
val dt: Double MUST be fixed. Substep internally if your frame time varies -- this is what makes the SOC layer and the RNG stream bit-reproducible.
val worldScale: Double Characteristic length of your scene. All length-dependent thresholds derive from this, so it is the only world-scale number you need to set.
val zeta: Double
val omega0: Double
val omegaMin: Double
val omegaMax: Double
val integralFrac: Double
val integralLeak: Double
val maxForce: Double
val magRateMax: Double
val angRateMax: Double rad/s. THE dial. This is a scale-free constraint, so unlike maxForce it never stops binding as the error shrinks. It sets a hard ceiling on closed-loop bandwidth: omega_d > angRateMax means capture is impossible at any amplitude and you get marginal spirals instead.
val angAccelMax: Double Finite gives the aim its own inertia -- overshoot and precession, a gimbal with a torque limit. Reads as intention. Also adds the two state dimensions that push the grab-to-grab map from quasi-periodic to chaotic.
val axisMemoryTau: Double
val antipodalGuard: Double
val releaseTau: Double
val lambda: Double
val p0: Double
val pTraceMax: Double
val filterRatio: Double
val deadzone: Double
val bMin: Double
val cMax: Double
val freezeIdWhenSaturated: Boolean
val observerGain: Double
val jumpFactor: Double
val lagTarget: Double Chosen distance from the capture/orbit bifurcation, in units of turn-rate deficit. 0 = strictly convergent servo. >0.3 = permanent orbit. The interesting band is narrow. Put this on a fader.
val gammaUp: Double
val gammaDown: Double
val muOrbit: Double The angle limiter generates orbits on its own, from an actuator constraint rather than a heuristic. Leave muOrbit at 0 unless angRateMax is high enough that the limiter never binds.
val muDamp: Double
val rhoMax: Double
val rhoRate: Double
val stallTol: Double
val frustrationRise: Double
val frustrationFall: Double
val ditherFrac: Double Radial only. Tangential dither is eaten by the angle limiter, and radial excitation is exactly what separates b from d.
val ditherTau: Double
val seed: Long

Telemetry

data class Telemetry(val omega: Double, val lag: Double, val chatter: Double, val massEst: Double, val dragEst: Double, val biasEst: Vector3f, val winding: Double, val aimDir: Vector3f, val forceMag: Double, val frustration: Double, val rho: Vector3f, val uncertainty: Double, val saturated: Boolean, val idFrozen: Boolean, val jumped: Boolean, val errMag: Double, val speed: Double)

Declaration Summary
val omega: Double
val lag: Double
val chatter: Double
val massEst: Double, val dragEst: Double, val biasEst: Vector3f
val winding: Double
val aimDir: Vector3f, val forceMag: Double
val frustration: Double, val rho: Vector3f
val uncertainty: Double
val saturated: Boolean, val idFrozen: Boolean, val jumped: Boolean
val errMag: Double, val speed: Double

PointDriver

class PointDriver(val cfg: DriverConfig = DriverConfig())

Declaration Summary
var omega
var winding
fun grab(target: Vector3f, targetVel: Vector3f = Vector3f(), targetAcc: Vector3f = Vector3f()) Call every frame while holding. The target may move; targetVel/Acc are feedforward and optional. The three vectors are copied, so the caller stays free to mutate whatever it passed in.
fun releaseGrab() Let go. Keep calling update() -- the magnitude ramps out, the aim freezes, and identification continues through the ringdown.
fun update(position: Vector3f): Vector3f Per frame. Position in, force out. The returned force is a fresh vector the caller owns; position is only read.
fun telemetry()
fun reset(keepModel: Boolean = false)

ShaderBoids.kt

Reynolds boids as a Vulkan compute shader, over a GPU neighbour grid, with the interaction rule swappable for a trained network.

Two declarations. BoidSettings carries the three classical weights, the speed limit, and two forcing terms that contribute nothing until they are turned on. ShaderBoids owns one storage buffer of particles, generates the GLSL that steps them, and installs the grid rebuild and the rule into one ordered target map, in the order those two have to run.

One system, three rules

With neither learned nor equivariant set it computes the classical three-term force in hand-written GLSL. Given DeepSetsWeights it evaluates a trained sum-then-combine network at the same place in the same neighbour loop, and given EquivariantWeights it evaluates the rotation-equivariant form. The buffer, the neighbour walk, the speed limit and the integration are the same code in all three cases, so changing that one argument compares rules rather than implementations. Passing both fails at construction.

Sized to the grid

The neighbour query is the 3x3 block of cells around a particle's own, which reaches one cell width in each direction, so BoidSettings.radius cannot exceed the grid's cellSize. A radius that does not fit still produces something that looks like a flock, computed from a subset of each particle's neighbours, so install checks it. The particle count has to fit the grid's capacity, and the constructor checks that.

Graded against a CPU oracle

referenceAcceleration is the same law in Kotlin over every other particle, and four.shader-boids.smoke compares the two per particle, for a uniform flock and for a clustered one. It runs them with dt = 0, where nothing moves and the accelerations are a fixed function of a fixed configuration; boids is chaotic, so two trajectories compared after a few hundred steps would measure how fast they separate and not whether the two implement the same law. The force each particle stores is the interaction alone, written before the containment and driver terms are added and before the velocity is integrated.

Drawing it

installDrawInto adds a point-list pass that reads the same allocation the compute pass wrote, with no copy, tinting from slow colour to fast colour by speed. The point the driver term pulls toward comes from a lambda the push bindings call every frame, so a box can move it without re-running anything.

The prose, with pictures: Neural particle systems.

src/jvmMain/kotlin/afield/simulation/physics/ShaderBoids.kt — read the source

Used in: Neural particle systems

BoidSettings

data class BoidSettings(var name: String = "boids", var radius: Float = 2f / 64f, var separation: Float = 0.02f, var alignment: Float = 3f, var cohesion: Float = 200f, var maxSpeed: Float = 0.25f, var dt: Float = 1f / 60f, var minSeparationFraction: Float = 0.05f, var separationRadiusFraction: Float = 0.25f, var containment: Float = 0f, var containmentRadius: Float = 0.8f, var containmentCenter: Vector2f = Vector2f(0f, 0f), var driverStrength: Float = 0f, var driverRadius: Float = 0.25f, var pointSize: Float = 2f, var slowColor: Vec4 = vec(0.16, 0.40, 0.95, 1.0), var fastColor: Vec4 = vec(1.0, 0.75, 0.25, 1.0), var intensity: Float = 0.9f)

The three classical weights, plus the speed limit that keeps a flock finite.

Declaration Summary
var name: String Prefix for the buffer, module and target names this system creates, so two flocks can share a renderer.
var radius: Float Interaction radius. Must be at most the grid's cell size or the 3x3 query misses people.
var separation: Float Push away from neighbours, as 1/r.
var alignment: Float Match neighbours' velocity.
var cohesion: Float Move toward neighbours' centre of mass.
var maxSpeed: Float Speed ceiling, applied after the acceleration.
var dt: Float Integration step. Set to 0 to compute accelerations without moving anything.
var minSeparationFraction: Float Closest approach used in the separation term, as a fraction of radius.
var separationRadiusFraction: Float The separation term's range, as a fraction of radius. 1 means "the same range as everything else"; the default, 0.25, is the setting that clumps.
var containment: Float Pull back toward the origin once a boid is further out than containmentRadius, per unit of excess. Zero — the default — adds exactly nothing, which is what the tests rely on.
var containmentRadius: Float Where containment starts to bite, measured from containmentCenter.
var containmentCenter: Vector2f What the containment pulls toward — so two flocks can share a domain without merging.
var driverStrength: Float Pull toward (positive) or push away from (negative) the moving point ShaderBoids.driver supplies. Zero — the default — adds exactly nothing.
var driverRadius: Float Distance over which driverStrength falls off.
var pointSize: Float Point size for installDrawInto.
var slowColor: Vec4 Slow boids take this colour.
var fastColor: Vec4 Fast boids take this one.
var intensity: Float Overall brightness of the draw.

ShaderBoids

class ShaderBoids(val grid: GpuNeighbourGrid, val count: Int, val renderer: Renderer = fromContext(), val learned: afield.ai.learning.DeepSetsWeights? = null, val equivariant: afield.ai.learning.EquivariantWeights? = null, configure: BoidSettings.() -> Unit = {})

Classical Reynolds boids, on the GPU, over a GpuNeighbourGrid.

Declaration Summary
val grid: GpuNeighbourGrid The neighbour structure the rule reads. install rebuilds it from the current positions before the rule runs, and its cellSize is the ceiling on BoidSettings.radius.
val count: Int How many particles. Must be in 1..grid.capacity, which the constructor requires.
val renderer: Renderer Where the buffers and the two modules are allocated.
val learned: afield.ai.learning.DeepSetsWeights? A trained Deep Sets interaction to use instead of the hand-written rule, or null for classical boids.
val equivariant: afield.ai.learning.EquivariantWeights? A trained equivariant interaction, used instead of either other rule.
val settings The weights this system is running, after configure.
val strideFloats 8 floats: position, velocity, accel, and the velocity this step integrated from.
val boids: FrameBufferGroup The flock.
var driver: () -> Vector2f Where the driver is, re-read every frame — the push buffer calls this when it writes, so this one lambda is the whole animation and a box can move the point without re-running.
val psiBuffer: FrameBufferGroup? psi's weights, when learned is set.
val phiBuffer: FrameBufferGroup? phi's weights, when a learned rule is set.
val computeSource: String The compute shader as installed — editable, and generated against this grid's dimensions.
fun reset(min: Vector2f, size: Float, speed: Float = 0.2f, seed: Long = 1L) Seeds the flock: uniform positions over min to min + size, and velocities of speed in random directions.
fun install(into: MutableMap<String, Target>) Installs the grid and then this rule into into, in that order.
fun removeFrom(into: MutableMap<String, Target>) Takes the grid rebuild and the rule back out of into, undoing install.
fun installDrawInto(target: afield.graphics.vulkan.targets.RenderTarget = renderer.renderList) Adds a point-list draw of the flock, reading the same allocation the rule writes — no copy, the afield_four_graphics.md compute-then-draw shape.
fun referenceAcceleration(state: FloatArray, index: Int): Pair<Float, Float> The same rule, in Kotlin, over every other particle — the oracle.

SimdVerlet.kt

The particle solver: structure-of-arrays Verlet integration, directed distance constraints, and the vector kernels that run both.

SimdVerlet holds some thousands of particles in flat float arrays, pulls them toward each other along up to four constraints each, and integrates them with Verlet. It is a CPU solver with no renderer of its own and no geometry of its own: it deals in particle indices, and the sibling files in this package are what turn those into things. Cloth wires a rectangular grid of constraints, Strand a chain, Collider the world to hit, Tearing the edges that give way, Wind the air.

Three of its nested types carry the state:

  • State3 is structure-of-arrays: x/y/z, the previous positions px/py/pz, the force accumulators ax/ay/az, and an optional invMass. A particle is an index into all of those at once rather than an object, invMass == 0 is how one is pinned, and the accumulators are scratch that step zeroes at the top of every frame.
  • DirectedConstraints4 gives every particle four outgoing distance constraints: four neighbour indices, four rest lengths, four stiffnesses, in parallel arrays. Four is the up/down/left/right neighbourhood of an interior grid particle, so a cloth's whole topology fits with nothing spare. DirectedConstraints3 is the same with three.
  • ConstraintScratch is the gather buffer the 4-slot solve needs, one per worker thread.

Directed is literal. A constraint i -> j writes its correction only to i and never scatters back to j, so disjoint particle ranges can be solved on separate threads with a scratch each and the inner loop vectorises. The price is that a symmetric spring is two constraints, and that it is possible to wire only one of them.

The frame

step runs, in this order: queued removals, zero the accumulators, gravity, the per-particle force generators, the registered bulkForces, the caller's accumulateForces, iterations passes of the constraint solve, Verlet integration, the registered positionProjections, and the registered constraintEdits. Everything up to integration is force space; those last two stages are the only places in a frame where positions are written directly.

Those three maps are how a layer in another file joins the frame without step knowing about it: a whole-state force goes in bulkForces, a collider in positionProjections, tearing in constraintEdits. Each is named and iterated in registration order, and each property's own KDoc has why its stage sits where it does.

The kernels

The companion carries the passes themselves as functions over arrays, each in a whole-state form and a ...Range form taking start and endExclusive: zeroing, Verlet integration, the 3- and 4-slot constraint solves, a velocity readback, and a spring toward a target point. The range forms are what let ForceExecutor hand contiguous chunks to a fixed thread pool. That chunking is coarse, two tasks per worker over a 256-particle minimum, because the target here is thousands of particles rather than millions.

The solves and the bulk passes use the JDK Incubator Vector API, so a JVM that runs them needs --add-modules jdk.incubator.vector. Field's own launches pass it already.

The number to know before tuning anything is the product stiffness * iterations. These are not two independent knobs: the solve adds iterations corrections into the same accumulator with nothing moving in between, so stiffness and iteration count multiply, and the product has a ceiling past which an explicit integrator diverges. dev_docs/physics/SIMDVERLET_4_CONSTRAINTS.md has the measurements.

src/jvmMain/kotlin/afield/simulation/physics/SimdVerlet.kt — read the source

SimdVerlet

class SimdVerlet(val state: State3, var constraints: DirectedConstraints3? = null)

SIMD Verlet kernels and coarse chunk-parallel utilities for small/medium particle counts.

Declaration Summary
val state: State3
var constraints: DirectedConstraints3?
var constraints4: DirectedConstraints4? The 4-slot constraint set this simulation belongs to, if any. step's 4-slot overload still takes its constraints explicitly (defaulting it would make the two overloads ambiguous), so this is a place for a builder -- e.g. Cloth -- to hand its constraint set to whoever ends up driving the simulation:
val bulkForces: MutableMap<String, (state: State3, dt: Float) -> Unit> Named whole-state force passes, run on every step after gravity and the per-particle force generators and before the constraint solve -- the same slot as step()'s accumulateForces argument, but registered on the simulation rather than per call, so a force that belongs to a group of particles (see Cloth's curvature force) can be added once and then survive plain sim.step(...) calls made by somebody else.
val positionProjections: MutableMap<String, (state: State3, dt: Float) -> Unit> Named position-projection passes, run by step after integration, in registration order — the world layer's slot, and the one place in the frame where positions may be written directly.
val constraintEdits: MutableMap<String, (state: State3, dt: Float) -> Unit> Named constraint-editing passes, run by step after positionProjections, in registration order -- the last thing in a frame.
class ForceContext(val state: State3, var index: Int, var dt: Float)
val size: Int
fun addZeroed(count: Int = 1): Int
fun addParticle(x0: Float = 0f, y0: Float = 0f, z0: Float = 0f, px0: Float = x0, py0: Float = y0, pz0: Float = z0, ax0: Float = 0f, ay0: Float = 0f, az0: Float = 0f, invMass0: Float = 1f): Int
fun addForceGenerator(index: Int, name: String, generator: Generator<ForceContext, Unit>)
fun removeForceGenerator(index: Int, name: String) Drops the named force generator from particle index, if it has one.
fun addBulkForce(name: String, force: (state: State3, dt: Float) -> Unit) Registers (or replaces) a named whole-state force pass. See bulkForces.
fun removeBulkForce(name: String) Drops the named whole-state force pass, if it is registered. See bulkForces.
fun addPositionProjection(name: String, projection: (state: State3, dt: Float) -> Unit) Registers (or replaces) a named post-integration projection. See positionProjections.
fun removePositionProjection(name: String) Drops the named position-projection pass, if it is registered. See positionProjections.
fun addConstraintEdit(name: String, edit: (state: State3, dt: Float) -> Unit) Registers (or replaces) a named constraint-editing pass. See constraintEdits.
fun removeConstraintEdit(name: String) Drops the named constraint-editing pass, if it is registered. See constraintEdits.
var diverged: Boolean Set once this simulation has reported a non-finite position, so it says so once.
fun addPidPositionController(index: Int, name: String, targetX: () -> Float, targetY: () -> Float, targetZ: () -> Float, kp: Float = 5f, ki: Float = 0.1f, kd: Float = 1.5f, integralLimit: Float = 10f, maxForce: Float = 1000f)
fun resize(newSize: Int, zeroNew: Boolean = true)
fun queueRemove(index: Int)
fun queueRemoveRange(start: Int, count: Int)
fun applyQueuedRemovalsSwap()
fun step(dt: Float, damping: Float, gravity: Float, iterations: Int, constraints: DirectedConstraints3? = this.constraints, accumulateForces: ((state: State3, dt: Float) -> Unit)? = null) Advances the simulation one frame. The force accumulators (ax/ay/az) are treated as scratch: they are zeroed at the start of every step, then rebuilt from gravity, the per-particle force generators, accumulateForces, and the constraint solve, and finally consumed by integration. So anything written to ax/ay/az before calling step() is discarded — inject per-frame forces through a force generator (per particle) or through accumulateForces (whole-range, e.g. the bulk SIMD kernels), never by pre-accumulating.
fun step(dt: Float, damping: Float, gravity: Float, iterations: Int, constraints: DirectedConstraints4?, accumulateForces: ((state: State3, dt: Float) -> Unit)? = null) 4-slot-constraint variant of step; see that overload for the force-accumulation contract.
class State3(var x: FloatArray, var y: FloatArray, var z: FloatArray, var px: FloatArray, var py: FloatArray, var pz: FloatArray, var ax: FloatArray, var ay: FloatArray, var az: FloatArray, var invMass: FloatArray? = null) Particle state in SoA form. invMass is optional. If present, integration treats ax/ay/az as forces and scales by invMass. If absent, ax/ay/az are treated as accelerations.
class DirectedConstraints3(val n0: IntArray, val n1: IntArray, val n2: IntArray, val r0: FloatArray, val r1: FloatArray, val r2: FloatArray, val s0: FloatArray, val s1: FloatArray, val s2: FloatArray) Directed 3-slot constraint layout: i -> n0i, i -> n1i, i -> n2i Unused slot: neighbor = -1 or stiffness = 0.
class DirectedConstraints4(var n0: IntArray, var n1: IntArray, var n2: IntArray, var n3: IntArray, var r0: FloatArray, var r1: FloatArray, var r2: FloatArray, var r3: FloatArray, var s0: FloatArray, var s1: FloatArray, var s2: FloatArray, var s3: FloatArray) Directed distance constraints with up to 4 neighbor slots per particle (the 4-slot sibling of DirectedConstraints3). Each particle i owns outgoing constraints to n0[i](workspace.md#stdlib-kt--i), n1[i](workspace.md#stdlib-kt--i), n2[i](workspace.md#stdlib-kt--i), n3[i](workspace.md#stdlib-kt--i); an unused slot is marked with neighbor = -1 or stiffness = 0. Slot arrays are parallel: n* are neighbor indices, r* rest lengths, s* stiffnesses, all of length size. The 4th slot is what lets a regular cloth grid attach all four of its axis neighbors (up/down/left/right) to a single particle.
class ConstraintScratch(private val species: VectorSpecies<Float>) Scratch buffers for constraint gather maps, reusable across frames. Keep one per worker thread to avoid allocations in the hot loop.
class ForceExecutor(threads: Int) : AutoCloseable

SimdVerletColliders.kt

Collision shapes for SimdVerlet: the world layer of the particle solver.

A Collider is a post-integration position projection registered through SimdVerlet.addColliders: no stiffness to tune, one square root per penetrating particle. This file provides PlaneCollider, SphereCollider, CapsuleCollider, BoxCollider, and SdfCollider (for shapes expressed as a signed-distance function), plus ColliderSet, which runs several colliders as one registered pass with optional multi-pass relaxation for particles caught in a crease between two shapes.

The contact response applies Coulomb friction (Collider.friction), optional bounce (Collider.restitution) and viscous tangential damping (Collider.tangentialDamping). Pinned particles (zero inverse mass) are never moved. PlaneCollider and SphereCollider carry a JDK Incubator Vector path alongside the scalar one; the rest are scalar.

src/jvmMain/kotlin/afield/simulation/physics/SimdVerletColliders.kt — read the source

Used in: Particles, welded, and traced, Physics: particles, constraints and cloth

Top-level functions

Declaration Summary
fun SimdVerlet.addColliders(vararg colliders: Collider, name: String = "colliders", range: IntRange? = null): ColliderSet Registers colliders on this simulation as one post-integration projection pass and hands back the set, which stays live: add to it, retune a collider's Collider.friction, move a SphereCollider, all between frames.

Collider

sealed class Collider

Things for the particles to hit: the world layer of SimdVerlet.

Declaration Summary
var enabled: Boolean Skipped entirely while false, without being unregistered.
var friction: Float Coulomb friction coefficient. The tangential correction applied on contact is bounded by friction * depth, which is the Coulomb limit in Verlet displacement units — see the class KDoc. 0f is frictionless; 0.6f holds a sheet on a sphere.
var tangentialDamping: Float Fraction of the remaining tangential velocity removed on every contact, unconditionally. The viscous companion to friction, for a surface that should be sticky rather than merely rough. 0f (the default) leaves Coulomb friction as the only tangential effect.
var restitution: Float Bounce: the fraction of an approaching normal velocity returned. 0f is a dead surface, 1f is lossless. A separating normal velocity is never touched, so this can only remove energy. Note that a high restitution never lets a particle come fully to rest — gravity re-penetrates by g * dt^2 every frame and gets that fraction of it back.
var margin: Float Extra clearance held between a particle's centre and the surface — cloth half-thickness, or a particle radius. Positive values keep the sheet visibly off the shape, which is also what stops a wireframe from z-fighting the thing it is draped over.
var vectorized: Boolean Use the vector path where there is one. false forces the scalar path; see the class KDoc.
var contacts: Int Number of particles the last projectRange found penetrating. Not a physical quantity — it is the "is this collider doing anything at all" readout, and what SimdVerletCollidersTest asserts a floor on so that a silently-missing contact fails loudly rather than passing as a cloth that happens to hang in the right place.
abstract fun projectRange(state: SimdVerlet.State3, start: Int, endExclusive: Int): Int Projects the particles in [start, endExclusive) out of this shape and returns how many were penetrating. Writes x/y/z and px/py/pz; reads nothing else. Non-overlapping ranges touch disjoint particles, so this is range-parallel safe for one collider — several colliders over the same range are not, and ColliderSet runs them in series.
fun project(state: SimdVerlet.State3): Int projectRange over the whole active state.
abstract fun outline(color: Vec4 = DEFAULT_OUTLINE_COLOR, thickness: Double = 1.6): List<FLine> The shape as FLines, for drawing next to SimdVerlet.debugLines. Not a renderer and not a mesh: a sphere is its silhouette circle in the XY plane, because the plane views these are for (Box.showPlane) project x/y and throw z away, and a great circle is the one curve that reads as "the thing the cloth is lying on" in that projection.

PlaneCollider

class PlaneCollider(normalX: Float, normalY: Float, normalZ: Float, var offset: Float) : Collider()

A half-space: everything with n . x >= offset + margin is legal, everything below the plane is pushed up onto it. The ground, a wall, a slope.

Declaration Summary
var offset: Float
var normalX: Float
var normalY: Float
var normalZ: Float
fun setNormal(nx: Float, ny: Float, nz: Float) Re-aims the plane; the vector is normalised here so the projection can assume unit length.
override fun projectRange(state: SimdVerlet.State3, start: Int, endExclusive: Int): Int
override fun outline(color: Vec4, thickness: Double): List<FLine> A 40-unit cross of two perpendicular in-plane directions, centred on the point of the plane nearest the origin — a plane has no extent, so the outline picks one.

SphereCollider

class SphereCollider(var centerX: Float, var centerY: Float, var centerZ: Float, var radius: Float, var inside: Boolean = false) : Collider()

A sphere. inside = false (the default) keeps particles out of it — the thing a cloth drapes over; inside = true keeps them within it, which is a bowl.

Declaration Summary
var centerX: Float
var centerY: Float
var centerZ: Float
var radius: Float
var inside: Boolean
fun moveTo(cx: Float, cy: Float, cz: Float) Moves the sphere. Nothing is cached, so this is live between frames.
override fun projectRange(state: SimdVerlet.State3, start: Int, endExclusive: Int): Int
override fun outline(color: Vec4, thickness: Double): List<FLine> The great circle in the XY plane, 64 segments.

CapsuleCollider

class CapsuleCollider(var ax: Float, var ay: Float, var az: Float, var bx: Float, var by: Float, var bz: Float, var radius: Float, var inside: Boolean = false) : Collider()

A capsule: everything within radius of the segment A-B. A limb, a branch, a rail. As with SphereCollider, inside = true inverts it into a tube to stay within.

Declaration Summary
var ax: Float, var ay: Float, var az: Float
var bx: Float, var by: Float, var bz: Float
var radius: Float
var inside: Boolean
override fun projectRange(state: SimdVerlet.State3, start: Int, endExclusive: Int): Int
override fun outline(color: Vec4, thickness: Double): List<FLine> Two end circles and the two tangent lines between them, in the XY plane.

BoxCollider

class BoxCollider(var minX: Float, var minY: Float, var minZ: Float, var maxX: Float, var maxY: Float, var maxZ: Float, var inside: Boolean = true) : Collider()

An axis-aligned box. inside = true (the default) is a room to stay within — the cheapest way to keep a simulation from wandering off; inside = false is a crate to stay out of.

Declaration Summary
var minX: Float, var minY: Float, var minZ: Float
var maxX: Float, var maxY: Float, var maxZ: Float
var inside: Boolean
override fun projectRange(state: SimdVerlet.State3, start: Int, endExclusive: Int): Int
override fun outline(color: Vec4, thickness: Double): List<FLine> The XY rectangle.

SdfCollider

class SdfCollider(val sdf: (x: Float, y: Float, z: Float) -> Float, var epsilon: Float = 1e-3f, var maxSteps: Int = 2, private val outlineLines: (() -> List<FLine>)? = null) : Collider()

Anything you can write down as a signed distance: negative inside, zero on the surface, positive outside, and roughly unit gradient. The escape hatch, and the way to collide against a shape the other four don't cover — a torus, a heightfield, an implicit surface, the output of a Manifold level set.

Declaration Summary
val sdf: (x: Float, y: Float, z: Float) -> Float
var epsilon: Float
var maxSteps: Int
override fun projectRange(state: SimdVerlet.State3, start: Int, endExclusive: Int): Int
override fun outline(color: Vec4, thickness: Double): List<FLine>

ColliderSet

class ColliderSet internal constructor(val sim: SimdVerlet, val name: String, var range: IntRange?)

Several Colliders applied as a single SimdVerlet.positionProjections pass.

Declaration Summary
val sim: SimdVerlet
val name: String
var range: IntRange?
val colliders: MutableList<Collider> The colliders, in the order they are applied. Mutable; the pass reads it live.
var relaxations: Int How many times to run the whole list per frame. See the class KDoc.
var contacts: Int Total penetrating-particle contacts across every collider in the last frame.
fun <T : Collider> add(collider: T): T Appends collider and returns it, so val ground = set.add(PlaneCollider(...)) reads well.
fun remove(collider: Collider): Boolean Drops collider from the list. Returns whether it was there.
fun project(state: SimdVerlet.State3) The pass itself, exposed for callers who would rather drive it than leave it registered.
fun outline(color: Vec4 = Collider.DEFAULT_OUTLINE_COLOR, thickness: Double = 1.6): List<FLine> Every collider's Collider.outline, concatenated.
fun detach() Unregisters the pass from sim. The colliders themselves are left alone.

SimdVerletDebugLines.kt

A SimdVerlet's particles and constraints as FLines, for seeing what the solver holds rather than what the scene means.

The file is one extension function, debugLines, and the list it returns is grouped by appearance rather than by particle: at most four lines, in the order constraint edges, velocity whiskers, unpinned particles, pinned particles, each already carrying its own colour and thickness, and each left out when it would have been empty. So a caller restyles a whole category by editing one element of that list, and the list's own length says only which categories drew something.

src/jvmMain/kotlin/afield/simulation/physics/SimdVerletDebugLines.kt — read the source

Top-level functions

Declaration Summary
fun SimdVerlet.debugLines(particles: IntRange = 0 until size, edges: Boolean = true, points: Boolean = true, velocityScale: Float = 0f, edgeColor: Vec4 = vec(0.36, 0.78, 1.0, 0.45), pointColor: Vec4 = vec(0.74, 0.82, 0.94, 0.9), pinnedColor: Vec4 = vec(1.0, 0.42, 0.32, 1.0), velocityColor: Vec4 = vec(0.55, 0.95, 0.6, 0.75), thickness: Double = 1.4, pointSize: Double = 0.7): List<FLine> The solver's own state as FLines: one segment per distance constraint, a dot per particle, pinned particles in their own colour, and — when asked — a velocity whisker per particle.

SimdVerletDyn4jBridge.kt

Runs a SimdVerlet particle simulation and a Dyn4jScene in one frame, so that chosen particles collide with rigid bodies as circles while the rest stay ordinary particles.

The coupling is by nomination. nominate takes a particle index and gives it a circle body in the scene's dyn4j world; every other particle is untouched and costs nothing. Then each step advances the particle simulation, copies the nominated particles into their bodies, advances dyn4j, and copies the bodies back. A nominated particle is still a Verlet particle that constraints and force generators pull on; it spends the end of every frame being a rigid body as well.

The frame, and the SimdVerlet features it drops

step does not call SimdVerlet.step. It runs its own copy of that frame, because it has to decide per particle whether gravity applies. In order: queued removals, zero the accumulators, gravity, the per-particle force generators, the constraint solve, Verlet integration, then the dyn4j half.

What the copy leaves out is what this class does not support. There is no SimdVerlet.bulkForces pass, no accumulateForces hook, and neither post-integration stage runs, so a ColliderSet in SimdVerlet.positionProjections or a Tearing in SimdVerlet.constraintEdits is silent under step. It also solves only SimdVerlet.DirectedConstraints3, while Cloth and Strand build SimdVerlet.DirectedConstraints4: step a cloth through this bridge and its constraints go unsolved, because the default constraints = sim.constraints is null when the fabric is in constraints4. So this is a bridge for loose particles rather than for fabric.

Positions and velocities, both ways

A Verlet particle carries its velocity as the gap between x and px; a dyn4j body carries one directly, in units per second. So the push sets the body to the particle's previous position with (x - px) / dt as its velocity and lets dyn4j integrate that forward over the same dt: the dyn4j step redoes the frame's last integration, this time with contacts in it. The pull then writes x from the body's centre and rebuilds px as centre - velocity * dt, so the particle's history after a frame comes from the body rather than surviving the hop.

Only x, y, px and py are written. CollisionPlane has one case and the constructor requires it: this is the simulation's x/y plane, and z is left alone.

Gravity, counted once

DynGravityMode.MIRROR_SIM copies step's gravity argument into the dyn4j world and withholds it from the nominated particles in the Verlet pass, so dyn4j is the only thing accelerating them. DynGravityMode.ZERO does the reverse: the dyn4j world gets none, and every particle, nominated or not, gets the simulation's. Either way a particle falls once per frame.

The two are not the same fall. What the Verlet pass adds gravity to is the ay accumulator, which is a force whenever the state has an invMass array, as SimdVerlet.State3.empty allocates by default, so a heavy particle falls more slowly in the simulation. A dyn4j world gravity is an acceleration, which every body there feels equally. They agree at inverse mass 1.

Mass, radius, removal

Each frame the body's density is set so its mass is the particle's 1 / invMass spread over the circle's area, and an inverse mass of zero or less becomes MassType.INFINITE, the same convention that pins a particle in the solver. That conversion is cached and redone only when the particle's inverse mass changes.

Changing BulletSpec.radius destroys the body and builds a new one, at the next step rather than when the spec changes, because the fixture's circle is not resized in place. Friction and restitution are written to the existing fixture instead. unnominate and clearNominations take the bodies back out of the world.

Nominations are keyed by particle index, and SimdVerlet's removal is a swap compaction: a deleted slot is filled from the tail. Each frame drops the nominations whose index has fallen out of range, which is as much as an index can tell it — a nomination whose index survives a removal now names whichever particle was swapped into that slot. Re-nominate after removing particles.

Nothing in the repository constructs one of these and no test covers it. It is a surface for box code to call, and there is no 3D equivalent: JoltPhysicsWorld is a rigid-body world of its own, with nothing wiring it to a particle simulation.

src/jvmMain/kotlin/afield/simulation/physics/SimdVerletDyn4jBridge.kt — read the source

CollisionPlane

enum class CollisionPlane

Which plane of the simulation the rigid-body scene lives in. Only XY is implemented.

Constants: XY.

DynGravityMode

enum class DynGravityMode

Which of the two engines accelerates a nominated particle, so that gravity is applied once rather than twice or not at all. See the file overview above for the difference between the two falls.

Constants: ZERO, MIRROR_SIM.

BulletSpec

data class BulletSpec(val radius: Float = 0.05f, val friction: Double = 0.2, val restitution: Double = 0.0, val linearDamping: Double = 0.0, val angularDamping: Double = 0.0, val bullet: Boolean = true, val userData: Any? = null)

The rigid body a nominated particle becomes: a circle of radius, with dyn4j's contact parameters.

Declaration Summary
val radius: Float
val friction: Double
val restitution: Double
val linearDamping: Double
val angularDamping: Double
val bullet: Boolean
val userData: Any?

SimdVerletDyn4jBridge

class SimdVerletDyn4jBridge(val sim: SimdVerlet, val scene: Dyn4jScene, val plane: CollisionPlane = CollisionPlane.XY, val gravityMode: DynGravityMode = DynGravityMode.MIRROR_SIM, val defaultSpec: BulletSpec = BulletSpec())

One particle simulation, one rigid-body scene, and the nomination table joining them. The file overview above has the frame in order and what it leaves out.

Declaration Summary
val sim: SimdVerlet
val scene: Dyn4jScene
val plane: CollisionPlane
val gravityMode: DynGravityMode
val defaultSpec: BulletSpec
fun nominate(index: Int, spec: BulletSpec = defaultSpec) Gives the particle at index a circle rigid body, so that it collides with the scene.
fun updateNomination(index: Int, spec: BulletSpec) Replaces the BulletSpec of an already-nominated index, keeping its body where possible.
fun unnominate(index: Int): Boolean Takes the particle at index back to being an ordinary particle, removing its body from the dyn4j world. false if it was not nominated.
fun clearNominations() Unnominates every particle, removing all of this bridge's bodies from the dyn4j world.
fun nominatedIndices(): Set<Int> The currently nominated particle indices, as a copy, so it is safe to iterate while calling nominate or unnominate.
fun step(dt: Float, damping: Float, gravity: Float, iterations: Int, constraints: SimdVerlet.DirectedConstraints3? = sim.constraints) Advances both engines by dt and reconciles them: a Verlet frame, then the dyn4j world, then the nominated particles written back from their bodies.

SimdVerletTearing.kt

Tearing for SimdVerlet: retiring over-stretched distance constraints, and the two measurements that say where to set the threshold.

Tearing retires a constraint once it has been stretched past a fraction of its rest length, so a sheet under load rips instead of stretching forever. It runs as a constraint-edit pass, registered by SimdVerlet.addTearing on a bare simulation or by Cloth.enableTearing on a Cloth. Both hand back the live object, so a threshold can be retuned or an edge cut by hand between frames, and the cloth version additionally drops the curvature stencil at each lip of a tear, without which the curvature force goes on flattening the sheet across a rip the solver has already made.

Strain is (distance - rest) / rest throughout, so a threshold is a fraction rather than a length, and it can be read off a measurement instead of found by bisection. SimdVerlet.peakStrain is that measurement over a whole simulation: a threshold below the strain a sheet settles at tears it while it hangs at rest. SimdVerlet.liveEdgeCount counts surviving edges the way the debug drawing does, a symmetric pair counted once, so a line lost from the picture and an edge torn in the solver are the same number.

src/jvmMain/kotlin/afield/simulation/physics/SimdVerletTearing.kt — read the source

Top-level functions

Declaration Summary
fun SimdVerlet.addTearing(threshold: Float = 0.5f, range: IntRange? = null, maxBreaksPerStep: Int = -1, name: String = "tearing", constraints: SimdVerlet.DirectedConstraints4? = this.constraints4, onTear: ((i: Int, j: Int) -> Unit)? = null): Tearing Registers tearing on this simulation and hands back the live handle: retune Tearing.threshold, read Tearing.brokenTotal, call Tearing.breakEdge to cut something by hand, all between frames.
fun Cloth.enableTearing(threshold: Float = 0.5f, maxBreaksPerStep: Int = -1, passName: String = "$name.tearing", alsoOnTear: ((i: Int, j: Int) -> Unit)? = null): Tearing Tearing for one cloth, wired to its curvature stencil.
fun SimdVerlet.peakStrain(range: IntRange? = null): Float The largest (distance - rest) / rest over every live constraint the simulation is solving, or -1f if it is solving none.
fun SimdVerlet.liveEdgeCount(range: IntRange? = null): Int How many live distance constraints the simulation is solving, counting a symmetric pair once — the same edge count SimdVerlet.debugLines draws, so "the picture lost 40 lines" and "40 edges tore" are the same number.

Tearing

class Tearing internal constructor(val sim: SimdVerlet, val constraints: SimdVerlet.DirectedConstraints4, val name: String, var threshold: Float, var range: IntRange?, var maxBreaksPerStep: Int, var onTear: ((i: Int, j: Int) -> Unit)?)

Constraint tearing: retiring a distance constraint that has been stretched past a strain threshold, so a sheet under load rips instead of stretching forever.

Declaration Summary
val sim: SimdVerlet
val constraints: SimdVerlet.DirectedConstraints4
val name: String
var threshold: Float
var range: IntRange?
var maxBreaksPerStep: Int
var onTear: ((i: Int, j: Int) -> Unit)?
var brokenTotal: Int Edges broken since this object was made.
var brokenLastStep: Int Edges broken in the most recent scan.
var peakStrainLastStep: Float The largest strain seen in the most recent scan, broken edges included. -1f before the first scan, and after a scan that found no live constraints at all.
fun scan(state: SimdVerlet.State3): Int The pass itself, exposed for callers who would rather drive it than leave it registered.
fun breakEdge(i: Int, j: Int): Boolean Breaks the edge between i and j by hand, in both directions, whether or not it is strained. Returns false if there was no live constraint between them. Fires onTear.
fun detach() Unregisters the pass from sim. Already-broken edges stay broken.

SimdVerletWind.kt

Wind for SimdVerlet: per-particle drag, per-quad cloth aerodynamics, and the reproducible gust field both of them sample.

The two force models answer different questions, and a sheet that is meant to flutter rather than lean needs the second one.

Per-particle drag (SimdVerlet.addWind) is F = drag * (w - v) per particle, where w is the wind velocity at that point and v = (x - px) / dt is the Verlet velocity. It applies to anything made of particles (a rope, a point cloud, a cloth), and it cannot see which way fabric is facing, because a particle has no orientation: a sheet edge-on to the wind is pushed exactly as hard as a sheet face-on, and under a steady wind such a cloth reaches an equilibrium lean and stays there.

Per-quad aerodynamics (Cloth.addWind) reads the sheet's own topology, which costs nothing: a Cloth is a contiguous row-major grid, so the quad at (col, row) is four adds. For each quad it takes the normal from the diagonals, the area from half that normal's length, the relative velocity from the mean of the four corners, and applies

F = 0.5 * rho * Cd * A * (n . v_rel) * |n . v_rel| * n

quartered onto the corners, plus an optional tangential term. Flutter comes out of the signed n . v_rel factor: a quad turning edge-on stops being pushed, overshoots, catches the wind on its other face, and the instability travels down the sheet as a wave.

SimdVerletWindTest measures both models through the same measurement. Two identical 12 x 12 sheets at rest in the same wind, one face-on and one edge-on, differ by more than twenty times in the force the per-quad pass puts into them, and what little the edge-on sheet gets is all skin friction: set ClothWind.tangentialCoefficient to zero and its normal force drops below a millionth of the face-on total. The drag pass gives the two sheets the same force to within a part in 100000, because a particle has no orientation to have.

The other measurement is sustained motion: a 20 x 12 flag in an 8-unit wind with 60 % gusts, scored as trailing-edge travel per frame once the transient has passed, with the per-quad pass on and off. What the test asserts is the shape rather than the figures. Per-quad always adds motion; it adds more than 2.5 times as much at Wind.drag = 0.05, and less than 1.5 times as much by 0.5. So the advantage is a low-drag one. Turn drag up and the drag pass is doing the forcing, and the sheet is being carried rather than flying.

The gust field

Wind.turbulence adds gusts on top of the mean Wind.velocityX/velocityY/velocityZ. The field is a small closed-form spectrum: a few sines per axis, with directions, spatial frequencies, temporal frequencies and phases all derived from the seed. It is not a noise lattice and not a fluid simulation, because what it has to be is reproducible and grid-free. Two runs at the same seed produce the same sheet, so a test can assert something about it and a capture can be re-taken; and a lattice can align its cells with the cloth's own grid spacing and stand a pattern in the sheet. A sum of sines has no lattice to align, and costs three sines per octave per sample.

Gust amplitude is turbulence times the mean wind's speed, so turbulence = 0.4f is gusts of about 40 % whatever the wind is set to, and 0f is a steady stream.

Wind time

Wind.time advances by dt on every pass, so a registered wind is a running one. Set it to drive the gusts from somewhere else (a transport, a scrub bar); reset it to zero to replay a gust exactly.

src/jvmMain/kotlin/afield/simulation/physics/SimdVerletWind.kt — read the source

Top-level functions

Declaration Summary
fun SimdVerlet.addWind(velocityX: Float = 0f, velocityY: Float = 0f, velocityZ: Float = 0f, drag: Float = 0.5f, turbulence: Float = 0f, turbulenceScale: Float = 0.35f, turbulenceSpeed: Float = 1.6f, octaves: Int = 3, seed: Int = 1, range: IntRange? = null, name: String = "wind"): Wind Registers per-particle drag on this simulation and hands back the live handle.
fun Cloth.addWind(wind: Wind, density: Float = 1.2f, dragCoefficient: Float = 1.28f, tangentialCoefficient: Float = 0.05f, passName: String = "$name.aero"): ClothWind The aerodynamic surface of one cloth: registers a per-quad pass that reads its wind from wind and hands back the handle for it.
fun Wind.windLines(x0: Float, y0: Float, z0: Float, x1: Float, y1: Float, z1: Float, columns: Int = 14, rows: Int = 10, scale: Float = 0.06f, color: Vec4 = vec(0.45, 0.95, 0.72, 0.30), thickness: Double = 1.0): List<FLine> The wind field itself, as FLine whiskers on a grid — what to draw underneath a fluttering sheet so that the gusts are visible rather than inferred.

Wind

class Wind internal constructor(val sim: SimdVerlet, val name: String, var velocityX: Float, var velocityY: Float, var velocityZ: Float, var drag: Float, var turbulence: Float, var turbulenceScale: Float, var turbulenceSpeed: Float, var range: IntRange?, val gusts: Turbulence)

One wind: a mean velocity, a gust field, and the per-particle drag pass those two feed.

Declaration Summary
val sim: SimdVerlet
val name: String
var velocityX: Float
var velocityY: Float
var velocityZ: Float
var drag: Float
var turbulence: Float
var turbulenceScale: Float
var turbulenceSpeed: Float
var range: IntRange?
val gusts: Turbulence
var time: Float Seconds of wind elapsed. Advanced by dt on every pass; assignable, so gusts can be scrubbed.
val speed: Float Speed of the mean wind, which is what turbulence is a fraction of.
fun sampleInto(x: Float, y: Float, z: Float, out: FloatArray, t: Float = time) The wind velocity at a world point and the current time, mean plus gust, into out[0..2].
fun accumulate(state: SimdVerlet.State3, dt: Float) The per-particle drag pass, exposed for callers who would rather drive it than register it.
fun detach() Unregisters the pass from sim.

Turbulence

class Turbulence(val seed: Int = 1, val octaves: Int = 3)

A small closed-form gust spectrum: three independent scalar fields over (x, y, z, t), each a sum of octaves sines whose direction, frequency and phase come from seed.

Declaration Summary
fun sample(component: Int, x: Float, y: Float, z: Float, t: Float): Float Component component (0, 1, 2) of the gust field at (x, y, z) and time t, in [-1, 1]. The coordinates are expected to be pre-scaled by the caller's spatial frequency.

ClothWind

class ClothWind internal constructor(val cloth: Cloth, val wind: Wind, var density: Float, var dragCoefficient: Float, var tangentialCoefficient: Float, val name: String)

The per-quad aerodynamic pass for one Cloth. See Cloth.addWind and Wind.

Declaration Summary
val cloth: Cloth
val wind: Wind
var density: Float
var dragCoefficient: Float
var tangentialCoefficient: Float
val name: String
var lastImpulse: Float Total force magnitude the last pass put into the sheet — the "is the wind on" readout, and what the tests floor so that a silently-dead aero pass fails instead of passing as a sheet that happens to hang the right way. Zeroed by detach, so it means "what the air did last frame" rather than "the last time the air did anything".
fun accumulate(state: SimdVerlet.State3, dt: Float) The pass itself, exposed for callers who would rather drive it than leave it registered.
fun detach() Unregisters the pass from the cloth's simulation, and zeroes lastImpulse.

Strand.kt

A rope inside somebody else's SimdVerlet: the one-dimensional sibling of Cloth, and the same shape of thing. sim.addStrand(...) (or Strand.addTo) appends count particles to a simulation that may already contain others, chains them through a SimdVerlet.DirectedConstraints4, and hands back a handle that speaks node indices.

val sim = SimdVerlet(SimdVerlet.State3.empty())
val rope = sim.addStrand(count = 40, spacing = 0.12f, stiffness = 60f)
rope.pin(0)
while (true) { rope.step(gravity = 9.8f, iterations = 16); draw(rope.lines()) }

Why every node has two spare slots

A chain node spends slot 0 on its successor and slot 1 on its predecessor; the ends use one each. That leaves slots 2 and 3 free on every node of every rope, which is what Strand.attachTo spends. A rope can be tied to a cloth corner, to another rope, or to any particle in the simulation with a spare slot, and the join is an ordinary symmetric distance constraint that the same solver, the same colliders and the same Tearing already understand. So a tied rope tears at its knot under enough load.

What it cannot tie to is a cloth's interior, which has all four slots on its axis neighbours. attachTo throws there and names the particle index rather than ignoring the tie. Corners have two free slots and edges have one.

Bending

Strand.bendStiffness straightens the rope by moving each interior node toward the midpoint of its two neighbours: the 1-D case of Cloth's curvature idea, but as a position projection in SimdVerlet.positionProjections rather than a force.

A projection rather than a force, because a curvature force would sit on the solver's stiffness * iterations scale, where the product has a divergence ceiling in dt, and the strength that holds a rope straight is above it: turned up far enough such a force thrashes instead of stiffening. A projection has no stiffness, so it has no ceiling. The projection itself is the standard mass-weighted one — C = b - (a+c)/2, corrections Δb = +w_b·d/S, Δa = Δc = -w·d/2S with S = w_b + (w_a + w_c)/4 — applied to x and px together so it injects no velocity and can add no energy, and swept in three colours so that overlapping stencils do not stack their corrections on top of each other. bendStiffness is clamped to 0..1, and Strand.MAX_BEND_CORRECTION is what 1 maps onto: the stiffest correction this integrator holds, rather than a full one.

What it controls is how tightly the rope can fold, which is not the same thing as how stiff the rope is. StrandTest heaps a 50-node rope on a floor between two anchors and reads the mean joint angle: it falls with every increase in bendStiffness, and by 1.0 it is under a third of the limp rope's, with no sharp corner left.

On a cantilever it does almost nothing, because a rope hanging under gravity is already straight and the base moment a cantilever needs is not something a local three-node stencil can supply. For a rod, add distance constraints across the gaps rather than turning this up.

0f is the default: a rope is limp unless told otherwise.

One ordering caveat. Projections run in registration order, so a strand created before its colliders bends first and is then pushed out of the world. Create the strand after the colliders and the last word belongs to bending, and a stiff rope can end a frame slightly inside a shape.

Stability

The same contract as Cloth: the rope's particles are the contiguous range [base, base + count), so it does not survive particle removal anywhere below its own top index. Appending after it is fine.

Strand.step, Strand.lines and the mutators serialize on this strand's own monitor.

src/jvmMain/kotlin/afield/simulation/physics/Strand.kt — read the source

Top-level functions

Declaration Summary
fun SimdVerlet.addStrand(count: Int, spacing: Float = 0.2f, stiffness: Float = 60f, mass: Float = 1f, bendStiffness: Float = 0f, position: (node: Int) -> Vector3fc = { n -> vec(n * spacing, 0f, 0f) }, restLengthFromLayout: Boolean = false, pinned: (node: Int) -> Boolean = { false }, constraints: SimdVerlet.DirectedConstraints4? = this.constraints4, name: String? = null): Strand Appends a rope to this simulation; see Strand.addTo.

Strand

class Strand private constructor(val sim: SimdVerlet, val count: Int, val base: Int, val spacing: Float, val stiffness: Float, val mass: Float, val constraints: SimdVerlet.DirectedConstraints4, val name: String, bendStiffness: Float)

The handle for one rope: node indices, the bending projection, and the ties to other particles.

Declaration Summary
val sim: SimdVerlet The simulation these particles live in.
val count: Int Number of nodes in the chain.
val base: Int Index of this strand's first particle. Its particles are base until base + count.
val spacing: Float Nominal segment length: the default layout's step and the default rest length.
val stiffness: Float Distance-constraint stiffness this strand wired into its slots.
val mass: Float Particle mass used by pin when it un-pins a node.
val constraints: SimdVerlet.DirectedConstraints4 The constraint set carrying this strand — shared with the rest of sim.
val name: String Key prefix for everything this strand registered on sim ("$name.bend", ...).
var bendStiffness: Float How much of the way toward straight each interior node is moved by the registered bending projection, per step, in 0..1. 0f disables it live; the file overview has why this is a projection and not a force.
val end: Int One past this strand's last particle index.
operator fun get(node: Int): Int Particle index of chain node node.
operator fun contains(particle: Int): Boolean True if particle is one of this strand's.
fun position(node: Int): Vec3 Current position of chain node node.
fun pin(node: Int, pinned: Boolean = true) Pins (pinned = true, inverse mass 0) or releases one node. Requires the state to carry an invMass array; without one every particle has unit mass and nothing can be pinned.
fun isPinned(node: Int): Boolean True if node has zero inverse mass.
fun teleport(node: Int, to: Vector3fc, keepVelocity: Boolean = false) Moves one node. By default the previous position moves with it, so the integrator sees a teleport rather than a one-frame velocity spike; keepVelocity = true throws it instead.
fun attachTo(particle: Int, node: Int = count - 1, restLength: Float = -1f, stiffness: Float = this.stiffness): Float Ties node node to an arbitrary particle with a symmetric distance constraint, spending one free slot on each. Returns the rest length used.
fun projectBend(state: SimdVerlet.State3, strength: Float = bendStiffness) The bending projection, exposed for callers who would rather drive it than leave it registered. Moves x and px together for every interior node, so it injects no velocity; touches only interior nodes, so a 2-node strand gets nothing from it.
fun enableBend(strength: Float = bendStiffness) Registers the bending projection on sim at strength, replacing it if already there.
fun disableBend() Drops the bending projection from sim altogether.
fun step(dt: Float = 1f / 60f, damping: Float = 0.99f, gravity: Float = 0f, iterations: Int = 8, accumulateForces: ((state: SimdVerlet.State3, dt: Float) -> Unit)? = null) Steps the whole simulation once with this strand's constraint set, growing that set first if particles were added since the last step — the same convenience, and the same caveat, as Cloth.step: it steps everything in sim, not just the rope.
fun lines(): List<FLine> The rope as a single polyline.
fun frames(dt: Float = 1f / 60f, damping: Float = 0.99f, gravity: Float = 0f, iterations: Int = 8): Sequence<List<FLine>> step then lines, forever — the shape a Field box's animation loop consumes.
fun meanKinkDegrees(): Float Mean angle in degrees between successive segments — the "how limp is this rope" readout, and what bendStiffness's documented scale is measured in. 0 for a rope in a straight line.
fun remove() Detaches this strand from sim: drops the bending pass and clears its constraint slots. The particles stay, as with Cloth.remove. Anything tied to it with attachTo keeps its own half of the join, so untie first if that matters.

FastMechanics.kt

The packed kernel for the membrane energy: FastMechanics computes the same energy and gradient as Mechanics from flat arrays prepacked once per solve.

InflationSolver builds one when SolveOptions has fastKernel set and minimises against it, then re-evaluates each step through Mechanics, so every residual and per-step figure it reports comes from the reference kernel. The Evaluation returned here carries no FilmState or TapeState; per-triangle and per-tape detail exists only on the reference path. PressureTest.materialBranchesForcesAndPackedKernelAgree holds the two kernels to 1e-7 on energy and on every gradient component, across both gas modes and three deformations.

src/jvmMain/kotlin/afield/simulation/physics/pressure/FastMechanics.kt — read the source

FastMechanics

class FastMechanics(private val model:Envelope,private val load:LoadCase)

Per-solve prepacked element data; numeric force path avoids per-element vectors and lists.

Declaration Summary
fun evaluate(x:DoubleArray,fraction:Double=1.0):Evaluation

Mechanics.kt

The membrane energy and its gradient: Mechanics evaluates film, tape, tether, gravity, ground contact and gas work for one set of node positions under a LoadCase.

Gas is either held at a regulated pressure or sealed and isothermal (GasMode). The Evaluation it returns carries per-triangle FilmState and per-tape TapeState alongside the energy, which InflationSolver minimises.

src/jvmMain/kotlin/afield/simulation/physics/pressure/Mechanics.kt — read the source

Used in: Pressure membranes

GasMode

enum class GasMode

How the enclosed gas responds to volume change: held at a set pressure, or sealed at constant temperature.

Constants: REGULATED, SEALED_ISOTHERMAL.

LoadCase

data class LoadCase(val pressure: Double = 0.0, val gasMode: GasMode = GasMode.REGULATED, val gravity: P3 = P3(0.0,0.0,0.0), val groundY: Double? = null, val groundPenalty: Double = 1e8)

Pressure difference in Pa, gravity in m/s², optional horizontal penalty-contact plane in m. Sealed gas uses the mesh initial volume as its reference.

Declaration Summary
val gravity: P3
val groundPenalty: Double

FilmState

data class FilmState(val major: Double, val minor: Double, val strain: Double, val direction: P3, val state: String, val area: Double)

Declaration Summary
val direction: P3, val state: String, val area: Double)

TapeState

data class TapeState(val tension: Double, val strain: Double)

Evaluation

data class Evaluation(val energy: Double, val gradient: DoubleArray, val pressure: Double, val volume: Double, val films: List<FilmState>, val tapes: List<TapeState>, val contact: DoubleArray, val pressureForces: DoubleArray, val filmEnergy: Double, val tapeEnergy: Double)

Declaration Summary
val volume: Double, val films: List<FilmState>, val tapes: List<TapeState>
val contact: DoubleArray, val pressureForces: DoubleArray
val filmEnergy: Double, val tapeEnergy: Double)

Mechanics

class Mechanics(val model: Envelope, val load: LoadCase)

Total-Lagrangian constant-strain triangles; large rotations, elastic Green strain. Isotropic plane-stress St Venant--Kirchhoff energy relaxed in compressive principal directions (taut / uniaxially wrinkled / slack). No bending or wrinkle wavelength. Film force resultants are pushed into the current surface, in N per current metre. This elastic law does not model bending, self-contact, plasticity or material failure.

Declaration Summary
fun evaluate(x: DoubleArray, fraction: Double = 1.0, details: Boolean = true): Evaluation

MinimumSearch.kt

A sampled check of whether a solved membrane sits at an energy minimum: MinimumSearch estimates the lowest curvature with a finite-difference Lanczos iteration and tries displacements along it and along eight smoothed random directions.

The result is a MinimumAudit, which also folds in a ShapeAudit of the deformed skin. A positive Ritz value is not a stability certificate; the audit reports whether a lower-energy direction was found in the directions it sampled.

src/jvmMain/kotlin/afield/simulation/physics/pressure/MinimumSearch.kt — read the source

MinimumAudit

data class MinimumAudit(val energy: Double,val curvature: Double,val curvatureAtDoubleStep: Double, val ritzResidual: Double,val directions: Int,val testedDisplacements: Int, val bestEnergyDrop: Double,val escape: DoubleArray?,val shape: ShapeAudit, val rows: List<String> = emptyList())

The outcome of MinimumSearch: sampled curvature, the best energy drop found, and the shape checks.

Declaration Summary
val ritzResidual: Double,val directions: Int,val testedDisplacements: Int
val bestEnergyDrop: Double,val escape: DoubleArray?,val shape: ShapeAudit
val rows: List<String>

MinimumSearch

object MinimumSearch

Finite-difference Hessian/Lanczos audit. A positive Ritz value is NOT a stability certificate.

Declaration Summary
data class Ritz(val value: Double,val vector: DoubleArray,val residual: Double,val steps: Int)
fun lowest(n: Int,apply: (DoubleArray)->DoubleArray,steps: Int=96,seed: Long=47): Ritz
fun audit(s: Solution,cancelled: ()->Boolean={false},steps: Int=96): MinimumAudit
fun improve(input: Solution,cancelled: ()->Boolean={false},progress: (String)->Unit={}): Solution

Model.kt

The input model for the pressure-membrane solver: a closed triangulated Envelope whose triangles each carry a Film, with Tape reinforcements, Tether and Anchor supports, all in SI units.

Positions are a flat DoubleArray of x, y, z per node, read with point. SurfacePoint attaches a tape or tether to fixed barycentric weights on the material, so an attachment moves with the deformed skin. volume gives the enclosed volume that the gas load acts on.

src/jvmMain/kotlin/afield/simulation/physics/pressure/Model.kt — read the source

Used in: Pressure membranes

Top-level functions

Declaration Summary
fun Tape.startPoint()
fun Tape.endPoint()
fun point(x: DoubleArray,n: Int)
fun volume(x: DoubleArray,fs: List<PanelTriangle>): Double

P3

data class P3(val x: Double, val y: Double, val z: Double)

Double precision mechanical coordinates; metres throughout.

Declaration Summary
operator fun plus(b: P3)
operator fun minus(b: P3)
operator fun times(s: Double)
fun dot(b: P3)
fun cross(b: P3)
fun norm()
fun unit()
fun array()

Film

data class Film(val young: Double, val poisson: Double, val thickness: Double, val density: Double, val provenance: String = "User-supplied isotropic membrane material")

Isotropic membrane material: modulus in Pa, thickness in m and density in kg/m³. Supply measured values for the intended material.

Declaration Summary
val thickness: Double, val density: Double
val provenance: String

Tape

data class Tape(val id: String, val a: Int, val b: Int, val rest: Double, val ea: Double, val width: Double = 1.0, val linearDensity: Double = 0.0, val start: SurfacePoint? = null, val end: SurfacePoint? = null)

Tension-only axial element. ea is rigidity in newtons, rest and width are metres, linearDensity is kg/m.

Declaration Summary
val ea: Double, val width: Double
val start: SurfacePoint?

SurfacePoint

data class SurfacePoint(val nodes: List<Int>, val weights: List<Double>)

Material attachment: fixed barycentric weights, independent of the deformed embedding.

Declaration Summary
fun at(x: DoubleArray): P3
fun distribute(g: DoubleArray,f: P3)

Tether

data class Tether(val id: String,val attachment: SurfacePoint,val target: P3,val rest: Double,val ea: Double)

Declaration Summary
fun force(x: DoubleArray): P3

PanelTriangle

data class PanelTriangle(val a: Int, val b: Int, val c: Int, val panel: String, val uv: DoubleArray, val film: Film)

Each triangle retains material coordinates independently of its starting spatial shape.

Declaration Summary
val uv: DoubleArray, val film: Film)
val area: Double
val du: DoubleArray
val dv: DoubleArray
fun nodes()

Anchor

data class Anchor(val id: String, val node: Int, val target: P3, val stiffness: Double? = null, val capacity: Double? = null)

stiffness=null is an ideal fixed point; otherwise a 3D spring in N/m.

Declaration Summary
val capacity: Double?

Pattern

data class Pattern(val id: String, val outline: List<Pair<Double,Double>>)

Envelope

data class Envelope(val initial: DoubleArray, val triangles: List<PanelTriangle>, val tapes: List<Tape> = emptyList(), val anchors: List<Anchor> = emptyList(), val patterns: List<Pattern> = emptyList(), val description: String = "", val tapeProvenance: String = "Uncalibrated EA study parameter", val tethers: List<Tether> = emptyList())

Closed oriented pressure mesh with independent material coordinates and optional bonded tapes, tethers and anchors. No carrier or cutting algorithm is assumed. Arrays are solve inputs: do not mutate them during evaluation.

Declaration Summary
val tapes: List<Tape>
val patterns: List<Pattern>
val description: String
val tethers: List<Tether>
val mass: DoubleArray

NewtonPolish.kt

The optional Newton step that finishes an inflation solve: NewtonPolish takes the positions L-BFGS reached and drives the free-node force residual down further.

InflationSolver runs it once per pressure step, and only when that residual sits above the step's acceptance gate but close to it. Curvature enters as Hessian-vector products built from central differences of the gradient, two energy evaluations each, so no Hessian is ever assembled. The conjugate-gradient inner solve is preconditioned by the energy diagonal from MinimumSearch and truncated at the trust-region boundary or on negative curvature, and anchors with no stiffness stay out of the free set. SolveOptions carries the flag that turns it off.

src/jvmMain/kotlin/afield/simulation/physics/pressure/NewtonPolish.kt — read the source

NewtonPolish

object NewtonPolish

Truncated preconditioned CG in a trust region. A final force correction, not a stability certificate.

Declaration Summary
fun improve(model:Envelope,initial:DoubleArray,fraction:Double,options:SolveOptions, evaluate:(DoubleArray)->Evaluation,cancelled:()->Boolean,trace:SolveTrace, progress:(SolveProgress)->Unit):DoubleArray

ShapeAudit.kt

Geometric checks on a deformed pressure envelope: ShapeAudits finds triangle self-intersections, sharp folds between neighbouring triangles, panel seams and multi-panel junction angles.

The checks read the computed triangles only, independent of how a viewer shades or explodes them.

src/jvmMain/kotlin/afield/simulation/physics/pressure/ShapeAudit.kt — read the source

ShapeAudit

data class ShapeAudit(val intersections: List<Pair<Int,Int>>,val foldedEdges: Int,val maximumFoldDegrees: Double)

Intersecting triangle pairs, the folded-edge count and the sharpest fold angle in degrees.

ShapeAudits

object ShapeAudits

Diagnostics on computed triangles, independent of shading and viewer explosion.

Declaration Summary
fun junctionAngles(model: Envelope): Map<Int,Double>
fun seams(model: Envelope): List<Pair<Int,Int>>
fun inspect(model: Envelope,x: DoubleArray): ShapeAudit

Solver.kt

InflationSolver relaxes an Envelope to equilibrium by raising pressure in steps and minimising the Mechanics energy at each one with Field's L-BFGS.

A step is accepted on free-force residuals in newtons, not on optimiser step size, and the Solution reports anchor reactions, force and moment balance and the step Solution.history. A failed continuation returns unconverged at the load fraction it reached. SolveOptions sets the step count, tolerances and the optional Newton polish.

src/jvmMain/kotlin/afield/simulation/physics/pressure/Solver.kt — read the source

Used in: Pressure membranes

SolveOptions

data class SolveOptions(val pressureSteps: Int = 8, val iterationsPerStep: Int = 6000, val relativeTolerance: Double = 2e-5, val absoluteTolerance: Double = .02, val minimumStep: Double = 1.0/128, val precondition: Boolean = false, val physicalStopping: Boolean = true, val newtonPolish: Boolean = true, val fastKernel: Boolean = true)

Continuation and stopping settings for InflationSolver.solve.

Declaration Summary
val relativeTolerance: Double
val minimumStep: Double
val physicalStopping: Boolean

InflationStep

data class InflationStep(val fraction: Double,val pressure: Double,val volume: Double, val residualN: Double,val relativeResidual: Double,val iterations: Int, val accepted: Boolean,val reason: String,val positions: DoubleArray)

Declaration Summary
val residualN: Double,val relativeResidual: Double,val iterations: Int
val accepted: Boolean,val reason: String,val positions: DoubleArray)

AnchorReaction

data class AnchorReaction(val anchor: Anchor,val force: P3)

Solution

data class Solution(val model: Envelope,val load: LoadCase,val positions: DoubleArray, val evaluation: Evaluation,val history: List<InflationStep>, val converged: Boolean,val reason: String,val elapsedMs: Double, val reactions: List<AnchorReaction>,val balanceN: Double,val momentNm: Double, val residualN: Double,val relativeResidual: Double,val minimum: MinimumAudit?=null, val trace: SolveTrace = SolveTrace())

Declaration Summary
val evaluation: Evaluation,val history: List<InflationStep>
val converged: Boolean,val reason: String,val elapsedMs: Double
val reactions: List<AnchorReaction>,val balanceN: Double,val momentNm: Double
val residualN: Double,val relativeResidual: Double,val minimum: MinimumAudit?
val trace: SolveTrace

SolveProgress

data class SolveProgress(val iteration:Int,val fraction:Double,val residualN:Double,val relativeResidual:Double,val evaluations:Int,val method:String)

Uses Field's existing L-BFGS; only physical residuals can declare an equilibrium accepted.

SolveTrace

class SolveTrace

Declaration Summary
var evaluations
val rows
override fun toString()

InflationSolver

object InflationSolver

Declaration Summary
fun solve(model: Envelope, load: LoadCase = LoadCase(), options: SolveOptions = SolveOptions(), cancelled: () -> Boolean = { false }, initialPositions: DoubleArray? = null, onProgress:(SolveProgress)->Unit={}, onStep: (InflationStep) -> Unit = {}): Solution Relax a supplied closed membrane by pressure continuation. initialPositions enables a full-load warm start. Acceptance uses free-force residuals in N, not optimizer step size. Failed continuation returns an unconverged result with its actual load fraction in Solution.history. Cancellation propagates. A converged force balance is not proof of a minimum; MinimumSearch offers a sampled diagnostic.