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
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
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.
ScoreDraw
class ScoreDraw internal constructor()
The shapes the boxes under the playhead draw this frame, in output-canvas pixels.
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
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
How a character's movement responds to input — the dials on MovementState.update.
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:
radiusEdgeRatiosis what TetGen's-qswitch bounds, and therefore the number to check when you want to know whether the mesher honoured what you asked for.qualitiesis 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
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
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
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
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
The slow learning level: reward-modulated plasticity of the four sensor→motor weights.
VehicleParams
Body, sensors and wheels of a vehicle, plus its two NeuronParams layers of dynamics.
HysteresisSweep
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
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
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
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.
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
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
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
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
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
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
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
Which built-in measure CreatureSimulationConfig.fitnessObjective scores with, when CreatureSimulationConfig.fitness has not replaced it.
Constants: DIRECTIONAL_PROGRESS, NET_CENTROID_DISPLACEMENT.
CreatureSnapshot
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.
CreatureEvaluation
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
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.
EvolutionGeneration
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
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
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
The genetic algorithm itself: where a creature comes from, how one is scored, and how a population is bred.
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
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
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
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
One way of bringing a worker up and handing back a socket connected to it.
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
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
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
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
User-visible orchestration states. Preview geometry itself remains ordinary Manifold/TriMesh data.
Constants: GEOMETRY_PREVIEW, COARSE_PREVIEW, COMMITTED_SOLVE, READY, FAILED, CLOSED.
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
| Declaration | Summary |
|---|---|
val materialId: String |
|
override val formulation: String |
|
override fun wire(): Map<String, Any?> |
|
override fun regions(geometryId: String): Map<String, Any?> |
FlowParameters
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
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
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.
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 typeDand theStudyVariables that move it; - a
plan, mapping a design to theStudyCasethe solver runs (geometry and coefficients); - a
measure, reducing aSimulationResultto 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
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
Which end of the objective the study is looking for.
Constants: MINIMISE, MAXIMISE.
StudyVariable
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
What one design means to the solver.
| Declaration | Summary |
|---|---|
val parameters: SimulationParameters |
|
val geometry: SimulationGeometry? |
|
val edit: SimulationEdit |
StudySample
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
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
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
The three figures: the landscape panel, the convergence panel, and both stacked as a sheet.
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
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
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
Drive KratosStudyDemo through a real worker and write the figures.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
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
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
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
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
The three boolean operations, on the wire as the lowercased name.
Constants: UNION, DIFFERENCE, INTERSECTION.
SimulationCSG
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
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
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
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
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
What happens at the window's edges.
Constants: WRAP, BOUNCE.
BoidShape
How a kind's body is drawn.
Constants: ARROW, KITE, DOT, LINE, NONE.
BoidBlend
How trails and bodies combine with what is under them.
Constants: NORMAL, LIGHTER.
BoidRule
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.
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
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
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
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
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
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
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
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
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
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
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
Cloth
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
Whether dyn4j moves a body, or it stands still and is only collided against.
Constants: STATIC, DYNAMIC.
DynShapeDef
The collision shape of a body, in the body's own coordinates.
DynBodyDef
Everything Dyn4jScene.addBody needs to make one body, with defaults that give a unit-density, non-bouncing, undamped body at rest at the origin.
DynBodyHandle
data class DynBodyHandle(val id: Long)
An opaque reference to a body in one Dyn4jScene, counted up from 1 and never reused.
DynBodyState
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.
JoltCameraFrustumPlanes
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
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
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
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.
JoltBodyState
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
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
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.
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.
OctreeBarnesHutPool
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
- ~dt model-inverting PD+I on acceleration, output through a bounded-curvature force limiter (magnitude, magnitude rate, and angular rate/acceleration on the direction).
- ~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.
- ~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.
- ~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
Telemetry
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
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
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:
State3is structure-of-arrays:x/y/z, the previous positionspx/py/pz, the force accumulatorsax/ay/az, and an optionalinvMass. A particle is an index into all of those at once rather than an object,invMass == 0is how one is pinned, and the accumulators are scratch thatstepzeroes at the top of every frame.DirectedConstraints4gives 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.DirectedConstraints3is the same with three.ConstraintScratchis 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
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
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
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
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
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
Which plane of the simulation the rigid-body scene lives in. Only XY is implemented.
Constants: XY.
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
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
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
Tearing
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
Wind
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
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
Strand
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
How the enclosed gas responds to volume change: held at a set pressure, or sealed at constant temperature.
Constants: REGULATED, SEALED_ISOTHERMAL.
LoadCase
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
| Declaration | Summary |
|---|---|
val direction: P3, val state: String, val area: Double) |
TapeState
data class TapeState(val tension: Double, val strain: Double)
Evaluation
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
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
Finite-difference Hessian/Lanczos audit. A positive Ritz value is NOT a stability certificate.
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
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
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
| Declaration | Summary |
|---|---|
fun force(x: DoubleArray): P3 |
PanelTriangle
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
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
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
Truncated preconditioned CG in a trust region. A final force correction, not a stability certificate.
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
Intersecting triangle pairs, the folded-edge count and the sharpest fold angle in degrees.
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
Continuation and stopping settings for InflationSolver.solve.
| Declaration | Summary |
|---|---|
val relativeTolerance: Double |
|
val minimumStep: Double |
|
val physicalStopping: Boolean |
InflationStep
| 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
SolveProgress
Uses Field's existing L-BFGS; only physical residuals can declare an equilibrium accepted.
SolveTrace
| Declaration | Summary |
|---|---|
var evaluations |
|
val rows |
|
override fun toString() |
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. |