Graphics
Everything that ends up as an FLine, a colour or a shader: drawing helpers, contouring, geometry, colour spaces, keyframing and the small shading languages' host side.
330 source file(s), 8046 public symbol(s), 5296 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.
gestureKeyframes.kt
Gesture takes and keyframe lanes, in both directions: bakeGesture turns a take recorded on a
pad into two editable lanes, and lanesToTake renders lanes back into a take a pad accepts.
A take recorded by gesture(...) is a run of (t, x, y) samples that GestureHandle.at joins with
straight lines. Baking keeps the fewest of those samples that stay within a tolerance of the
take, as linear keys in two lanes that share their key times. Because the keys are a subset of
the samples and both are joined by straight lines, the largest difference anywhere in time is the
largest difference at a sample, so the tolerance holds at every t, not only at the samples.
src/jvmMain/kotlin/afield/animation/gesture/gestureKeyframes.kt — read the source
Used in: The keyframe editor: drawing a value over time
Top-level functions
| Declaration | Summary |
|---|---|
fun reduceTakeIndices(take: DoubleArray, tolerance: Double): List<Int> |
The indices of the samples of take (flattened (t, x, y) triples) to keep so that neither axis strays more than tolerance from the take: Ramer–Douglas–Peucker over both axes at once, measured vertically (at the same time), which is how a lane is evaluated. The first and last samples are always kept, so the lanes span the take's whole loop. |
fun takeToLanes(take: DoubleArray, tolerance: Double): GestureLanes |
Bakes take (flattened (t, x, y) triples) into two linear lanes; see reduceTakeIndices. |
fun measureGestureFit(take: DoubleArray, x: KeyframeEvaluator, y: KeyframeEvaluator, between: Int = 8): GestureFit |
Measures x and y against take: at every sample time and at between evenly spaced moments between neighbouring samples, the worse axis's difference. |
fun lanesToTake(x: KeyframeEvaluator, y: KeyframeEvaluator, samplesPerSecond: Double = 60.0): List<Pair<Double, Vector2f>> |
Renders two lanes into a take that gesture(initialTake = ...) or GestureHandle.setTake accepts: samplesPerSecond evenly spaced samples from the first key to the last of either lane, plus every key time, so corners stay sharp and bezier segments come out as smooth runs of short lines. Time starts at the earlier lane's first key; a take always starts at 0, so it is shifted there. |
fun Box.bakeGesture(pad: GestureHandle, tolerance: Double = 0.02, xName: String = "x", yName: String = "y", channel: String? = null): Pair<KeyframeEvaluator, KeyframeEvaluator> |
Bakes the take recorded on pad into two editable lanes, x and y by default, shown in this box's keyframe editors and saved with it. The lanes share a time axis and their key times, so a Cmd-drag across a moment selects it in both. tolerance is in pad units: no point of the lanes is further than that from the take, on either axis. Returns the two lanes; their keys replace whatever the lanes held. With no take recorded, the lanes are left alone. |
GestureFit
How well a pair of lanes matches a take: errors in pad units, worst axis at each moment.
| Declaration | Summary |
|---|---|
val maxError: Double |
Largest difference between take and lanes, over a dense resample of the take's length. |
val meanError: Double |
Mean of that difference over the same resample. |
val keyCount: Int |
Keys in each lane (the lanes share their key times). |
val sampleCount: Int |
Samples in the take. |
override fun toString() |
GestureLanes
data class GestureLanes(val x: List<Keyframe>, val y: List<Keyframe>)
The x and y lanes baked from a take.
keyframeInterpolants.kt
Custom key types for the keyframe editor that keyframes(...) opens: a named segment shape,
written as a Kotlin lambda.
interpolant("overshoot") { v0 + (v1 - v0) * (1 + 2.7 * (u - 1).pow(3) + 1.7 * (u - 1).pow(2)) }
The name becomes a key type, offered in the key menu, and a key of that type shapes the segment
that leaves it. The interpolant macro (macro_interpolant) keeps the lambda's source beside
the live lambda and saves it with the box, in its keyframeInterpolants property. Compiling
that source again is what keeps a curve's shape while the definition has not run: when the box
defines the type only after it calls keyframes, and when the definition has been deleted.
src/jvmMain/kotlin/afield/animation/interpolation/keyframeInterpolants.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.interpolant(name: String, block: KeySegment.() -> Double) |
Defines a key type named name for this box's keyframe editors: a segment shape, written as the value the curve should have at each point of the segment. |
fun Box.registerInterpolant(name: String, source: String, block: KeySegment.() -> Double): KeyInterpolant |
What the interpolant macro rewrites a definition to: the lambda, and its source to save. |
fun Box.macro_interpolant(args: List<String>): Insert? |
The interpolant("name") { body } macro: keeps the body's source next to the lambda. |
KeySegment
One segment of a keyframe curve, as an interpolant sees it: the receiver of its lambda.
| Declaration | Summary |
|---|---|
val t: Double |
The time being evaluated, between t0 and t1. |
val u: Double |
How far through the segment t is: (t - t0) / (t1 - t0), from 0 to 1. |
val t0: Double |
The time of the key the segment leaves. |
val t1: Double |
The time of the next key. |
val v0: Double |
The value of the key the segment leaves. |
val v1: Double |
The value of the next key. |
val before: Keyframe? |
The key before this segment's first, if there is one: for shapes that look past the segment. |
val after: Keyframe? |
The key after this segment's second, if there is one. |
val span: Double |
The segment's length in time. |
fun lerp(f: Double): Double |
The value f of the way from v0 to v1: lerp(u) is a straight line, lerp(f(u)) an easing. |
fun lerp(a: Double, b: Double, f: Double): Double |
a + (b - a) × f. |
fun smoothstep(f: Double): Double |
The smooth step from 0 to 1 as f goes from 0 to 1, with zero slope at both ends. |
KeyInterpolant
A named key type: its lambda, and the Kotlin source it was written as.
| Declaration | Summary |
|---|---|
val name: String |
The name a key stores to select this type. |
val boxId: String |
The box that defined it, and the box its source is compiled back into. Empty for a library type. |
val source: String |
The lambda's Kotlin source, saved with the box. Empty when there is none to save: a library type, or a definition the interpolant macro did not rewrite. |
val entry: KeyTypeEntry? |
Where the type sits in the key menu; KeyTypeLibrary entries say, a box's own are "This box". |
fun evaluate(s: KeySegment): Double |
The value of segment s under this type. A definition that cannot run gives the straight line. |
KeyframeControls.kt
src/jvmMain/kotlin/afield/animation/keyframes/KeyframeControls.kt — read the source
Used in: Visualizers, Displays, and Little Instruments
Top-level functions
KeyframeControl
One keyframed control declared as a delegated local:
| Declaration | Summary |
|---|---|
var name: String |
The key name — the delegated property's name, once declared. |
val label: String |
The control's label: the label argument, or the property name. |
var value: T |
The current value; the same thing reading the delegated local returns. |
operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): KeyframeControl<T> |
|
override fun getValue(thisRef: Any?, property: KProperty<*>): T |
|
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) |
keyframeCodeBlend.kt
Blending two code keyframes by finding the structure they share and interpolating the numbers they do not.
A KeyframeScope can snapshot code as well as values: a stack.auto StackSL program through
KeyframeScope.stackAuto, and a Kotlin expression through the sourceBlock { } macro. Two such
snapshots have no midpoint in general — half a program is not a program — so
KeyframeScope.interpolate steps them, taking the right-hand source once t >= 1. That is all
that is available when the two programs disagree about what they do.
When they agree about what they do and differ only in numbers, there is a real midpoint, and
this file finds it. Both sources are lexed, the two token streams are aligned, and every place
where the alignment pairs a number with a number becomes a CodeBlendSite. Anything the
alignment cannot pair is a CodeBlendDivergence, and one divergence is enough to put the whole
slot back on the stepping path: a program built from the left structure with the right numbers,
where the two structures differ, is neither keyframe at either end.
The two ways to spend the blend
CodeBlendMode.STATIC writes the interpolated numbers into the source and hands back a new
program, which the slot then compiles. It reaches every site — including StackSL's modifier chains
and random ranges, whose grammar demands literals — and it costs a shader compile (or, for Kotlin,
a K2 compile) per value of t. Scrubbing a timeline or stepping between two looks is what it is
for; sixty times a second is not.
CodeBlendMode.RUNTIME emits one program in which each moving site reads the blend from
outside. The program is then invariant in t, so it compiles once and animates for free. StackSL
gets there by rewriting 0.5 as 0.2 0.9 ?pc.keyframeBlend .x mix — a push of each endpoint and
the builtin mix, which leaves one value on the stack exactly as the literal it replaced did, so
the surrounding stack arithmetic is untouched. Kotlin gets there by rewriting 0.5f as
afield.animation.keyframes.kfMix(0.2f, 0.9f, ...). Sites a mix expression cannot reach — see
StackSLNumericFormKind — make the plan report CodeBlendPlan.runtimeCapable false, and a
RUNTIME request over one of those falls back to STATIC and says so.
The push-constant convention
A StackSL program can only read what the host has already put in the shader, and a stack.auto
slot cannot declare a uniform of its own. So the blend parameter arrives by convention: declare a
vec4 called keyframeBlend in the shader's push-constant block, and register the matching
keyframe lane so the scope can write it.
layout(push_constant) uniform Controls {
vec4 keyframeBlend;
} pc;
scope.target("keyframeBlend") { module.current.liveUniformStructures["pc"] }
scope.codeBlend = CodeBlendOptions(mode = CodeBlendMode.RUNTIME)
KeyframeScope.interpolate then fills that lane with keyframeBlendVector on every blend, and
the four components are four shapes of the same parameter — see CodeBlendLane. A vec4 rather
than a float because a push-constant block is padded to vec4 boundaries anyway, and because a
program that wants the eased blend for one number and the raw one for another should not need a
second uniform to say so.
The names are defaults (keyframeBlendPushConstant, keyframeBlendKey); a shader whose block
instance is called something other than pc sets CodeBlendOptions.stackSLRead to match.
src/jvmMain/kotlin/afield/animation/keyframes/keyframeCodeBlend.kt — read the source
Used in: Keyframes: capturing, persisting and replaying live state
Top-level functions
| Declaration | Summary |
|---|---|
fun keyframeBlendVector(t: Double): Vector4f |
The blend parameter as the vec4 the push-constant convention expects: the same t in the four shapes of CodeBlendLane. |
fun keyframeBlendAmount(id: Int, component: Int = CodeBlendLane.RAW.component): Double |
Read blend slot id's parameter on component component — the function a CodeBlendMode.RUNTIME Kotlin blend calls, once per number, every time the block runs. |
fun kfMix(a: Double, b: Double, t: Double): Double |
Blend two Doubles linearly — the generated call behind a blended Kotlin Double literal. |
fun kfMix(a: Float, b: Float, t: Double): Float |
Blend two Floats linearly, staying a Float so a blended 1.5f still fits a Float uniform. |
fun kfMix(a: Int, b: Int, t: Double): Int |
Blend two Ints and round, so a blended Int literal is still an Int. |
fun kfMix(a: Long, b: Long, t: Double): Long |
Blend two Longs and round, for a literal written with an L. |
fun kfMix(a: Vector2fc, b: Vector2fc, t: Double): Vector2f |
Blend two vec2s component by component, into a fresh vector. |
fun kfMix(a: Vector3fc, b: Vector3fc, t: Double): Vector3f |
Blend two vec3s component by component, into a fresh vector. |
fun kfMix(a: Vector4fc, b: Vector4fc, t: Double): Vector4f |
Blend two vec4s component by component, into a fresh vector. |
fun kfMix(a: Quaternionf, b: Quaternionf, t: Double): Quaternionf |
Blend two quaternions along the shorter arc, into a fresh quaternion. |
fun kfMix(a: DualQuaternionf, b: DualQuaternionf, t: Double): DualQuaternionf |
Blend two rigid transforms as a screw motion, into a fresh dual quaternion. |
fun planStackSLCodeBlend(left: String, right: String, options: CodeBlendOptions = CodeBlendOptions()): CodeBlendPlan |
Align two StackSL programs and report what a blend between them would do. |
fun planKotlinCodeBlend(left: String, right: String, options: CodeBlendOptions = CodeBlendOptions()): CodeBlendPlan |
Align two Kotlin expressions and report what a blend between them would do. |
Top-level values
| Declaration | Summary |
|---|---|
const val keyframeBlendPushConstant: String |
The conventional StackSL read expression for the blend push constant: a vec4 keyframeBlend member of a push-constant block whose instance is called pc. |
const val keyframeBlendKey: String |
The default keyframe lane name the blend vec4 is written to: keyframeBlend. |
CodeBlendLanguage
Which of the two languages a CodeBlendPlan was built from.
Constants: STACKSL, KOTLIN.
CodeBlendMode
What KeyframeScope.interpolate should do when both sides of a slot are code.
Constants: STEP, STATIC, RUNTIME.
CodeBlendLane
enum class CodeBlendLane(val component: Int, val swizzle: String)
Which component of the blend vec4 a runtime-blended program reads.
Constants: RAW, CLAMPED, SMOOTH, COMPLEMENT.
| Declaration | Summary |
|---|---|
val component: Int |
The component index in the blend vec4, and the offset keyframeBlendAmount reads. |
val swizzle: String |
The StackSL swizzle word that selects this component. |
CodeBlendOptions
How a scope should blend code: which CodeBlendMode, which CodeBlendLane, and the two names the push-constant convention needs.
| Declaration | Summary |
|---|---|
val mode: CodeBlendMode |
What to do with two code values. Defaults to CodeBlendMode.STEP, the pre-existing behaviour. |
val lane: CodeBlendLane |
Which component of the blend vec4 a runtime-blended program reads. |
val stackSLRead: String |
The GLSL expression a runtime-blended StackSL program reads the blend from. |
val blendKey: String |
The keyframe lane KeyframeScope.interpolate writes keyframeBlendVector to, when registered. |
CodeBlendInterpolation
enum class CodeBlendInterpolation
How the numbers of one CodeBlendSite are interpolated.
Constants: LERP, SLERP, SCLERP.
CodeBlendSiteKind
enum class CodeBlendSiteKind(val interpolation: CodeBlendInterpolation, val runtimeCapable: Boolean)
What kind of thing one blended site is, which decides both how its numbers move and whether a runtime blend can reach it.
Constants: NUMBER, VECTOR, QUATERNION, DUAL_QUATERNION, RANGE, MODIFIER.
CodeBlendSite
One place two code keyframes hold numbers in the same position — the unit a code blend interpolates.
| Declaration | Summary |
|---|---|
val kind: CodeBlendSiteKind |
What the site is, and so how it blends. |
val start: Int |
Start of the site's span in the left source, inclusive. |
val end: Int |
End of the site's span in the left source, exclusive. |
val leftText: String |
The site exactly as the left source writes it. |
val rightText: String |
The site exactly as the right source writes it. |
val left: List<Double> |
The numbers the left source holds, in source order. |
val right: List<Double> |
The numbers the right source holds, in the same order. |
val moving: Boolean |
True when the two sides really differ, and so when a blend has anything to do here. |
val runtimeCapable: Boolean |
Whether a runtime blend can reach this site; false for the literal-only StackSL forms. |
fun blend(t: Double): List<Double> |
The site's numbers blended at t, by kind's interpolation. |
fun staticText(t: Double): String |
The site rewritten with its numbers blended at t — the CodeBlendMode.STATIC substitution. |
override fun toString(): String |
The left text, an arrow, the right text — one line of a breakdown. |
CodeBlendDivergence
One place the alignment could not pair the two sources — the reason a slot is not blendable.
| Declaration | Summary |
|---|---|
val index: Int |
Token index in the left stream where the two sources part company. |
val line: Int |
Line number in the left source, 1-based, or 0 for a token only the right source has. |
val left: String |
What the left source has here, or "" when it has nothing. |
val right: String |
What the right source has here, or "" when it has nothing. |
override fun toString(): String |
CodeBlendPlan
The alignment of two code keyframes: what they share, where they differ numerically, and where they differ structurally.
| Declaration | Summary |
|---|---|
val language: CodeBlendLanguage |
Which language was aligned. |
val options: CodeBlendOptions |
The options the plan was built under; a scope's plan cache is keyed on these. |
val leftSource: String |
The left source, verbatim — what bake and runtimeSource rebuild from. |
val rightSource: String |
The right source, verbatim. |
val sites: List<CodeBlendSite> |
Every numeric site the alignment paired, moving or not, in left-source order. |
val divergences: List<CodeBlendDivergence> |
Every token pair the alignment could not reconcile. Empty means the two structures agree. |
val movingSites: List<CodeBlendSite> |
The sites that actually differ between the two sources. |
val blendable: Boolean |
True when the two sources share one structure, so that a blend between them exists at all. |
val runtimeCapable: Boolean |
True when every moving site can be rewritten as a mix, so CodeBlendMode.RUNTIME is available. |
val effectiveMode: CodeBlendMode |
The mode this plan can actually deliver, which is CodeBlendOptions.mode unless something stopped it: a structural divergence drops any mode to CodeBlendMode.STEP, and a literal-only site drops CodeBlendMode.RUNTIME to CodeBlendMode.STATIC. |
val downgraded: Boolean |
True when effectiveMode is not what was asked for, and so when the breakdown is worth reading. |
val downgradeReason: String |
Why effectiveMode is not CodeBlendOptions.mode, or "" when it is. |
fun bake(t: Double): String |
The left source with every moving site's numbers interpolated at t — a different program for every t, and the only mode that can reach a literal-only site. |
val runtimeSource: String by lazy |
The one program a CodeBlendMode.RUNTIME blend runs: every moving site replaced by a mix of the two endpoints against the blend parameter. |
val runtimeId: Int by lazy |
The registry slot a Kotlin runtime blend reads its parameter from, allocated on first use. |
fun drive(t: Double) |
Publish t as this plan's runtime blend parameter, for a CodeBlendLanguage.KOTLIN blend to read on its next invocation. A no-op for StackSL, whose parameter travels as a push constant. |
fun describe(): String |
The plan as text: the counts, the downgrade if any, then a line per moving site and divergence. |
override fun toHTML(): String |
The breakdown as HTML: the header counts, a row per moving site with its midpoint, and a row per divergence. |
override fun toString(): String |
One line: what was asked for, what will happen, and how many sites are in play. |
keyframeSweep.kt
Overnight exploration of a keyframe space: a directory of saved keys in, an archive of renders out.
A KeyframeScope gives you one look at a time — capture it, edit it, put it back. A directory of
saved keys is something else: a space, whose interesting points are mostly the ones nobody has
visited. KeyframeSweep visits them while you sleep. Each pass draws a few keys from the pool,
blends them at random weights (sometimes past either end of the hull), applies the blend to the
live scope, makes a directory, and hands that directory to a suspend callback of yours which
runs to completion before the next pass starts. What the callback writes there is its business —
a still, a clip, a mesh, a CSV. What the sweep writes there is the provenance: the blended key as
ordinary key JSON, and a manifest saying which keys went in at which weights.
The point of the archive is what happens to it afterwards. A night of this is a few thousand
directories to categorise, cluster, score or hand to a model, and the sweep's job is to make that
second pass possible: every render is self-describing, index.jsonl is one line per render for a
reader that does not want to walk the tree, and renders/ is itself a valid keyframe pool — so
tonight's output is a legitimate input for tomorrow night.
The blend is a fold, not new maths
KeyframeScope.interpolate already knows how every lane type blends — numbers and vectors lerp,
quaternions slerp, CameraPose sclerp, code blends or steps. So an N-way blend here is a
pairwise fold over it rather than a second type table that would immediately drift from the
first: with weights sorted descending and running sums S, folding t = w / S at each step
yields exactly the affine weighted mean for every linear lane, and inherits the rest for free.
Sorting descending is what makes it safe rather than merely neat. Weights sum to 1, so the running
sums start at the largest weight and end at 1, rising while the terms are positive and falling
once they go negative — the minimum is therefore min(w_max, 1), which is always positive. No
division ever approaches zero, including under extrapolation, where some weights are negative.
Three things the fold alone gets wrong, and this file handles — see DiscreteBlend,
MissingSlots and SweepConfig.preserveNumericKind. All three are recorded per render rather
than assumed, so a later pass over the archive can see which of them applied to any given render.
What a night survives
Nothing here trusts the run to finish. A render's manifest is written before its callback and
rewritten after, atomically, so a crashed pass is identifiable as one; SweepConfig.resume picks
up where the process died and re-derives each render's randomness from seed + index, so skipping
completed work cannot change what the unfinished work would have drawn. A throwing callback is
one failed render, not a lost night — until SweepConfig.maxConsecutiveFailures of them in a row
says the night is broken. And SweepConfig.minFreeBytes stops the sweep before the disk does.
src/jvmMain/kotlin/afield/animation/keyframes/keyframeSweep.kt — read the source
Used in: Sweeping saved keyframes overnight
Top-level functions
| Declaration | Summary |
|---|---|
fun dirichlet(rng: Random, n: Int, concentration: Double = 1.0): DoubleArray |
n weights summing to 1, drawn from a symmetric Dirichlet with concentration concentration. |
fun readSweepArchive(out: Path): List<SweepRenderManifest> |
Every render in an archive, newest last — the offline pass's way in. |
fun sweepArchiveSummary(out: Path): String |
A readout of what a night actually did: how many renders, how they ended, how wide the blends ranged, and which pool keys got used. |
fun SweepReport.rendersPerHour(): Double |
The one number an overnight run is judged on: completed renders per hour. |
Top-level values
| Declaration | Summary |
|---|---|
const val sweepManifestFormat: Int |
Bumped when the archive layout changes in a way a reader of an older archive would notice. |
DiscreteBlend
What a blend does with the lanes that have no midpoint: strings, StackSLString programs and SourceBackedBlock Kotlin.
Constants: ANCHOR, ROULETTE_PER_LANE, AS_INTERPOLATED.
MissingSlots
What a blend does when its participants disagree about which lanes exist.
Constants: FILL_FROM_LIVE, FILL_FROM_ANCHOR, INTERSECT, REQUIRE_ALL.
SweepConfig
Every knob a sweep has, as one serializable value — so the run's own configuration is recorded in sweep.json beside its output and a night is reproducible from its archive rather than from whatever the box said at the time.
| Declaration | Summary |
|---|---|
val seed: Long |
Root of the run's randomness. Every render draws from seed and its own index, nothing else. |
val count: Int |
How many renders to attempt. Left unbounded, maxDurationMs or the disk floor ends the run. |
val pattern: String |
Which files under the pool directory are keys. See everyKeyGlob, and keyFilesMatching. |
val minArity: Int |
Fewest keys in one blend. 1 is meaningful and worth allowing: a blend of one key is that key replayed exactly, which salts the archive with unmixed references to compare the blends against. |
val maxArity: Int |
Most keys in one blend, clamped to the pool size. |
val concentration: Double |
The Dirichlet concentration the weights are drawn from, and the single most consequential number here. |
val extrapolationProbability: Double |
How often a blend is pushed outside the hull of its participants. |
val minExtrapolationGain: Double |
Least push, measured in hull-crossings from the anchor. |
val maxExtrapolationGain: Double |
Most push. Past about 3.0 most looks fall apart, which is occasionally the point. |
val discrete: DiscreteBlend |
See DiscreteBlend. |
val missingSlots: MissingSlots |
See MissingSlots. |
val preserveNumericKind: Boolean |
Round each blended number back to the numeric type the anchor held it in. |
val applyToScope: Boolean |
Write the blend to the live scope before calling the callback. Off makes the sweep a pure generator. |
val restoreScopeAtEnd: Boolean |
Put the scope back the way it was found when the run ends, however it ends. |
val maxDurationMs: Long |
Wall-clock budget in milliseconds; 0 for none. The "stop at 7am" knob. |
val minFreeBytes: Long |
Stop while this many bytes are still free on the archive's filesystem. Eight gigabytes by default: an overnight sweep of GPU stills is a rate of disk consumption, and the thing that goes wrong is not the archive being large but the machine having nothing left at 4am. |
val maxConsecutiveFailures: Int |
Consecutive failures that mean the night is broken rather than the render unlucky. |
val resume: Boolean |
Skip renders a previous process already finished. See KeyframeSweep.run. |
val keepFailedRenders: Boolean |
Keep the directory of a failed render, with its manifest and partial output, for forensics. |
val draws: List<SweepDraw> |
Renders to make exactly as written, instead of drawing them at random. |
val renders: Int |
How many renders the run attempts: count, or the number of draws when there are any. |
val arity: IntRange |
minArity..maxArity, for reading. |
SweepDraw
One render chosen rather than drawn: which keys take part, their convex weights, and optionally a pole to push away from and how far.
| Declaration | Summary |
|---|---|
val keys: List<String> |
Keys by their path relative to the pool, as SweepParent.key names them. |
val weights: List<Double> |
One non-negative weight per key, normalised before use. |
val pole: String? |
The key to push away from, one of keys; null for a convex blend. |
val gain: Double? |
Hull-crossings to push; at least 1.0, and given exactly when pole is. |
val anchor: String? |
Whose discrete lanes and numeric kinds the blend takes; null for the largest final weight. |
SweepParent
One key's part in one blend.
| Declaration | Summary |
|---|---|
val key: String |
Path relative to the pool root, / separated — the pool's own identity for the key. |
val weight: Double |
Its weight in the affine blend. Sums to 1 across a render's parents; negative under extrapolation. |
val schemaVersion: Int |
schemaVersion as the file carried it. More than one across a render's parents means schema drift. |
val filled: List<String> |
Lanes this key did not have, filled per MissingSlots. Empty is the healthy case. |
SweepArtifact
What a render's callback left behind, as recorded by SweepRender.artifact.
| Declaration | Summary |
|---|---|
val kind: String |
Free-form: still, clip, frames, whatever the callback calls it. |
val file: String? |
Path relative to the render directory, or null when the artifact could not be produced. |
val bytes: Long |
|
val note: String? |
Why it is missing, when file is null. Never a fabricated file; always a stated gap. |
SweepRenderManifest
One render, completely described — the file an offline pass reads to decide what it is looking at.
| Declaration | Summary |
|---|---|
val format: Int |
|
val index: Int |
|
val id: String |
|
val seed: Long |
The per-render seed, derived from SweepConfig.seed and index. Redraws this render exactly. |
val status: String |
running, ok, failed or cancelled. |
val startedAt: String |
|
val finishedAt: String? |
|
val durationMs: Long? |
|
val parents: List<SweepParent> |
The keys that went in, and at what weight. |
val anchor: String |
The parent that won the discrete lanes, and supplies numeric types under preserveNumericKind — drawn by roulette over the final weights, so it is usually the key that dominates the picture. |
val extrapolationPole: String? |
The parent this blend was pushed away from, for an extrapolated render — a different key from anchor, which is where the discrete lanes and numeric types come from. One key in both roles would push a blend away from the look it then inherits. |
val extrapolationGain: Double? |
How far outside the hull this blend sits, in hull-crossings from the anchor — 1.0 is exactly on the far face, 1.5 is half as far again beyond it. Null for an ordinary convex blend. |
val discreteLanes: Map<String, String> |
Discrete lane name to the parent it came from — the provenance the fold could not be trusted with. |
val droppedLanes: List<String> |
Lanes present in the participants that the blend could not produce, usually a type disagreement. |
val laneCount: Int |
How many lanes the blended key carries. |
val artifacts: List<SweepArtifact> |
|
val error: String? |
The callback's failure, first line only. Null unless status is failed. |
SweepRunManifest
The run as a whole: what it was asked to do, against what it found. Written to sweep.json.
SweepReport
How a run ended, returned by KeyframeSweep.run and also the tail of sweep.json.
| Declaration | Summary |
|---|---|
val out: Path |
|
val completed: Int |
Renders whose callback returned normally. |
val failed: Int |
Renders whose callback threw. Their directories survive unless configured otherwise. |
val skipped: Int |
Renders a previous process had already finished. |
val ineligible: Int |
Pool keys excluded by MissingSlots.REQUIRE_ALL. |
val poolSize: Int |
|
val elapsedMs: Long |
|
val stoppedBecause: String |
count, duration, disk, failures or cancelled — why the loop stopped. Worth printing in the morning: disk and failures mean the night was cut short, and the render count alone does not say which. |
override fun toString() |
The one line worth printing in the morning: the counts, and why the run stopped. |
SweepRender
The handle a sweep's callback is given: where to write, what it is rendering, and how it got here.
| Declaration | Summary |
|---|---|
val index: Int |
Zero-based position in the run. Stable across a SweepConfig.resume. |
val id: String |
000123-4f2a9c, the directory's name: ordered by index, and distinct per seed. |
val dir: Path |
The render's own directory, created before the callback is called. |
val key: KeyframeScope.Key |
The blended key. Also written to key.json in dir before the callback runs. |
val seed: Long |
This render's seed — for a callback that wants its own reproducible randomness. |
var manifest: SweepRenderManifest |
The provenance record, as it will be written. artifact is the one part a callback adds. |
fun file(name: String): Path |
A path inside dir. Creates nothing; the callback writes it. |
fun artifact(kind: String, file: Path? = null, note: String? = null) |
Record something the callback produced, so the offline pass can find it without guessing file names. |
KeyframeSweep
A random walk of a keyframe space, with a render at every stop.
| Declaration | Summary |
|---|---|
val scope: KeyframeScope |
The live scope. Supplies the target set, the blend maths, and (by default) the fill values. |
val pool: Path |
Directory of saved keys to draw from. Read once, non-destructively. |
val out: Path |
Archive root. Created if absent; added to if it already holds a run. |
val config: SweepConfig |
|
val onRender: (SweepRenderManifest) -> Unit |
Called after every terminal render, completed or failed, with the manifest as written. The place to put progress reporting — a display table, a line in a log, a push to a dashboard. |
val rendersDir: Path |
out/renders, one directory per render. Itself a valid keyframe pool, via the key.json in each. |
val indexFile: Path |
out/index.jsonl: one compact SweepRenderManifest per line, for a reader that will not walk the tree. |
val runManifestFile: Path |
out/sweep.json: the run's own configuration and pool census. |
suspend fun run(body: suspend (SweepRender) -> Unit): SweepReport |
Run the sweep, calling body once per render and waiting for it each time. |
fun reindex(): Int |
Rebuild index.jsonl from the manifest.json files under renders/, and answer how many lines it wrote. |
keyframeSweepCapture.kt
The GPU half of a keyframe sweep: turning one blended look into the still and the short clip that a night's archive is actually made of.
KeyframeSweep is renderer-free: it blends, it records, it calls your callback. But
most callbacks do the same two things, and they are the two things with traps in them: an
offscreen readback that can come back empty, and a frame sequence that has to be muxed by a tool
which may not be installed. So they live here once rather than in every sweep.
Both helpers follow the pattern the RectSL and StackSL gallery harnesses established: one
renderer and one RenderToTexture for the whole run, reused across thousands of renders, with
only the uniforms changing. Building a target per render is what makes an overnight sweep run out
of device memory at 3am.
Neither helper will invent a file. A readback that comes back empty and a missing ffmpeg are
both recorded as an artifact with a null path and a stated reason, and the frame PNGs are left on
disk so the clip can be muxed later. An archive that says what it is missing can be analysed; one
with a plausible-looking wrong file in it cannot.
src/jvmMain/kotlin/afield/animation/keyframes/keyframeSweepCapture.kt — read the source
Used in: Sweeping saved keyframes overnight
Top-level functions
keyframeTidy.kt
src/jvmMain/kotlin/afield/animation/keyframes/keyframeTidy.kt — read the source
keyframeTypeLibrary.kt
The key types every keyframe editor offers beyond its five built-in ones: the easing family,
splines that look past the segment to the keys either side, shapes, and shaped noise. Each is a
KeySegment lambda like the ones a box defines with interpolant(...), evaluated in Kotlin and
drawn by the editor from samples of the segment.
src/jvmMain/kotlin/afield/animation/keyframes/keyframeTypeLibrary.kt — read the source
KeyTypeEntry
One entry in the library: the name a key stores, where it sits in the menu, and its shape.
| Declaration | Summary |
|---|---|
val name: String |
The name a key stores to select this type, as ease.sine.in or shape.smoothstep. |
val label: String |
How the type reads in the menu, as sine in or smoothstep. |
val group: String |
The menu section the type sits in: Ease, Spline, Shape or Noise. |
val family: String? |
For the easing table: the family, which is a row. Null for every other group. |
val variant: String? |
For the easing table: the direction, which is a column. One of in, out and in-out. |
val shape: KeySegment.() -> Double |
The shape itself: a KeySegment lambda returning the curve's value inside the segment. |
KeyTypeLibrary
| Declaration | Summary |
|---|---|
val entries: List<KeyTypeEntry> by lazy |
Every type in the library, built once: the easings first, then splines, shapes and noise. |
fun named(name: String): KeyTypeEntry? |
The entry a key of type name uses, or null if no library type is called that. |
keyframer.kt
Snapshot, interpolate and persist the live state of a running sketch. A KeyframeScope is a set
of named targets — typed Kotlin locals, keyframe-aware controlGroup controls, contributors such
as a score object, uniform Structure bindings, the uniform structures of a ModulePointer /
ComputeModulePointer / MeshModulePointer, a stackAuto shader-source slot via
KeyframeScope.stackAuto, or a Camera pose via KeyframeScope.camera. capture() reads them
all into a KeyframeScope.Key; invoking that Key writes them all back.
KeyframeScope.interpolate blends two keys per value type: numbers and vectors lerp, quaternions
slerp, dual quaternions and CameraPose sclerp, and strings / StackSLString /
SourceBackedBlock step to the right-hand value at t >= 1. remap can re-time an individual
key's t. The two code types can do better than stepping when the two sources differ only in
numbers — see KeyframeScope.codeBlend and keyframeCodeBlend.kt.
Persistence goes through the KValue serial union, so only the types KValue.fromAny recognises
survive a save — numbers (a Long exactly), Booleans, vec2/3/4, quaternion, dual quaternion,
camera pose, string, StackSL source and source-backed block; anything else is left out and reported
on error,keyframer. Every saveKey also writes a
numbered copy into a <file>.versions directory with a manifest, which
KeyframeScope.keyVersions lists and KeyframeScope.loadLatestKey follows. KeyframeScope.missingKey supplies defaults for
keys absent from an older file, optionally per schema version.
Keys render themselves as HTML for box output (KeyDiff, diffHtml), and
copyUpdateCodeToClipboard emits pasteable Kotlin — that one goes through the AWT clipboard, and on a headless JVM it logs and just returns the string.
src/jvmMain/kotlin/afield/animation/keyframes/keyframer.kt — read the source
Used in: Keyframes: capturing, persisting and replaying live state, Values over time: interpolants & keyframes
Top-level functions
Top-level values
| Declaration | Summary |
|---|---|
val KeyframeScope.Key.h: String |
A key's KeyframeScope.Key.toHTML table, for display() and anywhere else HTML is what is wanted. |
NumKind
Which Kotlin numeric type a saved number came from, so that KValue.toAny can hand back a Byte/Short/Int/Long/Float/Double rather than always a Double — a uniform bound to an Int has to come back as an Int.
Constants: BYTE, SHORT, INT, LONG, FLOAT, DOUBLE.
KValue
The closed set of value types a KeyframeScope.Key can persist: numbers, vec2/3/4, quaternion, dual quaternion, camera pose, string, StackSL source and source-backed block. A key in memory holds plain Kotlin values; this is the serial union they are converted to on the way to disk and back.
| Declaration | Summary |
|---|---|
data class Num(val kind: NumKind, val value: Double) : KValue |
Any Kotlin number, kept as a Double with the NumKind needed to restore its original type. |
data class LongValue(val value: Long) : KValue |
A Long, exactly: a Double would lose it past 2^53. |
data class Bool(val value: Boolean) : KValue |
A Boolean. |
data class Vec2(val x: Float, val y: Float) : KValue |
A Vector2f, component by component. |
data class Vec3(val x: Float, val y: Float, val z: Float) : KValue |
A Vector3f, component by component. |
data class Vec4(val x: Float, val y: Float, val z: Float, val w: Float) : KValue |
A Vector4f, component by component — the usual RGBA or homogeneous-point uniform. |
data class Quat(val x: Float, val y: Float, val z: Float, val w: Float) : KValue |
A Quaternionf as raw x, y, z, w; stored as-is, and not re-normalised on load. |
data class DualQuat(val realX: Float, val realY: Float, val realZ: Float, val realW: Float, val dualX: Float, val dualY: Float, val dualZ: Float, val dualW: Float) : KValue |
A DualQuaternionf — a rigid transform (rotation plus translation) as its real and dual quaternion halves. Unlike Quat this is normalised on the way back in, so a slightly drifted saved value is repaired on load. |
data class CameraPoseValue(val realX: Float, val realY: Float, val realZ: Float, val realW: Float, val dualX: Float, val dualY: Float, val dualZ: Float, val dualW: Float, val focusDistance: Float) : KValue |
A CameraPose: the camera's rigid transform as a dual quaternion, plus the focus distance that says how far in front of it the thing it is looking at sits. Also normalised on load. |
data class StackSL(val value: String) : KValue |
StackSL source text, tagged as code rather than as a string so that it reloads as a StackSLString and interpolation steps it discretely instead of trying to blend it. |
data class Str(val value: String) : KValue |
A plain String. |
data class SourceBlock(val boxId: String, val source: String) : KValue |
A SourceBackedBlock reduced to the box it came from and its Kotlin source — the live lambda is not persisted. On load the block re-compiles its source in that box on first invocation, so a key restored into a document where the box is gone throws when the value is finally called. |
fun toAny(): Any |
Rebuild the live Kotlin value this entry stands for — the load half of the round trip. A DualQuat and a CameraPoseValue are re-normalised on the way out, because a rigid transform that has drifted is a broken transform; a plain Quat is not (see its own note — it may be standing in for something that is not a rotation). Numbers regain their NumKind type, and a SourceBlock becomes a SourceBackedBlock that has not compiled anything yet. |
KeyDTO
data class KeyDTO(val schemaVersion: Int = 0, val values: Map<String, KValue>)
What a saved key looks like on disk: a schema version and a name → KValue map, pretty-printed as JSON with type as the discriminator.
| Declaration | Summary |
|---|---|
val schemaVersion: Int |
The owning scope's schemaVersion at the moment of saving. Nothing validates it on load: a mismatch is not an error, it only selects which KeyframeScope.missingKey defaults apply. |
val values: Map<String, KValue> |
The key's entries by name. Values that KValue.fromAny could not represent are already gone. |
KeyVersionInfo
One row of a key file's version history, as recorded in the <file>.versions/manifest.json that every saveKey appends to. KeyframeScope.keyVersions hands these back, sorted by index.
| Declaration | Summary |
|---|---|
val index: Int |
Monotonic version number, starting at 0 and simply "highest so far plus one" — a save counter, not a schema version and not related to KeyDTO.schemaVersion. |
val timestamp: String |
When the version was written, as an ISO-8601 Instant.now() string in UTC — or, for a row rebuilt from the file alone, that file's last-modified time. |
val note: String? |
Whatever note was passed to saveKey, for telling two snapshots apart later. A row rebuilt from the file alone says "(recovered from disk; the manifest row for this version was lost)" here instead, since the real note lived only in the manifest that went missing. |
val file: String |
File name inside the .versions directory, "%06d.json" of index. |
StackSLString
data class StackSLString(val value: String)
A string that a KeyframeScope should treat as code rather than as text: StackSL source.
| Declaration | Summary |
|---|---|
override fun toString(): String |
The StackSL source itself, so a StackSLString prints and concatenates like the string it wraps. |
KeyframeContributor
fun interface KeyframeContributor
A domain object that contributes its state to an existing KeyframeScope.
| Declaration | Summary |
|---|---|
fun registerKeyframes(scope: KeyframeScope, prefix: String) |
KeyframeScope
class KeyframeScope(val schemaVersion: Int = 0)
A named set of live things worth snapshotting — uniform Structure bindings, a module's live uniforms, a stack.auto shader slot, a Camera — plus the machinery to capture, blend, save and reload them. This is the object you keep around for the lifetime of a sketch.
| Declaration | Summary |
|---|---|
val targets |
The statically-bound targets, by key name, as registered by target(name, structure) — exposed so you can see and clear what a scope is watching. |
val remap |
Per-key re-timing for interpolate: remap["gain"] = { t -> t * t * (3 - 2 * t) } eases that one value on a smoothstep while everything else stays linear. |
var codeBlend: CodeBlendOptions |
What interpolate should do with a slot whose two sides are code — a StackSLString or a SourceBackedBlock. |
fun codeBlendPlans(a: Key, b: Key): Map<String, CodeBlendPlan> |
The blend plan interpolate would use for every slot of a and b that holds code on both sides — the debugging view of a code blend, and what the keyframe browser shows. |
inner class Key(val values: MutableMap<String, Any?>) : ToHTML |
One snapshot: names to values, and the ability to put them all back. Get one from capture, interpolate, loadKey, or by constructing it from a map you wrote by hand (as the docs examples do). |
fun target(name: String, structure: Structure) |
Register a key name against a uniform structure held for good — the simplest target, for a Structure you built once and will keep. |
fun target(name: String, structure: () -> Structure?) |
Register a key name against a structure that is looked up again on every read and write — the right choice for anything a shader reload can replace underneath you. |
fun target(name: String, get: () -> Any?, set: (Any?) -> Unit) |
Register an arbitrary live value through explicit get and set functions. This is the escape hatch for score objects, simulation state and other things that are not shader Structures: capture calls get, and invoking a Key hands its stored value to set. |
fun floatTarget(name: String, get: () -> Float, set: (Float) -> Unit) |
Register a float local without exposing an Any? setter at the call site. |
fun doubleTarget(name: String, get: () -> Double, set: (Double) -> Unit) |
Register a double local without exposing an Any? setter at the call site. |
fun booleanTarget(name: String, get: () -> Boolean, set: (Boolean) -> Unit) |
Register a Boolean local as an interpolable float lane. Applying crosses to true at 0.5; this is useful when a fade eventually switches a CPU-side path as well as a shader. |
fun intTarget(name: String, get: () -> Int, range: IntRange = Int.MIN_VALUE..Int.MAX_VALUE, set: (Int) -> Unit) |
Register an integer local as a float lane, rounding and clamping when a key is applied. |
fun vector2Target(name: String, get: () -> Vector2fc, set: (Vector2f) -> Unit) |
Register a vec2 local, copying at both boundaries so a captured key cannot alias it. |
fun vector3Target(name: String, get: () -> Vector3fc, set: (Vector3f) -> Unit) |
Register a vec3 local, copying at both boundaries so a captured key cannot alias it. |
fun vector4Target(name: String, get: () -> Vector4fc, set: (Vector4f) -> Unit) |
Register a vec4 local, copying at both boundaries so a captured key cannot alias it. |
fun stringTarget(name: String, get: () -> String, set: (String) -> Unit) |
Register a string local. Strings retain the keyframer's endpoint-step interpolation. |
fun include(contributor: KeyframeContributor, prefix: String = ""): KeyframeScope |
Add every lane supplied by contributor to this scope and return the scope for chaining. |
fun target(module: ModulePointer, name: String) |
Register a key against one of a graphics module's live uniform structures, resolved through the pointer each time so it survives a shader reload. |
fun target(module: ComputeModulePointer, name: String) |
target for a compute module's live uniform structures. |
fun target(module: MeshModulePointer, name: String) |
target for a mesh-shader module's live uniform structures. |
fun stackAuto(module: ModulePointer, name: String, keyName: String = name) |
Register a key against a stack.auto.<name> shader-source slot, so that the StackSL a module runs is part of the snapshot: capture reads it as a StackSLString and applying a key recompiles the slot. This is what lets a keyframe morph a patch's code as well as its numbers. |
fun stackAuto(module: ComputeModulePointer, name: String, keyName: String = name) |
stackAuto for a compute module; the source is reloaded on the compute path. |
fun stackAuto(module: MeshModulePointer, name: String, keyName: String = name) |
stackAuto for a mesh-shader module; the source is reloaded on the mesh path. |
fun stackAutoNumbers(module: ModulePointer, slot: String, keyPrefix: String = slot) |
Register each number typed in the StackSL of a stack.auto.<slot> slot as a key of its own — slot.k0, slot.k1, … numbered as the box's numbers card numbers them — so a key snapshots them and applying or blending keys turns them on the GPU with no recompile (StackAutoNumbers). |
fun stackAutoNumbers(module: ComputeModulePointer, slot: String, keyPrefix: String = slot) |
stackAutoNumbers for a compute module. |
fun stackAutoNumbers(module: MeshModulePointer, slot: String, keyPrefix: String = slot) |
stackAutoNumbers for a mesh-shader module. |
fun camera(camera: Camera, keyName: String = "camera") |
Register a Camera as a keyable target, so a viewpoint can be snapshotted and morphed like any other value. Capture stores the camera's CameraPose — its rigid transform plus focus distance — and applying a key calls applyPose, so interpolate gives you a screw-motion (sclerp) camera move rather than a separate position and target lerp. |
fun findByName(name : String) : ((Function0<*>) -> Unit)? |
The raw "bind a lambda to this target" function for a registered key name, or null if there is no such key — an escape hatch for driving a target continuously instead of through a Key. |
fun missingKey(name: String, value: Any?) |
Supply the value a load should use for a key that the file does not contain — how a scope that has grown a new parameter still loads snapshots taken before it existed. |
fun missingKey(name: String, value: () -> Any?) |
missingKey with the default computed at load time rather than fixed now — use it when the sensible fallback is "whatever the target currently holds". |
fun missingKey(schemaVersion: Int, name: String, value: Any?) |
A default that applies only when the loaded file's KeyDTO.schemaVersion equals schemaVersion — for the case where "the old files meant something different" rather than "the old files are missing this". |
fun missingKey(schemaVersion: Int, name: String, value: () -> Any?) |
The schema-specific missingKey with a lambda default. Evaluated at most once per load, and only when the file actually lacks the name — a default whose name the file already carries is never called at all. |
fun onChange(listener: (name: String) -> Unit) |
Call listener with the key name whenever a control declared with by keys.slider(...) (and the other KeyframeControl kinds) changes from outside the code: a hand on the control, or a recalled or morphed key. Assigning the local in code does not call it, and neither does a rerun: the local already holds the panel's last value from its declaration, so the body that follows sees it. |
fun capture() |
Read every registered target right now into a new Key — the snapshot call. |
fun interpolate(a: Key, b: Key, t: Double): Key |
Blend two snapshots into a new Key at position t, per value type: numbers and vectors lerp, quaternions slerp, dual quaternions and camera poses sclerp, and strings step to b's value once t >= 1. |
fun encodeKeyJson(key: Key): String |
key as the same pretty JSON saveKey writes, without writing anything. |
fun saveKey(path: Path, key: Key, note: String? = null) |
Write key to path as pretty JSON, creating parent directories — and, every time, also append a numbered copy to a <path>.versions directory with a manifest row carrying note and a timestamp. Saving is therefore always additive history, never just an overwrite; nothing prunes it, so a hundred saves leave a hundred files. |
fun loadKey(path: Path, version: Int? = null): Key |
Read a saved key back as a Key, filling in any missingKey defaults for names the file lacks. Reading only: nothing is applied to the sketch until you invoke the returned key. |
fun loadLatestKey(path: Path): Key |
The highest-numbered entry of path's version history, falling back to path itself when there is no history at all. |
fun loadKeyVersion(path: Path, version: Int): Key |
loadKey(path, version) by another name, for when the version is not optional at the call site. |
fun keyVersions(path: Path): List<KeyVersionInfo> |
The save history of path, oldest first — the list to show a person choosing which snapshot to go back to. |
fun savedKeyNames(dir: Path): List<String> |
The names of the keys saved directly in dir — each file saveKey wrote there, sorted, without the .versions histories beside them. Empty when dir does not exist yet. This is the list a preset strip shows; KeyframeCollection.load reads the same files with their contents. |
fun interpolateAlong(keys: List<Key>, t: Double): Key |
interpolate along a row of keys: they sit evenly along t in 0..1, in order, and between two neighbours the blend is theirs. t is clamped here, since past either end there is no neighbour to extrapolate towards. One key comes back as itself; none is an error. |
KeyDiffKind
How one name differs between two keys, always read left-to-right: ADDED means the right key has it and the left does not, REMOVED the other way round, CHANGED that both have it with unequal values. There is no "same" kind — unchanged names are left out of a diff entirely.
Constants: ADDED, REMOVED, CHANGED.
KeyDiffEntry
data class KeyDiffEntry(val name: String, val kind: KeyDiffKind, val left: Any?, val right: Any?)
One line of a KeyDiff: what the name was on each side, and which way it moved.
KeyDiff
The difference between two KeyframeScope.Keys: only the names that actually moved, sorted by name, ready to render. Produced by Key.diff.
| Declaration | Summary |
|---|---|
val leftName: String |
Heading for the left-hand column — cosmetic, defaulting to "left" at the call site. |
val rightName: String |
Heading for the right-hand column. |
val entries: List<KeyDiffEntry> |
The changes, name-sorted; empty means the two keys agree. |
override fun toString(): String |
The diff as text, one line per change prefixed + (added), - (removed) or ~ (changed), and the single line "No key differences." when there are none. |
override fun toHTML(): String |
The diff as a small dark-mode HTML table — one row per change, marker coloured green for added, red for removed and amber for changed, with a header counting the changes. What a box shows when a KeyDiff is the value on screen. |
keyframes.kt
Keyframe curves edited by hand in a box's output panel and read back by code as a number at any time.
keyframes(name = "x") opens a curve editor in the box's output area and returns a
KeyframeEvaluator; the curve the user draws is saved on the box under that name, so it
survives a re-run and a reload. Seed a new curve with times, (time, value) pairs, or
Keyframes carrying their segment types:
val x = keyframes(name = "x", defaults = listOf(Vec2(0.0, 0.0), Vec2(5.0, 1.0), Vec2(10.0, 0.0)))
val now = x.evaluateValueAt(2.5)
The evaluator also edits the curve from code (add, remove, edit, simplify, smooth),
moves the editor's playhead with setTime, and draws reference overlays with setOverlay.
Tests: KeyframeEvaluatorTest, KeyframerTest.
src/jvmMain/kotlin/afield/animation/keyframes/keyframes.kt — read the source
Used in: The keyframe editor: drawing a value over time, Agent transcript gallery
Top-level functions
| Declaration | Summary |
|---|---|
fun loadKeyframeData(box: Box, name: String): String? |
Load persisted keyframe data for a given box and keyframer name. Returns the JSON string of keyframes, or null if none persisted. |
fun saveKeyframeData(box: Box, name: String, keyframesJson: String) |
Save keyframe data for a given box and keyframer name. Merges with any existing keyframeData on the box. |
fun installKeyframeHandler() |
|
fun Box.keyframes(channel: String? = null, name: String, defaultTimes: List<Number>? = null): KeyframeEvaluator |
Creates (or reconnects to) a keyframe curve editor displayed in the given output channel. |
fun Box.keyframes(channel: String? = null, name: String, defaults: List<Vec2>): KeyframeEvaluator |
Creates (or reconnects to) a keyframe curve editor, with default keyframes specified as Vec2 pairs of (time, value). |
fun Box.keyframes(channel: String? = null, name: String, keys: List<Keyframe>): KeyframeEvaluator |
Creates (or reconnects to) a keyframe curve editor, seeded with keys — keys with their types, so a curve can start with a held step or an eased segment rather than straight lines. |
Top-level values
| Declaration | Summary |
|---|---|
val knownKeyframeTargets |
KeyframeEvaluator
class KeyframeEvaluator(jsonString: String)
KeyframeEvaluator - Evaluates animation curves defined by keyframes
| Declaration | Summary |
|---|---|
lateinit var session: WebSocketSession |
|
val hasLiveSession: Boolean |
True when the iframe WebSocket is connected and still open. |
var associatedBoxId: String? |
The box ID + name uid this evaluator is associated with |
var associatedName: String? |
|
fun isEmpty(): Boolean |
|
fun set(jsonString: String) |
Replaces every keyframe with those in jsonString, a JSON list in the same form the constructor takes. They are sorted by time; fields this class does not know are ignored. |
fun setTime(t: Double) |
Moves the editor's playhead to t: an orange line, and a dot where it crosses the curve. |
fun send() |
Sends the current keys to the editor, with this box's custom key types and their segments. |
fun evaluateValueAt(time: Double): Double |
Evaluates the animation curve value at a given time |
fun getTimeRange(): Pair<Double, Double> |
Returns the time range over which the keyframes are defined |
fun getKeyframes(): List<Keyframe> |
Returns a defensive copy of the current keyframe list (sorted by time). |
fun getKeyframeCount(): Int |
Number of keyframes currently defined. |
fun getKeyframeAt(index: Int): Keyframe |
Returns the keyframe at the given index (0-based, sorted by time). |
fun getKeyframeTimes(): List<Double> |
Returns times of all keyframes (sorted ascending). |
fun getKeyframeValues(): List<Double> |
Returns values of all keyframes (in time order). |
fun getKeyframeTypes(): List<String> |
Returns the interpolation types of all keyframes (in time order). |
fun getKeyframePoints(): List<Vec2> |
Returns keyframes as a list of Vec2(time, value). |
var onChange: ((List<Keyframe>) -> Fn?)? |
Callback invoked whenever the keyframe data changes (e.g. user edits in the UI). Receives the updated list of Keyframes. If it returns an Fn, that function replaces the editor's overlay (the one setOverlay draws with its default id). Return null to leave the overlay unchanged, or clear it explicitly with clearOverlay. |
fun evaluateDerivativeAt(time: Double, h: Double = 0.0): Double |
The rate of change of the curve at time, in value per unit of time: exact, from the shape of the segment time falls in. Zero outside the keys and along a hold. At a key it is the slope of the segment leaving it (arriving, at the last key). |
fun setKeys(keys: List<Keyframe>) |
Replaces the keys with keys, as an edit would: the editor shows them and they are saved with the box. A bezier key without handles gets the ones the editor derives from its neighbours. onChange is not called; it is for edits made in the editor. |
fun add(time: Double, value: Double, type: String = "linear"): Keyframe |
Adds a key at time with value and type; see setKeys. Returns the new key. |
fun remove(key: Keyframe) |
Removes key (compared by value); see setKeys. |
fun removeAt(index: Int) |
Removes the key at index, in time order; see setKeys. |
fun clear() |
Removes every key; see setKeys. |
fun edit(block: MutableList<Keyframe>.() -> Unit) |
Edits the keys as a list, then saves and shows the result; see setKeys. |
fun simplify(tolerance: Double) |
Removes every key it can while the curve stays within tolerance of where it is now, in the curve's own units, so a dense take becomes a few smooth keys. Keys that stay become bezierContinuous, with handles that follow the curve's slope; locked, hold and custom keys stay as they are. The editor's Simplify slider does the same. See setKeys. |
fun smooth(width: Double) |
Smooths the keys' values with a Gaussian width wide in time (its standard deviation), so the jitter in a recorded take goes and its shape stays. The first and last keys and locked keys keep their values, and no key moves in time. The editor's Smooth slider does the same. See setKeys. |
fun setOverlay(samples: List<Vec2>, color: String = "#ff9800", strokeWidth: Double = 1.5, dashArray: String? = "6 3", id: String = "default") |
Send an overlay curve to the keyframe editor UI. The curve is drawn on top of the main keyframe graph as a non-interactive visual reference. |
fun setOverlay(fn: Fn, sampleCount: Int = 200, color: String = "#ff9800", strokeWidth: Double = 1.5, dashArray: String? = "6 3", id: String = "default") |
Draw an overlay curve from a Fn, sampled over the keyframe time range. |
fun clearOverlay(id: String = "default") |
Remove a specific overlay curve from the editor. |
fun clearAllOverlays() |
Remove all overlay curves from the editor. |
Keyframe
| Declaration | Summary |
|---|---|
val time: Double |
|
val value: Double |
|
val type: String |
|
val handles: Handles? |
|
val locked: Boolean |
Handles
data class Handles(val `in`: Handle, val out: Handle)
| Declaration | Summary |
|---|---|
val `in`: Handle |
|
val out: Handle |
Handle
data class Handle(val time: Double = 0.0, val value: Double = 0.0)
| Declaration | Summary |
|---|---|
val time: Double |
|
val value: Double |
ArdySmoke.kt
src/jvmMain/kotlin/afield/animation/mocap/ArdySmoke.kt — read the source
ArdySmoke
Actual model run, never substituted with a fixture. Cached prompt embeddings can run without the encoder service.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
CLoSDSmoke.kt
src/jvmMain/kotlin/afield/animation/mocap/CLoSDSmoke.kt — read the source
CLoSDSmoke
Real Kotlin process/skeleton/reference/executed/intervention check, explicitly skipped without its opt-in home.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
MotionInteropSmoke.kt
src/jvmMain/kotlin/afield/animation/mocap/MotionInteropSmoke.kt — read the source
MotionInteropSmoke
Real Kotlin -> Python -> Kotlin binary round trip; no network inference is claimed.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
PatchyWeb.kt
Drive the browser build of the Patchy editor from box code: Box.patchyWeb shows Patchy in an
output channel and returns a PatchyWebSession.
The session's suspend methods (PatchyWebSession.state, PatchyWebSession.script,
PatchyWebSession.newDocument, PatchyWebSession.addLayer, PatchyWebSession.fillRect,
PatchyWebSession.undo, PatchyWebSession.redo) send tool calls to the Patchy page and wait
for its replies, so call them inside go { ... }.
src/jvmMain/kotlin/afield/app/server/PatchyWeb.kt — read the source
Used in: Patchy
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.patchyWeb(channel: String = "patchy", width: Int = 1200, height: Int = 800, documentFile: File? = null, autosave: Boolean = documentFile != null): PatchyWebSession |
Show the Field build of Patchy, with its visible canvas controlled by this box's Kotlin. |
PatchyWebSession
class PatchyWebSession internal constructor() : AutoCloseable
One live browser Patchy session. Call suspend methods inside go { ... }.
| Declaration | Summary |
|---|---|
var connected |
|
suspend fun state(): JsonObject |
Read live documents, layer IDs and the edit token. |
suspend fun script(code: String): JsonElement? |
Run Patchy's documented JavaScript API in the visible editor. |
suspend fun newDocument(width: Int, height: Int): String |
Create a document in the visible editor and return its stable ID. |
suspend fun addLayer(documentId: String, name: String): String |
Add a paint layer to an open document. |
suspend fun fillRect(documentId: String, layerId: String, x: Int, y: Int, width: Int, height: Int, color: String) |
Paint a solid rectangle through Patchy's layer API. |
suspend fun undo(documentId: String): Boolean |
Undo the last edit in a live document. |
suspend fun redo(documentId: String): Boolean |
Redo the last undone edit in a live document. |
override fun close() |
fline_graph_connectivity.kt
Bridge from drawn geometry to the graph algorithms: turns one or more FLines into a welded
point graph.
flineGraph, or the toFLineGraph extensions on FLine and Iterable<FLine>, adaptively
subdivides cubics down to cubicFlatness, samples every node, then welds samples closer than
epsilon using a uniform grid plus union-find (epsilon = 0 welds only bit-identical
positions). A welded vertex is the mean of the samples that reached it, and
FLineGraph.vertexSources records which line index and node instruction each of those samples
came from. Edges join consecutive samples, close() joins back to the subpath start,
self-edges are dropped and duplicates collapse to the shortest.
The result carries a PointGraph of GraphEdges — the same types as graph_connectivity.kt —
so the search, spanning-tree and analysis functions there apply directly to a drawing.
src/jvmMain/kotlin/afield/data/graph/fline_graph_connectivity.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun flineGraph(lines: Iterable<FLine>, epsilon: Number = 1e-4f, cubicFlatness: Number = epsilon, maxCubicSubdivisions: Int = 12): FLineGraph |
Linearize lines into a graph by adaptively flattening curves and welding vertices whose distance is <= epsilon. |
fun Iterable<FLine>.toFLineGraph(epsilon: Number = 1e-4f, cubicFlatness: Number = epsilon, maxCubicSubdivisions: Int = 12): FLineGraph |
Convenience extension for flineGraph. |
fun FLine.toFLineGraph(epsilon: Number = 1e-4f, cubicFlatness: Number = epsilon, maxCubicSubdivisions: Int = 12): FLineGraph |
Convenience extension for a single FLine. |
FLineGraphSource
data class FLineGraphSource(val lineIndex: Int, val instruction: Float, val position: Vec3)
A sampled source point that contributed to a welded FLineGraph vertex.
| Declaration | Summary |
|---|---|
val lineIndex: Int |
|
val instruction: Float |
|
val position: Vec3 |
FLineGraph
Graph view of one or more FLines.
| Declaration | Summary |
|---|---|
val vertices: List<Vec3> |
|
val graph: PointGraph |
|
val vertexSources: List<List<FLineGraphSource>> |
|
val edges: List<GraphEdge> |
|
val pointCount: Int |
graph_connectivity.kt
Self-contained graph / computational-geometry library over 2D and 3D point sets
(Vec2/Vec3). Every algorithm has a vec2* and (where meaningful) a vec3* entry point
returning a PointGraph; see each function's own KDoc for its definition and complexity.
Proximity / neighbourhood families: KNN (vec2KnnGraph), Orthant (vec2OrthantGraph),
Relative Neighborhood (vec2RelativeNeighborhoodGraph), Gabriel (vec2GabrielGraph),
Beta-Skeleton (vec2BetaSkeleton), Nearest-Neighbor (vec2NearestNeighborGraph),
Minimum Spanning Tree (vec2MinimumSpanningTree), Maximum-Diameter/radius
(vec2MaximumDiameterGraph), Maximum-Cut (vec2MaximumCutGraph) and Complete
(vec2CompleteGraph) graphs.
JTS-backed (2D only): full Delaunay triangulation (vec2DelaunayGraph, vec2DelaunayTriangles),
Urquhart (vec2UrquhartGraph, a real Urquhart graph built by removing the longest edge of each
Delaunay triangle; there is no 3D one, because Field has no 3D Delaunay of a point set), Voronoi diagram/dual (vec2VoronoiDiagram, vec2VoronoiDualGraph) and convex
hull (vec2ConvexHull, vec2ConvexHullGraph, vec2HullProjectionGraph).
Traversal / analysis: BFS/DFS/A* path search, GraphEdgeVisitState greedy walks and
graph-diameter path decomposition (see the search/path functions below), plus FLine rendering
integration in fline_graph_connectivity.kt.
Custom distance metrics: pass GraphConnectivityOptions.customDistanceMetric. It is honoured by
the distance-driven algorithms (KNN, MST, RNG, Gabriel, Beta-Skeleton, Delaunay/Urquhart edge
weights, etc.). This includes vec2OrthantGraph/vec3OrthantGraph, whose nearest-neighbour
ranking within each orthant honours customDistanceMetric when supplied (Euclidean otherwise).
Smoke tests: afield.examples.stdlib.GraphConnectivitySmokeTest (its runTests() drives 35
checks, covering path search, edge-visit-state and FLine rendering as well as the core graphs).
Live rendered examples: public/docs/graph_connectivity_examples.md.
src/jvmMain/kotlin/afield/data/graph/graph_connectivity.kt — read the source
Used in: Neighbours and graphs on a point cloud, Points into structure — Voronoi, Apollonius, contours and clusters
Top-level functions
GraphEdge
data class GraphEdge(val i: Int, val j: Int, val weight: Double)
Represents an edge between two points in a graph.
| Declaration | Summary |
|---|---|
override fun equals(other: Any?): Boolean |
|
override fun hashCode(): Int |
PointGraph
data class PointGraph(val edges: List<GraphEdge>, val pointCount: Int)
Represents a graph computed from a set of points.
| Declaration | Summary |
|---|---|
fun neighbors(pointIndex: Int): List<Int> |
Get all neighbors of a point. |
fun neighbors1(pointIndex: Int): List<Int> |
|
fun degree(pointIndex: Int): Int |
Get the degree (number of neighbors) of a point. |
GraphConnectivityOptions
Options for graph connectivity algorithms.
| Declaration | Summary |
|---|---|
val allowDuplicatePoints: Boolean |
|
val customDistanceMetric: ((Int, Int, List<*>) -> Double)? |
GraphPath
data class GraphPath(val nodes: List<Int>, val edges: List<GraphEdge>, val totalWeight: Double)
Represents a path through a PointGraph.
| Declaration | Summary |
|---|---|
val nodes: List<Int> |
|
val edges: List<GraphEdge> |
|
val totalWeight: Double |
|
val start: Int |
|
val end: Int |
GraphEdgeVisitState
Tracks visited and unvisited edges for path cover generation.
| Declaration | Summary |
|---|---|
val pointCount: Int |
|
val visitedEdges: Set<GraphEdge> |
|
val unvisitedEdges: Set<GraphEdge> |
|
val allEdges: Set<GraphEdge> |
|
fun isVisited(edge: GraphEdge): Boolean |
|
fun isUnvisited(edge: GraphEdge): Boolean |
|
fun markVisited(edges: Iterable<GraphEdge>): GraphEdgeVisitState |
|
fun markVisited(edge: GraphEdge): GraphEdgeVisitState |
|
fun markUnvisited(edges: Iterable<GraphEdge>): GraphEdgeVisitState |
|
fun markUnvisited(edge: GraphEdge): GraphEdgeVisitState |
|
fun visitedGraph(): PointGraph |
|
fun unvisitedGraph(): PointGraph |
GraphPathSetResult
data class GraphPathSetResult(val paths: List<GraphPath>, val edgeState: GraphEdgeVisitState)
Result containing a list of paths and the updated edge-visit state.
| Declaration | Summary |
|---|---|
val paths: List<GraphPath> |
|
val edgeState: GraphEdgeVisitState |
VoronoiCell
data class VoronoiCell(val siteIndex: Int, val vertices: List<Vec2>)
Represents a Voronoi cell in the Voronoi diagram.
| Declaration | Summary |
|---|---|
val siteIndex: Int |
|
val vertices: List<Vec2> |
VoronoiDiagram
data class VoronoiDiagram(val cells: List<VoronoiCell>, val pointCount: Int)
Represents a Voronoi diagram computed from a set of points.
| Declaration | Summary |
|---|---|
val cells: List<VoronoiCell> |
|
val pointCount: Int |
|
fun cellForSite(siteIndex: Int): VoronoiCell? |
Get the Voronoi cell for a specific site point. |
ConvexHull2D
data class ConvexHull2D(val hullPoints: List<Vec2>, val hullIndices: List<Int>)
Represents a convex hull of a point set.
| Declaration | Summary |
|---|---|
val hullPoints: List<Vec2> |
|
val hullIndices: List<Int> |
PrincipalCurves.kt
Elastic principal graphs: smooth curves, trees and forests fitted through a 2-D or 3-D point cloud, as a port of ElPiGraph.
PrincipalCurves is the entry point, and what it hands back is not a finished answer but an
optimisation state. A PrincipalForest can be fitted again, grown with more nodes under one of
the graph grammars Growth offers, have nodes moved and pinned, and be read back at any point
along the way: as node positions and edges, as FLines, or as a GeometryNetwork whose channels
carry the per-node measurements. So pinning a node, replacing the points under a fitted graph, or
adding nodes to it continues from where the fit had reached rather than starting over.
ElasticParameters holds the energy the fit minimises, ElasticEnergy its value split by term,
PointAssignments which node and edge each point ended up on, and NodeStatistics the spread
of the points around each node, which is what the ribbon widths are drawn from.
The solver is the optional native library field_principal_curves
(PrincipalCurvesLibrary), so a forest holds native memory, is closed by the box's next run,
and PrincipalCurves.isAvailable reports whether the library loaded at all. The worked account
is public/docs/principal_curves.md.
src/jvmMain/kotlin/afield/geometry/curves/principal/PrincipalCurves.kt — read the source
Used in: Principal curves: smooth curves through point clouds, Chaining the geometry extensions
ElasticParameters
The weights of the elastic energy and the solver's controls.
Growth
enum class Growth(internal val flags: Int)
Which graph-grammar operations growth may use.
Constants: CURVES, TREES, REFINE.
ElasticEnergy
The elastic energy and its parts; trimmedFraction is the share of point weight beyond the trimming radius.
PointAssignments
Where each point went: its nearest node, the nearest edge at that node (-1 if trimmed or if the node has no edges), the parameter t along that edge from its first node, and its distance to the graph.
NodeStatistics
Per-node statistics of the points projected onto the graph. Each point is shared between the two ends of its edge by its parameter.
| Declaration | Summary |
|---|---|
val dimension: Int |
|
val support: DoubleArray |
|
val nEff: DoubleArray |
|
val residual: DoubleArray |
|
val spread: DoubleArray |
|
val standardError: DoubleArray |
|
val tangent: DoubleArray |
|
val basis: DoubleArray |
|
val width: DoubleArray |
PrincipalForest
An elastic principal graph fitted to a point cloud, kept as an optimisation state: refine it, grow it, move or pin nodes, split edges, and read it back at any point.
| Declaration | Summary |
|---|---|
val dimension: Int |
|
val pointCount: Int |
|
fun setPoints(points: DoubleArray, weights: DoubleArray? = null) |
Replaces the points (and weights), keeping the graph, so that refine follows data that moves: a particle system, a stipple that changes, a camera feed's bright pixels. |
var parameters: ElasticParameters |
|
fun initComponents(components: Int = 1, nodesEach: Int = 3) |
Replaces the graph with components separate chains of nodesEach nodes. The points are clustered by single linkage (k-means centres joined by their minimum spanning tree, cut at its longest edges), and each chain starts along the longest path through its cluster, so curved strands are followed rather than cut across. |
fun initTree(k: Int) |
Replaces the graph with the minimum spanning tree of k k-means centres. |
fun initGraph(nodePositions: DoubleArray, edges: IntArray) |
Replaces the graph with one given as node positions (dimension per node) and edges (pairs of node indices). |
fun initFromFLines(lines: List<FLine>, nodesPerCurve: Int = 8) |
Replaces the graph with lines: each subpath becomes a chain of nodesPerCurve nodes evenly spaced along it, closed subpaths become loops, and separate subpaths stay separate. Draw a few strokes, then fit or grow them onto the points. |
fun fit(iterations: Int = 0): Int |
Fits node positions to the points without changing the topology. Returns the iterations used. |
fun refine(iterations: Int = 5): Int |
fit with a small iteration budget, for refining a little at a time (from a frame loop, say). |
fun grow(nodes: Int, growth: Growth = Growth.CURVES): Int |
Grows the graph by grammar operations until it has nodes nodes, choosing at each step the operation whose fitted configuration has the lowest energy. Returns the steps applied. |
fun moveNode(node: Int, x: Double, y: Double, z: Double = 0.0, fixed: Boolean = true) |
Moves node to (x, y, z) and pins it there (unless fixed is false). Call fit to settle the rest. |
fun fixNode(node: Int, fixed: Boolean = true) |
Pins or frees node where it is. |
fun splitEdge(edge: Int, t: Double = 0.5): Int |
Inserts a node on edge at parameter t (0..1 from its first node). Returns the new node's index. |
val nodePositions: DoubleArray |
Node positions, dimension doubles per node, in node-index order. |
val edges: IntArray |
Edges as pairs of node indices. |
val nodeCount: Int |
|
fun energy(): ElasticEnergy |
|
fun assignments(): PointAssignments |
|
fun nodeStatistics(): NodeStatistics |
|
val network: GeometryNetwork |
The graph as a GeometryNetwork. Nodes are the graph's branch points and endpoints (node index as id); curves are the chains between them, so a curve's interior vertices are graph nodes of degree two. A component with no endpoints is one closed curve. Per-vertex samples: width, spread, support, nEff, residual, standardError, graphNode, and in 3D covariance (uu, uv, vv). Node channels: degree, fixed. |
fun toFLines(smooth: Boolean = true, color: Vec4 = Vec4(0.9f, 0.3f, 0.1f, 1f), thickness: Double = 2.0): List<FLine> |
The fitted curves as FLines (splines through the nodes with smooth), each carrying geometryNetwork. |
fun widthFLines(scale: Double = 2.0, color: Vec4 = Vec4(0.95f, 0.55f, 0.2f, 0.3f)): List<FLine> |
2D only: a filled ribbon around each curve, scale standard deviations of the transverse spread to each side, so its width shows how spread out the points are along the curve. |
fun assignmentFLines(every: Int = 1, color: Vec4 = Vec4(0.3f, 0.3f, 0.3f, 0.35f)): FLine |
Short lines from every everyth point to where it projects onto the graph, coloured by edge. |
fun pointsFLine(pointSize: Double = 2.0, color: Vec4 = Vec4(0.2f, 0.4f, 0.8f, 0.6f)): FLine |
The input points as one FLine of dots, for display. |
val isClosed: Boolean |
|
override fun close() |
|
override fun toString() |
PrincipalCurves
Principal curves, trees and forests: smooth curves fitted through weighted 2D or 3D point clouds, as elastic principal graphs (a port of ElPiGraph). Needs the optional native library field_principal_curves (see PrincipalCurvesLibrary).
PrincipalCurvesNative.kt
Finding field_principal_curves on this machine, and calling into it once found.
PrincipalCurvesLibrary is the half that answers without loading anything: the library's file
name on this OS, the paths it is looked for in, whether one of them exists, and the Gradle task
and per-OS shell commands that build it. NativeGeometry registers it as a
NativeGeometryCapability, so the welcome page's Geometry extensions panel can report on the
library whether or not it is installed. The path is resolved afresh on every read, so a library
built during a live session is picked up by the next box run.
The other half, internal to the package, binds the C ABI declared in
native/field-principal-curves/src/field_principal_curves.h with java.lang.foreign: one lazy
downcall handle per fpc_ entry point, the solver's parameter struct laid out by hand with its
padding, and a 512-byte buffer that each call which can fail carries down and reads a message out
of when it reports failure. Reading the fit back out (the node and edge arrays, the energies, the
per-point assignments, the per-node statistics) takes no error buffer. The first symbol lookup
checks fpc_abi_version against the version this binding expects and refuses a library that
disagrees, naming the task that rebuilds it.
PrincipalCurves is the API over it, and its only caller.
src/jvmMain/kotlin/afield/geometry/curves/principal/PrincipalCurvesNative.kt — read the source
PrincipalCurvesLibrary
object PrincipalCurvesLibrary : NativeGeometryCapability
Where field_principal_curves is, and whether it is there. Field runs without it; describe says how to build it.
| Declaration | Summary |
|---|---|
override val name |
|
override val title |
|
override val docsPage |
|
override val buildTask |
|
override val install: Map<String, List<String>> |
|
val fileName: String |
|
val searchedPaths: List<Path> |
Every path resolvedPath tries, in order. |
val resolvedPath: Path? |
Not cached: a library built during a live session is picked up by the next box run. |
override val isAvailable: Boolean |
|
val HOW_TO_BUILD: String |
|
override fun describe(): String |
Elements.kt
Propositions from Euclid's Elements, constructed with compass and straightedge in the manner of Oliver Byrne's coloured edition (1847).
Each proposition takes geometry in and returns a Construction: its Construction.result (the
triangle, segment, line or circle the proposition asks for) and the numbered Construction.steps
that build it, each holding the coloured circles, lines and lettered points it draws. A
proposition that cites an earlier one carries that one's steps inside its own, prefixed with the
citation, just as the text says "by I.1".
val c = Elements.prop1(Segment2(point2(100, 200), point2(220, 200)))
val triangle = c.result
lines(c.toFLines(width = 520.0, height = 400.0), w = 520.0, h = 400.0) // the plate, fitted to the output
toFLines(upTo = 2) draws only the first two steps, for stepping through a proof, and
toFLines(style = ByrneStyle.WORKING) shows the working of the propositions this one cites.
Every step is built only from the intersections of circles and straight lines in Euclid.kt;
nothing is placed by a formula the proposition does not use. Where Euclid says "take any point",
the choice here is fixed, so the same input always draws the same figure; Elements.solutions
enumerates the others.
src/jvmMain/kotlin/afield/geometry/euclid/Elements.kt — read the source
Used in: Euclid's Elements
Top-level functions
| Declaration | Summary |
|---|---|
fun <T> construction(proposition: String, enunciation: String, build: Proof.() -> T): Construction<T> |
Writes a proposition of your own, the way every proposition in Elements is written: build draws it step by step with Proof.step and returns its result. |
fun <T> List<Construction<T>>.nearest(p: Point2): Construction<T> |
The solution whose result lies nearest p. |
fun <T> List<Construction<T>>.farthest(p: Point2): Construction<T> |
The solution whose result lies farthest from p. |
Byrne
enum class Byrne(val r: Double, val g: Double, val b: Double)
Byrne's palette, plus black.
Constants: RED, YELLOW, BLUE, BLACK.
Role
What a drawn element is for. A proposition says only this; ByrneStyle decides how each role looks, so every diagram is styled in one place.
Constants: GIVEN, CONSTRUCTION, RESULT, AREA.
Figure
One drawn element of a construction: a shape in color playing role, perhaps lettered with label.
Pen
data class Pen(val width: Double, val dash: Double = 0.0, val alpha: Double = 1.0)
How one role is stroked: width in multiples of ByrneStyle.thickness, dashed when dash is above zero (dash and gap both that many thicknesses long), and faded by alpha.
ByrneStyle
The look of a Construction diagram.
Step
data class Step(val text: String, val figures: List<Figure>, val cited: String? = null)
One sentence of a proof and what it draws; cited names the earlier proposition it comes from, if any.
Triangle2
data class Triangle2(val a: Point2, val b: Point2, val c: Point2)
A triangle by its three corners.
Quadrilateral2
data class Quadrilateral2(val a: Point2, val b: Point2, val c: Point2, val d: Point2)
A four-sided figure by its corners in order.
Construction
The result of a proposition and the steps that construct it.
| Declaration | Summary |
|---|---|
val location: Point2 |
Where the result is: the mean of its points (a line's foot, a circle's centre). |
fun toFLines(upTo: Int = steps.size, style: ByrneStyle = ByrneStyle.PLATE, extent: Double = 400.0, width: Double? = null, height: Double? = null): List<FLine> |
The diagram as coloured FLines through step upTo (all of them by default), drawn in style. Lines and rays are drawn extent long. |
fun markdown(cited: Boolean = false) |
The enunciation and the numbered proof, as Markdown; cited steps are included when cited is true. |
override fun toString() |
Proof
class Proof internal constructor()
What a proposition is written with inside construction: numbered steps, the figures they draw, the choices it makes (pick, pickSide), the claim it checks (check) and the propositions it cites (by), plus the small measures proofs keep needing.
| Declaration | Summary |
|---|---|
fun Shape2.drawn(color: Byrne, role: Role = Role.GIVEN) |
A figure: this shape in color, playing role. |
fun Point2.named(label: String) |
A lettered point. |
fun meet(a: Shape2, b: Shape2, what: String = "construction"): List<Point2> |
Where a and b cross; a branch where they do not is dropped (or, called plainly, fails naming what). |
fun Point2.dist(o: Point2) |
The distance from this point to o. |
fun eq(x: Double, y: Double) |
Equal to a relative tolerance, for check. |
fun angleAt(v: Point2, a: Point2, b: Point2) |
The angle at v between the arms through a and b, in [0, π]. |
fun centroid(vararg ps: Point2) |
The mean of ps. |
fun isSquare(q: Quadrilateral2) |
True when q's four sides are equal and its diagonal is one side times √2, for check. |
fun isRegular(p: Polygon2, n: Int) |
True when p has n equal, non-zero sides and every interior angle of an n-gon, for check. |
fun far(points: List<Point2>, from: Point2) |
Of points, the one farthest from from: where a line through from leaves a circle. |
fun offset(o: Point2, p: Point2, q: Point2) |
How far round the circle about o, counter-clockwise from p, the point q is, in [0, 2π). |
fun aroundCentre(o: Point2, at: Double, points: List<Point2>) |
The polygon through points in order round o, starting at angle at. |
fun step(text: String, vararg figures: Figure) |
One numbered sentence of the proof, drawing figures. |
fun <T> pick(options: List<T>, preferred: T? = null): T |
One of options. A plain call takes preferred (or the first); inside Elements.solutions every option is tried in turn, preferred first. |
fun pickSide(line: Line2, points: List<Point2>, side: Int) |
Of points, the one on the side (+1 left, -1 right) of line by default; the others when searching. |
fun check(ok: Boolean, what: String) |
Drops this branch unless ok: the proposition's claim, checked on the geometry it made. |
fun <T> by(cited: () -> Construction<T>): T |
Cites an earlier proposition: its steps, unlettered, under its name. Returns its result. Inside Elements.solutions, each distinct valid outcome of the cited proposition is one choice here. |
Elements
| Declaration | Summary |
|---|---|
fun <T> solutions(limit: Int = 64, proposition: Elements.() -> Construction<T>): List<Construction<T>> |
Every distinct way proposition can come out, the usual one first. |
fun prop1(ab: Segment2, side: Int = 1): Construction<Triangle2> |
I.1. On a given finite straight line to construct an equilateral triangle. The apex is on the left of ab going from a to b when side is +1, on the right when -1. |
fun prop2(a: Point2, bc: Segment2): Construction<Segment2> |
I.2. To place at a given point a a straight line equal to a given straight line bc. |
fun prop3(greater: Segment2, less: Segment2): Construction<Segment2> |
I.3. From the greater of two given straight lines to cut off a part equal to the less. |
fun prop9(vertex: Point2, b: Point2, c: Point2): Construction<Ray2> |
I.9. To bisect a given rectilineal angle: the angle at vertex between the arms through b and c. |
fun prop10(ab: Segment2): Construction<Point2> |
I.10. To bisect a given finite straight line. |
fun prop11(ab: Segment2, c: Point2, side: Int = 1): Construction<Ray2> |
I.11. To draw a straight line at right angles to a given straight line ab from a given point c on it. The perpendicular goes to the left of a→b when side is +1. |
fun prop12(ab: Line2, c: Point2): Construction<Segment2> |
I.12. To draw a perpendicular to a given infinite straight line ab from a given point c not on it. |
fun prop23(along: Ray2, vertex: Point2, d: Point2, e: Point2, side: Int = 1): Construction<Ray2> |
I.23. On a given straight line along, at the point on it, to construct an angle equal to the angle at vertex between the arms through d and e. The new arm is on the left of along when side is +1. |
fun prop31(a: Point2, bc: Segment2): Construction<Line2> |
I.31. Through a given point a to draw a straight line parallel to a given straight line bc. |
fun prop46(ab: Segment2, side: Int = 1): Construction<Quadrilateral2> |
I.46. On a given straight line to describe a square, on the left of a→b when side is +1. |
fun book3prop1(circle: Circle2): Construction<Point2> |
III.1. To find the centre of a given circle (drawn, so its centre is not consulted). |
fun book4prop4(t: Triangle2): Construction<Circle2> |
IV.4. In a given triangle to inscribe a circle. |
fun book4prop5(t: Triangle2): Construction<Circle2> |
IV.5. About a given triangle to circumscribe a circle. |
fun book6prop13(ac: Segment2, b: Point2): Construction<Segment2> |
VI.13. To two given straight lines to find a mean proportional: ac is the two lines laid end to end, meeting at b. The result BD has BD² = AB · BC. |
fun prop47(t: Triangle2): Construction<Triple<Quadrilateral2, Quadrilateral2, Quadrilateral2>> |
I.47. In right-angled triangles the square on the side opposite the right angle equals the squares on the sides containing it. The right angle of t is at t.a. The result is the three squares: on BC, on BA and on AC. |
fun book2prop5(ab: Segment2, side: Int = 1): Construction<Polygon2> |
II.5. If a straight line ab be cut into equal and unequal segments, the rectangle on the unequal segments with the square on the line between the points of section equals the square on the half. The result is the gnomon, the L-shaped region equal to the rectangle. |
fun book2prop6(ab: Segment2, side: Int = 1): Construction<Polygon2> |
II.6. If a straight line ab be bisected and produced, the rectangle on the whole line with the added part, and the added part, with the square on the half, equals the square on the half with the added part. The result is the gnomon. |
fun book2prop11(ab: Segment2, side: Int = 1): Construction<Point2> |
II.11. To cut a given straight line ab so that the rectangle on the whole and one segment equals the square on the other: the golden section. The result is the point of section H, with AH² = AB·BH. |
fun book2prop14(rect: Quadrilateral2): Construction<Quadrilateral2> |
II.14. To construct a square equal to a given rectilineal figure. Euclid first makes a rectangle equal to the figure (I.45); this starts from that rectangle, rect, whose corners in order are B, E, D, C. The result is the square on EH. |
fun book3prop17(circle: Circle2, a: Point2): Construction<Segment2> |
III.17. From a given point a to draw a straight line touching a given circle. |
fun book4prop10(ab: Segment2): Construction<Triangle2> |
IV.10. To construct an isosceles triangle having each of the angles at the base double the remaining one: the golden triangle, apex ab.a, with sides AB = AD. |
fun book4prop11(circle: Circle2, at: Double = -PI / 2): Construction<Polygon2> |
IV.11. In a given circle to inscribe an equilateral and equiangular pentagon, with a corner at angle at from the centre. |
fun book4prop15(circle: Circle2, at: Double = -PI / 2): Construction<Polygon2> |
IV.15. In a given circle to inscribe an equilateral and equiangular hexagon, a corner at angle at. |
fun book4prop16(circle: Circle2, at: Double = -PI / 2): Construction<Polygon2> |
IV.16. In a given circle to inscribe a fifteen-angled figure, equilateral and equiangular. |
fun book6prop9(ab: Segment2, parts: Int = 3): Construction<Segment2> |
VI.9. From a given straight line ab to cut off a prescribed part, a parts-th. |
fun book6prop10(ab: Segment2, ac: Segment2, cuts: List<Point2>): Construction<List<Point2>> |
VI.10. To cut a given uncut straight line ab similarly to a given cut straight line ac (which starts at A too), cut at cuts. The result is the points of section on AB, in order. |
fun book6prop11(ab: Segment2, ac: Segment2): Construction<Segment2> |
VI.11. To two given straight lines ab and ac, set at an angle from A, to find a third proportional: CE, with AB : AC = AC : CE. |
fun book6prop12(first: Segment2, second: Segment2, third: Segment2, at: Point2 = Point2.ORIGIN): Construction<Segment2> |
VI.12. To three given straight lines first, second and third to find a fourth proportional HL, with first : second = third : HL. The working is laid out from at. |
fun book6prop30(ab: Segment2, side: Int = 1): Construction<Point2> |
VI.30. To cut a given finite straight line ab in extreme and mean ratio. Euclid applies to AB a rectangle equal to its square and exceeding by a square (VI.29); for a square that comes down to the circle drawn here. The result is the point of section E, with AB : AE = AE : EB. |
Euclid.kt
Plane geometry in the classical sense: points, lines, rays, segments, circles, arcs and polygons, with every pairwise intersection, closest point and distance, and the standard constructions (perpendicular bisectors, angle bisectors, circumcircles, incircles, tangents).
val c = Circle2(point2(0, 0), 50.0)
val s = Segment2(point2(-80, 10), point2(80, 30))
val hits = s.intersect(c).points // the two places the segment crosses the circle
val (onS, onC) = s.closestPoints(Circle2(point2(0, 200), 20.0))
lines(c.toFLine(), s.toFLine())
Everything is immutable and computed in Double. Vec2 converts in and out with
Vec2.toPoint2() / Point2.toVec2(). Every shape implements Shape2, so intersect,
closestPoints and distanceTo work for any pair, including a shape with itself.
Robustness: comparisons use a distance tolerance of Euclid.epsilon times the size of the inputs
(one plus the largest coordinate involved, and any radius), so tangencies, touching endpoints and
collinear or co-circular overlaps are reported as such rather than lost to rounding.
Overlaps come back as shapes in Intersection2.overlaps (a shared piece of a line or of a
circle), not as a single point. Degenerate inputs are treated as what they are: a zero-length
segment or zero-radius circle behaves as a point.
Arcs run from Arc2.start through a signed Arc2.sweep in radians: positive is
counter-clockwise in a y-up frame (clockwise on a y-down screen).
src/jvmMain/kotlin/afield/geometry/euclid/Euclid.kt — read the source
Used in: Euclid's Elements
Top-level functions
| Declaration | Summary |
|---|---|
fun point2(x: Number, y: Number) |
A Point2 from any numbers. |
fun Vec2.toPoint2() |
This vector as a Point2, in Double. |
Euclid
Tolerance settings shared by every Euclid computation.
| Declaration | Summary |
|---|---|
var epsilon |
Relative distance tolerance; scaled by the magnitude of the inputs of each computation. |
Point2
data class Point2(val x: Double, val y: Double) : Shape2
A point (or vector) in the plane.
| Declaration | Summary |
|---|---|
operator fun plus(o: Point2) |
|
operator fun minus(o: Point2) |
|
operator fun times(s: Double) |
|
operator fun div(s: Double) |
|
operator fun unaryMinus() |
|
infix fun dot(o: Point2) |
|
infix fun cross(o: Point2) |
The z component of the 3D cross product: positive when o is counter-clockwise of this. |
fun normalized(): Point2 |
Unit vector in this direction; the zero vector stays zero. |
fun rotated(radians: Double, about: Point2 = ORIGIN): Point2 |
This point turned radians counter-clockwise (in a y-up frame) about about. |
fun lerp(o: Point2, t: Double) |
The point t of the way from this to o. |
fun midpoint(o: Point2) |
|
fun toVec2() |
This point as a Vec2, in Float. |
override fun toFLine(extent: Double): FLine |
Shape2
Anything Euclid can intersect and measure against anything else: Point2, Line2, Ray2, Segment2, Circle2, Arc2 and Polygon2 (which is measured by its boundary).
| Declaration | Summary |
|---|---|
fun intersect(other: Shape2): Intersection2 |
Where this meets other: crossing and touching points, plus any shared stretch. |
fun intersects(other: Shape2): Boolean |
True when this and other share at least one point. |
fun closestPoint(p: Point2): Point2 |
The point of this shape nearest p. |
fun closestPoints(other: Shape2): Pair<Point2, Point2> |
A nearest pair of points, the first on this shape and the second on other. |
fun distanceTo(other: Shape2): Double |
The shortest distance between this and other; zero when they meet. |
fun toFLine(extent: Double = 1000.0): FLine |
An FLine drawing of this shape; unbounded lines and rays are drawn extent long each way. |
Line2
data class Line2(val point: Point2, val direction: Point2) : Shape2
The infinite line through point in direction (any nonzero length).
| Declaration | Summary |
|---|---|
fun at(t: Double) |
point plus t times direction, so t counts whole direction vectors rather than distance. |
fun signedDistance(p: Point2) |
Distance to p, positive on the left of direction (counter-clockwise side). |
fun side(p: Point2): Int |
+1 left of the line, -1 right, 0 on it (within tolerance). |
fun project(p: Point2) |
The foot of the perpendicular from p; same as closestPoint. |
fun reflect(p: Point2) |
p mirrored across this line. |
fun perpendicularThrough(p: Point2) |
|
fun parallelThrough(p: Point2) |
|
fun offset(distance: Double) |
The line moved distance to its left (negative moves it right). |
override fun toFLine(extent: Double): FLine |
Ray2
data class Ray2(val origin: Point2, val direction: Point2) : Shape2
The half-line from origin in direction.
| Declaration | Summary |
|---|---|
fun at(t: Double) |
origin plus t times direction; the ray itself is t of zero and above. |
override fun toFLine(extent: Double): FLine |
Segment2
data class Segment2(val a: Point2, val b: Point2) : Shape2
The finite segment from a to b.
| Declaration | Summary |
|---|---|
fun at(t: Double) |
The point t of the way from a to b; the segment itself is t in 0..1. |
fun reversed() |
|
override fun toFLine(extent: Double): FLine |
Circle2
data class Circle2(val center: Point2, val radius: Double) : Shape2
The circle of radius about center.
| Declaration | Summary |
|---|---|
fun at(angle: Double) |
|
operator fun contains(p: Point2) |
True when p is inside or on the circle. |
fun tangentPointsFrom(p: Point2): List<Point2> |
The points where the two tangent lines from p touch this circle (one if p is on it). |
fun tangentLinesFrom(p: Point2): List<Line2> |
The tangent lines from p; the single tangent at p when it lies on the circle. |
fun tangentAt(angle: Double) |
The tangent line at angle. |
fun commonTangents(other: Circle2): List<Segment2> |
Every line tangent to both circles (up to four: two outer, two inner), each as the segment from its touching point on this circle to its touching point on other. Where the circles touch, the shared tangent there comes back as a zero-length segment. |
override fun toFLine(extent: Double): FLine |
Arc2
The part of the circle about center of radius from angle start through signed sweep (radians, positive counter-clockwise). A sweep of 2π or more is the whole circle.
| Declaration | Summary |
|---|---|
fun at(t: Double) |
|
fun reversed() |
|
fun containsAngle(angle: Double) |
True when the direction angle (from center) falls within the arc. |
override fun toFLine(extent: Double): FLine |
Polygon2
data class Polygon2(val points: List<Point2>) : Shape2
The closed polygon through points in order. Intersections and distances are with its boundary, its edges; Polygon2.contains asks about its inside.
| Declaration | Summary |
|---|---|
operator fun contains(p: Point2): Boolean |
True when p is inside the polygon or on its boundary (even-odd rule). |
override fun toFLine(extent: Double): FLine |
Intersection2
The result of intersecting two shapes: isolated crossing or touching points, and any stretch the shapes share in overlaps (a Segment2, Ray2, Line2, Arc2 or Circle2).
GeometryBuilder.kt
A recipe language for triangle meshes: emit triangles through a transform stack with a current colour and a current material, and get back a scene of named, shaded parts.
GeometryBuilder is the language and GeometryScene is what a recipe builds: GeometryParts,
each an unindexed TriMesh with its own GeometryMaterial and a normal per triangle, plus the
GeometryView that says how the scene asks to be shown. The usual way to run a recipe is
afield.graphics.three.geometry, which draws it in a studio-lit three view in a box output,
or afield.graphics.three.animateGeometry, which runs it again every frame with the recipe's
t moving. geometryScene builds the same scene with no viewer, for code that wants the mesh
rather than the picture.
From there, GeometryScene.toTriMesh joins the parts into one mesh for the rest of
afield.geometry.mesh, writeGlb writes a still as glTF, and writeGlbAnimation records a
recipe played over time, which needs every frame to emit the same parts with the same vertex
counts.
src/jvmMain/kotlin/afield/geometry/mesh/GeometryBuilder.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun geometryScene(t: Double = 0.0, frame: Int = 0, dt: Double = 1.0 / 60, block: GeometryBuilder.() -> Unit): GeometryScene |
Build a GeometryScene from a recipe, with no viewer. afield.graphics.three.geometry is the same recipe drawn in a box output. |
GeometryBuilder
class GeometryBuilder(val t: Double = 0.0, val frame: Int = 0, val dt: Double = 1.0 / 60)
A stateful, immediate-mode triangle language: describe a model as a recipe of triangles, emitted through a transform stack with a current colour and a current material, and get back a GeometryScene of named, coloured, materialled TriMesh parts.
| Declaration | Summary |
|---|---|
val t: Double |
Seconds since an animation started; 0 for a still. See afield.graphics.three.animateGeometry. |
val frame: Int |
Frames since an animation started. |
val dt: Double |
Seconds between frames. |
val TAU: Double |
A full turn, 2π. |
var triangleCount: Int |
Triangles emitted so far. |
fun view(background: String? = null, ground: Boolean? = null, grid: Boolean? = null, groundY: Double? = null) |
How the scene is shown: the background, and whether the studio ground and its grid are drawn. Arguments left out keep their current value. |
fun mesh(name: String) |
Name the part that follows. Triangles already emitted keep the name they had. |
fun mat(color: String? = null, roughness: Double? = null, metalness: Double? = null, emissive: String? = null, emissiveIntensity: Double? = null, opacity: Double? = null, side: GeometrySide? = null, wireframe: Boolean? = null, flat: Boolean? = null) |
Change the material for triangles that follow. Arguments left out keep their current value. |
fun mat(color: Vec4, roughness: Double? = null, metalness: Double? = null, emissive: Vec4? = null, emissiveIntensity: Double? = null, opacity: Double? = null, side: GeometrySide? = null, wireframe: Boolean? = null, flat: Boolean? = null) |
mat with the base colour as a Vec4 (an afield.graphics.color.hsl, say). |
fun color(c: Vec4) |
The colour for vertices that follow, unless a vertex carries its own. |
fun color(css: String) |
color from a CSS hex string, "#rgb" or "#rrggbb". |
fun push() |
Save the name, material, colour and transform; pop restores them. |
fun pop() |
Restore what the matching push saved. |
fun identity() |
Reset the transform. |
fun translate(x: Double, y: Double, z: Double) |
Move geometry that follows by (x, y, z), in the current frame. |
fun translate(p: Vec3) |
Move geometry that follows by p. |
fun rotateX(angle: Double) |
Turn geometry that follows about x, by angle radians. |
fun rotateY(angle: Double) |
Turn geometry that follows about y, by angle radians. |
fun rotateZ(angle: Double) |
Turn geometry that follows about z, by angle radians. |
fun scale(x: Double, y: Double = x, z: Double = x) |
Scale geometry that follows, per axis. |
fun vertex(x: Double, y: Double, z: Double, color: Vec4? = null) |
A point with an optional colour of its own, for tri, quad and surface. Emits nothing. |
fun vertex(x: Double, y: Double, z: Double, color: String) |
vertex with a CSS hex colour. |
fun vertex(p: Vec3, color: Vec4? = null) |
vertex from a Vec3. |
fun mix(a: Vec3, b: Vec3, t: Double): Vec3 |
The point a fraction t of the way from a to b. |
fun polar(radius: Double, angle: Double, y: Double = 0.0) |
A point radius from the y axis at angle radians round it, at height y. |
fun spherical(radius: Double, theta: Double, phi: Double) |
A point on a sphere: theta round the y axis, phi down from the pole. |
fun profile(r: Double, y: Double, color: Vec4? = null) |
A point on a revolve profile: r from the axis at height y. |
fun profile(r: Double, y: Double, color: String) |
profile with a CSS hex colour. |
fun section(pos: Vec3, radius: Double, color: Vec4? = null) |
One ring of a tube: its centre, its radius and an optional colour. |
fun tri(a: GeometryVertex, b: GeometryVertex, c: GeometryVertex, flip: Boolean = false) |
One triangle. Counter-clockwise seen from the front; flip reverses it. |
fun tri(a: Vec3, b: Vec3, c: Vec3, flip: Boolean = false) |
One triangle in the current colour. |
fun quad(a: GeometryVertex, b: GeometryVertex, c: GeometryVertex, d: GeometryVertex) |
Two triangles, (a, b, c) and (a, c, d): corners in order round the quad. |
fun quad(a: Vec3, b: Vec3, c: Vec3, d: Vec3) |
quad in the current colour. |
fun surface(cols: Int, rows: Int, sampler: (u: Double, v: Double) -> GeometryVertex) |
A grid of cols × rows points, sampled at u and v from 0 to 1, joined into quads. The sampler returns a vertex, usually with a colour. |
fun revolve(profile: List<GeometryProfilePoint>, segments: Int = 32) |
A lathe: the profile points (radius and height) turned round the y axis in segments steps. |
fun tube(segments: Int, sides: Int, closed: Boolean = true, sampler: (t: Double) -> GeometryTubeSection) |
A tube: rings of sides corners swept along a curve sampled at segments values of t in 0..1. The sampler returns a section. closed joins the last ring back to the first, for a loop such as a knot; an open tube is a branch or a stem. |
fun box(center: Vec3, size: Vec3) |
An axis-aligned box of size centred on center, in the current colour and transform. |
fun box(center: Vec3, size: Double) |
A cube of side size centred on center. |
fun seed(value: Int) |
Restart rand from value, so the same seed gives the same model. |
fun rand(): Double |
A random number in 0..1. |
fun rand(max: Double): Double |
A random number in 0..max. |
fun rand(min: Double, max: Double): Double |
A random number in min..max. |
fun randInt(min: Int, max: Int): Int |
A random whole number from min to max, both included. |
fun noise(x: Double, y: Double): Double |
Smooth 2D Perlin noise, roughly in -1..1; the same everywhere for the same x, y. |
fun build(): GeometryScene |
The scene so far. |
GeometrySide
Which faces of a GeometryBuilder part draw.
Constants: FRONT, BACK, DOUBLE.
GeometryMaterial
A part's material: what GeometryBuilder.mat set. color and emissive are sRGB, as written; linearColor and linearEmissive are what a renderer shades with.
| Declaration | Summary |
|---|---|
val color: FloatArray |
The base colour as sRGB r, g, b in 0..1; the part's vertex colours multiply it. |
val roughness: Double |
0 is a mirror, 1 is chalk. |
val metalness: Double |
0 is a dielectric (plastic, clay, wood), 1 is a metal. |
val emissive: FloatArray |
A colour the surface glows with, sRGB, scaled by emissiveIntensity. |
val emissiveIntensity: Double |
How strongly emissive glows. 0 leaves the surface unlit from within. |
val opacity: Double |
Below 1 is see-through. |
val side: GeometrySide |
Which faces draw; double-sided by default, as in the toolbox. |
val wireframe: Boolean |
Draw the triangle edges over the surface. |
val flat: Boolean |
Shade each triangle flat rather than smoothing across them. |
val linearColor: FloatArray |
The base colour, linear. |
val linearEmissive: FloatArray |
The emissive colour, linear. |
override fun equals(other: Any?): Boolean |
|
override fun hashCode(): Int |
GeometryVertex
class GeometryVertex(val x: Double, val y: Double, val z: Double, val color: FloatArray?)
A point with an optional colour (sRGB), as GeometryBuilder.vertex makes it.
GeometryProfilePoint
class GeometryProfilePoint(val r: Double, val y: Double, val color: FloatArray?)
A point on a GeometryBuilder.revolve profile.
GeometryTubeSection
One ring of a GeometryBuilder.tube.
GeometryView
How a GeometryScene asks to be shown. See GeometryBuilder.view.
GeometryPart
class GeometryPart(val name: String, val material: GeometryMaterial, val mesh: TriMesh)
One named part of a GeometryScene: a triangle soup with face normals and linear vertex colours.
| Declaration | Summary |
|---|---|
override fun toString() |
GeometryScene
class GeometryScene(val parts: List<GeometryPart>, val view: GeometryView)
What a GeometryBuilder recipe built: its parts and how it asks to be shown.
| Declaration | Summary |
|---|---|
val parts: List<GeometryPart> |
The parts, in the order they were emitted. |
val view: GeometryView |
How the scene asks to be shown. |
val triangleCount: Int |
Triangles across every part. |
val vertexCount: Int |
Vertices across every part: three per triangle, since the parts are unindexed. |
fun toTriMesh(): TriMesh |
Every part in one mesh, materials aside. |
fun sameTopology(other: GeometryScene): Boolean |
True when other has the same parts with the same vertex counts — what an animation export needs. |
override fun toString() |
GeometryGlb.kt
The exit from GeometryBuilder to a file every other tool can open.
A GeometryScene becomes one .glb with a node per part; a recipe whose vertices move becomes an
animated one. toGlb returns the bytes and writeGlb puts them in a file; geometryAnimationGlb
and writeGlbAnimation do the same for a run of frames. Both writers compress by default, through
Gltfpack when it is installed.
The animation is stored as morph targets rather than a skeleton: one target per frame after the first, played by a weights animation. That needs no rig, which is what makes it open anywhere, and it is why every frame must emit the same parts with the same vertex counts. A morph target is a vertex-for-vertex delta against frame 0, so there is nowhere to put a triangle that appeared halfway through.
A part carries its positions, face normals, linear vertex colours and a metallic-roughness material
built from its GeometryMaterial. KHR_materials_emissive_strength is declared only when some
channel of a part's emissive colour, times its intensity, exceeds 1.
src/jvmMain/kotlin/afield/geometry/mesh/GeometryGlb.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun GeometryScene.toGlb(): ByteArray |
A GeometryScene as a binary glTF (.glb): one node per part, each with its positions, face normals, linear vertex colours and a metallic-roughness material made from its GeometryMaterial. |
fun GeometryScene.writeGlb(file: File, compress: Boolean = true): File |
Write toGlb to file, returning it. With compress, the GLB goes through Gltfpack when it is installed and makes the file smaller, as the toolbox's export does; otherwise it is written as it is. |
fun geometryAnimationGlb(frames: Int = 90, fps: Double = 30.0, block: GeometryBuilder.() -> Unit): ByteArray |
Record a GeometryBuilder recipe as an animated glTF: frames frames at fps, from t = 0, stored as morph targets (one per frame after the first, positions and normals) played by a weights animation — what three.js, Blender and every glTF viewer play back. |
fun writeGlbAnimation(file: File, frames: Int = 90, fps: Double = 30.0, compress: Boolean = true, block: GeometryBuilder.() -> Unit): File |
Write geometryAnimationGlb to file, returning it; compress as in writeGlb. |
Gltfpack.kt
The optional gltfpack step between a GLB and the disk.
Field writes binary glTF in two places, and both offer the bytes here first: writeGlb, for a
geometry recipe, and the three viewer's Export GLB, which posts to POST /three-gltfpack. The
object below is all there is to it: where to find a gltfpack, whether there is one, and two
ways to run it.
Nothing breaks without the tool. compressIfSmaller, which the file writers use, returns the GLB
it was handed when gltfpack is missing, fails, or saves nothing, and the route answers 404 so the
viewer keeps its uncompressed export. compress is the strict version, for a caller that would
rather hear about the failure.
src/jvmMain/kotlin/afield/geometry/mesh/Gltfpack.kt — read the source
Gltfpack
gltfpack, meshoptimizer's glTF optimiser, for making an exported GLB small: it quantises and meshopt-compresses the buffers (-cc), keeping node and material names (-kn, -km) — the settings the madp geometry toolbox exports with.
| Declaration | Summary |
|---|---|
val DEFAULT_ARGS: List<String> |
The arguments the toolbox's export uses. |
fun executable(): File? |
The gltfpack Field will run, or null when there is none. |
val available: Boolean |
Whether a gltfpack is installed. |
fun compress(glb: ByteArray, args: List<String> = DEFAULT_ARGS, timeoutSeconds: Long = 60): ByteArray |
glb through gltfpack with args. Throws if there is no gltfpack (see available) or it fails, with gltfpack's own message. |
fun compressIfSmaller(glb: ByteArray, args: List<String> = DEFAULT_ARGS): ByteArray |
compress when gltfpack is installed and makes glb smaller; otherwise glb as it was. |
MeasurementHelpers.kt
Small helpers for checking a mesh measurement against a known answer in a box.
compare prints a measured value next to its analytic value with absolute and relative error,
integrate sums a per-vertex field over a TriMesh using its mixed vertex areas, and
randomPoints draws repeatable uniform samples from an axis-aligned box.
src/jvmMain/kotlin/afield/geometry/mesh/MeasurementHelpers.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.compare(label: String, measured: Number, analytic: Number) |
Print a measured value beside its analytic answer, absolute error and (for a nonzero answer) relative error. Numeric text is locale-independent; a zero answer reports absolute error only. |
fun TriMesh.integrate(field: DoubleArray): Double |
Integrate one scalar per vertex using mixedVertexAreas, the same weights as the curvature operators. |
fun TriMesh.integrate(field: FloatArray): Double |
integrate for a float-valued vertex field; accumulation remains in double precision. |
fun randomPoints(count: Int, seed: Long = 0L, bounds: AxisAlignedBoundingBox3D): List<Vec3> |
Uniform points in an explicit axis-aligned 3D box. Uses a fresh seeded Java Random and three nextFloat samples per point, so calls are repeatable without changing any shared random state. Bounds may have zero extent along an axis; their coordinates must be finite and ordered. |
mesh.kt
TriMesh — the CPU triangle mesh Field's 3D geometry family is built on, plus the builder that
makes one and the whole-mesh transforms that move one around.
A TriMesh is not a renderer buffer (that is Field 4's
afield.graphics.render.Mesh) and not a graph of vertex objects. It is flat
FloatArray attribute storage plus an IntArray of triangle indices: the layout every algorithm
in this family wants, the layout a GPU upload wants, and cheap to copy. Positions are required;
normals, uvs and per-vertex colours are optional and either absent or complete.
The reason the vertices are floats-in-an-array rather than List<Vec3> is that Vec3 is a
mutable Vector3f. A mesh holding shared mutable vectors is a mesh where mesh.position(0).y +=
1 silently edits the mesh — or worse, edits three triangles because the vector was shared. So
every accessor here (position, normal, uv, color) returns a fresh vector you own, and
the …Into variants let a hot loop reuse one.
The family
| file | what it adds |
|---|---|
mesh.kt |
this: the type, TriMeshBuilder / triMesh, transforms, concatenation |
mesh_topology.kt |
MeshTopology — adjacency, boundary loops, components, manifoldness, Euler characteristic / genus, TriMesh.weld, TriMesh.fixWinding |
mesh_generate.kt |
boxMesh, uvSphereMesh, icosphereMesh, torusMesh, cylinderMesh, extrudeMesh, revolveMesh, tubeMesh, heightfieldMesh, reliefMesh |
mesh_sdf.kt |
ScalarField3D, the SDF primitives / combinators, and marchingCubes |
mesh_process.kt |
TriMesh.loopSubdivide, TriMesh.laplacianSmooth, TriMesh.taubinSmooth, TriMesh.withVertexNormals, TriMesh.decimate |
mesh_measure.kt |
TriMesh.surfaceArea, TriMesh.signedVolume, curvature, MeshBvh (rays, closest point, inside test) |
mesh_io.kt |
OBJ and STL, read and write |
mesh_field.kt |
the bridges: TriMesh.toFLines, VoronoiCell3.toTriMesh, Box.showMesh |
Conventions
- Winding is counter-clockwise seen from outside, so
faceNormalpoints out of a closed solid andsignedVolumeof one is positive. Everything in the family agrees on this, andTriMesh.fixWindingrepairs a mesh that does not. - Nothing here mutates: every operation returns a new
TriMesh. The arrays arevals and are not copied on construction, so do not hand a builder's scratch array to the constructor and then keep writing to it — useTriMeshBuilder.build. - No dependencies beyond the Kotlin stdlib and Field's own generated linalg. No GPU, no AWT, no model-format libraries.
val ball = icosphereMesh(subdivisions = 3, radius = 1.0)
val shell = ball.scaled(1.0, 0.6, 1.0).withVertexNormals()
println("${shell.triangleCount} tris, volume ${shell.signedVolume()}")
display(shell.toFLines()) // draw its wireframe
src/jvmMain/kotlin/afield/geometry/mesh/mesh.kt — read the source
Used in: Meshes and 3D geometry
Top-level functions
| Declaration | Summary |
|---|---|
inline fun triMesh(block: TriMeshBuilder.() -> Unit): TriMesh |
Run block against a fresh TriMeshBuilder and TriMeshBuilder.build the result. |
TriMesh
An indexed triangle mesh: vertexCount * 3 position floats, triangleCount * 3 indices, and optional matching normal / uv / colour arrays.
| Declaration | Summary |
|---|---|
val positions: FloatArray |
|
val indices: IntArray |
|
val normals: FloatArray? |
|
val uvs: FloatArray? |
|
val colors: FloatArray? |
|
val vertexCount: Int |
Number of vertices. |
val triangleCount: Int |
Number of triangles. |
val isEmpty: Boolean |
True when there are no triangles — an empty mesh is legal and is what a degenerate march returns. |
val hasNormals: Boolean |
True when this mesh carries per-vertex normals. |
val hasUvs: Boolean |
True when this mesh carries per-vertex texture coordinates. |
val hasColors: Boolean |
True when this mesh carries per-vertex colours. |
fun x(i: Int): Float |
Vertex i's x, without allocating. |
fun y(i: Int): Float |
Vertex i's y, without allocating. |
fun z(i: Int): Float |
Vertex i's z, without allocating. |
fun position(i: Int): Vec3 |
A fresh Vec3 holding vertex i's position — mutating it does not touch the mesh. |
fun positionInto(i: Int, dest: Vec3): Vec3 |
position into a vector you already own, for loops that must not allocate. Returns dest. |
fun normal(i: Int): Vec3? |
Vertex i's normal, or null when the mesh has none. Fresh vector. |
fun uv(i: Int): Vec2? |
Vertex i's texture coordinate, or null when the mesh has none. Fresh vector. |
fun color(i: Int): Vec3? |
Vertex i's colour, or null when the mesh has none. Fresh vector. |
fun ia(t: Int): Int |
Triangle t's first vertex index. |
fun ib(t: Int): Int |
Triangle t's second vertex index. |
fun ic(t: Int): Int |
Triangle t's third vertex index. |
fun triangle(t: Int): Triple<Vec3, Vec3, Vec3> |
Triangle t's three corner positions, as fresh vectors. |
fun faceNormal(t: Int): Vec3 |
Triangle t's geometric normal from its winding — normalize((b-a) x (c-a)). |
fun triangleArea(t: Int): Double |
Triangle t's area — half the length of the cross product, in double precision. |
fun vertices(): List<Vec3> |
All vertex positions as fresh vectors — convenient, but allocates one per vertex. |
fun triangleIndices(): List<Triple<Int, Int, Int>> |
Every triangle's (a, b, c) index triple. |
fun isFinite(): Boolean |
True when every stored float is finite — the cheapest "did my generator go wrong" check. |
fun copy(positions: FloatArray = this.positions, indices: IntArray = this.indices, normals: FloatArray? = this.normals, uvs: FloatArray? = this.uvs, colors: FloatArray? = this.colors): TriMesh |
A copy with some arrays replaced; anything not passed is kept. Passing null for an attribute drops it, so copy(normals = null) is this mesh without normals. |
fun withoutAttributes(): TriMesh |
Positions and indices only — drops normals, uvs and colours. |
fun withNormals(normals: FloatArray): TriMesh |
This mesh with normals attached (one xyz per vertex). See withVertexNormals to compute them. |
fun withUvs(uvs: FloatArray): TriMesh |
This mesh with uvs attached (one uv per vertex). |
fun withColors(colors: FloatArray): TriMesh |
This mesh with colors attached (one linear rgb per vertex). |
fun withColor(r: Number, g: Number, b: Number): TriMesh |
This mesh painted a single flat colour — the fast way to make a preview readable. |
fun coloredBy(f: (Vec3) -> Vec3): TriMesh |
This mesh with per-vertex colours from a function of position. |
fun transformed(f: (Vec3) -> Vec3): TriMesh |
Every vertex position through f, indices unchanged. |
fun translated(dx: Number, dy: Number, dz: Number): TriMesh |
Translated by (dx, dy, dz). Normals survive — translation does not affect them. |
fun translated(d: Vec3): TriMesh |
Translated by a vector. |
fun scaled(sx: Number, sy: Number, sz: Number): TriMesh |
Scaled about the origin, per axis. |
fun scaled(s: Number): TriMesh |
Uniformly scaled about the origin. |
fun rotated(q: Quat): TriMesh |
Rotated by a Quat. Rigid, so the normals are rotated with the positions rather than dropped. |
fun flipped(): TriMesh |
Every triangle wound the other way, and every normal negated — an inside-out copy. |
operator fun plus(other: TriMesh): TriMesh |
Two meshes as one, other's indices shifted past this one's vertices. |
fun filterTriangles(keep: (Int) -> Boolean): TriMesh |
A mesh made of the triangles keep accepts, with the now-unused vertices removed and the indices renumbered. Attributes follow their vertices. |
override fun toString(): String |
|
override fun toHTML(): String |
Inline thumbnail for the value visualizer: the mesh actually rasterised, shaded and depth-buffered, plus a one-line measurement caption. Evaluating a TriMesh in a box (or mesh.h) shows the shape rather than TriMesh@3f1a2b. |
TriMeshBuilder
The accumulator every generator in this family is written against: append vertices, append triangles that reference them, build.
| Declaration | Summary |
|---|---|
val vertexCount: Int |
How many vertices have been appended so far — also the index the next vertex will get. |
val triangleCount: Int |
How many triangles have been appended so far. |
fun group(name: String) |
Name the piece every triangle appended from here on belongs to. |
fun vertex(p: Vec3, normal: Vec3? = null, uv: Vec2? = null, color: Vec3? = null): Int |
Append a vertex and return its index. |
fun vertex(x: Number, y: Number, z: Number, normal: Vec3? = null, uv: Vec2? = null, color: Vec3? = null): Int |
vertex from loose floats — what the generators use, so they never allocate a Vec3 per vertex. |
fun triangle(a: Int, b: Int, c: Int) |
Append one triangle. Counter-clockwise seen from outside. |
fun quad(a: Int, b: Int, c: Int, d: Int) |
Append a quad as two triangles: (a,b,c) and (a,c,d). Corners in order around the quad. |
fun polygon(corners: List<Int>) |
Append a convex polygon as a fan from its first corner. Non-convex polygons will self-overlap — extrudeMesh triangulates properly via ear clipping for those. |
fun add(mesh: TriMesh): Int |
Append a whole mesh, offsetting its indices. Returns the index its vertex 0 landed on. |
fun build(): TriMesh |
Freeze into a TriMesh. The builder stays usable; the mesh gets its own arrays. |
mesh_field.kt
What makes the mesh family Field's rather than a generic geometry library: the bridges out of
TriMesh and into the things Field already draws, renders and displays.
Four bridges:
- Mesh →
FLine.TriMesh.toFLinesgives you the wireframe, the silhouette-free edge set, the open boundary loops or the normals as drawable lines, so a marched iso-surface goes straight into the same drawing / animation / export machinery as anything hand-drawn — and is visible in a Field document today, with no renderer set up. - Mesh → pixels.
TriMesh.renderPngrasterises the mesh — perspective, z-buffer, perspective-correct attributes, two-light lambert, depth-tested wireframe — by packing it forVisionGeometryPreview's renderer, which already had all of that.Box.showMeshpushes the result to a box output andTriMesh.toHTMLuses it for the inline value thumbnail, so evaluating a mesh in a box shows the shape. - 3D Voronoi → mesh.
VoronoiCell3.toTriMeshandVoronoiLattice.toTriMeshtriangulate the polygonal cellsvoronoi.ktalready computes by half-space clipping. Field could compute a 3D Voronoi diagram and not turn it into geometry; now it can. - Field lines → mesh.
tubeMeshon anFLine, so a drawn stroke becomes a solid.
val cells = voronoi3d(List(24) { vec(rand(-1, 1), rand(-1, 1), rand(-1, 1)) })
val solid = cells.toTriMesh(shrink = 0.9) // every cell, shrunk toward its own site
showMesh(solid, caption = "${solid.triangleCount} triangles from 24 sites")
src/jvmMain/kotlin/afield/geometry/mesh/mesh_field.kt — read the source
Used in: Geometry from disk, Meshes and 3D geometry, Three.js scenes with three { … }
Top-level functions
MeshLines
Which lines TriMesh.toFLines should produce.
Constants: EDGES, CREASES, BOUNDARY, NORMALS, FACES.
MeshView
How to draw a TriMesh in a preview: where the camera stands and what the frame looks like.
MeshRenderer
Which renderer the showMesh family draws with.
Constants: GPU, CPU.
mesh_generate.kt
Where a TriMesh comes from: the primitives (boxMesh, uvSphereMesh, icosphereMesh,
torusMesh, cylinderMesh, coneMesh, capsuleMesh, the five platonics) and the derived
generators that build one out of something you already have (extrudeMesh, revolveMesh,
tubeMesh, heightfieldMesh, reliefMesh, polygonMesh).
Welding and normals
Everything here is welded and watertight where it can be. A generator returns the minimal
vertex set, so MeshTopology.eulerCharacteristic of a box, a sphere, a cone or a capped cylinder
is exactly 2, of a torus exactly 0, and TriMesh.isWatertight is true. The volume, curvature
and decimation code all need that; a mesh that merely renders is not enough for any of them.
Normals are only attached where they are unambiguous. A sphere, icosphere, torus and capsule are smooth everywhere, so they carry per-vertex normals. A box, cylinder, cone, extrusion or platonic solid has creases: the two faces meeting at an edge want different normals at the same vertex, and you cannot have both a shared vertex and two normals. Duplicating the vertex would break watertightness and averaging across the crease would round it off, so these ship with no normals and you pick:
boxMesh().flatShaded() // one normal per triangle: hard edges, 3 verts per tri
boxMesh().shadedNormals(35.0) // split only where faces disagree by > 35 degrees
boxMesh().withVertexNormals() // one averaged normal per shared vertex: rounded corners
The preview / render bridges in mesh_field.kt call TriMesh.shadedNormals for you when a mesh
arrives without normals, so a bare boxMesh() still looks like a box.
Orientation
+y is up, matching the preview camera. Radial generators sweep x = r cos u, z = -r sin u, which
is the handedness that makes counter-clockwise-from-outside windings come out right. planeMesh
and polygonMesh live in the xy plane facing +z; heightfieldMesh lives in the xz plane
facing +y: a plane is a wall and a heightfield is a landscape.
src/jvmMain/kotlin/afield/geometry/mesh/mesh_generate.kt — read the source
Used in: Meshes and 3D geometry, Three.js scenes with three { … }, A photograph, closed and mounted
Top-level functions
| Declaration | Summary |
|---|---|
fun boxMesh(sizeX: Number = 1.0, sizeY: Number = 1.0, sizeZ: Number = 1.0): TriMesh |
An axis-aligned box centred on the origin, 8 vertices and 12 triangles — the minimal watertight cube. |
fun cubeMesh(size: Number = 1.0): TriMesh |
A cube: boxMesh with all three extents equal to size. |
fun planeMesh(nx: Int = 1, ny: Int = 1, width: Number = 1.0, height: Number = 1.0): TriMesh |
A rectangular grid of quads in the xy plane at z = 0, facing +z, with normals and uvs. |
fun uvSphereMesh(segments: Int = 32, rings: Int = 16, radius: Number = 1.0): TriMesh |
A latitude/longitude sphere: two pole vertices and rings - 1 rows of segments. |
fun icosphereMesh(subdivisions: Int = 2, radius: Number = 1.0): TriMesh |
A geodesic sphere: an icosahedron with each triangle split into four subdivisions times, every vertex pushed out to radius. |
fun tetrahedronMesh(radius: Number = 1.0): TriMesh |
A regular tetrahedron with its vertices on a sphere of radius. 4 vertices, 4 triangles, χ = 2. No normals — every face is a crease. |
fun octahedronMesh(radius: Number = 1.0): TriMesh |
A regular octahedron with its 6 vertices on a sphere of radius. 8 triangles, χ = 2. |
fun icosahedronMesh(radius: Number = 1.0): TriMesh |
A regular icosahedron with its 12 vertices on a sphere of radius. 20 triangles, χ = 2. |
fun dodecahedronMesh(radius: Number = 1.0): TriMesh |
A regular dodecahedron with its 20 vertices on a sphere of radius, as 12 pentagons fanned from their own centres — 32 vertices, 60 triangles, χ = 2. |
fun cylinderMesh(segments: Int = 32, radius: Number = 0.5, height: Number = 1.0, caps: Boolean = true): TriMesh |
A cylinder along y, centred on the origin. Watertight with caps (χ = 2); an open tube without them (χ = 0, two boundary loops). |
fun coneMesh(segments: Int = 32, radius: Number = 0.5, height: Number = 1.0, cap: Boolean = true): TriMesh |
A cone along y with its apex at +height/2 and its base at -height/2. Watertight with cap (χ = 2). No normals — the apex is a genuine singularity. |
fun torusMesh(majorSegments: Int = 48, minorSegments: Int = 24, majorRadius: Number = 1.0, minorRadius: Number = 0.35): TriMesh |
A torus in the xz plane: majorRadius out to the centre of the tube, minorRadius the tube itself. Watertight, smooth normals, and the family's canonical genus 1 shape — χ = 0, MeshTopology.genus 1. |
fun capsuleMesh(segments: Int = 32, rings: Int = 8, radius: Number = 0.4, height: Number = 1.0): TriMesh |
A capsule (cylinder with hemispherical ends) along y: total length height + 2 * radius. |
fun revolveMesh(profile: List<Vec2>, axis: RevolveAxis, segments: Int = 48, turns: Double = 1.0): TriMesh |
A lathe: a 2D profile of (radius, height) points swept around axis. |
fun triangulatePolygon(polygon: List<Vec2>): List<Int> |
Ear-clipping triangulation of a simple (non-self-intersecting) polygon. |
fun polygonMesh(polygon: List<Vec2>): TriMesh |
A flat, triangulated polygon in the xy plane at z = 0, facing +z. Concave polygons are handled (triangulatePolygon). A disc: χ = 1, one boundary loop. |
fun extrudeMesh(polygon: List<Vec2>, height: Number = 1.0, caps: Boolean = true): TriMesh |
A prism: polygon in the xy plane swept along +z by height. |
fun tubeMesh(path: List<Vec3>, radius: Number = 0.1, sides: Int = 12, closed: Boolean = false, caps: Boolean = true): TriMesh |
A round tube swept along a 3D polyline — the mesh you want for a drawn stroke, a trajectory or a graph edge in 3D. |
fun heightfieldMesh(nx: Int = 64, nz: Int = 64, width: Number = 2.0, depth: Number = 2.0, height: (Float, Float) -> Float): TriMesh |
A landscape: a grid in the xz plane whose y comes from height, with computed normals and uvs. |
fun Img.toHeightfieldMesh(scale: Number = 0.25, width: Number = 2.0, depth: Number? = null, step: Int = 1): TriMesh |
A heightfield sampled from an Img — one grid vertex per pixel by default, with the image's luminance (or its single channel) as y. |
fun reliefMesh(nx: Int = 64, nz: Int = 64, width: Number = 2.0, depth: Number = 2.0, baseY: Number = -0.25, height: (Float, Float) -> Float): TriMesh |
A relief plaque: heightfieldMesh's surface, closed — skirt walls dropped to baseY and a flat base under them. Watertight, χ = 2, and therefore the first heightfield you can hand to afield.geometry.solid.manifold.Solid. |
fun Img.toReliefMesh(scale: Number = 0.25, width: Number = 2.0, depth: Number? = null, step: Int = 1, baseThickness: Number = 0.1): TriMesh |
Img.toHeightfieldMesh's closed sibling — an image as a printable relief plaque, via reliefMesh. |
RevolveAxis
Which axis revolveMesh sweeps a (radius, height) profile around.
Constants: Y, Z.
mesh_io.kt
Getting a TriMesh in and out of the two formats every 3D tool on the machine can already read:
OBJ (text, indexed, carries normals and uvs) and STL (ASCII or binary, triangle soup, the
lingua franca of 3D printing).
Both directions, in pure Kotlin, and what each format carries is different:
TriMesh.toObj/objToTriMeshkeep the index structure, so a watertight mesh round-trips as a watertight mesh. Normals and uvs survive; per-vertex colours do not (OBJ has no standard for them). Faces with more than three corners are fan-triangulated on read, so a quad-mesh OBJ from a modeller loads fine.TriMesh.toStlAscii/TriMesh.toStlBinary/stlToTriMeshare triangle soup, because STL has no vertex sharing at all: a mesh written to STL and read back has3 * triangleCountvertices and reports every edge as a boundary.stlToTriMesh(bytes).weld()gives you the mesh back. The read path does not weld for you, because welding needs a tolerance, and a tolerance guessed on your behalf is what loses a hairline feature.
val mesh = icosphereMesh(3)
File("/tmp/ball.obj").writeText(mesh.toObj())
File("/tmp/ball.stl").writeBytes(mesh.toStlBinary())
val back = objToTriMesh(File("/tmp/ball.obj").readText())
These two formats, and no others — see meshFromFile for where to go for PLY, glTF and GLB, and
public/docs/geometry_from_disk.md for the measured account of what each format loses.
src/jvmMain/kotlin/afield/geometry/mesh/mesh_io.kt — read the source
Used in: Geometry from disk, Meshes and 3D geometry, Solids that cannot fail: Manifold in Field
Top-level functions
| Declaration | Summary |
|---|---|
fun TriMesh.toObj(name: String = "mesh", decimals: Int = 6): String |
This mesh as Wavefront OBJ text: v positions, then vn normals and vt uvs if present, then f faces with 1-based indices. |
fun objToTriMesh(text: String): TriMesh |
Parse Wavefront OBJ text into a TriMesh. |
fun TriMesh.saveObj(file: File, name: String = file.nameWithoutExtension) |
Write this mesh to file as OBJ. Creates parent directories. |
fun triMeshFromObj(file: File): TriMesh |
Read an OBJ file — see objToTriMesh for what survives. |
fun TriMesh.toStlAscii(name: String = "mesh"): String |
This mesh as ASCII STL. One facet per triangle with its face normal, no vertex sharing (the format has none). |
fun TriMesh.toStlBinary(header: String = "Field afield.stdlib mesh_io.kt"): ByteArray |
This mesh as binary STL: an 80-byte header, the triangle count, then 50 bytes per triangle (little-endian float normal, three float positions, a zero attribute short). |
fun stlToTriMesh(bytes: ByteArray): TriMesh |
Parse STL, ASCII or binary — the format is sniffed, not declared. |
fun TriMesh.saveStl(file: File) |
Write binary STL to file. Creates parent directories. |
fun triMeshFromStl(file: File): TriMesh |
Read an STL file, ASCII or binary — see stlToTriMesh. |
fun meshFromFile(path: String): TriMesh |
Load a mesh from a path, choosing the reader by extension (.obj, .stl). |
fun requireGeometryFrom(file: File, mesh: TriMesh) |
Throw when a non-empty file parsed to no vertices, naming what it looks like instead. A thing.obj that is really a ZIP, a glTF or an HTML error page otherwise reads as an empty mesh and a blank panel. |
mesh_measure.kt
Asking a TriMesh questions: how big is it, where is its middle, how curved is it — and, through
MeshBvh, what does this ray hit, what is the nearest point on the surface, and is this point
inside.
The measurements (TriMesh.surfaceArea, TriMesh.signedVolume, TriMesh.centroid) are exact for
the polyhedron, computed in double precision, and cheap. TriMesh.signedVolume is the one to keep
in mind: it is the divergence-theorem tetrahedron sum, so it is exact for any closed mesh, it is
positive for outward winding and negative for inward, and comparing it against an analytic
answer (4/3 π for a marched unit sphere) is the single most informative test a geometry pipeline
can have.
The curvatures are the standard discrete operators — angle deficit for Gaussian, the
cotangent-Laplacian's magnitude for mean, both divided by the mixed Voronoi area. They satisfy
Gauss-Bonnet (Σ K_v A_v = 2π χ) to float precision on a closed mesh, which is how they are tested.
MeshBvh exists because the useful queries are all O(n) without it and O(log n) with it. It is
a plain median-split bounding-volume hierarchy over triangles, built once, then:
val bvh = mesh.bvh()
bvh.raycast(vec(0, 0, -5), vec(0, 0, 1))?.let { println("hit at ${it.point}") }
bvh.closestPoint(vec(2, 0, 0)) // nearest surface point + distance
bvh.contains(vec(0, 0, 0)) // inside test, by parity of crossings
bvh.signedDistance(vec(0.5f, 0, 0)) // negative inside
src/jvmMain/kotlin/afield/geometry/mesh/mesh_measure.kt — read the source
Used in: Meshes and 3D geometry, Three.js scenes with three { … }
Top-level functions
| Declaration | Summary |
|---|---|
fun TriMesh.surfaceArea(): Double |
Total area of every triangle, in double precision. |
fun TriMesh.signedVolume(): Double |
Volume enclosed, as the signed sum of the tetrahedra from the origin to each triangle. |
fun TriMesh.volume(): Double |
signedVolume's magnitude — the volume whichever way the mesh is wound. |
fun TriMesh.centroid(): Vec3 |
The area-weighted centroid of the surface — where a hollow shell of this shape would balance. |
fun TriMesh.solidCentroid(): Vec3 |
The centre of mass of the enclosed solid, assuming uniform density — the volume-weighted sum of the same tetrahedra signedVolume uses. |
fun TriMesh.mixedVertexAreas(): DoubleArray |
Per-vertex mixed Voronoi area — the area each vertex "owns", the denominator both curvatures need. |
fun TriMesh.gaussianCurvature(): DoubleArray |
Discrete Gaussian curvature per vertex: the angle deficit 2π - Σθ divided by the vertex's mixed area. |
fun TriMesh.meanCurvature(): DoubleArray |
Discrete mean curvature per vertex, signed against the surface normal: half the magnitude of the cotangent Laplacian, divided by the mixed area. |
fun TriMesh.bvh(leafSize: Int = 8): MeshBvh |
Build a MeshBvh over this mesh. Hold on to it — building is the expensive part. |
fun TriMesh.raycast(origin: Vec3, direction: Vec3): RayHit? |
One-shot ray query. Builds a BVH, so use bvh directly for more than a handful of rays. |
fun TriMesh.closestPoint(query: Vec3): SurfacePoint? |
One-shot nearest-surface-point query. Builds a BVH — see bvh. |
fun TriMesh.contains(query: Vec3): Boolean |
One-shot inside test. Builds a BVH — see bvh. |
fun TriMesh.deviationFrom(other: TriMesh): Double |
How far apart two meshes are, sampled: the largest distance from a vertex of other to the surface of this mesh (a one-sided, vertex-sampled Hausdorff distance). |
fun maxDeviation(a: TriMesh, b: TriMesh): Double |
The larger of the two one-sided deviationFrom distances — symmetric, still vertex-sampled. |
fun TriMesh.isContainedIn(hullCandidate: TriMesh, tolerance: Double = 1e-4): Boolean |
The solid angle-free, purely combinatorial check that this mesh's vertices all lie on or inside hullCandidate — used by the convex-hull tests, and occasionally useful on its own. |
fun TriMesh.samplePoints(count: Int, seed: Long = 1L): List<Vec3> |
Uniform-ish points on this mesh's surface, chosen by area so a finely triangulated region is not over-sampled — the sampler a Monte-Carlo volume check or a point-cloud export wants. |
fun convexHullMesh(points: List<Vec3>, tolerance: Double = 1e-7): TriMesh |
The convex hull of a point cloud, as a TriMesh — incremental construction, no dependencies. |
fun TriMesh.convexHull(): TriMesh |
The convex hull of this mesh's own vertices — see convexHullMesh. |
fun angleBetweenDegrees(a: Vec3, b: Vec3): Double |
The angle between two vectors in degrees, 0..180, or 0 if either has no length. |
RayHit
What a ray hit: the triangle, the distance along the ray, and the point and normal there.
| Declaration | Summary |
|---|---|
val triangle: Int |
Index of the triangle that was hit. |
val distance: Double |
Distance along the (unit-length) ray direction. |
val point: Vec3 |
The intersection point. |
val normal: Vec3 |
The hit triangle's geometric normal — not flipped toward the ray. |
val u: Double |
Barycentric weight of the hit on corner b. u + v <= 1. |
val v: Double |
Barycentric weight of the hit on corner c. u + v <= 1. |
override fun toString(): String |
The triangle, the distance and the point, for println. |
SurfacePoint
class SurfacePoint(val triangle: Int, val point: Vec3, val distance: Double)
The nearest point on a mesh to a query point.
| Declaration | Summary |
|---|---|
val triangle: Int |
Index of the triangle the nearest point lies on. |
val point: Vec3 |
The nearest point itself. |
val distance: Double |
Distance from the query point to point. |
override fun toString(): String |
The triangle, the point and the distance, for println. |
MeshBvh
class MeshBvh(val mesh: TriMesh, leafSize: Int = 8)
A bounding-volume hierarchy over a mesh's triangles: build once, query many times.
| Declaration | Summary |
|---|---|
fun raycast(origin: Vec3, direction: Vec3, maxDistance: Double = Double.MAX_VALUE, cullBackFaces: Boolean = false, minDistance: Double = 1e-9): RayHit? |
The nearest triangle direction from origin, or null. |
fun raycastAll(origin: Vec3, direction: Vec3, maxDistance: Double = Double.MAX_VALUE): List<RayHit> |
Every triangle the ray meets, nearest first — what a "cut this solid with a line" query wants. |
fun closestPoint(query: Vec3): SurfacePoint? |
The nearest point of the mesh's surface to query, or null for an empty mesh. |
fun contains(query: Vec3): Boolean |
Is query inside the mesh? |
fun signedDistance(query: Vec3): Double |
Distance to the surface, negative inside — the mesh as a ScalarField3D. |
fun asSdf(): ScalarField3D |
This mesh as a signed-distance ScalarField3D, for composing with the sdf… combinators. |
fun bounds(): AxisAlignedBoundingBox3D |
Bounds of the whole hierarchy — the mesh's AABB, for free. |
override fun toString(): String |
Node and triangle counts — enough to see whether the tree is the shape you expected. |
mesh_process.kt
Changing a mesh's sampling without changing the shape it stands for: normals, subdivision, smoothing and decimation.
These are the four operations that make the difference between a mesh you generated and a mesh you can use. In rough order of how often you will reach for them:
TriMesh.withVertexNormals/TriMesh.flatShaded/TriMesh.shadedNormals— shading.TriMesh.shadedNormalssplits a vertex only where the faces meeting there genuinely disagree, so a cylinder comes out smooth around its side and hard at its rim, from one call.TriMesh.loopSubdivide— four triangles for one, converging on the Loop limit surface. Refines and smooths, and preserves the Euler characteristic exactly.TriMesh.laplacianSmooth/TriMesh.taubinSmooth— noise removal. Laplacian shrinks the shape (that is what a diffusion does); Taubin's λ/μ pair alternates a shrink with an expand and so does not, which is why it is the one to default to. Both offer cotangent weights, which are scale- and shape-independent where the uniform ones are not.TriMesh.decimate— quadric error metric edge collapse down to a triangle budget. The dual of subdivision, and the one that has to work hardest: it maintains per-vertex quadrics, a cost-ordered queue with lazy invalidation, the link condition (so a collapse cannot make the mesh non-manifold) and a normal-flip veto (so it cannot turn the mesh inside out locally).
val fine = icosphereMesh(4) // 5120 triangles
val coarse = fine.decimate(400) // 400, and still a sphere
val smooth = coarse.taubinSmooth(iterations = 20) // volume preserved to within ~1%
Catmull-Clark is absent: it is a quad scheme, TriMesh is triangles-only, and
running it on a triangulated quad mesh gives a different (worse) surface than running it on the
quads. Loop is the triangle scheme, and it is the one here.
src/jvmMain/kotlin/afield/geometry/mesh/mesh_process.kt — read the source
Used in: Meshes and 3D geometry, Geometry from disk
Top-level functions
| Declaration | Summary |
|---|---|
fun TriMesh.withVertexNormals(areaWeighted: Boolean = true): TriMesh |
Per-vertex normals from the incident triangles, each weighted by its area. |
fun TriMesh.flatShaded(): TriMesh |
One normal per triangle: every triangle gets its own three vertices, so every edge is hard. |
fun TriMesh.shadedNormals(creaseAngleDegrees: Double = 35.0): TriMesh |
Crease-aware normals: smooth across edges where the two faces are within creaseAngleDegrees of each other, hard where they are not. |
fun TriMesh.loopSubdivide(times: Int = 1): TriMesh |
Loop subdivision, times rounds: every triangle becomes four, and every old vertex moves toward the average of its neighbours by the Loop weights. |
fun TriMesh.laplacianSmooth(iterations: Int = 1, lambda: Double = 0.5, cotangent: Boolean = false, preserveBoundary: Boolean = true): TriMesh |
Laplacian smoothing: each vertex moved lambda of the way toward the weighted average of its neighbours, iterations times. |
fun TriMesh.taubinSmooth(iterations: Int = 10, lambda: Double = 0.5, mu: Double = -0.53, cotangent: Boolean = false, preserveBoundary: Boolean = true): TriMesh |
Taubin λ/μ smoothing: a shrinking pass at +λ followed by an inflating pass at -μ, iterations times. |
fun TriMesh.decimate(targetTriangles: Int, maxError: Double? = null): TriMesh |
Simplify to at most targetTriangles by quadric-error-metric edge collapse. |
fun TriMesh.quality(): MeshQuality |
Measure this mesh's triangle shapes — see MeshQuality. |
MeshQuality
How well-shaped a mesh's triangles are — the number to look at before and after a remesh, a decimation or a marching pass.
| Declaration | Summary |
|---|---|
val minAngleDegrees: Double |
The smallest interior angle anywhere. 60 degrees is perfectly equilateral; below about 5 the mesh is numerically unpleasant. |
val meanMinAngleDegrees: Double |
Mean over triangles of each triangle's own smallest angle — a better summary than the single worst one. |
val meanAspectRatio: Double |
Mean of circumradius / (2 * inradius): 1 for an equilateral triangle, unbounded for a sliver. |
val worstAspectRatio: Double |
The worst single triangle's aspect ratio. |
val degenerateCount: Int |
Triangles with no area at all. Legal — a UV sphere has none, a marched surface can. |
override fun toString(): String |
All five numbers on one line, for println. |
mesh_repair.kt
Why a mesh is not a solid, and what can be done about it — the paved road between "I loaded a file"
and toSolid().
TriMesh.diagnose answers why, in terms a person can act on: which loops are open and how big,
which edges have the wrong number of faces, which faces are duplicated, whether the winding is
consistent and whether it points out. TriMesh.repairToSolid then attempts the fixes in a
defensible order and hands back a MeshRepairResult that says what it did and what it could
not do — a repair that silently drops half your model is worse than one that refuses, so nothing
here returns a bare mesh.
val mesh = meshFromAnyFile("scan.glb")
println(mesh.diagnose().report()) // why it is not a solid, worst first
val fixed = mesh.repairToSolid()
println(fixed.report()) // what was done, and what was not
if (fixed.isSolid) display(fixed.mesh.toSolid() - drill)
What actually blocks toSolid
Every severity below was established by feeding Manifold a mesh damaged in exactly one named way
(see MeshDamage in the tests) and asking whether it accepted the result. Three of the answers
change how a diagnosis should be read:
- A mesh with several disjoint components is fine. Manifold accepts it and reports the sum of
the volumes. "Split into parts" is not a defect; it is
MeshDefectKind.MULTIPLE_COMPONENTS, and it is informational. - Zero-area triangles are fine. Manifold accepts them and the volume is unchanged.
- An inside-out mesh is fine, and that is the dangerous one. Manifold accepts a consistently
but inwardly wound closed surface and reports a negative volume — so the boolean you do next
silently computes the wrong thing rather than throwing (
toSolid()refuses it on Manifold's behalf when the total comes out negative). That isMeshDefectKind.INVERTED_ORIENTATION, and it is the reason this file reports severityMeshDefectSeverity.SUSPICIOUSat all instead of just a pass/fail.
What Manifold genuinely rejects is a short list: a non-finite position, an edge with other than two incident faces (which a duplicated face also produces, on all three of its edges), an open boundary, and inconsistent winding.
Which repair handles which defect
TriMesh.repairToSolid runs TriMesh.repaired first —
weld → removeDegenerateTriangles → removeUnusedVertices → fixWinding — which handles an
unwelded seam (welding is what turns an STL's pile of triangles into a surface) and inconsistent
winding. Of the twelve damaged meshes in MeshRepairTest's table that Manifold rejects, that
alone fixes six.
Two families of damage need the repairs in this file:
- Open boundaries —
fillHoles. - Non-manifold edges —
removeDuplicateFacesfor a duplicated face,removeNonManifoldFinsfor a dangling fin.
src/jvmMain/kotlin/afield/geometry/mesh/mesh_repair.kt — read the source
Used in: Solids that cannot fail: Manifold in Field, Geometry from disk, Meshes and 3D geometry
Top-level functions
| Declaration | Summary |
|---|---|
fun TriMesh.diagnose(weldTolerance: Double = 1e-6): MeshDiagnosis |
Build (and do not cache) this mesh's MeshDiagnosis. See MeshDiagnosis for the vocabulary. |
fun TriMesh.removeDuplicateFaces(): TriMesh |
Remove triangles that share all three corners with another triangle. |
fun TriMesh.removeNonManifoldFins(maxRounds: Int = 16): TriMesh |
Remove dangling flaps: triangles that hang off a non-manifold edge instead of being part of the closed surface. |
fun TriMesh.fillHoles(maxEdges: Int = 10_000): HoleFillResult |
Close every open boundary loop with a triangulated cap. |
fun TriMesh.repairToSolid(weldTolerance: Double = 1e-6, fillHoles: Boolean = true, maxHoleEdges: Int = 10_000, keepLargestComponentOnly: Boolean = false): MeshRepairResult |
Try to turn this mesh into something afield.geometry.solid.manifold.toSolid will accept, and report what it could not fix. |
MeshDefectSeverity
How much a defect matters — the distinction between "Manifold will refuse this" and "Manifold will accept this and give you a wrong answer without saying so".
Constants: BLOCKING, SUSPICIOUS, INFORMATIONAL.
MeshDefectKind
The kinds of thing that can be wrong with a mesh, declared in the order worth fixing them.
Constants: NON_FINITE_POSITION, UNWELDED_VERTICES, DEGENERATE_FACE, DUPLICATE_FACE, NON_MANIFOLD_EDGE, NON_MANIFOLD_VERTEX, OPEN_BOUNDARY, UNWALKABLE_BOUNDARY, INCONSISTENT_WINDING, INVERTED_ORIENTATION, MULTIPLE_COMPONENTS, UNREFERENCED_VERTEX.
MeshDefect
One thing that is wrong, with the evidence attached.
| Declaration | Summary |
|---|---|
val kind: MeshDefectKind |
|
val severity: MeshDefectSeverity |
|
val count: Int |
|
val detail: String |
|
val fix: String |
|
override fun toString(): String |
BoundaryLoopInfo
One open boundary loop, measured.
| Declaration | Summary |
|---|---|
val vertices: List<Int> |
|
val perimeter: Double |
|
val diameter: Double |
|
val planarity: Double |
|
val normal: Vec3 |
|
val edgeCount: Int |
Number of edges around the hole. |
val isPlanar: Boolean |
True when the loop is flat enough that a planar fill is trustworthy. |
val isSimple: Boolean |
True when the ring visits every vertex once. |
override fun toString(): String |
MeshComponentInfo
One connected piece of the mesh, and whether it is a solid in its own right.
| Declaration | Summary |
|---|---|
val triangles: IntArray |
|
val vertexCount: Int |
|
val isClosed: Boolean |
|
val signedVolume: Double |
|
val triangleShare: Double |
|
override fun toString(): String |
MeshDiagnosis
class MeshDiagnosis(val mesh: TriMesh, val weldTolerance: Double = 1e-6)
Everything wrong with one TriMesh, with the evidence, ordered by what to fix first.
| Declaration | Summary |
|---|---|
val topology: MeshTopology |
The topology this diagnosis reads. Shared, so ask it your own questions rather than rebuilding. |
val nonFiniteVertices: List<Int> |
Vertex indices whose position is NaN or infinite. |
val degenerateFaces: List<Int> |
Triangles with a repeated corner or no area. |
val duplicateFaceGroups: List<List<Int>> |
Groups of two or more triangles sharing the same three corners, whatever their winding. |
val nonManifoldEdges: List<Triple<Int, Int, Int>> |
Edges with three or more incident faces, as (u, v, faceCount). |
val nonManifoldVertices: List<Int> |
Vertices whose incident faces do not form a single fan. |
val boundaryLoops: List<BoundaryLoopInfo> |
Every open boundary loop, measured — see BoundaryLoopInfo. Largest first. |
val unwalkableBoundaryEdges: Int |
Boundary edges that MeshTopology.boundaryLoops could not walk into a closed ring. |
val inconsistentEdges: List<Pair<Int, Int>> |
Interior edges whose two faces walk them the same way — the winding disagreements themselves. |
val components: List<MeshComponentInfo> |
Each connected piece, measured — see MeshComponentInfo. Largest first. |
val invertedComponents: List<MeshComponentInfo> |
Closed components wound inward — accepted by Manifold, and a negative volume when they are. |
val weldableVertices: Int |
How many vertices TriMesh.weld at weldTolerance would merge away. |
val unreferencedVertices: Int |
Vertices no triangle references. |
val defects: List<MeshDefect> |
Every defect found, worst and most causal first. |
val blockers: List<MeshDefect> |
Just the defects Manifold will refuse the mesh over. Empty means TriMesh.toSolid should work. |
val isSolid: Boolean |
True when nothing MeshDefectSeverity.BLOCKING was found. |
override fun toString(): String |
One line: the counts, and the first blocker. |
fun report(maxDefects: Int = 20): String |
The whole diagnosis as readable text, worst first — the thing to print when a toSolid() threw. |
HoleFillMethod
How one hole got filled — the difference between a fill you can trust and one you should look at.
Constants: SINGLE_TRIANGLE, EAR_CLIP, CENTROID_FAN, SKIPPED_TOO_LARGE.
HoleFillReport
What happened to one hole.
| Declaration | Summary |
|---|---|
val loop: BoundaryLoopInfo |
|
val method: HoleFillMethod |
|
val trianglesAdded: Int |
|
val capAreaFraction: Double |
|
val note: String? |
|
val isSuspect: Boolean |
True when this fill is one to look at before trusting: fanned, non-planar, or skipped. |
override fun toString(): String |
HoleFillResult
class HoleFillResult(val mesh: TriMesh, val fills: List<HoleFillReport>)
The mesh with its holes closed, and the story of each one.
| Declaration | Summary |
|---|---|
val filled: Int |
Holes that were closed, whatever the method. |
val skipped: Int |
Holes still open because the caller's TriMesh.fillHoles limit refused them. |
val suspect: List<HoleFillReport> |
Fills worth looking at before trusting — see HoleFillReport.isSuspect. |
RepairStep
class RepairStep(val name: String, val changed: Int, val detail: String)
One thing a repair pipeline did, and how much of it.
| Declaration | Summary |
|---|---|
override fun toString(): String |
MeshRepairResult
What TriMesh.repairToSolid did, what it could not do, and the mesh that came out.
| Declaration | Summary |
|---|---|
val mesh: TriMesh |
|
val original: TriMesh |
|
val steps: List<RepairStep> |
|
val before: MeshDiagnosis |
|
val after: MeshDiagnosis |
|
val holeFills: List<HoleFillReport> |
|
val isSolid: Boolean |
True when nothing blocking remains — mesh should now survive toSolid(). |
val unfixed: List<MeshDefect> |
The blocking defects still present, each carrying its own explanation of why nothing was done. |
val remainingWarnings: List<MeshDefect> |
Defects Manifold will accept but which are probably still wrong — see MeshDefectSeverity.SUSPICIOUS. |
val suspectFills: List<HoleFillReport> |
Fills worth looking at with your eyes before trusting the volume. |
override fun toString(): String |
|
fun report(): String |
The whole story as readable text: what changed, what was filled, and what is still wrong. |
mesh_sdf.kt
Modelling with functions: a ScalarField3D is (Vec3) -> Float, the signed-distance primitives
and combinators that let you write a shape as an expression, and marchingCubes, which turns any
such expression into a watertight TriMesh.
This is the 3D counterpart of contours.kt. There, a ScalarField2D is marched by
generateIsoContours into FLine polylines; here a ScalarField3D is marched into triangles.
The two files are parallel, and reading the 2D one first is the fastest way to
understand this one.
val body = sdfSmoothUnion(
sdfSphere(0.8),
sdfBox(vec(1.4, 0.25, 0.25)),
k = 0.25,
)
val drilled = sdfSubtract(body, sdfCylinder(0.22, 3.0))
val mesh = marchingCubes(drilled, boundsOf(vec(-1.6, -1.6, -1.6), vec(1.6, 1.6, 1.6)), resolution = 96)
println("${mesh.triangleCount} tris, watertight ${mesh.isWatertight()}")
What marchingCubes here actually does
It is a topologically consistent marching cubes, not the 1987 lookup table:
- The field is sampled once per grid corner, so the cost is
(n+1)^3evaluations however many triangles come out. - Every crossing sits on a grid edge, and edges are keyed globally, so a vertex is created once
and shared by all the cubes that touch it. That is what makes the output watertight
(
χ = 2for a marched sphere) rather than a soup of unwelded triangles needingTriMesh.weld. - Instead of a 256-entry triangle table, each cube's crossings are joined into closed loops on the cube's own boundary and each loop is triangulated. On a face where four edges cross — the ambiguous case the classic table resolves arbitrarily — the pairing is chosen by the asymptotic decider (the sign of the bilinear saddle value). Because that decision is a pure function of the face's four corner values, the two cubes sharing the face always agree, which is exactly the property the classic table lacks and the reason plain marching cubes leaks.
- Winding comes from the field itself: each triangle is oriented against the gradient of the trilinear interpolant, so normals point toward increasing field value — outward, for the signed-distance convention where outside is positive.
Known limits, stated rather than hidden: a cube containing two separate sheets of surface can be oriented from one gradient and get the other sheet backwards, so a field with structure much finer than the grid wants more resolution; and the loop triangulation reproduces the sign pattern faithfully but is not guaranteed to reproduce the trilinear interpolant's exact topology in the rarest configurations.
A note on mutation
Vec3 is mutable and the march reuses one sample vector for speed. A ScalarField3D must
therefore read the vector it is handed and not write to it. Every combinator below copies
before transforming, so composing them is safe; a hand-written field that does p.mul(2f) will
corrupt the march.
src/jvmMain/kotlin/afield/geometry/mesh/mesh_sdf.kt — read the source
Used in: Meshes and 3D geometry, Three.js scenes with three { … }, Typography as geometry — a string becomes contours
Top-level functions
| Declaration | Summary |
|---|---|
fun sampledField3D(values: FloatArray, nx: Int, ny: Int, nz: Int, bounds: AxisAlignedBoundingBox3D, outside: Float = 1e6f): ScalarField3D |
A grid of samples as a ScalarField3D, read back by trilinear interpolation. |
fun boundsOf(a: Vec3, b: Vec3): AxisAlignedBoundingBox3D |
An AxisAlignedBoundingBox3D from two corners, sorted so min <= max on every axis. |
fun boundsAround(center: Vec3 = Vec3(0f, 0f, 0f), radius: Number = 1.0): AxisAlignedBoundingBox3D |
A cube of side 2 * radius centred on center — the usual march window for a centred shape. |
fun TriMesh.bounds(): AxisAlignedBoundingBox3D |
This mesh's axis-aligned bounds. A unit box centred on the origin for an empty mesh. |
fun AxisAlignedBoundingBox3D.expanded(margin: Number): AxisAlignedBoundingBox3D |
These bounds grown outward by margin on every axis — room for a march not to clip the shape. |
fun sdfSphere(radius: Number = 1.0, center: Vec3 = Vec3(0f, 0f, 0f)): ScalarField3D |
A sphere of radius centred on center. Exact signed distance. |
fun sdfBox(size: Vec3 = Vec3(1f, 1f, 1f), center: Vec3 = Vec3(0f, 0f, 0f)): ScalarField3D |
An axis-aligned box of full extents size centred on center. Exact signed distance (the standard length(max(q,0)) + min(max(q.x,q.y,q.z),0) form, correct outside and inside). |
fun sdfRoundBox(size: Vec3 = Vec3(1f, 1f, 1f), radius: Number = 0.1, center: Vec3 = Vec3(0f, 0f, 0f)): ScalarField3D |
A box with its edges and corners rounded off by radius — sdfBox shrunk then offset. |
fun sdfTorus(majorRadius: Number = 1.0, minorRadius: Number = 0.3, center: Vec3 = Vec3(0f, 0f, 0f)): ScalarField3D |
A torus in the xz plane: majorRadius to the tube centre, minorRadius the tube. Exact. |
fun sdfPlane(normal: Vec3 = Vec3(0f, 1f, 0f), offset: Number = 0.0): ScalarField3D |
A half-space: the signed distance to the plane through normal * offset with that normal. |
fun sdfCylinder(radius: Number = 0.5, height: Number = 1.0, center: Vec3 = Vec3(0f, 0f, 0f)): ScalarField3D |
A finite cylinder along y: radius across, height tall, centred on center. Exact. |
fun sdfCapsule(a: Vec3, b: Vec3, radius: Number = 0.25): ScalarField3D |
A capsule: the radius-thick tube around the segment from a to b. Exact. |
fun sdfGyroid(scale: Number = 3.0, thickness: Number = 0.2): ScalarField3D |
The gyroid, sin x cos y + sin y cos z + sin z cos x, thresholded to a sheet of thickness. |
fun sdfMetaballs(centers: List<Vec3>, radius: Number = 0.5, iso: Number = 1.0): ScalarField3D |
A sum of smooth radial blobs — the classic metaball field, iso at the surface. |
fun sdfUnion(vararg fields: ScalarField3D): ScalarField3D |
Everything inside any of fields — pointwise min. |
fun sdfIntersect(vararg fields: ScalarField3D): ScalarField3D |
Everything inside all of fields — pointwise max. |
fun sdfSubtract(a: ScalarField3D, b: ScalarField3D): ScalarField3D |
a with b drilled out of it — max(a, -b). |
fun sdfInvert(f: ScalarField3D): ScalarField3D |
Inside and outside swapped. |
fun sdfSmoothUnion(a: ScalarField3D, b: ScalarField3D, k: Number = 0.2): ScalarField3D |
A union with a fillet of radius roughly k where the two surfaces meet — the polynomial smooth minimum, the operator that makes SDF modelling feel like clay rather than CSG. |
fun sdfSmoothIntersect(a: ScalarField3D, b: ScalarField3D, k: Number = 0.2): ScalarField3D |
sdfSmoothUnion's counterpart for intersection: a filleted max. |
fun sdfSmoothSubtract(a: ScalarField3D, b: ScalarField3D, k: Number = 0.2): ScalarField3D |
sdfSubtract with a fillet, for a drilled hole whose rim is not a knife edge. |
fun sdfExpUnion(fields: List<ScalarField3D>, k: Number = 8.0): ScalarField3D |
The exponential smooth minimum — smoother than sdfSmoothUnion and, unlike it, associative, so blending many shapes at once gives the same answer whatever order you fold them in. |
fun ScalarField3D.shell(thickness: Number = 0.05): ScalarField3D |
A hollow shell of wall thickness 2 * thickness around this field's surface. |
fun ScalarField3D.rounded(radius: Number = 0.05): ScalarField3D |
This shape grown outward by radius, which rounds off every convex edge it has. |
fun ScalarField3D.translated(offset: Vec3): ScalarField3D |
This field moved by offset. |
fun ScalarField3D.scaled(factor: Number): ScalarField3D |
This field uniformly scaled about the origin. Distances are rescaled too, so it stays an SDF. |
fun ScalarField3D.rotated(q: Quat): ScalarField3D |
This field rotated by q. Rigid, so it stays an SDF. |
fun ScalarField3D.repeated(period: Vec3): ScalarField3D |
This shape tiled infinitely on a lattice of period — one primitive, unlimited copies, at no extra cost per copy. Zero on an axis leaves that axis untiled. |
fun ScalarField3D.twisted(turnsPerUnit: Number = 0.25): ScalarField3D |
This shape twisted about y by turnsPerUnit full turns per unit of height. |
fun ScalarField3D.displaced(amplitude: Number = 0.05, frequency: Number = 6.0): ScalarField3D |
This shape's surface pushed in and out by a sine lattice of amplitude amplitude and spatial frequency — the cheapest interesting texture, and no longer a true distance function (which the marcher does not mind). |
fun ScalarField3D.gradient(p: Vec3, h: Number = 1e-3): Vec3 |
Central-difference gradient of this field at p, step h. Six evaluations. |
fun ScalarField3D.normalAt(p: Vec3, h: Number = 1e-3): Vec3 |
The unit outward normal of this field's iso-surface at p — gradient, normalised. |
fun marchingCubes(field: ScalarField3D, bounds: AxisAlignedBoundingBox3D = boundsAround(), resolution: Int = 48, iso: Double = 0.0, positiveOutside: Boolean = true, vertexNormals: Boolean = true): TriMesh |
The iso level set of field over bounds, as a watertight TriMesh with shared edge vertices. |
fun marchingCubes(field: ScalarField3D, bounds: AxisAlignedBoundingBox3D, nx: Int, ny: Int, nz: Int, iso: Double = 0.0, positiveOutside: Boolean = true, vertexNormals: Boolean = true): TriMesh |
marchingCubes with the cell count given per axis, when the shape wants an anisotropic grid. |
fun ScalarField3D.march(bounds: AxisAlignedBoundingBox3D = boundsAround(), resolution: Int = 48, iso: Double = 0.0, positiveOutside: Boolean = true): TriMesh |
marchingCubes as a method on the field, for sdfSphere(1.0).march(...)-style chains. |
fun Img.asHeightSdf(scale: Number = 0.25, bounds: AxisAlignedBoundingBox3D = boundsAround()): ScalarField3D |
The image as a 3D height field: the image's value at (x, z) minus y, the same for every y. Marching it gives the same surface Img.toHeightfieldMesh builds directly, which is a useful cross-check and occasionally a useful modelling primitive (you can subtract a heightfield from a solid). |
ScalarField3D
typealias ScalarField3D = (Vec3) -> Float
A scalar field in space: f(x, y, z). For a signed distance function, negative is inside.
mesh_topology.kt
What a TriMesh is, as opposed to where it is: adjacency, boundaries, components,
manifoldness, orientation, and the Euler characteristic those add up to — plus the repairs
(TriMesh.weld, TriMesh.fixWinding, TriMesh.removeDegenerateTriangles) that turn a mesh
which fails those checks into one that passes.
MeshTopology is a derived, cached view: building one costs one pass over the triangles, and
everything after that is a lookup. Build it once and ask it many questions; the convenience
properties on TriMesh (TriMesh.isWatertight and friends) build a throwaway one each time,
which is fine interactively and wasteful in a loop.
The invariant the tests lean on: for a closed orientable surface, V - E + F = 2 - 2g. So a cube,
a sphere and an icosphere all report MeshTopology.eulerCharacteristic 2 and
MeshTopology.genus 0, a torus reports 0 and 1, and a two-holed shape reports -2 and 2.
A duplicated seam, a missing cap and an unwelded march all change that number, so it is the
cheapest single check on a generator.
val t = torusMesh(48, 24)
val topo = t.topology()
println("${topo.eulerCharacteristic} χ, genus ${topo.genus}, watertight ${topo.isWatertight}")
// 0 χ, genus 1, watertight true
src/jvmMain/kotlin/afield/geometry/mesh/mesh_topology.kt — read the source
Used in: Meshes and 3D geometry, Geometry from disk, Solids that cannot fail: Manifold in Field
Top-level functions
| Declaration | Summary |
|---|---|
fun TriMesh.topology(): MeshTopology |
Build (and do not cache) this mesh's MeshTopology. Hold on to the result if you will ask twice. |
fun TriMesh.surfaceTopology(): MeshTopology |
The topology of the surface rather than of its vertex numbering. A mesh carrying normals, UVs or colours may split a vertex wherever those differ — toTriMesh(normals = true) does at every hard edge — and read raw, every such seam is a line of holes and the genus is wrong. So an attributed mesh has exactly coincident positions welded first. A bare mesh is read as it is, so an unwelded triangle soup still reports its open edges. Figure footers (showMesh, toFigureHtml) use this too. |
fun TriMesh.isWatertight(): Boolean |
True when this mesh is a closed manifold surface — see MeshTopology.isWatertight and surfaceTopology. |
fun TriMesh.isManifold(): Boolean |
True when this mesh is a manifold surface — see MeshTopology.isManifold and surfaceTopology. |
fun TriMesh.eulerCharacteristic(): Int |
V - E + F — see MeshTopology.eulerCharacteristic and surfaceTopology. |
fun TriMesh.genus(): Int? |
Number of handles, or null when the mesh is not an orientable manifold — see MeshTopology.genus. A split-normal mesh is welded by position first, so it reports the genus of its surface. |
fun TriMesh.boundaryLoops(): List<List<Int>> |
Each open border as an afield.geometry.path.FLine-ready ring of vertex indices. |
fun TriMesh.weld(tolerance: Double = 1e-6): TriMesh |
Merge vertices closer together than tolerance and renumber the triangles onto the survivors. |
fun TriMesh.removeDegenerateTriangles(areaEpsilon: Double = 1e-14): TriMesh |
Drop triangles with no area — a repeated index, or three collinear corners — and any vertex that leaves unreferenced. |
fun TriMesh.removeUnusedVertices(): TriMesh |
Drop vertices no triangle references, renumbering the rest. |
fun TriMesh.fixWinding(): TriMesh |
Make the winding consistent across the whole mesh, then make it point outward. |
fun TriMesh.repaired(tolerance: Double = 1e-6): TriMesh |
Weld, drop degenerate triangles, drop unused vertices and fix the winding — the one call to make before trusting a mesh that came from a file, a scanner, or a per-cell generator. |
fun TriMesh.largestComponent(): TriMesh |
The largest connected component, by triangle count — the usual way to throw away the specks a noisy iso-surface leaves around the shape you wanted. |
MeshTopology
class MeshTopology(val mesh: TriMesh)
Cached connectivity for one TriMesh.
| Declaration | Summary |
|---|---|
val edges: List<Pair<Int, Int>> |
Undirected edges as packed (lowIndex, highIndex) pairs, in first-seen order. |
val edgeTriangles: List<IntArray> |
For each entry of edges, the triangles incident on it. Size 1 = boundary, 2 = interior, >2 = non-manifold. |
val vertexTriangles: List<IntArray> |
For each vertex, the triangles that use it. |
fun edgeOf(u: Int, v: Int): Int |
The index into edges of the edge joining u and v, or -1 if there is none. |
val edgeCount: Int |
Number of distinct undirected edges. |
val boundaryEdges: List<Pair<Int, Int>> by lazy |
Edges with exactly one incident triangle — the mesh's open border. |
val nonManifoldEdges: List<Pair<Int, Int>> by lazy |
Edges shared by three or more triangles: the classic non-manifold defect. |
val nonManifoldVertices: List<Int> by lazy |
Vertices whose incident triangles do not form a single fan — two cones meeting at a point, or a bowtie. Legal geometry, illegal for most mesh algorithms: subdivision, decimation and curvature all assume a disc-like neighbourhood. |
val isManifold: Boolean |
True when every edge has one or two triangles and every vertex star is a single fan. |
val isWatertight: Boolean |
True when the mesh is manifold and has no boundary — a closed surface enclosing a volume. |
val isConsistentlyOriented: Boolean by lazy |
True when neighbouring triangles agree about which side is out: every interior edge is traversed in opposite directions by its two triangles. |
val boundaryLoops: List<List<Int>> by lazy |
The open borders, each as a closed ring of vertex indices. |
val components: List<List<Int>> by lazy |
Connected components as vertex-index lists, joined through shared triangles. |
val componentCount: Int |
Number of connected components (ignoring vertices no triangle references). |
val eulerCharacteristic: Int |
V - E + F over the vertices triangles actually use. |
val genus: Int? |
Genus from χ = 2c - 2g - b — the number of handles, 1 for a torus, 2 for a two-holed solid — where c is componentCount and b the number of boundaryLoops. |
fun neighbors(v: Int): IntArray |
The neighbours of v — every vertex sharing an edge with it, in no particular order. |
fun isBoundaryVertex(v: Int): Boolean |
True when v sits on an open boundary. |
val boundaryVertices: Set<Int> by lazy |
Every vertex that sits on an open boundary. |
override fun toString(): String |
One line of the facts most worth printing: counts, χ, genus, and what is broken. |
pointing_mesh.kt
Pointing a Ray at a mesh and asking what it hits.
One extension lives here, because the two halves of this question are in different packages.
Rays are made in afield.graphics.interaction: Pointer is what a person points with, and the
unprojection behind RayCamera turns the pixel or the pose it reports into a Ray. The
structure that answers which triangle a ray meets is MeshBvh, which belongs with the meshes and
takes an origin and a direction as two separate Vec3 arguments. The overload below unpacks a
Ray into them.
Two things follow from that hop and are easier to state here than at the call site. A Ray
normalises its direction when it is constructed, so the distance in the RayHit that comes back
is in world units however the caller built the ray. And a Ray holds its origin and direction in
double precision while Vec3 is float, so both narrow at this call before the intersection test
widens them again and runs in double.
For ray to named part rather than ray to triangle, see PickScene.
src/jvmMain/kotlin/afield/geometry/mesh/pointing_mesh.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun MeshBvh.raycast(ray: Ray, maxDistance: Double = Double.MAX_VALUE, cullBackFaces: Boolean = false): RayHit? |
Convenience: MeshBvh.raycast down a Ray. Distances come back in world units. |
TetgenSolid.kt
src/jvmMain/kotlin/afield/geometry/mesh/tetgen/TetgenSolid.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Tetgen.tetrahedralize(solid: Solid, quality: Double = 1.2, targetEdgeLength: Double? = null, preserveSurface: Boolean = false, order: Int = 1): TetMesh |
Tetrahedralize a Manifold solid straight from its double positions. |
GeometryNetwork.kt
The answer Field's geometry-extraction modules share: named points, the curves that join them, and typed channels holding whatever each module measured along the way.
MorseSmale, Polyvector, PrincipalCurves, Mapper, ContourTrees, VectorFieldTopology
and the feature tracker all return a GeometryNetwork, so code that filters, counts or draws
one does not need to know which of them produced it, and metadata says which did.
The three parts divide the work. GeometryNodes holds the points a module considers meaningful,
each under an identity it keeps stable across recomputation where it can, so a downstream object
stays attached to the same feature. A GeometryCurve is a polyline that names the nodes at its
ends, which is why two curves crossing at a position are still unconnected: connectivity is
stated, never inferred from proximity. A Channel packs a scalar, a direction or a covariance
for every node or every vertex into one array, so a large network costs no per-element objects.
GeometryNetwork.toFLines turns a network into drawable lines and leaves the network itself on
each of them, so whatever the lines cannot carry is still reachable from them.
src/jvmMain/kotlin/afield/geometry/network/GeometryNetwork.kt — read the source
Channel
class Channel(val width: Int, val data: DoubleArray)
A per-element attribute channel: width doubles per element, packed into data.
| Declaration | Summary |
|---|---|
val width: Int |
Doubles per element: 1 for a scalar, 2 for a 2D tangent, 3 for the xx, xy and yy of a covariance. |
val data: DoubleArray |
Every element's values in storage order; element i begins at i * width. |
val count: Int |
The number of elements this channel describes. |
operator fun get(element: Int, component: Int = 0): Double |
The value at element, or component component of it for a wider channel. |
GeometryNodes
The vertices of a GeometryNetwork: critical points, junctions, endpoints.
| Declaration | Summary |
|---|---|
val dimension: Int |
Coordinates stored per node, which a network requires its curves to agree with. |
val ids: IntArray |
Node identities in storage order; indexOf maps one back to its index. |
val positions: DoubleArray |
Every node's coordinates in storage order, dimension doubles each. |
val channels: Map<String, Channel> |
Per-node channels by name. Each one describes every node. |
val size: Int |
How many nodes this holds. |
fun indexOf(id: Int): Int? |
The storage index of the node with identity id, or null. |
fun position(index: Int): Vec3 |
The position of the node at storage index index, padded to 3D. |
GeometryCurve
One curve of a GeometryNetwork: a polyline in dimension coordinates, its endpoint nodes, and per-vertex samples.
| Declaration | Summary |
|---|---|
val id: Int |
This curve's identity within its network, which GeometryNetwork.toFLines copies out as curveId. |
val dimension: Int |
Coordinates per vertex: 2 or 3. |
val coordinates: DoubleArray |
The polyline, dimension doubles per vertex, in order along the curve. |
val startNode: Int? |
The identity of the node this curve starts at, or null when it starts at no node. |
val endNode: Int? |
The identity of the node this curve ends at, or null when it ends at no node. |
val closed: Boolean |
Whether the last vertex joins back to the first. |
val attributes: Map<String, Double> |
Whole-curve values by name, such as kind or born, each copied onto the line this becomes. |
val samples: Map<String, Channel> |
Per-vertex channels by name. Each one holds a value for every vertex of this curve. |
val vertexCount: Int |
How many vertices the polyline has. |
fun vertex(i: Int): Vec3 |
Vertex i, padded to 3D. |
val length: Double |
Polyline length. |
GeometryNetwork
The common result type of Field's geometry-extraction modules (Morse–Smale, polyvector, principal curves): explicit nodes, curves that name their endpoint nodes, typed attribute channels, and string metadata recording provenance (which module, which parameters).
| Declaration | Summary |
|---|---|
val dimension: Int |
Coordinates per point. Every node and every curve must agree with it. |
val nodes: GeometryNodes |
The points curves may name as their ends. |
val curves: List<GeometryCurve> |
The curves, which may name only nodes that are in nodes. |
val metadata: Map<String, String> |
Provenance by name: which module made this network, and under which parameters. |
fun curvesAt(nodeId: Int): List<GeometryCurve> |
Curves whose start or end is the node with identity nodeId. |
fun degrees(): Map<Int, Int> |
The number of curve ends meeting at each node identity. |
fun components(): List<List<Int>> |
Connected components, as lists of curve ids. Curves are connected only through shared nodes, never by proximity. |
fun filterCurves(keep: (GeometryCurve) -> Boolean): GeometryNetwork |
A network holding only the curves that satisfy keep, and the nodes they still name. |
fun toFLines(smooth: Boolean = false, tension: Float = 1f, withSamples: Boolean = false): List<FLine> |
One FLine per curve. |
fun nodesFLine(): FLine |
Every node as a single FLine of points, for display; each point carries nodeId and node channels. |
override fun toString() |
Dimension, node and curve counts, and the source recorded in metadata, on one line. |
NativeGeometry.kt
Which of Field's optional native geometry libraries are installed, and what the welcome page says about the rest.
Morse–Smale complexes, principal curves and polyvector vectorization each need a native library
that Field does not build by default. NativeGeometry holds one NativeGeometryCapability per
module, registered by that module's own library object, and answers NativeGeometry.available
and NativeGeometry.describe without loading any of them, so asking never fails. Field runs
without any of them, and calling into one that is absent throws an error naming the Gradle task
that builds it.
NativeGeometry.status reports every module as a NativeGeometryStatus, which
GET /api/native-geometry-status serves to the welcome page's Geometry extensions panel:
whether the library loaded, its docs page, its build task, and the shell commands that install
its prerequisites on macOS, Linux and Windows.
src/jvmMain/kotlin/afield/geometry/network/NativeGeometry.kt — read the source
NativeGeometryCapability
interface NativeGeometryCapability
One optional native geometry library, as NativeGeometry sees it. Implementations must answer without loading the library's symbols, so asking never fails.
| Declaration | Summary |
|---|---|
val name: String |
|
val isAvailable: Boolean |
|
fun describe(): String |
One line: where the library was found, or why not and how to build it. |
val title: String |
A short human title, for the welcome page. |
val docsPage: String |
The docs page, relative to the docs root (e.g. morse_smale.md). |
val buildTask: String |
The Gradle task that builds it. |
val install: Map<String, List<String>> |
Shell commands that install its prerequisites and build it, per OS (macos, linux, windows). An empty list for an OS means the build does not support it yet. |
NativeGeometryStatus
One library's state, as GET /api/native-geometry-status reports it to the welcome page.
| Declaration | Summary |
|---|---|
val name: String |
|
val title: String |
|
val available: Boolean |
|
val description: String |
|
val docsPage: String |
|
val buildTask: String |
|
val install: Map<String, List<String>> |
NativeGeometry
Which of Field's optional geometry libraries are installed.
| Declaration | Summary |
|---|---|
fun register(name: String, capability: () -> NativeGeometryCapability) |
Called by each module's library object; the factory is evaluated only when asked. |
val names: Set<String> |
Module names Field knows about, installed or not. |
fun available(name: String): Boolean |
|
fun describe(name: String): String |
|
fun describe(): String |
|
fun status(): List<NativeGeometryStatus> |
Every module's state, for the welcome page. Never throws: a broken library describes itself. |
FLine.kt
FLine is Field's drawn path: a list of move, line and cubic segments with per-node
and per-line properties, built with moveTo / lineTo / cubicTo and drawn by Box.lines(...).
Also here are the operators that translate, scale and rotate a List<FLine> (or a map of
them) as a group.
src/jvmMain/kotlin/afield/geometry/path/FLine.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
operator fun List<FLine>.plus(offset: Vec3)+1 overload · also Vec2 |
Translate all lines in the list by offset. |
operator fun List<FLine>.times(scale: Vec3) |
Scale all lines in the list component-wise by scale. |
operator fun List<FLine>.times(scale: Vec2) |
Scale all lines in the list component-wise by scale. |
operator fun List<FLine>.times(scale: Number) |
Uniformly scale all lines in the list by scale. |
operator fun List<FLine>.times(rotation: Quat) |
Rotate all lines in the list by rotation. |
operator fun Map<String, List<FLine>>.plus(offset: Vec3) |
Translate all lines in each map entry by offset. |
operator fun FLineMap.plus(offset: Vec3) |
Translate all lines in an FLineMap by offset. |
operator fun Map<String, List<FLine>>.times(offset: Vec3) |
Scale all lines in each map entry component-wise by offset. |
operator fun FLineMap.times(offset: Vec3) |
Scale all lines in an FLineMap component-wise by offset. |
operator fun Map<String, List<FLine>>.times(scale: Number) |
Uniformly scale all lines in each map entry by scale. |
operator fun FLineMap.times(scale: Number) |
Uniformly scale all lines in an FLineMap by scale. |
operator fun Map<String, List<FLine>>.times(rotation: Quat) |
Rotate all lines in each map entry by rotation. |
operator fun FLineMap.times(rotation: Quat) |
Rotate all lines in an FLineMap by rotation. |
Top-level values
| Declaration | Summary |
|---|---|
var _flineSequenceNumber |
FLine
Mutable path composed of move/line/cubic/close instructions.
| Declaration | Summary |
|---|---|
var nodes |
|
val properties |
|
var rev |
|
val uid |
|
sealed class Node(val n: Int) : HasProperties |
Base class for drawing instructions in an FLine. |
val here: Node |
The most recently added node. |
fun endPointOf(index: Int): Vec3 |
Where the instruction at index leaves the pen. |
val currentPoint: Vec3? |
Where the pen sits after the last instruction — the point a lineTo/cubicTo would start from — or null for a line with no instructions at all. |
class MoveTo : Node(1) |
Move instruction (starts a new subpath). |
class LineTo : Node(1) |
Line instruction (straight segment). |
class CubicTo : Node(3) |
Cubic Bezier instruction (endpoint plus two control points). |
class Close : Node(0) |
Close instruction (connects back to the most recent move). |
var breakNext |
When set, the next lineTo or cubicTo becomes a moveTo. |
fun breakNext() |
Convenience to set breakNext for the next instruction. |
fun moveTo(x: Number, y: Number, z: Number = 0): FLine |
Move the current point without drawing, starting a new subpath. |
fun moveTo(v: Vec3): FLine |
Move the current point without drawing, starting a new subpath. |
fun moveTo(v: Vec2): FLine |
2D overload of moveTo. |
fun moveTo(v: Vector3d): FLine |
3D vector overload of moveTo. |
fun lineTo(x: Number, y: Number, z: Number = 0): FLine |
Add a line segment to the given position. |
fun lineTo(v: Vec3): FLine |
Add a line segment to the given position. |
fun lineTo(v: Vector3d): FLine |
3D vector overload of lineTo. |
fun lineTo(v: Vec2): FLine |
2D overload of lineTo. |
fun cubicTo(x0: Number, y0: Number, z0: Number = 0, x1: Number, y1: Number, z1: Number = 0, x: Number, y: Number, z: Number = 0): FLine |
Add a cubic Bezier segment. |
fun byThickening(_thickness: Float) |
Return a new line thickened to the given stroke width. |
fun byFiltering(predicate: (Vec3?, Node) -> Boolean): FLine |
Return a filtered copy of this line. |
fun cubicTo(x0: Number, y0: Number, x1: Number, y1: Number, x: Number, y: Number): FLine |
2D overload of cubicTo (z defaults to 0). |
fun cubicTo(v0: Vec3, v1: Vec3, v: Vec3): FLine |
Add a cubic Bezier segment using vector control points. |
fun close(): FLine |
Close the current subpath. |
fun transform(by: (Vec3) -> Vec3): FLine |
Copy this line while transforming every stored point. |
fun rot(angle: Number, center: FractionAlongAnchor): FLine+3 overloads · also Vec2, Vec3, ListAnchor |
Rotate in the XY plane around center, preserving Z. |
fun rot(angle: Number): FLine |
Rotate around the Z axis and the line center by default. |
fun rot(angle: Number, axis: Vec3, center: Vec3 = center()): FLine+2 overloads · also ListAnchor, FractionAlongAnchor |
Rotate in 3D around axis and center. |
fun scale(scale: Number): FLine |
Uniform scale around the line center by default. |
fun scale(scale: Number, center: Vec2): FLine |
|
fun scale(scale: Number, center: Vec3): FLine |
|
fun scale(scale: Number, center: ListAnchor): FLine |
|
fun scale(scale: Number, center: FractionAlongAnchor): FLine |
|
fun scale(scale: Vec2, center: Vec2 = anchor2(ListAnchor.Center)): FLine |
XY component scale around center, preserving Z. |
fun scale(scale: Vec2, center: ListAnchor): FLine |
|
fun scale(scale: Vec2, center: FractionAlongAnchor): FLine |
|
fun scale(scale: Vec3, center: Vec3 = center()): FLine |
XYZ component scale around center. |
fun scale(scale: Vec3, center: ListAnchor): FLine |
|
fun scale(scale: Vec3, center: FractionAlongAnchor): FLine |
|
operator fun plus(n: Vec3): FLine |
Translate this line by n. |
operator fun minus(n: Vec3): FLine |
Translate this line by -n. |
operator fun times(n: Vec3): FLine |
Scale this line component-wise by n. |
operator fun times(n: Quat): FLine |
Rotate this line by n. |
operator fun times(n: Number): FLine |
Uniformly scale this line by n. |
operator fun plus(n: Vec2) |
2D translation overload of plus. |
operator fun minus(n: Vec2) |
2D translation overload of minus. |
operator fun times(n: Vec2) |
2D scale overload of times. |
fun copyTo(other: Node) |
Append a copy of other to this line. |
fun copy() |
Deep copy of this line and its properties. |
fun center(): Vector3f |
|
fun midPoint(): Vector3f |
|
fun closeMidPoint() |
|
fun copy(block: FLine.() -> Unit) |
Copy this line and apply a mutating block to the clone. |
fun cursor(tol: Float = 0.01f): FLinesAndJavaShapes.Cursor |
Create a cursor for querying length, tangents, normals, and positions. |
fun cursor(closestTo: Vec3): Cursor |
Create a cursor positioned at the closest point to closestTo. |
fun sampleByNumber(steps: Int): List<Vec3> |
Sample evenly along arc length. |
fun drop(n: Int = 1): FLine |
Remove the last n nodes from this line. |
fun sampleByDistance(distance: Float): List<Vec3> |
Sample points by stepping along the curve in fixed increments. |
override fun setPropertyRaw(name: String, value: Any, doResync: Boolean) |
|
override fun getPropertyRaw(name: String, default: Any?): Any? |
|
override fun removePropertyRaw(name: String, doResync: Boolean) |
|
override fun toString(): String |
|
fun pieces(): List<FLine> |
Split this line into subpaths at MoveTo boundaries. |
fun fast2DBounds(): Rect |
Fast axis-aligned 2D bounds based on node control points. |
fun fast3DBounds(): Pair<Vec3, Vec3> |
|
fun data(format: String, parameters: List<Any>): FLine |
Build commands from a compact format string and parameter list. |
fun data(format: String, vararg parameters: Any): FLine |
Variadic form of data. Lets you write f.data("m l l z", 0,0, 1,0, 1,1) instead of building a List<Any> by hand. Parameters may be a mix of Number runs, Vec2 and Vec3; see data for the format grammar (m move, l line, c cubic, s smooth, z close, ( ) groups, trailing + greedy repeat). |
fun uniform_linearize(samplesPerCubic: Int = 100, a: (Vec3, Float) -> Unit) |
Walk the path into line segments. Callback receives: - position: current point - instr: drawing-instruction index for this point. A negative value indicates the start of a subpath (equivalent to the old break flag); otherwise it's interpolated between nodes (e.g. along cubics). |
fun uniform_linearize_cubic(dest: (Vec3, Float) -> Unit, a: Vec3, c1: Vec3, c2: Vec3, b: Vec3, samplesPerCubic: Int = 100, storage: Vec3, fromInstr: Double, toInstr: Double) |
Sample a cubic Bezier segment and feed points to dest. |
fun invoke(f: FLine.() -> FLine): FLine+1 overload · also FLine.() -> Unit |
|
fun tessellate(samplesPerCubic: Int = 100, coplanarity: Coplanarity = Coplanarity.DEFAULT, meshAcceptor: (FloatArray, List<Int>) -> Unit) |
Tessellate filled subpaths into triangle indices. |
operator fun plusAssign(other: FLine) |
|
operator fun plusAssign(p: Vec3) |
Append a point as a lineTo (or a moveTo if this line is empty): f += vec(1, 2). |
operator fun plusAssign(p: Vec2) |
2D overload of plusAssign. |
operator fun Vec3.unaryMinus(): FLine |
Draw a segment to this point (the dash looks like a segment). Starts the path if empty. |
operator fun Vec2.unaryMinus(): FLine |
2D overload of the draw operator. |
operator fun Vec3.unaryPlus(): FLine |
Lift the pen and start a new subpath at this point. |
operator fun Vec2.unaryPlus(): FLine |
2D overload of the pen-up operator. |
fun colorsPer(): FloatArray |
One Vec4 color per drawing instruction. Resolution order: node.properties["color"] if Vec4 → previous color → fline.properties["color"] if Vec4 → default vec4(0.8,0.8,0.8,0.5) |
fun pointSizePer(): FloatArray |
One point size (float) per drawing instruction. Resolution order: node.properties["pointSize"] if Number → previous size → fline.properties["pointSize"] if Number → default 1.0f |
fun clear() |
Remove all of the contents of this line |
FLineEditing.kt
The server side of an editable Box.lines(..., editable = true) drawing: an edit session per
widget that applies the browser's point drags and new strokes to the live FLines.
Box.lines returns an EditableFLineViewState when editable = true (null otherwise); call
EditableFLineViewState.currentLines to read the lines as they are after the user's edits.
src/jvmMain/kotlin/afield/geometry/path/FLineEditing.kt — read the source
EditableFLineViewState
class EditableFLineViewState internal constructor(val sessionId: String)
| Declaration | Summary |
|---|---|
val sessionId: String |
|
fun currentLines(): List<FLine> |
FLineExact.kt
Curve-preserving boolean operations and cubic offsets for FLine, in the manner of Paper.js.
Booleans: every segment of both operands is cut at its exact crossings with every other segment
(Bézier–Bézier by bounding-box subdivision refined by Newton, plus endpoint-on-curve projection
for coincident edges and T-junctions). Each resulting piece is a sub-curve of an original
segment, so cubics stay cubics and straight segments stay FLine.LineTo. A piece is kept when
the operation's answer differs a hair to its left and to its right (winding numbers of both
operands, non-zero rule), oriented with the result on its left, and the kept pieces are chained
into closed contours. The result is checked against the java.awt.geom.Area answer by area; on
any failure the Area answer is returned instead (FLineBooleanResult.exact says which).
Offsets: each cubic is approximated by a cubic whose end tangents are the original's and whose handle lengths are fitted to the true offset at the midpoint (Tiller–Hanson curvature scaling where that fit is ill-conditioned), subdivided until the sampled error is within tolerance. Corners get round, miter or bevel joins; closed results are resolved through the boolean engine with a positive-winding rule, which removes the swallowtails at concave corners.
All arithmetic is in doubles over the xy plane; z is dropped.
src/jvmMain/kotlin/afield/geometry/path/FLineExact.kt — read the source
Used in: FLine examples (Field graphics)
Top-level functions
| Declaration | Summary |
|---|---|
fun FLine.booleanExact(other: FLine, op: FLineBooleanOp, stroke: Boolean = false): FLineBooleanResult |
Boolean op of this filled region with other, keeping the curves: the result's cubics are pieces of the operands' cubics, cut exactly where the outlines cross. Falls back to the java.awt.geom.Area answer when the exact engine cannot produce a result it trusts (see FLineBooleanResult.fallbackReason). Both lines are read as filled, non-zero, in the xy plane, whatever their thickness or thicken; stroke reads a styled line as its stroke outline instead (through Area). |
fun FLine.byOffsettingExact(distance: Float, tolerance: Float = 0.01f, join: FLineStrokeJoin = FLineStrokeJoin.ROUND, miterLimit: Float = 4f): FLine |
Offset this line by distance with cubic output: each cubic becomes a few cubics within tolerance of the true offset (rather than byOffsetting's sampled polyline), straight segments stay straight, and corners get join (a miter longer than miterLimit × distance becomes a bevel). Positive distance is towards FLineLocation.normal2D (left of travel on Field's y-down canvas), as in byOffsetting. Closed subpaths are resolved into clean outlines (concave-corner swallowtails and collapsed parts removed); open ones are returned as single offset runs. |
fun FLine.segmentCount2D(): Int |
number of drawn, non-degenerate segments (what the exactness tests count). |
FLineBooleanOp
The four boolean operations.
Constants: Union, Intersect, Subtract, Xor.
FLineBooleanResult
class FLineBooleanResult(val line: FLine, val exact: Boolean, val fallbackReason: String?)
The answer of FLine.booleanExact: line is the result, exact is whether it came from the curve-preserving engine (otherwise from java.awt.geom.Area, for the fallbackReason given).
FLineGaussianProcess.kt
Gaussian-process curves: fit a GP to an FLine or to points, then draw its mean, samples and uncertainty as lines.
FLine.gaussianProcess and gpCurve return an FLineGaussianProcess, which gives the mean,
covariance, velocity and moving frames along the curve; FLineGaussianProcess.posterior gives an
FLineGpJoint for coherent samples. For scalar GPs, meanFLine, sampleFLine and
intervalFLines turn a joint into plottable lines. Tests: FLineGaussianProcessTest,
FLineGpGeometryTest.
src/jvmMain/kotlin/afield/geometry/path/FLineGaussianProcess.kt — read the source
Used in: Drawing with Gaussian processes
Top-level functions
FLineGaussianProcess
A 2D/3D curve as a vector-valued GP on its scalar parameter, with full output covariance. Velocities, frames and curvature need a GpDifferentiableKernel (the default RBF is one).
| Declaration | Summary |
|---|---|
val dimensions: Int |
|
fun mean(t: Double): Vec3 |
|
fun variance(t: Double): Vec3 |
Coordinate variances; these are squared distances, not standard deviations or a ribbon width. |
fun covariance(t: Double, s: Double = t): Array<DoubleArray> |
Full 3×3 covariance block; planar curves have a zero Z row and column. |
fun posterior(parameters: DoubleArray): FLineGpJoint |
|
fun velocity(t: Double): Vec3 |
Mean velocity d(position)/dt, in coordinate units per unit of parameter (Z is 0 for a planar curve). |
fun velocities(parameters: DoubleArray): GpVectorJoint |
The joint velocity at every parameter; sample it for coherent tangent fields. |
fun frames(parameters: DoubleArray, step: Double = defaultStep(parameters)): List<GpCurveFrame> |
A moving frame along the mean curve, with how uncertain its tangent is. The normal of a planar curve is the tangent turned a quarter to the left; a spatial curve's is the Frenet normal, taken from the mean velocity step either side, and is null where the curve is straight. |
fun curvature(parameters: DoubleArray, samples: Int = 256, seed: Long = 0L, step: Double = defaultStep(parameters)): GpCurvature |
Curvature with uncertainty, by drawing samples whole velocity fields and differencing each step either side. GpCurvature.ofMean is the curvature of the mean curve, which is not the mean curvature of the draws: wiggly draws curve more than their average does. Planar curvature is signed (positive turns left); spatial curvature is unsigned. Costs a joint of 3 × parameters × dimensions values, so keep parameters to a few hundred. step defaults to a thousandth of the parameter span (at least 0.001); much smaller and the sampler can no longer resolve the difference between neighbouring velocities, understating the uncertainty. The same floor applies to a curve pinned so tightly that its velocity variance nears the GpNumerics tolerances; if GpCurvature.std changes with step, it is at that floor. |
GpCurveFrame
One frame of FLineGaussianProcess.frames. speed is in coordinate units per unit parameter; tangentAngleStd is the delta-method standard deviation of the tangent's direction, in radians.
| Declaration | Summary |
|---|---|
val parameter: Double, val position: Vec3, val tangent: Vec3, val normal: Vec3?, val binormal: Vec3? |
|
val speed: Double, val speedStd: Double, val tangentAngleStd: Double |
GpCurvature
Curvature in 1 / coordinate units. See FLineGaussianProcess.curvature.
| Declaration | Summary |
|---|---|
val parameters: DoubleArray |
|
val ofMean: DoubleArray |
|
val mean: DoubleArray |
|
val std: DoubleArray |
FLineGpJoint
Draws and uncertainty on the same parameter grid. All vertices of a draw are correlated.
| Declaration | Summary |
|---|---|
val parameters: DoubleArray |
|
val meanPoints: List<Vec3> |
|
val variances: List<Vec3> |
|
fun meanLine(): FLine |
|
fun samplePoints(seed: Long): List<Vec3> |
|
fun sampleLine(seed: Long): FLine |
|
fun sampleLine(seed: Long, spread: Double): FLine |
spread scales each vertex's departure from the mean: 0 is the mean line, 1 a draw at the fitted scale, above 1 an exaggerated one. |
fun covarianceAt(index: Int, other: Int = index): Array<DoubleArray> |
|
fun projectedVariance(index: Int, direction: Vec3): Double |
Pointwise variance in a chosen direction; handles correlated XYZ, not just diagonal terms. |
fun normalBand(standardDeviations: Double = 1.96): Pair<FLine, FLine> |
Two pointwise normal-offset curves around a planar mean. Not a simultaneous credible region or the envelope of all draws. Degenerate tangents use the positive Y direction. |
FLineGeometry.kt
Comprehensive computational geometry utilities for FLine operations implemented as extension functions. Provides transformations, projections, intersections, decompositions, curvature analyses, tangents, osculating planes, offsets, and a rich set of 2D and 3D geometric algorithms.
Functions that return new FLine instances use a "by" prefix to indicate non-mutating operations:
- byWaving() instead of wave()
- byOffsetting() instead of offset()
src/jvmMain/kotlin/afield/geometry/path/FLineGeometry.kt — read the source
Used in: FLine examples (Field graphics), Vectorizing line drawings: polyvector fields, Euclid's Elements
Top-level functions
Top-level values
| Declaration | Summary |
|---|---|
val start |
|
val end |
|
val midpoint |
|
val center |
Vec2Anchor
typealias Vec2Anchor = (List<Vec2>) -> Vec2
Vec3Anchor
typealias Vec3Anchor = (List<Vec3>) -> Vec3
ListAnchor
Constants: Start, End, Midpoint, Center.
FractionAlongAnchor
data class FractionAlongAnchor(val t: Number)
Anchors2
| Declaration | Summary |
|---|---|
val start: Vec2Anchor |
|
val end: Vec2Anchor |
|
val midpoint: Vec2Anchor |
|
val center: Vec2Anchor |
|
fun fractionAlong(t: Number): Vec2Anchor |
Anchors3
| Declaration | Summary |
|---|---|
val start: Vec3Anchor |
|
val end: Vec3Anchor |
|
val midpoint: Vec3Anchor |
|
val center: Vec3Anchor |
|
fun fractionAlong(t: Number): Vec3Anchor |
EllipseFit
Fit an ellipse to points on the curve using moment-based approximation. Note: This uses a simplified moment-based method rather than full algebraic distance minimization. It provides good approximations for well-behaved elliptical data.
| Declaration | Summary |
|---|---|
val center: Vec2 |
|
val semiMajorAxis: Float |
|
val semiMinorAxis: Float |
|
val rotationAngle: Float |
|
val error: Float |
ParameterFunction
Interface for parameter functions that can vary along a curve. This allows any numeric parameter to be a function of position along the curve.
| Declaration | Summary |
|---|---|
fun evaluate(t: Float, position: Vec3): Float |
Evaluate the parameter at normalized position t and curve position. |
ConstantParameter
class ConstantParameter(val value: Float) : ParameterFunction
Constant parameter (doesn't vary).
| Declaration | Summary |
|---|---|
override fun evaluate(t: Float, position: Vec3) |
|
override fun toString(): String |
LinearParameter
class LinearParameter(val start: Float, val end: Float) : ParameterFunction
Linear interpolation from start to end value.
| Declaration | Summary |
|---|---|
override fun evaluate(t: Float, position: Vec3) |
|
override fun toString(): String |
CurveParameter
Parameter driven by another curve's Y coordinate.
| Declaration | Summary |
|---|---|
override fun evaluate(t: Float, position: Vec3): Float |
|
override fun toString(): String |
DistanceParameter
Parameter driven by distance from a point.
| Declaration | Summary |
|---|---|
val point: Vec3 |
|
val minValue: Float |
|
val maxValue: Float |
|
val maxDistance: Float |
|
override fun evaluate(t: Float, position: Vec3): Float |
|
override fun toString(): String |
CurvatureParameter
Parameter driven by curvature.
| Declaration | Summary |
|---|---|
val line: FLine |
|
val minValue: Float |
|
val maxValue: Float |
|
val maxCurvature: Float |
|
override fun evaluate(t: Float, position: Vec3): Float |
|
override fun toString(): String |
SineParameter
Sine wave parameter.
| Declaration | Summary |
|---|---|
val amplitude: Float |
|
val frequency: Float |
|
val phase: Float |
|
val offset: Float |
|
override fun evaluate(t: Float, position: Vec3) |
|
override fun toString(): String |
FLineDriverTargetUpdateMode
enum class FLineDriverTargetUpdateMode
How a driver-style smoother is allowed to retarget the sampled source line.
Constants: FORWARD_ONLY, WINDOWED_NEAREST, STICKY_WINDOWED_NEAREST.
FLineDriverSmoothingConfig
Configuration for FLine.byDriverSmoothing.
ClassicPerlin2D
Classic 2D Perlin noise with a fixed permutation table, in about -1..1, zero at integer lattice points.
| Declaration | Summary |
|---|---|
fun noise(x: Double, y: Double): Double |
The noise at (x, y). |
FLineGeometrySmile.kt
Resamples an FLine through a fitted curve: bySmileSplineInterpolating (Smile cubic spline),
bySmileGaussianProcessInterpolating (Gaussian process) and byAkimaInterpolating.
Each one samples the line, fits X, Y and Z separately against normalised arc length, and returns
a new line of samples points, mixed with the original by dryWet.
src/jvmMain/kotlin/afield/geometry/path/FLineGeometrySmile.kt — read the source
Top-level functions
FLineHyperbezier.kt
Fits ordinary cubics to Hyperbezier curves, so that family is usable everywhere an FLine
already accepts a cubic.
A hyperbezier is specified exactly the way a cubic Bezier is: a start, two control points, an end.
It is a different curve through that polygon, one whose curvature varies smoothly, and it is an
exact circular arc or Euler spiral when the arms are "circle-like" (see circleArm). Push the
arms out past that and the curve tightens into a corner instead of bulging. What it will not do is
the cubic's double inflection: a hyperbezier has at most one.
Nothing downstream of an FLine needs to know any of that. fitHyperbezierCubics hands back a
handful of ordinary cubics, G1-continuous by construction because every join is pinned to the
hyperbezier's own tangent there. To draw one, use FLine.hyperbezierTo in place of
FLine.cubicTo, or hyperbezierBetween for a whole line from one control polygon and
hyperbezierThrough to run a curve through a list of points.
The curve itself, and the control-polygon-to-curve map, are in Hyperbezier.
src/jvmMain/kotlin/afield/geometry/path/FLineHyperbezier.kt — read the source
Used in: FLine examples (Field graphics)
Top-level functions
HyperbezierCubic
data class HyperbezierCubic(val control1: Vec3, val control2: Vec3, val end: Vec3)
One cubic of a fit — the two control points and the endpoint, as FLine.cubicTo wants them.
HyperbezierFit
The cubics fitHyperbezierCubics fitted to a hyperbezier, plus how well they did.
| Declaration | Summary |
|---|---|
val start: Vec3 |
Where the curve starts — the p0 handed to the fit. |
val cubics: List<HyperbezierCubic> |
The fitted cubics, in order. |
val maxError: Float |
The largest distance, in world units, from the true hyperbezier to the fitted cubics. Zero when curve is null, since there is then no hyperbezier to have missed. |
val tolerance: Float |
The tolerance the fit was asked for, in world units. |
val curve: Hyperbezier? |
The curve that was fitted, or null if the solver stalled or the polygon was degenerate — in which case cubics is the single cubic that was passed in, unchanged. |
val solved: Boolean |
False when the fit fell back to the input cubic; see curve. |
val toleranceMet: Boolean |
True when the fit came in under the tolerance it was asked for. |
val segmentCount: Int |
How many cubics the fit used. |
fun toFLine(): FLine |
A new line containing just this fit, opening with a moveTo to start. |
fun appendTo(line: FLine): FLine |
Append this fit to line as cubics. If line is empty it is opened with a moveTo to start; otherwise the caller is responsible for line already ending at start. |
override fun toString(): String |
FLineInstructionTrajectories.kt
Traces how the points of a changing drawing move from frame to frame, as one trajectory line per point.
FLineSideways.update takes each frame's line(s) and matches their anchor points to the previous
frame's; FLineSideways.trajectories and FLineSideways.trajectoryMap return the tracks drawn so far.
src/jvmMain/kotlin/afield/geometry/path/FLineInstructionTrajectories.kt — read the source
FLineSideways
Incrementally tracks drawing-instruction anchor points across frames and emits one trajectory FLine per tracked point.
| Declaration | Summary |
|---|---|
val maxMatchDistance: Float |
|
val stableTopologyDistanceThreshold: Float |
|
val maxMissedFrames: Int |
|
val breakOnGap: Boolean |
|
val includeCloseInstructions: Boolean |
|
val instructionIndexWeight: Double |
|
val lineKeySelector: (lineIndex: Int, line: FLine) -> Any |
|
val activeTrackCount: Int |
Number of currently active tracks. |
val totalTrackCount: Int |
Number of total tracks (active + archived). |
fun reset() |
Clears all state and trajectories. |
fun update(line: FLine): List<FLine> |
Update with a single line frame. |
fun update(lines: Iterable<FLine>): List<FLine> |
Update with a frame of lines. |
fun update(lines: Map<*, FLine>): List<FLine> |
Update with explicitly keyed lines (handy when source line order can change). |
fun trajectories(includeArchived: Boolean = true): List<FLine> |
Return trajectories ordered by creation id. |
fun trajectoryMap(includeArchived: Boolean = true): FLineMap |
Return trajectories as an FLineMap (track_<id> keys). |
FLineInterpolation.kt
Morphing one FLine into another: align, match and blend two drawings so any in-between shape can be drawn.
interpolatorTo builds a reusable FLineInterpolator from an FLineInterpolationConfig
(alignment, matching and motion settings), and FLineInterpolator.lineAt returns the line at a
given t. byInterpolatingTo is the one-shot form for a single evaluation.
src/jvmMain/kotlin/afield/geometry/path/FLineInterpolation.kt — read the source
Used in: FLine examples (Field graphics)
Top-level functions
| Declaration | Summary |
|---|---|
fun FLine.interpolatorTo(other: FLine, config: FLineInterpolationConfig = FLineInterpolationConfig()): FLineInterpolator |
Create a reusable interpolator to other. |
fun FLine.byInterpolatingTo(other: FLine, t: Float, config: FLineInterpolationConfig = FLineInterpolationConfig(), motion: FLineInterpolationMotion = config.motion): FLine |
One-shot convenience wrapper around FLineInterpolator. |
FLineInterpolationScaleMode
enum class FLineInterpolationScaleMode
Uniform scale normalization used during interpolation setup.
Constants: NONE, RMS_RADIUS, BOUNDS_DIAGONAL.
FLineInterpolationRotationMode
enum class FLineInterpolationRotationMode
Pre-alignment rotation used during interpolation setup.
Constants: NONE, FIT_LINE_2D, FIT_LINE_3D.
FLineInterpolationSpinMode
enum class FLineInterpolationSpinMode
Evaluation-time envelope for spin-style motion modifiers.
Constants: LINEAR, MIDPOINT_RETURN.
| Declaration | Summary |
|---|---|
fun envelope(t: Float): Float |
Reach full value at t = 0.5, then return to zero so the final keyframe is hit exactly. |
FLineInterpolationAlignment
Geometric normalization before matching.
| Declaration | Summary |
|---|---|
val translateToCenter: Boolean |
|
val scaleMode: FLineInterpolationScaleMode |
|
val rotationMode: FLineInterpolationRotationMode |
FLineInterpolationMatching
Setup-time matching and resampling controls.
FLineInterpolationMotion
Fast evaluation-time motion controls applied after the cached basis blend.
FLineInterpolationConfig
Configuration for FLineInterpolator.
FLineInterpolationOrderTransform
data class FLineInterpolationOrderTransform(val reverse: Boolean = false, val shift: Float = 0f)
Reparameterization applied to the second line while solving the correspondence.
| Declaration | Summary |
|---|---|
val reverse: Boolean |
|
val shift: Float |
|
fun forward(rawU: Float, closed: Boolean): Float |
|
fun inverse(orderedU: Float, closed: Boolean): Float |
FLineInterpolationDiagnostics
Introspection for the chosen interpolation setup.
FLineInterpolator
Slow-to-configure, fast-to-evaluate single-contour FLine morphing primitive.
| Declaration | Summary |
|---|---|
val from: FLine |
|
val to: FLine |
|
val config: FLineInterpolationConfig |
|
val diagnostics: FLineInterpolationDiagnostics |
|
val basisCount: Int |
Number of cached basis points used by this interpolator. |
fun sourceBasis(): Pair<FLine, FLine> |
Return the cached source basis lines used for evaluation. |
fun pointsAt(t: Float, motion: FLineInterpolationMotion = config.motion): List<Vec3> |
Generate the interpolated point sequence at t. |
fun writeInto(target: FLine, t: Float, motion: FLineInterpolationMotion = config.motion): FLine |
Write the interpolated geometry into target, clearing it first. |
fun lineAt(t: Float, motion: FLineInterpolationMotion = config.motion): FLine |
Generate a new FLine at t. |
FLineMeasure.kt
Measuring, locating, trimming and crossing an FLine.
There is one measurer: FLine.cursor. A cursor walks a line by distance (setD) or by node
parameter (setT), and everything it reports about the point under it — position, unit tangent,
Frenet frame, curvature — comes from the one segment evaluator in this file: exact cubic first
and second derivatives, straight runs degree-elevated to cubics, degenerate ends handled.
location() hands the lot back as an FLineLocation.
The FLine-level functions here are front doors onto a cursor for the common one-shot questions:
length, locationAt, locationAtFraction, nearestLocation, trim, splitAt. Use a cursor
yourself when you walk a line step by step.
Two parameterisations run through all of it:
- distance (
d), in drawing units along the line, from 0 tolength. Use it when you want things evenly spaced. t, the node parameter:t = i + uis fractionuof the way (by curve parameter, not by distance) along the segment that ends at nodei + 1.
Every FLineLocation carries both.
Everything whose answer only exists in the xy plane says so in its name: FLineLocation.normal2D,
FLineLocation.signedCurvature2D, tightBounds2D, area2D, isClockwise2D, windingNumber2D,
contains2D, crossings2D, selfCrossings2D. Everything else is 3D.
src/jvmMain/kotlin/afield/geometry/path/FLineMeasure.kt — read the source
Used in: FLine examples (Field graphics)
Top-level functions
| Declaration | Summary |
|---|---|
fun FLine.length(): Float |
The total drawn length of this line, in drawing units — MoveTo jumps do not count, Close segments do. cursor().lengthD(); Paper.js path.length, Skia SkPathMeasure.getLength. |
fun FLine.locationAt(distance: Float): FLineLocation? |
The location distance units along this line (clamped to 0..length()): cursor().setD(distance).location(). Paper.js getLocationAt and its getTangentAt / getNormalAt / getCurvatureAt family; Skia SkPathMeasure.getPosTan. null for a line with nothing drawn in it. |
fun FLine.locationAtFraction(fraction: Float): FLineLocation? |
locationAt by fraction of the length: 0 is the start, 1 the end, 0.5 half way by distance. |
fun FLine.nearestLocation(point: Vec3): FLineLocation? |
The location on this line nearest to point, by 3D distance: cursor(point).location(). Paper.js getNearestLocation. null for a line with nothing drawn in it. |
fun FLine.trim(from: Float, to: Float): FLine |
The piece of this line between from and to units along it (both clamped to 0..length(); swapped if reversed). Cubics are cut exactly, so the result draws on top of the original. Skia SkPathMeasure.getSegment, Processing-style "draw the first 30% of a path" animations. |
fun FLine.splitAt(distance: Float): Pair<FLine, FLine> |
This line cut in two at distance units along it: first runs from the start to the cut, second from the cut to the end. Both are always non-null (one may be a single moveTo), and first.length() + second.length() is the original length. Paper.js splitAt. |
fun FLine.tightBounds2D(): Rect |
The axis-aligned 2D bounds of what this line actually draws — tight on cubics (it finds their extrema), unlike FLine.fast2DBounds, which encloses the control points. MoveTo points with nothing drawn from them still count, as in Paper.js bounds. A line with no nodes has an empty Rect(0, 0, 0, 0). |
fun FLine.area2D(): Float |
The signed area this line encloses in the xy plane, exact on cubics. Each subpath is closed back to its start if it is not already, and subpaths add: a counter wound the other way subtracts. Positive when the contours run counter-clockwise with y pointing up — which is clockwise on screen, where y points down. Paper.js path.area. |
fun FLine.isClockwise2D(): Boolean |
Whether this line winds clockwise on screen (y pointing down, as on Field's canvas and in SVG / Paper.js) — i.e. area2D is positive. A line with no area answers false. |
fun FLine.crossings2D(other: FLine, tolerance: Float = 0.01f): List<FLineCrossing> |
Every point where this line crosses other in the xy plane, with the parameter on both lines — Paper.js getIntersections, Skia SkPathOps building block. Straight segments are intersected exactly; cubics by subdivision to tolerance drawing units. Hits closer than 4 * tolerance are one crossing. Sorted by FLineCrossing.t. Overlapping collinear runs report no isolated crossing. |
fun FLine.selfCrossings2D(tolerance: Float = 0.01f): List<FLineCrossing> |
Every point where this line crosses itself, as pairs of parameters t < otherT — Paper.js getCrossings() on one path. Neighbouring segments meeting at their shared node, and a closed subpath meeting itself at its start, are joins, not crossings, and are not reported. |
fun FLine.windingNumber2D(point: Vec2): Int |
The winding number of this line about point in the xy plane, following its curves (cubics are flattened finely), with every subpath treated as closed back to its moveTo; the jump between subpaths is not a segment. Its sign follows the travel direction; non-zero means inside under the non-zero rule. A glyph's counter, wound the other way, cancels to 0 inside the hole. |
fun FLine.contains2D(point: Vec2, rule: FLineFillRule = FLineFillRule.NonZero): Boolean |
Whether point is inside this line in the xy plane under rule (non-zero by default), following its curves, with every subpath treated as closed. Use FLineFillRule.EvenOdd when a counter should be a hole regardless of its winding direction. |
FLineLocation
A point on an FLine with its local frame.
| Declaration | Summary |
|---|---|
val point: Vec3 |
Where on the line. |
val tangent: Vec3 |
Unit direction of travel. At a degenerate end — a cubic whose control point sits on its end point, where the derivative vanishes — it is the limiting direction. Zero only on a segment of zero length. |
val normal: Vec3? |
The unit principal normal: perpendicular to tangent, towards the centre of curvature, in whatever plane the line is bending. null on straight runs, where there is no such direction — there is no sensible fallback in 3D, and in 2D you want normal2D anyway. |
val binormal: Vec3? |
tangent × normal, the axis the line is turning about; null where normal is. |
val curvature: Float |
Unsigned 3D curvature \|r' × r''\| / \|r'\|³, i.e. 1 / radius; 0 on straight runs. |
val normal2D: Vec2 |
tangent's xy part turned a quarter turn, (tangent.y, -tangent.x), unit length. On Field's canvas (y down) that is to the left of the direction of travel; with y up, to the right. It is defined on straight runs too, and is the zero vector only where the line runs straight along z. This is the normal to offset a 2D drawing along. |
val signedCurvature2D: Float |
Curvature of the line's xy projection, positive when it bends towards normal2D; 0 on straight runs. |
val distance: Float |
Distance along the line from its start, in drawing units. |
val t: Float |
The node parameter of this point (see the file overview). |
val segment: Int |
Index of the node that ends the segment this point is on. |
FLineCrossing
data class FLineCrossing(val point: Vec3, val t: Float, val otherT: Float)
One place where two lines (or one line with itself) cross in the xy plane.
| Declaration | Summary |
|---|---|
val point: Vec3 |
The crossing point. |
val t: Float |
The node parameter on the line the method was called on. |
val otherT: Float |
The node parameter on the other line (on the same line, for selfCrossings2D). |
FLineFillRule
How contains2D decides "inside" when contours overlap or nest — the SVG / canvas fill-rule.
Constants: NonZero, EvenOdd.
FLineSampling.kt
Choosing points along an FLine: pluggable samplers that return parameter and position pairs.
FLineSamplers holds the built-in FLineSamplers (uniform, by distance, random, curvature
adaptive, Douglas-Peucker, patterned) and FLineSamplers.compose / then chain them.
sampleSeries returns FLineSamples and samplePoints returns just the points.
src/jvmMain/kotlin/afield/geometry/path/FLineSampling.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
infix fun FLineSampler.then(next: FLineSampler): FLineSampler |
Compose two samplers in sequence. |
fun FLine.sampleSeries(samples: Int, sampler: FLineSampler? = null, seed: Long? = null, pointSetAware: Boolean = true): List<FLineSample> |
Sample an FLine into FLineSample values. |
fun FLine.samplePoints(samples: Int, sampler: FLineSampler? = null, seed: Long? = null, pointSetAware: Boolean = true): List<Vec3> |
Sample an FLine into plain points. |
FLineSample
data class FLineSample(val t: Float, val point: Vec3)
A sampled point on an FLine, with normalized parameter t in [0, 1].
| Declaration | Summary |
|---|---|
override fun toString(): String |
FLineSampleContext
Sampling request context for FLineSampler.
| Declaration | Summary |
|---|---|
val requestedCount: Int |
|
val seed: Long? |
|
val pointSetAware: Boolean |
|
override fun toString(): String |
FLineSampler
Composable sampler interface for extracting point-series data from an FLine.
| Declaration | Summary |
|---|---|
fun sample(line: FLine, context: FLineSampleContext): List<FLineSample> |
FLineSamplers
Built-in sampler factories.
| Declaration | Summary |
|---|---|
fun uniform(): FLineSampler |
Uniform parameter sampling (equivalent to legacy sampleByNumber for non point-set lines). |
fun points(): FLineSampler |
Return instruction points (node endpoints) in order. |
fun byDistance(spacing: Float): FLineSampler |
Arc-length sampling by fixed spacing. For point-set lines this returns point nodes. |
fun random(stratified: Boolean = true, includeEnds: Boolean = true, seed: Long? = null): FLineSampler |
Random parameter sampling (optionally stratified). Endpoints are included by default. |
fun curvatureAdaptive(curvaturePower: Float = 1f, gain: Float = 2f, oversample: Int = 4, minBaseSamples: Int = 32): FLineSampler |
Curvature-adaptive sampling: spends more samples where turning is higher. |
fun douglasPeucker(epsilon: Float, denseOversample: Int = 4, minDenseSamples: Int = 64): FLineSampler |
Ramer-Douglas-Peucker simplification on a dense sample. |
fun patterned(pattern: List<Float>, includeEnds: Boolean = true): FLineSampler |
Patterned spacing with repeating interval weights (e.g. [1,2,1,3]). |
fun compose(vararg samplers: FLineSampler): FLineSampler |
Compose several samplers by applying each to the output polyline of the previous. |
FLineSerialization.kt
Serializes an FLine to JSON and back, nodes and properties included, through FLineSerializer.
FLine names FLineSerializer in its @Serializable annotation, so any kotlinx.serialization
format picks it up. Each property value is written with a type tag: numbers, booleans, strings and
Vec2/Vec3/Vec4. A value of any other type throws SerializationException; a few runtime-only
properties (onClick callbacks, thicken strokes) are skipped instead.
src/jvmMain/kotlin/afield/geometry/path/FLineSerialization.kt — read the source
FLineSerializer
object FLineSerializer : KSerializer<FLine>
| Declaration | Summary |
|---|---|
override val descriptor: SerialDescriptor |
|
override fun serialize(encoder: Encoder, value: FLine) |
|
override fun deserialize(decoder: Decoder): FLine |
FLineThickening.kt
Turning a line into an outline: offsets, ribbons and buffers of an FLine, at fixed or varying width.
byRibboning and byVariableRibboning return an FLineRibbon with left and right edges and a
closed shell. byJavaOffsetting uses Java2D strokes; byJtsBuffering, byJtsOffsetting and
byJtsVariableBuffering use JTS, with FLineStrokeJoin and FLineStrokeCap for the corner and
end style. byJtsSplitting separates a self-intersecting line into an FLineSplit.
src/jvmMain/kotlin/afield/geometry/path/FLineThickening.kt — read the source
Top-level functions
FLineStrokeJoin
enum class FLineStrokeJoin(internal val awtJoin: Int, internal val jtsJoin: Int)
Join style for Java2D/JTS offset and buffer operations.
Constants: MITER, ROUND, BEVEL.
FLineStrokeCap
enum class FLineStrokeCap(internal val awtCap: Int, internal val jtsCap: Int)
Cap style for Java2D/JTS offset and buffer operations.
Constants: BUTT, ROUND, SQUARE.
FLineRibbon
data class FLineRibbon(val left: FLine, val right: FLine, val shell: FLine)
Ribbon result split into its left edge, right edge, and closed shell.
| Declaration | Summary |
|---|---|
val left: FLine |
|
val right: FLine |
|
val shell: FLine |
|
override fun toString(): String |
FLineSplit
data class FLineSplit(val outerLoop: FLine?, val innerLoops: List<FLine>, val lines: List<FLine>)
JTS split result: largest outer loop, interior loops, and open line components.
| Declaration | Summary |
|---|---|
val outerLoop: FLine? |
|
val innerLoops: List<FLine> |
|
val lines: List<FLine> |
|
override fun toString(): String |
JtsCurveSplitter
class JtsCurveSplitter(private val flatness: Double = 0.5)
JTS helper for splitting/flattening/shape conversion.
| Declaration | Summary |
|---|---|
fun split(line: FLine): FLineSplit |
FLineWiggle.kt
Wiggling one line along another, and the moving frames that make it possible.
wiggledBy reads a second line's coordinates in a frame travelling along this one, so a small wave
becomes a decoration along a path. sweptFrames returns the SweptFrames (position, tangent,
normal, binormal) it uses, for placing other things along a line. Tests: FLineWiggleTest.
src/jvmMain/kotlin/afield/geometry/path/FLineWiggle.kt — read the source
Used in: FLine examples (Field graphics)
Top-level functions
| Declaration | Summary |
|---|---|
fun FLine.wiggledBy(a: FLine, repeats: Int = 1, subtractChord: Boolean = true, amplitude: Number = 1.0, resolution: Int = 200, up: Vec3? = Vec3(0f, 0f, 1f)): FLine |
Wiggle this line with another: a's coordinates are read in a frame that travels along this line. |
fun FLine.sweptFrames(samples: Int = 400, up: Vec3? = Vec3(0f, 0f, 1f)): SweptFrames? |
Frames along this line; see SweptFrames. null if the line has no length. |
SweptFrame
One frame along a line: where it is, and the tangent / normal / binormal there.
SweptFrames
Frames swept along a line, sampled evenly by arc length. Query any point with frameAt.
| Declaration | Summary |
|---|---|
val positions: Array<DoubleArray> |
|
val tangents: Array<DoubleArray> |
|
val normals: Array<DoubleArray> |
|
val binormals: Array<DoubleArray> |
|
val fractions: DoubleArray |
Arc-length fraction (0..1) of each sample. |
val planar: Boolean |
|
val strength: Double |
|
val length: Double |
Arc length of the line. |
fun frameAt(s: Double): SweptFrame |
The frame at arc-length fraction s, interpolated between samples. Past either end the line is continued straight along its end tangent, with the end frame. |
FloatEarcut.kt
src/jvmMain/kotlin/afield/geometry/path/FloatEarcut.kt — read the source
FloatEarcut
| Declaration | Summary |
|---|---|
fun earcut(data: FloatArray, holeIndices: IntArray? = null, dim: Int = 2): MutableList<Int> |
Triangulates the given polygon |
Hyperbezier.kt
The hyperbezier curve family, ported from Raph Levien's hyperbezier
(linebender.org/blog/hyperbezier/, August 2026); fitHyperbezierCubics is what draws one into an
FLine.
A hyperbezier is the plane curve traced at unit speed whose curvature is a rational function of
arc length s:
kappa(s) = (a*s + b) / q(s)^1.5, q(s) = c*s^2 + d*s + 1
over s in [0, 1], then similarity-transformed so it runs from (0, 0) to (1, 0), which is
what the rest of this file calls the chord frame. q is a dial between two regimes a single
cubic has to compromise between: q constant gives circular arcs and Euler spirals exactly,
while q dipping towards zero gives a corner-like curvature spike. The family also contains the
log-aesthetic curves and the circle involute exactly. Its curvature numerator is linear in s, so
it has at most one inflection, and that is what it gives up relative to a cubic: following a cubic
with two inflections takes more than one hyperbezier.
theta(s), the integral of kappa, has a closed form. The position integral of e^(i*theta)
does not, being Fresnel-like, so that part is done numerically with Gauss-Legendre quadrature.
Construct a curve from a cubic-shaped control polygon with hyperbezierFor; it is rarely useful
to pick a, b, c and d by hand.
src/jvmMain/kotlin/afield/geometry/path/Hyperbezier.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun circleArm(angle: Double): Double |
The "parabola rule" arm length for a tangent that leaves the chord at angle radians: the arm at which a cubic's midpoint coincides exactly with a circular arc's apex, at every angle. Arms are measured against this, so an arm of circleArm(theta) means "circle-like", and hyperbezierFor returns an exact arc or Euler spiral there. |
fun hyperbezierFor(th0: Double, th1: Double, l0: Double, l1: Double, cornerBias: Double = 1.0, armResponse: Double = 2.0): Hyperbezier? |
The control-polygon to hyperbezier map: turn a cubic-Bezier-shaped control polygon, expressed in the chord frame, into the curve that replaces it. |
Hyperbezier
class Hyperbezier(val a: Double, val b: Double, val c: Double, val d: Double)
One hyperbezier, as the four coefficients of its curvature function, evaluated in the chord frame.
| Declaration | Summary |
|---|---|
val a: Double |
Linear coefficient of the curvature numerator. |
val b: Double |
Constant coefficient of the curvature numerator. |
val c: Double |
Quadratic coefficient of the denominator q. |
val d: Double |
Linear coefficient of the denominator q. |
val chordScale: Double |
Length of the endpoint integral. Everything in the chord frame is the raw curve scaled by 1 / chordScale, so raw curvatures multiply by this and raw lengths divide by it. |
val isEulerFamily: Boolean |
True when q is constant, i.e. the curve is exactly a circular arc (a == 0) or an Euler spiral. Control polygons whose arms are exactly the "parabola rule" length circleArm land here at every angle. |
val arcLength: Double |
Total arc length of the curve in the chord frame, where the chord itself has length 1. |
fun q(t: Double): Double |
The denominator quadratic q(t) = c*t^2 + d*t + 1. |
fun pointAt(t: Double): DoubleArray |
Position at arc-length fraction t in the chord frame: pointAt(0) is (0, 0) and pointAt(1) is (1, 0), to within the accuracy of the quadrature. Returned as doubleArrayOf(x, y) — the caller converts to Field's single-precision Vec2/Vec3 once, at the end, rather than losing digits at every step of a fit. |
fun tangentAngleAt(t: Double): Double |
Tangent angle at arc-length fraction t, in radians, measured counter-clockwise from the chord. tangentAngleAt(0) and tangentAngleAt(1) reproduce the th0 and th1 handed to hyperbezierFor — that G1 interpolation is what the solver is solving for. |
fun curvatureAt(t: Double): Double |
Signed curvature at arc-length fraction t, in the chord frame. |
fun featureParameters(): List<Double> |
Parameters strictly inside (0, 1) that are natural places to split the curve when approximating it: the minimum of q — the corner, where curvature spikes — and the extrema of curvature. Sorted ascending. |
override fun toString(): String |
LowSegmentFLineFitting.kt
Fits a point sequence with as few line and arc segments as its error budget allows.
fitLowSegmentFLine returns the fitted FLine, and fitLowSegmentFLineResult also reports the
error in a LowSegmentFLineFitResult; LowSegmentFLineFitConfig sets the segment limit and the
improvement a split must earn. Tests: LowSegmentFLineFittingTest.
src/jvmMain/kotlin/afield/geometry/path/LowSegmentFLineFitting.kt — read the source
Top-level functions
LowSegmentFLineFitConfig
Controls the conservative low-segment fitter used by fitLowSegmentFLine.
LowSegmentFLineFitResult
| Declaration | Summary |
|---|---|
val line: FLine |
|
val rmsError: Double |
|
val maxError: Double |
|
val segmentCount: Int |
|
val primitives: List<LowSegmentFLineFitPrimitive> |
LowSegmentFLineFitPrimitive
sealed class LowSegmentFLineFitPrimitive
PathFlattener.kt
Converts between arc length along an FLine and its node parameter, by flattening its curves to a tolerance.
src/jvmMain/kotlin/afield/geometry/path/PathFlattener.kt — read the source
PathFlattener
class PathFlattener(private val c: FLine, tol: Float)
Flattens a line to tolerance tol; lengthToDot turns a distance along it into a node parameter and dotToLength goes back.
PlanarTessellation.kt
Fills sets of 3D rings, holes included, by grouping them into planes and handing each plane to
FloatEarcut.
PlanarTessellation.tessellate returns PlanarTessellation.Polygons whose triangles refer back
into the input rings, and Coplanarity sets how close two rings must be to share a plane.
Tessellation commutes with rigid rotation, which TessellationOrientationOracleTest checks.
src/jvmMain/kotlin/afield/geometry/path/PlanarTessellation.kt — read the source
Coplanarity
data class Coplanarity(val sineOfAngle: Float = 1e-3f, val offsetFraction: Float = 1e-3f)
How close two rings have to be before PlanarTessellation treats them as lying in the same plane.
| Declaration | Summary |
|---|---|
val sineOfAngle: Float |
|
val offsetFraction: Float |
PlanarTessellation
The one triangulator for filled, arbitrarily oriented, hole-carrying ring sets.
| Declaration | Summary |
|---|---|
data class Vertex(val ring: Int, val vertex: Int) |
A reference to one point of one input ring: ringsring``. |
class Polygon internal constructor(val outer: Int, val holes: List<Int>, val triangles: List<Vertex>, val normal: Vec3) |
One filled polygon: an outer ring, the coplanar rings subtracted from it, and its triangles. |
fun newellNormal(ring: List<Vec3>): Vec3 |
Newell's normal of a ring, unnormalised — its length is twice the ring's planar area, which is what makes it usable both as a direction and as a measure of how much to trust that direction. |
fun tessellate(rings: List<List<Vec3>>, coplanarity: Coplanarity = Coplanarity.DEFAULT): List<Polygon> |
Triangulate rings — each an ordered, closed, already-flattened ring, with no repeated final point — grouping them into planes and holes as described on PlanarTessellation. |
PolynomialTreeFitting.kt
Fits a List<Vec3> or an FLine with a tree of 3D polynomial curves, splitting a
span until each leaf meets its error targets.
The fitPolynomialTree3D extensions take a PolynomialTreeFitConfig (polynomial order, depth and leaf limits,
RMS and max-error targets) and returns a PolynomialTreeFitResult. Split, stop and claim rules
are pluggable through PolynomialTreeSplitStrategies, PolynomialTreeStopCriteria and
PolynomialTreeClaimCriteria; PolynomialLeafFLineStyles holds preset styles for
drawing the fitted leaves as FLines.
src/jvmMain/kotlin/afield/geometry/path/PolynomialTreeFitting.kt — read the source
Top-level functions
Polynomial3Curve
Parametric 3D polynomial curve: x(t), y(t), z(t) for t in [0, 1].
| Declaration | Summary |
|---|---|
val xCoefficients: DoubleArray |
|
val yCoefficients: DoubleArray |
|
val zCoefficients: DoubleArray |
|
val order: Int |
|
fun evaluate(t: Double): Vec3+1 overload · also Float |
|
fun sample(samples: Int = 64, tStart: Double = 0.0, tEnd: Double = 1.0): List<Vec3> |
|
fun toFLine(samples: Int = 64, tStart: Double = 0.0, tEnd: Double = 1.0): FLine |
PolynomialTreeNode
| Declaration | Summary |
|---|---|
val id: Int |
|
val depth: Int |
|
val sourceRange: IntRange |
|
val supportIndices: IntArray |
|
val polynomial: Polynomial3Curve |
|
val supportRmsError: Double |
|
val supportMeanError: Double |
|
val supportMaxError: Double |
|
val splitPointIndex: Int? |
|
val stopReason: String |
|
val claimedIndices: IntArray |
|
val claimedRmsError: Double? |
|
val claimedMaxError: Double? |
|
val left: PolynomialTreeNode? |
|
val right: PolynomialTreeNode? |
|
val isLeaf: Boolean |
|
fun leaves(): List<PolynomialTreeNode> |
PolynomialTreeTraversalContext
| Declaration | Summary |
|---|---|
val node: PolynomialTreeNode |
|
val depth: Int |
|
val indexInLevel: Int |
|
val parent: PolynomialTreeNode? |
PolynomialTreeTraversalDecision
| Declaration | Summary |
|---|---|
val emit: Boolean |
|
val descend: Boolean |
|
val stopAll: Boolean |
PolynomialTreeTraversalLevel
| Declaration | Summary |
|---|---|
val depth: Int |
|
val nodes: List<PolynomialTreeTraversalContext> |
|
fun toFLines(samplesPerNode: Int = 64): List<FLine> |
|
fun toFLines(mapper: (PolynomialTreeTraversalContext) -> FLine?): List<FLine> |
PolynomialTreeTraversalController
fun interface PolynomialTreeTraversalController
| Declaration | Summary |
|---|---|
fun decide(context: PolynomialTreeTraversalContext): PolynomialTreeTraversalDecision |
PolynomialTreeFitResult
| Declaration | Summary |
|---|---|
val inputPoints: List<Vec3> |
|
val config: PolynomialTreeFitConfig |
|
val root: PolynomialTreeNode |
|
val leaves: List<PolynomialTreeNode> |
|
val pointErrors: DoubleArray |
|
val nearestLeafIds: IntArray |
|
val overallRmsError: Double |
|
val overallMeanError: Double |
|
val overallMaxError: Double |
|
fun leafById(id: Int): PolynomialTreeNode? |
|
fun levelTraversal(controller: PolynomialTreeTraversalController = PolynomialTreeTraversalController { PolynomialTreeTraversalDecision() }): Sequence<PolynomialTreeTraversalLevel> |
|
fun toLeafFLines(samplesPerLeaf: Int = 64): List<FLine> |
|
fun toLeafFLines(render: PolynomialLeafFLineRenderConfig): List<FLine> |
|
fun toLeafFLineMap(samplesPerLeaf: Int = 64): Map<String, List<FLine>> |
|
fun toLeafFLineMap(render: PolynomialLeafFLineRenderConfig, keyPrefix: String = "poly_leaf"): Map<String, List<FLine>> |
|
fun incrementalUpdater(points: List<Vec3> = inputPoints): PolynomialTreeIncrementalUpdater |
PolynomialLeafFLineOutputMode
enum class PolynomialLeafFLineOutputMode
Constants: SEPARATE_LINES, SINGLE_MULTIPATH, SINGLE_CONNECTED.
PolynomialLeafOrderMode
enum class PolynomialLeafOrderMode
Constants: SOURCE_ASCENDING, SOURCE_DESCENDING, ERROR_DESCENDING, ERROR_ASCENDING, DEPTH_ASCENDING, DEPTH_DESCENDING.
PolynomialLeafGapStyle
| Declaration | Summary |
|---|---|
val enabled: Boolean |
|
val absolute: Double |
|
val fractionOfSpan: Double |
|
val trimStart: Boolean |
|
val trimEnd: Boolean |
|
val minAbsolute: Double |
|
val maxAbsolute: Double |
PolynomialLeafOvershootStyle
| Declaration | Summary |
|---|---|
val enabled: Boolean |
|
val startAbsolute: Double |
|
val endAbsolute: Double |
|
val startFractionOfSpan: Double |
|
val endFractionOfSpan: Double |
|
val maxAbsolute: Double |
PolynomialLeafSketchStyle
| Declaration | Summary |
|---|---|
val passes: Int |
|
val jitterAmplitude: Double |
|
val alongTangentFactor: Double |
|
val keepEndpoints: Boolean |
|
val seed: Long |
PolynomialLeafFLineRenderConfig
PolynomialLeafFLineStyles
object PolynomialLeafFLineStyles
PolynomialTreeSplitCriterion
fun interface PolynomialTreeSplitCriterion
| Declaration | Summary |
|---|---|
fun chooseSplit(context: PolynomialTreeSplitContext): Int? |
PolynomialTreeStopCriterion
fun interface PolynomialTreeStopCriterion
| Declaration | Summary |
|---|---|
fun shouldStop(context: PolynomialTreeStopContext): Boolean |
PolynomialTreeClaimCriterion
fun interface PolynomialTreeClaimCriterion
| Declaration | Summary |
|---|---|
fun accepts(context: PolynomialTreeClaimContext): Boolean |
PolynomialTreeSplitContext
| Declaration | Summary |
|---|---|
val points: List<Vec3> |
|
val depth: Int |
|
val supportIndices: IntArray |
|
val supportRange: IntRange |
|
val pointErrors: DoubleArray |
|
val nearestT: DoubleArray |
|
val rmsError: Double |
|
val meanError: Double |
|
val maxError: Double |
|
val maxErrorLocalIndex: Int |
|
val size: Int |
|
fun middleLocalIndex(): Int |
|
fun globalIndexAt(localIndex: Int): Int |
|
fun localIndexOf(globalPointIndex: Int): Int |
PolynomialTreeStopContext
| Declaration | Summary |
|---|---|
val split: PolynomialTreeSplitContext |
|
val config: PolynomialTreeFitConfig |
PolynomialTreeClaimContext
PolynomialTreeClaimConfig
PolynomialTreeFitConfig
PolynomialTreeSplitStrategies
object PolynomialTreeSplitStrategies
PolynomialTreeStopCriteria
object PolynomialTreeStopCriteria
| Declaration | Summary |
|---|---|
val byThresholds |
|
fun maxErrorOnly(maxError: Double): PolynomialTreeStopCriterion |
|
fun rmsOnly(rmsError: Double): PolynomialTreeStopCriterion |
|
val never |
PolynomialTreeClaimCriteria
object PolynomialTreeClaimCriteria
PolynomialTreeIncrementalSummary
| Declaration | Summary |
|---|---|
val pointCount: Int |
|
val overallRmsError: Double |
|
val overallMeanError: Double |
|
val overallMaxError: Double |
PolynomialTreeIncrementalUpdater
Mutable updater that keeps a fixed tree topology and reusable buffers while refitting against new points.
contours.kt
Marching-squares iso-contours for 2D scalar fields, plus the scalar field most callers want to contour.
A field is just a ScalarField2D, (Double, Double) -> Double. pointEdgeScalarField2D builds
one from points and edges — or from a 2D FLine — as the union of weighted discs and capsules,
signed so that iso = 0.0 is the tube boundary; it re-reads the supplied point list on every
sample, so mutating those Vec2s moves the field live. generateIsoContours samples a field on
an nx by ny grid and returns stitched polylines, generateIsoContoursWithStep takes a target
cell size instead, and the FLine overloads choose the sampling window from the line's 2D bounds
grown by the tube radius.
smoothIsoContours and generateSmoothIsoContours fit cubic splines through the polylines,
treating a contour that ends on the sampling bounds as open and the rest as closed loops.
Varying the radius along the line — weights
A tube of one radius is pointRadius/edgeRadius. To make it taper, weight the primitives:
every function that builds a point/edge field takes a weight callback rather than a list of
weights. On an explicit graph that is PointEdgeWeight, (index, point) -> Double, indexed the
same way as the points you passed in. On an FLine it is FLinePointWeight,
(nodeIndex, point) -> Double, where nodeIndex is the fractional drawing-instruction index of
the flattened point — integral at a moveTo/lineTo, and interpolated across a cubicTo, which
is the same quantity FLine.uniform_linearize hands its callback and linesOut uses to look up
per-node colour. FLine.isoWeights is the stock implementation: it reads a per-node property and
interpolates between the nodes that set it.
It is a callback rather than a list of weights because on the FLine overloads there is no count
a caller could size a list against: the points and edges belong to pointEdgeGraph2D's internal
flattening, and its edge count depends on the line's geometry as well as its instructions.
An edge's weight is always the minimum of its two endpoints'. A capsule has one radius for its whole length and so cannot taper: the taper comes from the union of densely sampled discs, and the edges only bridge the gaps between them. Taking the maximum would let a capsule bulge past the disc envelope exactly where the weight changes fastest; the minimum fills the gap without ever fattening it.
The sign convention
A grid corner is inside iff its sample is strictly below iso; a sample exactly at iso
counts as outside. The test is two-valued rather than three-way, because under a three-way test —
below at one end of an edge, above at the other, so "equal" is neither — a sample sitting
exactly on iso suppresses the crossing on every edge out of its corner, and the contour
vanishes or shatters into arcs. Samples land exactly on iso for ordinary input: any grid that
samples a linear field at its own zero level puts a whole column there, and a unit circle
contoured at iso = 0 passes through a grid corner at plenty of resolutions, nx = ny = 16 over
[-2, 2] among them.
A crossing that lands on a corner is pushed off it by ISO_CROSSING_EPSILON, which moves it by
at most a thousandth of the cell edge it lies on — an analytic bound, since it is a coerceIn on
the interpolation parameter. marchingCubes in mesh_sdf.kt is the 3D marcher and uses the same
convention for the same reason.
The saddle cell
A cell with four crossings is resolved by the asymptotic decider: the sign of the cell's
bilinear saddle value decides which diagonal pair of corners the centre of the cell belongs to.
It is a pure function of the cell's own four corner values, so two cells sharing an edge cannot
disagree about the crossing on it, which is what the stitcher needs. The ambiguity itself does
not go away: when the saddle value is exactly iso the two pairings are equally correct and the
decider has nothing to go on. SADDLE_TIE_RELATIVE_EPSILON settles that case in the code rather
than leaving it to the direction a multiplication rounded — which matters because every bilinear
field lands there exactly.
Stitching and winding
Each crossing is computed once and keyed by its grid edge, as marchingCubes keys its shared
vertices, so segments are chained by key in time linear in their number. Every segment is
oriented with the inside on its left, so a closed contour comes back counter-clockwise around an
inside region and clockwise around a hole in one: summed signed areas give the inside's true area.
src/jvmMain/kotlin/afield/geometry/path/contours.kt — read the source
Used in: Points into structure — Voronoi, Apollonius, contours and clusters
Top-level functions
Top-level values
| Declaration | Summary |
|---|---|
const val FLINE_ISO_WEIGHT_KEY |
Default property name that FLine.isoWeights reads a per-node weight from. |
var FLine.Node.isoWeight: Double? |
Per-node tube weight — a multiplier on pointRadius at this instruction, which FLine.isoWeights interpolates into an FLinePointWeight. null, the default, means "this node is not a keyframe". |
var FLine.isoWeight: Double? |
Line-wide fallback weight, used for every node when no node sets an isoWeight of its own. |
PointEdgeGraph2D
A 2D FLine flattened into the point/edge graph that pointEdgeScalarField2D contours, plus the provenance of each point.
| Declaration | Summary |
|---|---|
val points: List<Vec2> |
|
val edges: List<Pair<Int, Int>> |
|
val nodeIndex: List<Double> |
PointEdgeWeight
typealias PointEdgeWeight = (index: Int, point: Vec2) -> Double
Per-point weight for a field built from an explicit point/edge graph — a multiplier on pointRadius for the disc around pointsindex``.
FLinePointWeight
typealias FLinePointWeight = (nodeIndex: Double, point: Vec2) -> Double
Per-point weight for a field built from an FLine — a multiplier on pointRadius for the disc around one flattened sample of the line.
ScalarField2D
typealias ScalarField2D = (Double, Double) -> Double
Scalar field f(x, y) -> value. You can use a lambda or method reference, e.g. rbf::interpolate2D.
FLinesAndJavaShapes.kt
Converts FLines to and from java.awt.geom shapes, and walks them by distance with
FLinesAndJavaShapes.Cursor.
The round trip through java.awt.geom (geometry only, not attributes) gives 2D FLines
Java2D's flattening, stroking, insetting and outsetting. Cursor moves along a
line by arc length or by node parameter, reports the position, tangent and curvature there, and
splits the line. CubicSegment2 and CubicSegment3 hold the per-segment cubic maths: roots,
inflections, bounding boxes and intersections.
src/jvmMain/kotlin/afield/geometry/path/java2d/FLinesAndJavaShapes.kt — read the source
FLinesAndJavaShapes
Utility classes for manipulating and traversing FLines.
| Declaration | Summary |
|---|---|
fun fromFourPoints(p0: Vec3, u: Float, p1: Vec3, v: Float, p2: Vec3, p3: Vec3): Pair<Vec3, Vec3>? |
returns the c1, c2 control points for a cubic segement to go through p0@0, p1@u, p2@v and p3@ t=1.0 |
fun segment(f: FLine): List<FLine> |
segments this along .moveTo's |
fun positions(f: FLine, flatness: Float): List<Vec3> |
A list of low curvature positions out of a FLine |
class Cursor(on: FLine, private val tol: Float) |
A class for navigating FLines either by distance "D" or by node "T". |
class CubicSegment3 |
|
inner class CubicSegment2 |
helper class for a 2d cubic segment, for the things that we can only reasonably do in 2d (like intersections). |
ManifoldHeaderCheck.kt
Checks every FFM descriptor ManifoldNative binds against the prototypes in the installed
manifoldc.h, and against the structs and enums in its types.h. A descriptor has to declare the
same number of arguments, and at every position, and for the return, the same kind of value:
pointer, double, float, 32- or 64-bit integer (size_t is 64), void, or a struct passed by
value of the same size.
A descriptor that disagrees with the header still links and still returns a number, so this is
the only thing that notices one. ManifoldSmokeTest runs it first.
Pointer types are not compared, since FFM has one ADDRESS. The two places where the pointee
matters are handled once in the wrapper rather than at each call: Solid.slice and
Solid.project convert the ManifoldPolygons that manifold_slice and manifold_project
return into a Section in one place, and Solid.raycast turns the C API's segment fraction
into a distance in the solid's units.
src/jvmMain/kotlin/afield/geometry/solid/manifold/ManifoldHeaderCheck.kt — read the source
ManifoldHeaderCheck
Every bound FFM descriptor against the installed manifoldc.h; check returns what disagrees.
| Declaration | Summary |
|---|---|
data class Prototype(val name: String, val returns: String, val arguments: List<String>) |
One C prototype: its return type and argument types, as written. |
fun headerCandidates(): List<File> |
Where manifoldc.h is looked for; AFIELD_MANIFOLD_HEADER names it directly. |
fun header(): File? |
The first of headerCandidates that exists, or null when none of them does. |
fun check(header: File): List<String> |
Every problem with the bound descriptors, or empty. Binds every handle ManifoldNative declares first. |
ManifoldSmokeTest.kt
src/jvmMain/kotlin/afield/geometry/solid/manifold/ManifoldSmokeTest.kt — read the source
ManifoldSmokeTest
The Manifold binding, end to end, against geometry whose answers are known in closed form.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
PlanarContact.kt
Finds where two parts touch on a shared plane, and audits apertures cut through that contact, in double precision.
planarContact intersects the boundary triangles of two SolidPlanarMeshes lying on a ContactPlane and
returns a ContactPatch; planarContacts discovers the shared planes itself. auditApertures compares
removed regions against an expected boundary and reports mismatch areas against GeometryTolerances.
polylineTube builds a Solid tube of cylinders and spheres along a polyline.
src/jvmMain/kotlin/afield/geometry/solid/manifold/PlanarContact.kt — read the source
Used in: Solids that cannot fail: Manifold in Field
Top-level functions
| Declaration | Summary |
|---|---|
fun planarContact(a: SolidPlanarMesh, b: SolidPlanarMesh, plane: ContactPlane, tolerances: GeometryTolerances = GeometryTolerances()): ContactPatch |
|
fun auditApertures(stockA: SolidPlanarMesh, stockB: SolidPlanarMesh, removedA: SolidPlanarMesh, removedB: SolidPlanarMesh, expectedBoundary: SolidPlanarMesh, plane: ContactPlane, tolerances: GeometryTolerances = GeometryTolerances(), minimumContactMargin: Double = 0.0): ApertureAudit |
Compare actual removed boundaries against an explicitly supplied expected aperture boundary on the same plane. |
fun planarContacts(a: SolidPlanarMesh, b: SolidPlanarMesh, tolerances: GeometryTolerances = GeometryTolerances()): List<ContactPatch> |
Discover boundary planes shared by two parts. Coincident triangles in remote parts produce no patch. |
fun polylineTube(points: List<Point3D>, radius: Double, radialSegments: Int = 48): Solid |
Union of polygonal cylinders and spheres along a saved polyline; not a toolpath or exact circular sweep. |
ContactPlane
data class ContactPlane(val origin: Point3D, val normal: Point3D)
A finite physical contact is extracted from boundary triangles on this plane, not from an infinite cut.
ContactPatch
| Declaration | Summary |
|---|---|
val rings: List<List<Pair<Double,Double>>>, val area: Double) |
ApertureAudit
| Declaration | Summary |
|---|---|
val expected: ContactPatch, val mismatchArea: Double, val expectedMismatchArea: Double |
|
val tolerances: GeometryTolerances, val outsideContactArea: Double |
|
val insufficientMarginArea: Double |
|
val passed: Boolean |
GeometryTolerances
Length, area and volume thresholds in caller-chosen units.
Section.kt
2D regions with holes, from Manifold's CrossSection: boolean operations, offsetting with a choice of
JoinType, and extrusion into a Solid.
FLine.toSection converts drawn outlines into a Section, and Section.toFLines converts back for display.
src/jvmMain/kotlin/afield/geometry/solid/manifold/Section.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun FLine.toSection(sampleDistance: Double = 1.0, fillRule: FillRule = FillRule.POSITIVE): Section |
Section.ofFLine, as an extension, for reading left to right off an existing line. |
fun List<FLine>.toSection(sampleDistance: Double = 1.0, fillRule: FillRule = FillRule.POSITIVE): Section |
Section.ofFLines, as an extension. |
fun Section.toVec3s(z: Double = 0.0): List<Vec3> |
The 3D points of a Section's contours, on the z = 0 plane — for Solid.hullOf and friends. |
Section
class Section internal constructor(handle: MemorySegment) : AutoCloseable
Section — a 2D region with holes, that you can offset. Manifold's CrossSection, which is Clipper2 underneath.
| Declaration | Summary |
|---|---|
val isClosed: Boolean |
|
operator fun plus(other: Section): Section |
|
operator fun minus(other: Section): Section |
|
infix fun and(other: Section): Section |
|
infix fun or(other: Section): Section |
|
fun offset(delta: Double, joinType: JoinType = JoinType.ROUND, miterLimit: Double = 2.0, circularSegments: Int = 0): Section |
Grow (or shrink, for a negative delta) the region by delta, as a true parallel curve. |
fun translated(x: Double, y: Double): Section |
|
fun rotated(degrees: Double): Section |
Rotate about the origin, in degrees. |
fun scaled(x: Double, y: Double): Section |
|
fun scaled(factor: Double): Section |
|
fun mirrored(normalX: Double, normalY: Double): Section |
Mirror through the line through the origin with this normal. |
fun simplified(epsilon: Double): Section |
Drop vertices that are within epsilon of the line between their neighbours — the Douglas-Peucker-ish cleanup for a contour that came out of a Solid.slice or a dense offset. |
fun hull(): Section |
The convex hull of the region. |
fun decompose(): List<Section> |
Split into separate regions, largest first by area. Holes stay with the outline that owns them. |
fun extruded(height: Double, slices: Int = 0, twistDegrees: Double = 0.0, scaleTop: Double = 1.0, scaleTopY: Double = scaleTop): Solid |
Extrude along +Z, optionally twisting and tapering on the way — a lathe's opposite number. |
fun revolved(segments: Int = 0, revolveDegrees: Double = 360.0): Solid |
A lathe: the cross-section's x becomes a radius and its y becomes the solid's z, so the resulting solid's axis of symmetry is Z. |
val area: Double |
Enclosed area, holes subtracted. |
val vertexCount: Long |
|
val contourCount: Long |
Contours, outlines and holes together. An annulus is 2. |
val isEmpty: Boolean |
|
val bounds: Pair<Vec2, Vec2> |
The bounding rectangle, as (minX, minY) to (maxX, maxY). |
fun toPolygons(): List<List<Vec2>> |
The contours, as point lists. Outlines are counter-clockwise, holes clockwise — see the winding note on Section. |
fun toFLines(z: Double = 0.0): List<FLine> |
The contours as closed FLines, ready to display(), thicken, animate or export — the way back into everything Field already does with a line. |
fun toFLine(z: Double = 0.0): FLine |
Every contour merged into one FLine with a sub-path each — the single-value form of toFLines. |
fun copy(): Section |
An independent copy — the one thing that survives its enclosing Solid.scope. |
override fun close() |
|
override fun toString(): String |
Solid.kt
Closed 3D volumes from the Manifold library: primitives, transforms, union, difference and intersection, with results that stay closed manifolds.
Solid is the type a box uses; TriMesh.toSolid brings an existing mesh in, and Solid.toTriMesh
hands the result to Field's mesh display. The native library is optional: ManifoldLibrary reports
whether it is installed.
src/jvmMain/kotlin/afield/geometry/solid/manifold/Solid.kt — read the source
Used in: Solids that cannot fail: Manifold in Field
Top-level functions
| Declaration | Summary |
|---|---|
fun TriMesh.toSolid(check: Boolean = true, weldTolerance: Double? = null): Solid |
Read a TriMesh into Manifold, so it can be cut. |
fun TriMesh.isValidSolid(): Boolean |
true when Manifold agrees this mesh is a closed solid and it is not inside out (a negative total volume) — the cheap pre-flight for toSolid, and false exactly when toSolid() would throw. |
Solid
class Solid internal constructor(handle: MemorySegment) : AutoCloseable, ToHTML
Solid — a guaranteed-closed 3D volume you can subtract, union and intersect without thinking about whether the answer is still a surface. Field's binding to Manifold, through Panama/FFM.
| Declaration | Summary |
|---|---|
fun <T> sampleSlices(n: Int, fillRule: FillRule = FillRule.POSITIVE, sample: (z: Double, section: Section) -> T): List<T> |
Sample n evenly spaced horizontal layer midpoints, from the bottom bound upward. Each Section is closed after sample returns, including when it throws. Return copied values such as area or polygons; a native section retained from the callback will be closed. fillRule has the same meaning as in slice. Integration weights remain the caller's choice. |
val isClosed: Boolean |
true once close (or an enclosing scope) has released the native volume. |
operator fun plus(other: Solid): Solid |
Union — the volume in either solid. |
operator fun minus(other: Solid): Solid |
Difference — this solid with other removed. |
infix fun and(other: Solid): Solid |
Intersection — only the volume in both solids. |
infix fun or(other: Solid): Solid |
Alias for plus, for the reading where a union is an or. |
fun minkowskiSum(other: Solid): Solid |
The Minkowski sum — every point of this solid displaced by every point of other. Sweeping a small sphere over a part is how you round all of its edges at once: |
fun minkowskiDifference(other: Solid): Solid |
The Minkowski difference — minkowskiSum's erosion counterpart, for shrinking by a shape. |
fun splitByPlane(normalX: Double, normalY: Double, normalZ: Double, offset: Double = 0.0): Pair<Solid, Solid> |
Cut by a plane and keep both halves: first is the side the normal points toward, second is the rest. |
fun split(other: Solid): Pair<Solid, Solid> |
Split by another solid: first is the part inside other, second the part outside. |
fun trimByPlane(normalX: Double, normalY: Double, normalZ: Double, offset: Double = 0.0): Solid |
Keep only the half the normal points toward — splitByPlane's first, without building the offcut. The cheap way to open a solid up and look inside it. |
fun decompose(): List<Solid> |
Split into connected components, largest first by volume. |
fun translated(x: Double, y: Double, z: Double): Solid |
|
fun translated(v: Vec3): Solid |
|
fun rotated(xDegrees: Double, yDegrees: Double, zDegrees: Double): Solid |
Rotate by Euler angles in degrees, applied X then Y then Z — Manifold's own convention. |
fun scaled(x: Double, y: Double, z: Double): Solid |
|
fun scaled(factor: Double): Solid |
|
fun mirrored(normalX: Double, normalY: Double, normalZ: Double): Solid |
Mirror through the plane with this normal, through the origin. |
fun transformed(c0x: Double, c0y: Double, c0z: Double, c1x: Double, c1y: Double, c1z: Double, c2x: Double, c2y: Double, c2z: Double, tx: Double, ty: Double, tz: Double): Solid |
Apply a 3x4 affine transform, given column-major as three basis columns and a translation — the same 12 numbers Manifold takes. |
fun warped(map: (Double, Double, Double) -> Triple<Double, Double, Double>): Solid |
Move every vertex by a Kotlin function — a bend, a twist, a taper — without re-meshing. |
fun refined(n: Int): Solid |
Subdivide every triangle into n² smaller ones, so a later warped or smoothOut has somewhere to act. Triangle count grows as the square of n. |
fun refinedToLength(length: Double): Solid |
Subdivide until no edge is longer than length — the resolution-independent refined. |
fun refinedToTolerance(tolerance: Double): Solid |
Subdivide until the mesh is within tolerance of its own smooth interpolation. |
fun smoothOut(minSharpAngle: Double = 60.0, minSmoothness: Double = 0.0): Solid |
Round the mesh off, treating edges sharper than minSharpAngle degrees as creases to keep. |
fun simplified(tolerance: Double): Solid |
Collapse edges shorter than tolerance, keeping the solid valid. |
fun withTolerance(tolerance: Double): Solid |
Set the ε Manifold treats as "the same point" for later operations. |
fun hull(): Solid |
The convex hull of this solid. |
fun slice(height: Double = 0.0, fillRule: FillRule = FillRule.POSITIVE): Section |
The cross section at z = height — a real slice, not a silhouette. |
fun project(fillRule: FillRule = FillRule.POSITIVE): Section |
The shadow of the whole solid on the XY plane — the union of every slice. |
val volume: Double |
Enclosed volume. Exact for the primitives — Solid.cube(2,3,4).volume is 24.0, not 23.999997 — because Manifold measures the solid rather than summing float triangles. |
val surfaceArea: Double |
Total surface area. Exact for the primitives, for the same reason as volume. |
val vertexCount: Long |
|
val edgeCount: Long |
|
val triangleCount: Long |
|
val propertyCount: Long |
Per-vertex property channels beyond position, if any were carried through. |
val genus: Int |
Topological genus — the number of holes through the solid. A block is 0, a torus 1, a plate with three bores 3. |
val isEmpty: Boolean |
true for a solid with no volume — including the result of a failed operation. |
val status: ManifoldStatus |
Why the last operation could not produce a valid solid, or ManifoldStatus.NO_ERROR. |
val tolerance: Double |
The ε this solid treats as "the same point". |
val epsilon: Double |
Manifold's own precision estimate for this solid's coordinate range. |
val bounds: AxisAlignedBoundingBox3D |
The axis-aligned bounds, as the same type Field's mesh family and afield.geometry.mesh.marchingCubes use. |
fun minGap(other: Solid, searchLength: Double): Double |
The smallest gap between this solid's surface and other's, searching out to searchLength. |
fun raycast(originX: Double, originY: Double, originZ: Double, dirX: Double, dirY: Double, dirZ: Double, length: Double = 1e4): List<RayHit> |
Every intersection of a ray with this solid's surface, near to far. |
fun contains(x: Double, y: Double, z: Double): Boolean |
true when x, y, z is inside the solid, by raycast's parity test. |
fun checked(what: String = "solid"): Solid |
Throw if this solid is not valid, naming the status. Put it at the end of a boolean chain, where an invalid result would otherwise travel on as a 0.0 volume. |
fun toPlanarMesh(): SolidPlanarMesh |
Export the native polygon-face topology without narrowing positions to floats. |
fun toTriMesh(normals: Boolean = false, minSharpAngle: Double = 60.0): TriMesh |
The TriMesh Field's mesh family, visualizers, FLine bridge and OBJ/STL writers all take. |
override fun toHTML(): String |
The inline card: toTriMesh's, so a Solid draws like every other mesh in Field, plus the numbers only a solid has. |
override fun toString(): String |
|
override fun close() |
Release the native volume now. |
fun copy(): Solid |
An independent copy — the one thing that survives its enclosing scope. |
SolidPlanarMesh.kt
A double-precision export of a Solid's boundary that keeps Manifold's planar-face provenance.
Solid.toPlanarMesh produces a SolidPlanarMesh; each SolidPlanarFace groups the triangles of one planar
face. SolidPlanarMesh.toSolid rebuilds a Solid from it, and SolidPlanarMesh.toTriMesh is the lossy
float conversion for rendering.
src/jvmMain/kotlin/afield/geometry/solid/manifold/SolidPlanarMesh.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun SolidPlanarMesh.toSolid(check: Boolean = true): Solid |
Rebuild a native solid without the Float32 narrowing of TriMesh.toSolid. |
SolidPlanarFace
One polygonal face in Manifold's full-precision mesh export.
| Declaration | Summary |
|---|---|
val runIndex: Int |
|
val originalId: Int? |
|
val faceId: Long |
|
val triangles: IntArray |
Triangle ordinals in SolidPlanarMesh.indices, after degenerate index triangles are removed. |
SolidPlanarMesh
Manifold's full-precision boundary mesh: double-precision positions plus native polygonal-face provenance.
| Declaration | Summary |
|---|---|
val positions: DoubleArray |
|
val indices: IntArray |
|
val triangleFaces: IntArray |
|
val faces: List<SolidPlanarFace> |
|
val nativeTolerance: Double |
|
val vertexCount: Int |
|
val triangleCount: Int |
|
fun toTriMesh(): TriMesh |
SolidCraft.kt
An optional bridge to the SolidCraft B-rep document engine, whose model stays in native Rust code.
SolidCraftLibrary locates the native library and reports whether it is available. SolidCraftDesign.open
loads a design, SolidCraftDesign.setParameter edits it, and SolidCraftDesign.mesh copies a body out as a
SolidCraftMesh for display.
src/jvmMain/kotlin/afield/geometry/solid/solidcraft/SolidCraft.kt — read the source
Used in: SolidCraft: edit the design, then see the part
SolidCraftLibrary
Optional SolidCraft B-rep/document engine. Its model remains in Rust; Field copies display meshes.
| Declaration | Summary |
|---|---|
val searchedPaths: List<Path> |
|
val resolvedPath: Path? |
|
val isAvailable: Boolean |
|
fun describe(): String |
SolidCraftMesh
data class SolidCraftMesh(val positions: DoubleArray, val indices: IntArray, val faceIds: IntArray)
Double-precision SolidCraft tessellation plus B-rep face IDs, copied out of Rust memory.
| Declaration | Summary |
|---|---|
fun toTriMesh(): TriMesh |
SolidCraftDesign
class SolidCraftDesign private constructor(private var handle: MemorySegment) : AutoCloseable
A saved SolidCraft design with an incremental evaluator. Close it when finished.
| Declaration | Summary |
|---|---|
fun summary(): JSONObject |
|
fun setParameter(name: String, expression: String) |
An invalid expression or failed feature leaves the previous design and mesh intact. |
fun mesh(bodyIndex: Int = 0, tolerance: Double = 0.05): SolidCraftMesh |
|
fun exportStep(): ByteArray |
|
fun exportStl(): ByteArray |
|
fun saveDesign(): ByteArray |
|
override fun close() |
SolidCraftBuilder.kt
Writing a SolidCraft part in Kotlin instead of drawing it: sketches, the features that turn them into solids and surfaces, and named parameters that stay editable afterwards.
solidCraft opens a SolidCraftBuilder, whose calls accumulate into SolidCraft's own design
document, the same JSON a saved design holds. So a part written here is evaluated by the engine
that opens one from disk, and everything downstream treats the two alike.
SolidCraftBuilder.json is that document and SolidCraftBuilder.open evaluates it, giving back
a SolidCraftDesign to mesh, re-parameterise or export.
Only the evaluation needs the native bridge, since the document is assembled as plain JSON. A
box can therefore build and inspect a design on a machine where SolidCraft was never compiled,
and SolidCraftLibrary says whether this one has it.
Lengths and angles are SolidCraft expressions rather than numbers: "12" in the design's units,
a quantity such as "30 deg", or a formula over the design's parameters such as "width/2".
That is what keeps a finished part adjustable, through SolidCraftDesign.setParameter, without
rebuilding it here.
src/jvmMain/kotlin/afield/geometry/solid/solidcraft/SolidCraftBuilder.kt — read the source
Used in: SolidCraft: edit the design, then see the part
Top-level functions
| Declaration | Summary |
|---|---|
fun solidCraft(name: String = "Untitled", units: String = "mm", build: SolidCraftBuilder.() -> Unit): SolidCraftBuilder |
Builds a SolidCraft design from Kotlin: sketches of points and curves (lines, arcs, circles, splines, 3D wires) and the features that turn them into bodies and surfaces — extrude, revolve, sweep with guide rails, loft, pipe, and the surface sweep, loft, revolve and boundary patch. |
SolidCraftSketchRef
A sketch feature already added to a SolidCraftBuilder; pass it to the features that use it.
SolidCraftFeatureRef
class SolidCraftFeatureRef internal constructor(val id: Long, val name: String)
A feature added to a SolidCraftBuilder, by the id and name SolidCraft reports it under.
SolidCraftRailFit
enum class SolidCraftRailFit(internal val json: String)
How a sweep's guide rails reshape its profile: evenly (SCALE), or only across to the rail.
Constants: SCALE, STRETCH.
SolidCraftOperation
enum class SolidCraftOperation(internal val json: String)
How a joining feature meets bodies already in the design.
Constants: NEW_BODY, JOIN, CUT, INTERSECT.
SolidCraftContinuity
enum class SolidCraftContinuity(internal val json: String)
How much of a neighbouring surface a patch or loft end repeats: position, tangent, curvature, its change.
Constants: G0, G1, G2, G3.
SolidCraftBuilder
class SolidCraftBuilder internal constructor(private val name: String, private val units: String)
| Declaration | Summary |
|---|---|
fun param(name: String, expression: String, unit: String = inferUnit(expression)) |
A named, editable parameter: param("width", "40 mm"). unit is inferred from a plain quantity. |
fun sketch(plane: Any = "XY", draw: SolidCraftSketch.() -> Unit): SolidCraftSketchRef |
Adds a sketch on plane ("XY", "XZ" or "YZ", or offsetPlane), drawn by draw. Coordinates are the sketch's own 2D ones; wires are 3D, in world coordinates. |
fun offsetPlane(base: String, distance: String): JSONObject |
base ("XY"…) moved along its normal by distance, for stacking loft sections. |
fun planeSquareTo(line: FLine): JSONObject |
The plane through the start of line, square to it there, with the sketch origin on the line. A profile drawn about (0, 0) on it sweeps along wire(line) as a true tube, because a sweep keeps the angle the profile was drawn at to the path's start. |
fun extrude(sketch: SolidCraftSketchRef, distance: String, operation: SolidCraftOperation = SolidCraftOperation.NEW_BODY, symmetric: Boolean = false, taper: String? = null) |
Extrudes every closed profile of sketch by distance along its normal. |
fun revolve(sketch: SolidCraftSketchRef, axis: String = "y", angle: String = "360 deg", operation: SolidCraftOperation = SolidCraftOperation.NEW_BODY) |
Revolves every closed profile of sketch about its sketch axis ("x" or "y"). |
fun sweep(profile: SolidCraftSketchRef, path: SolidCraftSketchRef, pathCurves: List<String> = path.chain(), rails: List<SolidCraftSketchRef> = emptyList(), railFit: SolidCraftRailFit = SolidCraftRailFit.SCALE, twist: String? = null, scale: String? = null, operation: SolidCraftOperation = SolidCraftOperation.NEW_BODY) |
Sweeps the closed profiles of profile along path (its curves and wires, as one chain unless pathCurves picks some). Each of rails is a sketch whose chain the profile's far side follows, reshaping it by railFit. Without rails, twist turns the profile along the way and scale grows it to the end; with rails, the rails decide both. |
fun loft(sections: List<SolidCraftSketchRef>, operation: SolidCraftOperation = SolidCraftOperation.NEW_BODY) |
A solid through the closed profiles of sections, in order (each sketch one section). |
fun pipe(path: SolidCraftSketchRef, diameter: String, wall: String? = null, pathCurves: List<String> = path.chain(), operation: SolidCraftOperation = SolidCraftOperation.NEW_BODY) |
A round tube of diameter along path; wall makes it hollow. |
fun fillet(radius: String, vararg edgesNear: DoubleArray) |
A fillet of radius on the edges nearest each of edgesNear (world points). |
fun surfaceSweep(profile: SolidCraftSketchRef, path: SolidCraftSketchRef, profileCurves: List<String> = profile.curves, pathCurves: List<String> = path.chain(), rails: List<SolidCraftSketchRef> = emptyList(), railFit: SolidCraftRailFit = SolidCraftRailFit.SCALE, twist: String? = null, scale: String? = null) |
A surface (open sheet body) swept from the curves of profile along path; rails as in sweep. |
fun surfaceLoft(sections: List<SolidCraftSketchRef>, start: SolidCraftContinuity = SolidCraftContinuity.G0, end: SolidCraftContinuity = SolidCraftContinuity.G0) |
A surface through one curve chain per section sketch; start/end set how it leaves the first and last. |
fun surfaceRevolve(profile: SolidCraftSketchRef, axis: String = "y", angle: String = "360 deg", curves: List<String> = profile.curves) |
A surface of revolution of the curves of profile about its sketch axis. |
fun surfaceBoundary(sketch: SolidCraftSketchRef, curves: List<String> = sketch.curves, orders: List<SolidCraftContinuity> = emptyList(), crown: Double = 0.0, samples: Int = 16) |
One patch through the boundary curves of sketch, given in loop order (each ends where the next begins). Two curves give the bridge between them; three or four give the patch their sides span, which takes no crown. Draw both curves in the same direction: the bridge joins start to start, so opposed curves give a patch folded over itself. orders is the continuity across each boundary; crown fills the middle (0 plain, up to 3 fuller, down to -0.9 flatter). |
fun thicken(bodies: List<String>, thickness: String, symmetric: Boolean = false) |
Thickens surface bodies (by name, as SolidCraftDesign.summary lists them) into solids. |
fun feature(type: String, fields: JSONObject) |
Any other SolidCraft feature, as its JSON fields beside "type" (snake_case, as in FeatureKind): feature("shell", JSONObject().put("faces", ...).put("thickness", "2")). |
fun json(): JSONObject |
The design as SolidCraft's JSON document. |
fun open(): SolidCraftDesign |
Evaluates the design; a feature that fails throws with SolidCraft's message. |
SolidCraftSketch
class SolidCraftSketch internal constructor()
The points and curves of one sketch. Each drawing call returns the new curve's id, which features take to pick curves (pathCurves, surfaceBoundary(curves = ...)). Endpoints that coincide are shared, so consecutive lines and splines form one connected chain.
| Declaration | Summary |
|---|---|
fun line(x0: Double, y0: Double, x1: Double, y1: Double): String |
|
fun circle(cx: Double, cy: Double, r: Double): String |
|
fun arc(cx: Double, cy: Double, x0: Double, y0: Double, x1: Double, y1: Double): String |
An arc about (cx, cy) running counter-clockwise from (x0, y0) to (x1, y1). |
fun spline(vararg xy: Double, periodic: Boolean = false, degree: Int = 3): String |
A smooth spline through xy (x0, y0, x1, y1, …); periodic closes it. |
fun rectangle(x0: Double, y0: Double, x1: Double, y1: Double): List<String> |
Four lines around the rectangle (x0, y0)–(x1, y1). |
fun polygon(vararg xy: Double): List<String> |
A closed polyline through (x, y) pairs. |
fun wire(vararg xyz: Double): String |
A 3D curve in world coordinates through xyz (x0, y0, z0, …), usable as a sweep path, rail or loft section. |
fun wire(line: FLine, spacing: Float = 1f): String |
line as a 3D wire, sampled every spacing along its length (its own coordinates, z included). |
SolidCraftBuilderSmokeTest.kt
src/jvmMain/kotlin/afield/geometry/solid/solidcraft/SolidCraftBuilderSmokeTest.kt — read the source
SolidCraftBuilderSmokeTest
object SolidCraftBuilderSmokeTest
Run with ./gradlew smokeSolidCraft: builds one design per construction feature and checks its geometry.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
SolidCraftSmokeTest.kt
src/jvmMain/kotlin/afield/geometry/solid/solidcraft/SolidCraftSmokeTest.kt — read the source
SolidCraftSmokeTest
Run with ./gradlew smokeSolidCraft after opting into the native build.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
bspot.kt
One-to-one matching between two equally sized 2D point clouds, at a million points a side, using BSP-OT: every source point gets exactly one target and every target is used exactly once, with the total squared travel distance kept small.
The exact answer to that problem is an assignment problem, and the Hungarian method that
assignment in transport.kt solves it with is O(n³) time over an n × n cost matrix. At a
thousand points that is fine. At a million the matrix alone is four terabytes. BSP-OT gives up
provable optimality to get to O(n log n):
- Choose a direction. Split each cloud at its own median along it, so both left halves hold the same number of points, and so do both right halves.
- Recurse, left with left and right with right, with a new direction each time. A leaf of one point pairs its source with its target; a leaf of two picks the cheaper of the two pairings.
- That is one candidate bijection. Make several and merge each new one into the best so far: the union of two permutations falls apart into disjoint alternating cycles, and on each cycle you keep whichever orientation is cheaper. Then try two-point swaps along the candidate's edges.
The direction in step 1 is what BspOt2D.Splits chooses. The default, as in the reference
implementation, fits a Gaussian to each of the two subsets and splits along an eigenvector of the
linear map that carries one Gaussian onto the other, picking one of the two at random per node.
When the subsets differ in shape that axis is the one along which they need to move, and it
about halves the excess cost of random directions at eight trees. Below 50 points it stops fitting and splits
perpendicular to the parent.
The counts are equal by construction, not because the two clouds occupy the same cells, so clouds of very different density still match completely.
val result = matchPoints(from, to) // two List<Vec2> of the same size
val destination = result.reorder(to) // destination[i] is where from[i] goes
val halfway = from.indices.map { from[it] + (destination[it] - from[it]) * 0.5f }
For raw FloatArrays, call BspOt2D.match directly. A previous permutation can be passed as
initial and is improved rather than replaced, which suits matching successive frames of a
simulation.
Cost, measured
On an M1 Max with the default four workers and eight trees, a million points a side takes 3 to
4.5 s for two uniform clouds, and 5.7 s from a uniform square onto three discs (7.4 s with random
splits, which reach a 5% higher cost). Merging is sequential. Array storage is about 69 + 60 P
bytes a point for P workers, roughly 309 MB at a million points and four workers. The SIMD
paths (projection, and cost evaluation through a gather) gave no measurable end-to-end gain over
simd = false when this was first measured; partitioning, cycle walking and the swaps are
scalar.
What it does not give you
- No optimality guarantee, and further from optimal than the paper. From a uniform square onto three discs, 1000 points, the total cost is 5.5% above the exact Hungarian answer with 8 trees and 2.9% with 32. Two independent uniform clouds are the hard case: every node's two subsets have nearly the same Gaussian, so the transport map is close to the identity and its eigenvectors are noise. At 400 points that lands 26% above exact with 64 trees, where random splits reach 16%. Leaving those clouds unmatched is 120 times worse. The paper reports typically under 1% with 64 candidates; it ranks all candidates before merging, which this version, merging as it goes in bounded memory, does not. The bijection is good enough for a morph to read as a morph; it is not a solver for the exact assignment.
- 2D only, equal sizes only, uniform weights only. No partial or unbalanced transport.
- Deterministic for a given seed and options, across worker counts. Not promised bit-identical across JDKs or CPUs.
Algorithm from Baptiste Genest, Nicolas Bonneel, Vincent Nivoliers and David Coeurjolly,
"BSP-OT: Sparse transport plans between discrete measures in loglinear time", ACM Transactions on
Graphics (SIGGRAPH Asia 2025), https://perso.liris.cnrs.fr/david.coeurjolly/publication/bspot-25/.
The Gaussian split follows gaussianPartialBSPOT and the merge MergePlansNoPar and
BijectiveMatching::swapIfUpgrade in their MIT-licensed
reference implementation, https://github.com/baptiste-genest/BSP-OT, Copyright (c) 2025 the same
authors.
src/jvmMain/kotlin/afield/geometry/spatial/bspot.kt — read the source
Used in: A Drawn Morph, Heard
Top-level functions
| Declaration | Summary |
|---|---|
fun List<Vec2>.resampleByLength(count: Int, closed: Boolean = false): List<Vec2> |
Exactly count points spaced evenly along this polyline by arc length, first and last point included — the step that turns two strokes with different numbers of unevenly spaced points (a hand slows into a corner) into two lists matchPoints can pair. With closed, the polyline returns to its first point and the samples go round the loop without repeating it. |
fun matchPoints(from: List<Vec2>, to: List<Vec2>, trees: Int = 16, seed: Long = 1L, initial: IntArray? = null): BspOt2D.Result |
Matches from onto to one-to-one with BspOt2D; both lists must be the same size. |
BspOt2D
Equal-cardinality, equal-mass 2D BSP-OT with squared Euclidean cost: median BSP proposals split along Gaussian-guided or random directions, merged by alternating cycles and improving two-point swaps.
lp_fan_map.kt
The central-path fan as a map from the (θ, log μ) plane into a polygon.
For an objective direction c(θ) = (cos θ, sin θ) and barrier weight μ, the barrier maximiser x(θ, μ) solves c(θ) = μ Σ wᵢ nᵢ / sᵢ(x). Write g(x) = Σ wᵢ nᵢ / sᵢ(x): the gradient of the (negated) barrier, a diffeomorphism from the polygon's interior onto the whole plane. Then g(x) = c(θ)/μ, so (θ, 1/μ) are polar coordinates of g(x) and the fan is the polar grid pulled back through g⁻¹: one-to-one onto the interior minus the weighted analytic centre (g = 0).
Differentiating the optimality condition with M = μ Σ wᵢ nᵢnᵢᵀ / sᵢ² (the negated Hessian):
∂x/∂θ = M⁻¹ c'(θ)
∂x/∂log μ = −M⁻¹ c(θ)
and det[∂x/∂θ, ∂x/∂log μ] = det(M⁻¹) · det[c', −c] = 1 / det M > 0. The map never folds; it only pinches, at both ends of the log μ axis.
src/jvmMain/kotlin/afield/geometry/spatial/lp_fan_map.kt — read the source
Used in: Linear programs as curves
Top-level functions
| Declaration | Summary |
|---|---|
fun LpPolygon.fanMap(weights: DoubleArray? = null): LpFanMap |
This polygon's central-path fan as a map x(θ, log μ); see LpFanMap. |
LpFanPoint
One point of an LpFanMap: where (theta, logMu) lands in the polygon, and the map's Jacobian there.
| Declaration | Summary |
|---|---|
val theta: Double |
The objective direction's angle, radians. |
val logMu: Double |
The natural logarithm of the barrier weight μ. |
val x: Double |
The barrier maximiser's x. |
val y: Double |
The barrier maximiser's y. |
val dxdTheta: Vec2 |
∂x/∂θ, how the point moves as the direction turns: M⁻¹ c'(θ). |
val dxdLogMu: Vec2 |
∂x/∂log μ, how it moves as μ grows: −M⁻¹ c(θ). Its negative points along the central path toward the optimal vertex. |
val newtonSteps: Int |
The Newton steps this point took from its starting guess. |
val position: Vec2 |
The point, for drawing. |
val jacobianDeterminant: Double |
det[∂x/∂θ, ∂x/∂log μ]: the area in the polygon that a unit of (θ, log μ) area covers here. Always positive, and equal to 1 / det M; it falls toward 0 near the analytic centre (large μ) and near the vertices (small μ), where many parameter values crowd into little room. |
override fun toString(): String |
LpFanMap
class LpFanMap(val polygon: LpPolygon, weights: DoubleArray? = null)
The fan of central paths as a smooth map x(θ, log μ) from a parameter plane into polygon: θ is the objective direction's angle and log μ the barrier weight's logarithm. Each value is the barrier maximiser, found by damped Newton from a starting guess; give it a nearby point (at with near, or let grid and warp chain their own) and it takes one or two steps.
| Declaration | Summary |
|---|---|
val polygon: LpPolygon |
The polygon the map lands in. |
val weights: DoubleArray |
Each edge's barrier weight, as for centralPath. |
fun at(theta: Double, logMu: Double, start: Vec2 = polygon.center): LpFanPoint |
The point for direction angle theta and barrier weight exp(logMu), with Newton started at start (strictly inside; the polygon's centre by default). |
fun at(theta: Double, logMu: Double, near: LpFanPoint): LpFanPoint |
The point for theta and logMu, with Newton started from a neighbouring point near. |
fun grid(thetas: Int = 96, logMus: Int = 48, logMuHigh: Double = ln(100.0), logMuLow: Double = ln(1e-3), startAngle: Double = 0.0): LpFanGrid |
The map sampled on a thetas × logMus grid: θ in equal steps round the whole circle from startAngle, log μ in equal steps from logMuHigh down to logMuLow. Each point starts Newton from whichever of its two solved neighbours (the previous angle at this μ, or this angle at the previous μ) Newton judges closer, which takes a little over half the steps cold starts would (LpFanGrid.newtonSteps). |
fun warp(line: FLine, stepsPerSegment: Int = 16): FLine |
Carry line, drawn in the parameter plane with x as θ (radians) and y as log μ, into the polygon. Each segment is cut into stepsPerSegment pieces before mapping (cubic segments are evaluated along their curve), since a straight segment in the parameter plane is a curve in the polygon; subpaths stay separate. Newton for each point starts from the last. Colour and thickness are copied from line. |
LpFanGrid
The fan map sampled on a grid, from LpFanMap.grid. pointsiis angle `thetas[`i`](workspace.md#stdlib-kt--i)` and level `logMus`j (largest μ first).
| Declaration | Summary |
|---|---|
val newtonSteps: Int |
Newton steps the whole grid took. |
val minDeterminant: Double |
The smallest Jacobian determinant on the grid; it is at the small-μ or large-μ edge. |
fun thetaLines(every: Int = 1, alpha: Double = 0.85, thickness: Double = 1.0): List<FLine> |
The θ lines, every everyth angle: each is a central path from the large-μ end to the small, coloured round a hue circle as toFLines colours a fan. |
fun muLines(every: Int = 1, color: Vec4 = vec(1.0, 1.0, 1.0, 0.45), thickness: Double = 0.8): List<FLine> |
The μ lines, every everyth level: closed rings round the analytic centre. |
lp_polytope.kt
The three-dimensional companion of lp_trajectories.kt: the same linear-programming methods, as
curve generators, on a convex polytope { x : nᵢ·x ≤ bᵢ } in space. The methods are the 2-D ones
with 3×3 linear algebra in place of 2×2 (the ellipsoid method's constants change with the
dimension: centre step 1/(n+1) = 1/4, expansion n²/(n²−1) = 9/8). The simplex walk follows the
polytope's edges, which the constructor finds by enumerating vertices.
Pure math, independent of Box; doubles throughout, Vec3 only for drawing.
src/jvmMain/kotlin/afield/geometry/spatial/lp_polytope.kt — read the source
Used in: Linear programs as curves
Top-level functions
| Declaration | Summary |
|---|---|
fun LpPolytope.centralPath(direction: Vec3, weights: DoubleArray? = null, samples: Int = 160, muHigh: Double = 1000.0, muLow: Double = 1e-5, start: Vec3 = center): LpTrajectory3 |
The log-barrier central path in 3-D: for μ falling geometrically from muHigh to muLow, the maximiser of c·x + μ Σ wᵢ ln(slackᵢ), by damped Newton (a 3×3 solve per step) warm-started from the previous sample. It leaves the weighted analytic centre and ends at the optimal vertex for direction. Every sample is strictly feasible; LpSample3.scale is μ. See the 2-D LpPolygon.centralPath for what the weights do. |
fun sphereDirections(count: Int): List<Vec3> |
count directions spread evenly over the unit sphere (a Fibonacci spiral), the directions LpPolytope.centralFan uses. |
fun LpPolytope.centralFan(directions: Int = 64, samples: Int = 100, weights: DoubleArray? = null, muHigh: Double = 1000.0, muLow: Double = 1e-5): List<LpTrajectory3> |
A fan of central paths over directions objective directions spread over the sphere (sphereDirections): every path leaves the same weighted analytic centre and ends at the vertex that wins for its direction. |
fun LpPolytope.primalDual(direction: Vec3, maxIterations: Int = 60, tolerance: Double = 1e-9, stepFraction: Double = 0.95, start: Vec3 = center): LpTrajectory3 |
Mehrotra's predictor–corrector primal-dual interior-point method in 3-D, every Newton iterate recorded; the 3×3 port of LpPolygon.primalDual. LpSample3.scale is the duality measure μ. |
fun LpPolytope.pdhg(direction: Vec3, iterations: Int = 1000, tau: Double = 0.15, sigma: Double = 0.15, theta: Double = 1.0, start: Vec3 = center): LpPdhgTrace3 |
Primal-dual hybrid gradient (Chambolle–Pock) in 3-D, every primal and dual state kept, infeasible ones included; the port of LpPolygon.pdhg. The primal walk overshoots the polytope and spirals into the optimal vertex. LpSample3.scale is the dual vector's norm. |
fun LpPolytope.ellipsoid(direction: Vec3, iterations: Int = 120, initialRadius: Double? = null, start: Vec3 = center): LpEllipsoidTrace3 |
The central-cut ellipsoid method in 3-D, every centre and ellipsoid kept. It starts from a ball of initialRadius (default: the polytope's diameter) around start, cuts on the most violated half-space at an infeasible centre and on the objective at a feasible one. In n = 3 dimensions each cut moves the centre by −Pg / (4√(gᵀPg)) and replaces P by 9/8 · (P − ½ (Pg)(Pg)ᵀ / gᵀPg) (n²/(n²−1) and 2/(n+1)), which multiplies the volume by 27/32 ≈ 0.84 every step: slower than the 2-D method's 0.77, as the method is in higher dimensions. LpSample3.tag and LpSample3.scale (√det P) are as in LpPolygon.ellipsoid. |
fun LpPolytope.simplexWalk(direction: Vec3, startVertex: Int = 0): LpTrajectory3 |
The simplex method in 3-D: a walk along the polytope's LpPolytope.edges from startVertex, moving each time to the neighbouring vertex that improves c·x most, until none does. A convex polytope has no local optimum that is not global, so it ends at the optimal vertex. LpSample3.scale is NaN. |
fun LpPolytope.cuttingPlanes(direction: Vec3, iterations: Int = 20, center: LpCenter = LpCenter.ANALYTIC, minVolume: Double = 1e-9): LpCuttingPlaneTrace3 |
A cutting-plane walk in 3-D: find a centre of the region still in play, cut it with the objective (c·x ≥ c·centre), repeat. center is LpCenter.ANALYTIC (the analytic centre of every constraint so far, cuts included), LpCenter.CENTROID (the centre of gravity, which removes at least 1 − (3/4)³ ≈ 58% of the volume each step) or LpCenter.CHEBYSHEV (the centre of the largest inscribed ball) or LpCenter.VOLUMETRIC (Vaidya's volumetric centre of every constraint so far, started from the analytic centre). Stops when the remaining volume falls below minVolume or after iterations. LpSample3.scale is the remaining volume, and LpCuttingPlaneTrace3.regions holds each region. |
fun List<LpTrajectory3>.toFLines(alpha: Double = 0.8, thickness: Double = 1.1): List<FLine> |
Every path of a 3-D fan as an FLine, coloured round a hue circle. |
LpPolytope
class LpPolytope(normals: List<Vec3>, bounds: List<Double>)
A bounded convex polytope with an interior, held as its half-spaces nᵢ·x ≤ bᵢ (unit outward normals) and as the vertices and edges those half-spaces make, ready for the trajectory methods (centralPath, centralFan, primalDual, pdhg, ellipsoid, simplexWalk, cuttingPlanes).
| Declaration | Summary |
|---|---|
val normalX: DoubleArray |
x component of each half-space's unit outward normal. |
val normalY: DoubleArray |
y component of each half-space's unit outward normal. |
val normalZ: DoubleArray |
z component of each half-space's unit outward normal. |
val bound: DoubleArray |
Each half-space's bound: the polytope is normal[i](workspace.md#stdlib-kt--i)·x ≤ bound[i](workspace.md#stdlib-kt--i) for every i. |
val vertices: List<Vec3> |
The vertices, each where three or more of the planes meet. |
val vertexPlanes: List<IntArray> |
For each vertex, the indices of the half-spaces tight at it. |
val edges: List<IntArray> |
The edges, as pairs of indices into vertices. |
val center: Vec3 |
The mean of the vertices, strictly inside; the default start. |
val constraintCount: Int |
The number of half-spaces, which is the number of constraints. |
fun slack(i: Int, x: Double, y: Double, z: Double): Double |
How far (x, y, z) is inside half-space i: positive inside, zero on its plane. |
fun violation(x: Double, y: Double, z: Double): Double |
The largest constraint violation at (x, y, z), zero inside or on the polytope. |
fun containsStrictly(x: Double, y: Double, z: Double): Boolean |
True when (x, y, z) is strictly inside every half-space. |
fun optimalVertex(direction: Vec3): Vec3 |
The vertex that maximises direction · x. |
fun neighbours(i: Int): List<Int> |
The vertices next to vertex i along an edge. |
fun toFLine(color: Vec4 = vec(0.62, 0.70, 0.80, 0.9), thickness: Double = 1.4): FLine |
The edges as one FLine of separate segments: the polytope's wireframe. |
fun toMesh(): TriMesh |
The solid, as a triangulated TriMesh (the hull of vertices). |
val volume: Double |
The enclosed volume. |
LpSample3
One step of a 3-D trajectory: the 3-D LpSample.
| Declaration | Summary |
|---|---|
val x: Double |
The iterate's x. |
val y: Double |
The iterate's y. |
val z: Double |
The iterate's z. |
val iteration: Int |
The step number, starting at 0. |
val objective: Double |
c·x with c the normalised objective direction. |
val violation: Double |
The largest constraint violation, zero for a feasible iterate. |
val scale: Double |
The method's own measure, as for LpSample: μ for centralPath and primalDual, the dual norm for pdhg, √det P for ellipsoid, the remaining volume for cuttingPlanes, NaN for simplexWalk. |
val tag: String |
What kind of step this is: the method's name, or for ellipsoid which cut was taken — feasibility-cut, objective-cut, or final at the last step. |
val position: Vec3 |
The iterate as a Vec3, for drawing. |
LpTrajectory3
class LpTrajectory3(val method: String, val samples: List<LpSample3>, val solverSteps: Int = 0)
A 3-D method's whole walk, in order; see LpTrajectory.
| Declaration | Summary |
|---|---|
val last: LpSample3 |
The final iterate. |
val positions: List<Vec3> |
The iterates as Vec3s. |
fun toFLine(color: Vec4 = vec(1.0, 0.72, 0.32, 0.95), thickness: Double = 1.6): FLine |
The walk as an open 3-D polyline. |
LpEllipsoid
The ellipsoid { z : (z − c)ᵀ P⁻¹ (z − c) ≤ 1 } with centre (cx, cy, cz) and symmetric positive definite P = [[pxx, pxy, pxz], [pxy, pyy, pyz], [pxz, pyz, pzz]], as kept by LpPolytope.ellipsoid.
| Declaration | Summary |
|---|---|
val cx: Double, val cy: Double, val cz: Double |
|
val pxx: Double, val pxy: Double, val pxz: Double, val pyy: Double, val pyz: Double, val pzz: Double |
|
val determinant: Double |
det P; the volume is 4/3 π √det. |
val isPositiveDefinite: Boolean |
True when P is symmetric positive definite (all leading minors positive). |
fun quadraticForm(x: Double, y: Double, z: Double): Double |
(z − c)ᵀ P⁻¹ (z − c) for z = (x, y, z): at most 1 inside the ellipsoid. |
fun toFLine(rings: Int = 6, segments: Int = 48, color: Vec4 = vec(0.55, 0.80, 1.0, 0.4), thickness: Double = 0.8): FLine |
A wireframe: the unit sphere's rings circles of latitude and rings meridians, mapped onto the ellipsoid through the Cholesky factor of P, as one FLine. |
LpEllipsoidTrace3
class LpEllipsoidTrace3(val trajectory: LpTrajectory3, val ellipsoids: List<LpEllipsoid>)
LpPolytope.ellipsoid's result: the centres, and the ellipsoid kept at every step.
LpPdhgTrace3
class LpPdhgTrace3(val trajectory: LpTrajectory3, val duals: List<DoubleArray>)
LpPolytope.pdhg's result: the primal walk, and the dual vector at every step.
LpCuttingPlaneTrace3
class LpCuttingPlaneTrace3(val trajectory: LpTrajectory3, val regions: List<LpPolytope>)
LpPolytope.cuttingPlanes's result: the centres, and the region still in play at every step.
lp_trajectories.kt
Linear-programming algorithms on a convex polygon, used as curve generators.
A 2-D linear program is "maximise c·x over a convex polygon". Every classical method for it walks
from somewhere to the same optimal vertex, and each walks differently: the barrier method's central
path is a smooth arc from the analytic centre, the primal-dual method takes a few long Newton
strides, PDHG spirals in, the ellipsoid method shrinks a sequence of ellipses, the simplex method
hops along the boundary and cutting planes carve the region away. Here those walks are the product.
Every method returns its whole trajectory with per-step metadata (LpSample), and each result type
has a toFLine that draws it, with toFLines for a whole fan of them.
The pure math is independent of Box and of FLine: an LpPolygon is a list of half-planes and the
methods are plain functions on it. Doubles throughout; LpSample.position is the Vec2 for drawing.
The visual reference for all of this is https://lpviz.net.
src/jvmMain/kotlin/afield/geometry/spatial/lp_trajectories.kt — read the source
Used in: Linear programs as curves
Top-level functions
| Declaration | Summary |
|---|---|
fun LpPolygon.centralPath(direction: Vec2, weights: DoubleArray? = null, samples: Int = 160, muHigh: Double = 1000.0, muLow: Double = 1e-5, start: Vec2 = center, warm: LpTrajectory? = null): LpTrajectory |
The log-barrier central path: for μ falling geometrically from muHigh to muLow, the maximiser of c·x + μ Σ wᵢ ln(slackᵢ), found by damped Newton warm-started from the previous sample. At large μ it sits at the weighted analytic centre; as μ falls it bends toward the optimal vertex for direction, which it approaches as μ → 0. |
fun LpPolygon.centralFan(angles: Int = 48, samples: Int = 120, weights: DoubleArray? = null, startAngle: Double = 0.0, muHigh: Double = 1000.0, muLow: Double = 1e-5): List<LpTrajectory> |
A fan of central paths: the objective direction turned through a full circle in angles equal steps, starting at startAngle radians, one centralPath each. Every path leaves the same weighted analytic centre and ends at the vertex that is optimal for its direction, so the fan is a sampling of the surface x(θ, μ). |
fun LpPolygon.primalDual(direction: Vec2, maxIterations: Int = 60, tolerance: Double = 1e-9, stepFraction: Double = 0.95, start: Vec2 = center): LpTrajectory |
Mehrotra's predictor–corrector primal-dual interior-point method, recording every Newton iterate. Primal x starts at start with slacks s = b − Ax, so it stays strictly feasible; the duals start at 1 and the dual residual Aᵀy − c is driven to zero alongside the duality measure μ = sᵀy / m. Each step is the 2×2 normal-equation solve, with separate primal and dual step lengths at fraction stepFraction of the distance to the boundary. Stops when μ and the dual residual are below tolerance, or after maxIterations. LpSample.scale is μ. |
fun LpPolygon.pdhg(direction: Vec2, iterations: Int = 1000, tau: Double = 0.15, sigma: Double = 0.15, theta: Double = 1.0, start: Vec2 = center): LpPdhgTrace |
Primal-dual hybrid gradient (Chambolle–Pock) on the saddle problem max_x min_{y ≥ 0} c·x − yᵀ(Ax − b), keeping every primal and dual state, infeasible ones included. Each step projects the dual y ← max(0, y + σ(A x̄ − b)), moves the primal x ← x + τ(c − Aᵀy), and extrapolates x̄ = x + θ(x − x_prev). |
fun LpPolygon.ellipsoid(direction: Vec2, iterations: Int = 80, initialRadius: Double? = null, start: Vec2 = center): LpEllipsoidTrace |
The central-cut ellipsoid method, returning every centre and every ellipse. It starts from a disc of initialRadius (default: the polygon's diameter) around start. At an infeasible centre it cuts on the most violated edge; at a feasible one it cuts on the objective, keeping the half where c·x is at least its current value. Each central cut in two dimensions moves the centre by −Pg / (3√(gᵀPg)) and replaces P by 4/3 · (P − 2/3 · (Pg)(Pg)ᵀ / gᵀPg), which multiplies the area by √(16/27) ≈ 0.77 every step. LpSample.tag is "feasibility-cut" or "objective-cut" for the cut taken at that centre ("final" for the last), and LpSample.scale is √det P. |
fun LpPolygon.simplexWalk(direction: Vec2, startVertex: Int = 0): LpTrajectory |
The simplex method in two dimensions: a walk along the boundary from startVertex, moving to whichever neighbouring vertex improves c·x more, until neither does. It ends at the optimal vertex, having visited at most half the polygon. LpSample.scale is NaN. |
fun LpPolygon.cuttingPlanes(direction: Vec2, iterations: Int = 40, center: LpCenter = LpCenter.ANALYTIC, minArea: Double = 1e-10): LpCuttingPlaneTrace |
A cutting-plane walk: find a centre of the region still in play, cut the region with the objective (c·x ≥ c·centre), and repeat. With LpCenter.ANALYTIC the centre is the analytic centre of every constraint so far, cuts included, so earlier cuts keep pushing on the path just as extra constraints push a central path; with LpCenter.CENTROID it is the centre of gravity, which removes at least 1 − (2/3)² of the area each step; with LpCenter.CHEBYSHEV it is the centre of the largest inscribed disc, and with LpCenter.VOLUMETRIC Vaidya's volumetric centre of every constraint so far. The trajectory approaches the optimal vertex from inside; it stops when the remaining area falls below minArea or after iterations. LpSample.scale is the remaining area, and LpCuttingPlaneTrace.regions holds the region at every step. |
fun List<LpTrajectory>.toFLines(alpha: Double = 0.8, thickness: Double = 1.1): List<FLine> |
Every path of a fan as an FLine, coloured round a hue circle so neighbouring angles read apart. |
LpPolygon
class LpPolygon(points: List<Vec2>)
A strictly convex polygon, held both as its vertices and as the half-planes nᵢ·x ≤ bᵢ of its edges, ready for the trajectory methods (centralPath, centralFan, primalDual, pdhg, ellipsoid, simplexWalk, cuttingPlanes).
| Declaration | Summary |
|---|---|
val vertices: List<Vec2> |
The vertices, counter-clockwise, without a closing repeat. |
val normalX: DoubleArray |
x component of each edge's unit outward normal. |
val normalY: DoubleArray |
y component of each edge's unit outward normal. |
val bound: DoubleArray |
Each edge's bound: the polygon is normalX[i](workspace.md#stdlib-kt--i)·x + normalY[i](workspace.md#stdlib-kt--i)·y ≤ bound[i](workspace.md#stdlib-kt--i) for every i. |
val center: Vec2 |
The mean of the vertices, strictly inside a strictly convex polygon; the default start. |
val edgeCount: Int |
The number of edges, which is also the number of constraints and of vertices. |
fun slack(i: Int, x: Double, y: Double): Double |
How far (x, y) is inside edge i: positive inside, zero on the edge, negative outside. |
fun violation(x: Double, y: Double): Double |
The largest constraint violation at (x, y), zero for any point inside or on the polygon. |
fun containsStrictly(x: Double, y: Double): Boolean |
True when (x, y) is strictly inside every edge. |
fun optimalVertex(direction: Vec2): Vec2 |
The vertex that maximises direction · x: the answer every method here walks toward. |
fun toFLine(color: Vec4 = vec(0.62, 0.70, 0.80, 0.9), thickness: Double = 1.4): FLine |
The polygon's outline as a closed FLine. |
LpSample
One step of a trajectory.
| Declaration | Summary |
|---|---|
val x: Double |
The iterate's x. |
val y: Double |
The iterate's y. |
val iteration: Int |
The step number, starting at 0. |
val objective: Double |
c·x with c the normalised objective direction. |
val violation: Double |
The largest constraint violation, zero for a feasible iterate. |
val scale: Double |
The method's own measure at this step: the barrier weight μ for centralPath, the duality measure μ for primalDual, the dual norm for pdhg, √det of the ellipse for ellipsoid, the remaining area for cuttingPlanes, and NaN for simplexWalk. |
val tag: String |
What kind of step this is: the method's name, or for ellipsoid which cut was taken — feasibility-cut, objective-cut, or final at the last step. |
val position: Vec2 |
The iterate as a Vec2, for drawing. |
LpTrajectory
class LpTrajectory(val method: String, val samples: List<LpSample>, val solverSteps: Int = 0)
A method's whole walk, in order. method names the method that produced it.
| Declaration | Summary |
|---|---|
val last: LpSample |
The final iterate. |
val positions: List<Vec2> |
The iterates as Vec2s. |
fun toFLine(color: Vec4 = vec(1.0, 0.72, 0.32, 0.95), thickness: Double = 1.6): FLine |
The walk as an open polyline. |
LpEllipse
The ellipse { z : (z − c)ᵀ P⁻¹ (z − c) ≤ 1 } with centre (cx, cy) and symmetric positive definite shape matrix P = [[pxx, pxy], [pxy, pyy]], as kept by ellipsoid.
| Declaration | Summary |
|---|---|
val determinant: Double |
det P; the ellipse's area is π √det. |
val isPositiveDefinite: Boolean |
True when the shape matrix is symmetric positive definite, i.e. this is a real ellipse. |
fun toFLine(segments: Int = 72, color: Vec4 = vec(0.55, 0.80, 1.0, 0.5), thickness: Double = 1.0): FLine |
The boundary as a closed FLine of segments chords, through the Cholesky factor of P. |
LpEllipsoidTrace
class LpEllipsoidTrace(val trajectory: LpTrajectory, val ellipses: List<LpEllipse>)
ellipsoid's result: the centres, and the ellipse kept at every step (same length).
LpPdhgTrace
class LpPdhgTrace(val trajectory: LpTrajectory, val duals: List<DoubleArray>)
pdhg's result: the primal walk, and the dual vector (one entry per edge) at every step.
LpCuttingPlaneTrace
class LpCuttingPlaneTrace(val trajectory: LpTrajectory, val regions: List<List<Vec2>>)
cuttingPlanes's result: the centres, and the region still in play at every step.
LpCenter
The centres cuttingPlanes can cut through.
Constants: ANALYTIC, CENTROID, CHEBYSHEV, VOLUMETRIC.
LpContinuation
A warm-started centralPath for a polygon, direction or weights that move smoothly between calls — an animation's frames. From the third call on it extrapolates each sample linearly from the previous two paths (2·last − before, a secant predictor) and passes that as centralPath's warm; on the second call it passes the previous path itself. Newton then starts next to the answer and needs fewer steps than following the path cold, for the same path. last is the latest path and lastSteps its Newton step count; reset forgets the history, which a jump should do. A prediction that does not fit (a different sample count, or a sample Newton judges further off than the path's own previous sample) is ignored, so a jump costs a cold start and nothing else.
| Declaration | Summary |
|---|---|
var last: LpTrajectory? |
The path the previous call returned, or null before the first call and after reset. |
val lastSteps: Int |
Newton steps the previous call took. |
fun centralPath(polygon: LpPolygon, direction: Vec2, weights: DoubleArray? = null, samples: Int = 160, muHigh: Double = 1000.0, muLow: Double = 1e-5): LpTrajectory |
polygon's central path for direction, warm-started from the previous calls'. |
fun reset() |
Forget the previous paths, so the next call starts cold. |
spatial_field.kt
The bridges out of Field's plane-geometry results and into the things Field already draws.
Every partitioner in graph_connectivity.kt, dirichlet.kt, contours.kt and clustering.kt
answers with structure — cell vertex rings, circular arcs, triangle index triples, cluster
labels — and none of them answers with geometry. This file converts each of them: one toFLines
per result type, so a Voronoi diagram, an Apollonius cell, a Delaunay triangulation or a k-medoids
partition lands in the same drawing, animation and export machinery as anything drawn by hand.
It is the plane counterpart of mesh_field.kt, which does the same job for TriMesh.
Two layers:
- Structure →
FLine.PointGraph.toFLine,delaunayTrianglesToFLine,VoronoiDiagram.toFLines,CircularDirichlet.Diagram.toFLines,CircularDirichlet.CellBoundary.toFLines,ClusterLayer.toFLinesandpointCloudFLine. Each returns styled, drawable lines and nothing else — noBox, no output, so they are usable from a test or a renderer as readily as from a box. FLine→ a captioned figure.Box.showPlaneandBox.showPlaneGridrasterise lines to inline SVG withdrawToSVGand push a titled card — live and pannable for one figure, frozen for a grid of them — to a box output channel: the fastest way to see a whole pipeline at once, and the 2D twin ofBox.showMeshGrid.planeFigureHtmlis the markup on its own.
The figure helpers are AWT-free (Skia's SVG canvas, same as Box.lines), so they work headless,
including inside the docs build.
src/jvmMain/kotlin/afield/geometry/spatial/spatial_field.kt — read the source
Used in: Points into structure — Voronoi, Apollonius, contours and clusters, Linear programs as curves, Evolving Creatures
Top-level functions
stippling.kt
Stippling: place exactly N points so that they follow an image's brightness and keep an even, low-correlation ("blue-noise-ish") spacing from one another.
val dots = imgFromFile("face.png").stipple(5000) // List<Vec2>, pixel coordinates
Sampling the brightness as a probability distribution gets the density right but clumps: two independent samples are as likely to land on top of each other as anywhere else. This file instead computes a weighted centroidal Voronoi tessellation (Secord's stippling) by Lloyd relaxation on the CPU. Each iteration
- rasterises the points into the pixel grid,
- finds every pixel's nearest point with a separable exact Euclidean distance transform
(a two-scan nearest-seed pass along each row, then Felzenszwalb–Huttenlocher's lower envelope of
parabolas down each column), which yields the Voronoi cells as a label raster in
O(pixels)regardless of the point count, - moves each point to the density-weighted centroid of its cell.
The rows and columns are split across a fork-join pool of their own, and nothing on the per-iteration path allocates: the label rasters, the column scratch and the per-worker centroid accumulators are all allocated once, in the constructor.
Where the points go, and what relaxation does not change
The number of points in a region is decided by the starting sample. Relaxation evens out the
spacing between neighbours and moves hardly any points from one region to another, so the point
density is whatever the start gave, brightness^densityPower. With the default of 1, points follow
brightness, and a larger power exaggerates contrast.
That makes the start matter. An independent weighted random sample has the right density on
average and random clumps everywhere, and relaxation keeps the clumps' counts: on a flat field the
count variance in 48-pixel windows is 0.60 of a random scatter's after relaxing, which reads as
blotches in a mid tone. So the start is stratified along a Hilbert curve instead. The curve visits
the image in a way that keeps nearby pixels nearby, it is cut into count stretches of equal
density, and each stretch gets one point. Every region the curve covers then starts with its exact
share, and the same measurement after relaxing is 0.028.
Theory predicts a different density: the optimal centroidal tessellation of a density ρ in 2-D
has point density ρ^(1/2). Lloyd iteration from a weighted start does not travel there. On a
field half at brightness 1 and half at 0.5 the bright/dim count ratio stays at 2 through hundreds of
iterations, against an optimum of 1.41, until the iteration reaches a fixed point on the raster
where Stippler.step returns 0. StipplingTest pins both powers, the drift and the lumpiness.
Memory
Besides five rasters of supersample² * width * height pixels, each worker keeps its own three
DoubleArray(count) centroid accumulators so that workers never contend. That is cheap at ten or a hundred thousand
points, and 384 MB at a million points on 16 workers — pass a smaller threads there.
src/jvmMain/kotlin/afield/geometry/spatial/stippling.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Img.stipple(count: Int, iterations: Int = 20, densityPower: Float = 1f, invert: Boolean = false, seed: Int = 1): List<Vec2> |
count stipple points following this image's brightness, after iterations rounds of weighted Lloyd relaxation. Points are in pixel coordinates: origin top-left, y down, a pixel's centre at +0.5. Use Stippler directly to relax incrementally, for instance one step per frame. |
fun stipplesToFLine(points: List<Vec2>, size: Double = 1.0, z: Number = 0): FLine |
Stipple points as one pointed FLine, ready for lines(...): a moveTo per point and no segments, so it draws dots and nothing between them. |
fun stipplesToImg(points: List<Vec2>, width: Int, height: Int, radius: Double = 1.0, ink: Boolean = true): Img |
Stipple points rasterised as anti-aliased discs: a 1-channel Img, for showImg(...). |
Stippler
An incremental weighted Lloyd relaxation of count points over an image's brightness — the engine behind stipple, kept as an object so a box can watch it settle:
| Declaration | Summary |
|---|---|
val count: Int |
How many points; points always returns exactly this many. |
val seed: Int |
The seed for the initial placement and for re-placing empty cells. |
val width |
The image's width in pixels; points have x in 0..width. |
val height |
The image's height in pixels; points have y in 0..height. |
val supersample: Int |
How many times finer than the image the tessellation is computed. The centroid of a cell a few pixels across is quantised to the pixel grid, and in dense regions that lines the dots up into chains. So unless asked otherwise this picks the factor, up to 4, that gives an average cell at least 128 raster pixels. Points are reported in image pixels regardless. |
val xs |
Point i's x, in pixels. Written by step; read it directly in a hot loop. |
val ys |
Point i's y, in pixels, y down. Written by step. |
var iterations |
How many steps have run. |
fun points(): List<Vec2> |
The points as values, in pixel coordinates. Allocates a fresh list; see xs / ys to avoid it. |
fun relax(iterations: Int = 20, tolerance: Double = 0.0): Stippler |
Run up to iterations steps, stopping early once the mean movement falls to tolerance pixels or below. Returns this, so Stippler(img, n).relax(20).points() reads in one line. |
fun step(): Double |
One Lloyd iteration: Voronoi cells by distance transform, then every point to its cell's density-weighted centroid. Returns the mean distance the points moved, in pixels — it falls towards zero as the tessellation settles, which makes it the thing to plot. |
transport.kt
Optimal transport between point sets: which source goes to which target, or how much of each source goes to each target, at the least total cost. Three solvers, from exact and small to approximate and large:
assignment(the Hungarian method) answers the one-to-one question exactly, for two equally sized sets. It isO(n³)over ann × ncost matrix, so it is for hundreds of points, maybe a few thousand. Flow matching's minibatch coupling and the FLine trajectory matcher use it.matchPoints(BSP-OT, inbspot.kt) answers the same one-to-one question approximately, for 2D clouds of a million points a side.sinkhornanswers a softer question exactly: it transports mass, so the sets may differ in size and carry weights, and every source may split between several targets. The answer is a transport plan rather than a pairing. A smallsinkhornepsilonmakes the plan nearly a permutation and its cost nearly theassignmentcost; a large one blurs it.
Entropic transport
sinkhorn solves the entropy-regularised problem: minimise Σ πᵢⱼ Cᵢⱼ + ε Σ πᵢⱼ (ln πᵢⱼ − 1) over
plans π whose rows sum to the source weights a and whose columns sum to the target weights b.
The entropy term makes the problem strictly convex, so the answer is unique, smooth in the inputs,
and found by alternately rescaling rows and columns (Cuturi 2013). This implementation keeps the
scalings as log-domain dual potentials f, g, so a small ε does not underflow, and
πᵢⱼ = exp((fᵢ + gⱼ − Cᵢⱼ) / ε).
ε is in the units of the cost. With squared-distance costs it is an area: ε = 0.01 on points in
a unit square blurs over roughly √0.01 = 0.1. Each iteration is O(n·m), and smaller ε needs
more of them; multiscaleSinkhorn solves a coarse version first and starts the fine one from it.
src/jvmMain/kotlin/afield/geometry/spatial/transport.kt — read the source
Used in: Optimal Transport
Top-level functions
| Declaration | Summary |
|---|---|
fun assignment(cost: Array<DoubleArray>): IntArray |
The exact one-to-one assignment for a square cost matrix by the Hungarian method: result[i](workspace.md#stdlib-kt--i) is the column row i is matched to, and the total of cost[i](workspace.md#stdlib-kt--i)[result[i](workspace.md#stdlib-kt--i)] is the least possible. O(n³); an empty matrix gives an empty result. |
fun assignment(from: List<Vec2>, to: List<Vec2>): IntArray |
The exact one-to-one matching of from onto to that minimises total squared distance: result[i](workspace.md#stdlib-kt--i) is the index in to that from[i](workspace.md#stdlib-kt--i) goes to. The two lists must be the same size. For more than a few thousand points use matchPoints, which is approximate and O(n log n). |
fun sinkhorn(cost: Array<DoubleArray>, epsilon: Double, sourceWeights: DoubleArray? = null, targetWeights: DoubleArray? = null, maxIterations: Int = 10_000, tolerance: Double = 1e-9, checkEvery: Int = 20, initialF: DoubleArray? = null, initialG: DoubleArray? = null): TransportPlan |
Entropy-regularised optimal transport by log-domain Sinkhorn iteration; see the file overview. |
fun sinkhorn(from: List<Vec2>, to: List<Vec2>, epsilon: Double, sourceWeights: DoubleArray? = null, targetWeights: DoubleArray? = null, maxIterations: Int = 10_000, tolerance: Double = 1e-9): TransportPlan |
sinkhorn between two 2D point sets with squared-distance cost, so epsilon is an area in the points' units. The sets may differ in size; the weights default to uniform. |
fun sinkhornDivergence(from: List<Vec2>, to: List<Vec2>, epsilon: Double, sourceWeights: DoubleArray? = null, targetWeights: DoubleArray? = null, maxIterations: Int = 10_000, tolerance: Double = 1e-9): Double |
The Sinkhorn divergence OTε(μ, ν) − (OTε(μ, μ) + OTε(ν, ν)) / 2 between two weighted 2D point sets, with squared-distance cost: a distance-like measure of how different two distributions are that, unlike the regularised cost alone, is zero when they are the same and does not reward blurring. It costs three sinkhorn solves. |
fun multiscaleSinkhorn(from: Array<DoubleArray>, to: Array<DoubleArray>, epsilon: Double, sourceWeights: DoubleArray? = null, targetWeights: DoubleArray? = null, minSize: Int = 64, maxIterationsPerLevel: Int = 5_000, tolerance: Double = 1e-9): TransportPlan |
sinkhorn for larger point sets, coarse to fine. Each set is reduced to a pyramid of farthest-point-sampled centres, each centre carrying the weight of the points nearest it, halving until about minSize remain; the coarsest pair is solved first, and each finer solve starts from the potentials of the one before, copied from each point's centre. The finest level is the full problem, so the answer is the same plan sinkhorn would reach; what changes is how many sweeps it takes to get there. Points are rows of coordinates of any dimension, with squared-distance cost. |
fun squaredDistanceCost(from: List<Vec2>, to: List<Vec2>): Array<DoubleArray> |
The n × m matrix of squared distances from each of from to each of to. |
fun squaredDistanceCost(from: Array<DoubleArray>, to: Array<DoubleArray>): Array<DoubleArray> |
squaredDistanceCost for points of any dimension, one row of coordinates per point. |
HungarianAssignment
The Hungarian method for square cost matrices, O(n³). assignment is the public way in; this object is the one implementation that flow matching, the FLine trajectory matcher and the BSP-OT tests share.
| Declaration | Summary |
|---|---|
fun solve(cost: Array<DoubleArray>): IntArray |
result[i](workspace.md#stdlib-kt--i) is the column assigned to row i; see assignment. |
TransportPlan
The answer sinkhorn gives: a transport plan, the dual potentials that generate it, and how well it met its constraints.
| Declaration | Summary |
|---|---|
val plan: Array<DoubleArray> |
plani` is how much of sourceigoes to targetj`. Rows sum to the source weights, columns to the target weights, and the whole plan to 1. |
val sourceWeights: DoubleArray |
The source weights the plan was solved for, normalised to sum to 1. |
val targetWeights: DoubleArray |
The target weights, normalised to sum to 1. |
val f: DoubleArray |
The dual potential on each source; the plan is exp((f[i](workspace.md#stdlib-kt--i) + gj− costi) / ε). |
val g: DoubleArray |
The dual potential on each target. |
val epsilon: Double |
The regularisation the plan was solved at. |
val cost: Double |
The transport cost Σ plani· costi``, without the entropy term. |
val objective: Double |
The regularised objective cost + ε Σ π (ln π − 1) that was minimised. |
val iterations: Int |
Row-then-column sweeps run. |
val converged: Boolean |
Whether both marginal errors fell to the tolerance before the iteration limit. |
val rowError: Double |
The largest difference between a row sum and its source weight. |
val columnError: Double |
The largest difference between a column sum and its target weight. |
val sources: Int |
The number of sources (rows). |
val targets: Int |
The number of targets (columns). |
fun barycentricMap(to: List<Vec2>): List<Vec2> |
Where each source goes on average: the plan-weighted mean of the targets it sends mass to. With a small ε this is close to the one target it is matched with; with a large one it is pulled toward the middle of everything it spreads over. |
fun barycentricMap(to: Array<DoubleArray>): Array<DoubleArray> |
barycentricMap for points of any dimension: toj` is targetj`'s coordinates. |
fun strongest(): IntArray |
For each source, the target that receives the most of it. |
override fun toString(): String |
voronoi.kt
Practical 3D Voronoi and approximate 3D multiplicatively weighted Voronoi ("CircularDirichlet") for Kotlin + Field linalg.
voronoi3dbuilds convex polyhedral cells by intersecting pairwise bisector half-spaces. Six clip-box planes are added to every cell, not only to the unbounded ones, so a cell always comes back as a bounded polyhedron;VoronoiCell3.boundedis how you tell whether the answer is the site's own cell or an artefact of the box. The box is the sites' bounding box grown on every side byclipPadding * scale + 1, wherescaleis the cloud's own diagonal (floored at 1).CircularDirichlet3d.buildextracts approximate support patches on bisector spheres / planes by clipping a sampled surface mesh with the weighted score predicate.
The weighted contourer is renderer-oriented: it prefers stable, wound boundary loops over exact symbolic patch topology.
How the unweighted clipper assigns a corner to a face
Combinatorially: a corner records the planes that produced it, and a face's polygon is exactly
the corners recording that plane. Not by a distance test against a tolerance — a tolerance scaled
off the point cloud lets faces near a near-cospherical site cluster claim corners that are not on
them, and then the cells do not close, which is a T-junction and a hairline crack in any mesh
built from the faces. The collectCorners KDoc in Voronoi3d has the detail.
Closure is not total, because the clipper works in Float: 4 of 465 bounded cells and 12 of
447 clipped cells fail the strict check that every edge is walked by exactly two faces in
opposite directions. VoronoiFaceTopologyTest pins both counts with <=, so they can improve but
cannot regress silently. Cell volumes tile the clip box to 1e-4 relative, and a cubic lattice of
sites gives exact unit cubes.
src/jvmMain/kotlin/afield/geometry/spatial/voronoi.kt — read the source
Used in: Points into structure — Voronoi, Apollonius, contours and clusters, Meshes and 3D geometry
Top-level functions
| Declaration | Summary |
|---|---|
fun voronoi3d(l: List<Vec3>): VoronoiLattice |
The 3D Voronoi diagram of l, with the default tolerance and a generous clip box. |
Aabb3
data class Aabb3(val min: Vec3, val max: Vec3)
An axis-aligned box; by convention min is componentwise <= max. The clipper's own window.
| Declaration | Summary |
|---|---|
val min: Vec3 |
The low corner, componentwise. |
val max: Vec3 |
The high corner, componentwise. |
Plane3
data class Plane3(val normal: Vec3, val d: Float)
The plane normal · x = d.
VoronoiFace
One face of a VoronoiCell3: the plane it lies in, and the cell's corners that lie on it.
| Declaration | Summary |
|---|---|
val neighborSite: Int? |
The site across this face, or null when this is a clip-box face rather than a real bisector. |
val plane: Plane3 |
The plane this face lies in: the perpendicular bisector of the two sites, or a clip-box side. |
val polygon: List<Vec3> |
The face outline, wound counter-clockwise as seen from the +plane.normal side — its signed area along Plane3.normal is non-negative. |
val isClip: Boolean |
true if this face is a side of the clip box rather than a bisector between two sites. |
val truncated: Boolean |
true if any of this face's corners touches the clip box, so the face is cut by the window rather than closed by the sites. |
VoronoiCell3
The convex polyhedron of points nearer to site than to any other site, intersected with the lattice's clip box.
| Declaration | Summary |
|---|---|
val siteIndex: Int |
Which input site this is the cell of, as an index into the list that was clustered. |
val site: Vec3 |
The site itself. |
val faces: List<VoronoiFace> |
The bounding faces, in no particular order. See VoronoiFace. |
val bounded: Boolean |
true only if no face is a clip face and no face is VoronoiFace.truncated — that is, the cell is closed by its neighbours alone and the clip box did not participate. |
VoronoiLattice
data class VoronoiLattice(val cells: List<VoronoiCell3>, val clipBounds: Aabb3)
One cell per input site, in input order, plus the box they were all clipped against.
| Declaration | Summary |
|---|---|
val cells: List<VoronoiCell3> |
One cell per input site, in input order — so cells[i](workspace.md#stdlib-kt--i) is the cell of site i. |
val clipBounds: Aabb3 |
The box every cell was clipped against. Its volume is what the cell volumes must sum to. |
operator fun get(siteIndex: Int): VoronoiCell3 |
The cell of site siteIndex — the same thing as cellssiteIndex``, and as loud when out of range. |
Voronoi3d
The unweighted 3-D clipper, with its knobs exposed — voronoi3d is this with the defaults.
| Declaration | Summary |
|---|---|
fun build(points: List<Vec3>, tolerance: Float = 1e-4f, clipPadding: Float = 8f): VoronoiLattice |
Build the Voronoi diagram of points, clipped to a box. |
WeightedSite3
data class WeightedSite3(val position: Vec3, val weight: Float)
A site with an influence radius: distance to it is measured as |x − position| / weight.
| Declaration | Summary |
|---|---|
val position: Vec3 |
Where the site is. |
val weight: Float |
The divisor on distance: larger claims more space. Must be strictly positive — CircularDirichlet3d.build rejects zero and negative weights by index rather than producing nonsense. |
SupportSurface3
sealed interface SupportSurface3
The surface a weighted bisector lives on: a sphere when the two weights differ, a plane when they are equal.
| Declaration | Summary |
|---|---|
val samplePoint: Vec3 |
Any one point known to lie on the surface — the seed a contour walk starts from. |
SupportSphere3
data class SupportSphere3(val center: Vec3, val radius: Float) : SupportSurface3
An Apollonius sphere: the locus where two differently-weighted sites are equidistant.
| Declaration | Summary |
|---|---|
val center: Vec3 |
Centre of the sphere. Not either site's position. |
val radius: Float |
Radius of the sphere, strictly positive — a degenerate solution is discarded rather than emitted. |
override val samplePoint: Vec3 |
A point on the sphere, taken along +x from the centre. Arbitrary but deterministic. |
SupportPlane3
data class SupportPlane3(val normal: Vec3, val d: Float) : SupportSurface3
The ordinary perpendicular bisector plane, which is what a weighted bisector degenerates to when the two weights are equal (within the build tolerance).
| Declaration | Summary |
|---|---|
val normal: Vec3 |
The plane's normal, not normalized. |
val d: Float |
The offset: the plane is the points x with dot(normal, x) == d. |
override val samplePoint: Vec3 |
The point on the plane closest to the origin. |
CircularDirichletPatch3
sealed interface CircularDirichletPatch3
One piece of one cell's boundary in the weighted diagram: the part of a bisector surface that is actually on the boundary once every other site has had its say.
| Declaration | Summary |
|---|---|
val siteIndex: Int |
The cell this patch bounds. |
val neighborSite: Int |
The site on the other side of it. |
val support: SupportSurface3 |
The unbounded surface this patch was cut out of. |
val truncated: Boolean |
true when the clip box cut this patch, so its extent is the window's and not the sites'. |
SpherePatch3
A patch of an Apollonius sphere.
PlanePatch3
A patch of an ordinary bisector plane, between two equally-weighted sites.
CircularDirichletCell3
data class CircularDirichletCell3(val siteIndex: Int, val patches: List<CircularDirichletPatch3>)
One weighted cell, as the collection of boundary patches that fence it in.
| Declaration | Summary |
|---|---|
val siteIndex: Int |
Which weighted site this is the cell of. |
val patches: List<CircularDirichletPatch3> |
The boundary pieces, sorted by CircularDirichletPatch3.neighborSite then by support size. |
CircularDirichletDiagram3
data class CircularDirichletDiagram3(val cells: List<CircularDirichletCell3>, val clipBounds: Aabb3)
One weighted cell per input site, in input order, plus the box they were all clipped against.
| Declaration | Summary |
|---|---|
val cells: List<CircularDirichletCell3> |
One cell per input site, in input order. |
val clipBounds: Aabb3 |
The box the patches were clipped against, built the same way as VoronoiLattice.clipBounds. |
operator fun get(siteIndex: Int): CircularDirichletCell3 |
The cell of site siteIndex. |
CircularDirichlet3d
The multiplicatively weighted diagram, by sampling and contouring rather than by exact construction.
| Declaration | Summary |
|---|---|
fun build(sites: List<WeightedSite3>, tolerance: Float = 1e-4f, clipPadding: Float = 8f, sphereSubdivisions: Int = 3, planeSteps: Int = 56): CircularDirichletDiagram3 |
Build the weighted diagram of sites. |
Mapper.kt
Mapper: a graph that summarises the shape of a point cloud, in any number of dimensions.
Mapper.compute gives every point a lens value, cuts the range of the lens into overlapping
bins, clusters the points within each bin, and joins clusters in neighbouring bins that share
points. The MapperGraph that comes back has a MapperNode per cluster and a MapperEdge
wherever two clusters share points, so a cloud with branches, loops or separate pieces produces
a graph with branches, loops or separate pieces; MapperGraph.components and MapperGraph.loops
count those directly.
The lens decides what the graph can see. Mapper.coordinate slices the cloud along one axis,
Mapper.eccentricity measures how far each point is from the rest and so picks out the ends of
branches whatever direction they run in, and Mapper.density measures how crowded a point's
neighbourhood is. Any array with one value per point will do.
MapperGraph.toFLines draws the graph over the cloud's first two coordinates, each node sized by
how many points it holds and coloured by its mean lens value; MapperGraph.pointsFLine draws the
points themselves; and MapperGraph.network hands the graph on as a GeometryNetwork. The
worked account is public/docs/mapper.md.
src/jvmMain/kotlin/afield/geometry/topology/mapper/Mapper.kt — read the source
Used in: Mapper graphs of point clouds
MapperNode
A node of a MapperGraph: one cluster of points within one bin of the lens. members are point indices; centroid their mean position (all MapperGraph.dimension coordinates) and lens their mean lens value.
| Declaration | Summary |
|---|---|
override fun toString() |
MapperEdge
data class MapperEdge(val a: Int, val b: Int, val shared: Int)
An edge of a MapperGraph: nodes a and b share shared points.
MapperGraph
The Mapper graph of a point cloud: a cluster per node, an edge wherever two clusters share points. It is a coarse picture of the cloud's shape: branches, loops and separate pieces show up as branches, loops and separate pieces of the graph.
| Declaration | Summary |
|---|---|
val dimension: Int |
|
val points: DoubleArray |
|
val lensValues: DoubleArray |
|
val nodes: List<MapperNode> |
|
val edges: List<MapperEdge> |
|
val nodesOfPoint: List<IntArray> by lazy |
For each point, the nodes it belongs to (one, or two where bins overlap; none if it was noise). |
fun components(): List<List<Int>> |
The graph's connected components, as lists of node ids. |
fun toFLines(minSize: Double = 4.0, maxSize: Double = 14.0, colormap: (Double) -> Vec4 = ::lensColour): List<FLine> |
The graph drawn over the cloud's first two coordinates: each edge a line between its nodes' centroids, each node a dot sized by how many points it has (minSize to maxSize pixels) and coloured by its mean lens value through colormap (low to high). |
fun pointsFLine(pointSize: Double = 2.0, colormap: (Double) -> Vec4 = ::lensColour): FLine |
The points as one pointed FLine (first two coordinates), each coloured by its lens value, so you can see what the lens measured. |
fun network(): GeometryNetwork |
The graph as a GeometryNetwork: nodes at their centroids (channels size, lens), each edge a straight curve (attribute shared). |
override fun toString() |
Mapper
Mapper (Singh, Mémoli and Carlsson, 2007): a graph summarising the shape of a point cloud.
MorseSmale.kt
The Morse–Smale complex of a 2-D scalar field: where its peaks, pits and passes are, how they are joined, and which of them are significant.
MorseSmale is the entry point. It builds a MorseSmaleComplex once, from an image channel or
a float grid, and the complex then answers for any persistence threshold without being rebuilt,
which is what makes a persistence slider cheap. Each threshold gives a MorseSmaleResult: the
surviving CriticalPoints, the separatrices between them as a GeometryNetwork, and the basin
and peak segmentation as one id per pixel. The whole hierarchy is readable from the complex as
PersistencePairs and Cancellations, and MorseSmaleTimings splits the build by stage.
Persistence is in the field's own units and removes features by significance, so it is a
different thing from blurring the field or smoothing the drawn separatrices. What the threshold
does to a picture, and what the complex is good for once you have it, are in
public/docs/morse_smale.md; following the critical points through time or scale is
afield.geometry.topology.tracking.
The complex is computed by the optional native library field_morse_smale
(MorseSmaleLibrary), so it holds native memory and is closed by the box's next run.
src/jvmMain/kotlin/afield/geometry/topology/morsesmale/MorseSmale.kt — read the source
Used in: Morse–Smale complexes: the shape of a scalar field, Tracking features through time and scale
CriticalType
What kind of critical point: a minimum is a vertex, a saddle an edge, a maximum a triangle of the grid.
Constants: MINIMUM, SADDLE, MAXIMUM.
CriticalPoint
A critical point of the unsimplified complex.
| Declaration | Summary |
|---|---|
val id: Int |
|
val type: CriticalType |
|
val value: Double |
|
val x: Double |
|
val y: Double |
|
val cancelledAt: Double |
PersistencePair
One pair of the persistence diagram. dimension 0 pairs a minimum (birth) with the saddle that merges its basin away (death); dimension 1 pairs a saddle with the maximum that fills its cycle. The global minimum's death is null and its persistence infinite.
Cancellation
data class Cancellation(val saddle: Int, val extremum: Int, val persistence: Double)
One step of the simplification hierarchy: saddle and extremum cancel at persistence.
MorseSmaleTimings
Wall-clock milliseconds spent building a complex, by stage.
MorseSmaleComplex
The discrete Morse–Smale complex of a scalar field, built once and simplified at any persistence threshold without rebuilding.
| Declaration | Summary |
|---|---|
val width: Int |
|
val height: Int |
|
val criticalPoints: List<CriticalPoint> |
Every critical point of the unsimplified complex. |
fun critical(id: Int): CriticalPoint? |
The critical point with cell id, or null. |
val persistencePairs: List<PersistencePair> |
The persistence diagram, computed independently of the complex by union-find. |
val cancellations: List<Cancellation> |
The simplification hierarchy in the order it is applied; persistence never decreases along it. |
val timings |
|
fun survivalCurve(): List<Pair<Double, Int>> |
How many critical points survive at each threshold where the count changes: (threshold, count). |
fun sample(x: Double, y: Double): Double |
The field value at (x, y) in pixel units, bilinear, clamped at the edges. |
fun simplify(persistence: Double = 0.0): MorseSmaleResult |
The complex after cancelling every saddle–extremum pair with persistence below persistence, in field units. 0 gives the unsimplified complex. Replays the stored hierarchy, so it is cheap to call repeatedly from a slider. |
val isClosed: Boolean |
|
override fun close() |
|
override fun toString() |
MorseSmaleResult
A Morse–Smale complex at one persistence threshold.
| Declaration | Summary |
|---|---|
val complex: MorseSmaleComplex |
|
val threshold: Double |
|
val criticalPoints: List<CriticalPoint> |
|
val basins: IntArray |
|
val peaks: IntArray |
|
val separatrixCount: Int |
How many separatrices survive; known without building network. |
val network: GeometryNetwork by lazy |
Built on first access, since an unsimplified noisy field can have millions of separatrices and a slider that only reads criticalPoints or basins should not pay for them. |
fun count(type: CriticalType) |
|
val descending: GeometryNetwork |
Separatrices running down to minima. |
val ascending: GeometryNetwork |
Separatrices running up to maxima (or out of the grid). |
fun toFLines(smooth: Boolean = false, traceDescending: Boolean = true, traceAscending: Boolean = true, descendingColor: Vec4 = Vec4(0.15f, 0.35f, 0.9f, 0.9f), ascendingColor: Vec4 = Vec4(0.9f, 0.25f, 0.15f, 0.9f), thickness: Double = 1.5): List<FLine> |
Separatrices as FLines: descending ones in descendingColor, ascending ones in ascendingColor. With smooth the polylines become splines through the same vertices; the topology does not change. Each line keeps curveId, startNode, endNode, and geometryNetwork, which is the whole network even when one kind is left out. |
fun criticalPointsFLine(pointSize: Double = 6.0): FLine |
The surviving critical points as one FLine of dots: minima blue, saddles green, maxima red. |
fun segmentationImage(peaks: Boolean = false): Img |
The basins (or, with peaks true, the peaks) as an RGB image, one colour per region. |
override fun toString() |
MorseSmale
Morse–Smale complexes of 2D scalar fields: critical points, the separatrices between them, basin and peak segmentation, and persistence-based simplification. Needs the optional native library field_morse_smale (see MorseSmaleLibrary).
MorseSmaleNative.kt
Finding field_morse_smale on this machine, and calling into it once found.
MorseSmaleLibrary is the half that answers without loading anything: the library's file name on
this OS, the paths it is looked for in, whether one of them exists, and the Gradle task and
per-OS shell commands that build it. NativeGeometry registers it as a
NativeGeometryCapability, so the welcome page's Geometry extensions panel can report on the
library whether or not it is installed. The path is resolved afresh on every read, so a library
built during a live session is picked up by the next box run.
The other half, internal to the package, binds the C ABI declared in
native/field-morse-smale/src/field_morse_smale.h with java.lang.foreign: one lazy downcall
handle per fms_ entry point, and a 512-byte buffer that building a complex and simplifying one
carry down and read a message out of when they hand back nothing. The counts and read-outs take
no error buffer. The first symbol lookup checks fms_abi_version against the version this
binding expects and refuses a library that disagrees, naming the task that rebuilds it.
Two kinds of native object pass through here, and only one of them is the caller's to free. Building a complex hands back an address that must be freed exactly once, and reading it copies the critical points, the cancellation order, the persistence pairs and the stage timings out into Kotlin arrays in one pass. Simplifying at a threshold builds a second native object, a view; that call copies the surviving nodes, the separatrix arcs with their traced paths, and the basin and peak segmentation out of it, then frees the view before returning.
MorseSmale is the API over it, and its only caller.
src/jvmMain/kotlin/afield/geometry/topology/morsesmale/MorseSmaleNative.kt — read the source
MorseSmaleLibrary
object MorseSmaleLibrary : NativeGeometryCapability
Where field_morse_smale is, and whether it is there. Field runs without it; describe says how to build it.
| Declaration | Summary |
|---|---|
override val name |
|
override val title |
|
override val docsPage |
|
override val buildTask |
|
override val install: Map<String, List<String>> |
|
val fileName: String |
|
val searchedPaths: List<Path> |
Every path resolvedPath tries, in order. |
val resolvedPath: Path? |
Not cached: a library built during a live session is picked up by the next box run. |
override val isAvailable: Boolean |
|
val HOW_TO_BUILD: String |
|
override fun describe(): String |
FeatureTracking.kt
Following the topological features of a 2-D field from one moment to the next, so that a peak keeps its identity while it moves, and comparing two fields by their topology alone.
A Morse–Smale complex says where the peaks, pits and passes of one field are; it says nothing
about which peak of this frame is which peak of the last. That correspondence is an assignment
problem, and FeatureMatching solves it exactly for each moment, with a cost per match and a
fixed price for leaving a feature unmatched (MatchingWeights), so births and deaths are priced
in the same optimisation as the matches. FeatureTracker drives it frame by
frame and accumulates Tracks; ScaleSpace runs the same matching over a blur series instead of
over time, where the scale a feature survives to is a measure of its size.
The input is a list of Features, which MorseSmaleResult.features reads off a Morse–Smale
result and VectorFieldTopology supplies from a vector field's critical points, so the tracker
itself is indifferent to where the features came from.
persistenceDiagramDistance answers the other question, about whole fields rather than single
features: how far apart two persistence diagrams are, exactly for diagrams of up to a few
thousand pairs and entropically for larger ones. Everything here is plain Kotlin and needs no
native library, though its usual source does. The worked account is
public/docs/topology_tracking.md.
src/jvmMain/kotlin/afield/geometry/topology/tracking/FeatureTracking.kt — read the source
Used in: Tracking features through time and scale, Vector field topology
Top-level functions
| Declaration | Summary |
|---|---|
fun MorseSmaleResult.features(minPersistence: Double = 0.0, types: Set<CriticalType> = setOf(CriticalType.MINIMUM, CriticalType.MAXIMUM)): List<Feature> |
The features of a Morse–Smale result whose persistence is at least minPersistence. Each critical point gets the persistence of its pair in the persistence diagram; the global minimum, which has no pair, gets infinity. |
fun persistenceDiagramDistance(a: List<Pair<Double, Double>>, b: List<Pair<Double, Double>>, p: Double = 2.0, epsilon: Double? = null): Double |
The p-Wasserstein distance between two persistence diagrams, each a list of (birth, death) pairs: the least total cost, over all matchings, of moving each pair of one onto a pair of the other or onto the diagonal, with \|Δbirth\|^p + \|Δdeath\|^p per move and the result's p-th root. Pairs with an infinite death are left out. Small distances mean two fields with similar topology. |
fun MorseSmaleResult.persistenceDiagram(dimension: Int): List<Pair<Double, Double>> |
The persistence diagram of a Morse–Smale result as (birth value, death value) pairs, in dimension 0 or 1. |
Feature
A topological feature at one moment: a minimum, saddle or maximum with its position, value and persistence (how much it stands out: the persistence of the persistence pair it belongs to). sourceId is its id in the source it came from, e.g. a Morse–Smale cell.
| Declaration | Summary |
|---|---|
val type: CriticalType |
|
val x: Double |
|
val y: Double |
|
val value: Double |
|
val persistence: Double |
|
val sourceId: Int |
MatchingWeights
Weights for matching features between two moments.
| Declaration | Summary |
|---|---|
val distanceWeight: Double |
|
val valueWeight: Double |
|
val birthDistance: Double |
|
val maxDistance: Double |
FeatureMatching
Optimal matching between two sets of features: pairs of indices, null for born or died.
| Declaration | Summary |
|---|---|
fun match(before: List<Feature>, after: List<Feature>, weights: MatchingWeights = MatchingWeights()): List<Pair<Int?, Int?>> |
Matches before to after at least total cost (see MatchingWeights). Returns one entry per feature: (i, j) for a match, (i, null) for a feature that dies, (null, j) for one born. |
Track
class Track internal constructor(val id: Int, val type: CriticalType)
One feature followed through time (or scale): its samples, in order, and when it was born and died.
| Declaration | Summary |
|---|---|
val samples |
(time, feature) for every moment the feature was seen. |
val born: Double |
|
val last: Double |
|
var died: Double? |
Set when the feature failed to match at a later moment; null while it is still alive. |
val isAlive: Boolean |
|
fun toFLine(): FLine |
The track's path as an FLine in the feature's own coordinates. |
override fun toString() |
FeatureTracker
class FeatureTracker(val weights: MatchingWeights = MatchingWeights())
Follows features from moment to moment, giving each a track that persists for as long as the feature can be matched, by FeatureMatching, to one at the next moment.
| Declaration | Summary |
|---|---|
val tracks: List<Track> |
Every track so far, alive or dead. |
var alive: List<Track> |
The tracks alive after the latest add, in the order of the features passed to it. |
var born: List<Track> |
Births and deaths in the latest add. |
var died: List<Track> |
|
fun add(time: Double, features: List<Feature>, motion: ((x: Double, y: Double) -> Pair<Double, Double>)? = null): List<Track> |
Adds the features seen at time, continuing, starting or ending tracks. Returns the tracks alive now. |
fun toFLines(thickness: Double = 1.5, includeDead: Boolean = true): List<FLine> |
Every track's path as an FLine, coloured by track id, the alive ones ending in a dot. |
fun aliveFLine(pointSize: Double = 7.0): FLine |
The alive features as dots, coloured by track id. |
fun network(threeD: Boolean = false): GeometryNetwork |
Every track as a curve of a GeometryNetwork, in (x, y, time) when threeD, else (x, y). Curve id = track id; samples time, value, persistence; attributes born, died (infinity while alive) and type (0 minimum, 1 saddle, 2 maximum). |
ScaleSpace
Features followed across scale: the image blurred at each σ in turn, its Morse–Smale complex computed and simplified at persistence, and its extrema tracked from each σ to the next. A feature that survives to large σ is large; one that dies early is fine detail.
| Declaration | Summary |
|---|---|
class Result(val sigmas: List<Double>, val tracker: FeatureTracker, val complexes: List<MorseSmaleResult>) |
|
fun track(image: Img, sigmas: List<Double>, persistence: Double = 0.02, maxDistance: Double = 12.0, channel: Int = 0, types: Set<CriticalType> = setOf(CriticalType.MINIMUM, CriticalType.MAXIMUM)): Result |
Tracks extrema of channel channel of image across sigmas (increasing). Features are kept if their persistence is at least persistence, and matched within maxDistance pixels. |
ContourTrees.kt
Merge trees and contour trees of a scalar field on a regular grid.
ContourTrees.joinTree follows the components of the sublevel sets as the threshold rises,
ContourTrees.splitTree does the same from the top down, and ContourTrees.contourTree follows
every component of every level set, so joins and splits are both recorded. Each hands back a
ScalarTree of TreeNodes typed by TreeNodeType and TreeArcs between them, and every arc
owns the pixels of the level-set component it stands for, readable per pixel through
ScalarTree.arcOfPixel.
ScalarTree.simplify is what makes a tree readable: it repeatedly removes leaf arcs whose value
span is under a persistence threshold, so small bumps and dips go first. ScalarTree.plot draws
the tree with height for value, ScalarTree.segmentationImage paints each pixel by the arc that
owns it, and ScalarTree.network hands the tree on as a GeometryNetwork.
The field is read as the same piecewise-linear surface Field's Morse–Smale complexes use: the
grid cut into triangles along one diagonal, with ties between equal samples broken by pixel
index. The worked account is public/docs/contour_trees.md.
src/jvmMain/kotlin/afield/geometry/topology/trees/ContourTrees.kt — read the source
Used in: Merge trees and contour trees
TreeNodeType
The kind of a tree node: where a component of the level set begins or ends (MINIMUM, MAXIMUM), where two components join or one splits (SADDLE), or where the tree is rooted (ROOT, the global extremum a merge tree ends at).
Constants: MINIMUM, MAXIMUM, SADDLE, ROOT.
TreeNode
A node of a ScalarTree: the grid vertex it sits on, its value and position (pixels).
TreeArc
data class TreeArc(val id: Int, val lower: Int, val upper: Int, val vertices: IntArray)
An arc of a ScalarTree from node lower to node upper (by value), with the grid vertices it owns: the regular vertices whose level-set component it represents.
| Declaration | Summary |
|---|---|
override fun equals(other: Any?) |
|
override fun hashCode() |
ScalarTree
A merge tree or contour tree of a scalar field on a regular grid: critical nodes joined by arcs, each arc owning the pixels whose level-set component it stands for.
| Declaration | Summary |
|---|---|
val kind: String |
|
val width: Int |
|
val height: Int |
|
val nodes: List<TreeNode> |
|
val arcs: List<TreeArc> |
|
fun node(id: Int): TreeNode |
|
val arcOfPixel: IntArray by lazy |
Per pixel, the id of the arc that owns it (nodes' own pixels belong to an arc ending there), or -1. |
fun arcsAt(id: Int): List<TreeArc> |
The arcs at node id. |
fun simplify(persistence: Double): ScalarTree |
The tree with every leaf arc shorter than persistence (its value span) removed, repeatedly, and nodes left with one arc above and one below merged away. The leaf's pixels go to the arc it joined. This is the tree's persistence simplification: small bumps and dips vanish first. |
fun plot(width: Double = 400.0, height: Double = 240.0, margin: Double = 0.0, color: Vec4 = Vec4(0.9f, 0.85f, 0.75f, 1f)): List<FLine> |
The tree drawn the classic way, as a plot: each node at height = its value (scaled into height pixels, higher values up), leaves spread evenly along x in depth-first order and every other node above the mean of the leaves below it; arcs drawn as right-angled elbows. margin insets the whole plot from the edges of the width by height area. |
fun segmentationImage(): Img |
Each arc's pixels in its own colour: the field divided into the regions the tree's arcs stand for. |
fun network(): GeometryNetwork |
The tree as a GeometryNetwork in image space: nodes at their pixels (channels type, value), each arc a straight curve between its nodes (attributes lowerValue, upperValue, pixels). |
override fun toString() |
ContourTrees
Merge trees and contour trees of 2D scalar fields.
| Declaration | Summary |
|---|---|
fun joinTree(field: Img, channel: Int = 0): ScalarTree |
The join tree: sublevel components born at minima, joining at saddles, rooted at the global maximum. |
fun splitTree(field: Img, channel: Int = 0): ScalarTree |
The split tree: superlevel components born at maxima, joining at saddles, rooted at the global minimum. |
fun contourTree(field: Img, channel: Int = 0): ScalarTree |
The contour tree, by Carr, Snoeyink and Axen's merge of the augmented join and split trees: leaves are peeled off one at a time, each giving one contour-tree arc, until one vertex is left. O(n log n) for the sort and close to linear after it. |
VectorFieldTopology.kt
The topology of a 2-D flow field: where it stands still, and the curves that divide it up.
VectorFieldTopology.compute reads the flow either as two images, u and v in their first
channel, or as one RGB image with u in channel 0 and v in channel 1. It finds the field's zeros
as FlowCriticalPoints, classified by FlowCriticalType into sources, sinks, saddles, the two
kinds of focus and centres, together with the Separatrix curves that leave each saddle along
its eigen-directions.
Those curves cut the frame into regions inside which every streamline starts at the same place
and ends at the same place, so they summarise the whole field rather than sampling it.
What comes back is ready to draw: VectorFieldTopology.streamlines for a grid-seeded picture of
the flow, VectorFieldTopology.toFLines for the separatrices and
VectorFieldTopology.criticalPointsFLine for the points, coloured by type.
VectorFieldTopology.features hands the critical points to
afield.geometry.topology.tracking.FeatureTracker, so a flow that changes can be followed frame
by frame, and VectorFieldTopology.network gives the same topology as a GeometryNetwork,
which is what the other geometry modules read.
Everything here is Kotlin, with no optional native library behind it. A scalar field can be read
the same way through VectorFieldTopology.gradientOf, which turns one into its gradient, or into
the flow along its contours. The worked account is public/docs/vector_field_topology.md.
src/jvmMain/kotlin/afield/geometry/topology/vectorfield/VectorFieldTopology.kt — read the source
Used in: Vector field topology
FlowCriticalType
What a flow does near a critical point: flows out (SOURCE), in (SINK), spirals out or in (REPELLING_FOCUS, ATTRACTING_FOCUS), circles (CENTER), or comes in along one direction and leaves along another (SADDLE).
Constants: SOURCE, SINK, SADDLE, REPELLING_FOCUS, ATTRACTING_FOCUS, CENTER.
FlowCriticalPoint
A zero of the flow, at (x, y) in pixels. jacobian is the flow's derivative there, row-major (du/dx, du/dy, dv/dx, dv/dy); divergence and curl are its trace and rotation, and strength sqrt(|det J|), how fast the flow moves away from the point (in units of the field per pixel).
| Declaration | Summary |
|---|---|
val id: Int |
|
val x: Double |
|
val y: Double |
|
val type: FlowCriticalType |
|
val jacobian: DoubleArray |
|
override fun equals(other: Any?) |
|
override fun hashCode() |
Separatrix
class Separatrix(val saddle: Int, val unstable: Boolean, val points: DoubleArray, val end: Int?)
A separatrix: the streamline that leaves a saddle along one of its four eigen-directions. An unstable one flows out of the saddle (forward in time); a stable one flows into it (traced backward). points are pixel positions starting at the saddle; end is the critical point it reached, or null if it left the frame, ran out of length, or stopped at a point weaker than minStrength.
| Declaration | Summary |
|---|---|
fun toFLine(): FLine |
VectorFieldTopology
The topology of a 2D vector field: its critical points, classified, and the separatrices joining its saddles to the rest. The separatrices divide the frame into regions inside which all streamlines start at the same place and end at the same place.
| Declaration | Summary |
|---|---|
val width: Int |
|
val height: Int |
|
val criticalPoints: List<FlowCriticalPoint> |
|
val separatrices: List<Separatrix> |
|
fun of(vararg types: FlowCriticalType) |
Critical points of the given types. |
fun toFLines(): List<FLine> |
The separatrices as FLines, unstable in UNSTABLE colour, stable in STABLE. |
fun criticalPointsFLine(size: Double = 6.0): FLine |
The critical points as one pointed FLine, each node coloured by type: sources and repelling foci red, sinks and attracting foci blue, saddles white, centres yellow. |
fun streamline(x: Double, y: Double, length: Double = 200.0, step: Double = 0.5, minSpeed: Double = 0.0): FLine |
The streamline through (x, y), traced length pixels forward and backward (RK4, steps of step pixels, stopping at the frame's edge or where the flow is slower than minSpeed). Streamlines follow the flow's direction whatever its speed, so a minSpeed keeps them out of places where the flow is only noise. |
fun streamlines(spacing: Double = 12.0, length: Double = 40.0, color: Vec4 = Vec4(0.6f, 0.6f, 0.6f, 0.5f), minSpeed: Double = 0.0): List<FLine> |
Streamlines seeded on a grid every spacing pixels, each traced length pixels both ways: a quick picture of the flow to draw the topology over. Seeds where the flow is slower than minSpeed are skipped, and lines stop there. |
fun features(minStrength: Double = 0.0): List<Feature> |
The critical points as tracking Features, for afield.geometry.topology.tracking.FeatureTracker: sinks and attracting foci as MINIMUM, sources and repelling foci as MAXIMUM, saddles as SADDLE (centres are left out). value is the divergence and persistence the strength. |
fun network(): GeometryNetwork |
The topology as a GeometryNetwork in pixel space: critical points as nodes (channels type, the FlowCriticalType ordinal, and strength), separatrices as curves from their saddle to the point they reach (attribute unstable, 1 or 0). A separatrix that leaves the frame ends at a node of type -1 at its last point. |
override fun toString() |
Polyvector.kt
Turning a raster line drawing into curves, by the polyvector-field method of Bessmeltsev and Solomon (2019).
The problem this solves is the junction: where two strokes cross, a tracer that follows ink alone
has no way to tell which outgoing branch continues which incoming one. A PolyvectorField gives
every ink pixel two unsigned directions rather than one, so a crossing is a pixel where both
strokes are represented and each can be followed through it. Polyvector is the entry point:
Polyvector.fitField solves for the field, PolyvectorField.trace reads curves off it as a
PolyvectorDrawing, and Polyvector.vectorize does both for a dark-on-light scan.
Fitting the field is the expensive step and tracing is not, which is why the controls come in two
parts: FrameFieldParameters for the field, TraceParameters for the trace. A drawing can be
re-traced at several prune ratios without the field being solved again.
The input is evidence, 0 to 1, for how much each pixel is ink, not a picture:
Polyvector.inkOf gets it from a dark-on-light drawing as 1 - luminance, and an edge or ridge
detector's output can be passed to Polyvector.fitField instead. Coordinates throughout are
pixels at pixel centres, x right and y down. The worked account, including which drawings the
method suits, is public/docs/polyvector.md.
The solver is the optional native library field_polyvector (PolyvectorLibrary), so a field
holds native memory and is closed by the box's next run.
src/jvmMain/kotlin/afield/geometry/vectorize/polyvector/Polyvector.kt — read the source
Used in: Vectorizing line drawings: polyvector fields
FrameFieldParameters
Controls for building the frame field. inkThreshold is the evidence level (0..1) above which a pixel counts as ink. regularizer pushes the two directions towards a right angle (larger gives sharper junctions, smaller follows narrow junctions more closely). smoothness trades fidelity to the image's local direction for smoothness; raise it only for very noisy input. repairPasses fill single-pixel holes in the ink mask. The defaults are the authors'.
| Declaration | Summary |
|---|---|
val inkThreshold: Double |
|
val regularizer: Double |
|
val smoothness: Double |
|
val repairPasses: Int |
TraceParameters
Controls for tracing curves on a field. pruneRatio (0..1) is how readily short side branches are removed; maxLoopPixels is the largest hole, in background pixels, that is closed over rather than kept as a loop; simplifyTolerance simplifies the final polylines (pixels); smooth applies the authors' final smoothing. The defaults are the authors'.
| Declaration | Summary |
|---|---|
val pruneRatio: Double |
|
val maxLoopPixels: Int |
|
val simplifyTolerance: Double |
|
val smooth: Boolean |
PolyvectorField
The polyvector field of a line drawing: at every ink pixel, two unsigned directions that the lines through that pixel follow. Where one stroke runs, the two directions are the stroke's direction and one across it; where strokes cross, they are the two strokes' directions. Building the field is the expensive step; trace reads curves off it, and can be called again with different TraceParameters without rebuilding.
| Declaration | Summary |
|---|---|
val width: Int |
|
val height: Int |
|
val parameters: FrameFieldParameters |
|
val mask: ByteArray |
1 where the evidence counts as ink, 0 elsewhere; row-major. |
val weight: FloatArray |
Per pixel, the authors' confidence that one direction dominates there (0..1); row-major. |
val directions: FloatArray |
Per pixel, the two field directions as unit vectors (x0, y0, x1, y1); zero off the ink. |
val singularities: List<Vec2> |
Points where the field cannot be continued smoothly, in pixels. |
val componentCount: Int |
Connected pieces of ink; each is vectorized on its own. |
val fieldMs: Double |
Milliseconds spent building the field. |
fun evidenceAt(x: Double, y: Double): Double |
The evidence at (x, y) in pixels, bilinear, clamped. |
fun directionFLines(step: Int = 4, length: Double = 3.0, color0: Vec4 = Vec4(0.95f, 0.45f, 0.1f, 1f), color1: Vec4 = Vec4(0.15f, 0.5f, 0.95f, 1f), thickness: Double = 0.8): List<FLine> |
The field drawn as small crosses, one every step pixels on the ink: each cross is the two directions there, length pixels long, the first in color0 and the second in color1, thickness wide. |
fun singularitiesFLine(pointSize: Double = 6.0, color: Vec4 = Vec4(1f, 0.1f, 0.6f, 1f)): FLine |
The singular points as one FLine of dots. |
fun trace(parameters: TraceParameters = TraceParameters()): PolyvectorDrawing |
Traces curves on this field. Repeatable with different parameters; the field is not rebuilt. |
val isClosed: Boolean |
|
override fun close() |
|
override fun toString() |
PolyvectorDrawing
A vectorized drawing: the curves traced on a field, and the streamlines they were built from. Coordinates are pixels at pixel centres, x right, y down.
| Declaration | Summary |
|---|---|
val field: PolyvectorField |
|
val parameters: TraceParameters |
|
val droppedPoints: Int |
Non-finite points the reference produced and that were removed by splitting curves there. |
val traceMs: Double |
|
val curves: List<FloatArray> |
The final curves, each as x, y floats. |
val streamlines: List<FloatArray> |
Streamlines traced along the field before any topology processing, each as x, y floats. |
fun network(junctionTolerance: Double = 0.5): GeometryNetwork |
The curves as a GeometryNetwork. A node is placed only where two or more curve ends coincide (within junctionTolerance pixels); other ends have none, and a curve that ends against another curve's side stays unjoined. Per-vertex sample evidence: the input's value under the curve. Curve attribute meanEvidence. |
val meanStrokeWidth: Double by lazy |
Ink area divided by total curve length: the drawing's mean stroke width, in pixels. |
fun coverage(radius: Double = meanStrokeWidth / 2 + 1.5): Double |
How well the curves explain the ink: the share of ink pixels within radius pixels of a curve. The default radius is half the meanStrokeWidth plus a pixel and a half, so a stroke's full width counts as explained. 1 means every inked pixel is accounted for; a dropped stroke shows as a lower number. |
fun toFLines(smooth: Boolean = false, color: Vec4 = Vec4(0.85f, 0.15f, 0.1f, 1f), thickness: Double = 1.5): List<FLine> |
The curves as FLines, each carrying curveId, startNode, endNode and geometryNetwork. |
fun streamlinesFLine(color: Vec4 = Vec4(0.2f, 0.5f, 0.9f, 0.35f), thickness: Double = 0.6): FLine |
The streamlines as one FLine, for seeing what the topology stage started from. |
override fun toString() |
Polyvector
Vectorization of line drawings by polyvector fields (Bessmeltsev & Solomon 2019): turns a raster drawing into smooth curves that follow its strokes through junctions and crossings. Needs the optional native library field_polyvector (see PolyvectorLibrary).
| Declaration | Summary |
|---|---|
val isAvailable: Boolean |
|
fun fitField(evidence: FloatArray, width: Int, height: Int, parameters: FrameFieldParameters = FrameFieldParameters()): PolyvectorField |
Builds the field of evidence (row-major, width × height, 1 = ink). |
fun fitField(evidence: Img, parameters: FrameFieldParameters = FrameFieldParameters()): PolyvectorField |
Builds the field of an evidence image (channel 0, 1 = ink). |
fun inkOf(drawing: Img): Img |
The evidence of a dark-on-light drawing: 1 - luminance. |
fun vectorize(drawing: Img, field: FrameFieldParameters = FrameFieldParameters(), trace: TraceParameters = TraceParameters()): PolyvectorDrawing |
Field and trace in one call, for a dark-on-light drawing. |
fun vectorizeAsync(drawing: Img, field: FrameFieldParameters = FrameFieldParameters(), trace: TraceParameters = TraceParameters()): CompletableFuture<PolyvectorDrawing> |
vectorize on a worker thread, for runAsync. |
PolyvectorNative.kt
Finding field_polyvector on this machine, and calling into it once found.
PolyvectorLibrary is the half that answers without loading anything: the library's file name on
this OS, the paths it is looked for in, whether one of them exists, and the Gradle task and
per-OS shell commands that build it. Its Windows list is empty, which is how a
NativeGeometryCapability says the build does not cover that OS yet, so the welcome page's
Geometry extensions panel offers no install route there. The path is resolved afresh on every
read, so a library built during a live session is picked up by the next box run.
The other half, internal to the package, binds the C ABI declared in
native/field-polyvector/src/field_polyvector.h with java.lang.foreign: one lazy downcall handle
per fpv_ entry point, two parameter structs laid out by hand with their padding (one for the
field fit, one for the trace, following the split in FrameFieldParameters and
TraceParameters), and a 512-byte buffer that the field fit and the trace carry down and read a
message out of when either reports failure. The read-outs take no error buffer. The first symbol
lookup checks fpv_abi_version against the version this binding expects and refuses a library
that disagrees, naming the task that rebuilds it.
Reading a result copies it out of native memory into Kotlin arrays: the ink mask, the per-pixel weight, the two directions and the singularities as one snapshot of the field, the streamlines and the final curves as polylines, and the field and trace milliseconds the library times for itself.
Polyvector is the API over it, and its only caller.
src/jvmMain/kotlin/afield/geometry/vectorize/polyvector/PolyvectorNative.kt — read the source
PolyvectorLibrary
object PolyvectorLibrary : NativeGeometryCapability
Where field_polyvector is, and whether it is there. Field runs without it; describe says how to build it.
| Declaration | Summary |
|---|---|
override val name |
|
override val title |
|
override val docsPage |
|
override val buildTask |
|
override val install: Map<String, List<String>> |
|
val fileName: String |
|
val searchedPaths: List<Path> |
Every path resolvedPath tries, in order. |
val resolvedPath: Path? |
Not cached: a library built during a live session is picked up by the next box run. |
override val isAvailable: Boolean |
|
val HOW_TO_BUILD: String |
|
override fun describe(): String |
PolyvectorRequiredGraph.kt
Polyvector tracing under a graph the result has to reproduce: a Polyvector.vectorize overload
that takes the curves the trace must keep, and a RequiredGraphDrawing saying which of them it
kept.
The required graph is evidence rather than a hard constraint. Its curves are drawn into the field
the tracer fits, so strokes follow them; traced ends landing within the snap tolerance of a
required node are then moved onto that node and take its id, and a required node no traced end
reached adopts a nearby traced junction instead. A connection can still be lost, which is what
RequiredGraphDrawing.missingCurves and RequiredGraphDrawing.missingNodes report and
RequiredGraphDrawing.satisfied answers in one call.
The graph usually comes from another reading of the same image, a Morse–Smale complex's network say, so that the traced curves share its nodes and their ids.
src/jvmMain/kotlin/afield/geometry/vectorize/polyvector/PolyvectorRequiredGraph.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Polyvector.vectorize(drawing: Img, required: GeometryNetwork, seed: Double = 0.6, snapTolerance: Double = 3.0, reach: Double = 12.0, field: FrameFieldParameters = FrameFieldParameters(), trace: TraceParameters = TraceParameters()): RequiredGraphDrawing |
Polyvector.vectorize for a drawing whose connectivity must agree with required, a 2-D GeometryNetwork in the drawing's pixel coordinates (a Morse–Smale complex's network, say). |
RequiredGraphDrawing
A PolyvectorDrawing traced under a required graph, and how far its connectivity keeps that graph's. network is the traced curves with every end within the snap tolerance of a required node moved onto that node and given its id; other shared ends become new nodes, numbered after the required ones. A required curve between nodes a and b is kept when some chain of traced curves joins a to b without passing through another required node.
| Declaration | Summary |
|---|---|
val drawing: PolyvectorDrawing |
|
val required: GeometryNetwork |
|
val network: GeometryNetwork |
|
val missingCurves: List<Int> |
Ids of the required curves (between two nodes) that no chain of traced curves reproduces. |
val missingNodes: List<Int> |
Ids of required nodes (those a required curve ends at) that no traced curve end reached. |
val satisfied: Boolean |
|
override fun toString() |
Colormaps.kt
The colour vocabulary the visualization helpers share: Color4, a [0,1]-component colour value
with no dependencies, and Colormaps, eight named ramps that can be sampled at any parameter.
A ramp here is a short table of evenly-spaced control points rather than a 256-entry lookup table
(nine colours for each of the matplotlib maps, seventeen for turbo, three for the house
cyan→orange), and Colormaps.sampleStops interpolates linearly between them. A caller that
colours one pixel at a time builds its own table from that: showHeatmap quantises the ramp into
1024 entries once per image rather than interpolating per pixel.
Names are matched after trimming and lowercasing, gray, greys and cyan-orange are aliases,
and an unknown name raises with Colormaps.names in the message rather than falling back to a
default. RectSL's colormap and cmap words, showHeatmap and the imaging visualizers all take
that same vocabulary, so a ramp named in one place is the same ramp in another.
src/jvmMain/kotlin/afield/graphics/color/Colormaps.kt — read the source
Used in: Imaging and computer vision
Color4
data class Color4(val r: Double, val g: Double, val b: Double, val a: Double = 1.0)
A colour with [0,1] components. A small, dependency-free colour value shared across the visualization helpers (showHeatmap, Colormaps) and the RectSL data-viz words.
| Declaration | Summary |
|---|---|
val r: Double |
|
val g: Double |
|
val b: Double |
|
val a: Double |
|
fun withAlpha(alpha: Double): Color4 |
|
fun alphaTimes(alpha: Double): Color4 |
|
fun mix(other: Color4, t: Double): Color4 |
Colormaps
Perceptually-uniform colormaps as named colour ramps — matplotlib's viridis / magma / plasma / inferno, Google's turbo, Kenneth Moreland's coolwarm diverging map, and a plain grayscale. Each map is a table of evenly-spaced RGB control points taken from the published data (the viridisLite 9-class swatches for the matplotlib family, the turbo_srgb_floats LUT sampled every 16th entry, Moreland's cool→warm table); sampleStops interpolates linearly between them. This is reusable domain code — the single source of truth for the showHeatmap visualizer and RectSL's colormap/cmap, bars, heat, and scatter words, and directly callable from Kotlin.
| Declaration | Summary |
|---|---|
val viridis: List<Color4> |
matplotlib's default: dark purple through blue and green to yellow, nine stops from the viridisLite 9-class swatches. The safe first choice for a quantity with no natural midpoint. |
val magma: List<Color4> |
Near-black through purple and red to pale cream, nine stops. Darker at the low end than viridis, which suits a heatmap whose background should read as empty. |
val inferno: List<Color4> |
magma's hotter sibling: the same near-black start, but through crimson and orange to pale yellow, nine stops. |
val plasma: List<Color4> |
Deep blue through magenta and orange to yellow, nine stops. Begins at a saturated blue rather than near-black, so the low end stays visible against a dark page. |
val turbo: List<Color4> |
Google's turbo, the rainbow redrawn to run smoothly: dark blue, cyan, green, yellow, orange, dark red, seventeen stops taken from turbo_srgb_floats every sixteenth entry plus the last. More steps are told apart than in a perceptually uniform ramp, at the cost of implying boundaries the data may not have. |
val coolwarm: List<Color4> |
Kenneth Moreland's cool-to-warm: blue through light grey to red, nine stops sampled every eighth of the way along his table. The one to reach for when zero means something — a residual, a difference of two runs — since the middle of the ramp is the neutral grey, so map the data to put zero there. |
val grayscale: List<Color4> |
Black to white, two stops, so a sample is the parameter itself in all three channels. Matched by the names gray and greys as well. |
val cyanorange: List<Color4> |
Field's house ramp: near-black, cyan through the middle, orange at the peaks, three stops. The same cyan and orange the signal plots use, and the default for the spectral heatmaps in the documentation. Also matched as cyan-orange. |
val names: List<String> |
The eight canonical names, in the order above, as stops quotes them when it is handed one it does not know. The three aliases gray, greys and cyan-orange are accepted by stops but are not among them. |
fun stops(name: String): List<Color4> |
|
fun sample(name: String, t: Double): Color4 |
Look up colormap name at parameter t in [0,1] (clamped). |
fun sampleStops(stops: List<Color4>, t: Double): Color4 |
Linearly interpolate an evenly-spaced list of control points at t in [0,1] (clamped). |
colors.kt
Colour constructors for the stdlib. Everything here returns a Vec4 of sRGB-encoded components
clamped to [0,1] with alpha last, so the results drop straight into FLine colours and uniforms.
rgb / rgba accept either 0..1 or 0..255 components — the scale is inferred per call from
whether any component exceeds 1 — and rgb255 forces the 0..255 reading. hsl is HSL with the
usual cube geometry, but the result is encoded through the sRGB transfer function on the way out,
so it is not interchangeable with CSS hsl() — see hsl for what that costs. okhsl and
okhsv are the Oklab-based equivalents; of the two only okhsl holds perceived lightness steady
across hue, because okhsv scales v against the per-hue gamut cusp exactly as HSV always has.
Hue may be given as a fraction of a turn or in degrees (magnitude above 1 is divided
by 360) and is wrapped either way.
For sets: hueSetHsl, hueSetOkhsl and hueSetOkhsv (aliased hslSet, okhslSet, okhsvSet)
space equally-separated hues around the wheel at a fixed saturation and lightness.
paletteForBackground starts from the same okhsl ring but then walks each entry's lightness up
or down until it clears a minimum WCAG contrast ratio against the supplied background, returning
the highest-contrast candidate it found when it cannot reach the target.
src/jvmMain/kotlin/afield/graphics/color/colors.kt — read the source
Used in: Colors to FLine examples, Visualizers, Displays, and Little Instruments
Top-level functions
pointing.kt
Turning a place a person is looking at back into a place in the world.
This file does one step of that: pixel → ray, the unprojection that gets you from a mouse
position, a controller pose or a headset's aim to a line in the scene. PickScene is the next
step, ray → named part, and Pointer the step before, person → pixel-or-pose. Once you have the
ray, MeshBvh.raycast, intersectRayTriangle and intersectRayPlane answer what it hits.
The two camera models
Field has two unrelated camera models, and confusing them is the main hazard here:
MeshView— the orbital camera behindTriMesh.renderPng, Field's CPU rasterizer. Yaw, pitch, a distance in multiples of the mesh's own bounding-sphere radius, and a field of view. This is the one that makes pixels with no GPU, no window and no AWT, so it is the one a headless test or a docs capture can prove things with.MeshView.rayCamerainverts it, and does so by calling straight intoVisionGeometryPreview.ViewFrame— the same object the rasterizer projects with, so the unprojection is the renderer's inverse by construction rather than by transcription.afield.graphics.vulkan.util.Camera— the live Vulkan scene's camera: alookAtmodel-view and aafield.graphics.vulkan.util.SuperProjection, which can be a perspective frustum, an off-centre one, an orthographic prism, or a Lie-group interpolation between perspective and ortho. There is no closed-form frustum to re-derive for that last case, sorayCameraForhandles all of them the same way: it inverts the clip matrix numerically, in double precision.
Conventions
- Pixel coordinates run from the top-left of the frame, y downward, which is what GLFW's
cursor reports (
Window.cursorisglfwGetCursorPos) and what an image's rows are indexed by. Pixel centres are at half-integers: pixel(0, 0)covers[0,1) x [0,1)and its centre is(0.5, 0.5). All therayThroughPixeloverloads take the pixel index and aim at its centre. - Field's clip space is Vulkan's depth range,
zin0..1, andyis up in NDC despite Vulkan's default being down —afield.graphics.vulkan.Rendererflips it with a negative-height viewport (setViewportAndScissorsetsy = rect.y + rect.handheight = -rect.h), precisely so that the familiar GL-style projection matrices keep working. So NDCy = +1is the top row. - A ray's direction is unit length, so
RayCamerahit distances are in world units and comparable between pointers.
Typical use
val mesh = icosphereMesh(2)
val view = MeshView(yawDegrees = 30.0, pitchDegrees = 20.0)
val camera = view.rayCamera(mesh)
val hit = mesh.bvh().raycast(camera.rayThroughPixel(360, 270))
println(hit) // the triangle under the middle of the picture
src/jvmMain/kotlin/afield/graphics/interaction/pointing.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun MeshView.rayCamera(mesh: TriMesh, caption: String? = null, title: String? = null): MeshViewRayCamera |
The camera TriMesh.renderPng would use for mesh under this view — inverted, so its pixels map back into the world. |
fun rayCameraFor(projection: Matrix4f, modelView: Matrix4f, pixelWidth: Int, pixelHeight: Int): ClipMatrixRayCamera |
A RayCamera over a projection and a model-view, in the order a vertex shader applies them. |
fun afield.graphics.vulkan.util.Camera.rayCamera(pixelWidth: Int = renderer.window.width, pixelHeight: Int = renderer.window.height): ClipMatrixRayCamera |
This live scene camera, inverted — so a mouse position in the window becomes a ray in the scene. |
Ray
A half-line in world space: where it starts and, in unit length, where it goes.
| Declaration | Summary |
|---|---|
val originX: Double |
Where the ray starts. |
val originY: Double |
|
val originZ: Double |
|
val dirX: Double |
Unit-length direction. |
val dirY: Double |
|
val dirZ: Double |
|
val origin: Vec3 |
A fresh Vec3 at the ray's start — mutating it does not touch the ray. |
val direction: Vec3 |
A fresh unit-length Vec3 along the ray. |
fun at(t: Double): DoubleArray |
The point t world units along the ray, in double precision. |
fun pointAt(t: Double): Vec3 |
at, as a fresh Vec3. |
override fun toString(): String |
PixelAt
data class PixelAt(val x: Double, val y: Double, val depth: Double)
Where a world point landed: a pixel, and how far in front of the eye it was.
| Declaration | Summary |
|---|---|
val x: Double |
Column, in pixels from the left edge. Not rounded — a point lands between pixels. |
val y: Double |
Row, in pixels from the top edge. |
val depth: Double |
Depth. Its units are the camera's own business — MeshViewRayCamera reports view-space z in world units, ClipMatrixRayCamera reports normalized device z in 0..1 — so compare two depths from the same camera and nothing else. |
fun isInside(width: Int, height: Int): Boolean |
True when this landed inside a width x height frame. |
RayCamera
A camera you can run backwards: pixel to ray, and world point back to pixel.
| Declaration | Summary |
|---|---|
val pixelWidth: Int |
Frame width in pixels. |
val pixelHeight: Int |
Frame height in pixels. |
fun rayThrough(x: Double, y: Double): Ray |
The ray leaving the eye through the point (x, y) in pixel coordinates, y downward. |
fun rayThroughPixel(x: Int, y: Int): Ray |
rayThrough the centre of pixel (x, y) — i.e. through (x + 0.5, y + 0.5). |
fun pixelOf(x: Double, y: Double, z: Double): PixelAt? |
Where a world point lands, or null when it is at or behind the eye and so has no pixel. |
fun pixelOf(point: Vec3): PixelAt? |
pixelOf a Vec3. |
val isNonDegenerate: Boolean |
Whether this camera actually spreads the frame out — the premise every round-trip test needs. |
fun nonDegeneracyMargin(): Double |
The smallest angle, in radians, between the rays through the frame's four quarter points. |
MeshViewRayCamera
class MeshViewRayCamera(val frame: VisionGeometryPreview.ViewFrame) : RayCamera
MeshView's camera, inverted — the one that maps pixels of a TriMesh.renderPng back into the world.
| Declaration | Summary |
|---|---|
val frame: VisionGeometryPreview.ViewFrame |
The rasterizer's projection, both ways. |
override val pixelWidth: Int |
|
override val pixelHeight: Int |
|
val eye: Vec3 |
Where the eye stands, as a fresh Vec3. |
override fun rayThrough(x: Double, y: Double): Ray |
|
override fun rayThroughPixel(x: Int, y: Int): Ray |
|
override fun pixelOf(x: Double, y: Double, z: Double): PixelAt? |
ClipMatrixRayCamera
Any projection at all, inverted numerically: a clip matrix, a frame size, and double-precision Gauss-Jordan.
| Declaration | Summary |
|---|---|
override val pixelWidth: Int |
|
override val pixelHeight: Int |
|
override fun rayThrough(x: Double, y: Double): Ray |
|
override fun pixelOf(x: Double, y: Double, z: Double): PixelAt? |
pointing_pick.kt
A scene you can point at, and a hit record that names the part you touched rather than the triangle.
Why a triangle index is not an answer
MeshBvh.raycast returns a RayHit, and RayHit.triangle is an index into an TriMesh's
indices array. That is the right thing for a physics query and the wrong thing for a person: a
human pointing at a house wants to hear "the east wall", not "triangle 4471", and a triangle
index changes every time the geometry is rebuilt, so it cannot be written down, logged, compared
between two runs or used as a selection.
So a pickable carries PickParts — a per-triangle part id with names — and a PickHit reports
the part. Three things then become possible that were not: naming the thing you touched, keeping
a selection across a re-render, and asking whether two pointers found the same part.
Where a part id comes from
In descending order of how much it is worth trusting:
- The geometry already knows.
PickParts.fromTriangulatedreadsTriangulatedGeometry.sourceFace— the polytope layer has recorded, for every triangle it emits, the polygonal face it was cut from — well covered by the polytope tests and, until now, read by nothing injvmMainoutsidepolytopeitself. This is the only source here where the id means something outside the mesh: it is anafield.geometry.polytope.ElementIdin the polytope's own topology, so it survives re-triangulation with a different triangulator and can be handed back to the polytope API. - The author said so.
PickParts.oftakes the array, for a builder that knows which triangles it just appended — seeTriMeshBuilder.groups, which records it for you. - The topology can guess.
PickParts.byComponentsplits on connected components. Cheap and cheap, and exactly as good as the mesh's connectivity: a house whose walls all share vertices is one component and therefore one part, which is a real limitation and not a bug here.
What happens when the geometry is rebuilt
PickParts is keyed by triangle index and valid only for the mesh it was built from.
Pickable checks the triangle count on construction, which catches the gross case; it cannot
catch a mesh of the same size with different triangles, and does not pretend to. If you rebuild
geometry every frame, rebuild the PickParts with it and carry the part id — never the triangle
— across the rebuild. That is the whole reason ids exist.
Typical use
val floor = planeMesh(width = 4.0, height = 4.0)
val scene = pickScene {
add("floor", floor, parts = PickParts.single(floor, "floor"))
add("lamp", icosphereMesh(2), at = Matrix4f().translation(0f, 1.5f, 0f))
}
val camera = MeshView().rayCamera(scene.mergedMesh())
val hit = scene.pick(camera.rayThroughPixel(360, 270))
println(hit?.describe()) // "lamp / lamp tri 812 1.8300 away at (0.3100, 1.7200, 0.4400)"
src/jvmMain/kotlin/afield/graphics/interaction/pointing_pick.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun TriMeshBuilder.groups(): PickParts |
A PickParts recording the groups this builder was told about — the "the author said so" source, and the one to reach for when you are assembling a model out of named pieces. |
fun pickScene(block: PickSceneBuilder.() -> Unit): PickScene |
Build a PickScene. See PickSceneBuilder.add. |
PickParts
Which named part each of a mesh's triangles belongs to — the "stable picking id through triangles" a pick record needs in order to say something a person can act on.
| Declaration | Summary |
|---|---|
val names: List<String> |
Part names, indexed by part id. |
val externalIds: List<Int?> |
For fromTriangulated, the part's id in the geometry's own world — a polytope afield.geometry.polytope.ElementId's value. null for parts that have no such identity. |
val triangleCount: Int |
Triangles this covers. Must equal the mesh's triangleCount. |
val partCount: Int |
How many named parts there are. |
val triangleParts: IntArray |
Per-triangle part indices, as a fresh array. |
fun partOf(triangle: Int): Int |
Which part triangle triangle belongs to, or UNASSIGNED. |
fun nameOfTriangle(triangle: Int): String |
The name of the part triangle triangle belongs to, or UNASSIGNED_NAME. |
fun nameOfPart(part: Int): String |
The name of part part, or UNASSIGNED_NAME when it is out of range. |
fun externalIdOfPart(part: Int): Int? |
Part part's identity in the geometry it came from, or null. See externalIds. |
fun trianglesOfPart(part: Int): IntArray |
Every triangle belonging to part part. Linear in the triangle count. |
fun triangleCounts(): IntArray |
How many triangles each part got, indexed by part. Useful in a caption. |
override fun toString(): String |
Pickable
One thing in a scene that a ray can hit: CPU geometry, a name, a part assignment, and where it stands.
| Declaration | Summary |
|---|---|
val name: String |
What to call this when a pick reports it. |
val mesh: TriMesh |
The geometry. Captured by reference; nothing here mutates it. |
val parts: PickParts |
Which part each triangle belongs to. Defaults to one part named after the object. |
val at: Matrix4f? |
Model-to-world. null means the mesh is already in world coordinates, and skips the maths. |
val enabled: Boolean |
Set false to leave it in the scene but stop it answering — a "look through this" flag. |
val bvh: MeshBvh |
The BVH over mesh, in model space. |
override fun toString(): String |
PickHit
What a pointer touched: which object, which named part, and the geometry of the contact.
| Declaration | Summary |
|---|---|
val pickable: Pickable |
The thing that was hit. |
val objectName: String |
Pickable.name, hoisted so a hit can be logged without holding the geometry. |
val part: Int |
Part id within Pickable.parts, or PickParts.UNASSIGNED. |
val partName: String |
The part's name. |
val externalId: Int? |
The part's identity in the geometry it came from — a polytope face's ElementId value — or null. This is the id worth writing down; triangle is not. |
val triangle: Int |
Index of the triangle hit, in Pickable.mesh. Changes whenever the geometry is rebuilt. |
val distance: Double |
Distance from the ray's origin, in world units. See Pickable's note on scale. |
val point: Vec3 |
The contact point, in world space. |
val normal: Vec3 |
The hit triangle's geometric normal in world space, unit length, not flipped toward the ray. |
val u: Double |
Barycentric weight on the triangle's second corner. u + v <= 1. |
val v: Double |
Barycentric weight on the triangle's third corner. u + v <= 1. |
val localPoint: Vec3 |
The contact point in the pickable's own model space — where its geometry thinks it is. |
fun isFrontFacing(ray: Ray): Boolean |
True when the normal points back toward where the ray came from. |
fun describe(): String |
One line a person can read. The println of a pick. |
override fun toString(): String |
PickScene
class PickScene(pickables: List<Pickable> = emptyList())
Several Pickables, and nearest-hit resolution across them.
| Declaration | Summary |
|---|---|
val pickables: List<Pickable> |
Everything in the scene, in the order it was added. |
operator fun get(name: String): Pickable? |
The pickable called name, or null. First match wins if two share a name. |
operator fun plus(pickable: Pickable): PickScene |
A scene with pickable added. PickScene is immutable; this returns a new one. |
val triangleCount: Int |
Total triangles across every pickable, enabled or not. |
fun pick(ray: Ray, maxDistance: Double = Double.MAX_VALUE, cullBackFaces: Boolean = false): PickHit? |
The nearest thing ray hits, or null. |
fun pickAll(ray: Ray, maxDistance: Double = Double.MAX_VALUE, cullBackFaces: Boolean = false): List<PickHit> |
Every hit along ray, nearest first — one per pickable, its own nearest. |
fun mergedMesh(): TriMesh |
Every pickable's geometry, transformed into world space and concatenated — for framing a camera on the whole scene, and for rendering it in one call. |
fun mergedParts(): PickParts |
mergedMesh's part assignment, named "object / part" so a merged hit is still legible. |
PickSceneBuilder
Collects Pickables for pickScene.
| Declaration | Summary |
|---|---|
fun add(name: String, mesh: TriMesh, parts: PickParts = PickParts.single(mesh, name), at: Matrix4f? = null, enabled: Boolean = true): Pickable |
Add something pointable. |
fun add(pickable: Pickable): Pickable |
Add an already-built Pickable. |
pointing_pointer.kt
A person pointing — mouse, XR controller or pinched hand — behind one interface, and the hover / press / drag / release machine that turns a stream of samples into an interaction.
A pointer is three things
A ray, a select value in 0..1, and whether it exists at all (PointerState). That is enough to
unify every pointing device Field can see:
- A mouse has a ray only because a camera can be inverted (
mousePointer), a select value that is0or1, and is always active while the window has a cursor. - An XR controller has a pose and a trigger, and reports
activewhen the runtime is tracking it (controllerPointer). - A pinched hand has 26 joints and a geometric pinch strength, read off
afield.graphics.vulkan.targets.OpenXRHmdTarget'shandState. Its select value is continuous and noisy, which is what the second half of this file is about (pinchPointer).
Hysteresis, in two places
Feed raw nearest-hit results to a UI and it flickers. There are two flickers here and
PointerInteraction treats them separately:
- The part flickers when the ray lands near an edge two parts share, or grazes a silhouette.
A pixel's worth of hand tremor alternates the answer every frame, and a label that alternates
every frame is unreadable. The fix is a dwell guarding every transition away from a held
hover — see
PointerInteraction.switchSamplesand the rule stated onresolveHover. Acquiring a hover is never delayed, because a lag between pointing at something and it lighting up reads as the pointer being broken. - The click flickers when a continuous select value sits on the threshold — which a
geometric
pinchStrengthderived from a 40 mm range of finger separation does constantly. The fix is a Schmitt trigger: press atPointerInteraction.pressThreshold, release at the lowerPointerInteraction.releaseThreshold. A value wobbling between them holds its state.
And once pressed, the drag belongs to what it grabbed: hover is frozen, and
PointerInteraction.dragOffset reports where the ray now crosses the plane through the original
contact point, so letting the pointer wander off the object does not drop the drag.
Typical use
val scene = pickScene { add("model", mesh, parts = builder.groups()) }
val pointer = mousePointer(camera = { view.rayCamera(mesh) }, cursor = { window.cursor },
selecting = { window.isMouseButtonPressed(GLFW_MOUSE_BUTTON_LEFT) })
val interaction = PointerInteraction(scene)
forever {
interaction.update(pointer.sample())
if (interaction.justPressed) println("grabbed ${interaction.grabbed?.partName}")
}
src/jvmMain/kotlin/afield/graphics/interaction/pointing_pointer.kt — read the source
Top-level functions
PointerState
class PointerState(val ray: Ray?, val select: Double = 0.0)
One reading of a pointing device.
| Declaration | Summary |
|---|---|
val ray: Ray? |
Where the pointer is aiming, or null when it is not there. |
val select: Double |
How hard the person is selecting, 0..1. A mouse button is 0 or 1; a trigger and a pinch are continuous, which is why PointerInteraction needs two thresholds rather than one. |
val active: Boolean |
True when there is a ray to use. |
override fun toString(): String |
Pointer
Something a person points with. Sampled, not subscribed to — see PointerInteraction.
| Declaration | Summary |
|---|---|
val name: String |
For logs and for telling two hands apart. |
fun sample(): PointerState |
Read the device now. |
LambdaPointer
class LambdaPointer(override val name: String, private val read: () -> PointerState) : Pointer
A Pointer from a lambda. What every factory below builds.
| Declaration | Summary |
|---|---|
override fun sample(): PointerState |
|
override fun toString(): String |
PointerPhase
What a PointerInteraction is doing.
Constants: IDLE, HOVER, PRESSED, DRAGGING.
PointerInteraction
A stream of PointerStates turned into an interaction: what is hovered, what is grabbed, and what a drag has moved by.
| Declaration | Summary |
|---|---|
var scene: PickScene |
What to pick against. Swap it freely between calls; the machine holds hits, not indices. |
val pressThreshold: Double |
Select value at which a press begins. Above releaseThreshold by construction. |
val releaseThreshold: Double |
Select value at which a press ends. The lower half of the Schmitt trigger: a value wobbling between the two thresholds holds whichever state it is in. |
val switchSamples: Int |
How many consecutive samples a different part must be the nearest hit before the hover moves to it. |
val maxDistance: Double |
Ignore anything further away than this. |
val cullBackFaces: Boolean |
Skip triangles facing away, as a renderer would. |
var phase: PointerPhase |
What the machine is doing. |
var hover: PickHit? |
What the pointer is over. Frozen to grabbed while PointerPhase.PRESSED or PointerPhase.DRAGGING — a drag belongs to what it grabbed. |
var grabbed: PickHit? |
What the press landed on, or null when nothing is pressed. |
var ray: Ray? |
The last sample's ray, for whoever wants to draw it. |
var justPressed: Boolean |
True for exactly the one update in which the press began. |
var justReleased: Boolean |
True for exactly the one update in which the press ended with the pointer still there — i.e. a click. A pointer that vanished mid-press sets justCancelled instead. |
var justCancelled: Boolean |
True for exactly the one update in which a press was abandoned because the pointer went away. |
var dragOffset: Vec3? |
How far the drag has moved the grabbed point, in world units, or null when nothing is grabbed. |
val dragDistance: Double |
Straight-line length of dragOffset, or 0. |
fun update(state: PointerState, moveEpsilon: Double = 1e-4): PointerPhase |
Feed one sample. Returns the new phase. |
override fun toString(): String |
PaintCanvas.kt
PaintCanvas, a simulated oil-painting canvas that FLines are painted and drawn onto, with
its statistics (PaintStats, PaperInfo) and pngRow for showing several canvases side by side.
The canvas runs on the optional native wrapper native/field-paint; see PaintCanvas for
building it. Tests: PaintCanvasTest, GesturePaintReplayTest, StereoTest.
src/jvmMain/kotlin/afield/graphics/paint/PaintCanvas.kt — read the source
Used in: Watercolour and ink
Top-level functions
| Declaration | Summary |
|---|---|
fun pngRow(canvases: List<PaintCanvas>, gap: Int = 12, background: Int = 0xffffffff.toInt(), wet: Boolean = false, relief: Double = 0.5): ByteArray |
canvases rendered side by side, top-aligned, gap pixels apart on background (ARGB), as one PNG: for comparing papers, brushes or pigments in one picture. wet renders each as it is now rather than dried; relief lights a dried render's surface as PaintCanvas.png does. |
PaintCanvas
A simulated oil-painting canvas that FLines are painted and drawn onto.
| Declaration | Summary |
|---|---|
val aspect: Double |
width / height. |
var view: Rect? |
The rectangle of FLine coordinates that maps onto the canvas, or null for canvas units. |
val pixelSize: Pair<Int, Int> |
Pixel width and height of the picture render returns. |
val widthPx: Int |
Pixel width. |
val heightUnits: Double |
Height in canvas units (the width is always 1000). |
val paintEngine: Int by lazy |
The engine version this canvas paints with (PaintCanvas.invoke's engine). |
fun brush(kind: BrushKind = BrushKind.Round, width: Double = 20.0, point: Double = 0.0, config: BrushConfig? = null, seed: Long? = null): PaintBrush |
A brush held over this canvas, width canvas units wide whatever the view. Load it with PaintBrush.load before stroking. config changes how it is made — how many bristles, how stiff and long, how much it lays and picks up — from the kind's own. |
fun knife(width: Double = 60.0): PaintKnife |
A painting knife whose blade is width canvas units long (a 4 cm blade is about 60 units on a 700 mm canvas). Tool sizes are canvas units whatever the view, as a brush's width is. |
fun rag(width: Double = 50.0, seed: Long? = null): PaintRag |
A clean cotton rag bunched into a pad width canvas units across. |
fun pencil(grade: String = "HB"): Pencil |
A graphite pencil of grade ("2H", "HB", "4B", ...) for draw, sketch and hatch. Hard grades lay a pale grey and stay sharp; soft ones lay more, darker, and blunt as they draw. |
fun chalk(): Pencil |
Natural black chalk: deep, matte, broad and crumbly; it wears fast. |
fun stroke(line: FLine, brush: PaintBrush, pressure: ClosedFloatingPointRange<Double> = 0.8..0.8, shake: Double = 1.0, attack: Double = 0.08, release: Double = 0.15, along: Boolean = false, swell: List<Double> = emptyList(), spacing: Double = 2.0, seed: Long? = null): PaintCanvas |
Drag brush along every subpath of line. |
fun touch(brush: PaintBrush, x: Double, y: Double, pressure: Double = 0.6, drag: Pair<Double, Double> = 0.0 to 0.0, twist: Double = 0.0, angle: Double = 0.0, seed: Long? = null): PaintCanvas |
Touch the brush tip down at (x, y) and lift it, with no path: a dab. Harder pressure makes a bigger, fuller mark; drag moves the hand that far while the hairs are down, twist rolls the handle (radians) and angle turns a flat's wide axis (radians). |
fun work(region: FLine, color: Vec4, handling: Handling = Handling.Body, hiding: Double = 0.85, stiff: Double = 0.8, angle: Double? = null, angleJitter: Double? = null, width: Double? = null, length: ClosedFloatingPointRange<Double>? = null, coverage: Double? = null, pressure: ClosedFloatingPointRange<Double>? = null, cross: Double? = null, order: StrokeOrder? = null, clip: Boolean? = null, feather: Double = 0.0): PaintCanvas |
Work a whole region with many strokes, the way a painter blocks in an area: the crate plans the strokes (lengths, directions, curvature, broken and restated strokes, trips to the palette) from a Handling preset, and paints each with a simulated brush. |
fun outline(line: FLine, brush: PaintBrush?, character: OutlineCharacter = OutlineCharacter.Firm, pressure: Double = 0.7, amount: Double? = null, closed: Boolean? = null, size: Double? = null): FLine |
Draw a hand outline through the rough points of line — each subpath one outline, closed when its last point returns to its first or closed says so — and drag brush along the strokes a hand would use: they lift and restart, overlap or leave gaps, overshoot corners and restate stretches, each with its own pressure. Returns those strokes as an FLine (in the same coordinates as line), so the drawing can be reused; pass brush = null to get the strokes without painting them. |
fun glaze(color: Vec4, region: FLine? = null, coats: Double = 1.0, hiding: Double = 0.05, feather: Double = 0.0): PaintCanvas |
A transparent Kubelka–Munk glaze of color, coats thick, over region (or the whole canvas). It dries at once. Glaze over dry paint: call dry first if the paint under it is still wet. |
fun spatter(brush: PaintBrush, x: Double, y: Double, toward: Pair<Double, Double>, spread: Double = 0.35, force: Double = 0.7): Int |
Flick brush at (x, y) towards toward (a direction whose length is how far the paint carries): the paint the hairs can't hold by capillarity flies off in droplets and lands in a cone spread radians either side. Only the loose paint flies — more of it the harder the force (0..1) and the more fluid the paint (thinned or turps-rich paint spatters, stiff tube paint barely leaves the brush) — and a harder flick breaks it into more, smaller drops. Each drop comes off one hair with that hair's paint, so a double-loaded brush spatters both colours. Returns how many droplets landed. |
fun knife(line: FLine, knife: PaintKnife, pressure: ClosedFloatingPointRange<Double> = 0.6..0.6, angle: Double? = null, lay: Boolean = true, lift: Double = 0.3, seed: Long? = null): PaintCanvas |
Pull knife along every subpath of line, pressed pressure from start to end, the blade across the path or at angle (radians). The flexible blade rests on the high points of the dry surface and stands off them by a gap that closes as it is pressed (a light touch rides about 0.3 mm up). Wet paint above the blade is cut into its bead, or pressed out past its ends into ridges; with lay, below it the bead fills the surface to the blade's level: a flat-topped slab, or paint on the peaks of dry impasto only. When the blade lifts, lift of the bead stays where it was. |
fun scrape(line: FLine, knife: PaintKnife, pressure: ClosedFloatingPointRange<Double> = 0.9..0.9, angle: Double? = null, seed: Long? = null): PaintCanvas |
Scrape wet paint off along line with knife, pressed hard: knife with nothing laid. |
fun wipe(line: FLine, rag: PaintRag, pressure: List<Double> = listOf(0.6), seed: Long? = null): Double |
Wipe rag along every subpath of line at pressure (one value, or values spread along the path). The pad rests on the tops of the weave and reaches into the hollows as it is pressed, and the cloth touches in creases, so a wipe lifts in streaks. Returns the mm³ of paint lifted: negative where a loaded rag left more than it took up, smearing its paint onto paint that is setting. |
fun blot(rag: PaintRag, x: Double, y: Double, pressure: Double = 0.6, seed: Long? = null): Double |
Press rag down at (x, y) and lift it off: a crumpled patch. Returns the mm³ lifted. |
fun dampen(rag: PaintRag, amount: Double = 0.5): PaintCanvas |
Dip rag's face in spirits, amount 0..1: damp, it lifts paint nearly to the ground until the spirits evaporate. |
fun refold(rag: PaintRag): PaintCanvas |
Turn a cleaner face of rag outward (no face is cleaner than what has soaked through the cloth). |
fun ragState(rag: PaintRag): RagState |
How loaded and damp rag is now, on this canvas's clock. |
fun carried(brush: PaintBrush): Carried |
The paint brush carries, in mm³ on this canvas's physical scale, and its mixed colour. |
fun carried(knife: PaintKnife): Carried |
The paint on knife's blade, as carried for a brush. |
val mmPerUnit: Double |
Millimetres per canvas unit. |
fun waterBrush(width: Double = 30.0, capacityMm3: Double? = null): WaterBrush |
A round watercolour brush width canvas units across. Its belly holds about 0.6 d³ mm³ of water for a brush d mm wide (a 6 mm round about 0.13 ml); capacityMm3 sets it instead. |
fun gesture(line: FLine, speed: Double = 80.0, pressure: List<Double> = listOf(0.6), gap: Double = 0.3, spacing: Double = 4.0, tilt: Double = 0.0, lean: Boolean = true, roll: Double? = null, facets: Double? = null): TimedGesture |
A TimedGesture along every subpath of line (one after another, gap seconds apart), moving at speed mm/s, pressing pressure (one value, a start and end, or a profile at evenly spaced stations), sampled every spacing canvas units. The handle is tilted tilt degrees from upright; with lean it leans toward where it is going (the hairs trailing behind, as a brush is pulled), and a flat's wide axis is held across the path unless roll (degrees) fixes it. Where the path doubles back on itself by more than 120°, as in scrubbing, the handle keeps its lean and the brush is pushed back. |
fun gesture3d(line: FLine, project: (Vec3) -> Vec2, mmPerWorld: Double = 1.0, speed: Double = 80.0, pressure: List<Double> = listOf(0.6), gap: Double = 0.3, spacing: Double = 2.0, tilt: Double = 0.0, lean: Boolean = true, roll: Double? = null): TimedGesture |
A TimedGesture along a line in three dimensions, as one eye of a stereo pair sees it: project takes a point of line (world units, z included) to this canvas's FLine coordinates. Lengths, speed and the pressure profile are the world's, measured along the line in 3D at mmPerWorld mm to a world unit: each sample carries its world arc (TimedGesture.Sample.s), so the two eyes' gestures press, pause and run out at the same places along the line however foreshortened each sees it. Each subpath is one movement of the hand (gap seconds apart); straight segments are sampled every spacing world units, curves finely enough to follow. The handle's tilt, lean and roll are as in gesture, its heading taken on this canvas. |
fun warp(warp: StereoWarp, ground: Boolean = true): PaintCanvas |
Make this canvas one eye of a stereo pair whose other eye (the reference) is painted without a warp: from now on texture keyed on the canvas's places (a pencil's grain, tacky paint's grab) is looked up where warp says each place is on the reference. With ground, the ground (its weave, priming and colour, or a watercolour sheet's relief and fibres) is pulled back through it too, as if made on the reference, so give the warp to a fresh canvas, before any paint. warp's canvas units are this canvas's own (1000 across), not its view's. |
fun warp(pins: List<StereoWarp.Pin>, cell: Double = 10.0, frame: Boolean = true, ground: Boolean = true): StereoWarp |
The StereoWarp through pins (this canvas's FLine coordinates, its view if it has one), solved over this canvas (StereoWarp.solve), and given to it (warp). |
fun stroke(gesture: TimedGesture, brush: PaintBrush, shake: Double = 1.0, attack: Double = 0.08, release: Double = 0.15, along: Boolean = false, seed: Long? = null): PaintCanvas |
Drag brush through gesture (e.g. from gesture3d): each movement of the hand one stroke, pressed as the gesture presses, and what is laid out along it following its world arc. |
fun knife(gesture: TimedGesture, knife: PaintKnife, angle: Double? = null, lay: Boolean = true, lift: Double = 0.3, seed: Long? = null): PaintCanvas |
Pull knife through gesture, as along a line, its tears and pressure following its world arc. |
fun wipe(gesture: TimedGesture, rag: PaintRag, seed: Long? = null): Double |
Wipe rag through gesture, its creases, wander and pressure following its world arc. Returns the mm³ lifted. |
fun sketch(gesture: TimedGesture, pencil: Pencil, passes: Int = 3, wander: Double = 2.0, tremor: Double = 0.4, seed: Long? = null): PaintCanvas |
Sketch along gesture (sketch), its searching strokes laid out along its world arc: a stereo pair's eyes break and wander the strokes at the same places along the line. The pressure is the gesture's mean. |
fun draw(gesture: TimedGesture, pencil: Pencil, tremor: Double = 0.4, ruler: Boolean = false, seed: Long? = null): PaintCanvas |
Draw gesture with pencil, its tremor and pressure following its world arc. |
fun wash(line: FLine, brush: WaterBrush, pressure: List<Double> = listOf(0.6), speed: Double = 80.0, seed: Long? = null): WashReport |
Drag a watercolour brush along line at speed mm/s (gesture), the water on the sheet flowing, sinking in and evaporating as it goes. What it laid and took up comes back. |
fun wash(gesture: TimedGesture, brush: WaterBrush, seed: Long? = null): WashReport |
Drag a watercolour brush through a timed gesture. |
fun dynamicBrush(kind: BrushKind = BrushKind.Round, width: Double = 30.0, hairs: Int = 32, segments: Int = 5, config: BrushConfig? = null, seed: Long? = null): DynamicBrush |
A brush of kind whose hairs move (DynamicBrush): hairs guide hairs of segments segments each, made as config says (its stiffness, length, point and so on). |
fun stroke(gesture: TimedGesture, brush: DynamicBrush, seed: Long? = null, splay: ((TimedGesture.Sample) -> Double)? = null, splayFan: ((TimedGesture.Sample) -> Double)? = null, splayScatter: ((TimedGesture.Sample) -> Double)? = null, cohesion: ((TimedGesture.Sample) -> Double)? = null, clumping: ((TimedGesture.Sample) -> Double)? = null): DynamicReport |
Drag a DynamicBrush through a timed gesture, in oil or watercolour as it was last loaded. |
fun stroke(line: FLine, brush: DynamicBrush, pressure: List<Double> = listOf(0.6), speed: Double = 80.0, tilt: Double = 30.0, seed: Long? = null): DynamicReport |
Drag a DynamicBrush along line at speed mm/s, tilted tilt degrees and leaning the way it goes (gesture makes the timing and the handle's pose). |
fun contactMap(): FloatArray? |
Seconds of hair contact per pixel in the last DynamicBrush stroke (row major), or null before one. |
fun contactMapPng(): ByteArray |
contactMap as a PNG, black where no hair touched and white at the most-touched pixel. |
fun hairs(brush: DynamicBrush): List<List<Triple<Double, Double, Double>>> |
brush's hairs as they are now: each a polyline of its nodes, x and y in this canvas's coordinates and z the height above the canvas in mm. Draw them with FLines to see the brush. |
fun drop(x: Double, y: Double, paint: WaterPaint, volumeMm3: Double = 20.0): PaintCanvas |
Let a drop of volumeMm3 µl of paint fall at (x, y): it spreads to a puddle, then the sheet takes it. |
fun wet(region: FLine, microns: Double = 100.0, paint: WaterPaint = WaterPaint.Clear, feather: Double = 0.0): PaintCanvas |
Lay microns µm of water over region at once, as a wide mop would: clean water to work wet into wet, or a flat wash of paint. |
fun tilt(degrees: Double, direction: Double = 90.0): PaintCanvas |
Tilt the sheet degrees from flat, falling toward direction degrees (90: down the canvas). Upright is 90°. |
fun evaporation(micronsPerSecond: Double): PaintCanvas |
Set how fast open water evaporates, µm/s: still air at 20 °C and 50 % humidity about 0.15, a warm draught several times that. |
fun marangoni(newtonsPerMetre: Double): PaintCanvas |
How far paint lowers the surface tension of the water it is in, N/m (0.008 by default; clean water is 0.072). Cleaner water pulls the surface toward it — Marangoni flow — so paint touched into a wet wash spreads into it: wet-in-wet's blooms. 0 turns it off; more spreads faster. |
fun dispersion(mm2PerSecond: Double): PaintCanvas |
An extra spreading of pigment through standing water, mm²/s, 0 by default. Not simulated: a dial for mixing the water model does not resolve, added to the Marangoni spreading. |
fun waitSeconds(seconds: Double): PaintCanvas |
Let seconds pass: water flows, sinks in and evaporates; oil paint ages with it. |
val water: WaterLedger? |
The water's books on this canvas, or null before any water touched it. |
val paper: PaperInfo? |
The paper under the water: its make-up, or null before any water touched the canvas. |
fun waterMap(map: WaterMap): FloatArray? |
One value per pixel of the water's state (row major, pixelSize); null before any water touched the canvas. |
fun waterMapPng(map: WaterMap, max: Double? = null): ByteArray |
A water map as a PNG, black at 0 and white at max (the map's own maximum when null), for looking at what the water is doing. |
fun draw(line: FLine, pencil: Pencil, pressure: List<Double> = listOf(0.6), tremor: Double = 0.4, ruler: Boolean = false, seed: Long? = null): PaintCanvas |
Draw line with pencil: each subpath one line, smoothed through its points and wavering a little (tremor canvas units), its pressure following pressure (one value, a start and end, or a profile through evenly spaced stations). A light line catches only the tops of the ground's tooth and breaks up into its grain; a heavy one fills in. The pencil wears as it draws. Graphite skips wet paint. |
fun draw(line: FLine, pencil: Pencil, pressure: Double, tremor: Double = 0.4, ruler: Boolean = false, seed: Long? = null): PaintCanvas |
draw with one pressure for the whole line. |
fun sketch(line: FLine, pencil: Pencil, pressure: Double = 0.5, passes: Int = 3, wander: Double = 2.0, tremor: Double = 0.4, seed: Long? = null): PaintCanvas |
Sketch along line: passes light strokes per subpath, each wandering about wander canvas units off the path and broken into pieces, the way a searching line is drawn. |
fun hatch(region: FLine, pencil: Pencil, angle: Double = 0.8, spacing: Double = 6.0, length: Double = 60.0, pressure: Double = 0.6): PaintCanvas |
Hatch region: short parallel strokes at angle (radians), spacing canvas units apart, each at most length long, bowed a little, heavy where they start and lifting off at the end. |
fun erase(region: FLine, strength: Double = 0.8, feather: Double = 0.0): PaintCanvas |
A kneaded eraser over region: lifts strength (0..1) of the loose drawing, better from the tops of the tooth than the hollows, and never quite all of it. Fixed or painted-over drawing stays. |
fun fixDrawing(): PaintCanvas |
Spray fixative over the drawing: an eraser no longer lifts it. |
fun stipple(region: FLine, color: Vec4, size: Double = 3.0, coverage: Double = 1.0, hiding: Double = 0.4, stiff: Double = 0.3, pressure: ClosedFloatingPointRange<Double> = 0.4..0.75, cluster: Double = 0.15, drag: Double = 0.0, dipEvery: Int = 24, feather: Double = 0.0): PaintCanvas |
Stipple region with many separate touches of an upright stippling brush size canvas units wide: coverage touches per point on average, in color. Touches into wet paint pick some of it up and mix with it, like every other brush mark. |
fun prime(color: Vec4, hiding: Double = 0.85, microns: Double = 60.0, stiff: Double = 0.4, texture: Double = 0.3, absorbent: Double = 0.0): PaintCanvas |
Lay a ground over the whole canvas: microns µm of color at hiding, with body stiff (fluid chalk-glue ≈ 0.2, oil lead white ≈ 0.6), roughened by texture before it levels. Dries the canvas first. absorbent (0..1, engine 4) makes it a chalk and glue ground that draws oil out of the paint laid on it — up to 0.6 coats' worth — so that paint dries leaner, stiffer and matte, until paint seals it. |
fun crack(kind: CrackKind = CrackKind.Aged): PaintCanvas |
Age the painting: craquelure grown through paint and ground in a stress field, cupped, with dirt in the cracks. CrackKind.Aged is a hierarchy of cracks developing patchily; CrackKind.Even one uniform network. Fitted to the ground's thickness, so it reads best on an oil ground. Dries the canvas first. |
fun wait(minutes: Double): PaintCanvas |
Let minutes of simulated time pass: wet paint stiffens, levels and sets where it lies. |
fun dry(): PaintCanvas |
Wait until every film is touch-dry. Paint laid afterwards no longer mixes with it. |
val stats: PaintStats |
Where the painting stands: its clock, the hand time spent, and how much paint is still wet. |
fun render(relief: Double = 0.0, gloss: Double = 0.0, wet: Boolean = false, light: PaintLight? = null): IntArray |
The picture as sRGB ARGB pixels, row major, pixelSize wide and high. |
fun png(relief: Double = 0.6, gloss: Double = 0.0, wet: Boolean = false, light: PaintLight? = null): ByteArray |
render encoded as PNG — what display("main", ...) shows as an image. |
fun save(path: String) |
Save a dried copy with the crate's own dithered PNG writer (no relief). |
fun copy(): PaintCanvas |
An independent copy of this canvas, wet paint and all: try something, and keep the original. |
fun saveState(path: String) |
Write the whole state, wet paint included, so PaintCanvas.load can carry on from here. |
override fun close() |
PaintStats
Clock, hand time and drying state of a PaintCanvas. Shares are of the canvas's area.
| Declaration | Summary |
|---|---|
val clockMinutes: Double |
Simulated minutes passed (PaintCanvas.wait, PaintCanvas.dry). |
val handMinutes: Double |
An estimate of the hand time the marks so far would take a painter, in minutes. |
val open: Double |
Workable wet paint: blends, lifts, levels. |
val setting: Double |
Near its gel point: stiff and sticky, barely blends. |
val tacky: Double |
A set film: doesn't move, grabs the brush. |
val dry: Double |
Touch-dry paint, or bare ground. |
val wetPaintMm3: Double |
Wet oil paint on the canvas, mm³. |
val solventMm3: Double |
Solvent (thinner) still in the open films, mm³. |
val engine: Int |
The engine the canvas paints with. |
PaperInfo
The make-up of the sheet under a canvas's water (PaintCanvas.paper).
PaintNoise.kt
Smooth 1D noise that varies a stroke along its length, the same for the same seed, so the
two eyes of a stereo pair can share it: PaintNoise.
src/jvmMain/kotlin/afield/graphics/paint/PaintNoise.kt — read the source
PaintNoise
Smooth noise for varying a stroke along itself (PaintCanvas.stroke's varied behaviours): a value from -1 to 1 that wanders as a stroke goes. Keyed on how far along a movement in the world a sample is (TimedGesture.Sample.s, mm), it lies at the same places along that movement on both canvases of a stereo pair, however long each sees it.
| Declaration | Summary |
|---|---|
fun along(mm: Double, scaleMm: Double = 60.0, seed: Long = 0, octaves: Int = 3): Double |
Smooth noise at mm along a stroke, -1..1, wandering over about scaleMm (a few octaves, each half the last's scale and strength), the same for the same seed. |
fun along(sample: TimedGesture.Sample, scaleMm: Double = 60.0, seed: Long = 0, octaves: Int = 3): Double |
along at a gesture's sample: by its world length, or its time where it has none. |
PaintToolbox.kt
Named painting tools that keep their state between strokes, and can be saved to a file and
loaded back: PaintToolbox.
src/jvmMain/kotlin/afield/graphics/paint/PaintToolbox.kt — read the source
PaintToolbox
class PaintToolbox : AutoCloseable
Tools kept by name between strokes: a brush stays dirty with what it last picked up, a knife keeps its bead, a rag its load, a pencil its wear, a watercolour brush its water and pigment, until they are reloaded, wiped or replaced.
| Declaration | Summary |
|---|---|
val names: List<String> |
The tools' names, in the order they were added. |
operator fun get(name: String): PaintTool? |
The tool called name, or null. |
operator fun set(name: String, tool: PaintTool) |
Keep tool as name, putting away (closing) whatever had that name. |
operator fun contains(name: String): Boolean |
|
fun remove(name: String) |
Put away the tool called name. |
fun brush(name: String): PaintBrush |
The brush called name. |
fun knife(name: String): PaintKnife |
The knife called name. |
fun rag(name: String): PaintRag |
The rag called name. |
fun pencil(name: String): Pencil |
The pencil (or chalk) called name. |
fun waterBrush(name: String): WaterBrush |
The watercolour brush called name. |
fun dynamicBrush(name: String): DynamicBrush |
The brush of moving hairs called name. |
val entries: List<Pair<String, PaintTool>> |
Every tool as name to tool. |
fun save(file: File) |
Write every tool, exactly, to file. |
fun write(out: DataOutputStream) |
Write every tool, exactly, to out. |
override fun close() |
Put every tool away. |
fun describe(canvas: PaintCanvas): String |
One line per tool, for an agent or a person to read: what it is, and what it carries on canvas. |
PaintTools.kt
The tools and materials a PaintCanvas is worked with: grounds, brushes, knives, rags, pencils,
and tubes of paint.
A tool is made by its canvas (PaintCanvas.brush, PaintCanvas.knife, PaintCanvas.rag,
PaintCanvas.pencil, PaintCanvas.waterBrush, PaintCanvas.dynamicBrush) and the native ones
are AutoCloseable. BrushKind and BrushConfig shape a bristle brush, Handling says how it
is moved, and Tubes names ready-mixed colours to load it with. Tests: PaintToolsTest,
DynamicBrushTest.
src/jvmMain/kotlin/afield/graphics/paint/PaintTools.kt — read the source
Used in: Paint and pencil from FLines, Drawn Strokes Become Paint, Brushes with moving hairs
PaintGround
enum class PaintGround(internal val code: Int)
What a PaintCanvas starts as.
Constants: Bare, Oil, OilRedGround, Linen, RawCotton, RawLinen.
BrushKind
enum class BrushKind(internal val code: Int)
The brushes the crate models.
Constants: Round, HogFlat, Filbert, Fan, Rigger, Badger, Stippler.
BrushConfig
How a brush is made, beyond its kind and width: any field left null keeps the kind's own. Lengths are canvas units; the rest are the crate's own scales.
| Declaration | Summary |
|---|---|
val bristles: Int? |
Number of simulated bristles (1..4000). |
val length: Double? |
How far the tips trail behind the contact at full pressure. |
val stiffness: Double? |
0 soft hair (sable, badger) .. 1 stiff hog bristle. |
val hair: Double? |
Bristle thickness multiplier: how much neighbouring hairs overlap. |
val run: Double? |
The stroke length over which a full load runs down (e-folding), canvas units. |
val lay: Double? |
Paint thickness laid at the start of a fully loaded stroke (coats). |
val pickup: Double? |
Share of the wet paint under it a bristle takes up per pass. |
val push: Double? |
Share of the wet paint a moving bristle ploughs aside per pass. |
val splay: Double? |
How much the footprint grows under pressure. |
val ragged: Double? |
Unevenness of the bristle lengths: ragged edges and broken marks. |
val point: Double? |
0 a blunt tuft .. 1 a fine point (the point argument of PaintCanvas.brush, when given here instead). |
val tooth: Double? |
How far a bristle's dryness keeps it on the canvas's tooth: 0 (as made), pressure pushes it into the weave's hollows however dry it is; 1, a bristle running dry rides only the peaks, however hard it is pressed, and breaks into dry-brush skips on a ground with grain (on a smooth one, none: it thins evenly). Past 1 drier still; below 0 the reverse. |
LoadPart
Which part of a brush a dip reaches (PaintBrush.load): a painter double-loads a brush by dipping one edge in a second pile.
PaintLight
A light to look at the canvas under (PaintCanvas.render light =): from azimuth degrees (0 from the right, 90 from the top, 135 the upper left) at elevation degrees above the canvas. Relief casts shadows along it, and wet paint has an oily sheen. strength scales the shading.
Handling
enum class Handling(internal val code: Int)
How PaintCanvas.work covers a region: the crate's own presets for its oil painters.
Constants: Broad, Body, Detail, Hatch, Glaze, Scumble, Blend.
StrokeOrder
The order PaintCanvas.work lays its strokes in.
| Declaration | Summary |
|---|---|
data object Passages : StrokeOrder |
Patch by patch, the strokes in a patch laid side by side. |
data class Sweep(val angle: Double) : StrokeOrder |
One sweep across the region in direction angle (radians; π/2 is top to bottom). |
data object Scatter : StrokeOrder |
Anywhere, in random order. |
OutlineCharacter
enum class OutlineCharacter(internal val code: Int)
How a hand draws an outline (PaintCanvas.outline).
Constants: Firm, Searching, Broken, Soft.
CrackKind
enum class CrackKind(internal val code: Int)
The two kinds of craquelure (PaintCanvas.crack).
Constants: Aged, Even.
DrawingMedium
enum class DrawingMedium(internal val code: Int)
What a Pencil is made of.
Constants: Graphite, Chalk.
Pencil
class Pencil internal constructor(val medium: DrawingMedium, val softness: Double) : PaintTool
A pencil or stick of chalk for PaintCanvas.draw, PaintCanvas.sketch and PaintCanvas.hatch. Get one from PaintCanvas.pencil or PaintCanvas.chalk. It remembers how far it has drawn, and its line widens as it blunts; sharpen starts it fresh.
| Declaration | Summary |
|---|---|
var worn: Double |
Millimetres drawn since the pencil was last sharpened. |
fun sharpen(): Pencil |
Sharpen the point. |
PaintTool
Something a painter holds and puts down again: a PaintBrush, DynamicBrush, WaterBrush, PaintKnife, PaintRag or Pencil.
PaintBrush
A brush held over a PaintCanvas, carrying paint between strokes.
| Declaration | Summary |
|---|---|
val width: Double |
Width at full pressure, canvas units. |
val config: BrushConfig |
How the brush is made: every construction value as it is, none left to the preset. |
fun load(color: Vec4, hiding: Double = 0.92, stiff: Double = 0.8, drying: Double = 1.0, amount: Double = 1.0, wipe: Double = 0.85, thinner: Double = 0.0, turps: Double = 0.0, oil: Double = 1.0, part: LoadPart? = null): PaintBrush |
Dip the brush in paint of masstone color (sRGB, as rgb(...) gives). |
fun load(tubes: Map<String, Double>, medium: Double = 0.0, amount: Double = 1.0, wipe: Double = 0.85, thinner: Double = 0.0, turps: Double = 0.0, oil: Double = 1.0, part: LoadPart? = null): PaintBrush |
Dip the brush in a pile mixed from tube paints, by name and parts by volume — load(mapOf("lead white" to 3.0, "yellow ochre" to 1.0)). The tubes mix the way paint does (Mixbox, weighted by each pigment's tinting strength), and the pile keeps their hiding, stiffness and drying rate. medium (0..1) thins it with oil. Tubes.names lists the tubes. thinner, turps, oil and part are as in the other load. |
fun wipe(fraction: Double = 0.85): PaintBrush |
Wipe fraction of the paint out of the brush on a rag, without loading any. |
val fullness: Double |
Paint left in the brush, relative to a full load. |
fun state(): ByteArray |
A snapshot of the brush, bristle by bristle: restore makes a brush that carries on exactly as this one. |
override fun close() |
PaintKnife
class PaintKnife internal constructor(internal val handle: MemorySegment) : PaintTool, AutoCloseable
A painting knife: a flexible steel blade width canvas units long, and the bead of paint it carries. It lays paint in flat slabs with ridges where it is pressed out at the blade's ends and a bead where it lifts; pressed hard without paint it scrapes the wet paint off down to the dry surface. On engine 5 a slab tears where it parts from the blade. Get one from PaintCanvas.knife.
| Declaration | Summary |
|---|---|
fun load(color: Vec4, hiding: Double = 0.92, stiff: Double = 0.9, drying: Double = 1.0, amount: Double = 0.6, wipe: Boolean = true, thinner: Double = 0.0, turps: Double = 0.0, oil: Double = 1.0): PaintKnife |
Pick up amount (0..1 of a full bead: about a millilitre on a 4 cm blade) of paint of masstone color. wipe cleans the blade first; otherwise it mixes into what is there. |
fun load(tubes: Map<String, Double>, medium: Double = 0.0, amount: Double = 0.6, wipe: Boolean = true): PaintKnife |
load from tube paints, by name and parts by volume. |
fun wipe(): PaintKnife |
Wipe the blade clean. |
val fullness: Double |
Paint on the blade, relative to a full bead. |
fun state(): ByteArray |
A snapshot of the blade and its bead, for restore. |
override fun close() |
PaintRag
class PaintRag internal constructor(internal val handle: MemorySegment) : PaintTool, AutoCloseable
A soft cotton rag bunched into a pad width canvas units across, wiped or pressed over open paint to lift it (PaintCanvas.wipe, PaintCanvas.blot). What it lifts soaks into the cloth: the face in use loads up and lifts less until it is refolded (PaintCanvas.refold); dampened with spirits (PaintCanvas.dampen) it lifts nearly to the ground. Only wet paint comes away: past its gel point paint is part of the dry picture.
| Declaration | Summary |
|---|---|
fun state(): ByteArray |
A snapshot of the rag, for restore. |
override fun close() |
WaterBrush
class WaterBrush internal constructor(internal val handle: MemorySegment) : PaintTool, AutoCloseable
A round watercolour brush: a belly that holds water, and the pigment dissolved in it. Where it touches the paper water flows between them by capillary suction, from the wetter to the drier: a full brush lays water and pigment, a damp one on a wet wash drinks it back up (with the pigment floating in it). A fast stroke lays less than a slow one. A light, nearly dry brush reaches only the peaks of the paper's tooth: drybrush comes from the paper, not a texture. Get one from PaintCanvas.waterBrush.
| Declaration | Summary |
|---|---|
fun load(paint: WaterPaint, fill: Double = 1.0, wipe: Double = 1.0): WaterBrush |
Dip it: first squeeze out wipe (0..1) of what it holds, then take up water to fill of its capacity (more than 1 overloads it) with paint dissolved in it. |
fun blot(share: Double = 0.6): WaterBrush |
Touch it to a towel: it loses share of its water and the pigment in it. |
val width: Double |
Width at full pressure, canvas units. |
val capacityMm3: Double |
Water the belly holds full, mm³. |
val waterMm3: Double |
Water in it now, mm³. |
val pigmentUg: Double |
Pigment in it now, µg. |
val fill: Double |
Its water over its capacity. |
fun state(): ByteArray |
A snapshot of the brush and the water and pigment in it, for restore. |
override fun close() |
DynamicBrush
A brush whose hairs move: a few dozen guide hairs simulated as chains of segments under a timed gesture of the handle (TimedGesture: position, pressure, tilt, lean and roll). The hairs bend against the paper and its paint, stick and slip by friction, clump when wet, and keep a bend they were forced past (retained deformation, so a brush once splayed stays splayed for a while). Load it with oil (load: upstream's bristles, following the hairs, carry and lay the paint) or with watercolour (loadWater). Get one from PaintCanvas.dynamicBrush.
| Declaration | Summary |
|---|---|
val widthMm: Double |
Width across the ferrule, mm. |
val lengthMm: Double |
Hair length out of the ferrule, mm. |
val hairCount: Int |
Guide hairs simulated. |
val friction: Pair<Double, Double> |
Static and kinetic friction against the surface. |
val fill: Double |
How full of paint or water it is, 0..1. |
val watercolour: Boolean |
True when it paints watercolour (it was last loaded with loadWater). |
val retainedBend: Double |
The bend the hairs keep at rest, radians, averaged over every joint: 0 a straight brush. |
fun set(friction: Pair<Double, Double>? = null, yieldDegrees: Double? = null, plasticity: Double? = null, recovery: Double? = null, cohesion: Double? = null, clumping: Double? = null, splay: Double? = null, splayFan: Double? = null, splayScatter: Double? = null): DynamicBrush |
How the hairs behave; null keeps a value. friction is static to kinetic against the surface; a joint bent more than yieldDegrees from its rest angle keeps some of the bend, at plasticity per second; rest bends straighten again at recovery per second; cohesion is how strongly the liquid in a wet brush draws its hairs together: 1 draws a full brush's tips to a point, more pulls harder and closer, 0 none, and below 0 pushes nearby hairs apart. |
fun load(color: Vec4, hiding: Double = 0.92, stiff: Double = 0.8, drying: Double = 1.0, amount: Double = 1.0, wipe: Double = 0.85, thinner: Double = 0.0, turps: Double = 0.0, oil: Double = 1.0, part: LoadPart? = null): DynamicBrush |
Load it with oil paint, as PaintBrush.load. |
fun load(tubes: Map<String, Double>, medium: Double = 0.0, amount: Double = 1.0, wipe: Double = 0.85): DynamicBrush |
Load it with tube paints by parts, as PaintBrush.load. |
fun loadWater(canvas: PaintCanvas, paint: WaterPaint, fill: Double = 1.0, wipe: Double = 1.0): DynamicBrush |
Load it with watercolour, as WaterBrush.load: from now on it paints watercolour, on canvas's scale. |
fun state(): ByteArray |
A snapshot of the whole brush: hairs, their retained bends, and the paint or water in it. |
override fun close() |
DynamicReport
What a segmented-brush stroke did (PaintCanvas.stroke with a DynamicBrush).
| Declaration | Summary |
|---|---|
val substeps: Long |
How many steps the engine moved the hairs in — what contacts is summed over. |
val seconds: Double |
|
val contacts: Long |
Hair contacts summed over the substeps. |
val maxContact: Double |
The most of one hair's length that lay on the paper, 0..1. |
val waterLaid: Double, val waterTaken: Double, val pigmentLaid: Double, val pigmentTaken: Double |
Watercolour: mm³ of water laid and taken up, µg of pigment laid and taken up. |
RagState
data class RagState(val load: Double, val soaked: Double, val damp: Double)
How loaded a PaintRag is (PaintCanvas.ragState).
Carried
data class Carried(val color: Vec4?, val paintMm3: Double, val solventMm3: Double)
Paint carried by a brush or knife (PaintCanvas.carried).
Tube
One tube paint in the crate's catalogue. Colours are sRGB.
| Declaration | Summary |
|---|---|
val name: String |
What the crate calls it, and the name Tubes looks a tube up by. |
val pigment: String |
What it is made of. |
val color: Vec4 |
Its masstone: the colour laid thick. |
val hiding: Double |
How much one coat hides (0 transparent .. 1 opaque). |
val stiff: Double |
Stiffness straight from the tube (0 fluid .. 1 stiff). |
val strength: Double |
Tinting strength relative to an average pigment. |
val drying: Double |
Drying rate relative to average paint. |
Tubes
The crate's tube paints, and paint-like colour mixing.
| Declaration | Summary |
|---|---|
val all: List<Tube> |
Every tube in the catalogue. |
val names: List<String> |
Their names, for PaintBrush.load. |
operator fun get(name: String): Tube |
The tube called name. |
fun mix(tubes: Map<String, Double>): Vec4 |
The masstone of a pile mixed from tubes (name to parts by volume), as paint mixes. |
fun mixSpectral(vararg colors: Pair<Vec4, Double>): Vec4 |
Mix colours as pigments rather than light, by weight: per-wavelength Kubelka–Munk (a port of spectral.js), so blue and yellow make green rather than grey. |
StereoWarp.kt
A smooth map between the two canvases of a stereo pair, so canvas texture such as weave and
grain sits on the painted surface in both eyes: StereoWarp.
src/jvmMain/kotlin/afield/graphics/paint/StereoWarp.kt — read the source
StereoWarp
Where each place on one eye's canvas of a stereo pair is on the other's (the reference): a rubber sheet stretched between pins, as dense as a grid every cell canvas units.
| Declaration | Summary |
|---|---|
val columns: Int |
Grid nodes across the canvas. |
val rows: Int |
Grid nodes down the canvas. |
val cell: Double |
Canvas units between nodes: the sheet is as fine as this, and map interpolates between. |
data class Pin(val here: Vec2, val there: Vec2) |
A place on this canvas (here) and the same place on the reference (there), canvas units. |
fun map(p: Vec2): Vec2 |
Where canvas position p is on the reference canvas. |
val largest: Double |
The largest displacement anywhere, canvas units. |
TimedGesture.kt
A hand movement sampled in time, with pressure and pen orientation, which drives a brush:
TimedGesture and its TimedGesture.Sample.
src/jvmMain/kotlin/afield/graphics/paint/TimedGesture.kt — read the source
TimedGesture
data class TimedGesture(val samples: List<Sample>)
A movement of the hand in time: where the tool is, when, how hard it presses, and how the handle is held. An afield.geometry.path.FLine gives only a path; flicking, dragging slowly, pausing, pressing and lifting need the time and the orientation too. PaintCanvas.gesture makes one from an FLine at a speed; a pen or an agent can give the samples themselves.
| Declaration | Summary |
|---|---|
data class Sample(val x: Double, val y: Double, val t: Double, val pressure: Double, val tilt: Double = 0.0, val azimuth: Double = 0.0, val roll: Double = 0.0, val s: Double = Double.NaN, val piece: Int = 0) |
One moment of a gesture. |
val duration: Double |
How long it lasts, seconds. |
fun faster(factor: Double): TimedGesture |
The same gesture factor times as fast. |
fun pause(seconds: Double): TimedGesture |
The same gesture held still for seconds at its end, the tool still down. |
fun then(next: TimedGesture, gap: Double = 0.0): TimedGesture |
This gesture followed by next, gap seconds later: as further movements of the hand (an ordinary brush lifts between them; a brush of moving hairs is carried on through). |
fun reversed(): TimedGesture |
The same path backwards, at the same pace. |
val pieces: List<List<Sample>> |
The movements of the hand, each its run of samples (Sample.piece). |
WaterMedia.kt
Watercolour and ink on paper: the Paper kinds, WaterPigments and their Watercolors
palette, the WaterPaint mixed from them, and the reports and ledgers a wash returns.
A sheet is made with PaintCanvas.paper and worked with PaintCanvas.waterBrush. WaterMap
names the fields of the sheet's state that can be read back. Test: WaterMediaTest.
src/jvmMain/kotlin/afield/graphics/paint/WaterMedia.kt — read the source
Used in: Watercolour and ink, Brushes with moving hairs
Paper
enum class Paper(internal val id: String)
A sheet of paper for watercolour and ink (PaintCanvas.paper). Each is a physical make-up — how much water it holds, how fast it drinks and wicks, its grain, tooth and sizing — not a texture: the same drop behaves differently on each because the water does. The numbers are estimates in the ranges reported for each kind of paper.
Constants: ColdPress, HotPress, Rough, Xuan, Blotting.
WaterPigment
A pigment as water carries it: its optics (Kubelka–Munk absorption k per channel and scattering s, per µg/mm² of pigment) and what decides how it moves — particle density (g/cm³) and diameterUm (dense, coarse pigment settles out onto the paper within a minute; fine pigment stays suspended), stain (per second it adsorbs onto fibres from water in the sheet, after which it can't be lifted) and lift (per second a deposit goes back into still water standing on it; a moving brush lifts much faster).
| Declaration | Summary |
|---|---|
val name: String |
What to call it. Watercolors uses the common name, and WaterPigment.of takes any. |
val k: List<Double> |
Kubelka–Munk absorption per µg/mm²: three entries, one per channel, in r, g, b order. |
val s: Double |
Kubelka–Munk scattering per µg/mm². Low is a dye, high a body colour. |
val density: Double |
Particle density, g/cm³. With diameterUm, this is what decides how fast it settles. |
val diameterUm: Double |
Particle diameter, µm. Dense, coarse pigment settles onto the paper within a minute; fine pigment stays suspended and travels with the water. |
val stain: Double |
Per second, how much adsorbs onto fibres from water in the sheet; after that it will not lift. |
val lift: Double |
Per second, how much of a deposit goes back into still water standing on it. A moving brush lifts much faster than this. |
operator fun times(concentration: Double): WaterPaint |
In paint concentration µg per mm³ of water (a strong wash is a few). |
Watercolors
Watercolour pigments, by their common names; estimates of each one's physics.
| Declaration | Summary |
|---|---|
val names: List<String> by lazy |
The names get knows. |
operator fun get(name: String): WaterPigment |
The pigment called name. |
WaterPaint
data class WaterPaint(val pigments: Map<WaterPigment, Double>)
Pigments dissolved in water, each at a concentration in µg per mm³ of water. Add two with +.
| Declaration | Summary |
|---|---|
operator fun plus(o: WaterPaint) |
Both paints at once: concentrations add pigment by pigment, so two washes make a stronger one rather than an average. |
WashReport
What a watercolour stroke did, mm³ of water and µg of pigment.
PigmentLedger
One pigment's books on a sheet, µg.
| Declaration | Summary |
|---|---|
val name: String |
The pigment these books are for, by WaterPigment.name. |
val suspended: Double |
In the surface water, moving with it. |
val carried: Double |
In the water in the sheet's pores, moving with it. |
val deposited: Double |
Settled out of water still on or in the sheet: water flowing through carries it on. |
val glued: Double |
Settled and dried in place, its gum set: rewetting lifts it slowly, a brush faster. |
val bound: Double |
Stained into the fibres: it no longer lifts. |
val added: Double |
All of it that has been laid on the sheet, however it has since moved. |
val taken: Double |
All of it a brush has lifted back off. |
val runoff: Double |
All of it carried off the edge of the sheet by water that ran away. |
val sealed: Double |
Sealed into the picture under paint laid over it. |
val imbalance: Double |
On and in the sheet, less what the ledger says should be: rounding only. |
WaterLedger
The water on a sheet and every volume that came or went, mm³ (µl), at clockSeconds.
| Declaration | Summary |
|---|---|
val clockSeconds: Double |
The canvas's own clock when these numbers were read. It advances as strokes are painted and as PaintCanvas.waitSeconds and PaintCanvas.wait let time pass, not with wall time. |
val surfaceMm3: Double |
Standing on the sheet right now. |
val absorbedMm3: Double |
In the sheet's pores right now. |
val addedMm3: Double |
All the water a brush has laid down. |
val takenMm3: Double |
All the water a brush has lifted back off. |
val evaporatedMm3: Double |
All the water that has evaporated. |
val runoffMm3: Double |
All the water that has run off the edge of the sheet. |
val absorbedInAllMm3: Double |
All the surface water that has gone into the sheet so far. |
val pigments: List<PigmentLedger> |
The same books kept per pigment, one entry for each the sheet has seen. |
val imbalanceMm3: Double |
Water on and in the sheet, less what the ledger says should be: rounding only. |
val dry: Boolean |
True once no water stands on or in the sheet. |
WaterMap
enum class WaterMap(internal val code: Int, val unit: String)
A map of the water's state (PaintCanvas.waterMap): one value per pixel.
Constants: SurfaceWater, AbsorbedWater, Saturation, Pigment, Suspended, Deposited, Bound, Relief, Sealed, Carried, Glued.
gestureReplay.kt
Gesture pad to brush: a take recorded on a gesture(...) pad, or its x and y keyframe lanes after
editing, painted onto a canvas as a TimedGesture. The pad's square is laid over a rectangle of
the canvas and the take's own timing is kept, so how fast the hand moved is how fast the brush
moves.
A pad carries pressure, tilt and twist only when it was made with pen = true, and only where the
pointer reported them: a mouse reports a flat 0.5 pressure while down and nothing else, and a
dimension that never left its resting value reads as unrecorded. Each one missing is filled the
way PaintCanvas.gesture(line, …) fills it from a path, which is to say a steady press, a steady
lean toward where the stroke is going, and a flat held across its path.
src/jvmMain/kotlin/afield/graphics/paint/gestureReplay.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun PaintCanvas.gesture(samples: List<PenSample>, xRange: Pair<Double, Double> = -1.0 to 1.0, yRange: Pair<Double, Double> = -1.0 to 1.0, into: Rect? = null, pressure: Double = 0.6, pressureScale: Double = 1.2, tilt: Double = 30.0, lean: Boolean = true, timeScale: Double = 1.0): TimedGesture |
The take samples, recorded on a pad spanning xRange by yRange (y up, as the pad shows it), as a TimedGesture on this canvas: the pad's square laid over into (in this canvas's FLine coordinates, y down; by default the largest square in the middle 80% of the canvas), its timing kept and stretched by timeScale. |
fun PaintCanvas.gesture(pad: GestureHandle, into: Rect? = null, pressure: Double = 0.6, pressureScale: Double = 1.2, tilt: Double = 30.0, lean: Boolean = true, timeScale: Double = 1.0): TimedGesture |
The take recorded on pad as a TimedGesture on this canvas; see the List<PenSample> overload. A pad made with pen = true brings its pressure, tilt and twist along. |
fun PaintCanvas.gesture(x: KeyframeEvaluator, y: KeyframeEvaluator, xRange: Pair<Double, Double> = -1.0 to 1.0, yRange: Pair<Double, Double> = -1.0 to 1.0, into: Rect? = null, pressureLane: KeyframeEvaluator? = null, tiltXLane: KeyframeEvaluator? = null, tiltYLane: KeyframeEvaluator? = null, twistLane: KeyframeEvaluator? = null, pressure: Double = 0.6, pressureScale: Double = 1.2, tilt: Double = 30.0, lean: Boolean = true, timeScale: Double = 1.0, samplesPerSecond: Double = 60.0): TimedGesture |
Keyframe lanes x and y — a take baked with bakeGesture and edited in the keyframe editor — as a TimedGesture on this canvas, sampled samplesPerSecond times a second and at every key. pressureLane (pen pressure, 0 to 1, as GestureHandle.penLane("pressure") bakes it) presses the brush where it is given; otherwise pressure does, throughout. tiltXLane, tiltYLane and twistLane (degrees, penLane("tiltX") and so on) hold the handle as the pen was held. The rest is as in the List<PenSample> overload. |
fun penPose(tiltX: Double, tiltY: Double): Pair<Double, Double> |
A pen's PointerEvent tiltX/tiltY (degrees toward +x and toward +y) as a TimedGesture.Sample's tilt from upright and the azimuth it leans toward, both degrees. Upright leans toward 0. |
AccelerationStructure.kt
Vulkan ray-tracing acceleration structures: AccelerationStructure builds a bottom-level structure
from triangles or AABBs, or a top-level one from RayTracingInstances, on the Renderer found
from context.
Geometry is described by AccelerationStructureTriangles and AccelerationStructureAabbs;
identityTransform3x4 is the default instance transform. After a build,
accelerationStructureBuildBarrier makes it visible to later commands that read it, and
rayTracingMemoryBarrier orders ray-tracing shader writes before later shader reads.
src/jvmMain/kotlin/afield/graphics/render/AccelerationStructure.kt — read the source
Used in: Ray Tracing Examples, Compute your ray-traced geometry
Top-level functions
| Declaration | Summary |
|---|---|
fun identityTransform3x4(): FloatArray |
|
fun accelerationStructureBuildBarrier(command: CommandBuffer) |
|
fun rayTracingMemoryBarrier(command: CommandBuffer) |
AccelerationStructureAddress
interface AccelerationStructureAddress
A BLAS device address usable in a TLAS instance, including NV cluster BLAS without a Vk handle.
HasAccelerationStructure
interface HasAccelerationStructure : AccelerationStructureAddress
| Declaration | Summary |
|---|---|
val vkAccelerationStructure: Long |
AccelerationStructureTriangles
AccelerationStructureAabbs
| Declaration | Summary |
|---|---|
val aabbBuffer: HasBuffer |
|
val stride: Long |
|
val primitiveCount: Int |
|
val flags: Int |
|
val primitiveOffset: Int |
|
val firstVertex: Int |
|
val transformOffset: Int |
RayTracingInstance
| Declaration | Summary |
|---|---|
val accelerationStructure: AccelerationStructureAddress |
|
val transform: FloatArray |
|
val customIndex: Int |
|
val mask: Int |
|
val shaderBindingTableRecordOffset: Int |
|
val flags: Int |
AccelerationStructure
AdvancedVulkanFallbackSmoke.kt
src/jvmMain/kotlin/afield/graphics/render/AdvancedVulkanFallbackSmoke.kt — read the source
AdvancedVulkanFallbackSmoke
object AdvancedVulkanFallbackSmoke
Force optional feature gates off on a supporting device and exercise ordinary shader inference.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
ClusterGeometry.kt
Builds NVIDIA cluster acceleration structures from compute-shader output: ClusterGeometry validates TriangleCluster records, builds cluster-level acceleration structures and one cluster BLAS for ray tracing.
It requires VK_NV_cluster_acceleration_structure; ClusterGeometryInput names the producer buffers it reads.
src/jvmMain/kotlin/afield/graphics/render/ClusterGeometry.kt — read the source
Used in: Compute your ray-traced geometry
TriangleCluster
One Field cluster description, eight uint32 words / 32 bytes, usable by ordinary compute shaders. Positions are vec4 (xyz in world/object units); indices are uint32 LOCAL to this cluster's vertex slice. A cluster may change counts, positions and indices on every build. IDs stay yours. Invalid GPU records become degenerate geometry and set ClusterGeometry.status rather than passing unchecked addresses/counts to Vulkan. No host readback occurs during builds.
| Declaration | Summary |
|---|---|
val clusterId: Int,val geometryId: Int |
|
fun write(out: ByteBuffer) |
ClusterGeometryInput
Borrowed producer buffers. Keep alive through build completion; synchronize any subsequent writes. clusters contains TriangleCluster records. Buffer sizes, not arbitrary user-supplied addresses, determine bounds in the GPU validation/conversion pass.
ClusterGeometry
General NV Mega Geometry builder: Field compute -> validated clusters -> CLAS -> one cluster BLAS. It owns bounded build/storage/scratch resources, never producer buffers. Single graphics queue; call build explicitly after a producer changes geometry. Static geometry needs only one build. Recreate to increase capacity. A rebuild changes this BLAS's bounds: rebuild the containing TLAS. Cluster BLAS has an address but no VkAccelerationStructure handle; use it in RayTracingInstance.
| Declaration | Summary |
|---|---|
val trianglesPerCluster: Int |
|
data class Limits(val vertices: Int,val triangles: Int,val scratchAlignment: Int,val clusterAlignment: Int, val blasAlignment: Int,val maxGeometryId: Int) |
|
val limits: Limits |
|
val status: VulkanBuffer |
One uint per cluster: 0 valid, 1 invalid descriptor, 2 invalid index, 4 nonfinite vertex. |
fun build(command: CommandBuffer,pipeline: RayTracingModule,input: ClusterGeometryInput,count: Int=capacity) |
Record outside a render pass on the graphics queue. The pipeline must opt into cluster AS; Vulkan requires it bound during cluster builds. Field rebinds descriptors/state when tracing. |
override fun deviceAddress(): Long |
|
override fun close() |
ClusterGeometryDemo.kt
A computed-terrain demo of ClusterGeometry: a compute shader writes every patch's vertices, indices and cluster
descriptions, and a ray-tracing pass renders the resulting cluster acceleration structure.
It needs a device with VK_NV_cluster_acceleration_structure.
src/jvmMain/kotlin/afield/graphics/render/ClusterGeometryDemo.kt — read the source
ClusterGeometryDemo
Computed terrain, using exactly the public ClusterGeometry input contract. The producer shader owns all vertices, indices and descriptions, and is replaceable through ordinary live reload. Each patch has 8x8 cells; topology switches diagonals without changing allocations.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
|
val columns: Int |
|
val rows: Int |
|
val width: Int |
|
val height: Int |
|
val name: String |
|
val count |
|
val geometry |
|
val positions |
|
val indices |
|
val descriptions |
|
val producer |
|
val output |
|
val gpuMilliseconds |
|
var time |
|
var animate |
|
var topology |
|
var view |
|
var invalidCluster |
|
var invalidMode |
|
fun install(): ClusterGeometryDemo |
|
override fun render(command: CommandBuffer, index: Int) |
|
override fun close() |
ClusterGeometrySmoke.kt
src/jvmMain/kotlin/afield/graphics/render/ClusterGeometrySmoke.kt — read the source
ClusterGeometrySmoke
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
ComputeMesh.kt
ComputeMesh is a compute dispatch with its own push constants inside a ComputeModule; the
rest of the file is the barriers that order compute work against transfers, graphics and host
reads.
Any ComputeTask can be added to a module, and computeTask wraps a lambda as one.
computeBarrier and zeroBuffer are ready-made tasks; computeMemoryBarrier,
graphicsMemoryBarrier, transferMemoryBarrier, hostReadBarrier and
graphicsHostReadBarrier record a Vulkan synchronization-2 barrier into a command buffer.
src/jvmMain/kotlin/afield/graphics/render/ComputeMesh.kt — read the source
Top-level functions
ComputeTask
| Declaration | Summary |
|---|---|
fun compute(inside: ComputeModule, cmd: CommandBuffer, pushConstantBuffer: ByteBuffer) |
ComputeMesh
class ComputeMesh(var renderer: Renderer = fromContext()) : ComputeTask
| Declaration | Summary |
|---|---|
var indirect: afield.graphics.vulkan.buffers.FrameBufferGroup? |
Optional GPU-written VkDispatchIndirectCommand; no CPU count readback. |
var indirectOffset: Long |
|
class FromUniform |
|
class BoundFromStructure<T> |
|
lateinit var inside: ComputeModulePointer |
|
var update |
Named sequence hooks advanced once per compute call, before push constants and dispatch. |
var push |
Push-constant values and bindings written to the buffer before each dispatch. |
var x |
|
var y |
|
var z |
|
class FromStructure<T> |
|
override fun compute(inside: ComputeModule, cmd: CommandBuffer, pushConstantBuffer: ByteBuffer) |
ComputeModule.kt
A compute pipeline as a renderable module: ComputeModule compiles and introspects a compute
shader, owns its descriptor sets, and dispatches the ComputeTasks registered in its meshes.
Boxes hold it through a ComputeModulePointer, which swaps in a rebuilt module when the shader
is reloaded and carries live values across the interface change.
src/jvmMain/kotlin/afield/graphics/render/ComputeModule.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun ComputeModule.pointer() |
ComputeModulePointer
class ComputeModulePointer(var current: ComputeModule) : Renderable
| Declaration | Summary |
|---|---|
var lastReloadReport: ModuleReloadReport? |
What the last successful reload did to the interface and which values it could not carry. |
override fun render(command: CommandBuffer, bounds: Rectanglef) |
|
enum class ReloadOutcome |
What a reloadOutcome did. |
fun reload(p: Parameters, onError: (String, Throwable?) -> Unit = p.errorHandler): Boolean |
True when the module was replaced. False covers both "already compiled" and "did not compile"; reloadOutcome tells them apart. |
fun reloadOutcome(p: Parameters, onError: (String, Throwable?) -> Unit = p.errorHandler): ReloadOutcome |
reload, saying which of the three things happened. |
operator fun plusAssign(m: Pair<String, BeforeTarget>)+2 overloads · also Pair<String, ComputeTask>, Pair<String, Target> |
|
fun zeroBefore(name: String, buffer: FrameBufferGroup)+1 overload · also VulkanBuffer |
|
fun enable() |
|
fun disable() |
ComputeModule
class ComputeModule(val parameters: Parameters) : Renderable
FLineUpload.kt
Writes FLines into GPU vertex streams: pointsOut, linesOut and trianglesOut sample lines into DynamicallyWriteable position and colour buffers.
FLineMap is a named, ordered collection of lines with a revision hash for change detection.
src/jvmMain/kotlin/afield/graphics/render/FLineUpload.kt — read the source
Top-level functions
FLineMap
class FLineMap : LinkedHashMap<String, FLine>()
Ordered map of named FLines with a lightweight revision hash.
| Declaration | Summary |
|---|---|
val rev: Long |
Combined revision hash of all contained lines. |
operator fun plusAssign(f: Pair<String, FLine>)+2 overloads · also FLine, Named<FLine> |
|
override fun toString(): String |
FanOutDispatch.kt
src/jvmMain/kotlin/afield/graphics/render/FanOutDispatch.kt — read the source
FourVisualizers.kt
Inline value printers for the Vulkan graphics types, so a renderer, buffer or texture shows as a readable table when a box prints it.
registerFourVisualizers runs at each new root and registers a printer for Renderer, Camera,
Window, the device and swapchain types, images and buffers, uniform Structures, the module and
module-pointer types, Mesh, render-to-texture targets, LoadedTexture and StorageImage. Each
printer renders a small coloured table of the object's state: frame counter and upload bytes for a
renderer, extents and formats for images, field values for a structure. TexturePreviewCache
holds the downscaled previews shown beside texture values.
src/jvmMain/kotlin/afield/graphics/render/FourVisualizers.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun registerFourVisualizers() |
TexturePreviewCache
Thread-safe cache of downsampled texture previews keyed by VkImage handle. Call invalidate when an image is destroyed or recreated.
| Declaration | Summary |
|---|---|
fun invalidate(vkImage: Long) |
|
fun clear() |
|
fun getCachedImgTag(vkImage: Long): String? |
Return cached <img> tag HTML, or null. |
fun capture(renderer: Renderer, vkImage: Long, format: Int, srcWidth: Int, srcHeight: Int, currentLayout: Int, maxDim: Int = MAX_DIM): String? |
Synchronous one-shot GPU readback of vkImage into a small PNG preview. |
GeometryAgentTools.kt
A worked, public example of giving Field's AI its own tools — two of them, over the geometry the loaders in MeshFromDisk.kt read off disk.
What this is for
afield.ai.tools.AgentToolExtensions is how any code outside afield.ai.core adds tools to AI4.
This file is the runnable half of public/docs/aiFeatures.md's "Give the AI a tool of your own",
built out of parts that are useful on their own:
meshFromAnyFile reads OBJ, STL, PLY, glTF/GLB and everything else Assimp handles, and
afield.geometry.mesh.renderPng rasterises the result with no GPU, no window and no AWT.
That last property is what makes the second tool worth copying. A model asked "is the scan I dropped
in there the right way up?" can look, because InspectGeometryFileResult implements
CarriesToolImages and the picture travels as an image rather than as base64 spent in a JSON string.
Using it
// in a box:
GeometryAgentTools.addSearchRoot(File(System.getProperty("user.home"), "scans"))
GeometryAgentTools.register()
// then open AI Chat and ask about the files in there.
GeometryAgentTools.register is idempotent, so leaving those two lines in a box you re-run is
fine, and a re-run reaches a conversation that is already open.
The three things this example is trying to demonstrate
- A gate on the tool set. Both tools are unavailable until a search root is added. A tool set that is always on costs its schema on every request in every conversation, including the ones that have nothing to do with it.
- A result that summarises itself. Both results implement
SummarisesForToolLog, so the tool I/O log — whichAiToolUseImprovementMonitorreads — records what happened rather than a class name. - Refusing to read outside the roots. These tools take a path from a language model. The
roots are the entire permission model, and
resolveInsideRootsis where it is enforced; a symlink or a../that escapes is an error, not a read. Every tool that takes a path from a model needs an equivalent, and it is the part an example is most likely to leave out.
What this does not do
It does not modify anything. There is no ConvertGeometryFile here: a writing tool
needs a confirmation protocol (the personal house cluster's commit=false preview pattern), which is
a bigger subject than one example should carry.
src/jvmMain/kotlin/afield/graphics/render/GeometryAgentTools.kt — read the source
Used in: Geometry from disk
GeometryAgentTools
Registers the geometry inspection tools with AI4 once a search root has been added.
| Declaration | Summary |
|---|---|
val LOADABLE_EXTENSIONS: Set<String> |
Extensions meshFromAnyFile can open, lower-cased and without the dot. |
fun addSearchRoot(dir: File) |
Let the AI see geometry files under dir. Nothing outside a root added here is readable. |
fun clearSearchRoots() |
Take every root away, which also makes enabled false. Symmetric with addSearchRoot, and for tests. |
fun searchRoots(): List<File> |
The roots, canonicalised, in the order they were added. |
fun enabled(): Boolean |
Whether there is anywhere to look at all. Both tools check this before they run. |
fun register() |
Register both tools with AI4. |
fun unregister() |
Retire both tools. A conversation that is open loses them on its next turn. |
ListGeometryFiles
Tool 1: what is there.
| Declaration | Summary |
|---|---|
val nameContains: String |
|
val limit: Int |
GeometryFileEntry
| Declaration | Summary |
|---|---|
val path: String |
|
val root: String |
|
val format: String |
|
val bytes: Long |
ListGeometryFilesResult
| Declaration | Summary |
|---|---|
val files: List<GeometryFileEntry> |
|
val totalMatches: Int |
|
val truncated: Boolean |
|
val searchRoots: List<String> |
|
override fun toolLogSummary(): String |
InspectGeometryFile
Tool 2: what one of them is, and what it looks like.
| Declaration | Summary |
|---|---|
val path: String |
|
val render: Boolean |
|
val yawDegrees: Double |
|
val pitchDegrees: Double |
|
val wireframe: Boolean |
InspectGeometryFileResult
What a model gets back — and, separately, what it gets to see.
| Declaration | Summary |
|---|---|
val path: String |
Relative to root, which is the form ListGeometryFiles reports and accepts back. |
val root: String |
|
val format: String |
|
val bytesOnDisk: Long |
|
val vertexCount: Int |
|
val triangleCount: Int |
|
val hasNormals: Boolean |
|
val hasUvs: Boolean |
|
val hasColors: Boolean |
|
val extent: List<Double> |
|
val minCorner: List<Double> |
|
val surfaceArea: Double |
|
val volume: Double? |
Null unless the mesh is watertight — a volume for an open shell is a number with no meaning. |
val watertight: Boolean |
|
val eulerCharacteristic: Int |
|
val boundaryLoopCount: Int |
|
val notes: List<String> |
|
override val toolImages: List<ToolImage> |
|
override fun toolLogSummary(): String |
Mesh.kt
Mesh is the vertex-buffer draw call of a graphics module: it holds the vertex inputs and push
constants the module's shaders declare, and draws them on the Renderer found from context.
Its layout has to agree with the vertex-buffer structure the module's pipeline was built with.
fullscreenTriangle makes the one-triangle mesh used for full-screen passes.
src/jvmMain/kotlin/afield/graphics/render/Mesh.kt — read the source
Used in: Field 4 Graphics, Field Probes: instrumenting shaders, FieldML's JVM bridge
Top-level functions
| Declaration | Summary |
|---|---|
fun fullscreenTriangle(module: ModulePointer = fromContext()): Mesh |
Three non-indexed vertices for a fullscreen shader that derives positions from gl_VertexIndex. module defaults to the current box's graphics module. No vertex attributes are allocated and nothing is attached or presented: add the returned mesh to the module under your chosen name. |
Top-level values
| Declaration | Summary |
|---|---|
val restart: Int |
The primitive-restart index: write it into a strip or fan index buffer to end the current run and begin another. Meshes index with VK_INDEX_TYPE_UINT32, so the value is all thirty-two bits set, and a module's pipeline turns restart on for exactly the strip and fan topologies. |
FromInput
| Declaration | Summary |
|---|---|
operator fun getValue(inside: Mesh, property: KProperty<*>): VertexBuffer |
Mesh
class Mesh(var renderer: Renderer = fromContext())
| Declaration | Summary |
|---|---|
var renderer: Renderer |
The renderer the vertex and index buffers are allocated on, and whose frame counter decides when prepareForDraw has already run for this frame. |
lateinit var inside: ModulePointer |
Module pointer supplying the mesh's shader inputs and push-constant layout. |
var v |
The vertex buffers by the shader input location they feed. resize creates one per input the module declares, grows or trims it to the vertex count, and frees any whose input the current shaders do not declare. |
var vn |
The same buffers again, by input name — the map input reads, and the one to reach for from box code, where the shader's position is easier to name than its location. |
var t: VertexBuffer? |
The index buffer, or null when the mesh is not indexed. resize allocates it only for a non-zero triangle count, and drops it again when that count goes back to zero. |
var numTriangle: Int |
Primitives the last resize made room for, or -1 before the first one. Above zero it is an indexed draw of this many primitives; at zero the mesh draws numVert vertices straight through. |
var numVert: Int |
Vertices the last resize made room for, or -1 before the first one. |
var update |
Named sequences advanced once per frame, from prepareForDraw, before the buffers are flushed — which is where a mesh that rewrites its own vertices each frame puts that work. |
var instanceCount |
How many copies of the mesh each draw call asks for; reaches vkCmdDraw and vkCmdDrawIndexed directly, so the shader distinguishes them by gl_InstanceIndex. |
var push |
The push constants for this draw, laid out to match the vertex and fragment members the current module declares. The FromStructure delegates read and write it by member name. |
class BoundFromStructure<T> |
|
class FromStructure<T> |
|
fun input(name: String): VertexBuffer |
Returns the vertex buffer for the named mesh input. |
operator fun get(name: String) |
|
var notInitialized |
Whether resize has yet run. Only input reads it, to tell "there is no such input in this module" from "there are no inputs yet because nothing has been sized". |
var builder |
The open-ended writer for this mesh's vertices and indices. prepareForDraw closes and applies it if the builder { } block left it open, so the contents land in the buffers before the draw that needs them. |
fun builder(b: MeshBuilder.() -> Unit): MeshBuilder |
|
fun primitiveSize(): Int |
|
fun resize(numVert: Int, numTriangle: Int = 0, inside: ModulePointer = this.inside): Mesh |
here numTriangle = 0 means 'we're not indexed and we don't need an index buffer' |
fun write(index: Int, numElements: Int, builder: FloatBuffer.() -> Unit) |
|
fun write(name: String, inside: Module = this.inside.current, builder: FloatBuffer.() -> Unit) |
|
fun prepareForDraw(cmd: CommandBuffer) |
|
fun draw(inside: Module, cmd: CommandBuffer, pushConstantBuffer: ByteBuffer) |
|
override fun toString(): String |
MeshBuilder.kt
Two ways of writing vertices into a Mesh, of which one is wired up.
writeSuperMap is the live one, and it is where m.set(4, 2) { … } in a shader box comes from. It
reads a vertex shader's introspected inputs and returns Kotlin source: a MappedStructure holding
one buffer field per input, and a Mesh.set(numVert, numTriangle) extension that resizes the mesh
and maps all of those buffers at once, so the lambda can assign them by the names the shader itself
declares. insertForShader calls this whenever a moduleHere box's vertex and fragment shaders
compile, and appends the text to that box's preamble.
MeshBuilder, VertexData and writeVertexData are the other way: stage vertex bytes on the CPU
through generated write(...) calls, then copy them into the mesh's buffers in one pass. Mesh
constructs a MeshBuilder for every mesh and offers it as builder { }, but nothing in the tree
calls writeVertexData to generate those write methods, and nothing calls the internal
closeAndApply that copies the staged bytes across, so vertices staged this way never reach the
mesh. m.set is the route to use.
src/jvmMain/kotlin/afield/graphics/render/MeshBuilder.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun writeSuperMap(v: List<Introspected.Input>): String |
The MappedStructure and Mesh.set source text for a vertex shader's inputs v, as one line. |
fun writeVertexData(n: List<Introspected.Input>): String |
The MeshBuilder.write(...) source text for a vertex shader's inputs n: one parameter per input, defaulted, writing into that input's own VertexData. |
VertexData
A growable byte buffer for one vertex attribute, with the number of vertices written to it.
| Declaration | Summary |
|---|---|
var cap |
|
var buffer |
|
var v |
|
fun rewind() |
|
fun checkCapacity(n: Int) |
MeshBuilder
class MeshBuilder(val target: Mesh)
Stages one mesh's vertices and triangles in host memory, with a slot for each of sixteen shader input locations.
| Declaration | Summary |
|---|---|
val data |
|
val triangle |
|
var isOpen |
|
fun tri(a: Int, b: Int, c: Int) |
|
fun tri() |
|
fun quad() |
|
fun getDataAndCheckCapacity(layout: Int, sizeInBytes: Int): VertexData |
MeshMesh.kt
The MeshTask interface for issuing mesh-shader work, and MeshMesh, a task whose push constants and uniforms are bound to Kotlin properties by name.
meshTask wraps a lambda as a MeshTask.
src/jvmMain/kotlin/afield/graphics/render/MeshMesh.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun meshTask(c: (inside: MeshModule, cmd: CommandBuffer, pushConstantBuffer: ByteBuffer) -> Unit): MeshTask |
MeshTask
| Declaration | Summary |
|---|---|
fun compute(inside: MeshModule, cmd: CommandBuffer, pushConstantBuffer: ByteBuffer) |
MeshMesh
class MeshMesh(var renderer: Renderer = fromContext()) : MeshTask
| Declaration | Summary |
|---|---|
var indirect: FrameBufferGroup? |
GPU-written VkDrawMeshTasksIndirectCommandEXT; null keeps direct x/y/z dispatch. |
var indirectOffset: Long |
Byte offset of the three uint group counts, aligned to four bytes. |
fun prepareForDraw(command: CommandBuffer) |
Transfers must be prepared outside dynamic rendering, like a conventional mesh's buffers. |
class FromUniform |
|
class BoundFromStructure<T> |
|
lateinit var inside: MeshModulePointer |
|
var update |
|
var push |
|
var x |
|
var y |
|
var z |
|
class FromStructure<T> |
|
override fun compute(inside: MeshModule, cmd: CommandBuffer, pushConstantBuffer: ByteBuffer) |
MeshModule.kt
A task/mesh-shader pipeline as a renderable module: MeshModule compiles and introspects task and
mesh shaders and owns their descriptor sets.
Boxes hold it through a MeshModulePointer, which swaps in a rebuilt module when a shader is
reloaded and carries live values across the interface change.
src/jvmMain/kotlin/afield/graphics/render/MeshModule.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun MeshModule.pointer() |
MeshModulePointer
class MeshModulePointer(var current: MeshModule) : Renderable
| Declaration | Summary |
|---|---|
var lastReloadReport: ModuleReloadReport? |
What the last successful reload did to the interface and which values it could not carry. |
fun prepareSampledImagesFor(command: CommandBuffer) |
|
override fun render(command: CommandBuffer, bounds: Rectanglef) |
|
fun reload(p: Parameters, forceReresolve: Boolean = true, onError: (String, Throwable?) -> Unit = p.errorHandler): Boolean |
|
operator fun plusAssign(m: Pair<String, BeforeTarget>)+3 overloads · also Pair<String, MeshMesh>, Named<MeshMesh>, Pair<String, Target> |
|
fun enable() |
|
fun disable() |
|
override fun toString(): String |
MeshModule
class MeshModule(val parameters: Parameters) : Renderable
| Declaration | Summary |
|---|---|
var valid |
|
var enabled |
|
var meshes |
|
var before |
|
var after |
|
val ti |
|
val mi |
|
val fi |
|
val pushConstantSize |
|
var uniforms: MutableMap<String, UpdatesDescriptorSet> |
|
var liveUniformStructures: MutableMap<String, Structure> |
|
var pipelineLayout: Long |
|
var pipeline: Long |
|
var tsm: Long |
|
var msm: Long |
|
var fsm: Long |
|
override fun render(command: CommandBuffer, bounds: Rectanglef) |
|
fun enable() |
|
fun disable() |
|
fun copyMeshesFrom(old: MeshModule) |
|
fun cleanup() |
Release this module's pipeline, layout, shader modules and descriptor layouts. It stops drawing at once; while its renderer is alive the handles go through the retirement queue, as for Module.cleanup — a full reload retires the module it replaces while frames that recorded it may still be in flight — and at once after the renderer has closed. |
operator fun plusAssign(m: Pair<String, BeforeTarget>)+2 overloads · also Pair<String, MeshMesh>, Pair<String, Target> |
Module.kt
The rasterising Module: one graphics pipeline built from vertex/fragment (or task/mesh)
GLSL, the Meshes it draws, and the uniforms, textures and push constants bound to it.
Parameters carries the shader sources and pipeline settings, and parameters builds one
against the renderer found by fromContext(). A ModulePointer stands in front of a module so
a box can recompile its shaders and swap in the new Module without the renderer losing its
reference; Module.pointer makes one. before wraps a command-buffer callback as a target
that runs ahead of the module's draws, and parseCompilerException splits a shader compiler
error into file, line and message.
Box code usually reaches this through moduleHere rather than constructing a Module
directly; the graphics lessons in public/docs/assets/canonical_graphics/LESSONS.md show
that shape.
src/jvmMain/kotlin/afield/graphics/render/Module.kt — read the source
Used in: Fabricate Renderer, FieldML's JVM bridge, A solid, written down: afield.lang.solidsl
Top-level functions
| Declaration | Summary |
|---|---|
fun attachDeclaredMeshes(module: ModulePointer, p: Parameters) |
Creates each mesh declared in p's block, adds it to module under its name, then runs its build block, in declaration order. Re-running the box replaces a mesh of the same name. |
fun parameters(renderer: Renderer = fromContext(), build: Parameters.() -> Unit): Parameters |
|
fun Module.pointer() |
|
fun before(a: (CommandBuffer, Int) -> Unit) |
|
fun parseCompilerException(exception: CompilerError, accept: (String, Int, String) -> Unit) |
Parameters
Shader sources and pipeline settings shared by Module and RayTracingModule.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
|
var vertex: String |
|
var fragment: String |
|
var compute: String |
|
var mesh: String |
|
var task: String |
|
var raygen: String |
|
var miss: String |
|
var closesthit: String |
|
var anyhit: String |
|
var intersection: String |
|
var callable: String |
|
var vertexIntrospection: Introspection? |
|
var fragmentIntrospection: Introspection? |
|
var computeIntrospection: Introspection? |
|
var meshIntrospection: Introspection? |
|
var taskIntrospection: Introspection? |
|
var raygenIntrospection: Introspection? |
|
var missIntrospection: Introspection? |
|
var closesthitIntrospection: Introspection? |
|
var anyhitIntrospection: Introspection? |
|
var intersectionIntrospection: Introspection? |
|
var callableIntrospection: Introspection? |
|
var vertexSourceName: String |
|
var fragmentSourceName: String |
|
var computeSourceName: String |
|
var meshSourceName: String |
|
var taskSourceName: String |
|
var raygenSourceName: String |
|
var missSourceName: String |
|
var closesthitSourceName: String |
|
var anyhitSourceName: String |
|
var intersectionSourceName: String |
|
var callableSourceName: String |
|
var vertexIncludeResolver: ShaderIncludeResolver? |
|
var fragmentIncludeResolver: ShaderIncludeResolver? |
|
var computeIncludeResolver: ShaderIncludeResolver? |
|
var meshIncludeResolver: ShaderIncludeResolver? |
|
var taskIncludeResolver: ShaderIncludeResolver? |
|
var raygenIncludeResolver: ShaderIncludeResolver? |
|
var missIncludeResolver: ShaderIncludeResolver? |
|
var closesthitIncludeResolver: ShaderIncludeResolver? |
|
var anyhitIncludeResolver: ShaderIncludeResolver? |
|
var intersectionIncludeResolver: ShaderIncludeResolver? |
|
var callableIncludeResolver: ShaderIncludeResolver? |
|
var color: Int |
|
var colorFormats: List<Int> |
|
var depth: Int |
|
var depthFormat: Int |
|
var primitiveType: Int |
|
var textureResolver: (Introspected.Texture) -> Pair<HasImageViewArray, TextureSampler>? |
|
var uboResolver: (BufferParameters) -> BufferParameters? |
|
var ssboResolver: (BufferParameters) -> BufferParameters? |
|
var ssboSizer: (Introspected.UBO) -> Int? |
|
var imageResolver: (Introspected.Image) -> ImageView? |
|
var accelerationStructureResolver: (Introspected.AccelerationStructure) -> HasAccelerationStructure? |
|
var rayTracingGroups: RayTracingGroups? |
|
var maxRayRecursionDepth: Int |
|
var rayTracingPipelineFlags: Int |
|
var allowClusterAccelerationStructure: Boolean |
Explicit pipeline opt-in for NV cluster geometry. Baseline pipelines need no extension. |
var name: String |
|
var errorHandler: (String, Throwable?) -> Unit |
|
var colorMask: Array<Boolean> |
|
var blend: Boolean |
|
var srcColor: Int |
|
var dstColor: Int |
|
var colorBlendOp: Int |
|
var srcAlpha: Int |
|
var dstAlpha: Int |
|
var alphaBlendOp: Int |
|
var depthTest: Boolean |
|
var depthWrite: Boolean |
|
var depthCompareOp: Int |
|
var viewport: Rect? |
|
var viewportCount: Int |
|
var viewMask: Int |
|
var fillMode: Int |
|
var imageFormat: Int |
|
val labelColor: Vec4 |
|
var fieldProbe: ShaderFieldProbe? |
|
var fieldStats: ShaderFieldStatsBinding? |
|
val declaredMeshes |
Meshes declared with +Mesh("name") { ... } inside a moduleHere { } block. A mesh cannot be sized before its module exists, and this block runs first, so they wait here and the module builder creates, adds and fills them once it has built the module. |
fun Mesh(name: String, build: Mesh.() -> Unit = {}): DeclaredMesh |
Inside moduleHere { }: a mesh called name for this module, filled by build once the module exists. Add it with a leading +: +Mesh("my mesh") { set(3, 3) { ... } }. Elsewhere Mesh() is the ordinary constructor. |
operator fun DeclaredMesh.unaryPlus() |
Adds a Mesh declared in this block to the module. |
val triangles |
|
val lines |
|
val points |
|
val triangle_strip |
|
val line_strip |
|
val filled |
|
val wireframe |
|
val depth_never |
|
val depth_less |
|
val depth_equal |
|
val depth_less_or_equal |
|
val depth_greater |
|
val depth_not_equal |
|
val depth_greater_or_equal |
|
val depth_always |
|
val pipelineDepthFormat: Int |
The depth format the pipeline is built for: depthFormat when the module has a depth attachment (depth > 0), otherwise VK_FORMAT_UNDEFINED. depth = 0 alone leaves depthFormat at its default, and a pipeline declaring that format would not match a pass that supplies no depth view. |
operator fun RenderToTexture.unaryPlus() |
Inside moduleHere { }: this module draws into this target. +target gives the module the target's colour formats, depth and viewports, which it needs before it can be attached with target += module. |
operator fun HasRenderToTexture.unaryPlus() |
Inside moduleHere { }: this module draws into the target this object owns, e.g. +view for an ElectronRTTView. |
operator fun MultiPingPongRenderToTexture.unaryPlus() |
Inside moduleHere { }: this module draws into this ping-pong target, as +target does for a RenderToTexture. |
fun textures(vararg t: Pair<String, HasImageView>) |
|
fun textureArrays(vararg t: Pair<String, HasImageViewArray>) |
Bind providers whose image views can change between frames (video ring buffers, ping-pong targets, and similar resources). Unlike textures, this keeps the provider itself live instead of capturing one of its current image views while the module is configured. |
DeclaredMesh
class DeclaredMesh(val name: String, val build: Mesh.() -> Unit)
A mesh declared inside moduleHere { +Mesh("name") { ... } }; see Parameters.declaredMeshes.
Renderable
| Declaration | Summary |
|---|---|
fun render(command: CommandBuffer, bounds: Rectanglef) |
FromUniform
| Declaration | Summary |
|---|---|
operator fun getValue(inside: ModulePointer, property: KProperty<*>): Structure |
ModulePointer
class ModulePointer(var current: Module) : Renderable
| Declaration | Summary |
|---|---|
var lastReloadReport: ModuleReloadReport? |
What the last successful reload did to the interface and which values it could not carry. |
fun prepareSampledImagesFor(command: CommandBuffer) |
|
override fun render(command: CommandBuffer, bounds: Rectanglef) |
|
fun reload(p: Parameters, forceReresolve: Boolean = true, onError: (String, Throwable?) -> Unit = p.errorHandler): Boolean |
|
operator fun plusAssign(m: Pair<String, BeforeTarget>)+5 overloads · also Pair<String, Mesh>, Named<Mesh>, Pair<String, Target>, Named<Target>, Named<BeforeTarget> |
|
fun enable() |
|
fun disable() |
|
override fun toString(): String |
Module
class Module(val parameters: Parameters) : Renderable
BeforeTarget
value class BeforeTarget(val target: Target)
ModuleRebuild.kt
Told when a module pointer swaps in a rebuilt module (a full shader reload), with the old and new modules' live uniform structures, before the old one is dropped.
Order. The generic carry-over (ReloadCarryOver.finish) runs first and is authoritative: it has
already moved every value and binding that still fits the new reflection, and dropped (and reported)
the rest. Only then are hooks called, with a Rebuild saying which new structures were carried and
from which old one. A hook fills in what the generic carry could not know (a stack.auto numbers
block with no old counterpart is seeded from its slot) and must not re-apply what was carried, or
it would restore bindings the carry dropped as retyped and values it refused. Both steps run in
ReloadCarryOver.finishAndNotify.
An in-place pipeline update keeps its structures and does not come here.
src/jvmMain/kotlin/afield/graphics/render/ModuleRebuild.kt — read the source
ModuleRebuild
The registry of rebuild hooks, called after the generic carry-over on every full shader reload.
| Declaration | Summary |
|---|---|
class Rebuild(val pointer: Any, val old: Map<String, Structure>, val new: Map<String, Structure>, val carriedFrom: Map<String, String>) |
One rebuild: the pointer (a ModulePointer, ComputeModulePointer, MeshModulePointer or RayTracingModulePointer), the structures old and new, and carriedFrom — for each new structure the generic carry filled, the name of the old structure it was filled from (itself, or the old name of a renamed block). |
val hooks |
Called after the generic carry-over, once per rebuild; see the ordering above. |
val accessed |
Called when Kotlin reaches a module's uniform structure by name (module.look), with the pointer, the name and the structure — how a stack.auto numbers block goes live when code or a keyframe target takes hold of it. |
val onCreated |
Called when a module has just made its uniform structures, before anything has written them. A uniform buffer starts as whatever memory it was given, so a member that must read as zero until set — a stack.auto numbers block's foo_live — is written here. |
RayTracingModule.kt
The ray-tracing counterpart of Module: a Vulkan ray-tracing pipeline built from raygen,
miss, hit-group and callable shaders, with its shader binding table and bound resources.
RayTracingGroups lists the stages; RayTracingGroups.fromParameters builds it from the
single-stage sources on Parameters. A RayTracingModulePointer lets a box swap a recompiled
module in place, as ModulePointer does for rasterising modules. Work is recorded as
RayTracingTasks: RayTracingDispatch traces a width × height × depth grid of rays, and
rayTracingTask wraps an arbitrary command-buffer callback. rayTracingHostReadBarrier
makes ray-tracing shader writes visible to a host read.
src/jvmMain/kotlin/afield/graphics/render/RayTracingModule.kt — read the source
Used in: Ray Tracing Examples
Top-level functions
RayTracingShaderStage
One ray-tracing shader: its name, stage kind and GLSL source.
| Declaration | Summary |
|---|---|
val name: String |
|
val kind: ShaderKind |
|
val source: String |
|
val introspection: Introspection? |
|
val sourceName: String |
|
val includeResolver: ShaderIncludeResolver? |
RayTracingHitGroup
| Declaration | Summary |
|---|---|
val name: String |
|
val closestHit: RayTracingShaderStage? |
|
val anyHit: RayTracingShaderStage? |
|
val intersection: RayTracingShaderStage? |
RayTracingGroups
| Declaration | Summary |
|---|---|
var raygen: RayTracingShaderStage? |
|
val miss: MutableList<RayTracingShaderStage> |
|
val hit: MutableList<RayTracingHitGroup> |
|
val callable: MutableList<RayTracingShaderStage> |
RayTracingModulePointer
class RayTracingModulePointer(var current: RayTracingModule) : Renderable
| Declaration | Summary |
|---|---|
var lastReloadReport: ModuleReloadReport? |
What the last successful reload did to the interface and which values it could not carry. |
override fun render(command: CommandBuffer, bounds: Rectanglef) |
|
fun reload(p: Parameters, forceReresolve: Boolean = true, onError: (String, Throwable?) -> Unit = p.errorHandler): Boolean |
|
operator fun plusAssign(task: Pair<String, RayTracingTask>)+1 overload · also Named<RayTracingTask> |
|
operator fun plusAssign(m: Pair<String, Target>) |
|
fun enable() |
|
fun disable() |
|
override fun toString(): String |
RayTracingFromUniform
| Declaration | Summary |
|---|---|
operator fun getValue(inside: RayTracingModulePointer, property: KProperty<*>): Structure |
RayTracingModule
class RayTracingModule(val parameters: Parameters) : Renderable
RayTracingTask
| Declaration | Summary |
|---|---|
fun trace(inside: RayTracingModule, cmd: CommandBuffer, pushConstantBuffer: ByteBuffer) |
RayTracingDispatch
class RayTracingDispatch(var width: Int, var height: Int = 1, var depth: Int = 1) : RayTracingTask
ReloadCarryOver.kt
What survives when a module compiles a new shader and swaps it in: which uniform buffers keep their contents, which are reallocated, which are retired, and which of the values already written into them are re-applied.
Every module pointer finishes a reload through ReloadCarryOver.finishAndNotify, which runs this
generic policy first and the ModuleRebuild hooks second, so a hook only fills in what the policy
left. The policy reads the ShaderInterfaceTransition between the old and new introspections. A
uniform block whose layout is unchanged keeps its buffer and its bytes, including across a rename,
since the same members at the same offsets still mean what they meant. A block that was resized or
retyped gets a fresh buffer, zeroed before anything writes it, and the values set on the old one
are re-applied wherever they still fit the new reflection. A buffer the old module allocated and
the new one does not use goes through the renderer's retirement queue rather than being freed under
a frame that may still be reading it.
Only a buffer the module allocated for itself is zeroed, carried by layout or retired. One that came from the surrounding context, or that a resolver the box supplied handed back, is resolved again the way it was the first time and is never freed here. Zeroing applies to uniform blocks only: a storage buffer has its values carried and is retired the same way, but its contents are left as they are.
ModuleReloadReport is what the box side reads back: the transition, and one note per value that
could not be carried, so a reload that silently dropped a uniform says which one.
src/jvmMain/kotlin/afield/graphics/render/ReloadCarryOver.kt — read the source
ModuleReloadReport
What a module pointer's full reload did to the interface: the transition old→new (null when the pointer could not compare, for want of an introspection) and notes, one per value that could not be carried to the new module. Read by the box-side reload (reloadShader and siblings) to say it.
ReloadResolvers.kt
Keeps a module's resource resolvers one layer deep across repeated shader reloads.
Each module pointer installs its carry-over behaviour by replacing the resolvers on its
Parameters. Module, ComputeModule and MeshModule replace all three of uboResolver,
ssboResolver and textureResolver; RayTracingModule replaces the latter two. A reload is
handed a copy of the parameters, and a copy brings the resolvers across as they are, so each
wrap would otherwise land on the one the previous reload installed and every uniform, buffer and
texture lookup would walk one closure more than it did on the run before.
reloadWrap unwraps before it wraps, so what sits underneath stays the resolver the box or
moduleHere supplied and the depth stays at one however often a shader is reloaded.
reloadWrapDepth reports that depth; ReloadResolversTest holds it at one across a hundred
reloads, and checks that replacing the resolver yourself makes the replacement what the next
reload wraps.
src/jvmMain/kotlin/afield/graphics/render/ReloadResolvers.kt — read the source
SplatCloudEdit.kt
Editable CPU-side Gaussian splat clouds: SplatCloud holds splats in world-unit scales, 0..1 opacity and linear colour, so cropping, filtering, decimating, transforming and merging are plain arithmetic.
A cloud converts from the table readCompressedSplatPly reads and writes back out as a .compressed.ply that CompressedSplatPly reads again.
src/jvmMain/kotlin/afield/graphics/render/SplatCloudEdit.kt — read the source
SplatCloud
A Gaussian splat cloud as editable CPU data: read it, ask it questions, crop it, filter it, decimate it, move it, merge it, and write it back out as a .compressed.ply that CompressedSplatPly reads again.
| Declaration | Summary |
|---|---|
val positions: FloatArray |
x, y, z per splat, world space. |
val colors: FloatArray |
Linear r, g, b per splat in 0..1 — what the shader consumes, not the SH DC coefficient. |
val alphas: FloatArray |
Opacity in 0..1, post-sigmoid. |
val rotations: FloatArray |
Unit quaternion per splat, w, x, y, z — rot_0 first, as a 3DGS PLY spells it. |
val scales: FloatArray |
sx, sy, sz per splat in world units, not logs. |
val count: Int |
|
data class CloudStats(val count: Int, val boundsMin: Vector3f, val boundsMax: Vector3f, val meanColor: Vector3f, val meanSaturation: Float, val meanAlpha: Float, val alphaPercentiles: FloatArray, val meanScale: Float, val scalePercentiles: FloatArray, val nonFinite: Int) |
What is actually in this cloud. |
fun stats(): CloudStats |
|
fun where(keep: (Int) -> Boolean): SplatCloud |
A new cloud holding only the splats keep accepts, in the same order. |
fun cropped(min: Vector3f, max: Vector3f): SplatCloud |
Splats whose centres lie inside the axis-aligned box min..max, inclusive. |
fun filtered(minAlpha: Float = 0f, maxAlpha: Float = 1f, minScale: Float = 0f, maxScale: Float = Float.POSITIVE_INFINITY): SplatCloud |
Splats within the given opacity and world-scale windows. |
fun decimated(fraction: Float): SplatCloud |
A fraction of the splats, taken by a deterministic stride rather than at random. |
fun transformed(transform: Matrix4f): SplatCloud |
Every splat through transform — position by the full affine map, rotation and scale by the linear part. |
var shearLoss: Float |
How much shear transformed had to throw away: the largest absolute dot product it saw between two axes that should have been orthogonal, before it orthogonalised them. Zero after any transform the format can represent exactly. |
fun withAlphaScaled(factor: Float): SplatCloud |
This cloud with every opacity multiplied by factor and clamped to 0..1. |
fun withScale(factor: Float): SplatCloud |
This cloud with every world scale multiplied by factor. |
fun toRawSplats(): CompressedSplatPly.RawSplats |
Re-quantize into CompressedSplatPly.RawSplats, so an edited cloud can go straight into GaussianSplatRenderer.packInto — and therefore into SplatRaster and into a live GPU Cloud.reupload — without a file in between. |
fun write(file: File, mortonSort: Boolean = true) |
Write a valid PlayCanvas .compressed.ply. See CompressedSplatPlyWriter. |
SplatCloudEditTest.kt
Tests the splat cloud edit loop: read a compressed PLY into a SplatCloud, query and edit it,
preview it, and write it back with CompressedSplatPlyWriter.
It checks two claims that reading the code cannot settle:
- A cloud that goes out comes back.
CompressedSplatPlyWriteris a new encoder and the only thing standing behind it is that it is the inverse ofCompressedSplatPly's decoder. So: write, read back, and compare, with the error pinned at the digits the quantization actually delivers. And separately, write the same input throughCompressedSplatPlyFixture— the independent transcription of splat-transform's own encoder that already lives here — and require the bytes to be identical, so the writer is checked against an encoder other than the reader it mirrors. - The CPU transform is the transform the GPU bakes.
SplatCloud.transformeddecomposes and re-composes a rotation and three scales;packIntofolds the same matrix into the covariance congruence and never writes it down. If those two disagree, an edited cloud looks different after it is saved, and no amount of float comparison inside either path would show it. So the test rasterizes both and compares pixels.
src/jvmMain/kotlin/afield/graphics/render/SplatCloudEditTest.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun main() |
SplatCloudEditTest
The cases behind the four.splat-cloud-edit.smoke test; main runs them all.
| Declaration | Summary |
|---|---|
fun runTests(): Boolean |
SplatContactSheet.kt
src/jvmMain/kotlin/afield/graphics/render/SplatContactSheet.kt — read the source
SplatContactSheet
A whole splat sequence in one image: N frames rasterized small and tiled into a grid.
SplatFusedPackTest.kt
Tests GaussianSplatRenderer.packInto — the fused compressed-file-to-GPU-layout path — against
GaussianSplatRenderer.pack over the PlyFloatTable, which is the same computation the slow way.
The two are not bit-identical. The compressed format stores colour as linear 0..1 and
opacity post-sigmoid, which is exactly what the shader consumes, so the fused path skips the
f_dc = (c - 0.5) / SH_C0 and logit round trips that the table path performs and undoes. Every
other quantity — position, scale, rotation, covariance — is computed by the same expressions in
the same order and to the same spelling, so the tolerances below are tight enough that a swapped
field or a wrong bit shift cannot pass.
src/jvmMain/kotlin/afield/graphics/render/SplatFusedPackTest.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun main() |
SplatFusedPackTest
The cases behind the four.splat-fused-pack.smoke test; main runs them all.
| Declaration | Summary |
|---|---|
fun runTests(): Boolean |
SplatRaster.kt
A CPU rasterizer for Gaussian splat clouds, so a splat can be rendered to pixels, tested and captured without a GPU.
SplatRaster ports the vertex and fragment stages of GaussianSplatRenderer rather than defining a second look.
src/jvmMain/kotlin/afield/graphics/render/SplatRaster.kt — read the source
SplatRaster
A Gaussian splat cloud rasterized on the CPU, into pixels you can look at without a GPU.
SplatRasterTest.kt
Tests SplatRaster at the level of pixels.
SplatFusedPackTest checks that the two pack paths produce the same floats, and every assertion
in it would still hold if the shader drew the cloud mirrored, upside down, or with red and blue
swapped. This is the test that cannot. It asserts:
- Orientation, from four corner-marked splats — the only proof of the Y and X conventions that does not reduce to re-deriving the same viewport call the rasterizer was written from.
- A known splat at a known pixel in a known colour, with the pixel derived on paper from the perspective matrix and written here as a literal, so a change in the projection has to be noticed rather than absorbed.
- Slot sensitivity — the differential oracle. Each of the sixteen floats in
GaussianSplatRenderer's layout is perturbed in turn, and the image is required to move for the thirteen the shader reads and to not move for the three it does not, which tests that the CPU and the GPU read the same cloud. - Pixel agreement between the two pack paths, which is
SplatFusedPackTest's float claim carried through to what a person actually sees. - That the poison detector detects poison. A cloud is broken with NaNs and the non-finite count is required to find exactly them — and required to be zero on good data, so the detector cannot be passing by always firing.
src/jvmMain/kotlin/afield/graphics/render/SplatRasterTest.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun main() |
SplatRasterTest
The cases behind the four.splat-raster.smoke test; main runs them all.
| Declaration | Summary |
|---|---|
fun runTests(): Boolean |
SplatTools.kt
A command line over the CPU splat toolchain, for looking at a capture without opening Field.
run-jvm-main afield.graphics.render.SplatToolsKt sheet ~/Downloads/circle-05-a120p0_00001_ out/
run-jvm-main afield.graphics.render.SplatToolsKt frame ~/Downloads/shot/frame_000060.compressed.ply out/
sheet draws a contact sheet of frames spread across a capture directory; frame rasterizes one
compressed PLY. Both run on the CPU through SplatRaster, so judging a fresh capture (is it any
good, which frames are worth looking at) needs no GPU, window or scrubbing.
src/jvmMain/kotlin/afield/graphics/render/SplatTools.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
SplatTools
The sheet and frame commands behind this file's main.
| Declaration | Summary |
|---|---|
data class Written(val file: File, val bytes: Int, val note: String) |
What sheet and frame wrote, so a caller can report it without re-reading the files. |
fun sheet(directory: File, outputDir: File, tiles: Int = 24, layout: SplatContactSheet.Layout = SplatContactSheet.Layout(), config: SplatRaster.RasterConfig = SplatRaster.RasterConfig(sizeScale = 2.5f, alphaScale = 1.6f)): Written |
A contact sheet of directory's frames into outputDir. |
fun describe(path: File): String |
Print what is in a cloud, without drawing it: SplatCloud.CloudStats over one file. |
fun frame(path: File, outputDir: File, width: Int = 800, height: Int = 450, config: SplatRaster.RasterConfig = SplatRaster.RasterConfig(sizeScale = 2.5f, alphaScale = 1.6f)): Written |
One frame of a sequence, or one standalone .compressed.ply, at width x height. |
StandardDrawing.kt
The small vocabulary of FLine construction and styling that sketches and examples reach for
first.
fline is the builder; the rangeTo operator makes a one-segment line out of two vectors
(a..b, composable with +=), and the styling overloads of moveTo / lineTo / cubicTo
position a node and style it in the same call. Appearance is carried by delegated properties on
FLine and FLine.Node — color, thickness, thicken, filled, stroked, pointed,
pointSize — which the renderers read back; onClick and hoverColor only do anything in the
interactive outputs.
The rest are shape helpers: rect and Rect.toFLine, arrow (a real filled arrowhead rather
than a point sprite), seg, and fitInto, which scales and centres a list of lines into a
Rect using their 2D bounds only.
src/jvmMain/kotlin/afield/graphics/render/StandardDrawing.kt — read the source
Used in: FLine examples (Field graphics), Points into structure — Voronoi, Apollonius, contours and clusters, Three.js scenes with three { … }
Top-level functions
Top-level values
| Declaration | Summary |
|---|---|
var FLine.color: Vector4f |
The line's colour as RGBA in 0..1 (sRGB, alpha included) — one colour for the stroke, the fill and the node dots alike. Opaque white when never set. |
var FLine.Node.color: Vector4f |
Per-node colour override in 0..1, which is how one FLine carries a gradient: the GPU point/triangle writers in afield.graphics.render.StandardDrawing use a node's own colour where it has one and the whole line's color where it does not. |
var FLine.thickness: Double? |
Stroke width, in the same units as the line's own points (pixels, for the 2D outputs). null — the default — means "unset", which every renderer reads as 1.0 rather than as "invisible". |
var FLine.thicken: Stroke? |
An AWT Stroke that replaces "stroke at one width" with "convert this line into the outline of its own stroke" — dashes, tapers, calligraphic pens, anything Stroke.createStrokedShape can do. |
var FLine.filled: Boolean |
Whether closed subpaths are flooded with color. false by default, and independent of stroked — with both true the renderers fill and then draw the outline on top. |
var FLine.stroked: Boolean |
Whether the path is stroked at thickness. true by default; with this and filled both false the geometry still exists and still hit-tests, but nothing is drawn except the node dots that pointed asks for. |
var FLine.pointed: Boolean |
Draw a dot at every node of the line — every moveTo/lineTo/cubicTo endpoint, not the interior control points of a cubic and not close. Off by default. The fast way to see where a path's points actually landed, and how a particle set gets drawn at all. |
var FLine.pointSize: Double |
Size of the dots pointed draws, 1.0 by default, overridable node by node with FLine.Node.pointSize. |
var FLine.Node.pointSize: Double? |
Per-node override of the line's FLine.pointSize, under the same * 3 radius convention. null — the default, and unlike the line-level property this one is not written back on read — means "follow the line". |
var FLine.onClick: ((FLineIntersection) - |
Callback invoked when this FLine is clicked in an interactive output. |
var FLine.label: String? |
A human name for this line, reported back as FLineIntersection.label when it is clicked and used as the heading by afield.ui.visualization.inspect. |
var FLine.hoverColor: Vec4? |
Color to show when the pointer hovers over this FLine in an interactive SVG output. When null no hover color change is applied. |
FLineHitKind
What kind of thing on a figure a click landed on — the same three-way distinction the editable FLine widget already draws its hit targets with (data-fline-hit-kind in sliders.js), now reported to FLine.onClick as well.
Constants: POINT, EDGE, FACE, UNKNOWN.
FLineFaceCoordinates
enum class FLineFaceCoordinates
Which convention FLineIntersection.u and v follow for a FLineHitKind.FACE hit — because there is no one right answer for a face nobody chose, and a plausible wrong number is worse than a labelled one.
Constants: NONE, BILINEAR, BOUNDING_BOX.
FLineIntersection
Hit information passed to FLine.onClick from the interactive line renderers — what a click on a figure returns.
| Declaration | Summary |
|---|---|
val closestVertexIndex: Int |
Index into the line's nodes of the node nearest the click, or -1 when the client did not work one out. |
val position: Vec3 |
Where the click landed, in the line's own coordinate space. The three.js output fills in all three components; the 2D SVG output leaves z at 0 — onElement is the one to read for a depth, because it interpolates z along the element that was hit. |
val kind: FLineHitKind |
Which kind of element was hit. FLineHitKind.UNKNOWN when the output does not distinguish. |
val lineIndex: Int |
Index of the clicked line within the lines passed to that one lines(...) call, or -1 when unknown. The point of it: one shared onClick closure can serve a whole figure, instead of one closure per part. |
val label: String? |
The clicked line's FLine.label, if it had one. |
val nodeIndex: Int |
Index into the line's nodes of the node that is this element: the node itself for a FLineHitKind.POINT, the node that draws the segment for an FLineHitKind.EDGE, the Close for a FLineHitKind.FACE. -1 when unknown. Unlike closestVertexIndex this is the element, not the nearest dot. |
val subPathIndex: Int |
Which subpath (moveTo-delimited piece) of the line the element belongs to, or -1. |
val elementIndex: Int |
Ordinal of the element among its own kind within the line — edge 0, 1, 2…; face 0, 1… Or -1. |
val u: Double |
Where on the element the click landed, 0..1, or NaN when it does not apply. |
val v: Double |
The second face coordinate for an FLineHitKind.FACE, NaN otherwise. See faceCoordinates. |
val faceCoordinates: FLineFaceCoordinates |
Which convention u and v follow for a face hit — bilinear on a four-corner face, its bounding box otherwise, or a refusal. Always FLineFaceCoordinates.NONE for a non-face hit. |
val onElement: Vec3? |
The point on the element nearest the click, in the line's own coordinate space, or null. |
groups.kt
Mesh groups: lists of meshes that share one live push-constant binding.
MeshGroup, MeshMeshGroup and ComputeMeshGroup each carry a BoundFromStructure delegate, so
a property assigned on the group (a supplier () -> T) is bound to the push constant of the same
name in every member, and read back from the first member that has it. A name no member's module
declares as a push constant throws. bindPush is the looser form for meshes that are not in a
group: it runs one binding block against each target in turn.
src/jvmMain/kotlin/afield/graphics/render/groups.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun <T> bindPush(vararg targets: T, bindings: T.() -> Unit) |
Apply the same live push bindings to each mesh: bindPush(even, odd) { count = { n } }. The receiver retains its concrete mesh type and generated shader properties. The block executes once per target; suppliers assigned inside it remain live rather than being sampled here. |
MeshGroup
class MeshGroup : ArrayList<Mesh>()
A list of Meshes whose push constants are bound together by property name.
| Declaration | Summary |
|---|---|
class BoundFromStructure<T> |
MeshMeshGroup
class MeshMeshGroup : ArrayList<MeshMesh>()
| Declaration | Summary |
|---|---|
class BoundFromStructure<T> |
ComputeMeshGroup
class ComputeMeshGroup : ArrayList<ComputeMesh>()
| Declaration | Summary |
|---|---|
class BoundFromStructure<T> |
DirectLighting.kt
Direct lighting from one-sided rectangular area emitters, shaded over a raster G-buffer.
DirectLighting is a render target that DirectLighting.attach adds to a render list's pre-targets.
It reads DirectLightSurfaces: world position and depth, normal, albedo, the unlit source image
and motion.
It lights those surfaces from an AreaEmitter, tracing shadow rays against an OpaqueRayScene
when the device supports ray queries; otherwise its mode defaults to passing the source image
through, with an unshadowed mode available.
DirectLighting.enableRtxdi adds optional RTXDI reservoir resampling, configured by RtxdiSettings.
The view setting switches the output to debug images such as normals, depth or light id.
src/jvmMain/kotlin/afield/graphics/render/lighting/DirectLighting.kt — read the source
Used in: Direct lighting
AreaEmitter
One-sided rectangle: center plus full edge vectors. cross(edgeU,edgeV) faces the emitting side. Radiance is unclamped, linear RGB. Values are snapshotted by DirectLighting.render.
| Declaration | Summary |
|---|---|
fun validate() |
DirectLightSurfaces
Raster surface contract. XYZ world position + Vulkan depth [0,1] (clear=1, not reversed); XYZ world shading normal; linear RGB albedo. Motion is currentUV-previousUV, Y down, unjittered. Point sampling only. Material identity, emission, eligibility and temporal validity are NOT alpha. The optional RTXDI bridge snapshots these inputs; material IDs are not encoded in alpha.
| Declaration | Summary |
|---|---|
val albedo: HasImageView, val source: HasImageView, val motion: HasImageView) |
DirectLighting
Experimental, mono, raster-primary direct light, with optional pinned RTXDI resampling. Insert after the producer of surfaces on the graphics queue. All mutations, reload and close occur on Field's render thread between frames.
DirectLightingDemo.kt
A small scene for trying DirectLighting: a floor, three cubes and a moving area light.
DirectLightingDemo builds the raster renderer, the OpaqueRayScene and the lighting pass at a
given size; call DirectLightingDemo.animate from the box's frame loop and
DirectLightingDemo.moveObject to move a cube. Re-running the box that built it closes the
previous instance. DirectLightingSmoke is the standalone entrypoint behind the
four.direct-lighting.smoke smoke test, with a --fallback mode that runs without ray tracing.
src/jvmMain/kotlin/afield/graphics/render/lighting/DirectLightingDemo.kt — read the source
DirectLightingDemo
class DirectLightingDemo(val renderer: Renderer, width: Int=640,height: Int=360) : AutoCloseable
A reusable live-coding scene. Camera is bundle.scene.camera; call animate from the box frame loop.
DirectLightingSmoke
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
DirectLightingQualitySmoke.kt
src/jvmMain/kotlin/afield/graphics/render/lighting/DirectLightingQualitySmoke.kt — read the source
DirectLightingQualitySmoke
object DirectLightingQualitySmoke
Full-resolution, linear-light quality checks and actual GPU documentation captures. Separate from the small estimator acceptance suite so its statistical tests stay inexpensive.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
LightingGpuTimer.kt
How long a lighting pass took on the GPU, timed per swapchain image.
Two timestamps per image, top of pipe to bottom, reused once the renderer has waited on that
image's fence. Each begin collects the previous result if it is available, so reading one never
costs a CPU wait, and the last 1024 readings stay in samples in milliseconds for a live box.
The queries are a slice of a pool shared with other timers (TimestampSlabs), not a pool of
their own: KosmicKrisp backs every VkQueryPool with a Metal counter heap, and a process gets
32 of those, each at most 4096 timestamps (measured, SDK 1.4.363, M1 Max), which a pool per timer
exhausts. A timer that cannot get a slice records nothing.
src/jvmMain/kotlin/afield/graphics/render/lighting/LightingGpuTimer.kt — read the source
OpaqueRayScene.kt
Opaque triangle geometry for ray-query visibility, as used by DirectLighting for shadows.
OpaqueTriangles is an immutable indexed mesh (OpaqueTriangles.box makes a box), and an
OpaqueInstance places one with a stable id and transform. OpaqueRayScene.commit takes the
instance list and builds one bottom-level acceleration structure per geometry and a new top-level
structure for each edit, waiting for the build, so commit between frames on the render thread.
OpaqueRayScene.available reports whether the device supports ray queries.
src/jvmMain/kotlin/afield/graphics/render/lighting/OpaqueRayScene.kt — read the source
Used in: Direct lighting
OpaqueTriangles
class OpaqueTriangles(positions: FloatArray, indices: IntArray)
Immutable indexed XYZ triangles. Opaque, double sided visibility only; no alpha or transmission.
OpaqueInstance
Stable object id is independent of list position. Transform is copied at OpaqueRayScene.commit.
OpaqueRayScene
class OpaqueRayScene(val renderer: Renderer) : HasAccelerationStructure, AutoCloseable
Static BLAS per geometry identity; a new TLAS for each explicit instance edit. No refit is claimed. Commit on the render thread between frames. Builds submit on Field's graphics queue and wait for their fence; removed resources retire via Field's frames-in-flight queue. Rebuilding rather than refitting makes edits safe, at the cost of a stall on instance or topology edits.
| Declaration | Summary |
|---|---|
var blasBuilds |
|
var tlasBuilds |
|
var closed |
|
override fun deviceAddress(): Long |
|
fun commit(instances: List<OpaqueInstance>): Boolean |
Returns false when unchanged or unavailable. Empty scenes are represented by a masked instance. |
override fun close() |
RtxdiEngine.kt
The RTXDI implementation behind DirectLighting's two RTXDI modes: reservoir resampling run
entirely as compute passes over GPU buffers, with none of the SDK's CPU-side wrapper linked in.
An engine is built when RTXDI is enabled and sized from the render dimensions it finds then, and
it owns what that sizing implies: a packed surface buffer, host-visible buffers per swapchain
image for the camera matrices and for this frame's and the previous frame's area lights, and
reservoir storage of three slices of 24 bytes a pixel, rounded up to whole 16×16 tiles. That
allocation is checked against the device's maxStorageBufferRange as the engine is built, so an
oversized render resolution is refused there rather than inside a descriptor write. Ray queries
are required.
Reuse across frames holds only while the history does. Every frame compares the projection, the camera (a view difference over 0.75 counts as a cut), the acceleration structure's build count and a signature of the sampling settings, and any change resets the history and records which of them it was. A reference render runs a single reference estimator in place of the four resampling stages and leaves no history behind. The reconstruction filter that can follow runs over the radiance view alone, and feeds neither the estimator nor the reservoirs.
src/jvmMain/kotlin/afield/graphics/render/lighting/RtxdiEngine.kt — read the source
RtxdiLightingSmoke.kt
src/jvmMain/kotlin/afield/graphics/render/lighting/RtxdiLightingSmoke.kt — read the source
RtxdiLightingSmoke
Optional GPU acceptance check: build the pinned shaders before run-jvm-main.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
RtxdiProgram.kt
Loads the optional RTXDI shaders, and refuses a binary whose interface disagrees with the host code written against it.
Field does not ship NVIDIA's sampling SDK and loads nothing from it at startup. Running
tools/lighting/build_rtxdi.py --prepare clones the SDK and fetches a shader compiler into
ignored build storage, and the build leaves a manifest under
build/lighting-experiment/rtxdi-shaders/. load reads that manifest; with no manifest there it
fails with the command to run.
What load then checks is the interface rather than the file. The manifest has to record the SDK
commit this source was written against, the SPIR-V has to hash to what the manifest recorded, and
the shader's own reflection has to still carry the workgroup size, the push-constant block's size
and member offsets, the reservoir and offset array strides, and the surface and light struct
layouts that the host side writes into. Shaders rebuilt from a different SDK revision therefore
fail to load, instead of being handed the host's bytes at offsets that have moved.
A loaded program is passed to DirectLighting through enableRtxdi, which builds the pass and
leaves the current one in place if that fails.
src/jvmMain/kotlin/afield/graphics/render/lighting/RtxdiProgram.kt — read the source
RtxdiProgram
Optional compiled SDK shaders. Build with tools/lighting/build_rtxdi.py; no SDK is loaded at startup.
RtxdiSettings.kt
Live controls for the RTXDI resampling path of DirectLighting.
RtxdiSettings holds the camera matrices, temporal and spatial reuse parameters, shadowing and an
optional spatial denoise, and takes the light list as IdentifiedAreaLights through
RtxdiSettings.commitLights, so a light keeps its history across reordering. Edit it on the render
thread between frames; RtxdiSettings.resetHistory discards history after a cut or material edit.
src/jvmMain/kotlin/afield/graphics/render/lighting/RtxdiSettings.kt — read the source
Used in: Direct lighting
IdentifiedAreaLight
data class IdentifiedAreaLight(val id: Int, val emitter: AreaEmitter)
Stable light identity, independent of order. Opaque one-sided area emitters only.
RtxdiSettings
class RtxdiSettings(val view: () -> Matrix4f, val projection: () -> Matrix4f)
Live RTXDI controls. Commit lights and edit these values between frames on Field's render thread. Current/previous camera matrices use Field's Vulkan projection (the raster viewport flips Y). Geometry commits invalidate all history; material semantics edits must call resetHistory.
| Declaration | Summary |
|---|---|
val view: () -> Matrix4f |
The camera's view matrix, read once per frame rather than stored, so a camera that moves between frames needs no further bookkeeping here. |
val projection: () -> Matrix4f |
The camera's projection matrix, read per frame like view. Field's Vulkan convention, where the raster viewport flips Y. |
var denoisePasses |
Surface-guided spatial reconstruction, separate from reservoir reuse. Zero preserves raw output. No color history is stored, so moving lights and camera cuts cannot leave color trails. Each pass doubles the pixel spacing; three passes have a 14-pixel support radius. |
var temporal |
Whether each pixel may reuse the reservoir its surface carried last frame. Off, every frame resamples from scratch: noisier, and the only setting under which a still camera does not keep improving. |
var spatialSamples |
How many neighbouring pixels' reservoirs each pixel resamples, 0 to 32. This is the reuse that cleans up a single frame, and the cost that scales with it. |
var maxHistory |
The largest number of frames' worth of samples a reservoir may claim to hold, 1 to 32. Capping it is what lets a reservoir forget light that has moved; raising it trades responsiveness for a quieter image. |
var spatialRadius |
The radius in pixels that spatial neighbours are drawn from, 0 to 64. |
var normalThreshold |
How closely two surface normals must agree before one's reservoir may be reused for the other, as a dot product from 0 to 1 — the default admits normals within about 18° of each other, which is what keeps light from bleeding around a crease. Handed to both the temporal and the spatial resampling pass. |
var depthThreshold |
The companion depth test, from 0 to 0.2, so that a pixel sitting on a surface behind this one is not reused for it. Handed to the same two passes as normalThreshold. |
var shadows |
Whether a shadow ray is traced towards the chosen light. Off, every sample is treated as visible, which is faster and wrong wherever anything occludes anything. |
var animateSeed |
Whether the sampling seed advances with the frame counter. Off, every frame draws the same sequence, which is what makes two renders of one scene comparable pixel for pixel. |
var resetReason |
The reason last passed to resetHistory, or initial before any call. The renderer reads it when it notices the reset and reports it as its own reason for that frame, which is how a deliberate cut can be told apart from the resets it detects for itself — a geometry commit, a change of projection, a large camera move, or an edit to temporal, spatialSamples, spatialRadius, maxHistory or shadows, which are the settings that invalidate history. |
fun resetHistory(reason: String = "explicit cut or edit") |
|
fun commitLights(values: List<IdentifiedAreaLight>) |
Transactional snapshot. Moving/reordered lights retain their IDs; deleted IDs map to -1. |
megaTextures.kt
A directory of images as one texture, however many of them there are.
Box.megaTextureHere is the whole box-facing surface: point it at a folder and it scans the
folder, builds the atlas, and registers the GLSL your shaders interpolate with {{ mega.all }}.
The machinery behind it lives in afield.graphics.texture.mega, and the reason it is worth reaching
for rather than a pile of LoadedTextures is that it is GPU-driven: the fragment shader
measures its own on-screen footprint and asks for the resolution it needs, so nothing here has to
be told where anything is or how big it looks.
dev_docs/agents/transcripts/mega_texture_wall.yaml is the worked example — five lines of Kotlin
and two shaders you can edit live.
src/jvmMain/kotlin/afield/graphics/render/megaTextures.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.megaTextureHere(build: MegaTextureSpec.() -> Unit): MegaTexture |
Build (or reuse) this box's MegaTexture and register its GLSL under {{ <templateName>.… }}. |
fun Box.megaTexture(templateName: String = "mega"): MegaTexture? |
This box's MegaTexture under templateName, if megaTextureHere has built one. |
MegaTextureSpec
What to show and how, for Box.megaTextureHere.
| Declaration | Summary |
|---|---|
val folders |
Directories to take images from, in the order given. |
var recursive: Boolean |
Walk subdirectories too. |
var extensions: Set<String> |
Lower-case file extensions to take, without the dot. Anything else in the folder is ignored. |
var maxImages: Int |
Stop after this many images — useful for trying a huge directory at a small size first. |
var templateName: String |
The shaderTemplate key the GLSL slots are registered under: {{ mega.all }} by default. |
var warmBaseLevel: Boolean |
Queue level-0 pages for everything up front, so the wall fills in without being looked at. |
val config |
Page sizes, residency budget, decode threads, bindings — see MegaTextureConfig. |
fun folder(path: String) |
Add a directory of images, by path. |
fun folder(path: Path): MegaTextureSpec |
Add a directory of images. |
OpenDlssNrGpuSmoke.kt
src/jvmMain/kotlin/afield/graphics/render/neural/OpenDlssNrGpuSmoke.kt — read the source
OpenDlssNrGpuSmoke
Managed-runner entry point: real Field device, native pass, caller-owned submission and head readback. Arguments: image, output-directory, tone, structure, style. Diagnostics use CPU readback; the normal pass does not. Pair the resulting head with the standalone render for parity.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
OpenDlssNrImage.kt
Runs one image file through OpenDlssNrPass and shows the result on screen.
OpenDlssNrImage loads the image and requires OpenDlssNrSupport.availability to be ready. After
OpenDlssNrImage.install it evaluates the pass each frame under the renderer, with controls
for the network and exposure, gamma and offset for grading the result as it reaches the
screen.
src/jvmMain/kotlin/afield/graphics/render/neural/OpenDlssNrImage.kt — read the source
OpenDlssNrImage
Editable image study. Install below a renderer; inference precedes the grading/display module. close detaches immediately and retires allocations after submitted frames finish.
| Declaration | Summary |
|---|---|
val pass |
|
val output: RenderToTexture |
|
var controls: OpenDlssNrControls |
|
var exposure |
Linear RGB multipliers, independent of the network condition. |
var gamma |
|
var offset |
|
fun install(): OpenDlssNrImage |
|
override fun render(command: CommandBuffer, index: Int) |
|
override fun close() |
OpenDlssNrPass.kt
The Vulkan pass that runs the native neural-rendering (NR) network over a sampled image.
OpenDlssNrPass.evaluate takes OpenDlssNrInputs (colour with its OpenDlssNrColorEncoding, and
optional motion and depth guides for temporal mode), an OpenDlssNrFrame and the current
OpenDlssNrControls (seed, style, tone, structure, intensity), and writes the result to its output
texture. OpenDlssNrPass.postEffect wraps it as a post effect, and OpenDlssNrPass.reset drops
temporal history after a cut or edit.
src/jvmMain/kotlin/afield/graphics/render/neural/OpenDlssNrPass.kt — read the source
Used in: Neural image rendering with OpenDLSS-NR
OpenDlssNrColorEncoding
enum class OpenDlssNrColorEncoding
What a Vulkan sampler returns. SRGB-format views already decode, so use LINEAR_LDR for those.
Constants: SRGB_CODE, LINEAR_LDR, LINEAR_HDR.
OpenDlssNrControls
Style is the network condition, independently of grading after the pass. Tone and structure take any finite binary16 value: -10..30 is useful for image exploration. Skin is active only with autoMask; negative skin inherits structure. Fixed seed is the default.
| Declaration | Summary |
|---|---|
val skin: Float |
OpenDlssNrInputs
Top-left origin, same-sized sampled images. Motion is UV displacement, by default Field's current-minus-previous convention; depth is standard Vulkan depth with background 1. Temporal mode needs both guides. An arbitrary image/video must supply real motion to use history.
| Declaration | Summary |
|---|---|
val motion: HasImageView? |
|
val motionCurrentMinusPrevious: Boolean |
OpenDlssNrFrame
data class OpenDlssNrFrame(val index: Long,val revision: Long=0,val reset: Boolean=false)
OpenDlssNrPass
class OpenDlssNrPass(val renderer: Renderer,val width: Int,val height: Int) : AutoCloseable
Optional native Vulkan pass, recorded outside render passes on Field's graphics queue. Construct/evaluate/edit/close on the renderer thread. Recreate at a new resolution; close retires behind Field's completion fences. The output is linear RGBA16F, ready for normal Field grading. No CPU frame transfer or private per-frame submit occurs. Failure is explicit; callers can use availability() before creating the pass and present the source when unavailable.
| Declaration | Summary |
|---|---|
val output: RenderToTexture |
|
val nativeAllocatedBytes: Long |
Native VkDeviceMemory allocations, including the shared model/cache and upload staging; excludes driver-internal pipeline/PTX allocations and other passes' history images. |
val outputAllocatedBytes: Long |
|
var controls |
|
val isClosed: Boolean |
Whether this pass has retired, explicitly or with its renderer. |
var resetCount |
|
var resetReason |
|
fun reset(reason: String="camera cut or live edit") |
|
fun postEffect(name: String,inputs: () -> OpenDlssNrInputs,frame: () -> OpenDlssNrFrame): FabricatePostEffect |
Configure an editable Fabricate post effect; its native target calls the same evaluate path. |
fun evaluate(command: CommandBuffer,inputs: OpenDlssNrInputs,frame: OpenDlssNrFrame) |
|
override fun close() |
OpenDlssNrSmokeTest.kt
src/jvmMain/kotlin/afield/graphics/render/neural/OpenDlssNrSmokeTest.kt — read the source
OpenDlssNrSmokeTest
CPU contracts and normal renderer startup with the optional backend explicitly disabled.
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
OpenDlssNrStudy.kt
An editable source image fed through the native neural-rendering (NR) pass, with a graded comparison.
OpenDlssNrStudy renders a source, either a fragment shader, a loaded image or a small geometry
scene, into a texture, runs it through OpenDlssNrPass when OpenDlssNrSupport reports the
native library ready, and displays a graded comparison. Without the library it still shows the
source. OpenDlssNrStudy.reload swaps shaders live and OpenDlssNrStudy.saveFrame captures a frame.
src/jvmMain/kotlin/afield/graphics/render/neural/OpenDlssNrStudy.kt — read the source
OpenDlssNrStudy
Editable source -> native NR -> graded comparison. Source mode works without the optional library. The current box run owns the study: stopping or rerunning it closes the old resources. The renderer also closes the study before destroying its device. The comparison and capture use the very same targets as the live Starting Point.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
|
val size: Int |
|
val name: String |
|
val geometry: Boolean |
|
val availability |
|
val source |
|
val pass |
|
val output |
|
val isClosed: Boolean |
Whether close has run, by the box or by the renderer going away. |
var time |
|
var animate |
|
var recording |
The editable capture box reserves scene time while recording. |
var controls |
|
var grade |
|
fun attach(): OpenDlssNrStudy |
|
fun reload(vertex: String=if(geometry)resource("cubes.vert") else defaultVertex, fragment: String=if(geometry)resource("cubes.frag") else defaultFragment): Result<Unit> |
A shader edit replaces the source at a frame boundary. An edit that keeps the stage interface swaps the pipeline in place; one that changes it (adding or removing a varying, a push-constant member, a binding) rebuilds the source module and mesh. Invalid edits retain the current source. |
val reloadPending: Boolean |
Whether the last interface-changing reload has reached the GPU. |
fun saveFrame(path: Path): CompletableFuture<Path> |
Queue the next comparison frame for a lossless PNG after its GPU fence completes. |
override fun render(command: CommandBuffer,index: Int) |
|
override fun close() |
OpenDlssNrSupport.kt
Whether the native neural-rendering (NR) pass can run on this renderer.
OpenDlssNrSupport.availability returns an OpenDlssNrSupport.Availability that is ready only when
the device enabled the required Vulkan features and the pinned weights and kernels are on disk; its
reason names the Gradle task to run when they are missing (fetchOpenDlssNrWeights,
buildOpenDlssNrNative). The paths can be overridden with FIELD_OPEN_DLSS_NR_MODEL and
FIELD_OPEN_DLSS_NR_SHADERS.
src/jvmMain/kotlin/afield/graphics/render/neural/OpenDlssNrSupport.kt — read the source
OpenDlssNrSupport
Advertised features are checked before device creation; readiness also requires the pinned assets.
| Declaration | Summary |
|---|---|
data class Availability(val ready: Boolean,val reason: String) |
|
fun availability(renderer: Renderer): Availability |
CappedTwistFixture.kt
A closed, rigged tube: the fixture for measuring what a twist costs a limb in volume.
RiggedFixture's bending cylinder is an open tube, so it encloses nothing and Manifold will not
accept it as a solid. This is the same two-bone rig with both ends capped and its rings four
times as close along the bone, so a 180° twist spreads over eight blended bands rather than one
and the volume that goes missing is the blend's doing rather than the triangulation's. At rest
the tube is a straight prism over a regular 12-gon, so its volume has a closed form and
REST_VOLUME is exactly 3.
Nothing is committed for this rig: locate writes the generated text to a temporary file named
after its hash, so a changed generator cannot be answered by a stale copy.
A twisted limb, weighed and traced is this fixture posed, weighed
and path-traced.
src/jvmMain/kotlin/afield/graphics/scene/CappedTwistFixture.kt — read the source
CappedTwistFixture
A closed, rigged tube for measuring what skinning does to volume.
| Declaration | Summary |
|---|---|
const val SEGMENTS: Int |
Radial segments around the tube. |
const val RINGS: Int |
Rings along the tube, at y = 0, 0.25, … , 4. |
const val RING_SPACING: Float |
Distance between rings. |
const val RADIUS: Float |
Tube radius, the same as RiggedFixture.RADIUS. |
const val HEIGHT: Float |
Tube height. |
const val UPPER_JOINT_Y: Float |
Where boneUpper sits, in the bind pose. |
const val MAX_TWIST_DEGREES: Float |
The twist, in degrees, at the end of the clip. |
const val VERTEX_COUNT: Int |
Ring vertices plus the two cap centres. |
const val TRIANGLE_COUNT: Int |
Side triangles plus two twelve-triangle cap fans. |
const val REST_VOLUME: Double |
The prism's volume, in closed form: 12-gon area 0.75 times height 4. See the class comment. |
fun secondsFor(degrees: Float): Float |
The clip time at which boneUpper has turned degrees about its own axis. |
fun cappedTwistGltf(): String |
The generated glTF 2.0 document, as text. Deterministic: same bytes every call. |
fun locate(): String |
An absolute path to the fixture, written from cappedTwistGltf into the temporary directory. |
CompressedSplatPly.kt
Reads PlayCanvas's quantized .compressed.ply splat files back into ordinary per-splat floats.
Nothing upstream of this file has to know the format exists. readPlyAsFloatTable recognises the
schema from the file's own header and routes here, and what comes back carries the columns an
uncompressed 3D Gaussian splatting PLY would have given, in the same conventions. The unpacking
itself is CompressedSplatPly: the per-chunk ranges, the bit layouts, and the inverse of each
quantization. readRaw hands back the packed words instead, for a tool that wants to repack a
file rather than draw it.
src/jvmMain/kotlin/afield/graphics/scene/CompressedSplatPly.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun readCompressedSplatPly(path: String, includeSphericalHarmonics: Boolean = false): PlyFloatTable |
Read a PlayCanvas .compressed.ply as the uncompressed splat table it stands for. See CompressedSplatPly for the format and CompressedSplatPly.readCompressedSplatPly for the parameters. |
CompressedSplatPly
The PlayCanvas compressed PLY — the .compressed.ply that splat-transform writes and that SuperSplat and the PlayCanvas engine read back.
| Declaration | Summary |
|---|---|
const val CHUNK_SIZE |
Splats per chunk. Fixed by the format, not a tunable. |
const val SH_C0 |
The DC term of the real spherical-harmonic basis, as 3DGS and splat-transform use it. |
const val OPACITY_LOGIT_LIMIT |
Opacity is stored as an 8-bit sigmoid value, so 0 and 255 invert to a logit of -infinity and +infinity. The uncompressed PLY column this reader emits is the logit, and callers downstream push it back through a sigmoid — an infinity there is either a NaN or, in GaussianSplatRenderer's case, a non-finite guard that lands on 0.5 and turns a solid splat translucent. Clamping to ±30 costs nothing: sigmoid(30f) is 1 and sigmoid(-30f) is 9.4e-14, both of which round-trip to the byte they came from. |
val requiredChunkProperties |
Chunk properties that every compressed PLY has, in the order the format declares them. |
val colorChunkProperties |
Per-chunk colour range. Older files omit these six and store colour as a plain unorm instead of a lerp between them, so they are optional — but all six or none. |
val vertexProperties |
The four packed 32-bit words that make up a splat. |
val validShColumnCounts |
Coefficient counts a valid sh element may have: one, two or three bands beyond DC, times RGB. |
val decompressedColumns |
Column names of the table readCompressedSplatPly produces, before any f_rest_*. |
data class Layout(val splatCount: Int, val chunkCount: Int, val hasChunkColors: Boolean, val shColumns: List<String>) |
What a compressed PLY's header says about its payload, once recognised. |
sealed interface Recognition |
The outcome of looking at a PLY header and asking "is this a compressed splat file?". |
fun isCompressedSplatPly(path: String): Boolean |
True when path is a compressed splat PLY. Reads only the header. A file that is trying to be one and failing counts as one for this purpose — it is certainly not a plain PLY — so use readCompressedSplatPly when you want the reason. |
fun rawCount(path: String): Int |
Read only the header and return the number of splats, without touching the packed payload. |
fun readCompressedSplatPly(path: String, includeSphericalHarmonics: Boolean = false): PlyFloatTable |
Read a PlayCanvas compressed PLY and hand back the uncompressed splat table. |
class RawSplats(val chunkRanges: FloatArray, val packed: IntArray, val count: Int, val hasChunkColors: Boolean) |
A file's quantized payload, read but not decoded: CHUNK_RANGE_COUNT range floats per chunk (the colour six left zero when the file omits them) and the four packed words per splat, in file order. |
fun readRaw(path: String): RawSplats |
Read path's quantized payload without decoding it: one pass over the file, two arrays out and nothing per-splat but four getInts. |
fun unpackUnorm(value: Int, bits: Int): Float |
Invert packUnorm: take the low bits of value back to 0..1. |
fun dcFromColor(color: Float): Float |
A colour channel, as unorm, back to the SH DC coefficient the trainer produced. |
fun logit(alpha: Float): Float |
A sigmoid'd opacity byte back to its logit, clamped to OPACITY_LOGIT_LIMIT so the 0 and 255 ends stay finite. |
fun unpackShCoefficient(byte: Int): Float |
An SH coefficient byte back to its float, matching splat-transform's endpoint handling. |
fun unpackRotationInto(value: Int, out: FloatBuffer) |
Undo the 2,10,10,10 quaternion packing straight into out as rot_0..rot_3. |
CompressedSplatPlyFixture.kt
src/jvmMain/kotlin/afield/graphics/scene/CompressedSplatPlyFixture.kt — read the source
CompressedSplatPlyWriter.kt
How an edited splat cloud leaves Field, in the PlayCanvas .compressed.ply format it arrived in.
Box code reaches the object below through SplatCloud.write for a file and
SplatCloud.toRawSplats for the packed tables a renderer uploads. Both run the same encoder over
the same ordering, so what a preview shows and what a save contains are quantized alike.
CompressedSplatPly is the reader this inverts.
Quantization is per chunk of 256 splats against that chunk's own bounds, which makes a splat's
accuracy a question of how tightly its neighbours in file order bound it. That is what
CompressedSplatPlyWriter.write's default Morton sort is for.
src/jvmMain/kotlin/afield/graphics/scene/CompressedSplatPlyWriter.kt — read the source
CompressedSplatPlyWriter
object CompressedSplatPlyWriter
Writes PlayCanvas .compressed.ply files — the inverse of CompressedSplatPly.
| Declaration | Summary |
|---|---|
const val SCALE_LOG_LIMIT |
Log-scale clamp, matching splat-transform. Beyond this the exp is not worth storing. |
fun write(file: File, cloud: SplatCloud, mortonSort: Boolean = true): Int |
Write cloud to file. |
fun quantize(cloud: SplatCloud, mortonSort: Boolean = true): CompressedSplatPly.RawSplats |
Quantize cloud into the same payload CompressedSplatPly.readRaw would hand back after a round trip through write, without touching the disk. |
const val MORTON_BITS |
Bits per axis in the Morton grid. See mortonOrder for why it is 13 and not 21. |
fun mortonOrder(cloud: SplatCloud): IntArray |
Splat indices in Morton (Z-curve) order over a MORTON_BITS-per-axis grid across the cloud's bounds. |
DualQuaternionSkinning.kt
The second of Field's two skinning blends: joint motions averaged as dual quaternions, for rigs that twist far enough to break the usual one.
Most callers arrive here without naming the file. GltfSkinning.skin is the entry point, and
passing it SkinningOptions(method = SkinningMethod.DUAL_QUATERNION) hands the vertices
straight to DualQuaternionSkinning.skin, which takes the same arguments and returns the same
type. Linear blending averages the joint matrices, which is what every other engine defaults to
and what the GPU path does by default; it holds up until two influences disagree by a large
rotation, at which point the average of two rotations stops being a rotation and the
cross-section of a twisted limb flattens. Averaging rigid motions instead keeps it round.
Three things qualify that, and DualQuaternionSkinning sets out each of them with its
measurement. A dual quaternion is a rigid motion and nothing else, so a joint that scales or
shears needs a residual matrix carried beside it. Two influences stored with opposite signs
cancel rather than average, unless one of them is chosen as a pivot and the others are flipped
to agree with it. And the comparison with linear blending is a trade rather than a win: this
bulges a joint ring where linear blending pinches it.
src/jvmMain/kotlin/afield/graphics/scene/DualQuaternionSkinning.kt — read the source
DualQuaternionSkinning
Dual quaternion linear blending — the skinning method that does not collapse a twisted joint.
GltfSkinning.kt
Skeletal animation on the CPU: bones, joint palettes and linear blend skinning, for a rigged file loaded through Assimp.
GltfSkinning reads the scene's node tree and its bones, samples one clip at a time, composes
each joint's palette entry down the parent chain, and blends the vertices. The short way in is
skinnedGltfToTriMesh: give it a file, a clip name and a time, and it returns the posed surface
as a TriMesh in scene space, ready to measure, trace or write back out. CpuSkinner is the
same maths against a live mesh, deforming into the vertex buffer the renderer uploads.
GpuSkinning carries the palette to the GPU and blends in the vertex shader, which is what a
crowd needs, and takes this file as its oracle. Where linear blending fails is a twist about a
bone's own axis, which pinches the joint ring to nothing: DualQuaternionSkinning is the
alternative blend, and A twisted limb, weighed and traced weighs
the difference between the two.
src/jvmMain/kotlin/afield/graphics/scene/GltfSkinning.kt — read the source
GltfSkinning
Skeletal animation: bones, joint palettes and linear blend skinning, on the CPU.
GpuSkinning.kt
Skinning in the vertex shader: one mesh, one draw call, and a crowd of characters each posed differently.
The bind-pose vertices are uploaded once and never touched again. Per frame the CPU writes only
the joint palette, which is instances times bones matrices, and the GPU does the per-vertex
blend, so a hundred characters cost a hundred palettes rather than a hundred deformations.
GpuSkinner is the box-facing half: build one over a loaded GltfSupport.MeshInstance, hand it
a palette per instance with uploadPalettes, then bindTo the mesh you are drawing.
GpuSkinning holds the GLSL and the palette packing underneath it.
The CPU path in GltfSkinning is the oracle. The same GLSL that draws also runs as a compute
shader writing deformed positions to a buffer the CPU reads back, so a test that compares the two
is checking the shader that renders rather than a second implementation written to pass it.
Linear blending and dual quaternions can be mixed inside one draw call, chosen per instance,
which is the clearest way to see what separates them.
src/jvmMain/kotlin/afield/graphics/scene/GpuSkinning.kt — read the source
GpuSkinning
Skinning in the vertex shader, with a joint-matrix palette on the GPU, and a crowd of characters from one mesh and one draw call.
| Declaration | Summary |
|---|---|
const val MAX_INFLUENCES: Int |
Influences per vertex, fixed by GltfSkinning.MAX_INFLUENCES and by uvec4/vec4. |
const val VERTEX_STRIDE_FLOATS: Int |
Floats per vertex in the packed buffer the bundled glTF shaders read. |
const val LOCAL_SIZE: Int |
Invocations per workgroup in verifyComputeShader. |
const val DUAL_VEC4S_PER_JOINT: Int |
vec4s per joint in the dual-quaternion palette: real.xyzw, dual.xyzw, then the residual 3×3 S as three columns in JOML order (m00 m01 m02, …) with w unused. |
const val DUAL_FLOATS_PER_JOINT: Int |
Floats per joint in the dual-quaternion palette: DUAL_VEC4S_PER_JOINT × 4. |
const val METHOD_LINEAR: Int |
Method word 0: linear blend skinning from the mat4 palette. |
const val METHOD_DUAL_QUATERNION: Int |
Method word bit 0: dual quaternion blending from the dual palette. |
const val METHOD_RESIDUAL_BIT: Int |
Method word bit 1: this instance's palette has at least one non-rigid joint, so the residual 3×3 must be blended. Only meaningful with METHOD_DUAL_QUATERNION. |
fun packDualPalette(palette: List<Matrix4f>, into: FloatArray, offset: Int = 0): Int |
Write palette into into at offset in the dual-quaternion layout — DUAL_FLOATS_PER_JOINT floats per joint — and return the method word the shader needs for it. |
fun glsl(): String |
The skinning maths, as GLSL: five buffer-reference types and six functions. |
val skinnedVertexShader: String |
GltfSupport.gltVertexShader with the skin in it: same packed 12-float vertex, same buffer references, same push constants — plus a joint stream, a palette and gl_InstanceIndex. |
val skinnedFragmentShader: String |
A fragment shader that actually uses vColor. |
val verifyComputeShader: String |
The same glsl, run over every vertex of every instance, writing deformed positions and normals to a buffer the CPU can read back and compare with GltfSkinning.skin. |
GpuSkinner
The GPU half of CpuSkinner: joints and weights uploaded once, a palette written per frame, and a crowd drawn from one mesh.
| Declaration | Summary |
|---|---|
val instance: GltfSupport.MeshInstance |
|
val instances: Int |
How many characters this palette is sized for. One draw call covers all of them. |
val renderer: Renderer |
|
val name: String |
Distinguishes this skinner's buffers from another's. |
val skin: GltfSkinning.MeshSkin |
The mesh's skin; non-null by construction, as for CpuSkinner. |
val vertexCount: Int |
|
val boneCount: Int |
|
val clips: List<String> |
Clip names available on this mesh's skeleton, in file order. |
val bindPositions: FloatArray |
The bind-pose positions and normals, snapshotted at construction. |
val bindNormals: FloatArray |
|
val jointBuffer: FrameBufferGroup |
uvec4 per vertex. Static: written once in init, never again. |
val weightBuffer: FrameBufferGroup |
vec4 per vertex. Static. |
val paletteBuffer: FrameBufferGroup |
instances × boneCount mat4. The only thing written per frame, and the reason a crowd is nearly free: 64 characters of a 7-bone rig is 448 matrices, against 64 × vertexCount vertices the GPU would otherwise have to be handed. |
val dualPaletteBuffer: FrameBufferGroup |
The same instances × boneCount palette as dual quaternions plus residual — GpuSkinning.DUAL_FLOATS_PER_JOINT floats a joint. Written by uploadPalettes only for the instances that are dual-quaternion skinned; the rest of it is never read. |
val methodBuffer: FrameBufferGroup |
One uint per instance: which blend the shader uses for it. Written by uploadPalettes. |
var method: GltfSkinning.SkinningMethod |
The blend every instance gets unless uploadPalettes is handed a per-instance list. Linear blend by default, matching GltfSkinning.SkinningOptions and every other engine. |
fun placedPalette(model: Matrix4f, clip: String?, seconds: Float): List<Matrix4f> |
This instance's palette at seconds of clip, with model multiplied in on the left, so one entry is model · global(node, t) · inverseBind. |
fun blendedPalette(model: Matrix4f, clipA: String?, aSeconds: Float, clipB: String?, bSeconds: Float, mix: Float): List<Matrix4f> |
As placedPalette, but blending two clips — the per-instance clip blend. mix is 0 for all clipA, 1 for all clipB. |
fun uploadPalettes(palettes: List<List<Matrix4f>>, methods: List<GltfSkinning.SkinningMethod> = List(palettes.size) { method }) |
Write one palette per instance. palettes.size must be at most instances, and every entry must have boneCount matrices. |
fun methodWords(count: Int = instances): IntArray |
The method word the shader will see for each of the first count instances after the last uploadPalettes — GpuSkinning.METHOD_LINEAR, or GpuSkinning.METHOD_DUAL_QUATERNION with GpuSkinning.METHOD_RESIDUAL_BIT when that palette had a non-rigid joint. |
fun uploadPalette(palette: List<Matrix4f>) |
One palette, for the common single-character case. |
fun bindTo(mesh: Mesh, camera: Camera = fromContext(), instanceCount: Int = instances, tint: Boolean = instances > 1) |
Point mesh at this skinner and set every push constant GpuSkinning.skinnedVertexShader needs. Call once; after this only uploadPalettes and, if the camera moves, updateCamera are per-frame. |
fun updateCamera(mesh: Mesh, camera: Camera = fromContext()) |
Push the camera. modelView is view alone, with no model matrix: glTF 2.0 §3.7.3.2 says a skinned mesh node's own transform is ignored, and each instance's placement is already inside its palette. Composing a model matrix in here would apply it twice. |
fun uploadPosedVertices(mesh: Mesh) |
Hand mesh back to the stock, un-skinned path: the bind pose as instance.buffers holds it now, drawn by the skinEnabled == 0 branch. |
fun restoreBindPose(mesh: Mesh) |
Put the bind pose back in the GPU buffer and re-enable GPU skinning. |
GpuSkinningEvidence.kt
A program that draws GPU-skinned characters through the real Vulkan path and writes the frames out as PNGs, each one paired with a CPU-skinned frame of the same pose.
./gradlew --no-daemon runGpuSkinningEvidence
It opens a window, poses a rigged fixture bent, blended across two clips, as a crowd of
instances and twisted, and writes the pictures under dev_docs/agents/evidence/gpu_skinning.
Each pose is rendered twice so that the two frames can be differenced: they differ in where the
skinning happened and in nothing else, which makes the difference a statement about the vertex
shader rather than about two renderers. A zero difference is weak on its own, so a control
frame with skinning switched off has to look different from the skinned one.
Every check ends the run with a non-zero exit code when it fails, including a frame that came
back blank, so this reads as a gate as well as a set of pictures. SkinningEvidence is the
CPU-side companion, drawn with the software rasteriser.
src/jvmMain/kotlin/afield/graphics/scene/GpuSkinningEvidence.kt — read the source
GpuSkinningEvidence
Render GPU-skinned characters and write frames a person can look at.
| Declaration | Summary |
|---|---|
const val EVIDENCE_DIR: String |
|
fun main(args: Array<String>) |
LoadPly.kt
Reads one element of a PLY file as a flat table of floats, addressed by property name.
This is the column-oriented half of Field's PLY support, and the Gaussian splat path reads
through it: gaussianSplats and SplatCloud.read both arrive at readPlyAsFloatTable, then ask
for the columns they want by name. Every scalar property is widened to a float whatever it was on
disk, and list properties are read past and dropped, so a mesh's faces do not survive the trip.
For triangles, and a module to draw them with, load through PlySupport.loadPly instead.
A compressed PlayCanvas splat file is recognised from its header and decompressed on the way
through, so a caller sees the same columns either way; see CompressedSplatPly.
src/jvmMain/kotlin/afield/graphics/scene/LoadPly.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun readPlyAsFloatTable(path: String, elementName: String = "vertex", includeSphericalHarmonics: Boolean = false): PlyFloatTable |
Read one PLY element into a flat PlyFloatTable, one row per element, every scalar property widened to a float. |
fun testPly(fn: String): PlyFloatTable |
Reads a file and prints its columns, row count and float count. A look at a PLY, from a box. |
PlyFloatTable
data class PlyFloatTable(val offsets: Map<String, Int>, val stride: Int, val buffer: FloatBuffer)
One PLY element as rows of floats: a shared buffer, a stride in floats, and the column each property name sits at.
| Declaration | Summary |
|---|---|
val offsets: Map<String, Int> |
Column index per property name, in the order the file's header declared them. |
val stride: Int |
Floats per row, which is the number of scalar properties the element had. |
val buffer: FloatBuffer |
Every row end to end, rowCount × stride floats. |
val columns: List<String> |
The property names, in column order. |
val rowCount: Int |
How many of the element's entries the buffer holds. Throws if its length is not a whole number of rows. |
fun hasColumn(name: String): Boolean |
Whether the element carried this property, which is how an optional column is tested for. |
fun columnOffset(name: String): Int |
This property's column index, or an error naming every column the file does have. |
fun floatAt(row: Int, field: String): Float |
One value, by row and property name. Bounds-checked on the row, and throws on an unknown name. |
fun floatAtOrNull(row: Int, field: String): Float? |
The same, returning null for a column the file does not have rather than throwing. |
fun firstFloatAtOrNull(row: Int, fields: Iterable<String>): Float? |
The first of these property names the file has. PLY spells some quantities more than one way, colour as red or diffuse_red and transparency as alpha or diffuse_alpha, and this takes whichever of a list is present. |
fun requireColumns(vararg fields: String): PlyFloatTable |
Returns this table if it has all of these columns, and otherwise throws naming both the missing ones and the columns the file does have. The splat readers call it as the first thing they do with a table, so a wrong file fails by name rather than by arithmetic. |
fun subsetFloatBufferColumns(selectedFields: List<String>): FloatBuffer |
A fresh buffer holding only these columns, in the order asked for, packed to that narrower stride. Nothing in the tree calls this yet; it is here for narrowing a wide table down to the few columns a GPU buffer wants. |
MeshFromDisk.kt
Connects the loaders that read geometry off disk to the CPU renderer that can look at it.
Field has four things that read geometry from a file, and one of them feeds the CPU preview:
afield.geometry.mesh.mesh_io.ktreads OBJ and STL straight intoTriMesh— the typeafield.geometry.mesh.showMesh,TriMesh.renderPngand the inlinetoHTML()card all render.PlySupportreads PLY (ASCII, binary, both endians, colours, normals, point clouds) into a packed 10-float GPU buffer.GltfSupportreads glTF/GLB — and, beingAssimp.aiImportFileunderneath, also FBX, DAE, 3DS, OBJ, STL and PLY — into a packed 12-float GPU buffer plus materials.afield.graphics.scene.fabricated.FabricateGltfBridgetakes that same model into the PBRStandardRenderer.
Both GPU loaders produce plain host memory — memAlloc, not a Vulkan allocation — which is what
lets their output be read back into a TriMesh on the CPU: PlySupport.PlyModel.toTriMesh,
assimpToTriMesh and the dispatcher meshFromAnyFile, plus a Box.showMesh overload that takes
a File.
showMesh(File("/path/to/thing.glb")) // straight to the orbitable viewer
val m = meshFromAnyFile("/path/to/scan.ply") // or keep the mesh and compute with it
println(m.summary())
They live here rather than in afield.stdlib because mesh_io.kt carries no LWJGL dependency and
these need one. afield.graphics.scene.* is in every box's default imports
(see afield.document.graph.intro), so a box can call them without an import anyway.
What is not here, because nothing in the tree can read it: STEP / IGES / BREP. There is no CAD
kernel on the classpath, and those formats need NURBS-to-mesh tessellation rather than a parser —
see public/docs/geometry_from_disk.md for the full list of what does and does not load.
src/jvmMain/kotlin/afield/graphics/scene/MeshFromDisk.kt — read the source
Used in: Geometry from disk
Top-level functions
| Declaration | Summary |
|---|---|
fun PlySupport.PlyModel.toTriMesh(keepNormals: Boolean = true, keepColors: Boolean = true): TriMesh |
This PLY model's packed vertex buffer, unpacked into a TriMesh. |
fun assimpToTriMesh(path: String, keepColors: Boolean = true): TriMesh |
Read any file Assimp can import — glTF, GLB, FBX, DAE, 3DS, OBJ, STL, PLY and the rest of its forty-odd formats — into one TriMesh. |
fun meshFromAnyFile(path: String): TriMesh |
Load a mesh from a path, in any format Field can read, and return it as a TriMesh you can render, measure, weld, decimate or write back out. |
fun Box.showMesh(path: String, channel: String = "main", view: MeshView = MeshView(), caption: String? = null, title: String? = File(path).name, renderer: MeshRenderer = MeshRenderer.GPU, height: Int? = null) |
Read a mesh file and push a rendered view of it to one of this box's output channels — the one-liner for "I have a mesh on disk, show me". |
fun Box.showMesh(file: File, channel: String = "main", view: MeshView = MeshView(), caption: String? = null, title: String? = file.name, renderer: MeshRenderer = MeshRenderer.GPU, height: Int? = null) |
showMesh from a File rather than a path string, with the same renderer choice. java.io is not in a box's default imports, so inside a box the String overload is usually the one you want. |
PlyLoaderUnitTest.kt
Twelve checks over Field's PLY readers, as a standalone program that needs no Vulkan device.
It writes its own files into a temporary directory and reads them back: ASCII and binary meshes
in both byte orders, a point cloud, a file carrying neither colours nor normals, the float-table
view (PlyFloatTable), and the compressed Gaussian-splat format. One of those compressed files
has its packed words written out as literal bit patterns, so the reader is measured against the
format rather than against a second implementation of it; the others are round-tripped through
the writer.
Each check prints a line and the run ends in All tests PASSED, which is what the smoke catalog
matches on as four.ply-loader.smoke.
src/jvmMain/kotlin/afield/graphics/scene/PlyLoaderUnitTest.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun main() |
Runs PlyLoaderUnitTest.runTests and exits 0 when every check passed, 1 otherwise. |
PlyLoaderUnitTest
Unit tests for PLY loader that don't require graphics context
| Declaration | Summary |
|---|---|
fun createTestFiles() |
Write the small PLY files the checks read, into ply_test under the system temporary directory. runTests calls this itself; call it directly to leave the files somewhere a viewer can open them. |
fun runTests(): Boolean |
Run every check, printing a pass or fail line for each, and return whether all of them passed. A failing check does not stop the others, so one run reports every problem it finds. |
RiggedFixture.kt
The rigged glTF files the skinning tests pose, generated by this source rather than downloaded.
The repository ships no rigged model, and skinning cannot be tested without one, so these two
write their own: glTF 2.0 text with the buffer inline as a base64 data URI, the same bytes on
every call. RiggedFixture is the well-behaved case, a two-bone bending cylinder whose constants
are chosen so the bind pose comes out exact in binary, which lets a test assert bit-identity
against the unskinned mesh instead of a tolerance. StressFixture is the awkward one: a joint
that scales instead of rotating, so it shears a tilted plate; three vertices carrying six
influences where only four fit; and a skinned mesh node with a transform that glTF says to
ignore. Each of those arrives with the number it should produce.
Both commit their asset under dev_docs/agents/fixtures/skinning/, and locate returns an
absolute path to it from wherever the server happens to be running. CappedTwistFixture is the
third rig, a closed tube for measuring volume.
src/jvmMain/kotlin/afield/graphics/scene/RiggedFixture.kt — read the source
RiggedFixture
A rigged glTF, generated rather than downloaded.
| Declaration | Summary |
|---|---|
const val SEGMENTS: Int |
Radial segments around the tube. |
const val RINGS: Int |
Rings along the tube, at y = 0 .. RINGS - 1. |
const val RADIUS: Float |
Tube radius. |
const val UPPER_JOINT_Y: Float |
Where boneUpper sits, in the bind pose. |
const val VERTEX_COUNT: Int |
Vertices in the generated mesh. |
const val TRIANGLE_COUNT: Int |
Triangles in the generated mesh. |
fun upperWeight(y: Float): Float |
The weight this vertex gives to boneUpper; 1 - that goes to boneLower. |
fun bendingCylinderGltf(): String |
The generated glTF 2.0 document, as text. Deterministic: same bytes every call. |
const val COMMITTED_PATH: String |
Where the committed copy lives, relative to the repository root. |
fun locate(): String |
An absolute path to the committed fixture, found by walking up from the working directory. |
fun main(args: Array<String>) |
Rewrite the committed asset from this source. Run from the repository root. |
StressFixture
The second, adversarial rig: a shearing plate and a vertex with more influences than fit.
| Declaration | Summary |
|---|---|
const val COMMITTED_PATH: String |
Where the committed copy lives, relative to the repository root. |
val BIND_NORMAL: FloatArray |
The plate's bind-pose normal: the plane is spanned by +x and (0, 1, -1)/√2. |
val shearedNormal: FloatArray |
The plate's normal after shearBone reaches scale (1, 2, 1), by hand. |
const val CAPPED_DISPLACEMENT: Float |
What the six-influence triangle's x displacement is with the four-wide cap: 2.0 / 0.9. |
const val UNCAPPED_DISPLACEMENT: Float |
And without it: 0.30·1 + 0.25·2 + 0.20·3 + 0.15·4 + 0.06·5 + 0.04·6. |
val SIX_WEIGHTS: FloatArray |
The six weights, largest first, as the file writes them. |
fun shearAndCapGltf(): String |
The generated glTF 2.0 document, as text. Deterministic: same bytes every call. |
fun locate(): String |
An absolute path to the committed fixture; see RiggedFixture.locate. |
SkinnedMeshFromDisk.kt
Posing a rigged glTF on the CPU: into a fresh TriMesh, or back into the vertex buffer a loaded
model already owns.
Which of the two you want depends on what happens next. skinnedGltfToTriMesh loads the file,
deforms it at one time and hands back geometry, which suits measuring a pose, feeding the
software rasteriser, or treating a frame as data. CpuSkinner wraps a mesh that is already
loaded for drawing and overwrites its positions and normals in place, so the stock glTF shaders
draw a moving character without knowing that anything is being skinned.
posedGltfToTriMesh is skinnedGltfToTriMesh with the joint palette supplied by the caller,
for a clip blend or a pose an IK solver produced, and gltfClipNames reads the clip names out
of a file without deforming anything.
The blend itself is GltfSkinning's; what this file adds is the plumbing on either side of it.
GpuSkinner moves the same work into the vertex shader, which is the path to take when the
character has to animate every frame.
src/jvmMain/kotlin/afield/graphics/scene/SkinnedMeshFromDisk.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun skinnedGltfToTriMesh(path: String, clip: String? = null, seconds: Float = 0f, keepColors: Boolean = true, options: GltfSkinning.SkinningOptions = GltfSkinning.SkinningOptions()): TriMesh |
A rigged file, deformed at a time, as a TriMesh you can render, measure and look at. |
fun posedGltfToTriMesh(path: String, keepColors: Boolean = true, options: GltfSkinning.SkinningOptions = GltfSkinning.SkinningOptions(), palette: (GltfSkinning.Skeleton) -> List<Matrix4f>): TriMesh |
The same, with the palette chosen by the caller — for a clip blend, a pose driven by an IK solver, or anything else that is not "clip x at time t". |
fun gltfClipNames(path: String): List<String> |
The clip names in a rigged file, in file order. Empty for a file with no animations. |
CpuSkinner
CPU skinning straight into the GPU vertex buffer a loaded MeshInstance already owns — so the bundled glTF shaders draw a deforming character with no shader change at all.
| Declaration | Summary |
|---|---|
val instance: GltfSupport.MeshInstance |
|
val options: GltfSkinning.SkinningOptions |
|
val skin: GltfSkinning.MeshSkin |
The mesh's skin; non-null by construction. |
val clips: List<String> |
Clip names available on this mesh's skeleton, in file order. |
fun pose(palette: List<Matrix4f>): GltfSkinning.SkinnedVertices |
Deform to palette — one matrix per entry of skin.skeleton.bones — and write the result into the instance's vertex buffer, overwriting only the position and normal fields. |
fun pose(clip: String?, seconds: Float): GltfSkinning.SkinnedVertices |
Deform to clip clip at seconds. null is the rest pose. |
fun reset() |
Put the bind pose back, bit for bit — the snapshot is written straight through. |
SkinningEvidence.kt
The evidence run behind Field's CPU glTF skinning: ./gradlew --no-daemon runSkinningEvidence.
It writes the rigged fixture out as glTF, loads it back through the real Assimp path, and prints what came back with the population each number was measured over: the skeleton and its clips, the influence weights after the four-wide cap, whether the bind pose is bit-identical to the unskinned mesh, and the displacement through the bend. A second, harder fixture covers the cases that are easy to get wrong: a translated node that glTF says to ignore for a skinned mesh, a shear whose correct vertex normal is known in closed form, and a vertex with six influences for the cap to discard from. The run ends with a throughput figure in nanoseconds per vertex, with and without exact inverse-transpose normals.
Frames go to dev_docs/agents/evidence/skinning/ for a person to look at, named by
SkinningEvidence.frames so the sequence reads in sorted order, and the numbers are the ones
dev_docs/SKINNED_CHARACTERS.md quotes. GpuSkinningEvidence is the same exercise for the
compute-shader path.
src/jvmMain/kotlin/afield/graphics/scene/SkinningEvidence.kt — read the source
SkinningEvidence
Regenerate the rigged fixture, measure the skinning, and write frames a person can look at.
| Declaration | Summary |
|---|---|
const val FIXTURE: String |
|
const val EVIDENCE_DIR: String |
|
fun anchor(minX: Float = -3f, minY: Float = -0.5f, minZ: Float = -2f, maxX: Float = 1.5f, maxY: Float = 4.5f, maxZ: Float = 2f): TriMesh |
Two 2cm triangles at opposite corners of a fixed box, so every frame in a sequence is framed identically. Invisible at the sizes here, and outside the cylinder so they never occlude it. |
fun main(args: Array<String>) |
|
fun frames(): List<Pair<String, () -> TriMesh>> |
The frames written by main, named so the sequence reads in sorted order. |
simpleGLTFSupport.kt
Loading a model file onto the GPU, with its materials and textures.
gltfModelHere("model.glb") { } in a box is the usual case: the call
expands the way moduleHere does, into a graphics module carrying the bundled glTF shaders at the
end of this file, and hands back that module together with the loaded GltfSupport.GltfModel.
GltfSupport.loadGltfBasic is the same load without a module, for code bringing its own shaders.
The name says glTF and the importer is Assimp, so the formats are Assimp's: glTF and GLB, and also
FBX, DAE, OBJ, STL and PLY. A load returns one GltfSupport.MeshInstance per mesh in the node tree,
each carrying host-side vertex and index buffers in the fixed 12-float layout those shaders read
(position, normal, uv, colour), its material, and the node's animation track resampled at a fixed
rate, which this file's sample extension interpolates at whatever time you ask for.
Two things catch callers out. GltfSupport.GltfLoadConfig's loadTextures is on by default and
that path asks the box context for a Renderer, so a load taking the default arguments cannot run
outside a live renderer; pass GltfLoadConfig(loadTextures = false) for geometry alone, as
assimpToTriMesh does. And a GltfSupport.MeshInstance's bindPose is a
translation/rotation/scale triple, which cannot hold the mirror or shear a glTF node transform is
allowed to carry; bindMatrix, documented at length below, is the full 4×4 the loader also keeps.
src/jvmMain/kotlin/afield/graphics/scene/simpleGLTFSupport.kt — read the source
Used in: Geometry from disk
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.gltfModelHere(p: Parameters.() -> Unit): Pair<ModulePointer, List<GltfSupport.GltfModel>> |
gltfModelHere("model.glb") { } in box code: a graphics module for that file, carrying the bundled glTF shaders, together with the model the importer read. |
fun Box.macro_modelHere(args: List<String>): Insert? |
|
fun Box.macro_modelWithTextureHere(args: List<String>): Insert? |
|
fun Box._gltfModelHere(filename: String, p: Parameters.() -> Unit = {}): Pair<ModulePointer, List<GltfSupport.GltfModel>> |
|
fun Box._gltfModelHereWithTexture(filename: String, p: Parameters.() -> Unit = {}): Pair<ModulePointer, List<GltfSupport.GltfModel>> |
|
fun registerGLTFLoader() |
|
fun List<TRSSample>.sample(time: Float): TRS? |
GltfSupport
simplePLYSupport.kt
Loads a PLY mesh or point cloud into a live Vulkan module, shaders included.
In a box, plyModelHere("model.ply") is the whole of it: the macro builds a module wired to the
bundled shaders, loads the file, and returns both the module and the loaded model. Point clouds
take plyPointCloudHere, which draws every point as a screen-aligned quad that the fragment
shader rounds off into a dot. Underneath, PlySupport is a plain reader, ASCII or binary PLY in
either endianness, and it will load a file with no renderer in sight.
For a TriMesh to compute with rather than a module to draw, meshFromAnyFile reads the same
files; see Geometry from disk. Gaussian splats are the other
direction: their PLY carries a rotation and a scale per splat rather than faces, and
readPlyAsFloatTable is what reads it.
src/jvmMain/kotlin/afield/graphics/scene/simplePLYSupport.kt — read the source
Used in: Geometry from disk
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.plyModelHere(p: Parameters.() -> Unit): Pair<ModulePointer, PlySupport.PlyModel> |
What a box writes to draw a PLY mesh: plyModelHere("model.ply") { }, returning the module and the loaded model. |
fun Box.plyPointCloudHere(p: Parameters.() -> Unit): Pair<ModulePointer, PlySupport.PlyModel> |
The same for a point cloud, drawing each point as a rounded quad. Rewritten to _plyPointCloudHere. |
fun Box.macro_plyModelHere(args: List<String>): Insert? |
Expands a plyModelHere call, attaching the bundled mesh shaders to the module it creates. |
fun Box.macro_plyPointCloudHere(args: List<String>): Insert? |
Expands a plyPointCloudHere call, attaching the bundled point-cloud shaders instead. |
fun Box._plyModelHere(filename: String, p: Parameters.() -> Unit = {}): Pair<ModulePointer, PlySupport.PlyModel> |
What plyModelHere becomes: a module from p, plus filename loaded through PlySupport.loadPly. |
fun Box._plyPointCloudHere(filename: String, pointSize: Float = 8.0f, p: Parameters.() -> Unit = {}): Pair<ModulePointer, PlySupport.PlyModel> |
What plyPointCloudHere becomes. The module draws triangles rather than points, because the point-cloud vertex shader pulls six vertices per point to make a screen-aligned quad. |
fun registerPLYLoader() |
Registers both call rewrites with the macro registry at startup. |
PlySupport
Self-contained PLY (Polygon File Format) loader for Field 4.
Introspection.kt
Reflection of a compiled shader's interface: the uniform blocks, storage buffers, textures, images, acceleration structures and push constants it declares, with their sets, bindings, member offsets and sizes.
Introspection compiles GLSL to SPIR-V through ShaderSPIRVUtils (or takes SPIR-V or Slang reflection
directly, through fromSpirv and fromSlangReflection) and reads the result with SPIRV-Cross, so the
layout it reports is the compiler's, not a parse of the source. Introspected holds the reflected
types; parseGlslMemberComments recovers the trailing comments on block members, which the
reflection does not carry. Field's modules use this to allocate and bind each shader's buffers.
src/jvmMain/kotlin/afield/graphics/shader/Introspection.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun parseGlslMemberComments(source: String): Map<String, String> |
Scrape the // trailing comment off every member declaration inside a GLSL block — the push-constant block, a struct, a UBO, an SSBO — so a human note written next to a uniform can travel to the Kotlin accessor generated for it (see afield.graphics.shader.insertForShader, which appends it to the generated preamble line, and afield.tools.docs.ShaderBindingDocs, which reads it back for the completion popup). |
fun Either<PrimitiveType, List<Introspected.Member>>.layoutSizeInBytes(): Int |
Byte size of a member's type: a primitive's size, or the maximum offset+size across nested members. Structure.kt sizes a buffer's members with it. |
fun Introspected.Member.layoutSizeInBytes(): Int |
Byte size of a member: a device pointer's size if physical-pointer, else its type's layout size. |
fun List<Introspected.Member>.membersLayoutSizeInBytes(): Int |
Byte size of a list of members: the furthest offset+size any of them reaches, so a block's size is its last member's end rather than the sum of its members. |
ImageType
enum class ImageType(val vulkan_format: Int)
Constants: rgba16f.
PrimitiveType
Constants: float, vec2.
| Declaration | Summary |
|---|---|
val components: Int |
How many scalars the type holds. Matrices count every element, so mat4 is 16 rather than 4 columns. |
val componentsSizeInBytes: Int |
Bytes per scalar: 4 for the 32-bit types, 2 for the f16 ones, 8 for the 64-bit ones. |
val bufferFormat: Int |
The VK_FORMAT_* a vertex attribute of this type is described with, and -1 for the matrix and 64-bit types, which no single vertex format covers. Nothing substitutes a valid format for that -1. |
val kotlinTypeName: String |
The fully-qualified Kotlin type a generated preamble declares for this, so a vec3 uniform arrives as an afield.math.linalg.Vector3f. |
val kotlinDefault: String |
The expression a generated declaration is initialised with. vec4's is vec(0,0,0,1), opaque black rather than four zeroes. |
val writeXToBuffer: String |
A Kotlin statement to be pasted into generated vertex-writing code, with x for the value and buffer for the target. Its one consumer is writeVertexData, which nothing in the tree calls. |
val sizeInBytes: Long |
The type's packed size: components times component size. This is the size of the value itself, not the stride a std140 block would give it. |
TextureType
enum class TextureType(val dimensions: Int)
Constants: texture2D, texture2DArray.
Introspected
| Declaration | Summary |
|---|---|
open class Bindable(val name: String, val set: Int, val binding: Int) |
Anything the shader reaches through a descriptor set: a name at a set and a binding. |
data class Input(val name: String, val type: Either<PrimitiveType, List<Member>>, val location: Int) |
One in or out variable of a stage, at a location. The type is a primitive, or the members of a struct passed between stages. |
data class Member(val name: String, val offset: Int, val type: Either<PrimitiveType, List<Member>>, val arrayStride: Int = 0, var physicalPointer: Boolean = false) |
One field of a block, struct or push-constant range, at the byte offset the compiler gave it. A member whose type is a list of members is a nested struct, and arrayStride above zero makes it an array; physicalPointer marks a buffer-reference field, which occupies eight bytes whatever its pointed-to type says. |
class UBO(name: String, val size: Int, set: Int, binding: Int, val members: List<Member>) |
A block of members at a set and binding, in bytes. It stands for a uniform block and for a storage buffer — ubos and ssbo are both lists of this — so the kind of binding is which list it is in, not which type it is. A storage buffer reflected from Slang has size 0, since its length is not known from the declaration. |
class Texture(name: String, val type: String, set: Int, binding: Int, val arrayCount: Int = 0) : Bindable(name, set, binding) |
A sampled texture, with type the sampler type the shader wrote (sampler2D, sampler2DArray, …). An arrayCount above zero means an array of bindings, a descriptor array, rather than an array texture, which is part of the type instead. |
class Image(name: String, val type: String, set: Int, binding: Int) : Bindable(name, set, binding) |
A storage image. Its type is the reflected image format, not a sampler type. |
class AccelerationStructure(name: String, val type: String, set: Int, binding: Int) : Bindable(name, set, binding) |
A ray-tracing acceleration structure the shader traces against. |
data class Sampler(val name: String, val type: String, val set: Int, val location: Int) |
A sampler declared on its own, apart from the texture it will be combined with. It is not a Bindable, and its location is filled with the reflected binding, so read that field as the binding number it is. |
data class PushConstant(val name: String, val size: Int, val members: List<Member>) |
A push-constant block: its members, and the size in bytes rounded up to a multiple of 16. |
data class StructureType(val name: String, val members: List<Member>, val size: Int, val bufferReference: Boolean = false) |
A struct the shader declares, with bufferReference true for a buffer_reference block: a struct reached through a device address rather than bound to a set. |
val inputs |
The stage's in variables, by location. |
val outputs |
The stage's out variables, by location. |
val ubos |
The uniform blocks the stage declares. |
val ssbo |
The storage buffers, held as the same UBO type the uniform blocks use. |
val textures |
The sampled textures: a sampler2D and friends, where image and sampler arrive as one. |
val images |
The storage images. |
val accelerationStructures |
The acceleration structures. |
val samplers |
The samplers declared apart from a texture. The images declared that way are not collected: the reflection reports them separately and nothing reads that, so a shader that splits image from sampler has the sampler here and the image nowhere. |
val pushConstants |
The push-constant blocks this stage declares. |
val structureTypes |
The structs the shader declares, named from the GLSL source, since reflection keeps the layout but not the name. Each struct is matched to its reflected members by its list of member names, and two structs with the same member names in the same order collapse to the first. A shader given as SPIR-V takes the reflection's own names instead. Empty for an interface read from Slang reflection. |
val memberComments |
The // trailing comment a human wrote next to a member declaration in the shader source, keyed by Block.member, instance.member and (when unambiguous) the bare member — see parseGlslMemberComments for exactly how it is scraped and what it cannot see. |
fun memberComment(member: String, block: String? = null): String? |
The comment for a member, preferring the one qualified by block (the push-constant block, struct or UBO name) and falling back to the bare member name. Null when the source said nothing. |
var fixedWorkgroupSize: List<Int>? |
Fixed compute workgroup dimensions from SPIRV-Cross; null for other stages or specialized sizes. |
lateinit var code: ShaderSPIRVUtils.SPIRV |
The compiled SPIR-V the reflection was read from, which a pipeline then builds its shader module out of. It is assigned only once the compile has succeeded, so reading it after a failed introspection throws rather than returning nothing. |
override fun toString(): String |
A multi-line dump of the interface, for a log. It stops at the push constants: the declared structs, the member comments and any fixed workgroup size are not in it. |
Introspection
| Declaration | Summary |
|---|---|
val source: String |
The GLSL that was compiled. It is also read lexically, for the struct names the reflection does not carry and for members' trailing comments, and that reading happens whether or not the compile succeeds. A shader handed over as SPIR-V has none, and falls back to the names in the reflection's own type table. |
val kind: ShaderKind |
Which stage this is, which decides how the source is compiled and how Slang reflection is read. |
val errorHandler: (String, Throwable) -> Unit |
Where a compile or reflection failure goes. The default rethrows, so an introspection built without one either succeeds or throws; pass a handler to get an invalid introspection back instead of an exception. |
val sourceName: String |
The name compiler errors are reported against, VERTEX_SHADER.glsl and the like by default. |
val includeResolver: ShaderIncludeResolver? |
Answers this source's #include lines during the compile. |
var valid |
True once the compile and the reflection have both finished. While it is false every interface list is empty and code is unset. The member comments are there even so, since they are scraped from the source before anything is compiled. |
MetalShaderErrors.kt
Explains shader failures on macOS that come from Metal name collisions rather than from the GLSL.
src/jvmMain/kotlin/afield/graphics/shader/MetalShaderErrors.kt — read the source
MetalShaderErrors
What a shader failure on macOS means, when the cause is Metal rather than your GLSL.
| Declaration | Summary |
|---|---|
fun explain(message: String): String? |
What message — Vulkan's report of a Metal compile failure — means, in plain words; null when it is not a name collision. |
fun note(message: String): String? |
Record message if it explains a Metal failure, and return the explanation. |
fun recent(withinMs: Long = 10_000): String? |
The explanation of a Metal failure noted in the last few seconds, for the pipeline error that follows it; null otherwise. |
val reservedNames: Set<String> by lazy |
The names of the types Metal's standard library declares, read from the newest set of Metal headers on this machine; empty where there are none (not macOS). A function in a shader named like one of these does not build on macOS. |
MslEditorLanguage.kt
The Monaco editor language for Metal Shading Language, used by the msl box property.
src/jvmMain/kotlin/afield/graphics/shader/MslEditorLanguage.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun mslEditorLanguageJavascript(): String |
The same text MslEditorLanguage.javascript returns, as a top-level function. |
MslEditorLanguage
The Monaco language for Field's msl box property — Metal Shading Language for a visionOS head.
| Declaration | Summary |
|---|---|
const val id: String |
The language id, msl, as the same string languageId holds. Neither constant reaches the browser: the id Monaco registers is declared inside msl.js itself. |
const val languageId: String |
The Monaco language id Field uses for the msl property, and for .metal / .msl files. |
fun javascript(): String |
The text of msl.js, read from the classpath, or from src/commonMain/resources/msl.js relative to the working directory when the resource is not on it. Throws when neither is there, rather than returning an empty grammar. |
ShaderAbi.kt
Decides whether a recompiled shader can be swapped in without a full reload.
graphicsShaderAbiCheck and computeShaderAbiCheck compare the old and new Introspection of each
stage; a changed interface in any stage returns a ShaderAbiCheck that is not compatible, with a
message asking for a full reload.
src/jvmMain/kotlin/afield/graphics/shader/ShaderAbi.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun graphicsShaderAbiCheck(oldVertex: Introspection, oldFragment: Introspection, newVertex: Introspection, newFragment: Introspection): ShaderAbiCheck |
Compares a graphics shader's vertex and fragment interfaces across a recompile. |
fun computeShaderAbiCheck(oldCompute: Introspection, newCompute: Introspection): ShaderAbiCheck |
Compares a compute shader's interface across a recompile, on the same terms graphicsShaderAbiCheck applies to each graphics stage. |
ShaderAbiCheck
data class ShaderAbiCheck(val compatible: Boolean, val message: String = "")
ShaderInterfaceTransition.kt
What changed in a shader's interface across a full reload, binding by binding and member by member.
shaderInterfaceTransition and graphicsShaderInterfaceTransition compare two Introspections and
classify each binding as one ShaderBindingChange; the result names what was kept, added, removed,
renamed, resized or retyped, so a reload can re-apply the values that still fit. Tested in
ShaderInterfaceTransitionTest.
src/jvmMain/kotlin/afield/graphics/shader/ShaderInterfaceTransition.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun graphicsShaderInterfaceTransition(oldVertex: Introspection, oldFragment: Introspection, newVertex: Introspection, newFragment: Introspection): ShaderInterfaceTransition |
shaderInterfaceTransition for a graphics (vertex + fragment) module. |
fun shaderInterfaceTransition(old: List<Pair<String, Introspection>>, new: List<Pair<String, Introspection>>): ShaderInterfaceTransition |
Compare two module interfaces, stage by stage, and classify every binding — see ShaderInterfaceTransition. Bindings are merged across a module's stages by name, as the module itself merges them (one uniform per name, one push-constant layout per member name). |
ShaderBindingKind
enum class ShaderBindingKind(val label: String)
The kinds of thing a shader's interface binds, as a full reload compares them. label is how a transition summary names one: "uniform block Light grew 16→32 bytes".
Constants: UNIFORM_BLOCK, STORAGE_BUFFER, TEXTURE, IMAGE, ACCELERATION_STRUCTURE, PUSH_CONSTANT.
ShaderBindingChange
enum class ShaderBindingChange
What a full reload did to one binding (or one member of a block), old interface to new.
Constants: KEPT, ADDED, REMOVED, RENAMED, RESIZED, RETYPED.
ShaderMemberTransition
A member of a uniform block, storage buffer or push-constant block, old to new, by name.
ShaderBindingTransition
One binding's fate across a reload. name is its name in the new interface (the old one for ShaderBindingChange.REMOVED); oldName differs from it only for ShaderBindingChange.RENAMED. Sizes are in bytes, for blocks. members lists the members of a kept-by-name block whose type or offset changed, or that came or went.
| Declaration | Summary |
|---|---|
val kind: ShaderBindingKind |
Which sort of binding this is, and so how a summary line names it. |
val change: ShaderBindingChange |
What the reload did to it. |
val name: String |
The binding's name in the new interface. A removed binding has no new name, so this carries the old one. |
val oldName: String |
Its name in the old interface, equal to the new name except for a rename. |
val oldSize: Int? |
The old size in bytes, for a uniform block or storage buffer. Null for the other kinds, which have no size of their own, and for a block that is only in the new interface. |
val newSize: Int? |
The new size in bytes, on the same terms; null for a block that is only in the old interface. |
val oldType: String? |
The old type, for the kinds a type describes: a texture (with its array count, as sampler2D[4]), an image, an acceleration structure, or a push-constant member. Blocks say their sizes and members instead, so both type fields stay null for them. |
val newType: String? |
The new type, on the same terms. |
val members: List<ShaderMemberTransition> |
For a block kept under the same name, only those of its members that did not stay the same. Empty for every other case, a renamed block included — a rename is only recognised when the layout matches, so there is nothing to list. |
val quiet: Boolean |
Kept, with no member changed. |
ShaderInterfaceTransition
Old interface to new, for one full reload of a module: every binding classified, and the stages that came or went. What shaderInterfaceTransition computes and what the reload carries buffers and values across by — see dev_docs/stdlib_behaviour_changes.md, "Full shader reload diffs the interface".
| Declaration | Summary |
|---|---|
val bindings: List<ShaderBindingTransition> |
Every binding either interface declares, each appearing once, grouped in a fixed order: uniform blocks, storage buffers, textures, images, acceleration structures, then push-constant members. Kept bindings are in here too; changes is the filtered view. |
val stagesAdded: List<String> |
Stages the new module has and the old one did not. A ray-tracing module's stage list varies with what it declares, and a repeated stage is numbered (closesthit#2), so adding a second hit shader arrives here. |
val stagesRemoved: List<String> |
Stages the old module had and the new one does not. |
val changes: List<ShaderBindingTransition> |
Everything that is not simply kept. |
val quiet: Boolean |
Nothing changed that a person needs to hear about. |
fun of(kind: ShaderBindingKind, name: String): ShaderBindingTransition? |
The binding of kind called name in the new interface, if it has one. |
fun oldNameOf(kind: ShaderBindingKind, name: String): String |
The old name of the kind binding the new interface calls name: name unless it was renamed. |
val staleNames: Map<String, String> |
The Kotlin names the box's preamble exposed for the old interface that no longer mean what they did, each with why: a removed or renamed binding, a removed or retyped block member or push constant. Code that uses one was written against the old preamble; the next run compiles against the new one. |
fun summary(): String |
One line saying what the reload did to the interface — "uniform block Light grew 16→32 bytes, buffer reallocated; push constant tint removed" — or "" when nothing changed. |
ShaderSPIRVUtils.kt
GLSL to SPIR-V compilation through shaderc, with #include resolution and line-numbered error reports.
ShaderSPIRVUtils compiles a source for one ShaderKind (vertex, fragment, compute, mesh, task and
the ray-tracing stages), and throws CompilerError with the compiler's message on failure. A
ShaderIncludeResolver passed in a ShaderCompileSource answers each include request.
src/jvmMain/kotlin/afield/graphics/shader/ShaderSPIRVUtils.kt — read the source
ShaderKind
enum class ShaderKind(internal val kind: Int)
Constants: VERTEX_SHADER, GEOMETRY_SHADER, FRAGMENT_SHADER, COMPUTE_SHADER, MESH_SHADER, TASK_SHADER, RAYGEN_SHADER, ANY_HIT_SHADER, CLOSEST_HIT_SHADER, MISS_SHADER, INTERSECTION_SHADER, CALLABLE_SHADER.
| Declaration | Summary |
|---|---|
fun toVulkan(): Int |
The VK_SHADER_STAGE_* bit for this kind, as a pipeline stage wants it. Mesh and task are the VK_EXT_mesh_shader bits and the six ray-tracing kinds the VK_KHR_ray_tracing_pipeline ones, so the stage a shader compiles for is also the extension it needs. |
CompilerError
class CompilerError(override val message : String) : Exception(message)
ShaderIncludeType
enum class ShaderIncludeType(internal val shadercType: Int)
Constants: RELATIVE, STANDARD.
ShaderIncludeRequest
ShaderIncludeResolution
data class ShaderIncludeResolution(val resolvedPath: String, val source: String)
ShaderIncludeResolver
fun interface ShaderIncludeResolver
| Declaration | Summary |
|---|---|
fun resolve(request: ShaderIncludeRequest): ShaderIncludeResolution? |
Answers one #include, or returns null to fail the compile with "Unable to resolve include '…' from '…'". Throwing fails it the same way, with the exception's own message, so a resolver need not catch anything to produce a readable error. |
ShaderCompileSource
ShaderSPIRVUtils
| Declaration | Summary |
|---|---|
fun wrapBytecode(bytes: ByteArray): SPIRV |
Wraps SPIR-V that something else compiled (slangc's output, or a .spv read from disk) as a SPIRV the pipeline code accepts. The bytes are copied into a direct, native-order buffer with no shaderc result behind it, so freeing the result releases only the reference. |
fun compileShaderFile(shaderFile: String?, shaderKind: ShaderKind): SPIRV? |
Compiles a shader stored as a classpath resource, named the way the system class loader wants it. A name no resource matches throws rather than returning null — the null result is for a resource that is found and then cannot be read. |
fun compileShaderAbsoluteFile(shaderFile: String, shaderKind: ShaderKind): SPIRV? |
Compiles the shader at a file: URI, as compileShaderFile hands one over. Returns null when the URI will not parse or the file will not read, printing the stack trace; a compile error still arrives as CompilerError. |
fun compileShader(filename: String, source: String?, shaderKind: ShaderKind): SPIRV |
Compiles source under the name filename, or reads the file at filename when source is null. No include resolver, so this overload is for a self-contained stage. |
fun compileShader(source: ShaderCompileSource, shaderKind: ShaderKind): SPIRV |
The compile every other overload ends at: shaderc, targeting Vulkan 1.3 and SPIR-V 1.5, with main as the entry point and this source's resolver answering its includes. Debug info is emitted only when validation or shader printf is on, and the whole call is timed as shader.compile. |
class SPIRV(private val handle: Long, private var bytecode: ByteBuffer?) : NativeResource |
A compiled module: the bytes a VkShaderModule is created from. When compileShader produced it those bytes are shaderc's own, so freeing it releases shaderc's result and the buffer stops being readable; when wrapBytecode did, there is no result and freeing only drops the reference. |
ShadertoyChannels.kt
What a ShaderToy Image pass reads and draws onto in Field: ShadertoyChannel (a texture turned the
way ShaderToy samples one), ShadertoyCanvas (the picture a document's shadertoy boxes share, shown
in the renderer window) and webcam (the camera as a channel).
shadertoyHere builds the module; these give it inputs and somewhere to draw. The shadertrinket
starting point uses all three:
val canvas = shadertoyCanvas()
canvas += shadertoyHere {
+canvas.rtt
textures("iChannel0" to webcam())
}
src/jvmMain/kotlin/afield/graphics/shader/ShadertoyChannels.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun shadertoyCanvas(width: Int = 960, height: Int = 540, name: String = "shadertrinket", inWindow: Boolean = true, renderer: Renderer = fromContext()): ShadertoyCanvas |
This renderer's shadertoy canvas called name, made width×height on first use and shared after that (asking again with another size keeps the first, and says so). See ShadertoyCanvas. |
fun Box.webcam(device: Int = 0, width: Int = 1280, height: Int = 720, renderer: Renderer = fromContext()): HasImageView |
The camera device as a texture, for textures("iChannel0" to webcam()): opened once per renderer at width×height (if the camera offers it) and kept open across re-runs, and uploaded every frame as a renderer pre-target. |
ShadertoyChannel
A texture as a ShaderToy iChannel sees it: a copy, redrawn every frame, that is bottom-up and holds display values.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
The renderer this channel's flip pass runs on, and the first half of the key it is cached under: one channel per renderer and per source view. |
val width: Int |
The source's width in pixels (and so this channel's iChannelResolution). |
val height: Int |
The source's height in pixels. |
val target |
The flipped copy, which the shadertoy module samples. |
val sampler: TextureSampler |
Linear filtering, clamped at the edges. |
ShadertoyCanvas
The picture a document's shadertoy boxes draw into, like ShaderTrinket's canvas: a width×height render target, shown letterboxed in the renderer window, that can also be published or bound as another shader's iChannel (it is rtt).
| Declaration | Summary |
|---|---|
val renderer: Renderer |
The renderer that holds the canvas and composites it. Its window supplies the aspect the canvas is letterboxed into and the cursor that becomes iMouse. |
val name: String |
What this canvas is called, shadertrinket by default. Canvases are shared per renderer and per name, so two boxes asking for the same name draw into the same picture; it also names the render target and the compositing module in the renderer's lists. |
val width: Int |
The canvas's width in pixels, and the x of iResolution for every module drawn into it. Fixed at first use: asking for the same name at another size keeps this one and logs a warning. |
val height: Int |
The canvas's height in pixels, fixed at first use in the same way as the width. |
val rtt |
The canvas itself: _SRGB, so the display values a ShaderToy program writes are what it stores. |
fun fitInWindow(): Vector4f |
The canvas's rectangle in the window, as fractions (x, y, w, h) from the top left. |
val surface |
iResolution and iMouse for modules drawn into this canvas. |
var inWindow: Boolean |
Whether the window shows this canvas. Turn it off for a canvas that is only another shader's channel. |
operator fun plusAssign(module: ModulePointer) |
Draw module (a shadertoyHere module built with +canvas.rtt) as this canvas's picture. |
ShadertoyPublish.kt
ShaderTrinket's Publish, as a function: publishShadertoyCanvas streams a shader canvas over
WebRTC under a name, so what a viewer sees is whatever that canvas is showing, presets included.
src/jvmMain/kotlin/afield/graphics/shader/ShadertoyPublish.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.publishShadertoyCanvas(name: String = "field-shader", canvas: ShadertoyCanvas = shadertoyCanvas(), network: WebRTCNetwork = WebRTCNetwork.default): WebRTCSink |
ShaderTrinket's Publish: stream canvas to the class as login/name, with the stream's preview and status in this box's output. |
SlangCompiler.kt
Compiles Slang shaders by running an external slangc, to GLSL, SPIR-V and reflection JSON.
SlangCompiler looks for the executable in AFIELD_SLANGC, then -Dafield.slangc, then the pinned
download, the Vulkan SDK and PATH. compileVertexFragment and compileSharedStage return one
SlangStageArtifacts per stage, ready for Introspection.
src/jvmMain/kotlin/afield/graphics/shader/SlangCompiler.kt — read the source
SlangEntryPoint
data class SlangEntryPoint(val name: String, val stage: String)
SlangStageArtifacts
SlangCompiler
| Declaration | Summary |
|---|---|
fun compileToGlsl(source: String, kind: ShaderKind, entryFileName: String): String |
Translates source to GLSL for one stage and returns the text, with no entry point named and no SPIR-V or reflection asked for. For a stage that will be introspected and loaded, use compileSharedStage instead. |
fun compileVertexFragment(source: String, entryFileName: String = "vertex_fragment.slang"): Pair<SlangStageArtifacts, SlangStageArtifacts> |
Compiles both halves of a graphics shader out of one Slang file, which is where Slang differs from Field's GLSL pair: one source holds a vertex entry point and a fragment one, and the structs they pass between them are written once. The file is compiled twice, once per stage. |
fun compileSharedStage(source: String, kind: ShaderKind, entryFileName: String): SlangStageArtifacts |
Compiles one stage of a Slang source and returns everything Field needs to load it. Three slangc runs: one with -no-codegen to list the file's entry points, then one for GLSL and one for SPIR-V and reflection. The source must declare exactly one entry point for this stage. None, or two, is a CompilerError naming the ones it found. |
GpuShaderLearningView.kt
An editable render-to-texture teacher next to a learner that trains on the GPU while it runs.
GpuShaderLearningView pairs a source RenderToTexture with GpuShaderTraining; with learning off and the
learned-only view selected, the source stops running and the learned function stays.
src/jvmMain/kotlin/afield/graphics/shader/learning/GpuShaderLearningView.kt — read the source
GpuShaderLearningView
An editable RTT teacher and its live GPU learner. Source lifetime remains with the caller. View 1 with learning=false stops executing the source entirely; its learned function remains. parameter is normalized to [-1,1], with the same value supplied to the teacher and network.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
|
val source: RenderToTexture |
|
val name: String |
|
val model |
|
var learning |
|
var iterationsPerFrame |
|
var parameter |
|
var view |
|
var sourceRenderCount |
|
val output |
|
fun install(): GpuShaderLearningView |
|
override fun render(command: CommandBuffer, index: Int) |
|
override fun close() |
GpuShaderTraining.kt
On-GPU training of the learned RGB function, with no CPU image or weight readback.
GpuShaderTraining is the in-renderer alternative to the offline ShaderTraining path. It needs a device with cooperative-vector training enabled and the prebuilt training shaders under build/lighting/learned-shader/; otherwise its constructor refuses and the offline path remains.
src/jvmMain/kotlin/afield/graphics/shader/learning/GpuShaderTraining.kt — read the source
GpuShaderTraining
A small reusable learned RGB function, trained directly from Field images on the graphics queue. Architecture matches LearnedShader: 16 positional features -> 32 ReLU -> 32 ReLU -> RGB. Slang differentiates the network; cooperative-vector outer products accumulate its gradients. FP32 Adam master weights/moments, loss-scaled FP16 gradients and inference matrices. The source remains caller-owned. Train/evaluate/close only between or within render-thread work. No CPU image/weight readback is used in train/evaluate. This is not a general graph ML runtime.
| Declaration | Summary |
|---|---|
data class Layer(val inputs: Int,val outputs: Int,val matrixOffset: Int,val biasOffset: Int,val matrixBytes: Int) |
|
val layers: List<Layer> |
|
val trainingBytes: Int |
|
val output |
|
var steps |
|
var learningRate |
|
fun reset() |
Restart the deterministic initial model, including optimizer state, on the next recorded call. |
fun train(command: CommandBuffer,image: HasImageView,parameter: Float,iterations: Int=1) |
Sample the already-rendered source at this normalized parameter, then perform iterations Adam updates. Each step uses a new reproducible minibatch. Every eighth hashed pixel is held out; evaluate that subset separately when judging generalization. |
fun evaluate(command: CommandBuffer,index: Int,parameter: Float) |
Evaluate the learned function at any point in the trained parameter interval; source is not rendered. |
fun save(directory: File,sourceShaderDigest: String,fixedState: String): File |
Blocking, explicit checkpoint BETWEEN frames. Converts opaque training matrices to the existing portable LearnedShader row-major format. No training-optimal layout escapes into the file. The caller records the source digest and fixed-state description it actually used. A new directory prevents a partial checkpoint from replacing a working model. |
override fun close() |
GpuShaderTrainingSmoke.kt
src/jvmMain/kotlin/afield/graphics/shader/learning/GpuShaderTrainingSmoke.kt — read the source
GpuShaderTrainingSmoke
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
LearnedShader.kt
Runs a trained shader function as a compute pass that writes its own render target.
LearnedShader loads a model directory written by training, evaluates it each frame for the current parameter, and
can LearnedShader.attach itself to the renderer's pre-target list. It uses cooperative vectors when the device
supports them and a scalar variant otherwise.
src/jvmMain/kotlin/afield/graphics/shader/learning/LearnedShader.kt — read the source
LearnedShader
A frozen, user-trained shader function. Weights are owned by this view; replace the view at a frame boundary to change weights. The ordinary arithmetic variant needs no cooperative vectors. Architecture/layout are explicit and checked, not a general ML graph runtime.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
|
val width: Int |
|
val height: Int |
|
val cooperative: Boolean |
|
val name: String |
|
val gpuMilliseconds |
|
val parameterRange: ClosedFloatingPointRange<Float> |
|
var time |
|
fun setParameter(value: Float) |
Map the original capture parameter (for example seconds) into the network's normalized time. |
var view |
0 original function, 1 learned function, 2 original / learned / absolute error x8. |
val output |
|
val allocatedBytes |
|
fun attach(): LearnedShader |
|
override fun render(command: CommandBuffer, index: Int) |
|
override fun close() |
LearnedShaderSmoke.kt
src/jvmMain/kotlin/afield/graphics/shader/learning/LearnedShaderSmoke.kt — read the source
LearnedShaderSmoke
| Declaration | Summary |
|---|---|
fun main(args: Array<String>) |
ShaderLearningChecks.kt
Acceptance checks for a learned shader, run from a box beneath the moduleHere starting point once training finishes.
ShaderLearningChecks.run measures a ShaderLearningView at a held-out parameter: full-image error, timing, and whether a rejected model leaves the previous one attached. ShaderLearningChecks.stripeMetrics measures error at the bundled test pattern's hard stripe edge separately, because a whole-image mean can hide edge smoothing.
src/jvmMain/kotlin/afield/graphics/shader/learning/ShaderLearningChecks.kt — read the source
ShaderLearningChecks
Called from a child of the actual moduleHere Starting Point, after its training process completes.
| Declaration | Summary |
|---|---|
fun stripeMetrics(original: FloatArray,learned: FloatArray,width: Int,height: Int): JSONObject |
Measure the bundled hard stripe separately: a whole-image mean can hide edge smoothing. |
fun run(lab: ShaderLearningView,directory: File=File("build/neural-tools/from-module")) |
ShaderLearningView.kt
Side-by-side comparison of a real module's render target with its learned replacement.
ShaderLearningView can show the learned output alone, skipping the source pass, or source, learned output and their
amplified RGB error together. Load a model with it between frames.
src/jvmMain/kotlin/afield/graphics/shader/learning/ShaderLearningView.kt — read the source
ShaderLearningView
Compare a real module's render target with its learned replacement. View 1 skips the source pass completely; view 2 renders source / learned / absolute RGB error x8. Source resources stay caller-owned. Construct, load and close between frames. A rejected model leaves the last working model attached.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
|
val source: RenderToTexture |
|
val name: String |
|
var parameter |
|
var view |
|
var model: LearnedShader? |
|
val viewGpuMilliseconds |
|
var sourceRenderCount |
|
val sourceGpuMilliseconds |
|
val output |
|
fun load(directory: File, cooperative: Boolean = renderer.device.supportsCooperativeVector) |
|
override fun render(command: CommandBuffer, index: Int) |
|
override fun close() |
ShaderTraining.kt
Offline distillation of a Field render target into a small learned shader: capture samples, then train them in Python.
ShaderTraining.capture renders the source RenderToTexture across a parameter range and writes train and held-out sample files, keyed by a digest of the source's shaders. ShaderTraining.trainAsync runs tools/lighting/train_shader.py through uv as a child process and returns a ShaderTrainingJob whose result completes with the model directory. The source shaders keep running in Vulkan; only their output images reach Python.
src/jvmMain/kotlin/afield/graphics/shader/learning/ShaderTraining.kt — read the source
ShaderTraining
Offline image-space distillation of a real Field render target: normalized (x,y,parameter) -> linear RGB. Source shaders and their existing bindings execute in Vulkan; they are never translated to Python. Geometry, camera, textures and other uniforms must stay fixed unless represented by capture's parameter.
| Declaration | Summary |
|---|---|
fun sourceDigest(source: RenderToTexture): String |
|
fun capture(renderer: Renderer,source: RenderToTexture,directory: File, parameterRange: ClosedFloatingPointRange<Float> = -1f..1f,trainingFrames: Int=48, validationFrames: Int=13,pixelsPerFrame: Int=2048,seed: Long=4090, fixedState: String="Fixed camera, geometry, textures and all other uniforms", setParameter: (Float)->Unit): File |
Blocking export, called BETWEEN frames. Writes a new directory only; failures leave no valid manifest. Rows are six little-endian float32 values: normalized x,y,t followed by unclamped linear r,g,b. Validation uses different parameter slices, not merely different pixels from the training images. |
fun trainAsync(dataset: File,modelDirectory: File,steps: Int=12000): ShaderTrainingJob |
Train in a separate CPU process. This never evaluates a shader or mutates GPU state on that worker. |
ShaderTrainingJob
Poll result from a Field frame; close cancels the child process, including on scene Stop.
| Declaration | Summary |
|---|---|
val result: CompletableFuture<File> |
|
override fun close() |
shaders.kt
The bridge between a box's text and a live Vulkan pipeline: shader source lives in box properties, and this file turns it into modules and keeps them hot-reloading.
The properties come first — vertex, fragment, compute, task/mesh, the ray-tracing
stages, and a _s variant of each for Slang — alongside once.vertex { }-style seeders. The
user-facing entry points are the macro placeholders Box.moduleHere, Box.computeModuleHere,
Box.meshModuleHere, Box.rayTracingModuleHere and Box.shadertoyHere; each one throws
MacroNotExpandedException if actually called, because MacroRegistry is expected to rewrite it
into the real _moduleHere-family call.
Before compilation every stage goes through preprocess, which resolves {% property %} template
tags against other boxes' shader properties (with cycle detection and #line fixups) and against
built-in includes such as the field probe, field stats and quantile warp. Registered include roots —
registerShaderFilesystemIncludeRoot for a directory, registerLygiaIncludeRoot /
shaderCachedUrlIncludeRootResolver for URL-fetched, disk-cached libraries — are not reachable
from here: they are resolved by shaderc during compilation, from a real #include only. It also expands
{% copyPC %} (copying a previous stage's push-constant block) and stackAuto template slots
compiled from StackSL via stackAutoShaderSource.
reloadShader, reloadComputeShader, reloadMeshShader and reloadRayTracingShader are the
reload path: compile, report errors back onto the box's source lines, introspect the result, and
rewrite the box's Kotlin preamble with generated binding structures before swapping the module.
Box.attachShader adopts an externally built module into that same path. Box.fieldProbeHere,
Box.fieldStatsHere and Box.fieldProbeAutoControl attach the GPU readback instrumentation.
Everything here needs a live Box — properties, error reporting, preamble edits and the
generation-guarded async reload all key off it.
src/jvmMain/kotlin/afield/graphics/shader/shaders.kt — read the source
Used in: Field 4 Graphics, A learned shader function, Editing a document from outside Field
Top-level functions
| Declaration | Summary |
|---|---|
fun stackAutoShaderReport(source: String, name: String = "program", entrypoint: String? = null, sampleTexture: String = "source"): StackAutoReport |
Compile one StackSL program the way a stack.auto slot compiles it, and report what that turned up beyond success or failure. |
fun stackAutoShaderSource(module: ModulePointer, name: String): String |
The StackSL source currently bound to the {{ stack.auto.name }} slot of this graphics module's shaders — the getter behind the generated module.name property. |
fun stackAutoShaderSource(module: ComputeModulePointer, name: String): String |
The StackSL source bound to a compute module's stack.auto slot; "" when unbound. See the ModulePointer overload for the ownership and empty-string rules. |
fun stackAutoShaderSource(module: MeshModulePointer, name: String): String |
The StackSL source bound to a mesh module's stack.auto slot; "" when unbound. See the ModulePointer overload for the ownership and empty-string rules. |
fun stackAutoShaderSource(module: ModulePointer, name: String, source: String) |
Bind new StackSL source to a graphics module's {{ stack.auto.name }} slot and queue the pipeline update — the setter behind the generated module.name = "…" property, and the fastest live-coding loop in the file. |
fun stackAutoShaderSource(module: ComputeModulePointer, name: String, source: String) |
Bind StackSL source to a compute module's stack.auto slot; queues the same coalesced, ABI-preserving background update as the graphics stackAutoShaderSource setter. |
fun stackAutoShaderSource(module: MeshModulePointer, name: String, source: String) |
Bind StackSL source to a mesh module's stack.auto slot. |
fun registerShaderIncludeRoot(root: String, resolver: ShaderIncludeRootResolver) |
Claim a first path segment for a resolver, so #include "root/some/file.glsl" in any box's shader is answered by resolver instead of being looked for as a box name. |
fun unregisterShaderIncludeRoot(root: String): ShaderIncludeRootResolver? |
Remove an include root, returning the resolver that was registered under it (or null if none was). Shaders already compiled keep working — this only affects the next compile. |
fun listShaderIncludeRoots(): Set<String> |
Every include-root name currently registered, sorted — the quickest way to answer "why isn't #include "lygia/…" resolving?" from a box. |
fun shaderCachedUrlIncludeRootResolver(root: String, baseUrl: String, cacheOptions: ShaderRemoteIncludeCacheOptions = ShaderRemoteIncludeCacheOptions(cacheDirectory = defaultShaderIncludeCacheDirectory().resolve(normalizeShaderIncludeRootName(root)))): ShaderIncludeRootResolver |
An include root that fetches baseUrl/<path> over HTTP the first time and reads the local copy every time after — how Field consumes a shader library that lives on a website rather than on disk. |
fun shaderLygiaIncludeRootResolver(cacheOptions: ShaderRemoteIncludeCacheOptions = ShaderRemoteIncludeCacheOptions(cacheDirectory = defaultShaderIncludeCacheDirectory().resolve("lygia.xyz"))): ShaderIncludeRootResolver |
The Lygia GLSL library as an include root, cached under ~/.field4/shader-include-cache/lygia.xyz by default — one call and #include "lygia/math/const.glsl" works. |
fun shaderLygiaIncludeRootResolver(cacheDirectory: Path): ShaderIncludeRootResolver |
The Lygia include root with the cache pointed somewhere of your choosing and the default timeouts — a per-project cache directory, say, rather than the shared one under ~/.field4. |
fun shaderLygiaIncludeRootResolver(cacheDirectory: String): ShaderIncludeRootResolver |
The Lygia include root with the cache directory given as a string. Interpreted by Paths.get, so a relative path is relative to the JVM's working directory — not to the workspace. Prefer an absolute path. |
fun registerLygiaIncludeRoot(cacheOptions: ShaderRemoteIncludeCacheOptions = ShaderRemoteIncludeCacheOptions(cacheDirectory = defaultShaderIncludeCacheDirectory().resolve("lygia.xyz"))) |
Register Lygia under the include root lygia, so every box's shaders can #include "lygia/…". |
fun registerLygiaIncludeRoot(cacheDirectory: Path) |
Register Lygia under the root lygia, caching into cacheDirectory. |
fun registerLygiaIncludeRoot(cacheDirectory: String) |
Register Lygia under the root lygia, caching into a directory named by string — Paths.get-relative to the JVM's working directory, so prefer an absolute path. |
fun shaderFilesystemIncludeRootResolver(directory: Path): ShaderIncludeRootResolver |
An include root served straight from a directory on disk: #include "root/sub/thing.glsl" reads directory/sub/thing.glsl. |
fun shaderFilesystemIncludeRootResolver(directory: String): ShaderIncludeRootResolver |
A directory-backed include root named by string. Paths.get-relative to the JVM's working directory, which is not the workspace directory — pass an absolute path unless you are certain. |
fun registerShaderFilesystemIncludeRoot(root: String, directory: Path) |
Register directory as the include root root, so #include "root/…" reads files under it — the one-liner for pulling a checked-out shader library into a workspace. |
fun registerShaderFilesystemIncludeRoot(root: String, directory: String) |
Register a directory as an include root, with the directory given as a string — Paths.get-relative to the JVM's working directory, so prefer an absolute path. |
fun Box.fieldProbeHere(samples: Int = afield.graphics.vulkan.util.FIELD_PROBE_DEFAULT_SAMPLES, binding: Int = afield.graphics.vulkan.util.FIELD_PROBE_DEFAULT_BINDING, descriptorSet: Int = afield.graphics.vulkan.util.FIELD_PROBE_DEFAULT_SET, enabled: Boolean = true, ordinalRange: UIntRange? = null): ShaderFieldProbe |
Create (or reconfigure) this box's GPU sample probe and hand it back for chaining — the Kotlin half of #include "fieldProbe", which lets a shader call fieldProbe(x) and see the resulting distribution on the CPU. |
fun Box.fieldStatsHere(channels: Int = 1, sketchSize: Int = 256, maxPartials: Int = 65_536, descriptorSet: Int = 0, partialBinding: Int = FIELD_STATS_DEFAULT_PARTIAL_BINDING, resultBinding: Int = FIELD_STATS_DEFAULT_RESULT_BINDING, stateSlots: Int = 8): ShaderFieldStats |
Declare that this box produces a GPU statistics block, and get the handle to read it back with — the Kotlin half of #include "fieldStats" in a shader that records into it. |
fun Box._fieldStatsHere(channels: Int = 1, sketchSize: Int = 256, maxPartials: Int = 65_536, descriptorSet: Int = 0, partialBinding: Int = FIELD_STATS_DEFAULT_PARTIAL_BINDING, resultBinding: Int = FIELD_STATS_DEFAULT_RESULT_BINDING, stateSlots: Int = 8): ShaderFieldStats |
The real work behind Box.fieldStatsHere — what the macro rewrites the call site to. Reach for it directly only when you are driving a box from outside and do not want macro expansion. |
fun Box.macro_fieldStatsHere(args: List<String>): Insert |
Macro expansion for fieldStatsHere(...): parse the argument text, allocate the stats block now (at expansion time, so a computeModuleHere in the same fragment can already resolve #include "fieldStats"), and rewrite the call to Box._fieldStatsHere. |
fun Box.useFieldStats(stats: ShaderFieldStats): ShaderFieldStats |
Bind another box's ShaderFieldStats into this box as a consumer, so #include "fieldStats" here emits the read-only GLSL instead of the recording GLSL. |
fun Box.fieldProbeAutoControl(probe: ShaderFieldProbe = ensureFieldProbe(), controller: AdaptiveDistributionController = AdaptiveDistributionController(ControllerConfig(), UniformQuantileFeatureExtractor()), config: ShaderProbeAutoControlConfig = ShaderProbeAutoControlConfig(), install: Boolean = true, runloop: Runloop = Runloop.runLoop, apply: (params: DoubleArray, output: ControlOutput, batch: FieldProbeScalarBatch) -> Unit): ShaderFieldAutoController |
Wire this box's probe into an adaptive controller, so a shader parameter is steered every frame by what the shader's own output distribution turns out to be. |
fun Box.moduleHere(p: Parameters): ModulePointer |
Macro placeholder expanded to a graphics ModulePointer for the current box. |
fun Box.moduleHere(p: Parameters.() -> Unit): ModulePointer |
Macro placeholder for the trailing-lambda form, moduleHere { primitiveType = triangles }. |
fun Box.computeModuleHere(p: Parameters): ComputeModulePointer |
Macro placeholder expanded to a ComputeModulePointer built from this box's compute property. |
fun Box.computeModuleHere(p: Parameters.() -> Unit): ComputeModulePointer |
Macro placeholder for computeModuleHere { … }, the trailing-lambda form. The lambda configures Parameters, not the shader source. Calling it directly throws. |
fun Box.meshModuleHere(p: Parameters): MeshModulePointer |
Macro placeholder expanded to a MeshModulePointer built from this box's task, mesh and fragment properties — all three are required, and defaults are seeded for any that are missing. |
fun Box.meshModuleHere(p: Parameters.() -> Unit): MeshModulePointer |
Macro placeholder for meshModuleHere { … }, the trailing-lambda form. Calling it directly throws. |
fun Box.rayTracingModuleHere(p: Parameters): RayTracingModulePointer |
Macro placeholder expanded to a RayTracingModulePointer built from this box's raygen property plus whichever of miss/closesthit/anyhit/intersection/callable are non-blank. |
fun Box.rayTracingModuleHere(p: Parameters.() -> Unit): RayTracingModulePointer |
Macro placeholder for rayTracingModuleHere { … }, the trailing-lambda form. Calling it directly throws. |
fun Box.shadertoyHere(p: Parameters): ModulePointer |
Macro placeholder expanded to a Shadertoy-shaped graphics module: a full-screen triangle, the iResolution/iTime/iMouse/iDate uniform block already bound, four 1×1 dummy iChannel textures, and depth testing off — so pasting a mainImage body into the box's fragment shader mostly just works. |
fun Box.shadertoyHere(p: Parameters.() -> Unit): ModulePointer |
Macro placeholder for shadertoyHere { … }, the trailing-lambda form. Calling it directly throws. |
fun numberFromErrorMessage(m: String): Int? |
Dig a line number out of a shader (or Vulkan) error message, so the error can be pinned to a line in the box's editor rather than dumped in the log. |
fun preprocess(inside: Box, code: String, kind: ShaderKind): String |
Run one shader stage's source through Field's preprocessor and hand back the GLSL that shaderc would see — the thing to call when you are building a module by hand instead of through a moduleHere-family macro. |
fun normalizePreambleText(preamble: String): String |
Tidy a box's preamble text: drop duplicate declarations, hoist every import to the top, and remove blank lines. |
fun applyInsertToPreamble(box: Box, e: Insert) |
Fold an Insert's generated declarations into a box's preamble — how a shader reload gets its new typed bindings in front of the Kotlin compiler. |
fun reloadShader(box: Box): Boolean |
Recompile this box's vertex and fragment stages from its shader properties and swap the live graphics module — the Reload Shader action (alt-r), as a function. |
fun Box.attachShader(module: ModulePointer, overwriteProperties: Boolean = false): ModulePointer |
Adopt a graphics module you built yourself into this box's hot-reload machinery: the box gains the Reload Shader and Update Shader actions, its shader properties are seeded from the module's source, and the module becomes box._shader. |
fun reloadComputeShader(box: Box): Boolean |
Recompile this box's compute stage and swap the live compute module — the Reload Compute Shader action (alt-r) as a function. |
fun Box.attachShader(module: ComputeModulePointer, overwriteProperties: Boolean = false): ComputeModulePointer |
Adopt an externally built compute module into this box: install the reload and update actions, seed the compute property from the module's source, take it as box._computeShader, and reload once. |
fun reloadMeshShader(box: Box): Boolean |
Recompile all three stages of this box's mesh-shader pipeline — task, then mesh, then fragment — and swap the live module. |
fun Box.attachShader(module: MeshModulePointer, overwriteProperties: Boolean = false): MeshModulePointer |
Adopt an externally built mesh-shader module into this box, seeding the task, mesh and fragment properties and reloading once. |
fun reloadRayTracingShader(box: Box): Boolean |
Recompile whichever ray-tracing stages this box has and swap the live module. |
fun Box.attachShader(module: RayTracingModulePointer, overwriteProperties: Boolean = false): RayTracingModulePointer |
Adopt an externally built ray-tracing module into this box, seeding raygen (required) and whichever optional stages the module actually has. |
fun Box.macro_moduleHere(args: List<String>): Insert? |
Macro expansion for moduleHere(…) — the entry point MacroRegistry calls when it finds one in a box's text. Delegates to Box.macro_moduleHereWithDefaultShaders with Field's stock vertex and fragment shaders as the fallbacks. |
fun Box.macro_moduleHereWithDefaultShaders(args: List<String>, vertex: String = defaultVertexShader, fragment: String = defaultFragmentShader, resolvesToCall: String = "_moduleHere"): Insert? |
The shared body of every graphics-module macro: seed the box's shader properties if it has none, preprocess and compile both stages at expansion time, and return the Insert that carries the generated typed bindings plus the rewritten call site. |
fun Box.macro_shadertoyHere(args: List<String>): Insert? |
Macro expansion for shadertoyHere(…): register the shadertoy template preambles on the box, then run the ordinary graphics expansion with the shadertoy vertex/fragment shaders as fallbacks and _shadertoyHere as the runtime call. |
fun insertForShader(vi: Introspection, fi: Introspection, resolvesToCall: String = "_moduleHere", args: List<String>, box: Box? = null): Insert |
Turn a compiled vertex+fragment pair into the Insert that gives a box its typed graphics API: one val Mesh.<name> by FromInput() per vertex attribute, the m.set { } writer map, a generated Kotlin class per GLSL struct/UBO/SSBO layout, stack.auto string properties, and Mesh/MeshGroup accessors for every push-constant member. UBO members get less than that: the block itself becomes a val ModulePointer.<name> by FromUniform() and each member a var Structure.<name>, with no Mesh/MeshGroup accessor, so a UBO member is written through the uniform rather than per-mesh. (The compute and mesh-shader inserts do emit Mesh.<uboName>; this graphics path does not.) |
fun Box._moduleHere(p: Parameters.() -> Unit): ModulePointer |
The runtime graphics-module builder for the trailing-lambda call site, moduleHere { … } after expansion. Resolves the Renderer from the ambient context — which means this box must be a descendant of the box that owns the window, or fromContext() finds nothing. |
fun Box._moduleHere(p: Parameters = parameters {}): ModulePointer |
Build this box's graphics module and store it as box._shader — the function every expanded moduleHere call actually ends up in. |
fun Box._shadertoyHere(p: Parameters.() -> Unit): ModulePointer |
The runtime shadertoy-module builder for the trailing-lambda call site. Resolves the Renderer from the ambient context, so the box must be a descendant of its renderer box. |
fun Box._shadertoyHere(p: Parameters = parameters {}): ModulePointer |
Build a shadertoy-shaped module: an ordinary Box._moduleHere with depth testing off, the shadertoy uniform block bound to live values, the four iChannels bound, and a 3-vertex mesh named "shadertoy" already attached so the box draws without you building any geometry. |
fun fromViews(views: List<HasImageView>, name: String = "", sampler: TextureSampler? = null): Pair<HasImageViewArray, TextureSampler> |
Package a fixed list of image views as the (provider, sampler) pair a texture resolver returns — the usual way to answer "bind these images to that sampler". |
fun fromViews(provider: HasImageViewArray, name: String = "", sampler: TextureSampler? = null): Pair<HasImageViewArray, TextureSampler> |
Package a live view provider as the (provider, sampler) pair a texture resolver returns — the overload to prefer when the thing you are binding can change its views underneath you. |
fun Box.macro_computeModuleHere(args: List<String>): Insert |
Macro expansion for computeModuleHere(…): install the reload and update actions, seed a trivial outData[i](workspace.md#stdlib-kt--i) = inData[i](workspace.md#stdlib-kt--i) + 1 kernel if the box has no compute source, compile it, and return the Insert carrying the generated ComputeMesh bindings and the rewritten _computeModuleHere call. |
fun Box.macro_computeModuleHereWithDefaultShader(args: List<String>, compute: String? = null, resolvesToCall: String = "_computeModuleHere"): Insert |
Seed a specialised compute shader while retaining normal Field reflection, linking and reload actions. |
fun Box._computeModuleHere(p: Parameters.() -> Unit): ComputeModulePointer |
The runtime compute-module builder for the trailing-lambda call site, computeModuleHere { … } after expansion. Resolves the Renderer from the ambient context, so the box must be a descendant of its renderer box. |
fun Box._computeModuleHere(p: Parameters): ComputeModulePointer |
Build this box's compute module and store it as box._computeShader. |
fun Box.macro_meshModuleHere(args: List<String>): Insert |
Macro expansion for meshModuleHere(…): install the reload action, seed a runnable task/mesh/fragment trio for any of the three the box is missing, compile all three in order, and return the Insert with the generated MeshMesh bindings. |
fun Box._meshModuleHere(p: Parameters.() -> Unit): MeshModulePointer |
The runtime mesh-module builder for the trailing-lambda call site. Resolves the Renderer from the ambient context, so the box must be a descendant of its renderer box. |
fun Box._meshModuleHere(p: Parameters = parameters {}): MeshModulePointer |
Build this box's mesh-shader module from the prepared _task, _mesh and _fragment properties and store it as box._meshShader. |
fun Box.macro_rayTracingModuleHere(args: List<String>): Insert |
Macro expansion for rayTracingModuleHere(…): install the reload action, seed defaultRaygenShader if the box has no raygen, compile every stage the box does have, and return the Insert with the generated RayTracingDispatch bindings. |
fun Box._rayTracingModuleHere(p: Parameters.() -> Unit): RayTracingModulePointer |
The runtime ray-tracing-module builder for the trailing-lambda call site. Resolves the Renderer from the ambient context, so the box must be a descendant of its renderer box. |
fun Box._rayTracingModuleHere(p: Parameters = parameters {}): RayTracingModulePointer |
Build this box's ray-tracing module from the prepared stage properties and store it as box._rayTracingShader. |
Top-level values
| Declaration | Summary |
|---|---|
var Box.fragment: String? |
The fragment-stage GLSL for this box, as text — the shader tab you type into, and the only place a graphics module's fragment shader comes from (assignments inside a moduleHere { } lambda do not set it). |
var Box.vertex: String? |
The vertex-stage GLSL for this box. Its layout(location = …) in declarations are the contract that generates the box's Mesh writers (pos, col, …), so changing an input here changes the Kotlin API of every box downstream of the next reload. |
var Box.vertex_fragment: String? |
One Slang source holding both the vertex and fragment stages, compiled twice (once per stage) by slangc. It beats vertex, fragment, vertex_s and fragment_s outright: when this is non-blank it is the source for both graphics stages. |
var Box.task: String? |
The task-shader (amplification) stage GLSL of a mesh-shader pipeline. meshModuleHere needs task, mesh and fragment — there is no task-less mesh path here — and fills in whichever of the three the box is missing, each independently and each with its own stub: an EmitMeshTasksEXT amplifier here, a SetMeshOutputsEXT triangle emitter for mesh, and a constant-colour write for fragment. A box that already has a task shader keeps it and gets only the other two. |
var Box.mesh: String? |
The mesh-shader stage GLSL, the stage that emits vertices and primitives directly (SetMeshOutputsEXT / gl_MeshVerticesEXT) instead of consuming a vertex buffer. Paired with Box.task and Box.fragment by meshModuleHere; note the name collides conceptually with Mesh the Kotlin draw object, which is unrelated. |
var Box.fragment_s: String? |
Slang source for the fragment stage. When non-blank it wins over Box.fragment and is transpiled to GLSL by slangc before shaderc ever sees it, so compile errors arrive in Slang's (line): error format — which numberFromErrorMessage does understand. |
var Box.vertex_s: String? |
Slang source for the vertex stage; wins over Box.vertex when non-blank. Prefer Box.vertex_fragment if you want the two stages to share declarations — two separate _s properties are transpiled independently and cannot see each other. |
var Box.task_s: String? |
Slang source for the task stage; wins over Box.task when non-blank. |
var Box.mesh_s: String? |
Slang source for the mesh stage; wins over Box.mesh when non-blank. |
var Box._fragment: String? |
The prepared fragment GLSL: box-local, post-#include-template, post-{% copyPC %}, post-Slang-transpile — literally the text handed to shaderc. Written by every prepare pass and read by _moduleHere, which throws a NullPointerException if no pass ever ran. |
var Box._vertex: String? |
The prepared vertex GLSL — see Box._fragment for what "prepared" means and why you should treat it as read-only. |
var Box._task: String? |
The prepared task-stage GLSL. See Box._fragment. |
var Box._mesh: String? |
The prepared mesh-stage GLSL. See Box._fragment. |
var Box.compute: String? |
The compute-stage GLSL for this box, the source computeModuleHere { } builds from. Its layout(binding = …) buffer blocks and push constants generate the box's ComputeMesh accessors. |
var Box.compute_s: String? |
Slang source for the compute stage; wins over Box.compute when non-blank. |
var Box._compute: String? |
The prepared compute GLSL. See Box._fragment. |
var Box.raygen: String? |
The ray-generation stage GLSL: the one ray-tracing stage that is required, and the only one rayTracingModuleHere seeds a default for (defaultRaygenShader). The others are all optional and are simply skipped if their property is blank. |
var Box.miss: String? |
The miss-shader stage GLSL, run when a traced ray hits nothing. Optional: leave it blank and the stage is left out of the pipeline entirely. |
var Box.closesthit: String? |
The closest-hit stage GLSL. Optional; omitted from the pipeline when blank. |
var Box.anyhit: String? |
The any-hit stage GLSL, run per candidate intersection (alpha-cutout style rejection). Optional; omitted from the pipeline when blank. |
var Box.intersection: String? |
The intersection stage GLSL for custom (non-triangle) primitives. Optional; omitted from the pipeline when blank. |
var Box.callable: String? |
The callable-shader stage GLSL. Optional; omitted from the pipeline when blank. |
var Box.raygen_s: String? |
Slang source for the raygen stage; wins over Box.raygen when non-blank. |
var Box.miss_s: String? |
Slang source for the miss stage; wins over Box.miss when non-blank. |
var Box.closesthit_s: String? |
Slang source for the closest-hit stage; wins over Box.closesthit when non-blank. |
var Box.anyhit_s: String? |
Slang source for the any-hit stage; wins over Box.anyhit when non-blank. |
var Box.intersection_s: String? |
Slang source for the intersection stage; wins over Box.intersection when non-blank. |
var Box.callable_s: String? |
Slang source for the callable stage; wins over Box.callable when non-blank. |
var Box._raygen: String? |
The prepared raygen GLSL. See Box._fragment. |
var Box._miss: String? |
The prepared miss GLSL, or null when the box has no miss stage. See Box._fragment. |
var Box._closesthit: String? |
The prepared closest-hit GLSL, or null when the box has no such stage. See Box._fragment. |
var Box._anyhit: String? |
The prepared any-hit GLSL, or null when the box has no such stage. See Box._fragment. |
var Box._intersection: String? |
The prepared intersection GLSL, or null when the box has no such stage. See Box._fragment. |
var Box._callable: String? |
The prepared callable GLSL, or null when the box has no such stage. See Box._fragment. |
var Box._shader: ModulePointer? |
The live graphics module this box owns, or null if it has never built one. Set by Box._moduleHere and Box.attachShader; a re-run replaces the pointer rather than adding a second module. |
var Box._computeShader: ComputeModulePointer? |
The live compute module this box owns, or null. Set by Box._computeModuleHere and Box.attachShader; see Box._shader for the ownership rules. |
var Box._rayTracingShader: RayTracingModulePointer? |
The live ray-tracing module this box owns, or null. Set by Box._rayTracingModuleHere and Box.attachShader; see Box._shader. |
var Box.meshShader: MeshModulePointer? |
Vestigial: declared, inherited, and read or written by nothing in the tree. The mesh pipeline uses the box-local Box._meshShader throughout; treat this as a name to avoid rather than an API. |
var Box._meshShader: MeshModulePointer? |
The live mesh-shader module this box owns, or null. Set by Box._meshModuleHere and Box.attachShader; see Box._shader. |
var Box._fieldProbe: ShaderFieldProbe? |
This box's GPU sample probe, or null if nothing has asked for one. Created on demand either by Box.fieldProbeHere or by the first #include "fieldProbe" in one of this box's shaders, so the include alone is enough to bring it into being. |
var Box._fieldProbeAutoController: ShaderFieldAutoController? |
The per-frame controller installed by Box.fieldProbeAutoControl, or null. Kept on the box so a re-run can stop the previous one before installing its replacement — which is exactly what fieldProbeAutoControl does, and why re-running a box does not leave two controllers fighting over the same push constants. |
var Box._fieldStatsBinding: ShaderFieldStatsBinding? |
How this box is joined to a ShaderFieldStats block: the stats object plus whether this box is its producer (it owns the buffers and its GLSL records into them) or merely a consumer. |
val Once.vertex by Onceing<String>() |
once.vertex { …GLSL… } fills the box's Box.vertex property from code, the first time only. |
val Once.fragment by Onceing<String>() |
once.fragment { …GLSL… } fills Box.fragment once from code. Same fragment-ordering trap as Once.vertex: seed it in a fragment before the one that expands moduleHere. |
val Once.vertex_fragment by Onceing<String>() |
once.vertex_fragment { …Slang… } fills Box.vertex_fragment once — the shared-Slang route, which overrides whatever once.vertex/once.fragment put in place. |
val Once.compute by Onceing<String>() |
once.compute { …GLSL… } fills Box.compute once, ahead of the computeModuleHere fragment. |
val Once.task by Onceing<String>() |
once.task { …GLSL… } fills Box.task once, ahead of the meshModuleHere fragment. |
val Once.mesh by Onceing<String>() |
once.mesh { …GLSL… } fills Box.mesh once, ahead of the meshModuleHere fragment. |
val Once.raygen by Onceing<String>() |
once.raygen { …GLSL… } fills Box.raygen once, ahead of the rayTracingModuleHere fragment. |
val Once.miss by Onceing<String>() |
once.miss { …GLSL… } fills Box.miss once; leave it unset to build a pipeline with no miss stage at all. |
val Once.closesthit by Onceing<String>() |
once.closesthit { …GLSL… } fills Box.closesthit once. Optional stage. |
val Once.anyhit by Onceing<String>() |
once.anyhit { …GLSL… } fills Box.anyhit once. Optional stage. |
val Once.intersection by Onceing<String>() |
once.intersection { …GLSL… } fills Box.intersection once. Optional stage. |
val Once.callable by Onceing<String>() |
once.callable { …GLSL… } fills Box.callable once. Optional stage. |
val Box.shaderTemplate: MutableMap<String, Any> |
The named values this box's {{ slot }} template expressions resolve against — the mechanism behind the shadertoy preamble and any GLSL boilerplate you would rather write once. |
val Box.stackAutoShaderSources: MutableMap<String, String> |
StackSL source, by stack.auto slot name, for every {{ stack.auto.foo }} this box's shaders declare. Concurrent, because the compile/reload path reads it off the main thread. |
const val STACK_AUTO_REPORT_CHANNEL |
The box output channel the live-coding path writes a non-StackAutoReport.quiet report to. |
val stackAutoShaderUpdated |
Called on Field's main thread when a box's queued stack.auto pipeline update has finished and nothing newer is queued behind it: true when the new pipeline is in place (the next frame draws it), false when the update failed and the old one still draws. A slot change is only queued when its setter returns; a caller that must see the result waits for this. |
val defaultVertexShader |
The vertex shader a box gets if moduleHere expands and the box has no vertex source of its own: one vec3 pos input transformed by a push_constant block of two mat4s, p and m. |
val defaultFragmentShader |
The fragment shader a box gets if moduleHere expands with no fragment source of its own: a flat warm orange, chosen so "it drew something" is unmistakable. Declares no uniforms, so it generates no accessors. |
val shadertoyVertexShader |
The vertex shader shadertoyHere seeds: no inputs at all, just a full-screen triangle synthesised from gl_VertexIndex. That is why the shadertoy module needs a 3-vertex mesh and no vertex buffer. |
val shadertoyFragmentShader |
The fragment shader shadertoyHere seeds: the {{ shadertoy.image }} slot, which brings in the iResolution/iTime/iMouse/… uniform block, the #defines that make them look like shadertoy globals and the main that calls mainImage, then a placeholder mainImage drawing a uv gradient that pulses with iTime. |
var Box.customTextureResolver: ((Box, String) - |
A hook for deciding yourself what a shader's sampler2D someName should be bound to, consulted before Field's own by-name search of the box's properties. |
val defaultRaygenShader |
The ray-generation shader a box gets when rayTracingModuleHere expands and the box has no raygen source: it writes the launch uv into the ray payload and traces nothing at all. |
StackAutoReport
What compiling one stack.auto slot had to say beyond "it compiled" — the warnings, and the stack arithmetic that says whether the program is shaped the way its author thinks.
| Declaration | Summary |
|---|---|
val name: String |
The slot this is about — the foo of {{ stack.auto.foo }}. |
val compiled: Boolean |
False when the program has an ERROR diagnostic, which is then in errors. |
val problems: List<String> |
Almost certainly bugs. A definition whose net stack effect is not "consume one, leave one". |
val warnings: List<String> |
The compiler's own non-ERROR diagnostics: warnings and notes from a compile that succeeded. |
val notes: List<String> |
Worth seeing, not worth failing on — chiefly how much computed work nothing consumes. |
val errors: List<String> |
Why it did not compile: the compiler's ERROR diagnostics, empty when compiled. warnings leaves errors out, so a report that says why a candidate was refused has to read this list. |
val quiet: Boolean |
Nothing to say: it compiled, no warnings, every definition balanced, nothing pruned. |
val lines: List<String> |
Every line, most serious first, for a log or a one-shot println. |
override fun toHTML(): String |
The report as a small monospaced card: one coloured line per problem, warning and note, and a dim "clean" when there is nothing to say. This is what the stack.auto output channel shows. |
override fun toString(): String |
One line, for a log or a println. "stack.auto.<name>: clean" when there is nothing to say. |
StackAutoReportSet
data class StackAutoReportSet(val reports: List<StackAutoReport>) : ToHTML
Every slot of one box that currently has something to say, rendered as one output card.
| Declaration | Summary |
|---|---|
val reports: List<StackAutoReport> |
Every slot with something to say, ordered by slot name so the card does not reshuffle. |
override fun toHTML(): String |
Each slot's card, stacked, so one output area covers the whole shader. |
override fun toString(): String |
One line per slot. |
ShaderIncludeRootRequest
One #include that landed inside a registered include root, handed to a ShaderIncludeRootResolver to turn into text.
| Declaration | Summary |
|---|---|
val root: String |
The registered root name that matched — the first path segment of a root/… include, or, for a relative include, the root the including file itself came from. |
val includePath: String |
The path within root, normalised: separators unified to /, . and empty segments dropped, .. resolved (and rejected if it would escape the root), no leading slash. This is the key a resolver should look up. |
val requestedPath: String |
Exactly what the #include said, root prefix and all. Useful in diagnostics; do not resolve against it. |
val requestingSourcePath: String? |
The resolved path of the file doing the including (a previous resolution's resolvedPath, or a field/<box>-<id>/<property>.glsl name for box source), or null when shaderc did not supply one. |
val includeType: ShaderIncludeType |
RELATIVE for #include "…", STANDARD for #include <…>. Only RELATIVE includes are resolved relative to requestingSourcePath's directory. |
val includeDepth: Long |
How deep this include is nested, straight from shaderc. Informational — nothing in Field enforces a limit on it. |
ShaderIncludeRootResolver
fun interface ShaderIncludeRootResolver
Turns an include inside one registered root into source text. Register one under a name with registerShaderIncludeRoot and every #include "thatName/whatever.glsl" in every box's shaders goes through it.
| Declaration | Summary |
|---|---|
fun resolve(request: ShaderIncludeRootRequest): ShaderIncludeResolution? |
Resolve one include, or return null if this root does not have it (which the caller turns into a hard error). |
ShaderRemoteIncludeCacheOptions
Where a URL-backed include root keeps its on-disk copies, and how patient it is with the network.
ShaderProbeAutoControlConfig
How Box.fieldProbeAutoControl turns probe read-backs into controller updates: when a batch is big enough to act on, what counts as clipped, and where the diagnostics card goes.
| Declaration | Summary |
|---|---|
val minSamples: Int |
Minimum samples in a read-back before the controller is allowed to learn from it. A snapshot with fewer is skipped entirely — tick() returns null and nothing is applied. |
val clipLower: Double? |
Values at or below this (within clipEpsilon) count as clipped low and are reported to the controller as a clip rate; null means "nothing clips at the bottom". |
val clipUpper: Double? |
Values at or above this (within clipEpsilon) count as clipped high; null means nothing clips at the top. With both bounds null the reported clip rate is a flat 0.0. |
val clipEpsilon: Double |
Tolerance for the two clip comparisons — floating-point slop, not a percentage. |
val useNominalParams: Boolean |
true applies the controller's nominal parameters, false (the default) its applied ones, which carry the small dither the controller needs to identify the system. Turn it on once identification has settled and the dither is visible in the output. |
val displayChannel: String? |
Box output channel for the HTML diagnostics table (nominal vs applied parameters, objective, confidence, clip rate, p10/p50/p90). null disables the display entirely, which is what a headless test wants. |
val closeDisplayOnStop: Boolean |
Whether stop() closes the display channel by default. |
val clearDisplayOnStop: Boolean |
Whether stop() clears the display channel's contents by default. |
val runloopKeySuffix: String |
Prefix of the runloop hook key, which is "$runloopKeySuffix:${box.id}". Two controllers on one box need two different values here or the second silently displaces the first. |
ShaderFieldAutoController
The running loop that closes a shader's feedback circuit: read the probe, push the samples into an adaptive controller, hand the new parameters to your apply lambda, and draw the diagnostics.
| Declaration | Summary |
|---|---|
val box: Box |
The box this controller belongs to: the source of the display channel and the identity in the runloop hook key. |
val probe: ShaderFieldProbe |
The probe being read. Box.fieldProbeAutoControl has already put it into continuous-read mode, so it delivers a snapshot every frame rather than at the display rate. |
val controller: AdaptiveDistributionController |
The adaptive controller doing the actual learning; its parameterNames label the diagnostics table and its pushSamples is what turns a batch into parameters. |
val config: ShaderProbeAutoControlConfig |
The thresholds, clip bounds and display settings this controller was built with. Read on every tick, but the object is immutable — copy it and rebuild to change your mind. |
var tickCount: Int |
How many ticks have actually done work. Ticks that found no new snapshot, or too few samples, do not count — so a tickCount stuck at zero means the probe is not delivering, not that the hook is missing. |
var lastBatch: FieldProbeScalarBatch? |
The last probe batch the controller learned from, or null before the first useful tick. Carries both the raw snapshot and the clip rate computed from config. |
var lastOutput: ControlOutput? |
The last controller output — nominal and applied parameters plus diagnostics — or null before the first useful tick. The same object the apply lambda was handed. |
fun tick(): ControlOutput? |
Do one control step, returning the controller's output, or null if there was nothing to do. |
fun install(runloop: Runloop = Runloop.runLoop): ShaderFieldAutoController |
Install a preRun hook that calls tick once per frame, and return this for chaining. Idempotent — a second call while already installed does nothing. |
fun stop(runloop: Runloop = Runloop.runLoop, closeDisplay: Boolean = config.closeDisplayOnStop, clearDisplay: Boolean = config.clearDisplayOnStop): ShaderFieldAutoController |
Remove the runloop hook, optionally tidy the display channel, and forget this controller on the box. Returns this, and can be re-installed afterwards. |
ShadertoySurface
What a shadertoy module draws onto, for its iResolution and iMouse: size in pixels, and pointer, the cursor in those pixels with y up (null when there is no cursor over it) plus whether the left button is down.
| Declaration | Summary |
|---|---|
val size: () -> Pair<Int, Int> |
|
val pointer: () -> Pair<Pair<Float, Float>, Boolean>? |
SlugBuffer.kt
The GPU side of Slug filled-path rendering: SlugBuffer holds one SlugPath's atlas words in a
Vulkan storage buffer that a slug shader reads.
SlugBuffer.replace swaps in new geometry behind the same buffer supplier, so existing shader
bindings keep working, and SlugBuffer.close falls back to a shared empty atlas that draws
nothing. SlugRenderSmoke exercises it against a live renderer.
src/jvmMain/kotlin/afield/graphics/text/slug/SlugBuffer.kt — read the source
SlugBuffer
class SlugBuffer(private val renderer: Renderer, path: SlugPath) : HasBuffer, AutoCloseable
An immutable Slug atlas upload behind a stable address supplier.
| Declaration | Summary |
|---|---|
var isClosed: Boolean |
|
var revision: Long |
|
fun replace(path: SlugPath) |
Publish newly prepared geometry; existing shader bindings keep the same supplier. |
override fun getBuffer(): Long |
|
override fun getGPUAddress(cmd: CommandBuffer): Long |
|
override fun close() |
Retire the atlas. A mesh still bound to this buffer draws nothing from the next frame on, so it does not matter whether the mesh is removed first. Repeated close calls are harmless. |
SlugShaders
Shader sources shared by the documented box and shader/GPU regression tests.
| Declaration | Summary |
|---|---|
val vertex: String |
|
val fragment: String |
SlugPath.kt
Compiles a filled, flat FLine into Slughorn atlas data (SlugPath) by running the native
field-slug helper, built with ./gradlew buildSlugNative.
SlugPath.compile runs once per geometry edit, not per frame, and refuses paths that leave
z=0. The result is immutable and carries no placement; SlugBuffer uploads it to the GPU.
SlugPathTest covers it.
src/jvmMain/kotlin/afield/graphics/text/slug/SlugPath.kt — read the source
SlugPath
class SlugPath private constructor(private val packed: IntArray)
One filled XY path compiled into immutable Slughorn data. Placement is external.
| Declaration | Summary |
|---|---|
val words: IntArray |
A defensive copy; mutating it cannot change a validated path. |
val curveCount: Int |
|
val byteSize: Long |
typography.kt
Text as geometry: a string becomes contours, and from there everything Field already does to geometry applies to letters.
A layout here is a value you can extrude, march, sample or animate, not a label painted into a
raster — which is what every other use of java.awt.Font in the tree does (MeshGallery,
ImagingGallery, montage's 5x7 bitmap font). It needs no dependency beyond AWT:
Font.createGlyphVector(...).getOutline() hands back real quadratic/cubic glyph outlines, and it
works headless. typography_field.kt is the bridge to Field's own 2D and 3D outputs.
The three values
Typeface— a font at a size, from a logical/system name (typeface) or a.ttf/.otfon disk (typefaceFromFile). Carries the vertical metrics (ascent, descent, leading) a layout needs.Glyph— one glyph's outline, cached per typeface and glyph code, in its own frame with its origin on its own baseline. Both representations live side by side:Glyph.contoursare flattened, nesting-classified, winding-normalised polygons for geometry work, andGlyph.outlineis the same rings as anFLineof real cubics for drawing.TextLayout— glyphs placed on baselines by AWT's own advances and kerning, plus tracking, multi-line and alignment. Every glyph in it is aPlacedGlyphcarrying mutable transform handles (offset, rotation, scale, pivot). Those handles are how you animate letters individually, andTextLayout.onCurveis nothing but a routine that sets them from anFLine's arc length.
Text space
Glyph and layout coordinates are x right, y down, origin at the first baseline, units = the
typeface's size in points. That is AWT's own glyph frame and it is also the frame Field's 2D
outputs use (Rect's y grows downwards), so text handed straight to Box.lines or
Box.showText reads the right way up with no transform. The 3D bridges in typography_field.kt
flip y at their own boundary, because a mesh wants y up.
The winding convention
A glyph's counter — the hole in o, e, a, B — is only a hole if something says so, and what
the font shipped is not enough to go on. (isoContourFLines in contours.kt leaves winding
arbitrary, and every consumer downstream has to cope.) So this file decides:
For a ring
p0..pn, signed area is½Σ(xᵢyᵢ₊₁ − xᵢ₊₁yᵢ)in text space. Outer contours are positive; holes are negative. A ring'sGlyphContour.depth— how many other rings of the same glyph contain it — is authoritative: even depth is an outer contour, odd depth is a hole. A ring whose area sign disagrees with its depth is reversed on the way out.
Both representations obey it, so contours.filter { it.isHole } and the subpaths of
Glyph.outline agree, and TypographyTest asserts it on glyphs whose answer is known by eye
(o has one hole, l has none, % has two).
AWT and the main run loop
imaging_field.kt captions montage with a bitmap table instead of Graphics2D.drawString, for
a reason that reaches this file: on macOS, touching the AWT font manager pulls in AppKit, which
competes with GLFW for the main run loop, and Server.main never initialises the toolkit. This
family cannot avoid the font manager — it is the font manager — so it contains the hazard in one
place, Typefaces.warm, whose KDoc carries the measured stack trace and the conclusion:
A JVM that wants glyph outlines and passes
-XstartOnFirstThreadmust also pass-Djava.awt.headless=true. OtherwiseCFontManager.loadFontsdispatches to the process's first thread and waits forever for a run loop nobody is pumping.
Typefaces.warm detects that in four seconds and throws with that sentence in it, rather than
hanging the editor. Every jvm-main gallery in the tree already sets the property in the first line
of main, jvmTest does not need it (Gradle's test JVM has no -XstartOnFirstThread), and an
interactive Field window pumps its run loop through glfwPollEvents and so is expected to be
fine — expected, not tested; see Typefaces.warm. Everything after that one touch is pure
computation over cached outlines.
src/jvmMain/kotlin/afield/graphics/text/typography.kt — read the source
Used in: Typography as geometry — a string becomes contours, A Word Cut as a Stencil, Datalog in a box
Top-level functions
Typeface
class Typeface internal constructor(val awtFont: Font)
A font at a size: the thing you ask for glyphs and layouts.
| Declaration | Summary |
|---|---|
val awtFont: Font |
The AWT font, exposed: anything AWT can do to a font (attributes, transforms, deriveFont) is available, and Typeface.of wraps the result back up. |
val size: Double |
Point size, as a Double — awtFont.size truncates to Int. |
val familyName: String |
e.g. Helvetica. |
val fontName: String |
e.g. Helvetica-Bold, which is what a .ttf reports and what round-trips through AWT. |
val ascent: Double |
Distance from the baseline up to the top of most glyphs, positive, in text-space units. |
val descent: Double |
Distance from the baseline down to the bottom of descenders, positive. |
val leading: Double |
The font's recommended extra space between lines, positive. |
val lineHeight: Double |
ascent + descent + leading — the natural baseline-to-baseline distance at this size. |
val kerning: Boolean |
Whether AWT will apply this font's kerning pairs during layout. |
fun atSize(size: Number): Typeface |
The same font at a different size. |
fun withKerning(on: Boolean): Typeface |
The same font with AWT's kerning-pair lookup switched on or off. |
fun withStyle(bold: Boolean = false, italic: Boolean = false): Typeface |
The same font emboldened / italicised through AWT's style bits (synthetic if the family lacks it). |
fun canDisplay(text: String): Boolean |
True when this font has a glyph for every character of text. |
fun glyph(c: Char): Glyph |
One glyph's outline, by character. Convenience over layoutText for a single letter. |
override fun toString(): String |
|
override fun equals(other: Any?): Boolean |
|
override fun hashCode(): Int |
Typefaces
Font loading, the glyph cache, and the one dangerous moment in this family.
| Declaration | Summary |
|---|---|
val isWarm: Boolean |
True once warm has succeeded, so every later layout is pure computation. A caller on the main run loop can check this to avoid the stall warm describes, and warm from another thread instead. |
fun warm() |
Initialise the AWT font manager once, on a helper thread, and refuse to hang if it will not answer. |
var lastWarmStall: String? |
Where the last stalled warm attempt was, top frames first. |
fun named(name: String = Font.SANS_SERIF, size: Number = 64.0, bold: Boolean = false, italic: Boolean = false, kerning: Boolean = true, strict: Boolean = false): Typeface |
A logical or installed font by name. |
fun fromFile(path: String, size: Number = 64.0, kerning: Boolean = true): Typeface |
A .ttf / .otf / .ttc from disk, through Font.createFont(TRUETYPE_FONT, file). |
fun available(): List<String> |
Every font family the graphics environment can see, sorted. |
fun firstAvailable(vararg candidates: String, size: Number = 64.0, fallback: String = Font.SANS_SERIF): Typeface |
The first of candidates that is actually installed, else fallback. |
fun clearCache() |
Drop every extracted outline. Only useful if you are churning through fonts from disk. |
val cachedGlyphCount: Int |
How many glyph outlines are currently cached — the number TypographyTest checks caching by. |
GlyphContour
class GlyphContour internal constructor(val points: List<Vec2>, val depth: Int)
One closed ring of a glyph outline, flattened to a polygon and classified.
| Declaration | Summary |
|---|---|
val points: List<Vec2> |
The ring as a polygon in text space, flattened to the tolerance the extraction asked for, and wound so that signedArea agrees with depth. The first point is not repeated at the end. |
val depth: Int |
How many other rings of the same glyph contain this one. 0 for the bowl of o, 1 for its counter. Even is an outer contour, odd is a hole. |
val signedArea: Double |
½Σ(xᵢyᵢ₊₁ − xᵢ₊₁yᵢ) in text space; positive for outer rings and negative for holes, per the convention this file enforces. Its magnitude is the ring's area in square text-space units. |
val isHole: Boolean |
True for the counter of an o — an odd nesting depth, and so a negative signedArea. |
val isOuter: Boolean |
True for the bowl of an o — an even nesting depth, and so a positive signedArea. |
val bounds: Rect |
Tight bounds in text space. |
val perimeter: Double |
Closed-ring perimeter, in text-space units. |
fun toPolyline(): FLine |
This ring as its own closed FLine — a polyline, not the original cubics. |
fun contains(p: Vec2): Boolean |
Winding-number test against this ring alone, ignoring nesting. |
override fun toString(): String |
Glyph
One glyph's outline, in its own frame with the origin on its own baseline.
| Declaration | Summary |
|---|---|
val glyphCode: Int |
The font's own glyph id, not a character code: a ligature has one code and several characters, and the same code means different letters in different fonts. |
val chars: String |
The characters that produced this glyph the first time it was extracted. |
val advance: Double |
How far the pen moves after this glyph, in text-space units. Whitespace has an advance and no contours. |
val contours: List<GlyphContour> |
The rings as polygons, in the font's own order, depth-classified and winding-normalised. This is what the mesh, SDF, raster and point-sampling bridges consume. |
val outline: FLine |
The same rings in the same order, one moveTo-led subpath each, keeping the font's real cubics. This is what you draw, and its subpaths run in step with contours. |
val bounds: Rect |
Tight bounds of the outline (an empty rect at the origin for whitespace), which is the visual box: advance is generally wider, and italics can overhang it. |
val isBlank: Boolean |
True for a space, a tab, or any glyph the font draws nothing for. |
val contourCount: Int |
Rings in total. |
val holeCount: Int |
Rings at odd nesting depth: 1 for o, 2 for %, 0 for l. |
val outerCount: Int |
Rings at even nesting depth: 1 for o, 2 for i (stem and tittle). |
val inkArea: Double |
Ink area: outer rings minus holes. Always >= 0 for a well-formed glyph. |
override fun toString(): String |
TextAlign
Where a line's glyphs sit relative to the widest line of the layout.
Constants: LEFT, CENTER, RIGHT.
PlacedGlyph
One glyph placed on a baseline, plus the handles that make it kinetic.
| Declaration | Summary |
|---|---|
val glyph: Glyph |
The outline being placed, shared with every other placement of the same glyph in the same font and size. Do not expect to own it. |
val origin: Vec2 |
Where layout put this glyph, in the layout's text space: on its line's baseline, with the alignment shift already in it. Fixed for the life of the placement — offset is the handle that moves it. |
val lineIndex: Int |
Which of TextLayout.lines this glyph is on, counting from 0. |
val charIndex: Int |
Index into the laid-out string of the first character this glyph renders. Line breaks count, so this indexes TextLayout.text rather than TextLine.text. |
var offset: Vec2 |
Extra translation, in text-space units, applied after layout. |
var rotation: Double |
Rotation about pivot, radians, positive clockwise on screen (because y is down). |
var scale: Vec2 |
Scale about pivot. Negative components mirror, and the geometry bridges cope. |
var pivot: Vec2? |
Rotation/scale centre in the glyph's own coordinates; null means the visual-bounds centre. |
var color: Vec4? |
Per-glyph colour override for TextLayout.toFLines; null means take the caller's colour. |
val text: String |
The characters this placement renders — one, or several for a ligature. |
val resolvedPivot: Vec2 |
The resolved pivot, in glyph coordinates. |
fun transform(): AffineTransform |
The affine this placement's geometry goes through. A fresh instance each call. |
val isUntransformed: Boolean |
True when nothing has been asked of the handles, so the transform is a pure translation. |
override fun toString(): String |
TextLine
One baseline's worth of a layout.
| Declaration | Summary |
|---|---|
val index: Int |
Which line this is, counting from 0, and the PlacedGlyph.lineIndex its glyphs carry. |
val text: String |
This line's own characters, without the line break that ended it. |
val baselineY: Double |
This line's baseline in the layout's text space: index times the layout's TextLayout.lineHeight, so the first line's baseline is at 0 and later lines are at increasing y, which is downwards. |
val advanceWidth: Double |
How far the pen travelled across this line, tracking between glyphs included and the gap after the last glyph excluded. It is not the inked width — an italic f can overhang it. |
val glyphs: List<PlacedGlyph> |
The glyphs on this baseline, in reading order. They are the same objects as the matching stretch of TextLayout.glyphs, so a handle set through either is set for both. |
TextLayout
A laid-out string: glyphs on baselines, with handles.
| Declaration | Summary |
|---|---|
val text: String |
The string that was laid out, line breaks and all. |
val typeface: Typeface |
The typeface it was laid out in, which is where ascent, descent and the glyphs come from. |
val glyphs: List<PlacedGlyph> |
Every placement in the layout, in reading order across all lines. This is what eachGlyph walks and what the geometry bridges read. |
val lines: List<TextLine> |
The lines, in order, one per \n-separated run of TextLayout.text, empty lines included. |
val tracking: Double |
The extra advance after every glyph that was asked for, in text-space units. |
val lineHeight: Double |
Baseline-to-baseline distance in text-space units — the resolved value, typeface.lineHeight times the lineSpacing layoutText was given, not the multiplier. |
val align: TextAlign |
Where each line sits relative to the widest one. |
val ascent: Double |
Positive, from the typeface. |
val descent: Double |
Positive, from the typeface. |
val advanceWidth: Double |
The widest line's advance width. Tracking is included; the trailing gap is not. |
val layoutHeight: Double |
Baseline-to-baseline of the first and last line, plus one line's ascent and descent. |
val layoutBounds: Rect |
The em box: the rectangle layout reserved, ignoring what the glyphs actually inked. |
val bounds: Rect |
Tight bounds of the inked geometry with every handle applied. Empty for whitespace-only text. |
val inkedGlyphs: List<PlacedGlyph> |
Every glyph that inks something. Whitespace is in glyphs and not here. |
val contourCount: Int |
Total contour count across the layout — outer rings plus holes. |
val holeCount: Int |
Total hole count across the layout. |
fun eachGlyph(block: PlacedGlyph.(Int) -> Unit): TextLayout |
Set the handles glyph by glyph, and hand the layout back for chaining. |
fun resetHandles(): TextLayout |
Reset every handle to its identity. |
fun copyLayout(): TextLayout |
A deep copy: same structure, independent handles. |
override fun toString(): String |
typography_field.kt
The bridges out of a TextLayout and into the four families Field already has.
typography.kt answers with structure — rings, depths, advances, handles — and structure is not
yet a drawing, a mesh, an image or a point cloud. This file converts it: once a string is
contours, letters are inputs to machinery that never knew about text.
It is the typographic counterpart of spatial_field.kt (plane geometry → FLine) and
mesh_field.kt (TriMesh → FLine), and it is written to the same rule: every function here
returns a value — no Box, no output — except the two figure helpers at the bottom, which are
drawToSVG and so work headless, in a test and in the docs build.
Five bridges:
- →
FLine(TextLayout.toFLines,TextLayout.toFLine,Glyph.toFLine). One line per glyph, so a per-glyph handle actually moves something, with the font's real cubics intact. Filling works because oftypography.kt's winding convention and nothing else: Skia and Java2D both fill non-zero, so a counter is a hole precisely because its ring runs the other way.TextLayout.toFLinestakeshonourHoles = falseto show you the blob you get without a convention, which is the control arm the docs figure uses. - →
TriMesh(TextLayout.toMesh). Hole-aware extrusion:extrudeMeshandtriangulatePolygoneach take a single ring and cannot do holes, so the caps here go throughFloatEarcutwith hole indices and the walls are emitted ring by ring from the same flattened vertices, which is what makes the result watertight — and makes an extrudedogenus 1 and an extrudedlgenus 0. - → an SDF (
TextLayout.toSdf). A 2D signed-distance grid, bilinearly sampled and extruded in z, so letters compose withsdfSphere,sdfSmoothUnion,.twisted()and everything else inmesh_sdf.kt, andmarchingCubeswill mesh the result. - →
Img(rasterizeText). An analytic even-odd scanline fill of the layout's own rings — notGraphics2D.drawString, both because a stdlib call every box might make must not be able to initialise AWT's font manager and because filling our own outlines means a legible PNG is evidence about this code rather than about AWT's rasteriser. - → points (
TextLayout.samplePoints,TextLayout.toPointFLine). Even-by-arc-length samples along the outlines, which is what the alife agents, the clusterers and the contourers inclustering.kt/alife_agents.ktconsume.
Frames: bridges 1, 3, 4 and 5 stay in text space (y down, origin on the first baseline); bridge 2 flips y at its boundary, because a mesh wants y up, and re-normalises winding when it does.
src/jvmMain/kotlin/afield/graphics/text/typography_field.kt — read the source
Used in: Typography as geometry — a string becomes contours
Top-level functions
TextDistanceField
A signed-distance grid over a layout, in text space, negative inside the ink.
| Declaration | Summary |
|---|---|
val bounds: Rect |
The region the grid covers, in text space: the layout's inked bounds grown by the margin the build asked for. Samples sit on its edges, not half a cell inside them. |
val nx: Int |
Samples across TextDistanceField.bounds, at least 4. |
val ny: Int |
Samples down TextDistanceField.bounds, at least 4. Only the longer side of the grid gets the resolution that was asked for; the shorter one is scaled to keep the cells square. |
fun at(x: Double, y: Double): Double |
Bilinear sample. Outside TextDistanceField.bounds the nearest edge value is used plus the distance to the box — which keeps the field monotone far away, so a marcher never finds a spurious surface out there. |
fun at(p: Vec2): Double |
Bilinear sample at a point. |
fun toImg(): Img |
The grid as a one-channel Img, distance mapped to 0..1 — for looking at the field itself. |
fun deepestInside(): Double |
Largest inscribed distance: how far inside the ink you can get. A stroke-weight measure. |
TextSdf
A layout's SDF plus the bounds it lives in — the pair marchingCubes wants.
| Declaration | Summary |
|---|---|
val field: ScalarField3D |
The field itself, negative inside the letters, in normalised y-up coordinates. Safe to compose with any ScalarField3D. |
val bounds: AxisAlignedBoundingBox3D |
A box comfortably containing the surface, so march needs no arguments. |
val distanceField: TextDistanceField |
The 2D grid underneath, if you want to look at it or measure it. |
val scale: Double |
Normalised units per text-space unit, which is one over the layout's ascent: multiply a text-space length by this to get a normalised one. |
fun march(resolution: Int = 96, vertexNormals: Boolean = true): TriMesh |
March the field. resolution is cells along the longest axis, as in marchingCubes. |
TextFillRule
How a scanline decides whether a crossing count means "inside".
Constants: NON_ZERO, EVEN_ODD.
IOSurfaceHostBuffer.kt
IOSurfaceHostBuffer maps an IOSurface's memory into a Vulkan buffer, so pixels move between an IOSurface and a Vulkan image with one GPU copy on macOS drivers that cannot back an image with an IOSurface.
src/jvmMain/kotlin/afield/graphics/texture/IOSurfaceHostBuffer.kt — read the source
IOSurfaceHostBuffer
An IOSurface's own pages as a VkBuffer, through VK_EXT_external_memory_host.
| Declaration | Summary |
|---|---|
val ioSurfaceRef: Long |
|
val width: Int |
|
val height: Int |
|
val bytesPerRow: Long |
|
val buffer: Long |
|
fun copyToImage(commandBuffer: org.lwjgl.vulkan.VkCommandBuffer, image: Long, bytesPerPixel: Int) |
Record surface → image (in TRANSFER_DST_OPTIMAL), bytesPerPixel per texel. |
fun copyFromImage(commandBuffer: org.lwjgl.vulkan.VkCommandBuffer, image: Long, bytesPerPixel: Int) |
Record image (in TRANSFER_SRC_OPTIMAL) → surface, bytesPerPixel per texel. |
override fun close() |
Image.kt
Image wraps a VkImage and its memory, created from an Image.ImageData description, including the external-memory exports used to hand frames to other processes.
Alongside ordinary device-local images, an Image can be allocated for export as an opaque fd
(CUDA/NVENC on Linux, see VulkanExternalMemory), as a dma-buf with a DRM format modifier (see
LinuxDmaBufExport) or as a Metal IOSurface (see MetalIOSurface). The file also holds the
format helpers vkFormatBytesPerPixel and vkFormatBytesPerPixelOrNull, and
image_layout_transition for recording layout barriers.
src/jvmMain/kotlin/afield/graphics/texture/Image.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun vkFormatBytesPerPixelOrNull(format: Int): Int? |
Bytes occupied by one pixel of format, or null if this table does not know the format. |
fun vkFormatBytesPerPixel(format: Int, usage: String): Int |
vkFormatBytesPerPixelOrNull, throwing for formats it does not know. usage names the caller so the message says which capture or upload failed. |
fun image_layout_transition(command_buffer: VkCommandBuffer, image: Image, src_stage_mask: Long, dst_stage_mask: Long, src_access_mask: Long, dst_access_mask: Long, old_layout: Int, new_layout: Int, sub_resource_range: VkImageSubresourceRange, src_queue_family_index: Int = VK_QUEUE_FAMILY_IGNORED, dst_queue_family_index: Int = VK_QUEUE_FAMILY_IGNORED)+1 overload · also Long |
Image
class Image(private val device: Device, val imageData: ImageData)
Image wraps a VkImage and its memory, created from an Image.ImageData description, including the external-memory exports used to hand frames to other processes.
| Declaration | Summary |
|---|---|
var format: Int |
The VkFormat the image was created with, copied from the Image.ImageData it was built from so it can be read back without that description to hand. |
var mipLevels: Int |
How many mip levels the image has, copied from its Image.ImageData. The levels are allocated; filling them is the caller's own blit chain. |
var vkImage: Long |
The VkImage handle. Zero until the constructor has allocated, and zero again after cleanup. |
var vkMemory: Long |
The VkDeviceMemory allocated for vkImage and bound to it. Bound even when the pixels live in an imported IOSurface, where MoltenVK may not need it. |
var memorySize: Long |
The size the driver asked for to back this image, which is the allocation size CUDA's cuImportExternalMemory has to be given when the image is exported as an opaque fd (the Linux NVENC path, see VulkanExternalMemory). Read from the image's memory requirements, so it is the real figure rather than a computed one, and it is set for every image. |
var linearRowPitch: Long |
The row pitch of the image's linear layout, for NVENC's NV_ENC_REGISTER_RESOURCE.pitch. Queried from the image rather than computed as width times bytes per pixel, because the driver may pad a row out to an alignment. Set only for an image whose Image.ImageData asked for exportOpaqueFd, and zero otherwise. |
var drmFormatModifier: Long |
The DRM format modifier the driver chose, which is the tiling an importing process has to be told about before it can read the dma-buf. The driver picks from the modifiers Field offers, so the choice is read back rather than assumed. Set only for an image whose Image.ImageData asked for exportDmaBuf — see LinuxDmaBufExport. |
var drmPlaneLayouts: List<DmaBufPlaneLayout> |
The offset and pitch of each memory plane under the modifier the driver chose, read back alongside drmFormatModifier and sent with the handle so an importer can address the planes. |
fun cleanup() |
|
fun setLayoutFor(commandBufferHandle: Long, layout: Int) |
|
fun getLayoutFor(commandBufferHandle: Long): Int? |
|
fun getLastKnownLayout(): Int? |
|
fun exportMetalIOSurfaceRef(): Long |
Export the IOSurfaceRef backing this image's MTLTexture (MoltenVK, VK_EXT_metal_objects). Requires exportMetalIOSurface set on the Image.ImageData it was created from, and the device to have enabled VK_EXT_metal_objects. Returns the IOSurfaceRef pointer (pass to afield.graphics.texture.MetalIOSurface.ioSurfaceId), or 0 if the surface could not be exported. |
fun exportOpaqueFd(): Int |
Export this image's backing memory as an opaque POSIX fd (Linux, VK_KHR_external_memory_fd, see VulkanExternalMemory). Requires exportOpaqueFd set on the Image.ImageData it was created from. Per the spec each call to vkGetMemoryFdKHR transfers ownership of a new fd to the caller, so this caches and returns the same fd on repeated calls — call close to release it, don't just drop the value. The fd stays valid (and open in this JVM process) for the streamer's whole session; the helper process only ever gets a duplicate via /proc/<pid>/fd/<fd> (see WIRE_FORMAT.md's Linux addendum), never this one. |
fun exportDmaBufFd(): Int |
Export this image's backing memory as a POSIX dma-buf fd (Linux, VK_EXT_external_memory_dma_buf, see LinuxDmaBufExport). Requires exportDmaBuf set on the Image.ImageData it was created from. |
class ImageData |
ImageView.kt
ImageView wraps a VkImageView over an image, and the small interfaces Field's shader bindings use to find views and samplers.
HasImageView and HasImageViewArray are what a texture binding accepts; PreparesSampledImages
lets a source record its layout transitions before sampling, and HasDefaultTextureSampler carries
the sampler a binding uses when none is given.
src/jvmMain/kotlin/afield/graphics/texture/ImageView.kt — read the source
Used in: Field 4 Graphics
HasImageView
ImageView wraps a VkImageView over an image, and the small interfaces Field's shader bindings use to find views and samplers.
| Declaration | Summary |
|---|---|
val imageView: ImageView |
The view to bind. Implementations that follow a ring or a ping-pong swap return a different view on each read, which is what lets a binding made once keep pointing at the current image. |
HasImageViewArray
| Declaration | Summary |
|---|---|
val imageViewArray: List<HasImageView> |
The views to bind, in the order the shader's array takes them. Every one is written, so the list's length is the descriptor count the array binding receives; an empty list is refused. |
PreparesSampledImages
interface PreparesSampledImages
| Declaration | Summary |
|---|---|
fun prepareSampledImagesFor(command: CommandBuffer) |
HasDefaultTextureSampler
interface HasDefaultTextureSampler
| Declaration | Summary |
|---|---|
var defaultSampler: TextureSampler? |
The sampler a texture binding uses when the module does not name one, or null to leave that to the binding. The ping-pong targets forward this to the target itself, so setting it once covers every view they hand out. |
ImageView
| Declaration | Summary |
|---|---|
val image: Long |
The VkImage this is a view of. The constructor taking an Image passes its handle and records the image in underlyingImage; this one is for a handle whose owner is elsewhere, a swapchain image among them. |
val imageViewData: ImageViewData |
The view's format, aspect, layer range, mip count and debug name, read once here and kept for anything that needs to know what the view covers. |
val aspectMask: Int |
Which aspects of the image the view covers, from imageViewData — colour, or depth with or without stencil. A combined depth+stencil view cannot be sampled, which is why a sampled depth attachment gets a second, depth-only view. |
val mipLevels: Int |
How many mip levels the view covers, starting at level 0. A view of one level over a mipped image is how a render pass writes a single level. |
var vkImageView: Long |
The VkImageView handle, 0 until the constructor has created it. |
var underlyingImage : Image? |
The Image this views, when the view was made from one. It stays null for a view built over a bare handle, so a caller that needs the image's layout or memory has to hold it separately. |
fun cleanup() |
|
fun source(i : Image) : ImageView |
|
override val imageView: ImageView |
|
class ImageViewData |
LinuxDmaBufExport.kt
LinuxDmaBufExport exports Vulkan images as Linux dma-bufs described by a DRM format modifier, so another process (such as Electron) can import them without a copy.
LinuxDmaBufExport.shareableModifiers and LinuxDmaBufExport.supportsModifier check which
modifiers the device can export for a format; LinuxDmaBufExport.handle and
LinuxDmaBufExport.handles package an exported image as a DmaBufHandle of fd, modifier and
DmaBufPlaneLayouts. Linux only.
src/jvmMain/kotlin/afield/graphics/texture/LinuxDmaBufExport.kt — read the source
DmaBufPlaneLayout
data class DmaBufPlaneLayout(val offset: Long, val rowPitch: Long)
One DRM-modifier memory plane of a dma-buf image, as the importer must be told it: a byte offset into the allocation and a row pitch. These are memory planes of the modifier's layout, not colour planes of the format.
DmaBufHandle
An exported dma-buf image: one POSIX fd owning the allocation, the DRM format modifier that describes its tiling, and every memory plane's layout. This is the same information an Electron nativePixmap carries, and it maps directly onto afield.media.browser.ExternalBrowserImageHandle.LinuxDmaBuf.
| Declaration | Summary |
|---|---|
val fd: Int |
|
val modifier: Long |
|
val planes: List<DmaBufPlaneLayout> |
|
val size: Long |
LinuxDmaBufExport
The Linux/dma-buf counterpart of MetalIOSurface: turns a Field image into the cross-process, cross-vendor handle a dma-buf consumer needs.
| Declaration | Summary |
|---|---|
fun shareableModifiers(device: Device, format: Int, usage: Int, externalFeature: Int = VK_EXTERNAL_MEMORY_FEATURE_EXPORTABLE_BIT): List<Long> |
The DRM format modifiers this device can both create usage images with and share as a dma-buf, restricted to single-memory-plane layouts. Asked with vkGetPhysicalDeviceImageFormatProperties2 rather than inferred from drmFormatModifierTilingFeatures, because only the former accounts for the external handle type actually being used. |
fun supportsModifier(device: Device, format: Int, usage: Int, modifier: Long, externalFeature: Int): Boolean |
Whether modifier can back a format/usage image that is shareable as a dma-buf in the direction externalFeature describes (VK_EXTERNAL_MEMORY_FEATURE_EXPORTABLE_BIT to produce one, VK_EXTERNAL_MEMORY_FEATURE_IMPORTABLE_BIT to receive one). |
fun handle(image: Image): DmaBufHandle |
Export image — which must have been created with Image.ImageData.exportDmaBuf — as a fresh dma-buf handle. Each call produces a new fd, so each call yields a handle that a consumer may import (and thereby consume) independently. |
fun handles(rtt: RenderToTexture): List<DmaBufHandle> |
Handles for all of an RTT's internally-created color attachments, in slot order (skipping any slot backed by an external view, which has no Image of its own to export). Mirrors VulkanExternalMemory.handles and MetalIOSurface.ioSurfaceIds. |
MetalIOSurfaceExport.kt
MetalIOSurface reaches the macOS IOSurface framework from Kotlin, so a Field render-to-texture image can be shared with another process by IOSurfaceID or Mach port.
It looks up, locks, measures and releases IOSurfaces, and MetalIOSurface.ioSurfaceIds lists the
ids behind a afield.graphics.vulkan.targets.RenderToTexture created for IOSurface export. macOS only.
src/jvmMain/kotlin/afield/graphics/texture/MetalIOSurfaceExport.kt — read the source
MetalIOSurface
Bridges a Field RTT color image to a global IOSurfaceID that the field-encode co-process can attach to with IOSurfaceLookup (see native/field-encode/ and dev_docs/vision_frontend.md -> "The sender seam").
| Declaration | Summary |
|---|---|
const val LOCK_READ_ONLY |
kIOSurfaceLockReadOnly: the lock's owner promises not to write through the CPU mapping. |
fun lock(ioSurfaceRef: Long, options: Int = 0) |
IOSurfaceLock(ref, options, NULL): pin the surface's CPU mapping so baseAddress is valid. The address is only defined while the surface is locked. Balance with unlock. |
fun unlock(ioSurfaceRef: Long, options: Int = 0) |
|
fun baseAddress(ioSurfaceRef: Long): Long |
|
fun bytesPerRow(ioSurfaceRef: Long): Long |
|
fun allocSize(ioSurfaceRef: Long): Long |
|
fun width(ioSurfaceRef: Long): Int |
|
fun height(ioSurfaceRef: Long): Int |
|
fun lookup(id: Long): Long |
IOSurfaceRef IOSurfaceLookup(IOSurfaceID) — resolve a process-global id back to a local IOSurfaceRef pointer (0 if not found). The inverse of ioSurfaceId: use it to import a surface another process created (e.g. one the field-encode helper allocated and returned), then pass the pointer to RTTParameters.importMetalIOSurface. |
fun lookupFromMachPort(port: Int): Long |
Resolve an IOSurface send right that has already been transferred into this process. The returned IOSurfaceRef is retained; release balances it. This does not consume port. |
fun createMachPort(ioSurfaceRef: Long): Int |
Create a send right for ioSurfaceRef. The returned port name is local to this process; transfer the right in a Mach message rather than sending its integer value over ordinary IPC. The caller owns the right and must eventually call deallocateMachPort. |
fun deallocateMachPort(port: Int) |
Drop this process's send right after lookupFromMachPort retained the IOSurface. |
fun release(ioSurfaceRef: Long) |
Balances the retain performed by lookup or lookupFromMachPort. Call only after every Vulkan object importing the returned IOSurfaceRef has been destroyed. |
fun ioSurfaceId(ioSurfaceRef: Long): Long |
IOSurfaceID IOSurfaceGetID(IOSurfaceRef) — returns the process-global id as an unsigned Long (the raw C return is a uint32). 0 for a null ref. |
fun ioSurfaceId(image: Image): Long |
Export + resolve the global id for a single RTT color image. 0 if export failed. |
fun ioSurfaceIds(rtt: RenderToTexture): List<Long> |
The global IOSurfaceIDs for all of an RTT's internally-created color attachments, in slot order (0 for any slot backed by an external view or that failed to export). This is the list to hand to FieldEncodeProcess.open(surfaces = ...). |
TextureSampler.kt
TextureSampler wraps a VkSampler: filtering, mipmap mode, anisotropy and per-axis address modes for sampling a texture in a shader.
src/jvmMain/kotlin/afield/graphics/texture/TextureSampler.kt — read the source
TextureSampler
TextureSampler wraps a VkSampler: filtering, mipmap mode, anisotropy and per-axis address modes for sampling a texture in a shader.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
|
var vkSampler: Long |
|
fun cleanup() |
UnixFdPass.kt
UnixFdPass sends file descriptors to another process over a Unix domain socket with SCM_RIGHTS, which is how Field hands exported GPU memory to its Linux encoder helper.
It calls a separately built JNI helper (native/field-fdpass/), loaded through its own class
loader; UnixFdPass.available reports whether that helper is present. Linux only.
src/jvmMain/kotlin/afield/graphics/texture/UnixFdPass.kt — read the source
UnixFdPass
Sends raw file descriptors to another process via SCM_RIGHTS over a Unix domain socket — the mechanism VulkanExternalMemory's field-encode-linux handoff actually needs.
| Declaration | Summary |
|---|---|
val available: Boolean |
The optional helper must be built before starting an external-memory session. |
fun closeFd(fd: Int) |
Close an owned export descriptor after SCM_RIGHTS duplicated it, or on setup failure. |
fun sendFds(socketPath: String, fds: IntArray): Boolean |
Connect to the AF_UNIX SOCK_STREAM socket at socketPath (a peer must already be listening — see field-encode-linux's Linux open handling) and send every fd in fds in one sendmsg call (one SCM_RIGHTS control message, fds preserved in array order). Returns false on any failure (socket missing/not listening yet, connect refused, send failed) — callers should retry with backoff for the "not listening yet" race rather than fail immediately. |
VulkanExternalMemory.kt
Exports a render-to-texture color image as a POSIX file descriptor, so the Linux
field-encode-linux helper can import it into CUDA and encode it with NVENC.
The chain: render into an RTT created with exportOpaqueFd = true, then Image.exportOpaqueFd
(VK_KHR_external_memory_fd) yields an fd open in this JVM process, and
VulkanExternalMemory.handle packages it with the exact allocation size (CUDA's
cuImportExternalMemory requires an exact match) and the row pitch (NVENC's
NV_ENC_REGISTER_RESOURCE.pitch, which is the image's actual, possibly alignment-padded, linear
layout rather than width times bytes per pixel).
The fd number never travels over the JSON control channel, because a raw fd means nothing to
another process by value; a Mac IOSurfaceID, by contrast, is a system-wide handle.
VulkanExternalMemory.FdHandle.fd feeds UnixFdPass.sendFds, which passes the kernel object to
the helper over SCM_RIGHTS, and only size and rowPitch go into the JSON of
FieldEncodeProcess.open(fdSurfaces = ...).
vkGetMemoryFdKHR is an ordinary LWJGL Vulkan binding, so the export itself lives on Image;
this object is the RenderToTexture-level convenience, the counterpart of
MetalIOSurface.ioSurfaceIds on macOS.
Linux/NVIDIA only. The fd stays open in this process for the streamer's session, and the helper
receives a duplicate of the kernel object, so this process must not close its own fd while
streaming. Design notes: dev_docs/vision_frontend.md, "The sender seam", Linux row.
src/jvmMain/kotlin/afield/graphics/texture/VulkanExternalMemory.kt — read the source
VulkanExternalMemory
Packages an RTT's exported color images as fd handles for the Linux encode helper.
| Declaration | Summary |
|---|---|
data class FdHandle(val fd: Int, val size: Long, val rowPitch: Long) |
An exported image: its fd, exact allocation size in bytes, and linear rowPitch. |
fun handle(image: Image): FdHandle |
image must have been created with Image.ImageData.exportOpaqueFd = true. |
fun handles(rtt: RenderToTexture): List<FdHandle> |
The fd handles for all of an RTT's internally-created color attachments, in slot order (skipping any slot backed by an external view, which has no Image of its own to export). This is the list to hand to FieldEncodeProcess.open(fdSurfaces = ...). |
MegaTexture.kt
A virtual texture over thousands of source images, paged into a fixed GPU atlas on demand.
Shaders report which images and detail levels they need through a feedback buffer; MegaTexture
reads it back, asks MegaTextureDecoder for the missing pages, copies them into atlas slots and
publishes a page table the shader samples through. MegaTextureConfig sets page size, residency
budget and feedback rate, and MegaTextureStats reports per-level residency and decode queues.
src/jvmMain/kotlin/afield/graphics/texture/mega/MegaTexture.kt — read the source
MegaTextureConfig
How a MegaTexture is shaped: how big its pages are, how much GPU memory it may hold, how hard it decodes, and what its shader-visible names and bindings are.
| Declaration | Summary |
|---|---|
var minPageSize: Int |
Edge of a level-0 page, for a square page ladder. Level L is then minPageSize shl L. |
var levels: Int |
How many levels exist. The default 7 gives 8, 16, 32, 64, 128, 256 and 512-pixel pages. |
fun sourcePage(width: Int, height: Int): MegaTextureConfig |
Shape the pages like the source, and make the top level exactly this size. |
val topPageWidth: Int |
Top-level page width: sourcePage's, or the square ladder's minPageSize shl (levels-1). |
val topPageHeight: Int |
Top-level page height. Equal to topPageWidth unless sourcePage said otherwise. |
var atlasEdge: Int |
Edge of one atlas array layer, in pixels. Every level packs whole pages into these. |
var layersPerLevel: Int |
Array layers given to each level above the base. More layers, more pages resident. |
var maxBaseLevelLayers: Int |
Ceiling on layers for level 0, which otherwise gets however many it takes to hold every image at once. |
var decodeThreads: Int |
|
var maxQueuedRequests: Int |
Queued requests beyond this are dropped worst-first at the end of a feedback pass. |
var uploadBytesPerFrame: Int |
Ceiling on page bytes copied into the atlas per frame. Keeps a burst off the frame time. |
var feedbackEveryNFrames: Int |
How often the GPU's request buffer is read and cleared, in frames. 0 means "choose from the image count". |
var staleFrames: Int |
A request not re-asked for in this many frames is abandoned rather than decoded. |
var warmUpRequestsPerFrame: Int |
Base-level requests submitted per frame while warming up. |
var warmUpQueueTarget: Int |
Warm-up pauses while the decode queue is deeper than this, so it cannot outrun the workers. |
var warmUpReadyTarget: Int |
Warm-up also pauses while this many decoded pages are waiting for an upload slot. |
var pendingExpiryFrames: Int |
Backstop: after this many frames an outstanding request is assumed lost regardless. |
var evictionGraceFrames: Int |
Slots not sampled for this many frames may be evicted. |
var crossfadeFrames: Int |
Frames a newly arrived page takes to fade in over the one it replaces. 0 to switch instantly. |
var evictionsPerFramePerLevel: Int |
Evictions started per level per frame when a level is under pressure. |
var cacheMaxLevel: Int |
Highest level written to the on-disk page cache. Above this, pages are re-decoded. |
var cacheMaxVirtualBytes: Long |
A level whose fully-populated cache file would exceed this is not cached at all. |
var cacheRoot: Path? |
Where the catalog and page cache live. Null means MegaTextureCatalog.defaultCacheRoot. |
var descriptorSet: Int |
|
var atlasBinding: Int |
|
var pageTableBinding: Int |
|
var feedbackBinding: Int |
|
var atlasName: String |
GLSL names. Change them if they collide with something already in your shader. |
var pageTableName: String |
|
var feedbackName: String |
|
var name: String |
MegaTextureStats
What a MegaTexture is doing right now — for println, an output card, or a test.
| Declaration | Summary |
|---|---|
val imageCount: Int |
|
val frame: Long |
|
val wantedImages: Int |
|
val residentPages: IntArray |
|
val pageCapacity: IntArray |
|
val publishedAtLevel: IntArray |
|
val unpublished: Int |
|
val queueDepth: Int |
|
val inFlightDecodes: Int |
|
val decodedFromCache: Long |
|
val decodedFromSource: Long |
|
val decodeFailures: Long |
|
val cancelled: Long |
|
val uploadedPagesThisFrame: Int |
|
val uploadBytesThisFrame: Int |
|
val effectiveMaxLevel: Int |
|
val warmUpRemaining: Int |
Images with no base-level page yet. |
val pageWidths: IntArray |
Page width per level, so the table can name each level by the page it actually holds. |
val pageHeights: IntArray |
Page height per level. Equal to pageWidths for a square ladder. |
override fun toHTML(): String |
One row per level, because that is the shape of the question being asked: how sharp is the wall, and is any level out of pages. The ceiling is marked rather than stated separately — a level above it is not being served, and seeing which one is more useful than the number. |
override fun toString(): String |
MegaTexture
A GPU-driven virtual texture over an arbitrarily large set of images.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
|
val catalog: MegaTextureCatalog |
|
val config: MegaTextureConfig |
|
val imageCount: Int |
|
val levels: Int |
|
val layout |
Where every page lives, and how a page table entry is packed. Pure arithmetic, no device — see MegaTextureLayout for why it is a separate object. |
val totalLayers: Int |
|
val atlasImage: Image |
|
val atlasView: ImageView |
|
val sampler: TextureSampler |
|
override val imageViewArray: List<HasImageView> |
|
override val imageView: ImageView |
|
val template: Map<String, Any> |
The {{ mega.… }} slots this system exposes to a box's shaders. |
val atlasEdge: Int |
|
fun pageWidth(level: Int): Int |
|
fun pageHeight(level: Int): Int |
|
fun capacityOf(level: Int): Int |
|
fun isIdentityLevel(level: Int): Boolean |
|
fun bind(parameters: Parameters): Parameters |
Point a module's atlas sampler, page table and feedback buffer at this system. |
fun beforeTarget(): BeforeTarget |
The per-frame work, as a target to hang on a module ahead of its render pass. |
fun afterTarget(): Target |
Make the shader's feedback writes readable by the host once the render pass has ended. |
fun attach(module: ModulePointer, name: String = config.name): MegaTexture |
Register beforeTarget and afterTarget on module under name. |
fun warmBaseLevel(level: Int = 0): MegaTexture |
Ask for a base-level page for every image, so the wall fills in without being looked at. |
val warmingUp: Boolean |
True while the base level is not yet complete. |
fun frame(command: CommandBuffer, imageIndex: Int) |
One frame of cache work, recorded into command ahead of the render pass. |
fun stats(): MegaTextureStats |
|
override fun close() |
MegaTextureCatalog.kt
The stable image-to-index catalog behind a MegaTexture, and the on-disk pyramid cache keyed to it.
MegaTextureCatalog assigns each source file an append-only index and detects files that changed
since the last run; MegaTexturePyramidCache stores decoded pages so a later run can skip
decoding them.
src/jvmMain/kotlin/afield/graphics/texture/mega/MegaTextureCatalog.kt — read the source
MegaTextureCatalog
The set of source images a MegaTexture can show, and the index each one answers to.
| Declaration | Summary |
|---|---|
val cacheDir: Path |
Where this catalog and its pyramid cache live. |
val changedIndices: IntArray |
Indices whose source file's mtime/size differ from what the catalog recorded — their cached pages describe an older version of the picture and must be discarded. |
class Entry(val path: Path, var mtime: Long, var size: Long) |
|
val count: Int |
|
fun path(index: Int): Path? |
|
fun indexOf(path: Path): Int? |
|
fun forEachIndexed(block: (Int, Path) -> Unit) |
Every (index, path) pair, in index order. Useful for a warm-up pass over the whole set. |
MegaTexturePyramidCache
The on-disk store of downsampled pages, one sparse file per level.
| Declaration | Summary |
|---|---|
val cacheDir: Path |
|
val imageCount: Int |
|
val layout: MegaTextureLayout |
Where page sizes come from. Taken whole so the cache's stride cannot drift from the atlas's. |
val maxLevelCached: Int |
|
val maxVirtualBytes: Long |
|
val levels: Int |
|
fun sourceSize(index: Int): Pair<Int, Int>? |
The source image's pixel size, or null if it has never been decoded. |
fun setSourceSize(index: Int, width: Int, height: Int) |
Record the source image's pixel size, learned from a decode. |
fun has(index: Int, level: Int): Boolean |
True if level level has a page for index on disk. |
fun cachedLevels(): List<Int> |
Which levels this cache stores at all, cheapest first. |
fun read(index: Int, level: Int, into: ByteBuffer): Boolean |
Read the cached page for (image, level) into into, or return false if it is not there. into must have stride bytes remaining; its position is left at the start of the page. |
fun write(index: Int, level: Int, page: ByteBuffer) |
Store the page for (image, level). Silently does nothing for an uncached level. |
fun invalidate(indices: IntArray) |
Forget every cached level for these indices — their source files changed. |
fun residentCounts(): IntArray |
How many pages each cached level holds — the number reported by stats. |
override fun close() |
MegaTextureDecoder.kt
Background decoding of source images into downsampled MegaTexture pages.
MegaTextureDecoder runs a prioritised worker pool that turns requests into MegaDecodedPages,
drawing their pixel memory from per-level pools that the consumer returns with
MegaTextureDecoder.recycle.
src/jvmMain/kotlin/afield/graphics/texture/mega/MegaTextureDecoder.kt — read the source
MegaDecodedPage
One decoded, downsampled page on its way to the GPU.
MegaTextureDecoder
The parallel decode/downsample/cache queue behind a MegaTexture.
| Declaration | Summary |
|---|---|
val decodedFromCache |
|
val decodedFromSource |
|
val decodeFailures |
|
val cancelled |
|
val inFlight |
|
val queueDepth: Int |
|
val readyCount: Int |
|
fun request(index: Int, level: Int, score: Float, generation: Long) |
Ask for (image, level). Higher score is served first. |
fun poll(): MegaDecodedPage? |
Take a finished page, or null. The caller owns MegaDecodedPage.pixels until it recycles it. |
fun recycle(page: MegaDecodedPage) |
Return a page's native memory to the pool it came from. |
fun trimTo(keep: Int) |
Drop queued requests beyond keep, cheapest-scoring first. |
fun drainAbandoned(sink: (index: Int, level: Int) -> Unit) |
Hand every abandoned request since the last call to sink, as (index, level). |
override fun close() |
MegaTextureLayout.kt
The atlas geometry and page-table encoding for a MegaTexture, kept free of any GPU calls.
MegaTextureLayout computes per-level page sizes and slot placement and packs page-table entries;
MegaTextureLayoutTest checks that arithmetic without a Vulkan device.
src/jvmMain/kotlin/afield/graphics/texture/mega/MegaTextureLayout.kt — read the source
MegaTextureLayout
Where every page lives in the atlas, and how a page table entry is packed.
| Declaration | Summary |
|---|---|
val imageCount: Int |
|
val levels: Int |
|
val atlasEdge: Int |
|
val topPageWidth: Int |
Width of a top-level page. For a film set, the source frame's width exactly. |
val topPageHeight: Int |
Height of a top-level page. Equal to topPageWidth for the square default. |
val pageWidths |
Page width at this level: the top page halved once per level down, never below 1. |
val pageHeights |
|
val pagesPerRow |
Pages across one atlas layer at this level. At least 1, even for a page wider than a layer. |
val pagesPerColumn |
Pages down one atlas layer at this level. |
val pagesPerLayer |
|
val layersForLevel |
|
val layerBase |
First atlas array layer belonging to this level. |
val pageCapacity |
Total pages this level can hold at once. |
val identityLevel |
True when this level can hold every image, so slot == image index. |
val totalLayers: Int |
|
fun pageWidth(level: Int): Int |
|
fun pageHeight(level: Int): Int |
|
fun pageBytes(level: Int): Int |
Bytes one page at this level occupies as RGBA8 — the cache's stride and the upload's size. |
val bytesPerLayer: Long |
Bytes one atlas layer occupies as RGBA8. |
val atlasBytes: Long |
Total atlas allocation in bytes. |
fun layerOf(level: Int, slot: Int): Int |
The atlas array layer holding this level's slot. |
fun pageX(level: Int, slot: Int): Int |
Pixel column of this slot's page within its layer. |
fun pageY(level: Int, slot: Int): Int |
Pixel row of this slot's page within its layer. |
fun ceilingFor(wantCounts: IntArray): Int |
The highest level worth serving, given what the shader asked for and how many pages exist. |
fun encode(level: Int, slot: Int, averageColor: Int, usedWidth: Int, usedHeight: Int, fade: Int = FADE_FULL): IntArray |
One page table entry, as the four uints the shader unpacks. |
fun encodeAbsent(averageColor: Int): IntArray |
An entry for an image with no page yet: mean colour only. |
fun decode(entry: IntArray, at: Int = 0): Entry |
Read an entry back. The inverse of encode, and the Kotlin twin of what the GLSL does, so a test can assert that a page put in a slot comes back out as the same page, in the same layer, with the same aspect, without a GPU. |
data class Entry(val resident: Boolean, val pageX: Int, val pageY: Int, val layer: Int, val level: Int, val averageColor: Int, val usedWidth: Int, val usedHeight: Int, val fade: Int = FADE_FULL) |
|
override fun toString(): String |
megaTextureShader.kt
GLSL snippets that let a box's own shaders sample a MegaTexture.
megaTextureTemplate returns the slots (declarations, functions, fragment, vertex, all)
to interpolate into a shader template; the box writes the geometry and the draw itself.
src/jvmMain/kotlin/afield/graphics/texture/mega/megaTextureShader.kt — read the source
Top-level functions
textures.kt
Sampled textures for Field 4 shaders: images decoded from disk or memory, CPU-streamed frames, and GPU-writable storage images.
LoadedTexture decodes a PNG/JPEG (via STB) or an EXR (via tinyexr) into a Vulkan image with a
mip chain and a default TextureSampler; build one with LoadedTexture.fromFile,
LoadedTexture.fromExrFile, LoadedTexture.fromEncodedMemory or LoadedTexture.fromRawRGBA.
StreamingTexture re-uploads a CPU ByteBuffer each frame it is asked to, and
MappedMonochromeTexture uploads a single-channel image from a shared, header-prefixed mapped
buffer only when its frame counter changes. StorageImage is a texture a compute shader writes.
src/jvmMain/kotlin/afield/graphics/texture/textures.kt — read the source
LoadedTexture
Sampled textures for Field 4 shaders: images decoded from disk or memory, CPU-streamed frames, and GPU-writable storage images.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
The renderer whose device holds the image and whose command pool records the upload. |
val fileName: String |
What the texture was loaded from — a path for a file, or the synthetic name a generated or streamed texture was given. Its last path segment, with the size, is the Vulkan debug label on the image and its view. |
var height |
Height of mip 0, in pixels, taken from the decoded image. |
lateinit var image: Image |
|
override lateinit var imageView: ImageView |
|
var mipLevels |
How many mip levels the image has: the number the decoded source already carried, or else the requested count, which a request of zero or less turns into the full chain for the longest axis. |
var width |
Width of mip 0, in pixels, taken from the decoded image. |
var recordedTransition: Boolean |
Whether the upload and the layout transition into shader-read have been recorded. The recording is gated on it, so the copy happens once however often a frame asks for the texture. |
var stgBuffer: VulkanBuffer? |
The host-visible staging buffer holding the decoded pixels, the whole mip chain one level after another, until the copy into the image is recorded. It stays allocated after that; cleanupStgBuffer releases it. |
override var defaultSampler: TextureSampler? |
|
fun setDefaultSampler(anisotropyEnable: Boolean = true, filter: Int = VK10.VK_FILTER_LINEAR, mipmapMode: Int = if (filter == VK10.VK_FILTER_LINEAR) VK10.VK_SAMPLER_MIPMAP_MODE_LINEAR else VK10.VK_SAMPLER_MIPMAP_MODE_NEAREST, addressModeU: Int = VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, addressModeV: Int = VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, addressModeW: Int = VK10.VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE): TextureSampler |
|
fun repeatU(anisotropyEnable: Boolean = true, filter: Int = VK10.VK_FILTER_LINEAR, mipmapMode: Int = if (filter == VK10.VK_FILTER_LINEAR) VK10.VK_SAMPLER_MIPMAP_MODE_LINEAR else VK10.VK_SAMPLER_MIPMAP_MODE_NEAREST): TextureSampler |
|
fun cleanup() |
|
fun cleanupStgBuffer() |
|
override fun prepareSampledImagesFor(command: CommandBuffer) |
|
fun recordTextureTransition(cmd: CommandBuffer) |
|
fun transferNow() |
StreamingTexture
Extremely simple streaming texture: call recordUpload every frame.
| Declaration | Summary |
|---|---|
val source: ByteBuffer |
The buffer each upload reads from, by default: width * height * bytesPerPixel bytes from its current position, which the upload duplicates rather than consumes, so the position is where you left it. Writing into it between uploads is how the texture changes. |
override fun render(command: CommandBuffer, index: Int) |
|
fun recordUpload(cmd: CommandBuffer, src: ByteBuffer = source) |
|
fun uploadNow(src: ByteBuffer = source) |
Convenience for when you don't already have a frame command buffer. |
MappedMonochromeTexture
Streaming texture backed by a mapped shared-memory buffer with a small header: [frame counter (uint64), width (int32), height (int32)] followed by width*height bytes of R8 data.
| Declaration | Summary |
|---|---|
override fun render(command: CommandBuffer, index: Int) |
|
fun recordUploadIfChanged(cmd: CommandBuffer): Boolean |
Returns true if a new frame was uploaded. |
fun uploadNowIfChanged(): Boolean |
Convenience when you don't already have a frame command buffer. |
StorageImage
| Declaration | Summary |
|---|---|
val renderer: Renderer |
The renderer whose device allocates the image. |
val width: Int |
Width of the image, in pixels. |
val height: Int |
Height of the image, in pixels. |
val imageFormat: Int |
The image's VK_FORMAT_*, which a shader writing it has to declare to match. |
lateinit var image: Image |
|
lateinit override var imageView: ImageView |
|
var recordedTransition |
Whether the one transition this image needs, from undefined into the general layout a shader reads and writes it in, has been recorded. |
fun recordTextureTransition(cmd: CommandBuffer) |
|
fun transitionNow() |
|
fun cleanup() |
AudioDecoder.kt
Decodes an audio stream with FFmpeg into signed 16-bit PCM AudioFrames for AudioPlayer.
src/jvmMain/kotlin/afield/graphics/texture/video/AudioDecoder.kt — read the source
AudioDecoder
class AudioDecoder(private val audioPath: String)
AudioDecoder wraps FFmpeg functionality to decode audio streams. Decodes audio to PCM format (signed 16-bit) suitable for OpenAL playback.
| Declaration | Summary |
|---|---|
var audioStreamIndex: Int |
Index of the stream open decodes, or -1 before a successful open. open takes the first stream the container reports as audio, and fails if there is none. |
var sampleRate: Int |
Sample rate of the source stream in Hz, which need not be the rate of the frames you get: open resamples to the rate it was asked for, and AudioFrame.sampleRate reports that one. |
var channels: Int |
Channel count of the source stream, which is likewise the source's and not the output's: open converts to the count it was asked for, and AudioFrame.channels reports that one. |
var duration: Double |
Length of the audio in seconds, taken from the stream header or, when the stream declares none, from the container's own duration. 0.0 when neither declares one. |
fun open(outputSampleRate: Int = 44100, outputChannels: Int = 2): Boolean |
Open the audio file and initialize decoder. |
fun decodeFrame(): AudioFrame? |
Decode the next audio frame. |
fun seek(timestamp: Double): Boolean |
Seek to a specific timestamp in the audio stream. |
fun close() |
Close the decoder and free resources. |
AudioFrame
Data class representing a decoded audio frame in PCM format.
| Declaration | Summary |
|---|---|
val data: ByteBuffer |
Interleaved PCM samples, signed 16-bit, in native byte order. The decoder owns this buffer and refills it on the next AudioDecoder.decodeFrame, so queue or copy the samples before decoding again. |
val samples: Int |
Samples per channel in data, which therefore holds this many times channels values. |
val sampleRate: Int |
Sample rate of data in Hz, which is the rate asked of AudioDecoder.open rather than the source's own AudioDecoder.sampleRate. |
val channels: Int |
Channel count of data, likewise the count asked of AudioDecoder.open: 1 mono, 2 stereo. |
val timestamp: Double |
When the first sample is heard, in seconds on the source stream's own timeline. |
AudioPlayer.kt
Streams PCM audio through OpenAL by queuing a fixed ring of buffers, and reports the playback
position that AudioClock uses to keep video in sync.
src/jvmMain/kotlin/afield/graphics/texture/video/AudioPlayer.kt — read the source
AudioPlayer
class AudioPlayer(private val sampleRate: Int = 44100, private val channels: Int = 2)
AudioPlayer manages OpenAL audio playback for synchronized video/audio. Supports streaming audio via buffer queuing for low-latency playback.
| Declaration | Summary |
|---|---|
var volume: Float |
Gain on the OpenAL source: 0 for silence, 1 for the decoded level, and values outside that range clamped into it. Setting it before initialize is not lost, since initialize applies whatever it holds to the source it creates. |
fun initialize(): Boolean |
Initialize OpenAL context and resources. |
fun queueBuffer(audioData: ByteBuffer, numSamples: Int = audioData.remaining() / (channels * 2)): Boolean |
Queue an audio buffer for playback. |
fun queueAudioFrame(audioFrame: AudioFrame): Boolean |
Queue an AudioFrame for playback (convenience method). |
fun processBuffers() |
Process finished buffers and return them to the free pool. Also updates timing information. |
fun play() |
Start audio playback. |
fun pause() |
Pause audio playback. |
fun stop() |
Stop audio playback and clear all queued buffers. |
fun getCurrentTime(): Double |
Get current playback time in seconds. Tracks total samples played across all buffers for accurate timing. |
fun getQueuedBufferCount(): Int |
Get number of buffers currently queued. |
fun getProcessedBufferCount(): Int |
Get number of buffers processed (ready to be unqueued). |
fun isCurrentlyPlaying(): Boolean |
Check if audio is currently playing. |
fun getFreeBufferCount(): Int |
Get number of free buffers available for queuing. |
fun cleanup() |
Clean up OpenAL resources. |
FrameSetBarrier.kt
Holds back presentation of a frame set until every member is ready, so several textures switch frames together.
Ordinary clock ticks cannot replace an unfinished set; a new seek or reconfiguration revision
can. FrameSetBarrierTest covers it.
src/jvmMain/kotlin/afield/graphics/texture/video/FrameSetBarrier.kt — read the source
FrameSetBarrier
class FrameSetBarrier<K : Any>
Nonblocking two-phase presentation. Ordinary clock ticks cannot supersede an unfinished set (which would starve a slow decoder); an explicit seek/reconfiguration revision can. Call from one render thread. K must be an immutable source-frame identity.
| Declaration | Summary |
|---|---|
var pending: K? |
The frame identity select is holding back, or null when nothing is waiting. It survives ordinary clock ticks, and is cleared by presentIfReady, by revise with a new revision, or by reset. |
var presented: K? |
The frame identity the set is showing, or null before the first one. presentIfReady moves pending here once its upload callback has returned, so this names images the caller has committed to rather than ones it has asked for. |
fun select(desired: K, revision: Long): K? |
|
fun revise(revision: Long) |
Cancel stale pending work even when a paused pacer has no replacement key to select. |
fun presentIfReady(ready: (K) -> Boolean, upload: (K) -> Unit): Boolean |
No upload callback runs until the whole set is ready. Commit only after uploads succeed. |
fun reset() |
FrameSetPacer.kt
Paces an asynchronous frame set so each frame gets a whole source-frame interval to prepare before it is due.
A slow preparation stays the pending demand even when the wall clock has moved past it, which
keeps a slow decoder from being starved; seeks and reconfiguration may still replace it.
FrameSetBarrierTest exercises it alongside FrameSetBarrier.
src/jvmMain/kotlin/afield/graphics/texture/video/FrameSetPacer.kt — read the source
FrameSetPacer
class FrameSetPacer(private val frameCount: Int)
Gives an asynchronous frame set its whole display interval in which to prepare.
| Declaration | Summary |
|---|---|
data class Demand(val frame: Int, val notBeforeNanos: Long, val generation: Long) |
|
val generation: Long |
Counter incremented whenever the pacer throws away its pending demand: on a new revision, on a change of cadence, and on reset. Every FrameSetPacer.Demand carries the value current when it was made, so work begun for an older generation can be recognised and dropped. |
val pendingFrame: Int? |
Index of the frame select is currently asking for, or null when nothing is outstanding. markPresented clears it. |
fun select(requestedFrame: Int, playing: Boolean, sourceFramesPerSecond: Double, loop: Boolean, revision: Long, nowNanos: Long): Demand? |
|
fun markPresented(frame: Int, nowNanos: Long) |
|
fun reset() |
PlaybackClock.kt
Defines PlaybackClock, the time source VideoTexture reads to choose a frame, with two
implementations: SystemClock (a monotonic clock with play, pause and seek) and AudioClock
(time taken from an AudioPlayer).
src/jvmMain/kotlin/afield/graphics/texture/video/PlaybackClock.kt — read the source
PlaybackClock
Time source consumed by VideoTexture when selecting the frame for the current update.
| Declaration | Summary |
|---|---|
fun getCurrentTime(): Double |
Get the current playback time in seconds. |
fun isPlaying(): Boolean |
Check if the clock is currently active/playing. |
SystemClock
class SystemClock : PlaybackClock
Monotonic process clock with explicit play, pause, seek, and reset state.
| Declaration | Summary |
|---|---|
override fun getCurrentTime(): Double |
|
override fun isPlaying(): Boolean |
|
fun play() |
|
fun pause() |
|
fun seek(timestamp: Double) |
|
fun reset() |
AudioClock
class AudioClock(private val audioPlayer: AudioPlayer) : PlaybackClock
Delegates time and playing state to AudioPlayer's processed-sample and OpenAL-offset clock.
| Declaration | Summary |
|---|---|
override fun getCurrentTime(): Double |
|
override fun isPlaying(): Boolean |
VideoDecoder.kt
Decodes video frames with FFmpeg into P010 or RGBA8 buffers (VideoFrame) for VideoTexture.
VideoDecodeFormat picks the output: P010 for ordinary display, RGBA8 when the R/G/B channels
are values that 4:2:0 chroma subsampling would destroy. VideoFrameTest covers VideoFrame
buffer release.
src/jvmMain/kotlin/afield/graphics/texture/video/VideoDecoder.kt — read the source
VideoDecoder
VideoDecoder wraps FFmpeg functionality to decode video frames. Converts decoded frames either to P010 for ordinary video display or to tightly-packed RGBA8 for data-bearing RGB movies. The latter matters for atlases whose R/G/B channels are values rather than colour: routing those through 4:2:0 YUV would irreversibly subsample the channels.
| Declaration | Summary |
|---|---|
val outputFormat: VideoDecodeFormat |
Pixel layout decodeNextFrame produces: P010 for display video, RGBA8 for a movie whose R/G/B channels carry numbers rather than colour. Fixed at construction, because open configures the FFmpeg scaler for it. |
var videoStreamIndex: Int |
Index of the stream open decodes, or -1 before a successful open. open takes the first stream the container reports as video, and fails if there is none. |
var width: Int |
Frame width in pixels, read from the stream's codec parameters by open, and 0 until then. |
var height: Int |
Frame height in pixels, read from the stream's codec parameters by open, and 0 until then. |
var duration: Double |
Length of the video stream in seconds: its declared duration scaled by its time base, and 0.0 until open reads the header. A container that declares no duration stores a sentinel that is scaled like any other number, so check for a positive value before trusting it. |
var fps: Double |
Average frame rate the stream declares, in frames per second, and 0.0 when the container gives none. VideoTexture falls back to a 0.033 s frame interval rather than dividing by a non-positive rate. |
fun open(): Boolean |
Open the video file and initialize decoder. |
fun seek(timestampSeconds: Double): Boolean |
Seek to a specific timestamp in seconds. |
fun decodeNextFrame(): VideoFrame? |
Decode the next frame in outputFormat. Plane buffers are tightly packed even when FFmpeg's working frame has aligned row padding, so Vulkan uploads never need to know the source stride. |
override fun close() |
Close the decoder and free resources. |
VideoDecodeFormat
Constants: P010, RGBA8.
VideoFrame
A decoded upload frame. P010 uses both planes; RGBA8 uses plane0 and leaves plane1 empty. The owner must close the frame after upload or when superseding it so its large backing buffers can be reused without waiting for garbage collection.
| Declaration | Summary |
|---|---|
val width: Int |
Frame width in pixels, the VideoDecoder.width of the decoder that produced it. |
val height: Int |
Frame height in pixels, the VideoDecoder.height of the decoder that produced it. |
val format: VideoDecodeFormat |
Which planes carry data: P010 fills both, RGBA8 fills plane0 and leaves plane1 empty. |
val plane0: ByteBuffer |
Luma samples for a P010 frame, or the whole image for an RGBA8 one. Rows are tightly packed however much padding FFmpeg's working frame carried, so an upload needs no source stride. |
val plane1: ByteBuffer |
Interleaved chroma samples at half the width and half the height for a P010 frame, and an empty buffer for an RGBA8 one. |
val plane0Stride: Int |
Bytes per row of plane0: twice the width for P010 luma, four times for RGBA8. Rows are packed, so this is the row width rather than a padded pitch. |
val plane1Stride: Int |
Bytes per row of plane1, which for P010 is the same as plane0Stride because a chroma row holds half as many samples at twice the size. 0 for an RGBA8 frame. |
val timestamp: Double |
When this frame is shown, in seconds: FFmpeg's best-effort timestamp scaled by the stream's time base. VideoFrameIndex.decoded turns it into a frame number. |
val lumaData: ByteBuffer |
plane0 under its P010 name. For an RGBA8 frame this is the colour image, not luma. |
val chromaData: ByteBuffer |
plane1 under its P010 name, so it is the empty buffer for an RGBA8 frame. |
val lumaStride: Int |
plane0Stride under its P010 name. |
val chromaStride: Int |
plane1Stride under its P010 name, so it is 0 for an RGBA8 frame. |
override fun close() |
VideoFrameIndex.kt
Frame-number arithmetic for constant-frame-rate video, shared by the code that asks for a frame and the code that reports which frame a decoder produced.
A timeline position belongs to the frame whose interval contains it, so VideoFrameIndex.requested
floors seconds * fps. A decoded presentation timestamp marks a frame's own start, which may
carry rounding error in either direction, so VideoFrameIndex.decoded rounds to the nearest frame.
src/jvmMain/kotlin/afield/graphics/texture/video/VideoFrameIndex.kt — read the source
VideoFrameIndex
CFR frame identity: timeline positions floor; decoded presentation timestamps identify centres.
| Declaration | Summary |
|---|---|
fun requested(seconds: Double, fps: Double): Long |
The frame shown at timeline position seconds: floor of seconds * fps (tolerating 1e-9 of floating-point noise at a boundary), with negative times clamped to frame 0. |
fun decoded(seconds: Double, fps: Double): Long |
The frame a decoder's presentation timestamp seconds identifies: seconds * fps rounded to the nearest frame. |
VideoTexture.kt
Plays a video file into Vulkan textures that a shader can sample, with VideoTexture.load,
VideoTexture.play, VideoTexture.pause, VideoTexture.seek and VideoTexture.unload.
Frames come from VideoDecoder as P010 (luma and chroma planes) for display video, or as RGBA8
for movies whose channels carry data. Timing comes from a PlaybackClock: an internal
SystemClock by default, or an AudioClock for audio-locked playback.
src/jvmMain/kotlin/afield/graphics/texture/video/VideoTexture.kt — read the source
VideoTexture
VideoTexture manages video playback with P010 format support. Maintains configurable texture slot arrays for luma and chroma planes in semi-planar format. Supports load, play, scrub, and unload operations while reusing Vulkan resources when dimensions match.
| Declaration | Summary |
|---|---|
val pixelFormat: VideoDecodeFormat |
P010 for display video; RGBA8 for movies whose RGB channels carry numeric data. |
val backgroundDecode: Boolean |
Decode and colour-convert on a worker thread, leaving the render callback only the Vulkan upload; with it off, update decodes inline on whichever thread calls it. It also enables prepareFrame, and opens a second decoder so that a short loop does not pay for an end-to-start seek each time around. |
fun sampleFrom(source: VideoTexture?) |
|
val sampledFrom: VideoTexture |
The texture whose images imageViewArray hands out: this one, unless sampleFrom has pointed it at another already-loaded VideoTexture of matching format and size. Only the descriptor array follows the redirection, so getLumaTexture, timestamp and the rest still report this texture's own state. |
override fun browsable(): Browsable? |
|
var backgroundDecodeMicros: Long |
Microseconds the worker's most recent VideoDecoder.decodeNextFrame took, for watching whether decoding is keeping up. It reports the last frame rather than an average, and only backgroundDecode playback writes it. |
fun load(videoPath: String): Boolean |
Load a video file. If dimensions match the previously loaded video, reuses Vulkan resources. |
fun play() |
Start or resume playback. |
fun pause() |
Pause playback. |
fun seek(timestampSeconds: Double): Boolean |
Seek to a specific timestamp in seconds. |
fun update(commandBuffer: CommandBuffer): Boolean |
Update the video texture. Call this every frame to decode and upload new frames when playing. Returns true if a new frame was uploaded. |
fun getLumaTexture(): HasImageView? |
Get the current luma texture. |
fun getChromaTexture(): HasImageView? |
Get the current chroma texture. |
override val imageViewArray: List<HasImageView> |
Get both current textures as an array (luma at index 0, chroma at index 1). |
val width: Int |
Width in pixels of the loaded video, and 0 with nothing loaded. cleanup returns it to 0; unload leaves it, because the Vulkan textures stay allocated for the next video of the same size. |
val height: Int |
Height in pixels of the loaded video, and 0 with nothing loaded, on width's terms. |
val duration: Double |
Length of the loaded video in seconds, read from VideoDecoder.duration, and 0.0 with nothing loaded. |
val fps: Double |
Frame rate the loaded video declares, and 0.0 with nothing loaded. update and prepareFrame use it to turn a clock time into a frame number, and prepareFrame refuses to run while it is 0. |
val timestamp: Double |
Presentation time in seconds of the frame most recently uploaded, which is not the time the clock is asking for. seek moves it to the requested position before any frame has been decoded there. |
val playing: Boolean |
True between play and pause. play does nothing without a loaded video, and running off the end of the video loops back to the start rather than clearing this. |
val hasFrame: Boolean |
True after at least one decoded frame has reached its Vulkan image. |
var uploadedFrameIndex: Long |
Source-frame identity of the image selected for the next draw, not the requested clock. |
fun prepareFrame(frameIndex: Long) |
Prepare a CFR source frame off-thread without uploading it. Do not also schedule update. |
fun isFrameReady(frameIndex: Long): Boolean |
|
fun uploadPreparedFrame(commandBuffer: CommandBuffer, frameIndex: Long): Boolean |
Record this exact image's upload before the same draw as its matching geometry. Never waits. |
fun unload() |
Unload the current video and free resources. |
fun cleanup() |
Clean up all resources including Vulkan textures. |
override fun render(command: CommandBuffer, index: Int) |
WebcamTexture.kt
Exposes a webcam as an RGBA Vulkan texture, captured through OpenCV.
The first frame is uploaded on open so WebcamTexture.imageView is valid immediately; later
frames are grabbed on a background thread and uploaded from the render thread.
src/jvmMain/kotlin/afield/graphics/texture/video/WebcamTexture.kt — read the source
WebcamTexture
Webcam-backed RGBA texture for Vulkan sampling.
| Declaration | Summary |
|---|---|
val width: Int |
Width in pixels of the frames the camera is actually delivering, which need not be the size this texture asked for. 0 until the camera opens, and 0 again after cleanup; stop leaves it, since the last frame stays in the texture. |
val height: Int |
Height in pixels of the delivered frames, on width's terms. A camera that changes resolution part way through is not followed: those frames are dropped, one warning is logged, and the texture keeps the size it opened at. |
val active: Boolean |
True while the capture thread is grabbing frames: set when the camera opens, cleared by stop and cleanup, and cleared as well if the capture loop throws. A false value after frames had been arriving means capture has stopped, and latestFrameId still names the last frame that got through. |
val latestFrameId: Long |
Monotonic id of the most recently captured frame, or 0 before the first. |
fun withLatestFrame(action: (rgba: java.nio.ByteBuffer, width: Int, height: Int, frameId: Long) -> Unit): Boolean |
Read the newest frame on the CPU, without a GPU round trip. |
override val imageView: ImageView |
|
override fun render(command: CommandBuffer, index: Int) |
|
override fun close() |
|
fun start(): WebcamTexture |
|
fun stop() |
|
fun cleanup() |
|
fun update(command: CommandBuffer): Boolean |
Uploads the newest captured frame, if any. Returns true when a new frame reached the texture. |
override fun browsable(): Browsable? |
GeometryView.kt
Showing a geometry { } recipe in a studio-lit three view, and handing the recipe language to
this document's AI4 chat.
Box.geometry runs a GeometryBuilder block in a box and draws the GeometryScene it builds:
every part as a mesh with its own material, the scene's background and ground, and the toolbox's
three-quarter camera. Box.animateGeometry re-runs the block with GeometryBuilder.t moving and
returns a Frames for the box to launch. The camera is a spec rather than a position, so it
survives both a re-run and an animation frame and the model can be orbited while it changes
underneath.
geometryAgent bridges those views to AI4. The chat agent is given GEOMETRY_GUIDE before every
turn and a geometry.look tool that returns a picture of a view as it stands on screen, which is
what lets a model edit a recipe and then see the result. A view that is not open is drawn by the
CPU rasterizer instead, without the studio's lighting, and the agent is told which it got.
src/jvmMain/kotlin/afield/graphics/three/GeometryView.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun ThreeSceneBuilder.geometry(scene: GeometryScene) |
Draw a GeometryScene in this view: every part as a mesh with its own material, the studio look, the scene's background, and the toolbox's three-quarter camera. |
fun Box.geometry(channel: String = "main", name: String = "geometry", height: Int = 460, width: Int? = 680, block: GeometryBuilder.() -> Unit): GeometryScene |
Run a GeometryBuilder recipe and show the result in a studio-lit three view. |
fun Box.animateGeometry(fps: Double = 30.0, channel: String = "main", name: String = "geometry", height: Int = 460, width: Int? = 680, block: GeometryBuilder.() -> Unit): Frames |
Run a GeometryBuilder recipe fps times a second with GeometryBuilder.t moving, and show each result in the same studio-lit three view: the toolbox's Animate button. |
fun Box.geometryAgent() |
Bridge this document's geometry boxes to AI4: the chat agent learns the GeometryBuilder language (GEOMETRY_GUIDE, before every turn) and gets a geometry.look tool that returns a picture of a geometry view as it is on screen — the toolbox's screenshot-to-code loop, with Field's own chat. /geometry in the chat shows the guide. |
Top-level values
| Declaration | Summary |
|---|---|
val GEOMETRY_GUIDE: String |
The GeometryBuilder language, written for a model: what the madp toolbox's Copy Guide button put on the clipboard. geometryAgent gives it to this document's AI4 chat before every turn. |
SimplexView.kt
An orbitable picture of a handful of embeddings and the distances between them, in a box's output.
SimplexView wraps one of the three viewers and draws what the madp tetra page draws: a
coloured sphere per point, an edge per pair, the point numbers and edge lengths written onto the
picture, and a table of the pairs in the corner. simplexView opens one and hands it back, so
SimplexView.show can replace the simplex underneath without resetting the camera you orbited
to; showSimplex is the one-call form, for a picture that is drawn once. Before the first
simplex arrives the view holds a regular tetrahedron with no lengths on it, and SimplexView.dim
greys the current picture while a newer one is being worked out.
What is drawn is an EmbeddingSimplex, which places the vectors in 3D so that the drawn edges
are their pairwise distances: exactly so for four points or fewer, a best fit above that. The
note in the corner says which of the two you are looking at, and quotes the stress when it is
the second.
showSimplex(embeddingSimplex(listOf(clipEncode("cat"), clipEncode("dog"), clipEncode("car"))))
src/jvmMain/kotlin/afield/graphics/three/SimplexView.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.simplexView(channel: String = "simplex", name: String = "simplex", height: Int = 520, width: Int? = 820, note: String = "waiting for current embeddings"): SimplexView |
Open a SimplexView in this box's output: an orbitable picture of a few embeddings and the distances between them. It starts as the madp page's empty tetrahedron, saying note. |
fun Box.showSimplex(simplex: EmbeddingSimplex, colors: List<String> = SimplexView.SIMPLEX_COLORS, flags: Boolean = false, channel: String = "simplex", name: String = "simplex"): SimplexView |
Draw simplex in this box's output in one call: simplexView then SimplexView.show. For a picture that changes, keep the SimplexView and call show on it instead, so the view is not reopened (and its camera reset) each time. |
SimplexView
An EmbeddingSimplex drawn as the madp tetra page draws it: a coloured sphere per point, an edge per pair, the points' numbers and the edges' lengths written on the picture, a table of the pairs with bars in the corner, and a slow turn until you take hold of it.
| Declaration | Summary |
|---|---|
val view: ThreeGeometryViewState |
The live three view underneath, for anything this class does not do. |
fun show(simplex: EmbeddingSimplex, colors: List<String> = SIMPLEX_COLORS, flags: Boolean = false, table: Boolean = true, note: String? = null) |
Draw simplex, replacing whatever the view showed. |
fun dim(note: String = "diagram out of date") |
Grey out the picture on screen, for while a newer one is being worked out, with note in the corner. The next show brings it back. |
SolidInspection.kt
The three things the solid inspector measures off a part's boundary: which edges are worth drawing, which triangles belong to the same face, and one string that identifies the whole set of parts.
A boundary arrives as a SolidPlanarMesh whose triangles carry the diagonals its triangulator
needed and the coplanar edges its Boolean history left behind, so drawing every edge buries the
shape under both. solidOutlineEdges keeps an edge unless exactly two triangles meet along it and
those two are one flat face: normals parallel and facing the same way, and each triangle's
vertices within tolerance of the other's plane. An edge where some other number of triangles meet,
or where a triangle is too degenerate to have a normal, stays visible, so an open surface keeps
its perimeter rather than reading as a closed solid. The tolerance is the mesh's own
nativeTolerance or sixty-four ulps of the part's largest coordinate, whichever is larger, so a
part modelled in metres and the same part in millimetres get the same answer.
solidPartTopology groups the triangles into faces by merging across every edge that is not one
of those creases. It keeps the canonical double-precision positions and the original triangle
order, because a pick is resolved against this copy rather than against the render mesh, whose
vertices shading has already split.
solidArtifactDigest hashes part names, positions, indices and faces in sorted name order, so the
same boundaries hash the same however the parts were collected. That digest is the geometry
identity a review note is pinned to, which is what lets a note outlive a relaunch.
src/jvmMain/kotlin/afield/graphics/three/SolidInspection.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun solidArtifactDigest(parts: Map<String,SolidPlanarMesh>): String |
Exact boundary identity, including part names and topology; independent of input map order. |
SolidParts.kt
A set of named solid parts, shown in a Three.js scene as something to inspect rather than
something to look at. One call inside a three { … } block lays out every part as its own
pickable mesh, draws each one's creases and open boundaries over it as dark lines, and installs
the inspector: units, the explode slider, per-part visibility, assembly motions and the paths a
part came from.
A part arrives as a planar mesh whose triangles carry the diagonals the triangulator needed and the coplanar edges Boolean history left behind, so drawing every edge would bury the shape under both. What is drawn instead is the geometric outline: creases between faces that meet at a real angle, and boundaries where a part ends. Edges between separate parts stay visible, since those are where the assembly is.
Coordinates stay canonical throughout. Positions reach the GPU as Floats relative to one shared
render origin, and upAxis = ThreeUpAxis.Z is a rigid display rotation applied on the way out, so
neither large coordinates nor a Z-up convention changes the double-precision data the inspector
reports or the digest that identifies the artifact.
src/jvmMain/kotlin/afield/graphics/three/SolidParts.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun ThreeSceneBuilder.solidParts(parts: Map<String,SolidPlanarMesh>, origin: Point3D = Point3D.ORIGIN, assembly: List<ThreeAssemblyMotion> = emptyList(), review: ThreeReviewState? = null, explode: ThreeExplode = ThreeExplode(), units: String = "mm", upAxis: ThreeUpAxis = ThreeUpAxis.Y, partPaths: Map<String,List<String>> = emptyMap(), panel: Int = 0) |
Draw canonical double boundaries relative to one render origin. Boolean geometry is never read back from these Float buffers. Lines follow geometric creases and open boundaries within each part; coplanar construction boundaries and triangulation diagonals are omitted. Separate parts retain their edges. Z-up is an optional rigid display rotation (x,z,-y); canonical data and artifact identity stay unchanged. An overlay uses the same part ID/group as its solid, so visibility and supplied motions agree. panel is the viewport that draws the parts in a multi-panel scene; -1 draws them in every panel, which is how a sheet of close-ups shows one assembly framed differently in each panel (panel(bounds = …)). |
Three.kt
One 3D output for everything Field can draw: Box.three puts meshes and FLines in one orbitable
Three.js scene.
Box.three extends Box.threeLines to meshes, and lays several views out side by side when you ask
for a contact sheet:
three(channel = "car", height = 460) {
mesh(car, wireframe = false, pickable = true)
lines(measurements) // annotations in the same space as the solid
inspect(units = "mm") // …and a ruler that reaches both
camera(yaw = 34.0, pitch = 26.0, distance = 2.4)
title("ToyCar.glb — 108,936 triangles")
}
With pickable = true a click on a mesh resolves to a vertex, an edge or a triangle
(ThreeSurfaceHit), so the measure tool can span a solid and its annotations.
Compared with the CPU rasterizer
The CPU rasterizer in VisionGeometryPreview still draws a mesh to a static PNG, and remains the
tool for value-visualizer thumbnails and pixel-exact tests such as MeshDefectRenderTest, since it
is deterministic. It cannot orbit, or put a solid and its FLine annotations in one scene.
Shading differs
The shading models differ. The rasterizer is two-light Lambert over vertex colours; this is
three.js's MeshStandardMaterial under the viewer's ambient/key/rim rig. The same mesh will not
come out pixel-identical. The input is held identical (see toThreeDrawable), as is back-face
culling, so a face wound the wrong way is missing from both.
src/jvmMain/kotlin/afield/graphics/three/Three.kt — read the source
Used in: Three.js scenes with three { … }, Geometry from disk, Polytopes: topology first, coordinates later
Top-level functions
ThreeSceneBuilder
class ThreeSceneBuilder internal constructor(private val samplesPerCubic: Int)
The receiver of a three { … } block: collects meshes, lines, panels and camera settings for one view.
| Declaration | Summary |
|---|---|
fun helpers(on: Boolean) |
Whether the reference grid and axis tripod are drawn. |
fun mesh(mesh: TriMesh, label: String? = null, wireframe: Boolean = true, cullBackFaces: Boolean = true, flatShading: Boolean = false, opacity: Double = 1.0, panel: Int? = null, id: String = "", onClick: ((ThreeSurfaceHit) -> Unit)? = null, hoverColor: Vec4? = null, pickable: Boolean = false, group: Int = -1, unlit: Boolean = false, depthWrite: Boolean = opacity >= 1.0, explode: FloatArray? = null, solidOpacity: Double? = null, edges: Boolean = false, edgeColor: Vec4? = null, material: ThreeSurfaceMaterial? = null) |
Add a solid. |
fun tools(vararg modules: String) |
Install optional viewer tools from same-origin ES-module paths. Each module exports a default factory returning a tool descriptor. Modules run with the viewer's privileges; supply only trusted application code. A saved review never imports a module on its own. |
fun lines(lines: Collection<FLine>) |
Add FLines. They keep every behaviour they have in Box.threeLines — picking, hover, fills. |
fun lines(vararg lines: FLine) |
Vararg convenience for lines. |
fun camera(yaw: Double = 28.0, pitch: Double = 18.0, distance: Double = 2.7, fieldOfView: Double = 40.0, padding: Double = 1.06, upAxis: ThreeUpAxis = ThreeUpAxis.Y, follow: Boolean = false) |
Where the camera starts. Setting it turns auto-framing off — you picked an angle, keep it. |
fun camera(view: MeshView, padding: Double = 1.06) |
The camera as a MeshView already describes it — the migration path for existing figures. |
fun followCamera(point: afield.math.linalg.Vec3, halfLifeSeconds: Double = .4, epoch: String = "default", horizontalOnly: Boolean = true) |
Ease along a world point. Half the remaining distance is covered in halfLifeSeconds. Changing epoch translates immediately across a reset; orbit, pan and zoom remain yours. horizontalOnly ignores travel along the camera's up axis, keeping jumps from bobbing the view. |
fun studio(ground: Boolean = true, grid: Boolean = true, groundY: Double? = null, groundColor: String = "#08090a", shadows: Boolean = true, exposure: Double = 1.0, ambientOcclusion: Boolean = true) |
Light the scene like a studio: ACES tone mapping, a shadow-casting key light with a hemisphere fill and a cool rim, ambient occlusion, and a dark ground plane (with a faint grid) that catches the shadows. |
fun label(at: Vec3, text: String = "", style: String = "tag", color: String? = null, background: String? = null, border: String? = null, title: String? = null) |
Pin a piece of text to at: it follows the point as the view turns, and is page text over the canvas rather than a texture, so it stays sharp. It is kept inside the view, sliding along an edge rather than leaving by it, and is hidden behind the camera. |
fun overlay(html: String, corner: String = "top-right") |
Fix an HTML block to a corner of the view — a legend, a table, a key. It does not move with the camera and does not take the pointer, so the view still orbits underneath it. The HTML is inserted as written, as display(...) output is. |
fun dimmed(on: Boolean = true) |
Grey the scene and its labels out: the picture is older than what it shows, and a newer one is on its way. Corner overlays and the note stay as they are. |
fun note(text: String) |
A short line of text in the bottom-left corner of the view: what state the picture is in. |
fun hud(on: Boolean) |
Show or hide the viewer's own controls — X-ray, Export GLB, Pick, the parts list, the triangle count. Off for a view that is a picture to look at, whose corners are its own (overlay, note). Orbiting, zooming and the turntable work either way. |
fun background(css: String) |
Scene background as a CSS colour. |
fun linkCamera(group: String = "default") |
Open the view with Link camera already on, so it orbits, pans and zooms with the other linked views of group in this document — one box per view, or several channels of one box. |
fun turntable(degreesPerSecond: Double = 6.0, resumeAfterSeconds: Double = 2.0) |
Turn the view slowly about its up axis, for a page that is looked at rather than used. |
fun shot(periodSeconds: Double = 24.0, keys: List<ThreeShotKey> = ThreeShotSpec.MONEY_SHOT, resumeAfterSeconds: Double = 2.0) |
Play a looping presentation move: the camera dollies in and out while the model explodes and its surfaces go see-through, on one clock for every panel of the view. |
fun panel(title: String? = null, caption: String? = null, facts: String? = null, camera: ThreeCameraSpec? = null, bounds: ThreeBounds? = null, fitBounds: Boolean = false) |
A titled viewport. Call it once per panel, in order; columns lays them out. A mesh that names no panel goes into the panel declared most recently. |
fun columns(n: Int) |
Panels per row. |
fun title(text: String) |
A single title over a one-panel scene. |
fun caption(text: String) |
A single caption under a one-panel scene. |
fun inspect(units: String = "", decimals: Int = 2, explode: ThreeExplode = ThreeExplode(), section: ThreeSection = ThreeSection(), measurements: List<ThreeMeasurement> = emptyList(), pickToMeasure: Boolean = true, groups: List<String> = emptyList(), artifactDigest: String? = null, renderOrigin: DoubleArray = doubleArrayOf(0.0, 0.0, 0.0), renderUpAxis: ThreeUpAxis = ThreeUpAxis.Y, assembly: List<ThreeAssemblyMotion> = emptyList(), review: ThreeReviewState? = null, partPaths: Map<String, List<String>> = emptyMap()) |
Turn the measure / section / explode toolbar on for this scene. |
ThreeMeshPanel
One cell of a threeGrid: a mesh, its heading, and whatever you want written under it.
| Declaration | Summary |
|---|---|
val label: String |
|
val mesh: TriMesh |
|
val facts: String? |
Written under the panel in monospace. Plain text: it is set as textContent, not as HTML. |
val caption: String? |
ThreeJsInspector.kt
Measure, section and explode controls for the Three.js viewer, so a 3D preview of something to be built can be inspected without re-running its box.
The inspector answers three questions:
- how far apart are these two things:
ThreeMeasurement, drawn in the scene as a dimension with witness lines and a legible number; - what does this look like in section:
ThreeSection, a cut plane with a highlighted cut outline, not merely a hidden front half; - how does it come apart:
ThreeExplode, a parameter with a scrubber, in two modes.
Where the work happens
The displacement, the cut and the closest approach are computed in the viewer
(threejs_viewer.html), so moving a slider costs no websocket round-trip and no box re-run. Each
of the three is a marked // [viewer-region …] region of that HTML, which
ThreeInspectorMeasureDifferentialTest, ThreeInspectorExplodeDifferentialTest and
ThreeInspectorSectionDifferentialTest lift out and run on GraalJS against expectations derived
from the figure geometry.
The regions cover the geometry only. The three.js plumbing around them (localClippingEnabled,
the canvas-texture sprite the dimension text is drawn into, the HUD sliders) is not under those
tests, so an empty dimension sprite would leave them green.
src/jvmMain/kotlin/afield/graphics/three/ThreeJsInspector.kt — read the source
Used in: FLine examples (Field graphics), Three.js scenes with three { … }, Point to geometry, keep the context
Top-level functions
ThreeExplode
How the viewer pushes a figure's parts apart; amount 0.0 is the assembled state.
| Declaration | Summary |
|---|---|
val amount: Double |
How far apart to push the groups, in the figure's own units. |
val mode: String |
"radial" — every group slides directly away from the bounds centre. Reads well on an irregular assembly, which is what a routed conduit run is. |
val axis: FloatArray |
|
val sliderMax: Double |
The top of the viewer's own scrubber. 0.0 means "work it out from the bounds", which is what a caller who has not thought about it wants; a caller who has can pin it so that two figures side-by-side scrub at the same rate. |
override fun toString(): String |
ThreeSection
A cut plane through the figure.
| Declaration | Summary |
|---|---|
val enabled: Boolean |
|
val normal: FloatArray |
|
val fraction: Double |
|
val outline: Boolean |
|
val flip: Boolean |
Which half survives. Flipping is not the same as negating normal: fraction keeps its sense. |
override fun toString(): String |
ThreeMeasureElement
data class ThreeMeasureElement(val kind: String, val points: List<FloatArray>)
One end of a measurement: a point, a segment or a polygon, in the figure's own coordinates.
| Declaration | Summary |
|---|---|
val kind: String |
|
val points: List<FloatArray> |
|
override fun toString(): String |
ThreeMeasurement
A dimension the figure carries from the moment it is drawn, rather than one the user has to pick.
| Declaration | Summary |
|---|---|
val a: ThreeMeasureElement |
|
val b: ThreeMeasureElement |
|
val label: String |
Prepended to the number. "clear " gives clear 2.5 mm. |
ThreeAssemblyMotion
data class ThreeAssemblyMotion(val part: String, val displacement: DoubleArray)
A supplied, independently checked insertion motion, from displaced pose to assembled pose.
ThreeUpAxis
Canonical up axis; Z-up parts are rotated into the viewer's Y-up display frame.
Constants: Y, Z.
ThreePartTopology
Canonical double coordinates and real boundary edges for artifact-local feature references. Triangle order agrees with the display mesh even when shading duplicates its vertices. faces groups connected coplanar triangles; Boolean provenance edges are not selectable seams.
| Declaration | Summary |
|---|---|
val positions: DoubleArray |
|
val indices: IntArray |
|
val edges: List<List<Int>> |
|
val faces: IntArray |
ThreeFeatureSelection
A selection on one exact artifact. Interpolated points locate a pick, not a machining tolerance.
ThreeRegionSelection
A view-space rectangle and the frontmost surface triangles it actually covered.
ThreeRegionSurface
data class ThreeRegionSurface(val partId: String, val triangles: List<Int>)
Artifact-local visible triangles belonging to one part in a rectangular review region.
ThreeReviewState
Saved observations are valid only for the exact artifact digest. Face ordinals are artifact-local.
ThreeInspector
The inspector block of a geometry payload: what the viewer should be doing beyond drawing.
ThreeInspectorView
A live inspector view: the geometry, plus the three controls, plus the ability to change them.
ThreeJsViewer.kt
The Three.js viewer's server side: the geometry payload it is sent, the live state of each view,
and Box.threeLines, which puts a viewer in a box's output area.
A view is an iframe on /threejs that orbits, picks and edits the geometry it is given. The
payload types (ThreeGeometryPayload, ThreeGeometryData, ThreeAttribute, ThreeIndex,
ThreeBounds) mirror three.js BufferGeometry, plus the editable and interactive records a click
or a drag reports back. Each view has one ThreeGeometryViewState: call its update to push new
geometry to every connected viewer. installThreeJsHandler answers the viewers' websocket
messages, and edits, surface clicks and review notes return to the box through it.
src/jvmMain/kotlin/afield/graphics/three/ThreeJsViewer.kt — read the source
Used in: Three.js scenes with three { … }, FLine examples (Field graphics)
Top-level functions
ThreeAttribute
| Declaration | Summary |
|---|---|
val itemSize: Int |
|
val array: FloatArray |
|
val type: String |
|
val normalized: Boolean |
|
override fun toString(): String |
ThreeIndex
| Declaration | Summary |
|---|---|
val type: String |
|
val array: IntArray |
|
override fun toString(): String |
ThreeGeometryData
| Declaration | Summary |
|---|---|
val attributes: Map<String, ThreeAttribute> |
|
val index: ThreeIndex? |
|
override fun toString(): String |
ThreeBounds
data class ThreeBounds(val min: FloatArray, val max: FloatArray)
| Declaration | Summary |
|---|---|
val min: FloatArray |
|
val max: FloatArray |
|
override fun toString(): String |
ThreeMeta
data class ThreeMeta(val lineSegments: Int = 0, val triangles: Int = 0, val points: Int = 0)
| Declaration | Summary |
|---|---|
val lineSegments: Int |
|
val triangles: Int |
|
val points: Int |
|
override fun toString(): String |
ThreeEditableNode
| Declaration | Summary |
|---|---|
val nodeIndex: Int |
|
val kind: String |
|
val to: FloatArray? |
|
val c1: FloatArray? |
|
val c2: FloatArray? |
ThreeEditableLine
| Declaration | Summary |
|---|---|
val lineIndex: Int |
|
val color: FloatArray |
|
val thickness: Float |
|
val stroked: Boolean |
|
val filled: Boolean |
|
val pointed: Boolean |
|
val pointSize: Float |
|
val callbackId: String |
|
val hoverColor: FloatArray? |
|
val nodes: List<ThreeEditableNode> |
|
val label: String? |
The line's FLine.label, so an editable figure's hits name themselves too. |
val topology: String |
flineTopologyJson for this line — the same string the non-editable path sends. |
ThreeEditableScene
data class ThreeEditableScene(val sessionId: String, val lines: List<ThreeEditableLine>)
| Declaration | Summary |
|---|---|
val sessionId: String |
|
val lines: List<ThreeEditableLine> |
ThreeInteractiveObject
Geometry + interaction metadata for a single interactive FLine inside the Three.js viewer.
| Declaration | Summary |
|---|---|
val callbackId: String |
Server-side callback ID. Empty when this object only has hover (no click). |
val hoverColor: FloatArray? |
Optional hover override colour as [r, g, b, a] each in [0, 1]. |
val lines: ThreeGeometryData? |
|
val fills: ThreeGeometryData? |
|
val points: ThreeGeometryData? |
|
val vertices: List<ThreeInteractiveVertex> |
|
val lineIndex: Int |
Index of this FLine among the lines the viewer was given, reported back as lineIndex. |
val group: Int |
Which assembly group this whole object belongs to, or -1 when the payload has no inspector. |
val label: String? |
The line's FLine.label, reported back with a hit so one closure can serve a whole figure. |
val topology: String |
The line's segments and closed subpaths as flineTopologyJson writes them — the same string the interactive SVG puts in data-fline-elements, so the two clickable outputs cannot describe the same line differently. Only their picking differs, which it must: one has a click in a plane, the other a raycast hit that already knows whether it landed on a line, a fill or a point. |
override fun toString(): String |
ThreeInteractiveVertex
data class ThreeInteractiveVertex(val index: Int, val position: FloatArray)
| Declaration | Summary |
|---|---|
val index: Int |
|
val position: FloatArray |
ThreeSurfaceClick
A click on a surface drawable, as the viewer posts it — the sibling of FLineClick.
| Declaration | Summary |
|---|---|
val callbackId: String |
|
val id: String |
ThreeDrawable.id. |
val label: String? |
|
val drawableIndex: Int |
|
val kind: String |
vertex, edge, triangle, or empty for the classifier's refusal. |
val x: Double |
|
val y: Double |
|
val z: Double |
|
val faceIndex: Int |
|
val vertexIndex: Int |
|
val edgeIndex: Int |
|
val u: Double? |
|
val v: Double? |
|
val w: Double? |
|
val ex: Double? |
The point on the named element. |
val ey: Double? |
|
val ez: Double? |
|
val frontFacing: Boolean |
|
val triangle: List<Double> |
The hit triangle's nine ordinates, [ax, ay, az, bx, …], or empty. |
ThreeGeometryPayload
| Declaration | Summary |
|---|---|
val type: String |
|
val uid: String |
|
val revision: String? |
|
val sourceBox: String? |
|
val cameraLinkScope: String? |
Server-owned document identity for optional camera linking; not geometry identity. |
val lines: ThreeGeometryData? |
|
val fills: ThreeGeometryData? |
|
val points: ThreeGeometryData? |
|
val bounds: ThreeBounds? |
|
val meta: ThreeMeta? |
|
val autoFrame: Boolean |
|
val interactive: List<ThreeInteractiveObject> |
Per-FLine interactive objects (separate geometry with click/hover callbacks). |
val scene: ThreeEditableScene? |
|
val editableEnabled: Boolean |
|
val inspector: ThreeInspector? |
Measure / section / explode, when the payload came from Box.threeInspect. |
val drawables: List<ThreeDrawable> |
The scene as ThreeDrawables — geometry plus per-drawable material state. |
val toolModules: List<String> |
Explicit same-origin ES modules installed as optional viewer tools; notes cannot load modules. |
val panels: List<ThreePanel> |
The viewports, when this is a contact sheet. Empty or one-element means a single full-bleed view. |
val columns: Int |
Panels per row, when there is more than one. |
val background: String? |
Scene background as a CSS colour, or null for the viewer's transparent default. |
val helpers: Boolean |
Draw the reference grid and the axis tripod. |
val frameWidth: Int? |
The frame size this figure wants, in CSS pixels, for the viewer to report to its host. |
val frameHeight: Int? |
|
val camera: ThreeCameraSpec? |
Where the camera starts, when the caller picked an angle rather than accepting autoFrame. |
val cameraLink: ThreeCameraLinkSpec? |
Join the document's camera link on load, in this group. See ThreeSceneBuilder.linkCamera. |
val cameraFollow: ThreeCameraFollowSpec? |
Smoothed translation following a world point, independent of orbit and zoom. |
val turntable: ThreeTurntableSpec? |
Turn slowly about the up axis until somebody interacts. See ThreeSceneBuilder.turntable. |
val shot: ThreeShotSpec? |
A looping dolly, explode and x-ray move. See ThreeSceneBuilder.shot. |
val studio: ThreeStudioSpec? |
Tone mapping, soft shadows and a ground. See ThreeSceneBuilder.studio. |
val partAppearance: Map<String, ThreePartAppearance> |
The view's stored part styles, by ThreeDrawable.id, for the viewer to apply. |
val labels: List<ThreeLabel> |
Text pinned to points in the scene. See ThreeSceneBuilder.label. |
val overlays: List<ThreeOverlay> |
HTML blocks in the corners of the view. See ThreeSceneBuilder.overlay. |
val dimmed: Boolean |
Grey the scene out, for a picture that is older than its inputs. See ThreeSceneBuilder.dimmed. |
val note: String? |
A short line in the bottom-left corner. See ThreeSceneBuilder.note. |
val hud: Boolean |
Show the viewer's tool buttons and counts; false for a picture. See ThreeSceneBuilder.hud. |
override fun toString(): String |
ThreeGeometryViewState
class ThreeGeometryViewState(val uid: String)
Server-side state for a live Three.js geometry viewer.
| Declaration | Summary |
|---|---|
fun reviews(includeResolved: Boolean = false): kotlinx.serialization.json.JsonObject |
Most recent human selection and unresolved notes. Set includeResolved for completed notes and their history. |
fun attach(session: WebSocketSession, buffers: Boolean = false) |
Attach a websocket session to this viewer and replay the latest payload. |
fun update(payload: ThreeGeometryPayload) |
Push a new payload to all attached viewers. |
fun styleParts(expectedRevision: String, styles: Map<String, ThreePartAppearance>, replace: Boolean = false) |
Persist display styles on this view's source box and send them to connected viewers. |
val hasViewer: Boolean |
Whether a browser viewer is attached, so snapshot has someone to ask. |
suspend fun snapshot(timeoutMs: Long = 8000): ByteArray? |
A PNG of the view as a person sees it — the viewer's own canvas, drawn with its camera where the person left it — or null when no viewer is attached or none answers within timeoutMs. |
fun replayTo(session: WebSocketSession) |
Send the last payload to a newly-attached global listener (replay on subscribe-all). |
override fun toString(): String |
ThreePartAppearance.kt
Presentation overrides for a named part of a three-dimensional view.
Colours are RGB in 0..1, and a part with no x-ray opacity of its own follows the viewer's slider. None of it changes geometry, picking, assembly groups or review identity: the styles travel beside the geometry rather than baked into it, so restyling a part leaves every note on it attached.
src/jvmMain/kotlin/afield/graphics/three/ThreePartAppearance.kt — read the source
ThreePartAppearance
One part's overrides, as the viewer receives them.
ThreeReview.kt
Geometry identity for a three-dimensional view: what a review note is pinned to.
A note survives anything that is only presentation. Callback and session IDs, hover colour, style and label are left out of the digest, so orbiting, relighting and relaunching identical geometry all leave a note attached. Canonical solids keep their double-boundary identity, and geometry displayed alongside them contributes too: a moving ordinary point invalidates a note even when every solid is unchanged.
src/jvmMain/kotlin/afield/graphics/three/ThreeReview.kt — read the source
ThreeReviewEvidence.kt
Stores the screenshots a geometry review note carries as PNG files beside the document rather than as base64 inside it.
The browser sends a context image and a crop of it, each as a data URL, with the viewport and crop
rectangle they were taken from. Kept in the note, those would put megabytes of base64 into a text
property that is saved, versioned and diffed on every edit. store writes each image into the
document's .geometry-review directory under its own SHA-256 and returns the note's metadata with
a /three-review-image URL in place of the bytes, so a second note of the same pixels adds a
reference and no file.
The validation is what makes those files safe to serve back. The images have to agree with the
note about geometry digest, view and panel; the payload has to be a PNG by signature and by
decoding, at the dimensions the browser declared, within 1600 px for the context image and 1024 px
for the crop; no image may exceed two million bytes and the directory may not exceed sixty-four
million. read answers only for a 64-character hex name that one of the notes it was handed
actually cites, so the route cannot be talked into returning a path of the caller's choosing or an
image belonging to another view.
src/jvmMain/kotlin/afield/graphics/three/ThreeReviewEvidence.kt — read the source
ThreeScene.kt
The intermediate representation the Three.js viewer draws: a ThreeDrawable is some geometry plus
how to draw it.
FLines lower to drawables, TriMeshes lower to drawables, and anything with a toTriMesh()
reaches the viewer that way, so a mesh can be orbited and share a scene with its FLine
annotations. The viewer knows neither source type; it draws drawables.
The geometry is a ThreeGeometryData, a three.js BufferGeometry of named attributes and an
index, and TriMesh's positions, indices, normals and colours transcribe into it directly. What a
drawable adds is per-drawable material state, ThreeDrawStyle.
ThreeGeometryPayload's lines, fills and points are unchanged by drawables: a payload with
an empty ThreeGeometryPayload.drawables serialises as a lines-only payload always has, which the
editable path and the inspector rely on.
src/jvmMain/kotlin/afield/graphics/three/ThreeScene.kt — read the source
Used in: Three.js scenes with three { … }
Top-level functions
ThreeDrawStyle
How one ThreeDrawable is drawn: which three.js object class, and its material settings.
| Declaration | Summary |
|---|---|
val kind: String |
"surface", "lines" or "points" — which three.js object class draws this. |
val doubleSided: Boolean |
Draw back faces as well as front ones. |
val depthWrite: Boolean |
Write to the depth buffer. |
val wireframe: Boolean |
Depth-tested wireframe over the shading. Surfaces only; ignored for lines and points. |
val wireframeColor: FloatArray? |
Wireframe colour as [r, g, b, a] in [0, 1], or null for the viewer's default. |
val flatShading: Boolean |
Shade per triangle rather than per vertex: faceted, so a coarse mesh reads as coarse. |
val opacity: Float |
Multiplied into the per-vertex alpha. |
val unlit: Boolean |
Display data colours without lighting modulation. |
val xrayOpacity: Float? |
Optional display opacity multiplier in X-ray mode; null follows the viewer slider. |
val solidOpacity: Float? |
The opacity a ThreeSceneBuilder.shot draws this surface at when the shot's x-ray amount is 0; at 1 it is opacity. null keeps opacity throughout. Lets a see-through view start solid. |
val material: ThreeSurfaceMaterial? |
A physically based material for a surface: base colour, roughness, metalness and emission. null, the default, is the viewer's fixed roughness 0.6 / metalness 0.1 white, which every figure drawn before this existed still gets. afield.geometry.mesh.GeometryBuilder.mat sets it. |
override fun toString(): String |
ThreeDrawable
One thing to draw: geometry, how to draw it, and where it sits.
| Declaration | Summary |
|---|---|
val geometry: ThreeGeometryData |
|
val style: ThreeDrawStyle |
|
val label: String? |
Reported back with a hit, and shown in the panel chrome when there is one. |
val group: Int |
Assembly group for the inspector's explode, or -1 when this payload has no inspector. |
val transform: FloatArray? |
16 floats, column-major, as three.js Matrix4.fromArray wants them. null is the identity. |
val panel: Int |
Which viewport in a multi-panel scene draws this. 0 when there is one panel. |
val callbackId: String |
Server-side click callback ID, or empty for a drawable that is not clickable. |
val hoverColor: FloatArray? |
Hover override as [r, g, b, a] in [0, 1]. |
val topology: String |
flineTopologyJson, for a drawable that came from an FLine and wants element-level hits. |
val id: String |
A stable name for this drawable, echoed back on every hit as ThreeSurfaceHit.id. |
val pickable: Boolean |
Whether a raycast may hit this drawable at all. |
override fun toString(): String |
ThreeSurfaceHitKind
enum class ThreeSurfaceHitKind
Which element of a surface a click landed on.
Constants: VERTEX, EDGE, TRIANGLE, UNKNOWN.
ThreeSurfaceHit
Where a click landed on a solid — the sibling of afield.graphics.render.FLineIntersection.
| Declaration | Summary |
|---|---|
val id: String |
ThreeDrawable.id, or empty when the author did not name the drawable. |
val label: String? |
ThreeDrawable.label. |
val drawableIndex: Int |
The drawable's ordinal in the payload — the sibling of FLineIntersection.lineIndex. |
val kind: ThreeSurfaceHitKind |
Which part of the triangle was hit: a corner, an edge, the interior, or ThreeSurfaceHitKind.UNKNOWN. |
val position: Vec3 |
Where the ray met the surface, in the scene's own coordinates. |
val onElement: Vec3? |
The point on the named element: the corner for a ThreeSurfaceHitKind.VERTEX, the foot of the perpendicular for an ThreeSurfaceHitKind.EDGE, the hit itself for the interior. |
val faceIndex: Int |
Which triangle of the mesh, counting from zero — the sibling of elementIndex. |
val vertexIndex: Int |
The mesh's own vertex index for a ThreeSurfaceHitKind.VERTEX hit, else -1. |
val edgeIndex: Int |
Which of the triangle's three edges, 0 = (a,b), 1 = (b,c), 2 = (c,a). -1 otherwise. |
val u: Double |
Barycentric weight of the triangle's first corner. |
val v: Double |
Barycentric weight of the second corner. |
val w: Double |
Barycentric weight of the third corner. u + v + w == 1. |
val frontFacing: Boolean |
Whether the ray met the triangle's front face — its winding normal pointing back towards the camera. false means you clicked the inside of the model, which is either a deliberate look through an open shell or the thing that makes a mesh appear to have holes. Nothing in the FLine record corresponds, because an FLine fill is a sheet and has no inside. |
val triangle: List<Vec3> |
The three corners of the hit triangle, in the mesh's winding order. |
override fun toString(): String |
ThreeCameraSpec
Where the camera stands, in the terms MeshView and VisionGeometryPreview.Camera already use.
| Declaration | Summary |
|---|---|
val yawDegrees: Double |
Orbit around +y, in degrees. 0 looks straight down -z. |
val pitchDegrees: Double |
Lift above the horizon, in degrees. Positive looks down on the geometry. |
val distance: Double |
Eye distance in multiples of the bounding-sphere radius, as the rasterizer (MeshView) uses it. The Three.js viewer fits the shape to the view instead and ignores it; its framing is padding. |
val fieldOfViewDegrees: Double |
Vertical field of view in degrees. |
val padding: Double |
The Three.js viewer's fit margin around the shape's on-screen outline: 1.0 touches the edges, the default 1.06 leaves a little air. The rasterizer ignores it. |
val upAxis: ThreeUpAxis |
Which world axis is up. ThreeUpAxis.Z keeps the viewer's grid, which lies in the x–y plane, as the floor: yaw turns about +z, pitch lifts toward +z, and yaw 0 looks along +y. The yaw/pitch mapping is the y-up one, rotated rigidly, so ThreeCameraPresetParityTest still covers it. |
val follow: Boolean |
Track the geometry: on every update the camera moves by however far the centre of the bounds moved, keeping whatever orbit and zoom the viewer has, and the grid shifts by whole cells so the floor reads as fixed while the subject crosses it. |
ThreeCameraFollowSpec
Follow a world point by translating eye and orbit target together; the human keeps orbit and zoom.
| Declaration | Summary |
|---|---|
val target: List<Double> |
|
val halfLifeSeconds: Double |
|
val epoch: String |
|
val horizontalOnly: Boolean |
ThreeCameraLinkSpec
data class ThreeCameraLinkSpec(val group: String = "default")
A view that opens with Link camera already on. Written by ThreeSceneBuilder.linkCamera.
| Declaration | Summary |
|---|---|
val group: String |
Views link only with views of the same group in the same document. |
ThreeTurntableSpec
A slow automatic orbit about the view's up axis. Written by ThreeSceneBuilder.turntable.
| Declaration | Summary |
|---|---|
val degreesPerSecond: Double |
Positive turns the model counter-clockwise seen from above. |
val resumeAfterSeconds: Double |
How long the turn stays paused after the last interaction. |
ThreeSurfaceMaterial
A surface's MeshStandardMaterial settings, sent with a ThreeDrawStyle.
| Declaration | Summary |
|---|---|
val color: FloatArray |
|
val roughness: Float |
|
val metalness: Float |
|
val emissive: FloatArray |
|
val emissiveIntensity: Float |
|
override fun equals(other: Any?): Boolean |
|
override fun hashCode(): Int |
ThreeStudioSpec
The studio look: tone mapping, soft shadows, a dark ground and a faint grid, for a scene that is a sculpture on a plinth rather than a technical figure. See ThreeSceneBuilder.studio.
ThreeLabel
A piece of text pinned to a point in the scene: it follows the point as the view orbits, and is drawn over the canvas as ordinary page text, so it stays sharp at any size. See ThreeSceneBuilder.label.
ThreeOverlay
data class ThreeOverlay(val html: String, val corner: String = "top-right")
An HTML block fixed to one corner of the view. See ThreeSceneBuilder.overlay.
| Declaration | Summary |
|---|---|
val html: String |
The block's HTML. It is inserted as is, so it is trusted as display(...) output is. |
val corner: String |
"top-right", "top-left", "bottom-right" or "bottom-left". |
ThreeShotKey
One moment of a ThreeShotSpec: where in the loop it is, and how the view looks there. Between two keys every value eases (smoothstep) from one to the other.
ThreeShotSpec
A looping camera and model move for presentation. Written by ThreeSceneBuilder.shot.
ThreePanel
One viewport in a multi-panel scene, with its own camera and its own caption.
| Declaration | Summary |
|---|---|
val camera: ThreeCameraSpec? |
|
val title: String? |
|
val caption: String? |
|
val facts: String? |
The computed fact line — counts, extent, topology. Rendered in monospace under the panel. |
val bounds: ThreeBounds? |
What this panel frames, when it differs from the scene's overall bounds. |
val fitBounds: Boolean |
Frame bounds itself rather than the panel's geometry. The viewer normally fits the camera to the mesh's own points, which is what a contact sheet wants; a size comparison wants every panel framed at one scale, so threeGrid(sharedScale = true) sets this. |
ThreeWire.kt
What a Three.js view puts on the wire, and what it refuses to.
A view's geometry goes out in two parts. The websocket carries a description: the payload with
every numeric array replaced by a reference into a buffer table, so the message stays small however
large the scene is. The arrays themselves are one binary response from
GET /three-buffers?uid=…&generation=…, written straight out of the payload's own arrays, and only
when a viewer asks for them. Nothing is formatted as decimal text, nothing the size of the scene is
allocated, and the bytes go out on a Ktor IO thread rather than on the runloop.
ThreeWireBuffers is the table a description carries and ThreeWireBuffer one entry in it.
A geometry over AFIELD_THREE_PAYLOAD_LIMIT_MB, 512 MB by default, is not sent at all:
ThreePayloadTooLargeException names the largest contributors and the view keeps what it was
already showing. That ceiling is for the browser, which has to hold the buffers and then copy them
to the GPU; the server streams arrays it is holding anyway.
A client that does not ask for buffers in its hello, as the native visionOS viewer does not, is
sent the FLine half of the payload as JSON numbers instead, the format it decodes, without the
drawables and solid topology it never reads. That form is encoded only while such a client is
connected. The viewer side of all this is the wire-payload region of threejs_viewer.html, and
ThreeGeometryWireTest holds the two halves against each other.
src/jvmMain/kotlin/afield/graphics/three/ThreeWire.kt — read the source
ThreePayloadTooLargeException
A Three.js view's geometry too large to send.
ThreeWireBuffers
Where a description's buffers are: fetch generation from /three-buffers, bytes long, and read each entry as ThreeWireBuffer.count little-endian elements of its type at its offset.
ThreeWireBuffer
data class ThreeWireBuffer internal constructor(val type: String, val offset: Long, val count: Int)
One typed array in a buffer response. Offsets are multiples of eight, so a Float64 view needs no copy.
VisionGeometryPreview.kt
See a Live Metal box's geometry { … } on your desk, without a headset.
What this proves, and what it does not
A // {{ visionmetal }} box has three parts, and only one of them can be checked away from the
device today. This previews the geometry part, and only that:
- The mesh is built by the real builder.
captureresolves the box source through the realKotlinJsHeadProfiles.visionMetalprofile, compiles it with the realKotlinToJsCompiler, and runs the emitted JavaScript undernodeagainsthostShim— a mirror ofLiveMetalJavaScript.swift. What gets captured is the byte-identical packed array the headset's Metal vertex buffer would receive. Nothing here reimplementsMeshBuilder; if the sharedVisionMetalKotlinJsbuilder source changes, this changes with it. - The shader is a stand-in. The box's MSL is not compiled, not translated and not simulated.
rendershades with a fixed two-light lambert over the vertex colours the builder wrote. So a preview answers "is my geometry the shape I meant?" — the other half of the question the Vision Geometry: Built-in Cube command answers. It cannot tell you your shader is right.
That boundary is repeated in the caption burnt into every rendered frame.
Typical use
val capture = VisionGeometryPreview.capture(box.code)
val png = VisionGeometryPreview.renderPng(capture.mesh())
display(png)
or, from a Live Metal box's own commands, the Preview Vision Geometry action.
src/jvmMain/kotlin/afield/graphics/visionos/VisionGeometryPreview.kt — read the source
VisionGeometryPreview
VisionGeometryPreviewBox.kt
The in-Field half of VisionGeometryPreview: turning "my mesh only exists in the headset" into a
picture in the box you wrote it in.
A Live Metal box's Kotlin never runs on the Mac — it compiles to JavaScript for the head — so its
output panel is normally empty of anything but log lines. Box.previewVisionGeometry fills that
gap: it takes the box's own source, runs it for real (see VisionGeometryPreview.capture) and
draws the resulting mesh straight into the box's output. One command, and your headset geometry is
on the desktop.
src/jvmMain/kotlin/afield/graphics/visionos/VisionGeometryPreviewBox.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.previewVisionGeometry(source: String? = null, camera: VisionGeometryPreview.Camera = VisionGeometryPreview.Camera(), style: VisionGeometryPreview.Style = VisionGeometryPreview.Style(), frames: List<VisionGeometryPreview.FrameTick> = emptyList(), channel: String = "output"): VisionGeometryPreview.Capture? |
Build this box's geometry { … } for real and draw it into the box's output panel. |
fun VisionGeometryPreview.CapturedMesh.toFLines(): List<FLine> |
The mesh's unique edges as FLines, in metres, for Field's own 3-D viewer. |
VisionMetal.kt
Live Metal: the channel that pushes a box's Metal program down to a visionOS head, which compiles and renders it there.
A // {{ visionmetal }} box is sent as a VisionMetalProgramPayload carrying any of four independently
revisioned parts — shader (VisionMetalShaderPart), script (VisionMetalScriptPart), uniform
names (VisionMetalUniformsPart) and geometry (VisionMetalGeometryPart) — so reloading one part
leaves the others resident on the head. VisionMetalProgramState holds one program's current
parts on this side; Box.visionMetalProgramOrCreate finds or makes the one for a box, and
installVisionMetalHandler registers the /ws handler that heads subscribe through and report
VisionMetalStatus back on.
Offline checks of the same program live in VisionMetalProgramCheck (uniform names) and
VisionGeometryPreview (the geometry part).
src/jvmMain/kotlin/afield/graphics/visionos/VisionMetal.kt — read the source
Top-level functions
Top-level values
| Declaration | Summary |
|---|---|
const val VISION_METAL_VERSION |
Wire version 2 splits the single monolithic program payload into four independently revisioned parts — shader, script, uniforms, geometry — so that reloading one does not disturb the others. Version 1 restarted the JavaScriptCore context, the geometry buffers and the frame clock on every shader edit; a v2 shader reload keeps all three resident. |
const val VISION_METAL_VERTEX_FLOATS |
Floats per wire vertex: four float4s — position, normal, colour, then uv plus two free per-vertex channels. This is exactly MemoryLayout<LiveMetalVertex>.stride / 4, so the native head copies the wire array straight into its vertex buffer. |
const val VISION_METAL_MAX_UNIFORMS |
Metal binds one argument-table slot per named uniform; the ABI reserves sixteen float4s. |
val visionMetalWireJson |
The wire encoder. encodeDefaults puts channel/type/version on every message, and spells absent parts as explicit nulls, which is how the head tells "unchanged" from "cleared". Public so tests can assert against the real bytes rather than a lookalike configuration. |
var Box.msl: String? |
Metal Shading Language source for a native visionOS head: both entry points (field_vertex and field_fragment) in one property, since Metal compiles them from one translation unit. |
val visionMetalExampleShader |
A complete hot-reloadable shader for the head's built-in cube; a live-coding starting point. |
val visionMetalExampleScript |
The raw-JavaScript companion to visionMetalExampleShader, for the JVM-side Box.visionMetal entry point. It only writes uniforms, so pair it with geometry = VisionMetalGeometryOrigin.BUILTIN (or with vertices/indices) to give the shader something to draw. Kotlin/JS boxes should use visionMetalExampleKotlinScript instead. |
val visionMetalExampleKotlinScript |
The Kotlin/JS companion to visionMetalExampleShader: builds its own geometry in the head, then drives u_phase from the compositor clock. This is the shape a // {{ visionmetal }} box takes. |
VisionMetalVertex
One authored vertex. position.w and normal.w are supplied by the packer, so the fourteen authored floats become the sixteen the head expects.
| Declaration | Summary |
|---|---|
val x: Float, val y: Float, val z: Float |
|
val nx: Float |
|
val r: Float |
|
val u: Float |
|
val extra0: Float |
VisionMetalGeometryOrigin
enum class VisionMetalGeometryOrigin
Who owns the vertex buffer currently resident in the head.
VisionMetalShaderPart
data class VisionMetalShaderPart(val revision: Int, val source: String)
Whatever the box's Kotlin/JS geometry { … } builder last uploaded across the JS bridge.
VisionMetalScriptPart
data class VisionMetalScriptPart(val revision: Int, val source: String, val launchable: Boolean)
VisionMetalUniformsPart
data class VisionMetalUniformsPart(val revision: Int, val names: List<String>)
Declared slot order. The head turns these into #define u_NAME frame.user[i](workspace.md#stdlib-kt--i) for the shader.
VisionMetalGeometryPart
| Declaration | Summary |
|---|---|
val revision: Int |
|
val origin: VisionMetalGeometryOrigin |
|
val vertices: FloatArray |
|
val indices: IntArray |
|
override fun equals(other: Any?): Boolean |
|
override fun hashCode(): Int |
VisionMetalProgramPayload
One program update. Absent parts are unchanged — the head keeps whatever it already has resident. A shader-only reload therefore carries shader and nothing else.
VisionMetalStatus
| Declaration | Summary |
|---|---|
val channel: String |
|
val type: String |
|
val version: Int |
|
val debug_uid: String |
|
val uid: String |
|
val revision: Int |
|
val phase: String |
|
val ok: Boolean |
|
val message: String |
|
val line: Int |
One-based line in the part named by phase, when the head could recover one. Metal compiler diagnostics carry user-relative lines because the head emits #line 1 after its preamble. |
VisionMetalProgramState
class VisionMetalProgramState(val uid: String)
A durable program resident in the native visionOS Metal renderer.
VisionMetalKotlinJs.kt
Kotlin/JS declarations for the JavaScriptCore runtime owned by Field4Vision's Live Metal renderer.
Runtime Kotlin/JS compilation currently accepts one source file, so this surface is appended to
the user's source rather than compiled as a separate library. Appending means compiler
diagnostics keep the user's original line numbers. completionDeclarations mirrors the same
public surface in JVM-analysis-safe Kotlin because the completion sidecar does not
load the Kotlin/JS compiler frontend.
This only describes values that really exist in LiveMetalJavaScript.swift. Two things cross that
bridge: named float4 uniform writes, and whole geometry uploads. Geometry is the head's own —
geometry { … } builds an indexed triangle mesh in JavaScriptCore and hands the packed arrays to
Metal in one call, so nothing per-vertex crosses the network or the bridge at frame rate.
MSL source and the declared uniform slot order still travel in the visionmetal/program payload:
the shader lives in the box's msl property, and slot order comes from the head marker's
uniforms= option.
src/jvmMain/kotlin/afield/graphics/visionos/VisionMetalKotlinJs.kt — read the source
VisionMetalKotlinJs
| Declaration | Summary |
|---|---|
const val surfaceId: String |
|
val declarations: String |
|
val completionDeclarations: String |
|
fun appendTo(userSource: String): String |
Add the external surface without changing any user-source line number. |
VisionMetalProgramCheck.kt
The offline half of "will this Live Metal program actually run?".
A box names a uniform in three places, and these checks catch them disagreeing on the Mac:
- the
// {{ visionmetal uniforms=… }}marker, which becomes the head's slot order; - the shader, which reads
u_NAME— the head generates#define u_NAME frame.user[i](workspace.md#stdlib-kt--i)from that list, so an undeclaredu_NAMEis a Metal compile error on the device; - the script, whose
uniform("name", …)writes land in a slot only if the name is declared — an undeclared write is dropped without a warning, and the shader just sees zero.
All three failures show up as "it doesn't work in the headset", minutes away from the edit that
caused them. Every one is decidable from the box's own text plus a real run of its script, which is
what this does. It is the uniform-shaped companion to
VisionGeometryPreview.CapturedMesh.windingAgreement.
Nothing here compiles MSL. These are the checks that need only the ABI the head documents, so they run wherever Field runs; the real Metal compile happens on the device.
src/jvmMain/kotlin/afield/graphics/visionos/VisionMetalProgramCheck.kt — read the source
VisionMetalProgramCheck
object VisionMetalProgramCheck
| Declaration | Summary |
|---|---|
data class Problem(val fatal: Boolean, val message: String) |
One thing that will go wrong, and whether it stops the program dead. |
fun problems(code: String, msl: String?, uniformsWritten: Collection<String> = emptyList()): List<Problem> |
Check a whole box. |
fun problems(declared: List<String>, msl: String?, uniformsWritten: Collection<String> = emptyList()): List<Problem> |
As problems, with the slot order supplied directly. |
fun declaredUniforms(code: String): List<String> |
The marker's uniforms= list, in slot order; empty when the box declares none. |
fun summarise(problems: List<Problem>): String? |
A one-line summary, or null when there is nothing to say. |
VisionPose.kt
The pose uplink: head and per-eye viewing geometry travelling from a visionOS head to this Field process, so that the Vulkan renderer on this machine can draw the frame the headset is about to display.
This runs opposite to Field's other visionOS channels. Live Metal (VisionMetalProgramState) pushes a program down
to the head and the head renders it; the RTT stream pushes encoded pixels down
(native/field-encode/WIRE_FORMAT.md). Neither tells this process where the wearer is looking, so
the streamed image can only ever be a world-locked surface — a plane in space. Head-locked,
full-field VR needs the pose to come up first, which is what this file receives.
Wire format (__visionpose__, version 1)
JSON over the existing /ws bus, device → here. Three message types:
type |
when | carries |
|---|---|---|
visionpose/hello |
once per connection | device, name, the head's view count and refresh rate |
visionpose/frame |
once per compositor frame | the pose, below |
visionpose/bye |
on a clean immersive-space exit | device |
A visionpose/frame is:
{"channel":"__visionpose__","type":"visionpose/frame","version":1,
"device":"<uuid>","frameIndex":1842,
"predictedDisplayTime":123456.789,"sentAt":123456.778,
"trackingValid":true,
"originFromDevice":[16 floats],
"views":[{"transform":[16 floats],"projection":[16 floats]}, …]}
Every matrix is 16 floats, column-major — m[column * 4 + row], which is both simd_float4x4's
own storage order on the device and Matrix4f's here, so the array crosses the wire untransposed.
originFromDevice is ARKit's DeviceAnchor.originFromAnchorTransform: the head's pose in the
world origin the head established. views[i](workspace.md#stdlib-kt--i).transform is CompositorServices'
LayerRenderer.Drawable.View.transform, i.e. device→eye, and views[i](workspace.md#stdlib-kt--i).projection is that
drawable's computeProjection(viewIndex:). Both timestamps are in the head's own clock (seconds,
LayerRenderer.Clock), so differences within one head are meaningful and comparisons against
this machine's clock are not — see VisionPoseFrame.receivedAtNanos.
Not solved here
- Prediction.
predictedDisplayTimeis when the head's own next frame displays, which is earlier than when a frame rendered here could arrive. Shifting the query forward by the measured render→encode→network→decode latency is left to the head; this file carries the timestamp unchanged so that shift is a one-line change there. - Transport. JSON over the WebSocket reuses the bus, the dev-certificate trust and the reconnect logic the other native clients already have, at the cost of TCP in the tracking path. A pose is ~700 bytes; at 90 Hz that is ~60 kB/s, which is not a reason to move it to UDP; a measured latency would be.
src/jvmMain/kotlin/afield/graphics/visionos/VisionPose.kt — read the source
Used in: Field on Apple Vision Pro
Top-level functions
| Declaration | Summary |
|---|---|
fun installVisionPoseHandler() |
|
fun visionPoseLatest(): VisionPoseFrame? |
The newest pose from any head, or null if none has ever arrived. |
fun visionPoseConnectedHeadCount(): Int |
Heads currently publishing pose. |
fun visionPosePingHeads(): Int |
Ask every attached head to echo a timestamp, so the two clocks can be related. |
fun visionPoseLatencyReport(pings: Int = 8, spacingMillis: Long = 150): String |
Measure the clocks, then print where the time goes. This is the one call to make on device. |
Top-level values
| Declaration | Summary |
|---|---|
const val VISION_POSE_CHANNEL |
|
const val VISION_POSE_VERSION |
Wire version 1. Bump when the shape changes; a head sending another version is dropped rather than half-read, so an old Field4Vision build reports as silence rather than as garbage poses. |
val visionPoseWireJson |
VisionPoseWireView
data class VisionPoseWireView(val transform: FloatArray, val projection: FloatArray)
Per-eye viewing geometry exactly as the head's compositor stated it.
| Declaration | Summary |
|---|---|
val transform: FloatArray |
device→eye, column-major. |
val projection: FloatArray |
The compositor's projection for this eye, column-major. |
override fun equals(other: Any?): Boolean |
|
override fun hashCode(): Int |
VisionPoseWireController
One tracked controller, as the head reports it.
| Declaration | Summary |
|---|---|
val chirality: String |
"left" | "right" | "unknown" — the head's held chirality, not the device's inherent one. |
val tracked: Boolean |
The accessory is present and its pose is being tracked. |
val originFromGrip: FloatArray |
world→grip, column-major. Grip, not aim: it is the pose a hand-held object should follow. |
val select: Boolean |
|
val trigger: Float |
|
val triggerPressed: Boolean |
|
val squeeze: Float |
|
val squeezePressed: Boolean |
|
val trackpadX: Float |
|
val trackpadY: Float |
|
val trackpadTouched: Boolean |
|
override fun equals(other: Any?): Boolean |
|
override fun hashCode(): Int |
VisionPoseWireFrame
One frame of head tracking, as it arrives. VisionPoseFrame is the form Field consumes.
| Declaration | Summary |
|---|---|
val channel: String |
|
val type: String |
|
val version: Int |
|
val device: String |
|
val frameIndex: Long |
|
val predictedDisplayTime: Double |
|
val sentAt: Double |
|
val trackingValid: Boolean |
|
val originFromDevice: FloatArray |
world→head (ARKit originFromAnchorTransform), column-major. |
val views: List<VisionPoseWireView> |
|
val controllers: List<VisionPoseWireController> |
Tracked controllers as of this pose. Additive and optional: a head that reports none, or an older head that does not know the field, simply yields no controllers. That is why adding this did not need a VISION_POSE_VERSION bump — on a JSON channel with ignoreUnknownKeys, an optional field is compatible in both directions. Bump the version only for a change that would misread an old message. |
override fun equals(other: Any?): Boolean |
|
override fun hashCode(): Int |
VisionPoseHello
A head announcing itself, so a listener can size buffers before the first frame arrives.
| Declaration | Summary |
|---|---|
val channel: String |
|
val type: String |
|
val version: Int |
|
val device: String |
|
val name: String |
|
val viewCount: Int |
|
val refreshHz: Double |
VisionEyeView
One eye, ready to render with.
| Declaration | Summary |
|---|---|
val viewMatrix: Matrix4f |
|
val projection: Matrix4f |
|
val tanLeft: Float |
|
val tanRight: Float |
|
val tanDown: Float |
|
val tanUp: Float |
|
override fun toString(): String |
VisionController
class VisionController(val hand: OpenXRHmdTarget.Hand?, val state: OpenXRHmdTarget.ControllerState)
One controller, ready to use.
| Declaration | Summary |
|---|---|
val hand: OpenXRHmdTarget.Hand? |
|
val state: OpenXRHmdTarget.ControllerState |
|
override fun toString(): String |
VisionPoseFrame
One arrived pose. Immutable: VisionPoseStream republishes a whole new frame rather than mutating the last one, so a renderer that captured VisionPoseStream.latest at the top of its frame keeps a consistent head and eyes all the way through it — the association between a pose and the image drawn from it is the whole point of the milestone.
| Declaration | Summary |
|---|---|
val device: String |
|
val frameIndex: Long |
|
val predictedDisplayTime: Double |
Head clock, seconds: when the head expects to display its own next frame. |
val sentAt: Double |
Head clock, seconds: when the head handed this to its socket. |
val receivedAtNanos: Long |
This machine's System.nanoTime on arrival. Not comparable with the two above. |
val trackingValid: Boolean |
|
val originFromDevice: Matrix4f |
world→head. |
val headPosition: Vector3f |
|
val headOrientation: Quaternionf |
|
val eyes: List<VisionEyeView> |
|
val controllers: List<VisionController> |
|
fun controller(hand: OpenXRHmdTarget.Hand): VisionController? |
The controller in hand, or null. Unknown-handed controllers never match. |
val sendLatencySeconds: Double |
Head clock, seconds: how long the head spent between sampling and sending. |
override fun toString(): String |
VisionPoseStream
Everything known about the heads currently reporting pose.
| Declaration | Summary |
|---|---|
var latest: VisionPoseFrame? |
|
val heads: MutableMap<String, VisionPoseHello> |
The most recent visionpose/hello, per device. |
var logHz: Double |
Print a pose summary at this rate. Zero is silent; the first pose of a run always prints one line whatever this says, because "did anything arrive at all" is the question a fresh headset raises and grepping for silence is not an answer. |
val frameCount: Long |
Poses accepted since startup. |
val regressedCount: Long |
Poses dropped for arriving out of order, rather than applied over a newer one. |
val rejectedCount: Long |
Messages on the channel that were not usable: wrong version, malformed, wrong shape. |
val intervalMillis: Double |
Smoothed arrival interval in milliseconds, an EWMA over the gaps between arriving frames. This is the rate the head is delivering, which need not be the rate it advertises. |
val connectedHeadCount: Int |
Heads that have said hello on a still-open session. |
fun onPose(listener: (VisionPoseFrame) -> Unit): VisionPoseStream |
|
fun removePoseListener(listener: (VisionPoseFrame) -> Unit) |
|
fun accept(text: String, session: WebSocketSession? = null): Boolean |
Decode and publish one message from the channel. The handler is a thin wrapper over this, so a test can drive the real path — decode, matrix composition, tangent extraction, statistics — without a socket. |
fun detach(session: WebSocketSession) |
Forget a head whose session has gone, called from the /ws teardown. |
fun summary(): String |
One line for a log, a box output or a smoke test. |
fun reset() |
Forget every pose, statistic, listener and connected head, so a test can start from an empty stream. |
VisionPoseLatency.kt
Where the time goes between a head moving and the resulting image reaching it.
This attributes latency to stages rather than reporting one number, because the plausible explanations for "it feels laggy" are spread across five machines' worth of stages, and two of those stages hold frames back in order to do their job: the receiver's jitter buffer and the producer's render fence.
Two clocks, and why the headline number needs neither reconciled
The head's clock (LayerRenderer.Clock) and this process's System.nanoTime are unrelated. That
would normally make one-way timings unmeasurable. It does not here, because of a property of the
wire we already have: a pose's predictedDisplayTime is in the head's clock, and the media wire
carries it back to the head as poseDisplayTimeNanos. So the head can subtract two of its own
timestamps and get motion-to-photon with no clock agreement at all. That is why the headline
figure is measured on the device and reported here, rather than computed here.
The offset is still wanted, for splitting the remainder into "our side" and "their side". It is
estimated NTP-style from PING/PONG round trips, keeping the sample with the smallest round
trip rather than an average: the minimum is the one least polluted by queuing in either direction,
and averaging RTTs on a contended Wi-Fi link estimates the contention, not the offset.
The two stages that hold frames back
The receiver runs a jitter buffer (RTTStream.bufferFrames) that holds frames so that
network jitter does not become dropped frames. It is reported as its own span. Without that split,
the buffer doing exactly its job looks like the network being slow, and the obvious "fix" —
shrinking it — trades smoothness for a better number.
Likewise SUBMIT_TO_ENCODED includes the render fence: the producer only signals a surface
once its GPU work has completed, so scene complexity lands in that span rather than anywhere else.
What this cannot see
The device's last observable moment is the decoded frame reaching the display texture. Actual photons are the compositor's business, up to one refresh later, and with the present path still on RealityKit there is no hook for it. So the headline is a lower bound, short by up to one compositor frame. That closes when the present path moves to Compositor Services, which is also what makes reprojection possible; it is not worth faking before then.
src/jvmMain/kotlin/afield/graphics/visionos/VisionPoseLatency.kt — read the source
VisionPoseLatency
| Declaration | Summary |
|---|---|
const val PING |
|
const val PONG |
|
const val REPORT |
|
const val POSE_TRANSIT |
Spans this process can time on its own clock. |
const val POSE_AGE_AT_LATCH |
|
const val LATCH_TO_SUBMIT |
|
const val SUBMIT_TO_ENCODED |
|
var clockOffsetNanos: Long |
head clock ≈ this clock + clockOffsetNanos. Zero until a pong lands; offsetKnown says which, because a zero offset is also a legitimate estimate and silently treating "unknown" as "aligned" would report host-side spans as if the two machines shared a clock. |
var offsetKnown: Boolean |
|
var bestRoundTripNanos: Long |
Smallest round trip seen, the sample the offset is taken from. |
fun record(name: String, nanos: Long) |
|
fun ping(): String |
Build a ping. Send it to a head; acceptPong closes the loop. |
fun acceptPong(hostSendNanos: Long, headRecvNanos: Long, headSendNanos: Long) |
Close a round trip. |
fun headToHostNanos(headNanos: Long): Long |
Convert a head-clock instant into this process's clock. Only meaningful once offsetKnown. |
fun latched(seq: Long, pose: VisionPoseFrame) |
A stereo target latched pose and is about to render stream frame seq. |
fun submitted(seq: Long) |
The producer has signalled the surface for seq to the encoder. |
fun encoded(seq: Long) |
The helper reported {"ev":"encoded","seq":…}. |
var deviceSpans: Map<String, DeviceSpan> |
Spans the head measured on its own clock, newest report wins. |
var deviceReportCount: Long |
|
data class DeviceSpan(val p50Millis: Double, val p95Millis: Double, val maxMillis: Double, val n: Int) |
|
fun acceptDeviceReport(spansByName: Map<String, DeviceSpan>) |
|
fun report(): String |
The whole chain, host spans then device spans, with the headline last. |
fun reset() |
Clears every span, ping and device report, and the clock-offset estimate with them, so a measurement run starts from nothing. The offset has to be re-acquired after this. |
override fun toString(): String |
VisionPoseStereoSource.kt
Drives Field's stereo rendering from a visionOS head's uplinked pose — the consumer end of
VisionPoseStream.
Attach it to a afield.graphics.vulkan.targets.SideBySideStereoTarget and the eye passes render from the
headset rather than from a desktop mock:
val sbs = SideBySideStereoTarget(renderer)
sbs.poseSource = VisionPoseStereoSource()
The projection is rebuilt rather than forwarded. The head sends the matrix its compositor
computed, but that carries the compositor's near/far and depth range; Field's Vulkan pipelines use
Options.zNear/Options.zFar with a 0..1 depth range. So this takes the frustum tangents — the
convention-free part — and builds a projection exactly the way
afield.graphics.vulkan.targets.OpenXRHmdTarget builds one from XrFovf's angles. That is the whole reason
VisionEyeView carries tangents at all.
The view matrix, by contrast, is forwarded untouched: the wire already carries world→eye, composed on arrival the same way the head composes its own, so there is nothing to reinterpret.
src/jvmMain/kotlin/afield/graphics/visionos/VisionPoseStereoSource.kt — read the source
VisionPoseStereoSource
| Declaration | Summary |
|---|---|
var zNear: Float |
|
var zFar: Float |
|
var latched: VisionPoseFrame? |
The pose the current frame is being rendered from, or null before the first successful latch. |
var staleLatchCount: Long |
Latches that found nothing usable, so the view held its last pose. |
override val latchedFrameId: Long |
|
override val latchedDisplayTime: Double |
|
override val eyeCount: Int |
|
override fun latch(): Boolean |
|
override fun eyeViewMatrix(eyeIndex: Int, out: Matrix4f): Matrix4f |
|
override fun eyeProjectionMatrix(eyeIndex: Int, out: Matrix4f): Matrix4f |
|
override fun controller(hand: OpenXRHmdTarget.Hand, out: OpenXRHmdTarget.ControllerState): Boolean |
|
val latchedControllers: List<VisionController> |
Controllers carried by the latched pose, whichever hand — including unknown-handed ones. |
override fun headPose(outPosition: Vec3, outOrientation: Quat): Boolean |
|
override fun toString(): String |
CaptureOrientation.kt
The row order a pixel source hands to an encoder, and the paths that are held to it.
Row order is not a property of the encoder, and not a property of the file format: it is a property of the readback. A "flip Y" at a call site says what a line of code does and not what it is for, so each capture path carries the order its source delivers instead, and a test reads the source back to check that the two agree.
src/jvmMain/kotlin/afield/graphics/vulkan/CaptureOrientation.kt — read the source
GpuImageCapture.kt
src/jvmMain/kotlin/afield/graphics/vulkan/GpuImageCapture.kt — read the source
ModuleDestinations.kt
Where a module's drawing ends up, and a picture of it there: the window it is added to, or the render-to-texture that holds it — found by walking what the renderer draws, not by being told.
renderer += module puts a module in a window's main pass; rtt += module in a render target, which
draws when something reaches it (usually the renderer's preTargets). Walking the windows' passes,
everything their others and preTargets reach, and the headset target finds every place that draws
the module, compared by identity. A target drawn by some other path is not found.
ModuleDestinations.capture reads one of those places back as PNG bytes, and
ModuleDestinations.renderDisposable draws a module alone into a temporary render target and returns
that picture. StackSL's auto-study uses both to show what a module drew.
src/jvmMain/kotlin/afield/graphics/vulkan/ModuleDestinations.kt — read the source
ModuleDestinations
Finds where a module draws, and captures it there.
| Declaration | Summary |
|---|---|
sealed class Destination |
A place that draws a module. |
fun of(renderer: Renderer, module: Any): List<Destination> |
Every place renderer draws module — a graphics or mesh module pointer. Compute modules draw nowhere. |
fun capture(destination: Destination, timeoutMs: Long = 8000): ByteArray |
A PNG of destination as it is being drawn: the window's swapchain, or the render target's first colour attachment, both as displayed (sRGB-encoded bytes). Blocks until a frame has drawn it, so call it off the main thread, which draws. Throws with the reason when it cannot: a render target made without colorTransferSrc, a window that does not allow capture, or one that is not drawing. |
fun renderDisposable(renderer: Renderer, module: ModulePointer, width: Int = 1024, height: Int = 1024, timeoutMs: Long = 8000): ByteArray |
A PNG of module drawn once more, by itself, into a render target of our own — the same module, with its own state, meshes and uniforms, so a ping-pong module still samples its real previous frame, and nothing else that draws where it draws is in the picture. |
Renderer.kt
The Vulkan renderer: one Renderer owns the instance, device, queues, command pools and the
swapchain of each of its windows, and records, submits and presents a frame per tick.
A box beneath the renderer box reaches it as renderer and adds modules with renderer += module,
which puts them in the main window's Renderer.renderList; compute modules go to
Renderer.computeList. Each window has its own RenderTarget, found with Renderer.renderTargetFor.
Renderer.captureWindowToFile writes a window's next frame to a PNG, and Renderer.cleanup shuts the
renderer down, releasing its device, windows and targets.
The file also holds Target, the callback shape for drawing into a command buffer, and CPUSpan,
the per-span CPU timing statistics the frame loop records.
src/jvmMain/kotlin/afield/graphics/vulkan/Renderer.kt — read the source
Used in: Neural image rendering with OpenDLSS-NR, Profiling
Top-level functions
| Declaration | Summary |
|---|---|
fun target(a: (CommandBuffer, Int) -> Unit) |
Target
Something that records drawing into CommandBuffer for swapchain image index.
CPUSpan
Lightweight CPU span stats.
Renderer
class Renderer(private val mainWindow: Window, additionalWindows: List<Window> = emptyList())
SnapshotWindow.kt
src/jvmMain/kotlin/afield/graphics/vulkan/SnapshotWindow.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.snapshotWindow(renderer: Renderer, frames: Int = 10, channel: String = "window", width: Int = 640, skipBlank: Boolean = false): Job |
Puts a picture of renderer's window into this box's channel output, once frames frames have been drawn after the call. |
WindowCaptureQueue.kt
src/jvmMain/kotlin/afield/graphics/vulkan/WindowCaptureQueue.kt — read the source
DynamicBuffer.kt
The cursor a Vulkan buffer is written through: std430 padding, integers packed into float slots,
and a buffer that grows under a write that would run past its end. DynamicBuffer is all of it.
FLine geometry on its way to the GPU, the fabricated scene's shapes and a box's own
VulkanBuffer.write { … } block all put their floats here.
src/jvmMain/kotlin/afield/graphics/vulkan/buffers/DynamicBuffer.kt — read the source
DynamicBuffer
class DynamicBuffer(var target: FloatBuffer, var onOverrun: (FloatBuffer, Int) -> FloatBuffer)
A write cursor over GPU memory that pads as std430 does and grows when it runs out. This is what VulkanBuffer.write { … } and append { … } hand to their block, so a box streaming geometry or particle state writes put(…) calls and never computes an offset.
| Declaration | Summary |
|---|---|
val at: Int |
How far the cursor has been written, counted in floats rather than bytes. |
fun put(f: FloatBuffer, padding: Boolean = true): DynamicBuffer+1 overload · also IntBuffer |
Copies a buffer's remaining contents in bulk, after padding to a four-float boundary unless padding is false. A copy that would run past the end grows the target and is replayed, so it is never partial. The IntBuffer overload copies the integers' bits into the same float slots rather than converting them. |
fun put(f: FloatArray, padding: Boolean = true, offset : Int = 0): DynamicBuffer |
Copies a float array from offset onwards, padding to a four-float boundary first unless padding is false. |
fun put(d: DoubleArray): DynamicBuffer |
Writes each double narrowed to a float, one at a time, with no padding anywhere. |
fun putInt(d: List<Int>): DynamicBuffer |
Writes each integer's bits into its own float slot, one after another, with no padding before or between them. The shader side reads them as a run of int or uint members. |
fun lines(): (Boolean, Vec3) -> Unit |
Returns a callback that appends a line record as x, y, z, breakFlag. |
fun putInt(n: Int): DynamicBuffer |
Writes one integer's thirty-two bits into the next float slot, with no padding. The shader reads the slot as an int or uint. |
fun putScalar(n: Double): DynamicBuffer+1 overload · also Float |
Writes one value, narrowed to a float, wherever the cursor is. No alignment and no padding, which is what makes it the scalar case. |
fun put(n: Float): DynamicBuffer+2 overloads · also Vec4, Vec2 |
Writes one float at the cursor, with no padding. The Vec2 and Vec4 overloads grouped here pad to their own alignment first. |
fun put(n: Double, m: Double): DynamicBuffer+1 overload · also Float |
Writes a vec2: pads to a two-float boundary, then the two values narrowed to floats. |
fun put(n: Double, m: Double, p: Double, zero: Float = 0f): DynamicBuffer+1 overload · also Float |
Writes three floats after padding to a four-float boundary, leaving the fourth slot of the vec4 for whatever is written next. The Float overload grouped with it is not the same: it does no padding, and it does write a fourth value, zero. |
fun put(n: Vec3, zero: Float = 0f): DynamicBuffer |
Writes a padded vec3: aligns to a four-float boundary, then x, y, z and zero in the fourth slot, as std430 expects. |
fun pad4(zero: Float = 0f) |
Advances the cursor to the next four-float boundary by writing up to three copies of zero, and writes nothing when it is already there. This is the alignment a vec4 or a vec3 needs, and the bulk put overloads call it for you unless passed padding = false. |
fun pad2(zero: Float = 0f) |
Advances the cursor to the next two-float boundary, writing one zero when it is at an odd position. This is the alignment a vec2 needs, and the vec2 overloads of put call it. |
fun putIntBitsIntoFloatBuffer(src: IntBuffer, dst: FloatBuffer) |
todo, we need to realloc if it doesn't fit |
FrameBufferGroup.kt
FrameBufferGroup holds one Vulkan buffer per swapchain image, so data written for the current frame never overwrites a buffer an in-flight frame is still reading.
The BufferAccessPattern chosen at construction decides where the buffers live: host-visible
memory written directly, device-local memory fed through staging buffers (once or every frame), or
device-local memory with a host-visible copy for reading results back, host-cached memory read
every frame, or device-local memory zero-filled when built. Writes go to the current
image's buffer and are recorded with FrameBufferGroup.markAsWritten;
FrameBufferGroup.getLastWritten returns the most recently written one.
src/jvmMain/kotlin/afield/graphics/vulkan/buffers/FrameBufferGroup.kt — read the source
BufferAccessPattern
enum class BufferAccessPattern
Where a FrameBufferGroup's buffers live and how data moves between host and device.
Constants: HOST_VISIBLE, STAGED_WRITE_ONCE, STAGED_STREAM_WRITE, DEVICE_LOCAL_DEBUG_READ, HOST_VISIBLE_STREAM_READ, DEVICE_LOCAL_ZERO.
FrameBufferGroup
| Declaration | Summary |
|---|---|
val renderer: Renderer |
The renderer the buffers belong to. It supplies the image currently being drawn, the image count, and the cleanup list the buffers are released on. |
val n: Int |
How many buffers build makes. It defaults to one per swapchain image; pass 1 for data that does not change between frames. |
val buffers: MutableList<VulkanBuffer> |
The buffers themselves, in image order. It is empty until build runs, and every lookup here takes its index modulo this list's size. |
val accessPattern: BufferAccessPattern |
Where the buffers live and how data moves to and from them. It also sets the two staging flags below unless they are given explicitly. |
var lastWritten |
The index into buffers of the one written most recently, or -1 before anything has been written. getLastWritten falls back to the current image's buffer while it is -1. |
val usesStagedUploads: Boolean |
Whether host writes land in a separate staging buffer and reach the device through a copy. buildStaging must then have been called too. |
val usesReadbackStaging: Boolean |
Whether reads come back through a host-cached copy rather than straight out of the buffer. buildReadback must then have been called too. |
fun get(write : Boolean = false): VulkanBuffer |
The buffer for the image the renderer is drawing now. Passing write = true also records it as the most recently written. |
fun getAt(imageIndex: Int, write: Boolean = false): VulkanBuffer |
The buffer for a given swapchain image. The index is taken modulo the number of buffers, so a group built with one buffer answers with it for every image. |
fun read(builder: FloatBuffer.() -> Unit) |
mapAndRead under a shorter name, for a float view of the results. |
fun read_ints(builder: IntBuffer.() -> Unit) |
mapAndRead_ints under a shorter name, for an integer view of the results. |
fun getLastWritten() |
The buffer written most recently, which for a group written one frame and read the next is the one holding data. Before anything has been written it is the current image's buffer. |
fun markAsWritten(imageIndex: Int = renderer.currentImage) |
Records an image index as the one most recently written, without writing anything. The write paths call it themselves; call it by hand after filling a buffer through some route this class does not know about. |
fun build(builder: (Int) -> VulkanBuffer) |
Fills the group with n buffers, calling builder once per index. Any buffers already there are dropped from the list — without being released, so build once rather than to resize. A DEVICE_LOCAL_ZERO group also queues every new buffer for a zero fill, which the next clean records. |
fun buildStaging(builder: (Int) -> VulkanBuffer) |
Builds the matching host-visible staging buffers, n of them. A group with usesStagedUploads needs these as well as the ones from build; nothing allocates them for you, and the DSL in magic.kt is what normally does both. |
fun buildReadback(builder: (Int) -> VulkanBuffer) |
Builds the matching host-cached readback buffers, n of them, which a group with usesReadbackStaging copies into before a host read. |
override fun getBuffer() |
The current image's VkBuffer handle, as it stands. No pending zero or upload is flushed first. |
override fun getGPUAddress(cmd: CommandBuffer): Long |
The device address of the buffer written most recently, after any pending zero fill or staged upload has been recorded into cmd. This is the path a push constant takes, which is why binding a buffer is also what flushes it. |
override fun write(builder: DynamicBuffer.() -> Unit): DynamicallyWriteable |
Writes the current image's buffer from the beginning, through a DynamicBuffer that pads as std430 does. On a staged group the block fills the staging buffer and the copy is queued for the next clean instead of happening here. |
override val cursor : Int |
How far the current image's buffer has been written, in floats. |
override fun append(builder: DynamicBuffer.() -> Unit): DynamicallyWriteable |
Continues writing the current image's buffer from where the last write or append to it stopped. The per-image cursor is the buffer's own, so appending across frames appends to whichever buffer this frame's image owns, not to a single running one. |
override fun mapAndWrite(builder: FloatBuffer.() -> Unit): FrameBufferGroup |
Maps the current image's writable buffer and hands builder a float view of the whole allocation, no padding applied. On a staged group the upload is queued for the next clean. |
override fun mapAndWrite_bytes(builder: ByteBuffer.() -> Unit): FrameBufferGroup |
mapAndWrite with a byte view of the same memory, for packed or mixed layouts. |
override fun mapAndWrite_ints(builder: IntBuffer.() -> Unit): FrameBufferGroup |
mapAndWrite with an integer view of the same memory, for indices, counters and flags. |
override fun clean(hostReading: Boolean, cmd: CommandBuffer?): FrameBufferGroup |
Carries out the zero fills and staged uploads that writes and build left pending. |
override fun mapAndRead(builder: FloatBuffer.() -> Unit): FrameBufferGroup |
Reads the current image's buffer on the host as floats. |
override fun mapAndRead_bytes(builder: ByteBuffer.() -> Unit): FrameBufferGroup |
mapAndRead with a byte view of the same memory. |
override fun mapAndRead_ints(builder: IntBuffer.() -> Unit): FrameBufferGroup |
mapAndRead with an integer view of the same memory, which is how a compute counter comes back. |
GpuSchema.kt
GpuSchema derives a std430 memory layout from an annotated Kotlin class, packs instances of it into buffers, and emits the matching GLSL struct and buffer_reference declarations.
Annotate a class with GpuStruct (and exclude members with GpuIgnore), then call
gpuSchema to get its GpuSchema: field offsets, size and stride, GpuSchema.write and
GpuSchema.writeArray for packing, GpuSchema.arrayView for indexed writes into a mapped buffer,
and GpuSchema.glslDeclarations for the shader side. bindGpu binds a schema-backed value to a
Structure member. Schemas are cached per class.
src/jvmMain/kotlin/afield/graphics/vulkan/buffers/GpuSchema.kt — read the source
Used in: GPU schemas
Top-level functions
| Declaration | Summary |
|---|---|
fun <T : Any> Structure.bindGpu(schema: GpuSchema<T>, value: () -> T) |
Bind a reflected Field Structure (including a push-constant block) from one schema value. |
inline fun <reified T : Any> gpuSchema(): GpuSchema<T> |
The schema for T, built on first use and cached by class thereafter. |
fun <T : Any> gpuSchema(type: KClass<T>): GpuSchema<T> |
The schema for a class known only at runtime, built on first use and cached the same way as the reified form. |
GpuStruct
annotation class GpuStruct(val glslName: String = "")
Marks a Kotlin class as a CPU/GPU ABI declaration.
GpuIgnore
A constructor property that exists on the Kotlin value but is not part of its GPU representation.
GpuPrimitiveKind
enum class GpuPrimitiveKind(val glslType: String, val alignmentBytes: Int, val sizeBytes: Int)
The scalar/vector leaves understood by the first GPU-schema ABI.
Constants: FLOAT, INT, UINT, INT64, UINT64, VEC2, VEC3, VEC4.
GpuFieldType
| Declaration | Summary |
|---|---|
abstract val alignmentBytes: Int |
The std430 base alignment of this field, whether leaf or nested struct. |
abstract val sizeBytes: Int |
How many bytes this field occupies. For a nested struct it is that struct's rounded size. |
abstract val glslType: String |
The GLSL type name written for this field: a built-in for a leaf, the struct's own name for a nested one. |
data class Primitive(val kind: GpuPrimitiveKind) : GpuFieldType() |
A scalar or vector leaf, taking its layout straight from the kind. |
data class Struct(val schema: GpuSchema<*>) : GpuFieldType() |
Another @GpuStruct laid out inline at this field's offset, by its own schema. It is the bytes, not a pointer to them. |
GpuField
| Declaration | Summary |
|---|---|
val name: String |
The Kotlin property's name, which is used unchanged as the GLSL field name. |
val offsetBytes: Int |
Where the field starts, in bytes from the beginning of the struct. |
val type: GpuFieldType |
Whether this is a leaf or a nested struct, and the layout that follows from it. |
val sizeBytes: Int |
How many bytes the field occupies, from its type. |
val alignmentBytes: Int |
The field's std430 base alignment, from its type. |
GpuSchema
One reflected, deterministic std430-compatible memory schema.
| Declaration | Summary |
|---|---|
val kotlinClass: KClass<T> |
The annotated Kotlin class this layout was reflected from, and the key it is cached under. |
val glslName: String |
The name of the generated GLSL struct: the one given to the annotation, or the class's simple name. |
val fields: List<GpuField> |
The fields in primary-constructor order, with @GpuIgnore ones left out. That order is the ABI, so reordering the constructor changes the layout. |
val alignmentBytes: Int |
The struct's own base alignment, the largest of its fields', or 1 for a struct with no fields. |
val sizeBytes: Int |
The struct's size in bytes, the end of the last field rounded up to alignmentBytes. |
val strideBytes: Int |
Struct arrays use the struct's rounded size as their std430 stride. |
val referenceAlignmentBytes: Int |
buffer_reference_align is a promise made by the shader; 16 is a conservative BDA baseline. |
val abiSignature: String by lazy |
The layout written out in full: the struct's name, alignment and size, then every field's name, offset and type. Two schemas agree on memory exactly when these strings match, and comparing them says which field moved. |
val abiHash: String by lazy |
The first eight bytes of the signature's SHA-256, as sixteen hex characters: short enough to carry in a shader template or a log line as a layout version. |
fun field(name: String): GpuField |
The named field. A name this struct does not have is an error that names the struct, not a null. |
fun offsetOf(name: String): Int |
The named field's byte offset from the start of the struct. |
fun offsetOf(property: KProperty1<T, *>): Int |
The field's byte offset, named by property reference, so a renamed Kotlin property carries its GLSL field name with it. |
fun addressOf(baseAddress: Long, index: Int = 0): Long |
The device address of element index of an array of this struct starting at baseAddress, at strideBytes spacing. baseAddress is taken as the start of the array itself; nothing here knows about an offset into a larger buffer. |
fun addressOf(baseAddress: Long, index: Int, field: String): Long |
The device address of one field of element index, for a shader that reads a single member rather than the whole struct. |
fun write(value: T, target: ByteBuffer, baseOffset: Int = 0) |
Pack one value without changing target's position or byte order. |
fun writeArray(values: Iterable<T>, target: ByteBuffer, baseOffset: Int = 0): Int |
Pack values consecutively at strideBytes spacing. Returns the number written. |
fun arrayView(target: ByteBuffer, count: Int, baseOffset: Int = 0): GpuArrayView<T> |
A bounds-checked view for writing count values into target at this struct's stride. The capacity is checked once, here, rather than on each write. |
fun writer(value: () -> T): WritesToBuffer |
Adapter for Structure.bindAll { ... }, including whole push-constant blocks. |
fun glslStructDeclarations(): String |
All regular struct declarations needed by this schema, nested dependencies first. |
fun glslReferenceDeclaration(): String |
The buffer_reference block for a pointer to a single value of this struct, named after it with Ref appended. |
fun glslArrayReferenceDeclaration(): String |
The buffer_reference block for a pointer to an unsized array of this struct, named after it with Array appended. |
fun glslDeclarations(): String |
GLSL intended for {{ Particle.all }} directly after #version. |
val template: Map<String, Any> |
A value suitable for shaderTemplate["Particle"] = schema.template. |
GpuArrayView
A CPU view over an array whose stride is governed by one GpuSchema.
| Declaration | Summary |
|---|---|
val schema: GpuSchema<T> |
The layout every element is written with, and the stride between them. |
val count: Int |
How many elements the view covers. Every index is checked against it. |
val baseOffset: Int |
Where element zero starts, in bytes from the beginning of the target buffer. |
operator fun set(index: Int, value: T) |
Packs value over element index, leaving the target buffer's own position and byte order alone. |
fun byteOffsetOf(index: Int): Int |
Where element index starts, in bytes from the beginning of the target buffer, baseOffset included. |
fun addressOf(baseAddress: Long, index: Int): Long |
The device address of element index, given the address the target buffer begins at. This counts baseOffset in, unlike the schema's own addressOf, which measures from the array. |
ParallelGeometryPool.kt
ParallelGeometryPool lets several writers produce geometry for the same frame, each into its own per-swapchain-image buffers, and dispatches a module once per writer that published.
See ParallelGeometryPool for the frame-phase rules, writer publication and how buffer_reference
push constants switch a module into fan-out mode.
src/jvmMain/kotlin/afield/graphics/vulkan/buffers/ParallelGeometryPool.kt — read the source
ParallelGeometryPool
Per-writer, per-swapchain-image geometry buffers that fan one module invocation out into one invocation per writer published for the current renderer frame.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
The renderer whose geometry phase the pool follows. Constructing the pool registers it there, and cleanup is what takes it off again. |
val targetWindowIndex: Int |
Which window's swapchain images the channel buffers are sized for. Geometry tokens for any other window are ignored, so a pool only ever sees its own window's frames. |
val idleRetainFrames: Long |
How many frames a writer slot may go untouched before it is dropped at the next seal and its buffers queued on the renderer's cleanup list. A writer that is still publishing is never dropped; one that falls silent costs this many frames of buffers before it is reclaimed. |
data class WriterPublication(val vertexCount: Int? = null, val indexCount: Int? = null, val dispatchX: Int? = null, val dispatchY: Int? = null, val dispatchZ: Int? = null) |
What one writer asked its fan-out invocation to draw or dispatch this frame. A null count is not zero: it leaves the module's own draw or dispatch dimension in place for that writer. |
class ActiveWriter internal constructor(val writerId: Long, val publication: WriterPublication, internal val channels: Map<String, FrameBufferGroup>) |
One writer as the frame sealed it: its id, what it published, and the channel buffers it had at that moment. The channel map is copied at seal, so a later write cannot change what this frame's recording sees. |
data class SealedSnapshot(val token: Renderer.GeometryFrameToken, val writers: List<ActiveWriter>) |
The whole sealed writer list for one frame, in ascending writer-id order, together with the frame and swapchain image it was sealed against. Recording for a different frame or image finds no snapshot rather than a stale one. |
fun writer(writerId: Long = Thread.currentThread().threadId()): ParallelGeometryWriter |
A handle for writing and publishing under one writer id, by default the calling thread's. |
fun ref(channel: String): ParallelGeometryRef |
A reference to channel that resolves per writer at command-recording time, for binding to a buffer_reference push constant. Two references to the same channel of the same pool compare equal, so one can be rebuilt rather than held. |
fun cleanup() |
Takes the pool off the renderer, drops every writer slot, and queues all of their channel buffers on the renderer's cleanup list. The pool takes no further part in any frame; a write after this one fails for want of an active phase rather than silently going nowhere. |
ParallelGeometryWriter
A stable writer-id view into one ParallelGeometryPool.
| Declaration | Summary |
|---|---|
val writerId: Long |
The id this handle writes under. It identifies the pool slot, and it orders this writer among the frame's fan-out invocations. |
fun write(channel: String, block: DynamicBuffer.() -> Unit) |
Replace this frame image's channel contents. |
fun append(channel: String, block: DynamicBuffer.() -> Unit) |
Append to this frame image's existing channel contents. |
fun publish(vertexCount: Int? = null, indexCount: Int? = null, dispatchX: Int? = null, dispatchY: Int? = null, dispatchZ: Int? = null) |
Make this writer live for the current frame, optionally overriding the module's normal draw or dispatch dimensions. Publishing without writing is allowed and reuses retained channels. |
ParallelGeometryRef
Late-bound reference to one channel of the writer currently being recorded by fan-out dispatch. Resolving it outside that dispatch, or for a writer that lacks channel, is an error.
| Declaration | Summary |
|---|---|
val pool: ParallelGeometryPool |
The pool this reference resolves against. Every parallel reference in one push-constant block must name this same pool. |
val channel: String |
The channel name looked up in the writer being recorded. A writer that never wrote this channel fails its own invocation. |
override fun getBuffer(): Long |
The current writer's buffer for channel, for this frame's swapchain image. |
override fun getGPUAddress(cmd: CommandBuffer): Long |
The device address of the current writer's channel buffer, which is what the push constant carries. |
override fun equals(other: Any?): Boolean |
Same pool object and same channel name. The pool is compared by identity, the channel by value. |
override fun hashCode(): Int |
Built from the pool's identity hash and the channel name, to match equals. |
Structure.kt
Structure maps a shader's introspected uniform block or push-constant block onto named Kotlin members, so a box can set, read and bind values by member name.
A Structure is built from the Introspected.Member list that shader reflection produces, and
writes each value at its reflected offset when the block is uploaded. Values can be set
directly, or bound to a function that is re-evaluated each time the block is written
(bindPushConstant, HasUniform); a ProgressingUniform keeps being written until it reports
completion. A member that nothing has been assigned to fails with unassignedMemberMessage.
src/jvmMain/kotlin/afield/graphics/vulkan/buffers/Structure.kt — read the source
Used in: GPU schemas, Running code in a box
Top-level functions
| Declaration | Summary |
|---|---|
fun unassignedMemberMessage(member: Any?): String |
The message for a shader member that has nothing bound when a dispatch or draw needs it. It is not a missing buffer: nothing has been assigned to the member yet, and the usual reason is a binding written inside loop {}, which takes effect a frame after the first dispatch. |
fun <T : Structure> HasBuffer.asStructure(structure: T, members: List<Introspected.Member>): T |
|
fun <T : Structure> BufferParameters.asStructure(structure: T, members: List<Introspected.Member>): T |
|
fun <T> Structure.setPushConstant(name: String, value: T, notFound: () -> Nothing) |
Sets a push-constant value by name, or invokes notFound if name isn't a known offset. Shared by the FromStructure/BoundFromStructure delegates on Structure's various hosts (Mesh, MeshMesh, ComputeMesh, RayTracingDispatch, and their groups), which otherwise each hand-rolled this same offset-check-then-write shape. |
fun <T> Structure.getPushConstant(name: String, notFound: () -> Nothing): T |
Reads a push-constant value by name, or invokes notFound if name isn't a known offset. |
fun Structure.bindPushConstant(name: String, value: Function0<*>, notFound: () -> Nothing) |
Binds a live push-constant supplier by name, or invokes notFound if name isn't a known offset. |
fun <T> Structure.getBoundPushConstant(name: String, notFound: () -> Nothing): () -> T |
Reads a bound push-constant supplier by name, or invokes notFound if name isn't a known offset. |
fun <E> Iterable<E>.bindPushConstantAcrossGroup(name: String, value: Function0<*>, push: (E) -> Structure, ensureInitialized: (E) -> Unit, notFound: (E) -> Nothing) |
Binds value as a push constant on every element of a group, initializing each element's offsets on first use via ensureInitialized. Throws (via notFound, given the offending element) if some element in the group doesn't have name. |
fun <E, T> Iterable<E>.firstBoundPushConstant(name: String, push: (E) -> Structure, ensureInitialized: (E) -> Unit, notFound: () -> Nothing): () -> T |
Finds the first element of a group that has name bound, initializing each element's offsets on first use via ensureInitialized. Throws notFound if no element has it. |
HasUniform
interface HasUniform<T> : () -> T
A uniform value computed on demand: evaluateUniform is called each time the owning Structure is written.
| Declaration | Summary |
|---|---|
fun evaluateUniform(): T |
|
override fun invoke() |
ProgressingUniform
Marker for uniforms that progress over time and need to keep being written until they report completion.
| Declaration | Summary |
|---|---|
val isComplete: Boolean |
WritesToBuffer
| Declaration | Summary |
|---|---|
fun writeTo(b : ByteBuffer, baseOffset : Int) |
MissingRefException
class MissingRefException(override val message: String) : Exception(message)
Structure
StructureTypeCheck.kt
Decides what a Kotlin value set on a shader's uniform member is written as, or why the shader cannot be given it.
A Structure is set by member name, and the member that name refers to is whatever the shader's
Introspection last reported. Value and slot can therefore disagree: a Vector3f on a member the
shader now declares float, an Int on a float, a Float on a float16_t. Judged by the
value's Kotlin type alone, each of those writes the wrong number of bytes, or the right number
meaning something else.
The rule is to convert what converts without loss and to refuse a different shape. Shape is
checked first: a scalar belongs in a scalar, a vector in a vector of the same length, a matrix in
a matrix of the same size, and nothing but a buffer-writing value in a struct. Within one shape
the value is converted to the type the member declares whenever that type can hold it, which
covers any integer into a wider one, a narrower or differently signed integer that is in range, an
integer into float while it stays exactly representable, a whole float into an integer, and a
Double into a float. A float set on a half is rounded to the half's precision, which is what
the member asked for. Anything else is refused, naming the value that would not fit. Array
members, device pointers and values whose width is not knowable here are written as given.
Three callers ask this question. Structure.set asks it when the value arrives and declines to
store a value it would not be able to write, so the answer reaches whoever is typing. Writing a
block to its buffer asks it per member, and skips the member rather than writing nonsense into the
slot. Structure.carryValuesFrom asks it of every value a shader reload is trying to keep, which
is how a reload reports the uniforms it had to drop. Each distinct refusal is reported once, not
once a frame.
src/jvmMain/kotlin/afield/graphics/vulkan/buffers/StructureTypeCheck.kt — read the source
VBuffer.kt
VertexBuffer is a classic vertex buffer with a host-side staging copy that is transferred to device-local memory before drawing.
Most Field geometry instead pulls vertices in the shader through device addresses set in push
constants, which needs no staging step; VertexBuffer remains for code that binds vertex buffers
directly.
src/jvmMain/kotlin/afield/graphics/vulkan/buffers/VBuffer.kt — read the source
VertexBuffer
this is, as far as I can tell, completely decoy — with resizable BAR (or unified memory on Apple Silicon) this staging buffer thing is just excess engineering and writing shaders that do their own pulling is better and device pointers set through push constants is faster and architecturally skips all of the descriptor nonsense
| Declaration | Summary |
|---|---|
val renderer: Renderer |
The renderer whose device both allocations are made on, and whose graphics queue carries the copy. |
val size: Long |
Bytes. Both the staging and the device-local allocation are this size, for the life of the buffer; trimTo narrows what is copied, not what is allocated. |
val usage: Int |
The VkBufferUsageFlags the device-local buffer carries beyond transfer-destination. It also decides which reads the transfer barriers cover: vertex-attribute reads for a vertex buffer, index reads for an index buffer, and no barrier at all for a usage with neither bit. |
val createCPU: Boolean |
Whether to allocate the spare host-side cpu buffer. Nothing in the transfer path reads it. |
val cpu |
A plain host size-byte buffer, allocated only when createCPU was asked for, and null otherwise. It is not the staging buffer and is not part of the upload: nothing here writes to it or reads from it, and the renderer's buffer inspector only reports whether it exists. |
val src |
The host-visible, host-coherent staging buffer. The mapAndWrite family writes here, and a transfer copies it into dest. |
val dest |
The device-local buffer the GPU draws from, carrying usage on top of transfer-destination. This is the handle getBuffer returns. |
var watermark |
Counts host writes: every mapAndWrite call raises it by one, whether or not the block it ran completed. |
var transferWatermark |
The watermark the last transfer copied. While the two differ the device-local buffer is behind the staging one, and clean or transfer will copy; a transfer sets them equal. It starts at -1 so that a buffer written once before its first transfer is copied. |
var neededSize |
How many bytes a transfer copies, starting at the whole size and narrowed by trimTo. At zero or less the copy and both barriers are skipped entirely, so a trimmed-to-nothing buffer still reports itself clean after a transfer. |
override fun toString(): String |
Size, usage and whether the staging copy is still ahead of the device one, as DIRTY or clean. |
override fun clean(hostReading: Boolean, cmd: CommandBuffer?): VertexBuffer |
Brings the device-local buffer up to date with the staging one, if it is not already. |
override fun mapAndWrite(builder: FloatBuffer.() -> Unit): VertexBuffer |
Maps the staging buffer and hands builder a float view of the whole allocation, then unmaps and marks the device copy out of date. The view holds size / 4 floats and starts at position zero on every call, so a partial write leaves the rest of the previous contents in place. The buffer is unmapped and the watermark raised even if the block throws. |
override fun mapAndWrite_ints(builder: IntBuffer.() -> Unit): VertexBuffer |
mapAndWrite with an integer view of the same memory, size / 4 ints wide. Index data goes here. |
override fun mapAndWrite_bytes(builder: ByteBuffer.() -> Unit): VertexBuffer |
mapAndWrite with a byte view of the same memory, the full size. Use it for packed or mixed vertex formats. |
fun transfer(cmd: CommandBuffer, immediate: Boolean = false) |
Records the staging-to-device copy into cmd, bracketed by the two barriers that move the buffer between vertex input and transfer, and then counts it as clean. A buffer already at its transfer watermark records nothing. |
fun transferNow() |
Does the transfer on a command buffer of its own and blocks until the GPU has finished it. |
fun cleanup() |
Queues both allocations for release on the renderer's cleanup list rather than freeing them here, so a frame still reading this buffer finishes first. The buffer is unusable from the moment that list runs, not from the moment this is called. |
fun trimTo(neededSize: Long) |
Narrows the copy to the first neededSize bytes, for the common case of a buffer allocated for a worst-case vertex count and filled with fewer. It changes neither allocation, and it takes effect on the next transfer rather than marking the buffer dirty. |
override fun getBuffer(): Long |
The device-local dest handle, which is what a draw binds; the staging buffer is never handed out. |
override fun getGPUAddress(cmd : CommandBuffer): Long |
Always throws: neither allocation is created with the device-address usage bit, so this buffer cannot be reached by pointer. Shader-pulled geometry wants a VulkanBuffer built with addressable() instead. |
VulkanBuffer.kt
A block of GPU memory, and the ways to hand one to a shader. VulkanBuffer is the allocation
itself: VMA-suballocated, kept mapped on the CPU side for its whole life, resizable while frames
are in flight, and released through the renderer's frame-retirement queue rather than on the spot.
Vertex data, uniforms, storage buffers, acceleration-structure scratch and compute input and
output are all this one class under different usage flags.
Writing and reading go through three small interfaces, and which you reach for is a question of how
much you want to think about layout. BufferWriteable and BufferReadable map the allocation as
a plain FloatBuffer, ByteBuffer or IntBuffer and let you address it yourself.
DynamicallyWriteable gives you a DynamicBuffer instead, which pads to std430 as it goes and
grows the allocation under a write that would overflow it; write { … } starts at the beginning
and append { … } continues from the cursor.
HasBuffer is the handle a shader is bound to, and the useful thing about it is that it composes.
Adding a Long to one offsets it. zeroBefore on a buffer, and barrier or graphicsBarrier on
any handle, return a handle that records that work on the way to giving the address up. "This
buffer, cleared first" and "this buffer, once the compute writes to it are visible" then travel
with the buffer, so nothing has to remember where in the frame to put the clear or the barrier.
Render workers read the handle, address and descriptor range while recording a frame, on other
threads than the one that writes them. Every one of those fields is therefore @Volatile, and
allocation builds its replacements in locals and publishes them at the end, handle before range. A
worker sees either the old allocation, which is still alive, or the new one, and never a null
handle mid-resize.
src/jvmMain/kotlin/afield/graphics/vulkan/buffers/VulkanBuffer.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
operator fun HasBuffer.plus(offset: Long): HasBuffer |
Top-level values
| Declaration | Summary |
|---|---|
val VulkanBuffer.zeroBefore: HasBuffer |
|
val HasBuffer.barrier: HasBuffer |
|
val HasBuffer.graphicsBarrier: HasBuffer |
HasBuffer
A GPU buffer handle and the device address a shader reads it at.
| Declaration | Summary |
|---|---|
fun getBuffer(): Long |
|
fun getGPUAddress(cmd: CommandBuffer): Long |
BufferWriteable
DynamicallyWriteable
interface DynamicallyWriteable
| Declaration | Summary |
|---|---|
fun write(builder: DynamicBuffer.() -> Unit): DynamicallyWriteable |
|
fun append(builder: DynamicBuffer.() -> Unit): DynamicallyWriteable |
|
val cursor : Int |
BufferReadable
VulkanBuffer
magic.kt
The buffer DSL: buffer and defaultBuffer build a BufferParameters that says how a Vulkan buffer is sized, placed in memory and used.
defaultBuffer starts from host-visible, storage and device-addressable memory, which suits
hardware with resizable BAR or unified memory; buffer starts from nothing. Size helpers such as
10.floats or 4.vec4s count std430 bytes (a vec3 takes 16). linkBuffer looks for an existing
BufferParameters of the same name in the box context and merges new settings into it, so boxes
can share one buffer.
src/jvmMain/kotlin/afield/graphics/vulkan/buffers/magic.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun buffer(from: Introspected.UBO? = null, name: String = from?.name ?: "unnamed", parameters: BufferParameters.() -> Unit): BufferParameters |
Builds a BufferParameters named name, optionally sized from shader reflection from, and applies parameters to it. |
fun defaultBuffer(from: Introspected.UBO? = null, name: String = from?.name ?: "unnamed", parameters: BufferParameters.() -> Unit): BufferParameters |
buffer, but starting from host-visible, storage and device-addressable before parameters runs. That is the combination to reach for on hardware with resizable BAR or unified memory, where the host can write straight into memory the GPU reads by pointer. |
fun linkBuffer(name: String, initialSettings: BufferParameters): Pair<Boolean, BufferParameters> |
Looks for a BufferParameters called name in the box context, so boxes beneath a common parent can write to and read from one buffer. |
BufferParameters
| Declaration | Summary |
|---|---|
var source: Introspected.UBO? |
The reflected shader block this buffer is for, when there is one. It supplies the default name and is recorded on every buffer allocated from these parameters, which is what later bindings compare themselves against; sizing from it is the caller's own size = source.size. |
var name: String |
What the buffer is called: the name linkBuffer matches in the box context so two boxes can reach the same allocation, and the name the incompatibility message quotes. |
var perFrame: Boolean |
Whether to allocate one buffer per swapchain image rather than a single shared one, so the CPU can write next frame's contents while the GPU still reads this frame's. streamWrites sets it; writeOnce clears it. |
val Int.floats: Long |
n.floats — the std430 size in bytes of n floats. |
val Int.ints: Long |
n.ints — the std430 size in bytes of n 32-bit integers. |
val Int.bytes: Long |
n.bytes — n, for a size already counted in bytes. |
val Int.vec2s: Long |
n.vec2s — the std430 size in bytes of n vec2s, 8 apiece. |
val Int.vec3s: Long |
n.vec3s — 16 bytes apiece, not 12: std430 pads a vec3 out to a vec4, and a host-side writer that packs them tightly will be read back misaligned. |
val Int.vec4s: Long |
n.vec4s — the std430 size in bytes of n vec4s, 16 apiece. |
val customize |
The blocks that configure these parameters, run in order at allocation rather than when they are added, so a size written in terms of a count the box has not computed yet still works. mergeFrom puts a donor's blocks in front of these. |
var inlined |
Whether the contents live in plain host memory, pushed inline where they are used, instead of in a VkBuffer the GPU reads for itself. Such a buffer has nothing to bind to a descriptor and no device address, so asking for addressable as well throws. |
var size: Long |
Size of the buffer in bytes; the n.floats-style helpers above count std430 bytes for a given element count. |
var usage: Int |
The raw VkBufferUsageFlags. Set it through storage, uniform, indirect, addressable and the transfer pair dest() and source(), each of which adds its bit rather than replacing the field. |
var memory: Int |
The raw VkMemoryPropertyFlags the allocation is placed by. hosted, writeOnce, streamWrites, streamReads and zeroInitialized each set a coherent combination of this and accessPattern. |
var needsDeviceAddress |
Whether the buffer's GPU address is fetched after allocation, so a shader can reach it by pointer instead of through a descriptor. addressable sets it. |
var accessPattern: BufferAccessPattern |
How the buffer is written and read over its life. It is compared exactly when an existing allocation is offered for this binding, so a buffer tuned for readback will not stand in for a plain hosted one even though both are host visible. |
var stagedUploads |
Whether writes go through a host-visible staging buffer and a transfer, rather than into device memory the host can see directly. Allocates a second buffer of the same size. |
var readbackStaging |
Whether a host-cached buffer is allocated to read results back into, which is what readOccasionallyForDebugging is asking for. |
var postAdaptation |
Blocks run on each VulkanBuffer as it is created, while the group is still being built, so perFrame runs them once per swapchain image. Nothing in the tree registers one yet. |
var result: FrameBufferGroup? |
The allocation these parameters produced, held so that asking for the buffer twice does not allocate twice. Set it yourself to bind an allocation you already have, and toBuffer will refuse it rather than silently allocate a fresh one if its parameters do not match. |
fun storage() |
Adds the storage-buffer usage bit, so a shader can bind it as an SSBO. |
fun inlined() |
Marks the contents as pushed inline where they are used rather than living in a VkBuffer. It cannot be combined with addressable, which toBuffer refuses. |
fun addressable() |
Adds the device-address usage bit, and the storage bit with it, and asks for the address to be fetched once the buffer is allocated. This is what lets a shader reach the buffer through a buffer_reference pointer instead of a descriptor; it cannot be combined with inlined(). |
fun uniform() |
Adds the uniform-buffer usage bit, for a small block bound as a UBO. |
fun hosted() |
Puts the buffer in memory the host can write directly, with no staging copy in either direction. It adds the host-visible memory bit without clearing any other, so a buffer moved here from a device-local pattern keeps that bit as well. |
fun notHosted() |
Clears the host-visible and host-coherent memory bits. It touches neither the access pattern nor the staging flags, so it narrows where the allocation may be placed rather than switching the buffer to a staged scheme; writeOnce and the rest are what do that. |
fun writeOnce() |
Device-local memory, filled once from a host staging buffer. It adds the transfer-destination usage bit, clears the host memory bits, and asks for a single buffer rather than one per swapchain image, since the contents do not change between frames. |
fun stagedOnce() |
writeOnce under its other name. |
fun streamWrites() |
Device-local memory, refilled from a host staging buffer every frame. Same as writeOnce except that it asks for one buffer per swapchain image, so the host can write next frame's contents while the GPU still reads this frame's. |
fun stagedStream() |
streamWrites under its other name. |
fun readOccasionallyForDebugging() |
Device-local memory with a host-cached copy to read results back into. It adds the transfer-source usage bit and clears the host memory bits; a buffer that is already written through staging keeps that access pattern and gains only the readback. |
fun debugReads() |
readOccasionallyForDebugging under a shorter name. |
fun occasionalReads() |
readOccasionallyForDebugging under a shorter name. |
fun streamReads() |
Host-visible, host-cached memory read every frame, with no staging in either direction. The cached bit is what makes repeated host reads bearable; without it a host read of write-combined memory is very slow. |
fun zeroInitialized() |
Device-local memory whose contents start at zero. It adds the transfer-destination usage bit, because the zeroing is a fill recorded by the buffer group the first time the buffer is cleaned rather than anything the host writes. |
fun zeroInit() |
zeroInitialized under a shorter name. |
fun indirect() |
Adds the indirect-buffer usage bit, so a draw or dispatch can read its own counts out of this buffer. |
fun dest() |
Adds the transfer-destination usage bit, for a buffer something else copies into. source() is the other half. |
fun source() |
Adds the transfer-source usage bit, for a buffer something else copies out of. dest is the other half. |
operator fun invoke(buffer: BufferParameters.() -> Unit): BufferParameters |
Applies another block of settings and hands the same parameters back, so a prepared BufferParameters can be adjusted where it is used: params { size = 1024.floats }. |
fun mergeFrom(initialDefaults: BufferParameters) |
Takes another request's settings as defaults under these. Its customize blocks go in front of this one's, so they run first and anything set here wins, and its allocation is adopted if this request has none of its own. This is how linkBuffer lets two boxes share one buffer. |
fun incompatibilityReport(other: BufferParameters?): String |
A field-by-field account of why checkCompatibility said no, for the exception that reports it. |
fun checkCompatibility(result: BufferParameters?): Boolean |
Whether an existing allocation's recorded parameters will serve for this request. |
fun store(): BufferParameters |
A snapshot of the settled fields, recorded on every buffer this request allocates so that a later binding has something to compare itself against. It carries the flags, size, name and source block, and not the deferred customize blocks, perFrame or postAdaptation. |
val buffer: FrameBufferGroup |
toBuffer with the renderer taken from the box context. Asking twice returns the same allocation. |
fun toBuffer(renderer: Renderer = fromContext()): FrameBufferGroup |
Allocates the buffers this request describes, or returns the ones it already made. |
vma.kt
src/jvmMain/kotlin/afield/graphics/vulkan/buffers/vma.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun allocatorForRenderer(d : Renderer): Long |
The VMA allocator for d, created on the first ask and shared by every VulkanBuffer allocated on that renderer afterwards. |
Top-level values
| Declaration | Summary |
|---|---|
val allocators |
The live VMA allocator handles, one per Renderer that has allocated a buffer. |
Attachment.kt
An image plus image view sized and formatted to be used as a framebuffer attachment.
Attachment picks the view's aspect from the usage flags: a depth-stencil usage makes a depth
view and sets isDepthAttachment, a colour usage makes a colour view. cleanup destroys the view
and then the image.
src/jvmMain/kotlin/afield/graphics/vulkan/device/Attachment.kt — read the source
Attachment
class Attachment(device: Device, width: Int, height: Int, format: Int, usage: Int)
| Declaration | Summary |
|---|---|
val image: Image |
|
val imageView: ImageView |
|
var isDepthAttachment: Boolean |
|
fun cleanup() |
CommandBuffer.kt
A Vulkan command buffer allocated from a CommandPool: primary or secondary, one-time-submit or reusable, with CommandBuffer.beginRecording, CommandBuffer.endRecording and CommandBuffer.reset.
It tracks whether recording is inside a render pass instance, so transfer commands are not recorded where Vulkan forbids them, and can defer host-read barriers on graphics buffers until a flush.
src/jvmMain/kotlin/afield/graphics/vulkan/device/CommandBuffer.kt — read the source
CommandBuffer
class CommandBuffer(commandPool: CommandPool, primary: Boolean, oneTimeSubmit: Boolean)
A Vulkan command buffer allocated from a CommandPool: primary or secondary, one-time-submit or reusable, with CommandBuffer.beginRecording, CommandBuffer.endRecording and CommandBuffer.reset.
| Declaration | Summary |
|---|---|
var vkCommandBuffer: VkCommandBuffer |
The raw VkCommandBuffer handle, for the LWJGL vkCmd… calls Field has no wrapper for. |
var inRenderPassInstance: Boolean |
True while commands are being recorded inside a render pass instance or dynamic rendering. Vulkan forbids buffer and image barriers and transfer commands there, so code that needs one tests this and defers it; whoever ends the rendering clears the flag before flushing. |
data class DynamicRenderingInheritance(val colorFormats: IntArray = intArrayOf(), val depthFormat: Int = VK10.VK_FORMAT_UNDEFINED, val stencilFormat: Int = VK10.VK_FORMAT_UNDEFINED, val viewMask: Int = 0, val samples: Int = VK10.VK_SAMPLE_COUNT_1_BIT) |
The dynamic-rendering state a secondary buffer continues: the colour attachment formats, the depth and stencil formats, the multiview mask and the sample count. These have to match the vkCmdBeginRendering the primary buffer issued. |
data class SecondaryInheritance(val renderPass: Long = 0, val subPass: Int = 0, val frameBuffer: Long = 0, val flags: Int = 0, val dynamicRendering: DynamicRenderingInheritance? = null) |
What a secondary buffer declares about the render pass it records into — either a render pass handle with its subpass and framebuffer, or dynamicRendering for the dynamic-rendering path. Either one makes beginRecording set the render-pass-continue flag and inRenderPassInstance. |
fun beginRecording(inheritanceInfo: SecondaryInheritance? = null) |
Start recording, discarding anything a previous recording deferred. A secondary buffer has to pass inheritanceInfo and this throws without it; a primary buffer ignores the argument. One-time-submit is set from the flag the buffer was created with. |
fun cleanup() |
Return the buffer to the CommandPool it was allocated from. |
fun endRecording() |
Finish recording. Any barrier left by deferGraphicsHostReadBarrier is emitted first, unless recording is still inside a render pass instance — where emitting one is illegal, so it is discarded instead. A secondary buffer that records a deferred barrier therefore needs the primary to flush after vkCmdEndRendering. |
fun reset() |
Clear the buffer for re-recording, releasing the memory it held back to its pool rather than keeping it for the next recording. Any deferred barrier is dropped. |
fun deferGraphicsHostReadBarrier(buffer: VulkanBuffer) |
Ask for a graphics-write to host-read barrier on buffer, to be recorded once the render pass instance ends. This is how a shader's feedback buffer is made readable from Kotlin when the only place that knows about the read is inside the rendering, where a buffer barrier is not allowed. Repeated requests for the same buffer collapse into one. |
fun flushDeferredGraphicsHostReadBarriers() |
Record every barrier deferGraphicsHostReadBarrier asked for as a single vkCmdPipelineBarrier2, covering each buffer's whole requested size, and empty the list. Call it after vkCmdEndRendering and after clearing inRenderPassInstance; it does nothing when nothing was deferred. |
CommandPool.kt
A Vulkan command pool for one queue family, from which Field allocates command buffers.
CommandPool is created with VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT, so each command
buffer from it can be reset and re-recorded individually.
src/jvmMain/kotlin/afield/graphics/vulkan/device/CommandPool.kt — read the source
CommandPool
class CommandPool(device: Device, queueFamilyIndex: Int)
| Declaration | Summary |
|---|---|
val device: Device |
|
val queueFamilyIndex: Int |
|
var vkCommandPool: Long |
|
fun cleanup() |
DescriptorSet.kt
src/jvmMain/kotlin/afield/graphics/vulkan/device/DescriptorSet.kt
DescriptorSetLayout.kt
Descriptor set layouts for the renderer's shaders: DescriptorSetLayout owns a Vulkan layout handle and releases it with its device, and DescriptorSetLayout.SimpleDescriptorSetLayout builds one from parallel lists of bindings and descriptor types.
That builder supports partially bound arrays, one variable-count (bindless) binding, and update-after-bind. UpdatesDescriptorSet is the interface a bound resource implements to push its descriptor into a command buffer.
src/jvmMain/kotlin/afield/graphics/vulkan/device/DescriptorSetLayout.kt — read the source
UpdatesDescriptorSet
interface UpdatesDescriptorSet
Descriptor set layouts for the renderer's shaders: DescriptorSetLayout owns a Vulkan layout handle and releases it with its device, and DescriptorSetLayout.SimpleDescriptorSetLayout builds one from parallel lists of bindings and descriptor types.
DescriptorSetLayout
abstract class DescriptorSetLayout protected constructor(private val device: Device)
Device.kt
The logical Vulkan device: Device wraps a PhysicalDevice and enables the extensions and features the renderer uses.
It also reports optional capabilities such as ray tracing limits and Metal object export, and offers Device.calibratedHostGpuTimeNS for lining GPU timestamps up with host time.
src/jvmMain/kotlin/afield/graphics/vulkan/device/Device.kt — read the source
Device
class Device(val physicalDevice: PhysicalDevice, private val instance: Instance)
The logical Vulkan device: Device wraps a PhysicalDevice and enables the extensions and features the renderer uses.
| Declaration | Summary |
|---|---|
data class RayTracingLimits(val shaderGroupHandleSize: Int, val shaderGroupHandleAlignment: Int, val shaderGroupBaseAlignment: Int, val maxRayRecursionDepth: Int, val maxRayDispatchInvocationCount: Int, val maxRayHitAttributeSize: Int, val minAccelerationStructureScratchOffsetAlignment: Int, val maxGeometryCount: Long, val maxInstanceCount: Long, val maxPrimitiveCount: Long, val maxPerStageDescriptorAccelerationStructures: Int, val maxDescriptorSetAccelerationStructures: Int) |
The device's ray-tracing and acceleration-structure limits, read off it once when the acceleration path is usable. Shader-binding-table layout needs the three handle sizes and alignments; geometry building needs the scratch alignment and the geometry, instance and primitive ceilings. |
val openDlssNrFeatureFailure: String? |
Why DLSS ray reconstruction could not be prepared on this device, or null when it was. The extensions it asked for are already in enabledExtensions by then, so this is the message to show rather than a reason to re-probe. |
val vkDevice: VkDevice |
The raw VkDevice handle, which every LWJGL call in the engine takes as its first argument. |
val supportsPushDescriptors: Boolean |
VK_KHR_push_descriptor is available. A descriptor set layout built for push descriptors requires this, and refuses by name without it rather than failing inside Vulkan. |
val pipelineStatisticsQueryEnabled: Boolean |
The query features GPU profiling can use, enabled whenever the device has them so that Options.profiling can be switched on a running renderer. Performance counters cost something to enable, so they are only there when profiling was on at startup. |
val performanceQueryEnabled: Boolean |
VK_KHR_performance_query is available and profiling was on at startup. Unlike pipelineStatisticsQueryEnabled this cannot be switched later, because the counters cost something to enable. The renderer reads it together with a Windows-only test. |
val pushDescriptorMax: Int |
The device's maxPushDescriptors, or 0 when push descriptors are unavailable. A layout asking for more than this is refused with both numbers named. |
val supportsExtendedDynamicState3: Boolean |
VK_EXT_extended_dynamic_state3 is available and offers dynamic depth-clamp. Field sets only that one EDS3 state, so that one feature decides support; polygon mode is asked for separately where offered, since KosmicKrisp has EDS3 without it and requesting it anyway fails device creation. |
val supportsExtendedDynamicState: Boolean |
VK_EXT_extended_dynamic_state is available. Unlike EDS3 this needs no feature beyond the extension. |
val supportsMaintenance7: Boolean |
VK_KHR_maintenance7 is available and its feature enabled. Secondary graphics command buffers are used only where this and the option asking for them both hold. |
val supportsMaintenance11: Boolean |
VK_KHR_maintenance11 is available and its feature enabled. It is what lets a single array layer of an image be aliased as its own descriptor. |
val supportsDynamicRenderingUnusedAttachments: Boolean |
VK_EXT_dynamic_rendering_unused_attachments is available and its feature enabled, which is also the condition under which Field asks for it. Nothing in the engine branches on it: it relaxes which attachment formats a pipeline may declare rather than adding a call to make. |
val supportsUnifiedImageLayouts: Boolean |
The device offers unifiedImageLayouts; usesUnifiedImageLayouts says whether Field turned it on. |
val usesUnifiedImageLayouts: Boolean |
VK_KHR_unified_image_layouts is enabled and ImageLayouts sends GENERAL. |
val supportsMultiview: Boolean |
VK_KHR_multiview is available, which is how one draw reaches both eyes. A render-to-texture given a multiview view mask requires it, as does the multiview side-by-side stereo target; both refuse by name without it. |
val raySupport: RayDeviceSupport |
What the hardware offers for ray tracing, before any of Field's own opt-in. The supports… flags beside this are this probe and the ray-tracing option together. |
val enabledExtensions: Set<String> |
Exactly the extensions vkCreateDevice was given, deduplicated. This is what Field asked for; PhysicalDevice.hasDeviceExtension is what the device offers. |
val supportsAccelerationStructures: Boolean |
Acceleration structures can be built: ray tracing is opted into and the hardware path is complete. |
val supportsRayTracing: Boolean |
Ray-tracing pipelines — raygen, hit and miss stages — are available and opted into. |
val supportsRayQuery: Boolean |
Ray queries from an ordinary compute or fragment shader are available and opted into. |
val rayTracingLimits: RayTracingLimits? |
The device's ray-tracing limits, or null when the acceleration path is unusable. |
val supportsMultiviewMeshShader: Boolean |
Mesh-stage multiview is a separate feature from ordinary multiview. |
data class MeshShaderLimits(val outputVertices: Int, val outputPrimitives: Int, val taskPayloadBytes: Int, val multiviewViews: Int, val meshInvocations: Int, val taskInvocations: Int) |
|
val meshShaderLimits: MeshShaderLimits? |
|
val supportsOpticalFlow: Boolean |
Optional shader/vision features. SDKs are not required to enable these Vulkan features. |
val supportsCooperativeVector: Boolean |
In-shader cooperative vectors, which learned shaders infer with. It takes three features together — cooperative vectors, replicated composites and the Vulkan memory model — and can be turned off with the system property afield.cooperativeVector.disabled. |
val supportsCooperativeVectorTraining: Boolean |
Cooperative-vector training as well as inference. Offline shader training stays available without it. |
val supportsClusterAccelerationStructure: Boolean |
VK_NV_cluster_acceleration_structure, which needs acceleration structures underneath it. Switched off by the system property afield.clusterAccelerationStructure.disabled. |
var usePresentIndexing |
Presents are tagged with an id and waited on, which is Windows-only and needs both VK_KHR_present_id and VK_KHR_present_wait. Construction answers this for the real device, so the value here is only what holds until then. |
var enableMeshShading |
Mesh and task shader stages are enabled, which is Windows-only and needs VK_EXT_mesh_shader. Construction overwrites the value here the same way usePresentIndexing does. |
var enableRayTracing |
Whether ray-tracing pipelines were enabled at device creation. supportsRayTracing is the same answer as a read-only property and is what the engine reads; construction overwrites the value here as it does for usePresentIndexing. |
var usesPresentTiming |
True when VK_EXT_present_timing was successfully enabled on the device. Never true on macOS/MoltenVK. |
val supportsMetalObjects: Boolean |
VK_EXT_metal_objects (MoltenVK) — required to export an RTT color image's backing IOSurface. |
val supportsExternalMemoryHost: Boolean |
VK_EXT_external_memory_host, enabled on macOS: an IOSurface's own pages imported as a VkBuffer (afield.graphics.texture.IOSurfaceHostBuffer). That is how IOSurfaces reach Vulkan on KosmicKrisp, which imports image memory only as an MTLHeap — never an IOSurface-backed MTLTexture — and has no VK_EXT_metal_objects. |
val minImportedHostPointerAlignment: Long by lazy |
The alignment an imported host pointer and its size must have (16 KiB on Apple silicon). |
fun requireMetalObjects(what: String) |
Refuse, naming the way out, when supportsMetalObjects is false — which is the case on KosmicKrisp, the driver Field picks on Apple silicon with macOS 26 and a recent SDK. Field's two live IOSurface paths go through supportsExternalMemoryHost and work there; image-level IOSurface import and export is what needs this extension. |
val supportsExternalMemoryFd: Boolean |
VK_KHR_external_memory_fd (Linux) — required to export an RTT color image's backing memory as an opaque fd for CUDA/NVENC import (see VulkanExternalMemory.kt / field-encode-linux). |
val supportsExternalSemaphoreFd: Boolean |
Linux CUDA interop synchronization, negotiated independently of external memory. |
val supportsDmaBufImport: Boolean |
Extensions needed to import Electron nativePixmap dma-bufs with explicit DRM modifiers. |
val supportsExternalMemoryWin32: Boolean |
VK_KHR_external_memory_win32, used for duplicated Electron D3D11 NT handles. |
fun waitIdle() |
Block until every queue on this device is idle. The blunt instrument teardown and swapchain rebuilds use; a per-queue or per-fence wait is what a frame should use. |
var acceptingSubmissions |
False once the owning Renderer's teardown has begun (closeSubmissions); Queue.submit then refuses by name instead of queuing work against buffers that are about to be freed. |
fun closeSubmissions() |
Refuse every later Queue.submit; taken under Queue.globalQueueLock so none is mid-flight. |
fun cleanup() |
Close the reconstruction runtime if one was ever opened, then destroy the device. Everything allocated from the device has to be gone first; closeSubmissions and waitIdle are how a renderer gets there. |
fun hasCalibrateableDomains(): Boolean |
True if DEVICE and CLOCK_MONOTONIC time domains are calibrateable (guarded by extension presence). |
fun calibratedHostGpuTimeNS(): Pair<Long, Long> |
Returns (hostMonotonicNS, gpuNS). Requires VK_EXT_calibrated_timestamps. Callers should gate on hasCalibrateableDomains. |
Fence.kt
A Vulkan fence the CPU waits on to learn that submitted GPU work has finished.
Fence creates the fence, optionally already signalled, and registers it with AutoDisposer
under its Device, so a fence that is never cleaned up is still destroyed. fenceWait blocks
without a timeout, reset re-arms it, and isSignaled polls it.
src/jvmMain/kotlin/afield/graphics/vulkan/device/Fence.kt — read the source
Fence
class Fence(private val device: afield.graphics.vulkan.device.Device, signaled: Boolean)
| Declaration | Summary |
|---|---|
var vkFence: Long |
The raw VkFence handle, for the LWJGL calls Field has no wrapper for. cleanup zeroes it. |
fun cleanup() |
Destroy the fence and clear vkFence. Safe to call twice: the second call finds the AutoDisposer registration already taken and returns without a second vkDestroyFence. |
fun fenceWait() |
Block the calling thread until the fence signals, with no timeout. A fence that nothing submits work against never signals, so this hangs rather than returning an error. |
fun reset() |
Return the fence to unsignalled, ready for the next submission to signal it. |
fun isSignaled(): Boolean |
Whether the fence has signalled, polled without blocking. |
ImageLayouts.kt
Maps Field's logical image layouts to the layout handed to Vulkan, collapsing to GENERAL
when the driver supports VK_KHR_unified_image_layouts.
Every call that passes a layout to Vulkan goes through vkLayout; ImageLayouts holds the switch
that Device sets.
src/jvmMain/kotlin/afield/graphics/vulkan/device/ImageLayouts.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun vkLayout(layout: Int): Int |
The layout to hand Vulkan for logical layout; see ImageLayouts. |
ImageLayouts
VK_KHR_unified_image_layouts: with it, GENERAL is as good as any specialised layout for every use, so a driver that offers it has nothing to gain from Field's per-use layouts, and a layered driver such as KosmicKrisp has work to lose (a transition it must honour for Metal).
| Declaration | Summary |
|---|---|
var unified: Boolean |
Set by Device once unifiedImageLayouts is enabled; off with OpenXR, whose runtimes expect specific layouts on swapchain release. |
fun vk(layout: Int): Int |
Instance.kt
The Vulkan instance: SDK and driver selection, layers, extensions and the debug messenger.
Instance calls Options.doVulkanSDK before anything else, so the loader and driver it gets are
the ones Field chose (KosmicKrisp or MoltenVK on macOS). It enables the validation layer when
validation or shader debugPrintf is requested and a layer is installed, adds
VK_KHR_portability_enumeration on macOS when the loader offers it, and records what it actually
enabled in enabledExtensions. With VR enabled and an OpenXR runtime bootstrapped, the instance is
created through OpenXR's xrCreateVulkanInstanceKHR, so the runtime can add the extensions it needs.
src/jvmMain/kotlin/afield/graphics/vulkan/device/Instance.kt — read the source
Instance
class Instance(private val validate: Boolean)
| Declaration | Summary |
|---|---|
var vkInstance: VkInstance |
|
val enabledExtensions: Set<String> |
Vulkan instance extensions actually enabled at creation time. |
fun cleanup() |
|
fun dumpToolingInfo(instance: VkInstance, phys: VkPhysicalDevice) |
MacHdrActivator.kt
Enables Extended Dynamic Range (EDR) on the macOS CAMetalLayer that backs the GLFW/MoltenVK window. Without this call the OS compositor clamps output to SDR even when an HDR Vulkan swap-chain format (e.g. scRGB) is selected.
Implementation: the GLFW window handle is an NSWindow*. We walk the Objective-C object graph
via the ObjC runtime (objc_msgSend) using LWJGL's low-level JNI call helpers, without
pulling in any extra native library or annotation processor:
NSWindow → contentView (NSView) → layer (CAMetalLayer) → setWantsExtendedDynamicRangeContent: YES
All class and method lookups are done via reflection so this file compiles and loads on
Windows/Linux without modification. It reaches LWJGL through five entry points:
GLFWNativeCocoa.glfwGetCocoaWindow, LibObjC.sel_registerName, LibObjC.getLibrary,
JNI.callPPP and JNI.callPPZV.
All ObjC messaging is wrapped in a try/catch so a failure degrades gracefully to SDR.
src/jvmMain/kotlin/afield/graphics/vulkan/device/MacHdrActivator.kt — read the source
MacHdrActivator
One function, activate, which SwapChain calls as it builds a swap chain on macOS with HDR on.
| Declaration | Summary |
|---|---|
fun activate(windowHandle: Long) |
PhysicalDevice.kt
The GPU Field renders on: PhysicalDevice.createPhysicalDevice picks one, optionally by preferred name, and the object records its properties, features, memory types, queue families, extensions and driver identity.
src/jvmMain/kotlin/afield/graphics/vulkan/device/PhysicalDevice.kt — read the source
PhysicalDevice
class PhysicalDevice private constructor(vkPhysicalDevice: VkPhysicalDevice)
The GPU Field renders on: PhysicalDevice.createPhysicalDevice picks one, optionally by preferred name, and the object records its properties, features, memory types, queue families, extensions and driver identity.
| Declaration | Summary |
|---|---|
var vkMemoryProperties: VkPhysicalDeviceMemoryProperties |
The device's memory heaps and types, as an allocator reads them to choose a memory type. |
var vkPhysicalDevice: VkPhysicalDevice |
The raw VkPhysicalDevice handle, for the LWJGL calls Field has no wrapper for. |
var vkPhysicalDeviceFeatures: VkPhysicalDeviceFeatures |
The Vulkan 1.0 core feature set. Later feature structs are queried where they are needed. |
var vkPhysicalDeviceProperties: VkPhysicalDeviceProperties |
Limits, device name, API version and driver version, queried once at construction. |
var vkQueueFamilyProps: VkQueueFamilyProperties.Buffer |
One entry per queue family, carrying its capability flags, queue count and timestamp-valid bits. This is the list Queue.GraphicsQueue, Queue.ComputeQueue and Queue.PresentQueue search for a family to take a queue from. |
var driverId: Int |
VkDriverId of the ICD behind this device, e.g. VK12.VK_DRIVER_ID_MOLTENVK; 0 if unknown. |
var driverName: String |
The driver's own name for itself, which is what Field prints for the device it picked. Empty on a device reporting less than Vulkan 1.2, where the driver properties are not queried at all. |
var driverInfo: String |
The driver's version string, in whatever form it chooses. Empty on the same devices driverName is. |
val isMoltenVK: Boolean |
driverId is MoltenVK's. Nothing in Field reads this; the driver choice is made on driverId itself. |
val isKosmicKrisp: Boolean |
driverId is Mesa's KosmicKrisp. Nothing in Field reads this; see isMoltenVK. |
fun cleanup() |
Free the five native property structs. Called for every enumerated device that is not selected, as well as for the selected one when its renderer tears down. |
val deviceName: String |
The name the device reports for itself, which is also what Options.preferredDevice is matched against when choosing between several GPUs. |
fun hasDeviceExtension(name: String): Boolean |
Whether the device enumerated name among its extensions. This asks what the device offers, not what Field enabled; Device.enabledExtensions is the second question. |
fun requireExtensionsIfProfiling(profilingEnabled: Boolean): Boolean |
True unless profilingEnabled and the device has no VK_KHR_performance_query, printing what is missing either way. A missing VK_EXT_host_query_reset is reported as a warning and does not fail the check. Nothing in Field calls this; Device.performanceQueryEnabled is the answer the renderer actually reads. |
PipelineCache.kt
A Vulkan pipeline cache that the renderer passes to the pipelines it builds on one Device.
PipelineCache is created empty and lives only for the device's lifetime; nothing is saved to or
loaded from disk.
src/jvmMain/kotlin/afield/graphics/vulkan/device/PipelineCache.kt — read the source
PipelineCache
class PipelineCache(device: Device)
| Declaration | Summary |
|---|---|
val device: Device |
|
var vkPipelineCache: Long |
|
fun cleanup() |
Queue.kt
Vulkan device queues: Queue fetches a queue from a family and submits command buffers with wait and signal semaphores and an optional fence.
The subclasses Queue.GraphicsQueue, Queue.ComputeQueue and Queue.PresentQueue choose the queue family for graphics, compute and presentation to a surface.
src/jvmMain/kotlin/afield/graphics/vulkan/device/Queue.kt — read the source
Queue
open class Queue(private val device: Device, queueFamilyIndex: Int, queueIndex: Int)
Vulkan device queues: Queue fetches a queue from a family and submits command buffers with wait and signal semaphores and an optional fence.
| Declaration | Summary |
|---|---|
val queueFamilyIndex: Int |
The Vulkan queue family this queue was taken from. SwapChain compares it against the presentation queue's: images are shared CONCURRENT when they differ and EXCLUSIVE when every queue touching them is from one family. |
var vkQueue: VkQueue |
The raw VkQueue handle, for the LWJGL calls Field has no wrapper for. |
fun submit(commandBuffers: PointerBuffer?, waitSemaphores: LongBuffer?, dstStageMasks: IntBuffer?, signalSemaphores: LongBuffer?, fence: afield.graphics.vulkan.device.Fence?) |
Submit command buffers, waiting on waitSemaphores at the stages dstStageMasks names and signalling signalSemaphores when they complete. fence is optional and is what the CPU waits on to learn the work has finished. Taken under Queue.globalQueueLock, and refused outright once Device.closeSubmissions has run, so work is never queued against buffers a renderer teardown is about to free. |
fun waitIdle() |
Block until everything already submitted to this queue has finished. Held under Queue.globalQueueLock, so a submit from another thread either lands before the wait or blocks until it returns. |
class GraphicsQueue(device: Device, queueIndex: Int) |
A queue from the first family that offers graphics and compute. Field asks for both together so one queue can run a draw and a dispatch, and construction fails rather than falling back if no family offers both. |
class ComputeQueue(device: Device, queueIndex: Int) |
A queue from the first family that offers compute, graphics or not. On a device whose first compute family is also its graphics family this is the same family Queue.GraphicsQueue picks. |
class PresentQueue(device: Device, surface: Surface, queueIndex: Int) |
A queue from the first family that can present to surface. Presentation support is a property of the family and the surface together, so this is asked per window rather than read off the device. |
RayDeviceSupport.kt
What the GPU supports for hardware ray tracing: RayDeviceSupport lists the acceleration-structure, ray-query and ray-tracing-pipeline extensions and features, and derives whether each path is usable.
lightingCapabilityReport turns that into a JSON report for a Device.
src/jvmMain/kotlin/afield/graphics/vulkan/device/RayDeviceSupport.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Device.lightingCapabilityReport(): JSONObject |
JSON snapshot from the actual selected physical/logical device; safe on non-ray-query devices. |
RayDeviceSupport
Physical support, separate from Device's enabled features. Field already requires Vulkan 1.3, which supplies buffer-device-address, descriptor-indexing, SPIR-V 1.4 and float-control dependencies.
| Declaration | Summary |
|---|---|
val accelerationExtension: Boolean |
VK_KHR_acceleration_structure is in the device's extension list. |
val deferredHostOperations: Boolean |
VK_KHR_deferred_host_operations is present, which acceleration-structure builds depend on. |
val queryExtension: Boolean |
VK_KHR_ray_query is in the device's extension list. |
val pipelineExtension: Boolean |
VK_KHR_ray_tracing_pipeline is in the device's extension list. |
val bufferDeviceAddress: Boolean |
The bufferDeviceAddress feature, which shader-side acceleration-structure access needs. |
val accelerationStructure: Boolean |
The accelerationStructure feature. Only queried when accelerationExtension holds, so a device missing the extension reports this false as well. |
val rayQuery: Boolean |
The rayQuery feature — tracing from an ordinary compute or fragment shader. Only queried when queryExtension holds. |
val rayTracingPipeline: Boolean |
The rayTracingPipeline feature — raygen, hit and miss shader stages. Only queried when pipelineExtension holds. |
Semaphore.kt
A Vulkan semaphore for GPU-to-GPU ordering between queue submissions, timeline by default.
A timeline Semaphore keeps its own counter: nextSignalValue advances it for the submission
that will signal, currentValue is the value to wait on, and markExternallySignaled advances it
when something outside Field's submissions (such as image acquisition) signals it. Constructed with
timeline = false it is a binary semaphore, and both values are always 0.
src/jvmMain/kotlin/afield/graphics/vulkan/device/Semaphore.kt — read the source
Semaphore
class Semaphore(private val device: Device, private val timeline: Boolean = true)
Semaphore wrapper that can be either binary or timeline.
| Declaration | Summary |
|---|---|
var vkSemaphore: Long |
|
fun currentValue(): Long |
Value to wait on (current signaled value). For binary this is always 0. |
fun nextSignalValue(): Long |
Value to signal next; increments internal counter for timelines, returns 0 for binary. |
fun markExternallySignaled() |
Mark externally signaled (e.g., vkAcquireNextImageKHR). |
fun cleanup() |
Surface.kt
The Vulkan presentation surface for a GLFW window.
Surface asks GLFW to create the surface for a window handle on the instance behind a
PhysicalDevice, and cleanup destroys it.
src/jvmMain/kotlin/afield/graphics/vulkan/device/Surface.kt — read the source
Surface
class Surface(physicalDevice: PhysicalDevice, windowHandle: Long)
| Declaration | Summary |
|---|---|
var vkSurface: Long |
|
fun cleanup() |
SwapChain.kt
The window's presentation chain: SwapChain creates the swapchain images and their views for a surface, acquires the next image each frame and presents it on a Queue.PresentQueue.
The present mode follows the vsync setting, and SwapChain.presentImage returns a SwapChain.PresentResult that says whether the window needs resizing.
src/jvmMain/kotlin/afield/graphics/vulkan/device/SwapChain.kt — read the source
SwapChain
The window's presentation chain: SwapChain creates the swapchain images and their views for a surface, acquires the next image each frame and presents it on a Queue.PresentQueue.
| Declaration | Summary |
|---|---|
val device: Device |
The logical device this chain's images and semaphores belong to. |
val surface: Surface |
The window surface being presented to. One swapchain presents to exactly one surface. |
val imageViews: Array<ImageView> |
A view per swapchain image, in the order the driver reports them. The driver decides how many images there are, so this array's length is the count that matters and numImages reads it. |
var surfaceFormat: SurfaceFormat |
The pixel format and colour space chosen for the swapchain images. |
var swapChainExtent: VkExtent2D |
The size of the swapchain images in pixels, as agreed with the surface at creation. |
val syncSemaphoresList: Array<SyncSemaphores> |
One acquire and render-complete semaphore pair per swapchain image. Acquisition takes the pair at currentFrame; presentation waits on the pair belonging to the image being shown. |
val supportsTransferSrc: Boolean |
The surface allows its images to be used as a transfer source, which is what lets a presented frame be copied out for a screenshot. Requested in the image usage flags only when true, since asking for an unsupported usage fails swapchain creation. |
var vkSwapChain: Long |
The raw VkSwapchainKHR handle, for the LWJGL calls Field has no wrapper for. |
val presentTimingEnabled: Boolean |
True when VK_EXT_present_timing is active on this swapchain (never on macOS/MoltenVK). |
var refreshDurationNs: Long |
Nominal display refresh duration reported by vkGetSwapchainTimingPropertiesEXT (nanoseconds, 0 = unknown). |
var currentFrame: Int |
Which syncSemaphoresList pair the next acquisition will use, advanced by presentImage rather than by acquisition. It counts presents modulo the image count, so it is not the image index Vulkan hands back and not a frame number. |
var nextPresentId: Long |
The id the next present will be tagged with, counting from 1 because Vulkan reads 0 as untagged. It advances only while Device.usePresentIndexing holds, which is why the wait-for-present path has nothing to wait on before the first present. |
data class PresentResult(val resize: Boolean, val presentId: Long?, val result: Int) |
What presentImage reports: whether the caller should rebuild the swapchain, the present id the frame was tagged with (null when present indexing is off) and the raw VkResult. |
fun drainPastTimings() |
Harvest any completed past-presentation-timing entries from the driver's ring buffer and forward them to FramePresentationLog. |
fun acquireNextImage() |
Take the next image to render into, returning its index, or -1 when the swapchain is out of date and the caller should rebuild it. The acquire is given 16 ms rather than forever, because a hidden Wayland surface can stop producing images indefinitely and the Runloop has other windows to serve; a timeout raises a retryable Renderer.ImageNotAvailable instead of blocking the frame. |
fun cleanup() |
Destroy the swapchain along with every image view and semaphore pair it owns. The caller is responsible for having waited the device idle first; this issues no wait of its own. |
val numImages: Int |
How many images the swapchain has, counted from what the driver created rather than from what was asked for. On a surface reporting no maximum the request is dropped and only the surface minimum is asked for, so this is the number to read before assuming a frame depth. |
fun presentImage(queue: Queue, imageIndex: Int, frameId: Long = -1L, windowIndex: Int = 0): PresentResult |
Present the image at imageIndex, waiting on its render-complete semaphore, and advance currentFrame. frameId and windowIndex only label the entry written to FramePresentationLog. VK_ERROR_OUT_OF_DATE_KHR comes back as a resize; VK_SUBOPTIMAL_KHR asks for one only when the surface extent has really changed, since some drivers report suboptimal at a matching extent every frame and rebuilding on each would spin. |
data class SurfaceFormat(val imageFormat: Int, val colorSpace: Int) |
A VkFormat and VkColorSpaceKHR pair, as offered by the surface and chosen at creation. |
data class SyncSemaphores(val imgAcquisitionSemaphore: Semaphore, val renderCompleteSemaphore: Semaphore) |
The two binary semaphores one swapchain image needs: one the presentation engine signals when the image is ready to draw into, one the rendering signals when it is ready to show. |
VulkanNames.kt
Debug names and command-buffer labels for Vulkan objects, as seen in validation messages and frame debuggers such as RenderDoc.
nameObject attaches a name to a device handle; beginLabel and endLabel bracket a region of a
command buffer. All three do nothing unless validation is on (Options.validate) and their own
toggle (Options.debugObjectNames, Options.debugLabels) is set, and when the driver does not
expose the VK_EXT_debug_utils entry point. Labels are also skipped on macOS. Name strings are
encoded once and cached, so the same name can be passed every frame without allocating.
src/jvmMain/kotlin/afield/graphics/vulkan/device/VulkanNames.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Device.nameObject(handle: Long, type: Int, name: String) |
|
fun CommandBuffer.beginLabel(name: String, rgba: FloatBuffer) |
|
fun CommandBuffer.endLabel() |
timing.kt
GPU-side profiling for the Vulkan renderer: per-frame query pools for timestamps, pipeline statistics and, where the driver offers VK_KHR_performance_query, performance counters.
ProfilingOptions switches each kind on and sizes its pool; GpuProfiler owns one set of pools per frame in flight and hands out query slots as commands are recorded.
src/jvmMain/kotlin/afield/graphics/vulkan/device/timing.kt — read the source
ProfilingOptions
GPU-side profiling for the Vulkan renderer: per-frame query pools for timestamps, pipeline statistics and, where the driver offers VK_KHR_performance_query, performance counters.
PipeStatIndex
FrameAllocator
class FrameAllocator(private val max: Int)
| Declaration | Summary |
|---|---|
fun alloc(count: Int = 1): Int |
|
fun capacity(): Int |
|
fun reset() |
GpuProfiler
ImGuiPanel.kt
A world-space rectangle that shows an ImGuiRenderToTexture in VR and turns controller rays into
ImGui pointer input.
Place the panel with ImGuiPanel.place or ImGuiPanel.faceToward, raycast it with
ImGuiPanel.raycast or ImGuiPanel.interact, and draw its quad with the per-eye matrix from
ImGuiPanel.panelMvp. afield.examples.four.ImGuiVrPanelDemo is a worked example.
src/jvmMain/kotlin/afield/graphics/vulkan/imgui/ImGuiPanel.kt — read the source
ImGuiPanel
class ImGuiPanel(val imgui: ImGuiRenderToTexture, var halfWidth: Float, var halfHeight: Float)
A world-space rectangular panel that shows an ImGuiRenderToTexture and can be raycast against.
| Declaration | Summary |
|---|---|
val imgui: ImGuiRenderToTexture |
|
var halfWidth: Float |
|
var halfHeight: Float |
|
val model |
World transform of the panel. Assign directly, or set it with place / faceToward. |
data class Hit(val worldPoint: Vector3f, val u: Float, val v: Float, val onPanel: Boolean) |
|
fun place(position: Vector3f, right: Vector3f, up: Vector3f): Matrix4f |
Orient the panel at position with the given right and up axes, which are the panel's left-to- right and bottom-to-top as its reader sees them. The text reads correctly from the side right × up points away from; faceToward works that out from where the eye is. right and up need not be unit length or exactly orthogonal — they are re-orthonormalized (up is rebuilt as normal × right). |
fun faceToward(position: Vector3f, eye: Vector3f, worldUp: Vector3f = Vector3f(0f, 1f, 0f)): Matrix4f |
Place the panel at position with its front facing eye, kept upright against worldUp. |
fun panelMvp(view: Matrix4f, projection: Matrix4f, dest: Matrix4f = Matrix4f()): Matrix4f |
Model-view-projection for drawing the quad, given an eye's view and projection. |
fun raycast(origin: Vector3f, direction: Vector3f): Hit? |
Intersect a world ray with the panel plane. Returns null when the ray is parallel to the plane or the intersection is behind origin; otherwise a Hit whose Hit.onPanel says whether it landed inside the rectangle. |
fun interact(origin: Vector3f, direction: Vector3f, pressed: Boolean): Hit? |
raycast plus ImGui pointer injection: when the ray lands on the panel the hit UV is injected as the mouse position (with pressed as the left button); otherwise the pointer is parked far off-panel so ImGui registers no hover. Returns the same Hit as raycast. |
ImGuiRenderToTexture.kt
Draws Dear ImGui (through imgui-java) into a Field render-to-texture target, so an ImGui interface can be shown as a texture on any surface, including a panel in VR.
ImGuiRenderToTexture owns the ImGui context, the font atlas and the vertex buffers, and records
ImGui's draw lists into its target each frame, honouring clip rects with a dynamic scissor. Only the
font atlas is bound as a texture; other ImGui texture ids are ignored. ImGuiPanel places such a
texture in world space and feeds controller rays back to it as pointer input.
src/jvmMain/kotlin/afield/graphics/vulkan/imgui/ImGuiRenderToTexture.kt — read the source
ImGuiRenderToTexture
Minimal imgui-java renderer that draws Dear ImGui into a Field RenderToTexture.
| Declaration | Summary |
|---|---|
var fontSupersample: Float |
Supersample the font atlas: scales the rasterization size and keeps on-screen size stable via FontGlobalScale. |
var monochromeTheme: Boolean |
If true, converts the current style colors to grayscale for a monochrome look. |
var useWindowMouseEvents: Boolean |
When true, feeds window mouse/scroll into ImGui when no pointer override is provided. Set false when you inject pointers manually (e.g., from a stereo controller). |
var displayScale: Float |
Optional UI scale applied via DisplayFramebufferScale (acts like supersampling for layout). |
var ui: (ImGuiIO) -> Unit |
|
fun attachTo(target: RenderTarget = renderer.renderList, name: String = "imgui-rtt", blitName: String = "$name-blit"): ModulePointer |
Convenience: make sure this RTT is rendered every frame (as a preTarget) and blitted to the screen. |
fun cleanup() |
|
override fun prepareAdditionalResources(command: CommandBuffer, index: Int) |
|
override fun renderAdditional(command: CommandBuffer, index: Int, stack: MemoryStack) |
|
fun loadFontFromUrlAsync(url: String, sizePixels: Float = DEFAULT_FONT_SIZE, onFinished: (Boolean) -> Unit = {}) |
Download a TTF font asynchronously and use it once ready. |
fun loadFontFromResource(resourcePath: String, sizePixels: Float = DEFAULT_FONT_SIZE, onFinished: (Boolean) -> Unit = {}) |
Load a TTF font from application resources (classpath). |
fun loadFontFromBytes(bytes: ByteArray, sizePixels: Float = DEFAULT_FONT_SIZE) |
Load a TTF font from an in-memory byte array immediately (next frame). |
fun add(name: String, block: ImGuiDsl.(ImGuiIO) -> Unit) |
|
fun addUi(name: String, block: ImGuiDsl.() -> Unit) |
|
fun addLegacyUi(name: String, block: (ImGuiIO) -> Unit) |
|
fun removeUi(name: String) |
|
fun injectPointer(x: Float, y: Float, down: Boolean, scrollY: Float = 0f) |
Inject a pointer in RTT pixel coordinates (origin top-left). Overrides window mouse for the next frame. |
class ImGuiDsl |
BinocularGazeIntersection.kt
Estimates where the two eyes converge from a pair of per-eye gaze rays.
BinocularGazeIntersection takes the closest approach of the left and right rays and rejects
depths it cannot trust, so a caller can place an object at the point the viewer is looking at.
BinocularGazeIntersectionTest covers it.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/BinocularGazeIntersection.kt — read the source
BinocularGazeIntersection
Closest approach of two forward eye rays, in metres, with explicit rejection of unreliable depth. Real rays can be skew: position is the midpoint of leftPoint and rightPoint, and separation is their gap. Read valid before placing an object there.
| Declaration | Summary |
|---|---|
val minimumAngleDegrees: Double |
The smallest angle between the two rays that still gives a depth, in degrees. Nearer to parallel than this the closest-approach solve is ill-conditioned, so status becomes Status.PARALLEL and no point is written. Must be above 0 and below 90. |
val maximumSeparation: Double |
How far leftPoint and rightPoint may be apart, in metres, before the rays count as missing each other and status becomes Status.RAYS_MISS. Skew rays always leave some gap; this is how much of it to forgive. |
val minimumDistance: Double |
Nearest accepted depth along either ray, in metres. Closer gives Status.OUT_OF_RANGE. |
val maximumDistance: Double |
Farthest accepted depth along either ray, in metres. Beyond it, Status.OUT_OF_RANGE. |
enum class Status |
|
var status |
Why the last update or solve accepted or rejected a depth. Status.NO_TRACKING until the first call. |
val valid: Boolean |
True only for Status.VALID — the one check to make before using position. |
var raysValid |
True when both rays were finite, long enough to normalize and had distinct origins, so leftDirection and rightDirection are meaningful. The convergence tests come after this, so a pair of usable rays that converge badly leaves this true while valid is false. |
val position |
Midpoint of leftPoint and rightPoint — where to put an object the viewer is looking at. |
val leftPoint |
The point on the left eye's ray closest to the right eye's ray. |
val rightPoint |
The point on the right eye's ray closest to the left eye's ray. |
val leftDirection |
Unit forward direction of the left eye's ray, as the last solve normalized it. |
val rightDirection |
Unit forward direction of the right eye's ray, as the last solve normalized it. |
var leftDistance |
Distance from the left eye to leftPoint, in metres. Negative means the convergence is behind the eye. |
var rightDistance |
Distance from the right eye to rightPoint, in metres. Negative means behind the eye. |
var separation |
Gap between leftPoint and rightPoint, in metres — how far the two rays missed by. |
var angleDegrees |
Angle between the two rays, in degrees. Written before the convergence tests, so it is readable even when the rays were too close to parallel to intersect. |
fun invalidate() |
|
fun update(eyes: HtcEyeTrackingState?): Boolean |
|
fun solve(leftOrigin: Vec3, leftRay: Vec3, rightOrigin: Vec3, rightRay: Vec3): Boolean |
Both origins and directions must use the same coordinate system; directions may be unnormalized. |
ComputeTarget.kt
A Target that records compute and ray-tracing modules into a frame without drawing to a window.
Modules and plain command-recording functions are added with += on a ComputeTarget and run each
frame: compute modules first, then ray-tracing modules, then the other targets.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/ComputeTarget.kt — read the source
ComputeTarget
class ComputeTarget(val renderer: Renderer) : Target
Records named compute modules, ray-tracing modules and other Targets into the frame's command buffer.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
The renderer this target records into, and the identity each added module's lifecycle claim is filed under. |
var modules |
The compute modules dispatched each frame, by name, in insertion order and before rayTracingModules. Add with +=. |
var rayTracingModules |
The ray-tracing modules traced each frame, by name, after every compute module has been recorded. |
var others |
Targets recorded after this one's own modules, by name. A (name, (CommandBuffer, Int) -> Unit) pair added with += lands here too, wrapped as a target, which is how plain recording code joins the frame. |
operator fun plusAssign(m: Pair<String, (CommandBuffer, Int) -> Unit>)+6 overloads · also Pair<String, ComputeModulePointer>, Named<ComputeModulePointer>, Pair<String, ComputeModule>, Pair<String, RayTracingModulePointer>, Pair<String, RayTracingModule>, Pair<String, Target> |
|
override fun render(command: CommandBuffer, index: Int) |
ExtEyeGazeInteraction.kt
Combined eye gaze from OpenXR's XR_EXT_eye_gaze_interaction, as a single pose action inside the
HMD target's existing gameplay action set.
Creation returns null on a runtime that does not advertise the extension, reports no eye-gaze
support in its system properties, or refuses the action, its suggested binding or its action
space, so a target asks once and then has either gaze or none. It has to happen before the action
sets are attached to the session. Each frame the gaze pose is located against the target's
reference space and written into an EyeGazeState with the sample time the runtime reports for
it; the state is invalidated first, so a frame in which the action is not active reads as inactive
rather than as the last pose that worked.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/ExtEyeGazeInteraction.kt — read the source
FrameHistory.kt
Tracks the previous frame's camera for one render target, so temporal passes can reproject pixels and know when there is no usable history.
FrameHistory holds the reprojection matrix and an availability flag that drops whenever the
frame sequence, size or scene epoch breaks.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/FrameHistory.kt — read the source
FrameHistory
One render target's frame history: where the camera was last frame, and whether that answer is usable.
| Declaration | Summary |
|---|---|
val current |
This frame's view-projection. |
val previous |
Last frame's view-projection, or a copy of current when available is false. |
val inverse |
inverse(current) — current clip to world, for a pass that unprojects. |
val reprojection |
previous · inverse(current) — current clip position to previous clip position. |
var available |
True if previous describes the immediately preceding frame of the same target and scene. |
var frameId |
The frame id of the last capture, or Long.MIN_VALUE before the first one. |
var width |
Width at the last capture. |
var height |
Height at the last capture. |
val serial: Long |
Alias for frameId, matching the private prototype this was generalised from. |
val packed |
Four matrices then four scalars, ready for a uniform buffer. |
fun invalidate() |
Drop the history at the next capture. |
fun capture(frameId: Long, viewProjection: Matrix4fc, width: Int, height: Int, epoch: Any): Boolean |
Advance to frameId, and report whether history is now available. |
fun params(): Vector4f |
(width, height, available, 0) — the companion vec4 to reprojection in a push constant. |
fun params(out: Vector4f): Vector4f |
Reuse a vector when polling every frame. |
override fun toString(): String |
HasRenderToTexture.kt
A RenderToTexture owner that modules can be attached to as if it were the target itself.
ElectronRTTView implements HasRenderToTexture over the target it draws into, and
WebRTCSink forwards to the view it captures from, so a module reaches either of them the two
ways it reaches a RenderToTexture: +view inside moduleHere { }, or
view += "name" to module. The second form is the two operators here; the first is an operator
on Parameters, the moduleHere { } receiver.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/HasRenderToTexture.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
operator fun HasRenderToTexture.plusAssign(m: Pair<String, ModulePointer>)+1 overload · also Pair<String, Module> |
Attaches a module to HasRenderToTexture.rtt, as rtt += "name" to module does. |
HasRenderToTexture
Something that draws through a RenderToTexture it owns, such as ElectronRTTView, and can stand in for it: +view inside moduleHere { } points the module at rtt, and view += "name" to module attaches the module to it.
| Declaration | Summary |
|---|---|
val rtt: RenderToTexture |
The target this object draws through. |
HtcEyeTracker.kt
Native binding for XR_HTC_eye_tracker, which Field's LWJGL version does not generate.
Layouts and signatures follow HTC's ViveEyeTrackerHelper.cs and enum values in OpenXRUtils.cs: https://github.com/ViveSoftware/VIVE-OpenXR-Unity/tree/25a5fd212420688952ead9deba735357656278ec/com.htc.upm.vive.openxr/Runtime
Only 64-bit ABIs are supported: enum/bool32/float = 4 bytes, pointer/handle/time = 8 bytes, and
create returns null on any other pointer width. The per-eye arrays are inline rather than
pointers, and their elements carry no type/next header.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/HtcEyeTracker.kt — read the source
MndxXDevSpace.kt
Hand-rolled bindings for XR_MNDX_xdev_space.
This is a Monado vendor extension: it never made it into the official OpenXR registry, so
LWJGL generates no bindings for it. We resolve the entry points through xrGetInstanceProcAddr
and lay the structs out by hand. Definitions transcribed from Monado's
src/external/openxr_includes/openxr/XR_MNDX_xdev_space.h (spec version 3, extension 445).
It is the only route to WiVRn's body trackers. The obvious one, XR_FB_body_tracking, is a dead
end there; OpenXRHmdTarget's body-tracking setup says why, and cites the WiVRn source.
The extension enumerates every "xdev" (tracked device) the runtime knows about (HMD, controllers,
body trackers) and hands back an XrSpace per device, which the caller then locates with the
ordinary xrLocateSpace.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/MndxXDevSpace.kt — read the source
MotionItems.kt
Per-item motion for 2D items drawn by stable identity, for passes that need each item's previous pose.
MotionItems keeps this frame's and last frame's MotionPose for each key and writes the
difference, falling back to no motion when the item is new, changed revision or was cleared.
MotionItemsTest covers it.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/MotionItems.kt — read the source
MotionPose
Mutable 2D affine pose. Angles are radians; scale is positive and uniform.
| Declaration | Summary |
|---|---|
var x: Float |
Centre of the item on the x axis, in whatever units the pass draws in. |
var y: Float |
Centre of the item on the y axis. |
var angle: Float |
Rotation about that centre, in radians. Only the difference between two frames is written out. |
var scale: Float |
Uniform scale about that centre. Must be finite and above zero; MotionItems.write requires it. |
var energy: Float |
How much this item should count for. MotionItems.write treats a previous energy at or below 1e-7 as no usable history, so a fading item stops contributing motion before it disappears. |
var softness: Float |
How soft the item's edge is. Like energy, what reaches the pass is the previous frame's value. |
fun set(other: MotionPose) |
MotionItems
Correspondence by stable item identity, not draw-list index. Call begin once per rendered frame (duplicate calls are harmless). Topology or material changes use a different revision; camera cuts and world resets call clear. Retains only this frame and its immediate predecessor. Owner-thread only.
| Declaration | Summary |
|---|---|
val retained: Int |
How many keys are being held, which is this frame's items plus any from the frame before that have not been written again yet. begin drops the rest, and clear takes it to zero. |
fun begin(serial: Long) |
|
fun write(key: Any, pose: MotionPose, out: FloatArray, at: Int = 0, allow: Boolean = true, revision: Any? = null) |
Writes two vec4s: previous centre xy, cosine/sine scaled by previous/current scale; then valid, previous energy, previous softness, reserved. Use FrameHistory.available as allow when camera history is required. |
fun clear() |
MultiPingPongRenderToTexture.kt
Several passes per frame between two images: MultiPingPongRenderToTexture runs an optional
init pass, a number of middle passes and an optional final pass, each reading what the last one
wrote.
It suits iterated kernels such as blurs or relaxation steps, where one frame needs many passes. Shaders can tell the passes apart through push constants the target fills in (pass index, pass count, and whether this is the init or final pass).
src/jvmMain/kotlin/afield/graphics/vulkan/targets/MultiPingPongRenderToTexture.kt — read the source
MultiPingPongRenderToTexture
Multi-pass ping-pong render target.
MultiviewStereoTarget.kt
src/jvmMain/kotlin/afield/graphics/vulkan/targets/MultiviewStereoTarget.kt — read the source
MultiviewStereoTarget
One scene pass into two array layers, with one shared per-frame eye UBO. No packed intermediate.
OpenXREyeTracking.kt
Plain state holders for OpenXR eye tracking: the combined gaze of XR_EXT_eye_gaze_interaction and
the per-eye gaze, pupil and geometry streams of XR_HTC_eye_tracker.
Each holder is filled in place on the renderer's queue thread and carries its own validity flags,
so read EyeGazeState.poseValid or the per-stream flags of HtcEyeState before using a value.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/OpenXREyeTracking.kt — read the source
EyeGazeState
Combined gaze from XR_EXT_eye_gaze_interaction, in the target's LOCAL reference space (metres, +Y up, -Z forward). Updated in place on the renderer's queue thread. Numeric pose values retain the last valid observation; check poseValid before using them.
| Declaration | Summary |
|---|---|
val position |
Where the combined gaze ray starts, in LOCAL space, in metres. Only written while positionValid. |
val orientation |
Which way the gaze points, as a rotation of local -Z. Only written while orientationValid. |
var isActive |
False when the runtime reported no gaze action at all this frame, so nothing else here is current. |
var positionValid |
The runtime gave a usable position in the last frame it was asked. |
var orientationValid |
The runtime gave a usable orientation in the last frame it was asked. |
var positionTracked |
The position is being actively tracked rather than inferred. Valid but untracked is the runtime's best guess from an older observation, which is usable and should be trusted less. |
var orientationTracked |
The orientation is being actively tracked rather than inferred — see positionTracked. |
var sampleTime: Long |
OpenXR time in nanoseconds at which the pose is expressed; zero when unavailable. |
val poseValid: Boolean |
isActive with both halves of the pose valid — the one check to make before reading the pose. |
val isTracked: Boolean |
False for a valid but degraded/untracked pose. |
fun direction(out: Vec3): Boolean |
Copies the gaze's forward (-Z) direction into out, only when the whole pose is valid. |
HtcEyeState
One eye's independent observations from XR_HTC_eye_tracker. Check each validity flag.
| Declaration | Summary |
|---|---|
val position |
Gaze pose in the target's LOCAL reference space, in metres. Forward is local -Z. |
val orientation |
Which way this eye points, as a rotation of local -Z. Written together with position, under gazeValid. |
var gazeValid |
This eye's position and orientation came through in the last gaze query. |
var pupilDiameterValid |
pupilDiameter came through in the last pupil query. Independent of pupilPositionValid. |
var pupilPositionValid |
pupilPosition came through in the last pupil query. Independent of pupilDiameterValid. |
var pupilDiameter |
Pupil diameter in millimetres (unlike the gaze position). |
val pupilPosition |
Normalized sensor coordinates [0, 1], +X right, +Y up. |
var geometricValid |
eyeOpenness, eyeWide and eyeSqueeze came through in the last geometry query, as one group. |
var eyeOpenness |
How open the eye is, 0 shut to 1 open. Written under geometricValid. |
var eyeWide |
How wide the eye is held open past its resting opening, 0 to 1. Written under geometricValid. |
var eyeSqueeze |
How tightly the eye is squeezed shut, 0 to 1. Written under geometricValid. |
HtcEyeTrackingState
HTC per-eye gaze, pupil and geometry observations, updated in place on the renderer's queue thread. The three streams have independent validity and OpenXR timestamps (nanoseconds). A failed stream does not suppress the other streams. Numeric values retain the last valid observation; timestamps reset to zero when a query fails or a frame is skipped.
| Declaration | Summary |
|---|---|
val left |
The left eye's observations. Each of its three streams carries its own validity flag. |
val right |
The right eye's observations, read from the same three queries as left. |
var gazeTime: Long |
OpenXR time in nanoseconds of the gaze poses in left and right; zero when that query failed. |
var pupilTime: Long |
OpenXR time in nanoseconds of the pupil diameters and positions; zero when that query failed. |
var geometricTime: Long |
OpenXR time in nanoseconds of the openness, wide and squeeze values; zero when that query failed. |
OpenXRFrame.kt
src/jvmMain/kotlin/afield/graphics/vulkan/targets/OpenXRFrame.kt — read the source
OpenXRHmdTarget.kt
The OpenXR headset as a render target: OpenXRHmdTarget acquires each eye's swapchain image,
renders registered modules into it, and submits the frame with a projection layer.
It uses the OpenXR session that the Vulkan instance creates when VR is enabled, and also reports
head and controller poses. Instead of rendering per eye, it can copy a side-by-side texture (from
SideBySideStereoTarget or a wide RenderToTexture) into the two eyes.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/OpenXRHmdTarget.kt — read the source
OpenXRHmdTarget
class OpenXRHmdTarget(val renderer: Renderer = fromContext())
OpenXR HMD render target.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
The renderer whose Vulkan device the OpenXR session and its swapchains are created against, on that renderer's queue thread, at the first renderFrame. Found in the box graph by default. The XrInstance itself is older than any device and comes from the bootstrap afield.graphics.vulkan.device.Instance made at startup, which construction claims here. |
var currentEyeIndex: Int |
Index of the eye currently being rendered inside renderEyesVulkan, or -1 when idle. |
val hasEyeViews: Boolean |
True once the runtime has allocated a complete stereo view/projection pair. |
fun currentEyeViewMatrix(out: Matrix4f = Matrix4f()): Matrix4f |
Copies the current eye's view matrix into out and returns it. Only valid while rendering (i.e. called from module render callbacks). |
fun currentEyeProjectionMatrix(out: Matrix4f = Matrix4f()): Matrix4f |
Copies the current eye's projection matrix into out and returns it. Only valid while rendering (i.e. called from module render callbacks). |
fun eyeViewMatrix(eyeIndex: Int, out: Matrix4f = Matrix4f()): Matrix4f |
Like currentEyeViewMatrix but for an explicit eye index. |
fun eyeProjectionMatrix(eyeIndex: Int, out: Matrix4f = Matrix4f()): Matrix4f |
Like currentEyeProjectionMatrix but for an explicit eye index. |
fun headPose(outPosition: Vec3 = Vec3(), outOrientation: Quat = Quat()): Boolean |
Approximate head pose from the currently tracked eye views. |
enum class Hand |
|
data class JointPose(var position: Vec3 = Vec3(), var orientation: Quat = Quat(), var radius: Float = 0f, var positionValid: Boolean = false, var orientationValid: Boolean = false) |
|
enum class HandJoint |
|
class HandTrackingState |
|
enum class BodyJoint |
The body joints reachable over WiVRn, in the order its client forwards them. |
class BodyTrackingState |
|
data class ControllerState(var active: Boolean = false, var poseValid: Boolean = false, var position : Vec3 = Vec3(), var rotation : Quat = Quat(), var selectPressed: Boolean = false, var triggerValue: Float = 0f, var triggerPressed: Boolean = false, var squeezeValue: Float = 0f, var squeezePressed: Boolean = false, var trackpadX: Float = 0f, var trackpadY: Float = 0f, var trackpadTouched: Boolean = false, var trackpadPressed: Boolean = false, var thumbstickX: Float = 0f, var thumbstickY: Float = 0f, var thumbstickTouched: Boolean = false, var thumbstickPressed: Boolean = false, var aPressed: Boolean = false, var bPressed: Boolean = false, var menuPressed: Boolean = false) |
|
fun eyeGazeState(): EyeGazeState? |
Combined gaze from XR_EXT_eye_gaze_interaction, or null when unavailable/not initialized. The state is updated in place; check poseValid (and isTracked for nominal-quality gaze). |
fun htcEyeState(): HtcEyeTrackingState? |
Per-eye gaze, pupil and geometry from XR_HTC_eye_tracker, or null when unavailable. Independent of eyeGazeState; check each observation's validity before using its values. |
fun handState(hand: Hand): HandTrackingState? |
|
fun bodyState(): BodyTrackingState? |
Null when the runtime exposes no body trackers at all. |
fun bodyTrackerCount(): Int |
How many raw body trackers the runtime is publishing. |
fun bodyTrackerPose(index: Int): JointPose? |
The raw pose of the n-th tracker, bypassing the semantic mapping. Use this when the joint mapping came out empty — a runtime other than WiVRn, or a tracker count we don't recognise — and you would still rather have the poses than nothing. |
fun controllerState(hand: Hand, out: ControllerState = ControllerState()): ControllerState |
|
fun hapticPulse(hand: Hand, amplitude: Float = 0.5f, durationNs: Long = 20_000_000L, frequencyHz: Float = 0f) |
|
fun controllerGripPoseMatrix(hand: Hand, out: Matrix4f = Matrix4f()): Matrix4f |
|
var modules |
The graphics modules to draw, by name, the same way RenderTarget holds them. Every module here is rendered once per eye; adding a name that is already present replaces it. |
var meshModules |
The mesh-shader modules to draw, by name, rendered per eye alongside modules. |
var clearColor: FloatArray |
What each eye's swapchain image is cleared to, as RGBA, copied from Options.openXRClearColor at construction. An array of fewer than four is read channel by channel, with the missing channels taken from opaque magenta (1, 0, 1, 1) rather than from zero. |
fun setClearColor(r: Float, g: Float, b: Float, a: Float = 1f) |
|
fun sideBySideRTTParameters(parameters: RTTParameters.() -> Unit = {}): RTTParameters |
Convenience: Build an RTTParameters for a single side-by-side render target (left eye on the left half, right eye on the right half) matching the OpenXR recommended eye dimensions. |
fun copySideBySideToEyes(source: HasImageView) |
Convenience: Copy a side-by-side image into the OpenXR eyes every frame. |
fun copyLayersToEyes(source: HasImageView) |
Copy array layer zero/one directly into the acquired left/right OpenXR images. |
fun clearEyeSource(source: HasImageView) |
|
fun disableSideBySideCopy() |
|
operator fun plusAssign(m: Pair<String, MeshModulePointer>)+5 overloads · also Pair<String, ModulePointer>, ModulePointer, Pair<String, Module>, MeshModulePointer, Pair<String, MeshModule> |
|
data class XrFrameMarkers(var tsFrameBegin: Int = -1, var tsAfterEyes: Int = -1, var tsFrameEnd: Int = -1, var pipeStatsEyes: Int = -1, var perfFrameIndex: Int = -1) |
|
data class XrEyeMarks(val eyeIndex: Int, var tsBegin: Int = -1, var tsEnd: Int = -1, var pipeStatsIndex: Int = -1, var perfIndex: Int = -1) |
|
data class XrProfilerState(val profiler: GpuProfiler, var markers: XrFrameMarkers = XrFrameMarkers(), var perFrameEyeMarks: MutableList<XrEyeMarks> = mutableListOf(), val pending: ArrayDeque<Pending> = ArrayDeque(), val lock: Any = Any()) |
|
data class Pending(val frameCounter: Long) |
|
var xrFrameCounter |
How many OpenXR frames this target has worked through, counted at the end of renderFrame's queue-thread pass rather than by the renderer, so it does not follow Runloop.frame and does not advance while the target is disabled. It is the frame identity the GPU profiler pairs its readbacks with, and what the XR CPU-span report prints against. |
fun renderFrame(renderScene: () -> Unit = {}) |
Wait and locate this frame before renderScene records desktop/stereo work, then submit OpenXR using exactly those views and that predicted display time. The scene callback runs on the caller thread, outside the queue executor, so graphics workers can submit safely. It also runs when XR is inactive, keeping the desktop usable while a headset is asleep. |
var locatedViewFrameId: Long |
Frame whose views scene render callbacks currently read; -1 before the first locate. |
var locatedViewDisplayTime: Long |
Predicted display time, in the runtime's clock, shared by scene rendering and submission. |
fun cleanup() |
OwnedModules.kt
Ownership for the modules a box puts into a renderer's target maps, which is what
renderer += "name" to module records so that something can take the module out again.
The map belongs to the renderer and the module to the box that compiled it, a split the map alone
cannot express: a deleted box's module would otherwise go on drawing, holding the snippet
classloader that built it. Each put therefore files a lifecycle claim under
afield.runtime.lifecycle.ReleasePolicy.KEYED, so stopping the box leaves the module drawing, a
re-run under the same name replaces it, and retiring the box (delete, document close) removes it.
The claim is filed under the renderer as well, and disposing the renderer ends the ones whose
device has gone.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/OwnedModules.kt — read the source
PingPongRenderToTexture.kt
Feedback rendering: PingPongRenderToTexture keeps a ring of images per color attachment, so a
module can sample the previous frame while drawing the next.
Each frame renders into one slot of the ring and exposes the earlier, completed slots as
textures, which is the shape of trails, reaction-diffusion and other effects that read their own
last output. FrameCadencedPingPongRenderToTexture advances the ring only every few frames, for
a slow sequence that downstream code crossfades between.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/PingPongRenderToTexture.kt — read the source
Used in: Field 4 Graphics
PingPongRenderToTexture
RenderToTexture variant that ring-buffers every color attachment. Each frame it renders into one image set while exposing previous completed frames via otherImageView and previousFrameImageViews, so modules can sample feedback history without managing several RTT instances.
| Declaration | Summary |
|---|---|
val frameCount: Int |
How many images the ring holds per color attachment, and so how far back previousFrameImageViews reaches: a frame writes one slot and the other frameCount - 1 stay readable. Two or more, and two is the plain feedback case. |
open val otherImageView: HasImageView |
|
open val thisImageView: HasImageView |
|
val latestImageView: HasImageView |
Color attachment 0 of the slot the last completed frame wrote. The object is fixed and its view follows the ring, so a binding made once keeps reading the latest frame; while a pass records, that is the same image otherImageView gives. |
val previousImageView: HasImageView |
Color attachment 0 of the slot written one frame before latestImageView's, which is two frames back from the one being written. |
val colorAttachmentCount: Int |
How many color attachments the ring carries, each with its own frameCount images. latestImageViewFor and the other per-attachment accessors take a slot in 0 until colorAttachmentCount. |
fun recordReplaceHistory(command: CommandBuffer, attachment: Int = 0, copy: (vkImage: Long) -> Unit) |
Replace every history slot with one coherent external state, without rotating the ring. Intended for prepared playback cuts: copy records a GPU transfer into each image. The ordinary RTT transition helper owns the layouts, so subsequent feedback cannot read stale pre-cut history. Requires colorTransferDst = true, colorSampled = true and a single mip level. Call outside a render pass on the graphics recording thread. The callback records a copy of mip 0, including every array layer; it must not alias any ring image. Ring indices, cadence and the currently bound attachment remain unchanged. This records commands only; Renderer.whenCurrentGraphicsFrameComplete can report their completion. |
open fun otherImageViewFor(attachment: Int): HasImageView |
|
open fun thisImageViewFor(attachment: Int): HasImageView |
|
fun latestImageViewFor(attachment: Int): HasImageView |
|
fun previousImageViewFor(attachment: Int): HasImageView |
|
val previousFrameImageViews: HasImageViewArray |
Previous completed frames in render-time order: index 0 is the most recent completed frame, index 1 is the frame before that, and so on. |
fun previousFrameImageViewsFor(attachment: Int): HasImageViewArray |
|
val frameImageViews: HasImageViewArray |
All frame slots in write order, starting with writeIndex. Outside the update render this is oldest-to-newest for a warmed-up ring, with thisImageView substituted at its physical slot. |
fun frameImageViewsFor(attachment: Int): HasImageViewArray |
|
val writeFrameImageViews: HasImageViewArray |
Explicit alias for frameImageViews, named for code that wants to state that it depends on the write-order rotation. |
fun writeFrameImageViewsFor(attachment: Int): HasImageViewArray |
|
open fun frameImageView(offset: Int): HasImageView |
Returns a frame by this target's default binding order. |
open fun frameImageView(offset: Int, attachment: Int): HasImageView |
|
fun writeFrameImageView(offset: Int): HasImageView |
Returns a frame by write-order offset. Offset 0 is writeIndex, offset 1 is the next physical ring slot, and so on. When the selected physical slot is the slot represented by thisImageView, it resolves through thisImageView so subclass dynamic semantics are preserved. |
fun writeFrameImageView(offset: Int, attachment: Int): HasImageView |
|
fun previousFrameImageView(age: Int): HasImageView |
Returns the Nth previous completed frame relative to the next/current render. age == 1 is the most recent completed frame. |
fun previousFrameImageView(age: Int, attachment: Int): HasImageView |
|
val previousFrameCount: Int |
How many completed frames are readable while one is being written: frameCount less the slot in use, and the length of previousFrameImageViews — the array size a sampler2D history binding declares. |
override fun blitToScreen(renderer: Renderer, target: RenderTarget, attachment: Int, name: String, viewport: Rect?, sourceRect: Rect?, blend: Boolean, additive: Boolean, sourceLayer: Int, warper: String?, numSegmentsW: Int, numSegmentsH: Int, configure: ColorGrade.() -> Unit): ModulePointer |
Blits the latest ping-pong output to a screen target. |
override fun debugBlitToScreen(renderer: Renderer, target: RenderTarget, attachment: Int, name: String, viewport: Rect?, sourceRect: Rect?, blend: Boolean, additive: Boolean, sourceLayer: Int, warper: String?, numSegmentsW: Int, numSegmentsH: Int, configure: ColorGrade.() -> Unit): ModulePointer |
|
override fun beforeRender(index: Int) |
|
override fun afterRender(index: Int) |
FrameCadencedPingPongRenderToTexture
A PingPongRenderToTexture that only advances the frame ring once every framesPerPingPong render calls. Between advances, thisImageView remains the latest completed texture and otherImageView remains the previous one, so downstream modules can crossfade from other to this with crossfade.
| Declaration | Summary |
|---|---|
var framesPerPingPong: Int |
How many render calls one ring slot is held for: 1 advances every frame, 4 renders once and shows that result for four. Writing a value below 1 stores 1, and shortening the period also pulls framesSincePingPong back inside it, so the next advance is not stranded. |
var framesSincePingPong: Int |
Render calls since the last advance, counting 0 on the frame that advanced. It drives crossfade and framePhase, and reaching framesPerPingPong less one is what makes the next call render. |
var pingPongCount: Long |
How many times the ring has advanced, which is how many frames this target has actually rendered as against the render calls it was given. Zero until the first, which is why the first call always renders whatever the cadence says. |
val framesUntilPingPong: Int |
Render calls still to be held before the ring advances again: 0 means the next call renders. It is also 0 before the first advance, which is not waiting on the cadence. |
val crossfade: Float |
0 immediately after a ping-pong render, 1 on the last held frame before the next one. Intended for mix(other, this, crossfade). |
val framePhase: Float |
Similar to crossfade, but normalized by the whole cadence period and therefore stays below 1 before the next ping-pong. |
override val frameImageViewThisIndex: Int |
|
override val thisImageView: HasImageView |
|
override val otherImageView: HasImageView |
|
override fun thisImageViewFor(attachment: Int): HasImageView |
|
override fun otherImageViewFor(attachment: Int): HasImageView |
|
override fun render(command: CommandBuffer, index: Int) |
|
override fun toString(): String |
PointAccumulation.kt
Accumulating points: GPU points plotted into a float image that fades a little every frame, so
they build up into dust, trails or a density picture. PointAccumulation is the target;
AccumulatedPoint records are what it plots.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/PointAccumulation.kt — read the source
PointBlend
How points combine with what is already in a PointAccumulation.
Constants: ADDITIVE, ALPHA.
PointToneMap
How a PointAccumulation's accumulated light becomes a colour between 0 and 1 on screen.
Constants: LINEAR, EXPONENTIAL, REINHARD, LOG.
PointAccumulationSettings
open class PointAccumulationSettings
Settings for a PointAccumulation. Everything except the size and format can change while it runs: the passes read these each frame.
| Declaration | Summary |
|---|---|
var name: String |
Names the modules this accumulation installs, so a second one with the same name replaces it. |
var width: Int |
The target's size in pixels; 0 means the renderer's window. |
var height: Int |
The other half of that size; 0 means the renderer's window. Fixed once the accumulation runs. |
var format: Int |
The float format of the accumulation. 16-bit float is enough while fade is above zero. |
var fade: Double |
How much of the picture disappears each frame, 0..1. 0 keeps everything ever plotted. |
var blend: PointBlend |
How a point combines with what is already there — PointBlend.ADDITIVE light, or PointBlend.ALPHA paint. |
var toneMap: PointToneMap |
How the accumulated light is brought back into 0 to 1 for display; see PointToneMap. |
var exposure: Double |
Multiplies the accumulated light before the tone map. |
var whitePoint: Double |
Where PointToneMap.REINHARD and PointToneMap.LOG reach full brightness. |
var intensity: Double |
Multiplies every point's alpha as it is plotted, so a denser system can keep the same look. |
var background: Vec4 |
The ground the light is shown on, as an sRGB colour (what a CSS colour or hexColor("#010301") gives). |
var viewport: Rect |
The world rectangle that fills the target, as Rect(xMin, yMin, width, height) with y up. |
var keepAspect: Boolean |
Grow viewport to the target's shape, so a circle stays round, instead of stretching it. |
fun viewport(xMin: Double, xMax: Double, yMin: Double, yMax: Double) |
The viewport by its edges, the way a canvas demo writes it. |
PointAccumulation
Points that accumulate: a floating-point PingPongRenderToTexture that every frame keeps 1 - fade of what it held, then lets point modules draw on top with PointBlend.ADDITIVE or PointBlend.ALPHA blending, and shows the result tone mapped over a background colour.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
The renderer to build on. |
var settings: PointAccumulationSettings |
The settings, and the handle for changing them live. Every pass reads them each frame, so swapping in a new object (as a re-run does) takes effect on the next frame; the size and PointAccumulationSettings.format stay as built. |
val width: Int |
Width of the accumulation in pixels. |
val height: Int |
Height of the accumulation in pixels. |
val target |
The accumulation itself. Point modules are added to it; latestImageView is what to sample. |
var advance: () -> Boolean |
Called once per frame: return false to hold the picture where it is this frame. |
val fadeModule: ModulePointer |
The pass that carries the previous frame forward, faded. Always runs first. |
override val imageView: ImageView |
The latest accumulated frame, so the accumulation can be sampled like any texture. |
val latestImageView: HasImageView |
The latest accumulated frame. |
override fun render(command: CommandBuffer, index: Int) |
Runs the fade and every point module, unless advance says to hold. |
fun clear() |
Erase the picture on the next frame that advances. |
fun effectiveViewport(): Vec4 |
The viewport actually used, (xMin, yMin, xMax, yMax): PointAccumulationSettings.viewport, grown about its centre to the target's shape when PointAccumulationSettings.keepAspect is set. |
fun pointParameters(name: String, configure: Parameters.() -> Unit = {}): Parameters |
Parameters for a module that draws into this accumulation: the target's format, no depth, and the blend from PointAccumulationSettings.blend, expecting premultiplied colour. Hand them to moduleHere(...), then add the module with add. |
fun add(name: String, module: ModulePointer) |
Add a module that draws into this accumulation, after the fade. |
fun plot(points: () -> HasBuffer, count: Int, name: String = "${settings.name}-plot"): ModulePointer |
Plot count AccumulatedPoint records from points every frame, mapped from world coordinates through the viewport. points is read when each frame is drawn, so it may hand back a different buffer each time. A plot under a name already in use replaces it. Returns the module, already added. |
fun plot(points: HasBuffer, count: Int, name: String = "${settings.name}-plot"): ModulePointer |
plot for a buffer that does not change. |
fun installInto(renderTarget: RenderTarget = renderer.renderList, withDisplay: Boolean = true, viewport: Rect? = null): ModulePointer? |
Run this accumulation before renderTarget's own modules each frame and, unless withDisplay is false, show it there with display. Installing again under the same name replaces the earlier one. Returns the display module, or null. |
fun display(renderTarget: RenderTarget = renderer.renderList, name: String = "${settings.name}-display", viewport: Rect? = null): ModulePointer |
Draw the accumulation to renderTarget, tone mapped and over the background. viewport is where on the target it lands; null fills it. |
PointShaders
The packaged GLSL behind PointAccumulation and afield.simulation.particles.IteratedSystem. #include "points/system" in a compute shader reaches system.glsl, the iterated-system harness.
| Declaration | Summary |
|---|---|
fun has(name: String): Boolean |
Whether points/<name> names one of these sources, with or without .glsl. |
fun source(name: String): String |
The source of name (system, system.glsl, ...). |
RenderTarget.kt
The render pass that draws a window's swapchain image: an ordered collection of modules, mesh modules and other targets recorded into one frame.
Add passes with += on a RenderTarget; a RendersAfter pass is moved behind the target it
depends on before the list is recorded.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/RenderTarget.kt — read the source
RenderTarget
Records a window's frame from its named modules, mesh modules and other Targets, in insertion order.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
The renderer that owns the window this target draws, and that submits the command buffer the passes record into. |
val windowIndex: Int |
Which of the renderer's windows this target draws, matching the context it reads its swapchain and depth attachments from. renderer.renderTargetFor(windowIndex) is how a box reaches the one it wants, and the main window's is Renderer.renderList. |
var modules |
The modules drawn into this window, by name, recorded in insertion order before meshModules. Add with +=; passing a name-to-ModulePointer pair takes any entry already under that name out first, so re-adding one moves it to the end of the order. |
var meshModules |
The mesh-shader modules drawn into this window, by name, recorded after modules and in their own insertion order. |
var others |
Targets rendered after this one's pass has ended, by name — the render-to-textures and compute targets chained behind the window. A RendersAfter entry is moved behind the target it names before the list runs. |
var preRecordedInsideRendering: List<CommandBuffer>? |
Secondary command buffers to execute inside the rendering instance in place of recording modules and meshModules this frame. It is consumed: the field is set back to null once the frame has used it. |
var preTargets |
Targets rendered before any of this one's render-pass work, by name — where an upload or streaming copy goes, since a transfer cannot be recorded inside dynamic rendering. |
operator fun plusAssign(m : Pair<String, MeshModulePointer>)+6 overloads · also Pair<String, ModulePointer>, ModulePointer, Pair<String, Module>, MeshModulePointer, Pair<String, MeshModule>, Pair<String, Target> |
|
var seenBefore |
The swapchain image indices this target has already presented, which decide whether a frame's first barrier declares the old layout as present-source or undefined. It is cleared when the swapchain is recreated, since a resize hands back brand-new images. |
override fun render(command: CommandBuffer, index: Int) |
RendersAfter
A pass that has to render after another Target in the same list: a publisher after the texture it publishes. Wherever it was added, RenderTarget moves it behind rendersAfter before it iterates.
| Declaration | Summary |
|---|---|
val rendersAfter: Target |
The target this pass has to follow. RenderTarget looks for it in the same others map and moves this entry behind it; a target that is not in that map leaves the order alone. |
RenderToTexture.kt
Offscreen rendering: RenderToTexture draws modules into its own color (and optional depth)
images, which other modules then sample as textures.
A box beneath the renderer builds one with a configuration block over RTTParameters (size,
formats, attachment count, mip levels, clearing, depth), registers modules to draw into it, and
hands its image views to whatever samples the result. The same target can be blitted to the
window, read back to the CPU, or saved as a JPEG. PingPongRenderToTexture and
MultiPingPongRenderToTexture build feedback and multi-pass chains from it.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/RenderToTexture.kt — read the source
RTTParameters
Configuration for a RenderToTexture: size, formats, attachments, clearing and depth, set in its constructor block.
| Declaration | Summary |
|---|---|
var width: Int |
Width of every attachment, in pixels. |
var height: Int |
Height of every attachment, in pixels. |
var mipLevels: Int |
Mip levels on the color attachments; more than one asks for generateColorMipmaps. |
var format: Int |
The VK_FORMAT_* used for any color attachment colorFormats does not name. |
var colorAttachments: Int |
How many color attachments to create. |
var colorFormats: List<Int> |
Per-attachment formats; empty uses format throughout, and a list must be colorAttachments long. |
var colorLayerCount: Int |
Single layer count for all color attachments; 0 infers it from the external views. |
var forceColorArrayView: Boolean |
Expose color attachments as sampler2DArray even for a single layer. |
var viewports: List<afield.app.protocol.Rect> |
Optional viewport array override. |
var multiviewViewMask: Int |
Non-zero enables VK_KHR_multiview / dynamic-rendering multiview. |
var externalColorViews: Map<Int, HasImageView> |
Optional externally-owned attachments, by slot. |
var clearColor: Vec4 |
The RGBA the color attachments are cleared to when clearFirst. |
var clearFirst: Boolean |
Clear the color attachments at the start of the pass; false loads the previous contents instead. |
var colorStoreOp: Int |
The color attachments' VK_ATTACHMENT_STORE_OP_*. |
var colorSampled: Boolean |
Whether the result will be sampled, which controls usage flags and the final transition. |
var colorTransferSrc: Boolean |
Allow the produced color image to be used as a transfer source — mirroring, or copying into an OpenXR swapchain. |
var colorTransferDst: Boolean |
Allow the produced color image to be used as a transfer destination. |
var colorStorage: Boolean |
Opt in when an external compute pass writes the color attachment as a storage image. |
var generateColorMipmaps: Boolean |
Generate the color mip chain automatically, when mipLevels is more than one. |
var mipBlitFilter: Int |
Filter for the mip-generating blits; downgraded to VK_FILTER_NEAREST where linear is unsupported. |
var useDepth: Boolean |
Create a depth (by default depth+stencil) attachment and depth-test against it. |
var depthFormat: Int |
The depth attachment's VK_FORMAT_*; depth+stencil by default. |
var clearDepth: Float |
The depth value cleared to when depthLoadOp clears. |
var clearStencil: Int |
The stencil value cleared to when depthLoadOp clears. |
var depthLoadOp: Int |
The depth attachment's VK_ATTACHMENT_LOAD_OP_*. |
var depthStoreOp: Int |
The depth attachment's VK_ATTACHMENT_STORE_OP_*. |
var depthSampled: Boolean |
Set true only if you intend to sample the depth later. |
var debugName: String |
Name for this target in Vulkan debug labels; empty synthesizes one from a counter and the allocation site. |
var exportMetalIOSurface: Boolean |
Create color attachments with IOSurface-exportable backing (MoltenVK) so the target can be handed zero-copy to a hardware video encoder (field-encode). Opt-in; macOS/MoltenVK only. |
var importMetalIOSurface: Long |
Back color attachment 0 with an externally-created IOSurface, given as its IOSurfaceRef pointer, instead of exporting one. Preferred for streaming: an IOSurfaceCreate-born surface carries a real pixelFormat that CoreVideo/VideoToolbox can wrap, where MoltenVK's export leaves it 0. macOS/MoltenVK only; the surface's format and size must match this target's. |
var exportOpaqueFd: Boolean |
Create color attachments with opaque-fd-exportable backing (VK_KHR_external_memory_fd) so the target can be handed zero-copy to NVENC through CUDA external memory import (field-encode-linux). Opt-in; Linux/NVIDIA only; see VulkanExternalMemory. |
var exportDmaBuf: Boolean |
Create color attachments with dma-buf-exportable backing and a driver-chosen DRM format modifier, so the target can be handed to any dma-buf importer — including Field's own external-browser receiver, which is what makes that receiver testable without a browser. Opt-in; Linux only; see LinuxDmaBufExport. |
RenderToTexture
An offscreen target holding one set of color attachments, any number of them, which its modules draw into and other modules sample as textures. Optionally a depth+stencil attachment as well, to depth-test against while rendering. The attachments are single-buffered, so reading last frame's output while writing this one is PingPongRenderToTexture's job rather than this class's.
RollingCapture.kt
Reads a rendered image back to the CPU a strip at a time, so a full-frame capture never stalls a frame.
RollingCapture copies one horizontal strip per frame into a snapshot image, then copies the
finished snapshot into a CPU-visible staging buffer.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/RollingCapture.kt — read the source
RollingCapture
Rolling capture that copies a horizontal strip from a source image into an internal snapshot image each frame. Once a full frame has been copied, you can copy the snapshot to a CPU-visible buffer (e.g., on a low-priority transfer queue) without stalling rendering.
| Declaration | Summary |
|---|---|
val snapshotView |
A view of the snapshot image itself, so a pass can sample the partly-rebuilt frame on the GPU without waiting for a CPU readback. Expect to see the strip boundary: the rows above recordStrip's next row are from this sweep and the rows below are from the previous one. |
val stagingBuffer |
Host-visible, host-coherent buffer holding one whole captured frame, tightly packed at the capture's own size after downsample. copySnapshotToStaging fills it; nothing here synchronises the read, so wait on that copy's fence before looking at the bytes. |
var fullFrameReady: Boolean |
True once the strips have covered the whole image and the snapshot is a complete frame. While it is true recordStrip does nothing, so capture pauses rather than overwriting a frame nobody has taken; copySnapshotToStaging clears it and the next strip starts a new sweep. |
fun recordStrip(command: CommandBuffer) |
Record a strip copy from the source image into the snapshot image. Call this at the end of your frame on the graphics command buffer. |
fun copySnapshotToStaging(command: CommandBuffer) |
Copy the accumulated snapshot image to the staging buffer. Call on a transfer/graphics command buffer after checking fullFrameReady. |
fun copySnapshotToStagingAsync(): Fence |
Convenience: records and submits the staging copy on the internal transfer queue. Returns a fence you can wait on from a CPU thread. |
fun startBackgroundRead(onFrame: (ByteBuffer) -> Unit, sleepMs: Long = 1L) |
Start a background thread that waits for full captures, kicks the async staging copy, waits the fence, and hands the CPU buffer to onFrame. |
fun stopBackgroundRead() |
|
fun cleanup() |
|
fun install(r : Renderer, onFrame: (ByteBuffer) -> Unit, name : String = "__rolling_capture__") |
SideBySideStereoTarget.kt
Stereo rendering into one packed image: SideBySideStereoTarget draws the scene once per eye
and lays the two views side by side, left eye on the left half.
With an OpenXRHmdTarget attached, the per-eye view and projection matrices come from the
headset and the packed image can feed its swapchains; without one, an internal stereo camera
supplies them, so the same box code renders a stereo pair on a desktop window.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/SideBySideStereoTarget.kt — read the source
Used in: Field on Apple Vision Pro
SideBySideStereoTarget
Side-by-side stereo helper that renders two eye passes into a single RTT (left half / right half).
| Declaration | Summary |
|---|---|
val renderer: Renderer |
The renderer whose device owns the eye passes and the packed image. Found in the box graph by default. |
val eyeWidth: Int |
Width of one eye's pass in pixels. Defaults to half the swapchain's, so the pair fills the window. |
val eyeHeight: Int |
Height of one eye's pass in pixels. Defaults to the swapchain's full height. |
var openXR: OpenXRHmdTarget? |
The headset to take per-eye view and projection matrices from, and to hand packed to. Null renders the same stereo pair from stereoCamera instead, which is the desktop-window path. |
val stereoCamera |
The camera the eye passes use when neither poseSource nor openXR supplies a head. |
val packed |
The finished pair, 2 * eyeWidth by eyeHeight, left eye in the left half. This is what a desktop window samples and what OpenXRHmdTarget.copySideBySideToEyes copies halves out of; it carries both transfer usages so either path works. |
override val imageView |
The packed pair, so a SideBySideStereoTarget is itself usable anywhere a HasImageView is — a desktop window, a postprocess pass, an OpenXR side-by-side source. |
var currentEyeIndex: Int |
Which eye the modules are being drawn for right now: 0 left, 1 right, -1 between the two passes. Module code reads this to pick per-eye data, and it is the eye currentEyeViewMatrix and currentEyeProjectionMatrix answer for. |
var poseSource: StereoPoseSource? |
A remote head to render from, in preference to openXR and to stereoCamera. |
var renderedPoseFrameId: Long |
Identity of the pose the most recent frame was rendered from, or -1 before either the remote source or OpenXR has located a pose. |
var renderedPoseDisplayTime: Double |
Predicted display time for renderedPoseFrameId, in seconds in the selected source's clock. |
fun registerModule(name: String, factory: () -> ModulePointer) |
Register a module factory; it will be instantiated once per eye. |
operator fun plusAssign(entry: Pair<String, () -> ModulePointer>) |
|
fun attachToOpenXR(target: OpenXRHmdTarget) |
Convenience: attach this packed RTT as the OpenXR side-by-side source. |
fun currentEyeViewMatrix(out: Matrix4f = Matrix4f()): Matrix4f |
Current eye view matrix (a latched poseSource first, then OpenXR, then stereoCamera). |
fun currentEyeProjectionMatrix(out: Matrix4f = Matrix4f()): Matrix4f |
Current eye projection matrix (a latched poseSource first, then OpenXR, then stereoCamera). |
fun controllerState(hand: Hand = Hand.RIGHT, out: ControllerState = ControllerState()): ControllerState |
Controller state passthrough to OpenXR when attached; otherwise returns a simple mock right-hand controller positioned via updateMockControllerFromRay or updateMockControllerFromCamera. |
fun updateMockControllerFromCamera(distanceMeters: Float = 1f) |
Position the mock controller 1m along the current camera forward vector. |
fun updateMockControllerFromRay(origin: Vector3f, direction: Vector3f, distanceMeters: Float = 1f) |
Position the mock controller along an arbitrary ray (origin + direction * distance). |
fun enableMouseController(mouse: MouseEvents = renderer.window.mouseEvents ?: MouseEvents(renderer), name: String = "sbs-mouse-controller", distanceMeters: Float = 1f): () -> Unit |
Attach mouse handlers to drive the mock controller. Re-registering replaces previous handlers. |
override fun render(command: CommandBuffer, index: Int) |
StereoCamera
class StereoCamera(val base: Camera = Camera())
Minimal stereo camera: offsets along the right vector by +/-IPD/2, converges on a focus point, and supports asymmetric frustum offsets through the base projection.
| Declaration | Summary |
|---|---|
var ipd: Float |
Interpupillary distance in world units, split half to each side of base along its right vector. The default 0.064 is the usual adult average in metres. |
var convergenceDistance: Float |
Distance multiplier for the stereo convergence point, measured from the base camera toward its target along the forward vector. |
var asymmetricFrustumOffset: Double |
Shifts each eye's principal point instead of toeing its view in: the left eye gets -asymmetricFrustumOffset and the right +asymmetricFrustumOffset as the base projection's ox, so the units are half-widths of what that projection frames at its match depth. Zero leaves both frusta centred, and convergence is then convergenceDistance's business alone. |
data class EyePose(val eye: Camera) |
|
fun eyes(): Pair<EyePose, EyePose> |
|
fun eyeViewMatrices(): List<Matrix4f> |
|
fun projectionMatrices(): List<Matrix4f> |
StereoEyeCopy.kt
src/jvmMain/kotlin/afield/graphics/vulkan/targets/StereoEyeCopy.kt — read the source
StereoPoseSource.kt
The interface for a stereo head pose that arrives from somewhere other than OpenXR, such as a remote headset.
A StereoPoseSource is latched once per frame so both eyes render from the same pose, and it
reports which frame that pose belongs to.
src/jvmMain/kotlin/afield/graphics/vulkan/targets/StereoPoseSource.kt — read the source
StereoPoseSource
A source of stereo viewing geometry that is neither OpenXR nor a desktop mock — a head somewhere else, reporting where it is.
| Declaration | Summary |
|---|---|
fun latch(): Boolean |
Take the newest usable pose as the one to render this frame from. |
val latchedFrameId: Long |
Identity of the latched pose, or -1 when nothing has ever been latched. |
val latchedDisplayTime: Double |
The head's own predicted display time for the latched pose, in the head's clock (seconds). Meaningless against this machine's clock; carried so a consumer can hand it back to the head. |
val eyeCount: Int |
Views the latched pose carried. One on the visionOS simulator, two on hardware. |
fun eyeViewMatrix(eyeIndex: Int, out: Matrix4f = Matrix4f()): Matrix4f |
World→eye for the latched pose. Indices past eyeCount clamp to the last view. |
fun eyeProjectionMatrix(eyeIndex: Int, out: Matrix4f = Matrix4f()): Matrix4f |
Projection for the latched pose, in this renderer's own depth convention. |
fun headPose(outPosition: Vec3 = Vec3(), outOrientation: Quat = Quat()): Boolean |
Head pose of the latched frame. False when nothing is latched. |
fun controller(hand: OpenXRHmdTarget.Hand, out: OpenXRHmdTarget.ControllerState): Boolean |
Fill out with hand's controller as of the latched pose; false when this source has none. |
16bits.kt
Half-precision (binary16) conversion and packing for data headed to GPU buffers.
floatToHalfBits and halfBitsToFloat convert single values, packHalf1x16 and packHalf2x16 pack them as GLSL's packHalf2x16 does, and the .half views (Vec2h, Vec3h, Vec4h) mark a Field vector for 16-bit storage.
src/jvmMain/kotlin/afield/graphics/vulkan/util/16bits.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun packHalf1x16(x: Float): Int |
Converts one float to binary16 and returns the sixteen bits zero-extended into an Int. |
fun packHalf2x16(x: Float, y: Float): Int |
Packs two floats into one Int as binary16 pairs, with x in the low sixteen bits and y in the high sixteen: the layout GLSL's packHalf2x16 produces and unpackHalf2x16 reads. |
fun floatToHalfBits(value: Float): Short |
Convert a Float to IEEE 754 binary16 bit pattern (half float). |
fun halfBitsToFloat(bits: Short): Float |
Convert an IEEE 754 binary16 bit pattern (half) to Float. |
Top-level values
| Declaration | Summary |
|---|---|
val Vector2f.half: Vec2h |
Marks this vector for 16-bit storage: Vector2f(0.4f, 0.9f).half is the idiom for giving a push-constant member or a buffer field its half-precision form. |
val Vector3f.half: Vec3h |
Marks this vector for 16-bit storage, wrapping it rather than copying it. |
val Vector4f.half: Vec4h |
Marks this vector for 16-bit storage, wrapping it rather than copying it. |
Vec2h
data class Vec2h(val v: Vector2f)
A two-component vector marked for 16-bit storage, holding the full-precision Vector2f until something writes it out.
Vec3h
data class Vec3h(val v: Vector3f)
Three components marked for 16-bit storage. The packed form is two uints — x and y in the first, z in the low half of the second — so it costs the same eight bytes a uvec2 does.
Vec4h
data class Vec4h(val v: Vector4f)
Four components marked for 16-bit storage, packed into two uints and read back in the shader as a uvec2: eight bytes where a vec4 takes sixteen, at enough precision for a colour.
AutoDisposer.kt
Releases Vulkan handles when the Kotlin object that owned them is garbage collected.
A resource calls AutoDisposer.register with a cleanup lambda and keeps the returned
AutoDisposer.Token. When the object has been collected, the renderer's frame loop calls
AutoDisposer.drain and queues the cleanup on its retirement queue. Explicit cleanup calls
AutoDisposer.Token.disable first, so a resource is released at most once whichever path gets
there first. Tests are in AutoDisposerTest.
src/jvmMain/kotlin/afield/graphics/vulkan/util/AutoDisposer.kt — read the source
AutoDisposer
GC-driven disposal, with optional device ownership for deterministic shutdown. Cleanup actions must capture handles, not the registered object itself.
| Declaration | Summary |
|---|---|
class Token internal constructor(internal val ref: PhantomReference<Any>, internal val name: String, internal val site: StackTraceElement?, internal val owner: Any?, internal val order: Long) |
|
fun register(target: Any, name: String = target.javaClass.simpleName.ifEmpty { "unnamed" }, owner: Any? = null, cleanup: () -> Unit): Token |
Track target; cleanup runs once after it is collected, or when owner shuts down. |
fun drain(owner: Any? = null, enqueue: (Runnable) -> Unit) |
Poll collected objects, keeping other owners' releases for their own render thread. |
Camera.kt
The Vulkan renderer's look-at camera: a position, a target and an up vector, with a SuperProjection for its projection.
Camera supplies the model-view matrix as a uniform and moves in world or camera-local terms: dolly, pan, yaw, pitch, roll, and rotation around either the camera position or the target. OrbitControls drives it from the mouse, and CameraPose.kt converts it to and from a rigid pose.
src/jvmMain/kotlin/afield/graphics/vulkan/util/Camera.kt — read the source
Camera
class Camera(val renderer: Renderer = fromContext())
The Vulkan renderer's look-at camera: a position, a target and an up vector, with a SuperProjection for its projection.
| Declaration | Summary |
|---|---|
var at |
Where the camera is, in world space. Every movement helper writes through this vector rather than replacing it, so a reference taken once keeps following the camera. |
var up |
Which way is up, in world space. It need not be perpendicular to the view direction: forward and right cope with an oblique one, and reorthogonalizeUp squares it up again — which the rotation helpers do by default, and roll does not. |
var target |
The point the camera looks at. distanceToTarget measures to it, rotateAroundTarget orbits about it, and the default projection takes its focus distance from it. |
val forward: Vector3f |
Forward direction (normalized) from camera position toward target. Falls back to -Z if at == target. |
val right: Vector3f |
Right direction (normalized), derived from forward x up. If up is degenerate, this will be recomputed in a stable way. |
var p |
The projection, built against the renderer's window and told to read its SuperProjection.matchDepth from distanceToTarget. That is installed as SuperProjection.auto, which also re-sizes the projection to the window on every evaluation, so a one-off setAspect or p.resize(w, h) is overwritten on the next frame: to render into an offscreen target whose aspect differs from the window's, replace SuperProjection.auto rather than calling resize once. |
fun setAspect(aspect: Float) |
Manually set projection aspect ratio (width/height). |
var m |
Model-view matrix uniform wrapper. |
fun modelView(): Matrix4f |
Compute model-view matrix from at/target/up. |
fun distanceToTarget(): Float |
Distance from camera to target. |
fun lookAt(eye: Vector3f, center: Vector3f, up: Vector3f = this.up, reorthogonalizeUp: Boolean = true) |
Set camera pose using a classical look-at description. |
fun lookAlong(position: Vector3f, direction: Vector3f, upHint: Vector3f = this.up, reorthogonalizeUp: Boolean = true) |
Look from position in the given direction. direction does not need to be normalized. |
fun moveWorld(delta: Vector3f, moveTarget: Boolean = true) |
Translate in world space. |
fun moveWorld(dx: Double, dy: Double, dz: Double, moveTarget: Boolean = true) |
|
fun moveLocal(forward: Double = 0.0, right: Double = 0.0, upAmount: Double = 0.0, moveTarget: Boolean = true) |
Move in camera-local space. |
fun dolly(amount: Double, moveTarget: Boolean = false) |
Dolly along the view direction. |
fun pan(right: Double, upAmount: Double, moveTarget: Boolean = true) |
Pan in screen space: right/left and up/down in camera-local space. right > 0 moves camera to the right, upAmount > 0 moves upward. |
fun rotateAroundAt(angle: Double, axis: Vector3f, rotateUp: Boolean = true, reorthogonalizeUp: Boolean = true) |
General rotation of view around the camera position (at). |
fun rotateAroundTarget(angle: Double, axis: Vector3f, rotateUp: Boolean = true, reorthogonalizeUp: Boolean = true) |
General orbit of the camera position around the target. |
fun yaw(angle: Double, aroundTarget: Boolean = false, rotateUp: Boolean = true, reorthogonalizeUp: Boolean = true) |
Yaw around camera up axis. |
fun pitch(angle: Double, aroundTarget: Boolean = false, rotateUp: Boolean = true, reorthogonalizeUp: Boolean = true) |
Pitch around camera right axis. |
fun roll(angle: Double, reorthogonalizeUp: Boolean = false) |
Roll around the view direction, rotating up but leaving at and target fixed. |
fun reorthogonalizeUp() |
Make up orthogonal to the current forward direction, and ensure a stable, non-degenerate up vector. |
CameraPose.kt
Rigid camera poses for blending between viewpoints: CameraPose stores a Camera's placement as a dual quaternion plus a focus distance.
pose reads one from a camera and applyPose writes it back. sclerpTo moves a camera toward a target pose by screw interpolation and nlerpTo by normalised linear interpolation.
src/jvmMain/kotlin/afield/graphics/vulkan/util/CameraPose.kt — read the source
Used in: Keyframes: capturing, persisting and replaying live state
Top-level functions
| Declaration | Summary |
|---|---|
fun Camera.pose(): CameraPose |
Reads this camera's current placement as a CameraPose, turning its eye, target and up vectors into a rotation, a translation and a focus distance. |
fun Camera.applyPose(pose: CameraPose) |
Moves this camera to pose, writing its eye, target and up vectors in place. |
fun Camera.sclerpTo(targetPose: CameraPose, t: Float) |
Moves this camera a fraction t of the way toward targetPose by screw interpolation, reading its current pose and writing the blended one back. |
fun Camera.nlerpTo(targetPose: CameraPose, t: Float) |
Moves this camera a fraction t of the way toward targetPose by normalised linear interpolation. |
CameraPose
data class CameraPose(val transform: DualQuaternionf, val focusDistance: Float)
A camera body transform plus the look-at focus distance needed to reconstruct Field's traditional at/target/up camera state.
| Declaration | Summary |
|---|---|
val transform: DualQuaternionf |
Where the camera is and which way it faces, as one rigid transform — rotation and translation together in a dual quaternion, which is what makes two poses interpolable as a unit rather than axis by axis. |
val focusDistance: Float |
How far down forward the look-at target sits, in world units. A pose carries this because transform alone cannot say it, and Field's camera is steered by a target point rather than by a rotation. |
val position: Vector3f |
The eye point, read out of transform as its translation. |
val rotation: Quaternionf |
The camera's orientation, read out of transform. |
val forward: Vector3f |
The viewing direction: the OpenGL-style -Z axis turned by rotation, and a unit vector. |
val up: Vector3f |
The camera's up axis: +Y turned by rotation. Already perpendicular to forward, since a rotation is applied to both. |
val target: Vector3f |
The point the camera looks at, focusDistance along forward from position. This is what a Camera is actually steered by, so it is the value applyPose writes. |
fun nlerp(other: CameraPose, t: Float): CameraPose |
Blends toward other by normalised linear interpolation of the two dual quaternions, with focusDistance interpolated linearly alongside. |
fun sclerp(other: CameraPose, t: Float): CameraPose |
Blends toward other by screw interpolation, so the camera turns and travels at a constant rate about a single screw axis. focusDistance is interpolated linearly alongside. |
DeferredCleanup.kt
Releases a renderer's deferred GPU resources at shutdown, when no later frame will come to age them out.
While a renderer is running, a resource retired by one frame waits in a queue until the frames
that might still be reading it have finished. Shutdown removes that guarantee, so Renderer
drains the queues by hand: once the present queue, the graph queue and the device are idle, and
then again after releasing the resources a document still held, because releasing those enqueues
more work of its own.
Each batch is cleared before it is invoked, so a destructor may enqueue dependent work, and a destructor that throws does not strand the resources queued behind it. Failures are collected and handed back for the owner to report once the rest of its teardown is done, which is why a broken release shows up as one exception with the others suppressed on it rather than as a half-finished shutdown.
src/jvmMain/kotlin/afield/graphics/vulkan/util/DeferredCleanup.kt — read the source
FieldProbe.kt
Samples values out of a running shader into a GPU buffer and shows them in a box's output as a histogram and summary statistics.
A shader includes fieldProbe and writes scalar or vector samples into a buffer bound at
FIELD_PROBE_DEFAULT_BINDING; ShaderFieldProbe reads that buffer back off the render thread,
builds a FieldProbeSnapshot (counts of NaN, infinite and non-zero samples, ranges, summaries)
and, with ShaderFieldProbe.display, publishes it to an output channel at most
ShaderFieldProbe.maxDisplayHz times a second. ShaderFieldProbe.ordinalRange limits which
invocations record, and ShaderFieldProbe.onSnapshot hands each snapshot to box code.
src/jvmMain/kotlin/afield/graphics/vulkan/util/FieldProbe.kt — read the source
Used in: Field Probes: instrumenting shaders
Top-level functions
| Declaration | Summary |
|---|---|
fun FieldProbeSnapshot.scalarBatch(minSamples: Int = 1, clipLower: Double? = null, clipUpper: Double? = null, clipEpsilon: Double = 1.0e-6): FieldProbeScalarBatch? |
Packages this snapshot's scalar samples for a controller, returning null when there is not enough to act on. |
Top-level values
| Declaration | Summary |
|---|---|
const val FIELD_PROBE_BUFFER_NAME |
The name of the probe's buffer, which matches the shader's declaration to the buffer the host allocates. |
const val FIELD_PROBE_INCLUDE_NAME |
The name a shader includes to get the probe GLSL: #include "fieldProbe". |
const val FIELD_PROBE_DEFAULT_SAMPLES |
How many samples a probe keeps unless told otherwise, 1024, enough for a histogram to have a shape, small enough to read back every frame. |
const val FIELD_PROBE_MAX_SAMPLES |
The ceiling on ShaderFieldProbe.sampleCapacity, 262144. A larger capacity is clamped to it rather than rejected. |
const val FIELD_PROBE_DEFAULT_BINDING |
Default binding for the probe buffer, 63, just above the statistics buffers at 61 and 62. |
const val FIELD_PROBE_DEFAULT_SET |
Default descriptor set for the probe buffer, 0. |
FieldProbeSnapshot
One frame's worth of probe data: how many times the shader called fieldProbe, what it was holding, and a bounded sample of the values themselves.
| Declaration | Summary |
|---|---|
val dimensions: Int |
How many components each recorded value had: 1 for a scalar, 2, 3 or 4 for a vector, and 0 if nothing was recorded. The first call of a frame fixes this, and later calls of a different width are counted as typeMismatchCount instead. |
val invocationCount: Long |
How many times the shader called fieldProbe while the probe was enabled, before any filtering: the denominator for how much of the work was looked at. Each call's position in this count is its ordinal. |
val acceptedCount: Long |
How many of those calls got past the ordinal range and the width check, and so contributed to everything below. |
val finiteCount: Long |
How many accepted values were finite in every component. Also the index space the reservoir fills, so min(finiteCount, capacity) is sampleCount. |
val sampleCount: Int |
How many values are actually in samples: finiteCount capped at the configured capacity. |
val nanCount: Long |
How many accepted values had a NaN component. These are counted and dropped, so they do not reach the ranges or the samples; anyNaN is the quick test. |
val infCount: Long |
How many accepted values had an infinite component — counted and dropped like the NaNs, and checked after them, so a value that is both is counted only as NaN. |
val nonZeroCount: Long |
How many finite values had at least one non-zero component, counting only the components dimensions says are real. A probe whose output should not be zero and whose count here is zero has found something. |
val typeMismatchCount: Long |
How many in-range calls were rejected for recording a different number of components than the first call of the frame did. Normally zero; non-zero means two probes of different widths are sharing one buffer, and only the first width is being measured. |
val exactMin: DoubleArray |
The true minimum of each component over every accepted finite value, one entry per recorded component. Computed with integer atomics across the whole frame, so these are not limited to the sampled values. When nothing finite was recorded every entry is NaN, and there is one of them. |
val exactMax: DoubleArray |
The true maximum of each component, on the same terms as exactMin. |
val samples: List<Vec4> |
The retained values, always as Vec4 whatever dimensions is, with the unused components zero. Up to the capacity they are every finite value in order; past it the shader replaces a hashed slot, which keeps the retained set spread across the frame rather than stopping at the first thousand. Read them through scalarValues and the vector forms, which narrow them to the recorded width. |
val anyNaN: Boolean |
Whether any accepted value had a NaN component this frame. The first thing to check when a render goes black. |
val anyInf: Boolean |
Whether any accepted value had an infinite component this frame. |
val anyNonZero: Boolean |
Whether any finite value was not all zeros: the test for "did this code path run and produce anything". |
fun scalarValues(): List<Double>? |
The retained values as plain numbers, or null if this frame recorded vectors rather than scalars. Checking for null is how you find out what the shader was probing. |
fun vec2Values(): List<Vec2>? |
The retained values as two-component vectors, or null if dimensions is not 2. |
fun vec3Values(): List<Vec3>? |
The retained values as three-component vectors, or null if dimensions is not 3. |
fun vec4Values(): List<Vec4>? |
The retained values as four-component vectors, or null if dimensions is not 4. Note that samples is Vec4 whatever the width, while this is null unless the width really was 4. |
fun scalarSummary(): SummaryStats? |
Count, mean, spread and quartiles over the retained scalar values, or null if this frame was not scalar. Computed from the sample rather than from every call, so compare it against exactMin and exactMax rather than treating it as the whole story. |
fun vec2Summary(): Vec2SummaryStats? |
Per-component summary statistics over the retained two-component values, or null if dimensions is not 2. |
fun vec3Summary(): Vec3SummaryStats? |
Per-component summary statistics over the retained three-component values, or null if dimensions is not 3. |
fun vec4Summary(): Vec4SummaryStats? |
Per-component summary statistics over the retained four-component values, or null if dimensions is not 4. |
fun percentile(alpha: Double): Double |
The value at fraction alpha through the sorted scalar samples, interpolating between the two neighbouring samples, with alpha clamped to 0..1. |
fun summaryHtml(previous: FieldProbeSnapshot? = null, widthPx: Int = 520, chartHeightPx: Int = 120, decimals: Int = 4, background: String = "#000000", textColor: String = "#0af", accentColor: String = "#0af"): String |
Renders this snapshot as the HTML card a probe shows in a box's output: the counts, then a histogram and summary for a scalar frame, or a per-component table and a vector-length histogram for a vector one. |
FieldProbeScalarBatch
data class FieldProbeScalarBatch(val snapshot: FieldProbeSnapshot, val batch: ScalarSampleBatch)
A probe's scalar samples packaged for a controller, with the snapshot they came from kept alongside.
ShaderFieldProbe
class ShaderFieldProbe(val box: Box)
A box's shader probe: the buffer a running shader writes samples into, the read-back that brings them off the render thread, and the output card they are shown on.
| Declaration | Summary |
|---|---|
val box: Box |
The box that owns this probe: whose output channel the card goes to, and whose name a failed read-back is reported against. |
var sampleCapacity: Int |
How many samples are kept and read back each frame, clamped to 1..FIELD_PROBE_MAX_SAMPLES. |
var binding: Int |
The buffer binding the probe's GLSL declares. Change it if the shader's own bindings reach 63. |
var descriptorSet: Int |
The descriptor set the probe's buffer is bound in. |
var enabled: Boolean |
Whether the shader records at all. True by default. |
var maxDisplayHz: Double |
How often the output card may be redrawn, in times a second. Low by default, since building and sending the card is the expensive part of a probe. |
var maxReadHz: Double? |
How often the GPU-to-CPU read may run, for every kind of probe: callback, readLatest or display. Unset, a display-only probe reads at maxDisplayHz and one with a callback or no display channel reads every frame. 0.0 reads every frame. |
var ordinalStart: UInt |
The first invocation ordinal that records, set through ordinalRange or configure. |
var ordinalEndExclusive: UInt |
One past the last invocation ordinal that records. Read-only; set it through ordinalRange or configure. |
var latestSnapshot: FieldProbeSnapshot? |
The most recent completed read-back, or null before the first one lands. |
fun configure(samples: Int = sampleCapacity, binding: Int = this.binding, descriptorSet: Int = this.descriptorSet, enabled: Boolean = this.enabled, ordinalStart: UInt = this.ordinalStart, ordinalEndExclusive: UInt = this.ordinalEndExclusive): ShaderFieldProbe |
Sets several of the probe's settings at once and returns the probe, for configuring it in one named-argument call. |
fun ordinalRange(start: UInt = 0u, endExclusive: UInt = UInt.MAX_VALUE): ShaderFieldProbe |
Records only the calls whose ordinal falls in [start, endExclusive), and returns the probe for chaining. |
fun ordinalRange(range: UIntRange): ShaderFieldProbe |
The same as the two-argument ordinalRange, written as a Kotlin range, so 0u..4095u says what 0u, 4096u does. |
fun onSnapshot(callback: (FieldProbeSnapshot) -> Unit): ShaderFieldProbe |
Hands every completed read-back to callback, and returns the probe for chaining. |
fun display(channel: String = "probe"): ShaderFieldProbe |
Set the box output channel that receives probe summaries, and return this probe for chaining. |
fun deregister(closeOutput: Boolean = true, clearOutput: Boolean = false): ShaderFieldProbe |
Turns the probe off and takes down what it registered: the display channel, the callback, the last snapshot and any read-back still queued. |
fun readLatest(): FieldProbeSnapshot? |
The most recent completed read-back, or null before the first one. The same value as latestSnapshot, as a call for code that reads better that way. |
fun readLatestScalarBatch(minSamples: Int = 1, clipLower: Double? = null, clipUpper: Double? = null, clipEpsilon: Double = 1.0e-6): FieldProbeScalarBatch? |
readLatest put through scalarBatch, so a controller can take the latest samples in one call. |
fun readSnapshot(renderer: Renderer, imageIndex: Int): FieldProbeSnapshot |
Reads the probe buffer for one swapchain image and builds a snapshot from it, without waiting for the GPU. |
FieldStats.kt
Computes streaming statistics of shader values on the GPU and reads the results back to box code.
ShaderFieldStats owns a partial buffer and a result buffer (default bindings
FIELD_STATS_DEFAULT_PARTIAL_BINDING and FIELD_STATS_DEFAULT_RESULT_BINDING) for a shader
that includes fieldStats. Each channel reduces to a FieldScalarStatsSnapshot, whose
FieldScalarStatsSnapshot.quantile and FieldScalarStatsSnapshot.cdf read a fixed-size
centroid sketch; ShaderFieldStats.scalar and ShaderFieldStats.state give per-channel and
per-slot handles with read() and latest().
src/jvmMain/kotlin/afield/graphics/vulkan/util/FieldStats.kt — read the source
Used in: Field Probes: instrumenting shaders
Top-level values
| Declaration | Summary |
|---|---|
const val FIELD_STATS_INCLUDE_NAME |
The name a shader includes to get the statistics GLSL: #include "fieldStats". |
const val FIELD_STATS_PARTIAL_BUFFER_NAME |
The name of the scratch buffer the recording shader appends subgroup partials to, used to match the shader's declaration to the buffer the host allocates. |
const val FIELD_STATS_RESULT_BUFFER_NAME |
The name of the buffer the reduce pass writes finished statistics into, and the only one the host reads back. |
const val FIELD_STATS_DEFAULT_PARTIAL_BINDING |
Default binding for the partial buffer, 61, up beside the probe's 63, where a shader's own bindings are unlikely to reach. Override it on the constructor if they do. |
const val FIELD_STATS_DEFAULT_RESULT_BINDING |
Default binding for the result buffer, 62, next door to FIELD_STATS_DEFAULT_PARTIAL_BINDING. |
ShaderFieldStatsBinding
data class ShaderFieldStatsBinding(val stats: ShaderFieldStats, val producer: Boolean)
What a box remembers about the statistics block its shaders include: which block, and whether this box is the one recording into it.
FieldScalarStatsSnapshot
One channel's statistics for one frame, as the reduce pass left them and the host read them back.
| Declaration | Summary |
|---|---|
val generation: Long |
Counts completed reduce passes since the block was created, and so identifies the frame this came from. Two reads with the same generation are the same frame's numbers. |
val calls: Long |
How many shader invocations recorded into this channel, finite or not. |
val finiteCount: Long |
How many of those values were finite. This is the denominator for mean and variance, and the total weight the quantile sketch is read against. |
val invalidCount: Long |
How many were NaN or infinite. Non-zero here is the signal to look for: it says the shader is producing values that no average will show. |
val nonZeroCount: Long |
How many finite values were not exactly zero: the cheap test for whether a channel is carrying anything at all. |
val min: Double |
The smallest finite value recorded, exactly, not read off the sketch. Positive infinity when nothing finite was recorded. |
val max: Double |
The largest finite value recorded, exactly. Negative infinity when nothing finite was recorded. |
val mean: Double |
The mean of the finite values, sum over finiteCount, and zero when there were none. |
val variance: Double |
The population variance, computed on the GPU as the mean of squares less the square of the mean and clamped at zero. That form loses precision when the values are large and their spread is small, which is where the clamp earns its place. |
val sum: Double |
The sum of the finite values. |
val sumSquares: Double |
The sum of their squares. |
val standardDeviation: Double |
The square root of variance. |
val droppedPartials: Long |
How many subgroup partials the recording pass had to throw away because the partial buffer was full. Non-zero means every number here was computed from a subset, and the fix is a larger maxPartials. |
val centroids: List<Pair<Double, Double>> |
The quantile sketch: value-and-weight pairs in ascending value order, weights summing to finiteCount, empty entries dropped. Read it through quantile and cdf rather than directly. A centroid's value is one recorded subgroup's mean standing in for a whole span of them, so the sketch summarises group averages rather than individual samples. |
fun quantile(q: Double): Double |
The value at quantile q, read off the centroid sketch, with q clamped to 0..1. |
fun cdf(value: Double): Double |
The fraction of recorded values at or below value, read off the centroid sketch and clamped to 0..1. |
FieldStatsStateSnapshot
One smoothing slot's current reading: a statistic of a channel, carried across frames with exponential smoothing instead of being recomputed from scratch.
| Declaration | Summary |
|---|---|
val mode: Int |
Which statistic is being smoothed: 0 for an unused slot, 1 for the mean, 2 for the quantile at parameter. |
val channel: Int |
Which channel's statistic this slot follows. |
val parameter: Double |
The quantile this slot tracks when mode is 2, between 0 and 1. Unread for the mean. |
val halfLife: Double |
How many frames it takes the smoothed value to cover half the distance to a new reading. The per-frame blend is 1 - 2^(-1/halfLife), so larger is slower; the shader side floors it just above zero. |
val value: Double |
The smoothed value as of this frame. Zero on a slot that has not run yet. |
val initialized: Boolean |
Whether the slot has a real reading. A slot starts uninitialised and takes its first frame's statistic whole rather than blending up from zero, and a slot whose mode, channel, quantile or half-life changes restarts the same way, so a reconfigured slot does not drift from its old value to its new one. |
ShaderFieldStatsScalar
A handle on one channel of a statistics block, so box code can hold onto a channel rather than passing its index around.
| Declaration | Summary |
|---|---|
val index: Int |
Which channel this handle refers to. |
fun read(): FieldScalarStatsSnapshot |
Reads the result buffer as it stands, without waiting for the GPU. See ShaderFieldStats.read for what that means for the numbers. |
fun latest(): FieldScalarStatsSnapshot? |
The most recent fence-synchronised read-back for this channel, or null if none has completed yet. This is the one to call each frame. |
ShaderFieldStatsState
A handle on one smoothing slot of a statistics block, obtained from ShaderFieldStats.state.
| Declaration | Summary |
|---|---|
val index: Int |
Which slot this handle refers to. |
fun read(): FieldStatsStateSnapshot |
Reads the result buffer as it stands, without waiting for the GPU. |
fun latest(): FieldStatsStateSnapshot? |
The most recent fence-synchronised read-back for this slot, or null if none has completed yet. |
ShaderFieldStats
A statistics block: the two GPU buffers, the two compute passes that reset and reduce them, and the read-back that brings each frame's numbers to box code.
| Declaration | Summary |
|---|---|
val box: Box |
The box that owns this block. Its name and id are what a failed read-back is reported against. |
val channels: Int |
How many independent statistics this block tracks, 1 to 64. A channel is a slot the shader chooses when it records, not a component of a vector: four channels is how you watch four unrelated quantities. |
val sketchSize: Int |
How many centroids the quantile sketch keeps per channel: a power of two from 16 to 256, and also the reduce pass's workgroup size. Smaller is a coarser FieldScalarStatsSnapshot.quantile and nothing else: the counts and moments are reduced over every partial whatever this is. |
val maxPartials: Int |
How many subgroup partials one channel may record in a frame before the rest are counted as FieldScalarStatsSnapshot.droppedPartials. This is the term that dominates the partial buffer's size, at 48 bytes per partial per channel, and the block is double-buffered, so that figure is allocated twice over. |
val stateSlots: Int |
How many smoothing slots the block carries, 1 to 64. Slots cost almost nothing and are configured from the shader, so the default leaves room for a few without asking. |
val descriptorSet: Int |
The descriptor set both buffers are bound in. |
val partialBinding: Int |
Binding for the partial buffer. Change it only if the shader's own bindings reach this far up. |
val resultBinding: Int |
Binding for the result buffer. |
var cpuSnapshotsEnabled: Boolean |
Whether each frame queues a read-back of the result buffer to the CPU. On by default. |
fun scalar(index: Int): ShaderFieldStatsScalar |
A handle on channel index, for code that would rather hold a channel than an index. Throws for an index outside 0 until channels. |
fun latest(channel: Int = 0): FieldScalarStatsSnapshot? |
The most recent fence-synchronised read-back for channel, or null if none has completed yet. |
fun read(channel: Int = 0): FieldScalarStatsSnapshot |
Reads channel out of the result buffer immediately, without waiting for the GPU. |
fun state(index: Int): ShaderFieldStatsState |
A handle on smoothing slot index. Throws for an index outside 0 until stateSlots. |
fun latestState(index: Int): FieldStatsStateSnapshot? |
The most recent fence-synchronised read-back for slot index, or null if none has completed yet. The per-frame call for a smoothing slot. |
fun readState(index: Int): FieldStatsStateSnapshot |
Reads slot index out of the result buffer immediately, with the same no-fence caveat as read. |
FramePresentationLog.kt
A diagnostic log of swapchain presentation timing, written to a file while enabled.
FramePresentationLog records when each present call was made and returned, and when the presentation or scanout of that image was observed; FramePresentationLog.mark adds a labelled line to the same log.
src/jvmMain/kotlin/afield/graphics/vulkan/util/FramePresentationLog.kt — read the source
FramePresentationLog
A diagnostic log of swapchain presentation timing, written to a file while enabled.
GpuNeighbourGrid.kt
A uniform spatial grid over a particle buffer, rebuilt on the GPU each frame with GpuRadixSort, so a compute shader can find a particle's neighbours by visiting nearby cells.
src/jvmMain/kotlin/afield/graphics/vulkan/util/GpuNeighbourGrid.kt — read the source
GpuNeighbourGrid
A uniform spatial grid over a buffer of particles, built on the GPU every frame, so a compute shader can ask "who is near me" without looking at everybody.
| Declaration | Summary |
|---|---|
val capacity: Int |
How many particles the grid is sized for. |
val cellsPerSide: Int |
Cells along each axis. cellsPerSide * cellsPerSide cells in total. |
var domainMin: Vector2f |
Lower corner of the gridded region, in whatever coordinates the positions are in. |
var domainSize: Float |
Side length of the gridded region, in the same coordinates. |
val renderer: Renderer |
|
val name: String |
Distinguishes this grid's buffers and modules from another's. |
val cellCount: Int |
Total cells. |
val cellSize: Float |
The width of one cell — the largest interaction radius a 3x3 query covers. |
val keys: FrameBufferGroup |
Cell index per particle, sorted after install runs. |
val values: FrameBufferGroup |
Particle index per entry, carried along by the sort — the gather order. |
val cellStart: FrameBufferGroup |
First entry of each cell's run in values. |
val cellEnd: FrameBufferGroup |
One past the last entry of each cell's run. Equal to cellStart for an empty cell. |
fun install(into: ComputeTarget, positions: FrameBufferGroup, count: Int, strideFloats: Int, offsetFloats: Int = 0) |
Installs the four stages into into, in order, under name. |
fun install(into: MutableMap<String, Target>, positions: FrameBufferGroup, count: Int, strideFloats: Int, offsetFloats: Int = 0) |
As install, into any target's others map — a afield.graphics.vulkan.targets.RenderTarget's included. |
fun install(into: MutableMap<String, Target>, positions: () -> FrameBufferGroup, count: () -> Int, strideFloats: Int, offsetFloats: Int = 0) |
As install, with the positions and the particle count read every frame, for a system whose count changes while it runs (particles painted in) or whose positions alternate between two buffers. A count of zero skips the frame; one above capacity is clamped to it. |
fun target(positions: () -> FrameBufferGroup, count: () -> Int, strideFloats: Int, offsetFloats: Int = 0): Target |
The four stages as one Target, for a caller that sequences its own frame and wants the grid rebuilt at a particular point in it rather than as an entry in an others map. positions and count are read every frame, as in the live-count install. |
fun removeFrom(into: ComputeTarget) |
Takes the grid back out of into. |
fun removeFrom(into: MutableMap<String, Target>) |
Takes the grid back out of any target's others map. |
fun cleanup() |
|
fun glsl(prefix: String = "grid"): String |
GLSL declaring this grid's buffer-reference types and the two index helpers a consumer needs, plus a worked comment showing the 3x3 walk. |
GpuProbe.kt
Runs a compute shader against fixture buffers, checks that it ran, and compares its read-back output with stated bounds.
GpuProbe is the facility; a probe whose shader never produced output throws
GpuProbeNeverLanded.
src/jvmMain/kotlin/afield/graphics/vulkan/util/GpuProbe.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
inline fun <R> gpuProbe(renderer: Renderer, name: String = "gpuProbe", noinline pump: (() -> Unit)? = null, block: (GpuProbe) -> R): R |
Run block against a GpuProbe and close it however block ends. |
GpuProbe
A GPU compute shader, put on a bench: fixture buffers, a dispatch chain, evidence that it ran, and numbers printed next to the bounds they were compared against.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
|
val name: String |
|
val pump: (() -> Unit)? |
One rendered frame. { window.pollEvents(); renderer.render() } from jvmMain; from a box, leave it null and drive stillWaiting from inside frames { } with the box's own frame(). |
fun fixture(name: String, floats: Int): FrameBufferGroup |
A buffer of floats floats, host-writable and host-readable, owned by this probe. |
fun adopt(name: String, buffer: FrameBufferGroup, floats: Int): FrameBufferGroup |
Take a buffer this probe did not allocate under its wing, so it can be armed and read. |
fun nameOf(buffer: FrameBufferGroup): String |
The name fixture or adopt gave this buffer, for messages. |
fun sizeOf(buffer: FrameBufferGroup): Int |
How many floats fixture allocated, or 0 if this is not one of ours. |
fun upload(buffer: FrameBufferGroup, from: FloatArray) |
Upload from to the front of buffer. Fails loudly rather than truncating. |
fun upload(buffer: FrameBufferGroup, from: FloatBuffer) |
Upload from to the front of buffer, from its position to its limit. |
fun fill(buffer: FrameBufferGroup, value: Float, floats: Int = sizeOf(buffer)) |
Write value into the first floats floats of buffer. |
inner class Stage internal constructor(val stageName: String, internal val module: ComputeModulePointer, internal val key: String) |
One compute shader, registered on the renderer, with one or more dispatches behind it. |
inner class Bindings internal constructor(private val stage: Stage, private val push: Structure, private val label: String) |
The receiver of Stage.dispatch's binding block. |
val stageList: List<Stage> |
The stages created so far, in the order they run. |
fun stage(stageName: String, glsl: String, configure: Parameters.() -> Unit = {}): Stage |
Compile glsl as a compute shader and register it, after every stage created before it. |
fun arm(vararg outputs: FrameBufferGroup, seconds: Double = DEFAULT_LANDING_SECONDS) |
Prefill every outputs buffer with SENTINEL and start the clock. |
fun landed(): Boolean |
Whether every armed output has been completely overwritten by the GPU. |
fun stillWaiting(): Boolean |
!landed(), plus a deadline and a frame count — the loop condition. |
val framesToLand: Int |
How many frames the caller pumped before everything landed, or -1 if it never did. |
fun idle() |
Wait every fence this window has — "the GPU has nothing of mine left in flight". |
fun settle(vararg outputs: FrameBufferGroup, seconds: Double = DEFAULT_LANDING_SECONDS) |
arm, then pump until landed — the whole wait, when this probe owns a pump. |
fun settleUntil(vararg outputs: FrameBufferGroup, seconds: Double = DEFAULT_LANDING_SECONDS, readyLabel: String = "the caller's readiness condition", ready: () -> Boolean) |
settle, and then keep pumping until ready holds as well. |
fun pumpFrames(n: Int) |
Render n frames. Needs pump, which means jvmMain. |
fun read(buffer: FrameBufferGroup, floats: Int = sizeOf(buffer)): FloatArray |
Read the first floats floats of buffer back to the host. |
fun readInts(buffer: FrameBufferGroup, ints: Int = sizeOf(buffer)): IntArray |
Read the first ints ints of buffer back to the host. |
fun fail(message: String) |
Record an unconditional failure — a shader that did not compile, an exception, a gap. |
fun atLeast(label: String, measured: Number, floor: Number, buffer: String? = null, index: Int? = null) |
measured must be at least floor. Both numbers go in the verdict table. |
fun atMost(label: String, measured: Number, ceiling: Number, buffer: String? = null, index: Int? = null) |
measured must be at most ceiling. |
fun exactly(label: String, measured: Number, expected: Number, buffer: String? = null, index: Int? = null) |
measured must equal expected exactly — counts, indices, set sizes. |
fun varies(label: String, values: FloatArray, floor: Number, buffer: String? = null) |
The spread of values must be at least floor — the guard against a probe that passes because the GPU wrote a constant, or nothing but zeros. |
fun allFinite(label: String, values: FloatArray, buffer: String? = null) |
Every one of values must be finite, and none may still be SENTINEL. |
fun verdict(title: String = name, print: (String) -> Unit = ::println): Int |
Print every claim with its measured value beside its bound, and return the failure count. |
fun verdictLines(title: String = name): List<String> |
verdict's table as lines, for a caller that wants to put it somewhere other than a stream — a box display channel, a markdown fence, a test's assertion message. |
override fun close() |
Remove every module this probe put on the renderer. |
GpuProbeNeverLanded
class GpuProbeNeverLanded(message: String) : RuntimeException(message)
Thrown by GpuProbe.stillWaiting when the armed outputs are never completely written.
GpuRadixSort.kt
A stable GPU radix sort of uint keys in storage buffers, with an optional uint payload carried along.
GpuRadixSort owns its scratch buffers and compute shaders. asFrameBufferGroup wraps a single VulkanBuffer so it can be passed where a per-frame buffer group is expected.
src/jvmMain/kotlin/afield/graphics/vulkan/util/GpuRadixSort.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun VulkanBuffer.asFrameBufferGroup(renderer: Renderer = this.renderer): FrameBufferGroup |
GpuRadixSort
Stable GPU radix sort for uint keys stored in storage buffers.
ImageReducer.kt
Reduces an image to global summary statistics on the GPU in two compute passes.
ImageReducer reads the source as a sampled texture, so the image needs the sampled usage bit and its layout is left alone; results come back through ImageReducer.readResults.
src/jvmMain/kotlin/afield/graphics/vulkan/util/ImageReducer.kt — read the source
ImageReducer
class ImageReducer(val renderer: Renderer = fromContext(), private val imageView: HasImageView)
ImageReducer - reduces an image (HasImageView) to global summary statistics using two Vulkan compute-shader passes.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
|
data class ReductionResult(val min: FloatArray, val avg: FloatArray, val max: FloatArray, val lowAvgLuma: Float, val highAvgLuma: Float, val lowAvgRgb: FloatArray, val highAvgRgb: FloatArray, val midSatAvg: Float, val contrastRange: Float, val contrastMichelson: Float, val lowFraction: Float, val highFraction: Float, val midFraction: Float) |
Data class to hold the reduction results |
fun reduce(commandBuffer: CommandBuffer) |
Execute the reduction on the GPU. Must be called within a command buffer recording. |
fun readResults(): ReductionResult |
Read back the results from the GPU. This should be called after GPU execution has completed (e.g., after fence wait). |
fun cleanup() |
Clean up GPU resources |
fun reduceNow(): ReductionResult |
Execute the reduction once and return the results synchronously. This is a convenience method that creates a command buffer, executes the reduction, waits for completion, and returns the results. |
fun installInto(target: RenderTarget, name: String = "imageReducer") |
Install this reducer into a RenderTarget's others map for per-frame execution. The reduction will be executed every frame as part of the render target's rendering. Results can be read back asynchronously after the frame completes. |
fun installInto(target: RenderTarget, name: String = "imageReducer", onResults: (ReductionResult) -> Unit) |
Install this reducer into a RenderTarget with a callback that receives the results. The callback is invoked after each frame's reduction completes. Note: The callback is invoked synchronously during rendering, so keep it fast. |
KeyCamera.kt
Keyboard camera controls for a Vulkan window.
KeyCamera registers a poll callback on the renderer that moves a Camera from held keys; keyCamera and KeyCameraDelegateProvider create one named after the property it is assigned to, so re-running a box replaces its controls rather than adding more.
src/jvmMain/kotlin/afield/graphics/vulkan/util/KeyCamera.kt — read the source
Used in: Field 4 Graphics
Top-level functions
| Declaration | Summary |
|---|---|
fun keyCamera(renderer: Renderer = fromContext(), camera: Camera = fromContext()): KeyCameraDelegateProvider |
Factory function to create a delegate for KeyCamera using the property's name. |
KeyCamera
Binds keyboard camera controls into the current renderer poll loop.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
The renderer whose window is read for held keys and whose poll loop runs the controls. |
val camera: Camera |
The camera the keys move. R puts its position, target, up vector and field of view back to what they were when this KeyCamera was built, along with decay, movementSpeed and rotationSpeed. |
val name: String |
The key this registration takes in the window's poll map. Building a second KeyCamera under the same name replaces the first rather than adding a second set of controls, which is how re-running a box leaves one. |
var actions |
The motion still owed, as action key to amount and what to do with it. Holding a key sets its amount back to 1 every poll; each poll then decays the amount, calls the action with it, and drops the entry once it falls below 0.01 — which is the glide that carries on after the key comes up. Adding an entry here moves the camera exactly as a key would. |
var perActionDecays |
The decay in force for each action, for overriding decay on one of them. An action with no entry takes decay the next time it runs, and pressing its key clears the entry, so a value set here governs the glide of a key that is already down. |
var decay |
How much of an action's amount survives one 60 Hz frame, raised to the power of the frame's share of 1/60 s so the glide lasts the same wall-clock time at any frame rate. Lower stops sooner; 1 never stops. |
var movementSpeed |
How far one 60 Hz frame of a movement key travels, as a fraction of the camera's distance to its target, so the same keystroke moves less the closer you are. Turning has its own rotationSpeed. |
var rotationSpeed |
Scale for keyboard yaw, pitch and roll; larger values turn the camera faster. |
KeyCameraDelegateProvider
Delegate provider that creates a KeyCamera whose name equals the property name.
| Declaration | Summary |
|---|---|
operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): ReadWriteProperty<Any?, KeyCamera> |
KeyEvents.kt
Keyboard handlers for a Vulkan window, polled with the renderer.
KeyEvents registers handlers per key; a down handler's DownResult says whether it stays registered, runs a callback on the matching release, or starts a per-frame generator that runs while the key is held.
src/jvmMain/kotlin/afield/graphics/vulkan/util/KeyEvents.kt — read the source
DownResult
Result contract for "down" handlers.
| Declaration | Summary |
|---|---|
data object Keep : DownResult() |
Keep the handler registered (default behavior). |
data object Remove : DownResult() |
Unregister the handler after this down event. |
data class OnUp(val onUp: () -> Unit) : DownResult() |
Register a one-shot callback that will be invoked the next time the same key/button goes UP (i.e., the matching release for this specific press). The handler itself remains registered. |
data class WhileDown(val generator: Generator<Boolean, Boolean>) : DownResult() |
Register a per-frame generator that will be stepped every poll. The Boolean input is true while the key is down, false after release. The generator is driven until completion. |
KeyEvents
class KeyEvents(val renderer: Renderer = fromContext(), val name: String = "__keyevents__")
MapOfSequences.kt
Keyed collections of running sequences that a host steps once per frame.
MapOfSequences holds the Runloop's preRun/postRun hooks and a mesh's update map, and can record each hook in the lifecycle ledger; MapOfGenerators holds generators that take an input each step and yield a value, without ledger entries.
src/jvmMain/kotlin/afield/graphics/vulkan/util/MapOfSequences.kt — read the source
MapOfSequences
class MapOfSequences<T>(private val ledgerKind: String? = null)
Named sequences advanced one step per update — the Runloop's preRun/postRun hooks, a mesh's update. Setting a key replaces whatever ran under it; a sequence that ends is dropped.
| Declaration | Summary |
|---|---|
fun update() |
|
operator fun set(t: T, sequence: Sequence<Any?>) |
Run sequence under t from the next update, replacing whatever ran there. |
fun setUnowned(t: T, sequence: Sequence<Any?>) |
set without the ledger entry, for library code that installs a hook on its own schedule and removes it itself — a renderer's geometry hook, a spreadsheet poll — rather than on a box's behalf. |
fun isCurrent(t: T, sequence: Sequence<Any?>): Boolean |
Whether sequence is still what runs, or will run, under t. |
fun removeIfCurrent(t: T, sequence: Sequence<Any?>) |
Remove t only if sequence is still what it holds. |
fun remove(t: T) |
MapOfGenerators
| Declaration | Summary |
|---|---|
val starts |
|
var ongoings |
|
fun update(input: O): MutableList<T> |
|
fun updateNoCollect(input: O) |
|
operator fun set(t: String, g: Generator<O, T>) |
|
fun remove(t : String) |
MouseEvents.kt
Mouse input from a Vulkan renderer's window, delivered to named callbacks.
MouseEvents registers handlers for down, up, move, drag and scroll, keyed by a name so that re-running a box replaces its handler rather than adding another. Each handler receives a MouseEvent with position, delta, scroll, buttons held, and the window size.
src/jvmMain/kotlin/afield/graphics/vulkan/util/MouseEvents.kt — read the source
MouseEvent
Mouse input from a Vulkan renderer's window, delivered to named callbacks.
| Declaration | Summary |
|---|---|
val pos: Vec2 |
Where the pointer is, in points from the window's top-left corner — the same units as windowSize, not framebuffer pixels. |
val delta: Vec2 |
How far the pointer moved since the previous poll, in the same points. Only Type.MOVE and Type.DRAG carry one; a press, release or scroll arrives with zero. |
val scroll: Vec2 |
Wheel and trackpad movement since the previous poll, horizontal then vertical. Only a Type.SCROLL event carries one. |
val buttonsDown: Set<String> |
The buttons held at this poll, under the names left, right, middle and the GLFW numbers. Only buttons some handler asked about are read, so one with no onDown, onUp or per-button onDrag never appears here however hard it is held. An onDrag with no button named asks for left, right and middle. |
val button: String? |
Which button this event is about. A press, a release and a per-button drag name one; a move, a scroll and an any-button drag leave it null. |
val type: Type |
Which of the five kinds of event this is. |
val windowSize: Vec2 |
The window's size, in the same units as pos: window points, not framebuffer pixels. |
enum class Type |
|
fun isDown(name: String) |
|
val uv: Vec2 |
pos as a fraction of the window: (0, 0) at its top-left corner, (1, 1) at its bottom-right. |
MouseEvents
class MouseEvents(val renderer: Renderer = fromContext(), val name: String = "__mouseevents__")
| Declaration | Summary |
|---|---|
val renderer: Renderer |
The renderer whose window is polled. This becomes that window's mouseEvents if it does not already have one. |
val name: String |
The key this polling takes in the window's poll map, which is not the name a handler is registered under. A second MouseEvents built with the same name takes over the polling from the first, so re-running a box leaves one; the name passed to onDown, onMove and the rest is what keeps a box from stacking up duplicate handlers inside it. |
fun onDown(button: String, name: String, callback: (MouseEvent) -> DownResult)+1 overload · also () -> Boolean |
|
fun onDownUntilUp(button: String, name: String, onUp: (MouseEvent) -> Unit) |
Handy helper to attach a one-shot onUp for this button press. |
fun onUp(button: String, name: String, callback: (MouseEvent) -> Boolean)+1 overload · also () -> Boolean |
|
fun onMove(name: String, callback: (MouseEvent) -> Boolean) |
|
fun onDrag(name: String, callback: (MouseEvent) -> Boolean) |
Calls callback whenever the mouse moves with any of the left, right or middle buttons down. Return false to remove it. Needs no onDown beside it. |
fun onDrag(button: String, name: String, callback: (MouseEvent) -> Boolean) |
Calls callback whenever the mouse moves with button down. Return false to remove it. |
fun onScroll(name: String, callback: (MouseEvent) -> Boolean) |
|
fun remove(name: String) |
Remove only the callbacks installed under this name, leaving other tools' input intact. |
Options.kt
Global switches for the Vulkan renderer, mostly read from system properties at startup, and the code that locates the Vulkan loader and driver.
Options holds the platform flags, profiling and frame-presentation logging toggles, debug
naming and labels, and VR. On macOS, Options.doVulkanSDK uses the loader named by
AFIELD_VULKAN_LIBRARY, else VULKAN_SDK, else the newest ~/VulkanSDK version, falls back to
LWJGL's bundled MoltenVK when none exists, and pins the driver chosen by Options.macVulkanDriver.
src/jvmMain/kotlin/afield/graphics/vulkan/util/Options.kt — read the source
Options
Global switches for the Vulkan renderer, mostly read from system properties at startup, and the code that locates the Vulkan loader and driver.
| Declaration | Summary |
|---|---|
val isWindows |
Running on Windows, by os.name. The three platform flags are how the rest of the renderer branches, rather than each asking the JVM again. |
val isLinux |
Running on Linux, by os.name. |
val isMac |
Running on macOS, by os.name. This is the flag that gates the loader and driver work in doVulkanSDK, and the default for unifiedImageLayouts. |
val target |
The native classifier for this OS: macosx, win-x64 or linux-x64. Nothing reads it, but it is not inert: it is an eager val, so first touching Options on an OS that is none of the three throws rather than running on without a platform. |
enum class MacVulkanDriver(val icdManifest: String, val driverId: Int) |
The macOS Vulkan driver (ICD) Field asks the loader for. The SDK ships two, and they differ in ways Field's code can see — see dev_docs/KOSMICKRISP.md. |
var macVulkanDriver: MacVulkanDriver |
Which macOS driver to ask the loader for, from afield.four.vulkanDriver or AFIELD_VULKAN_DRIVER — auto, moltenvk or kosmickrisp, with auto the default and an unrecognised name an error when Options loads. resolvedMacVulkanDriver is the same question with auto answered, and is what doVulkanSDK pins. |
val resolvedMacVulkanDriver: MacVulkanDriver |
macVulkanDriver with AUTO decided. |
val kosmicKrispCanRun: Boolean by lazy |
Apple silicon, macOS 26+ (KosmicKrisp is built on Metal 4), and an SDK of at least KOSMICKRISP_MIN_SDK that ships the driver. Earlier SDKs ship a KosmicKrisp that is not ready for Field: 1.4.335's lacked vertex-pipeline stores, so vkCreateDevice refused Field's features. An SDK whose directory names no version (a hand-set VULKAN_SDK) is taken at its word. |
val KOSMICKRISP_MIN_SDK |
The first SDK whose KosmicKrisp Field's GPU smoke catalog passed on (1.4-conformant). |
val macVulkanSdk: File? by lazy |
The macOS SDK root (…/VulkanSDK/<version>/macOS) whose loader Field uses: VULKAN_SDK when set, else the newest version under ~/VulkanSDK. Null when there is none, in which case LWJGL loads its own bundled MoltenVK directly, with no loader, and KosmicKrisp cannot be reached at all. |
var vulkan_library_name: String? |
The loader doVulkanSDK hands LWJGL: afield.four.vulkanLibrary or AFIELD_VULKAN_LIBRARY when either is set, else the libvulkan.1.dylib inside macVulkanSdk. A path that does not exist is ignored, and setting it after Vulkan has loaded changes nothing. |
val useVulkanSDK |
Whether to hand LWJGL the SDK loader at all. Constant, and read only by doVulkanSDK: turning it off would leave macOS on LWJGL's bundled MoltenVK. |
val robustness |
Whether the device asks for robustBufferAccess, which bounds-checks shader buffer reads at some cost. Off: Field's shaders index within bounds and the smoke catalog is the check. |
var profiling |
Collect per-frame GPU timestamps, which the renderer's profiler reports. afield.four.profiling or AFIELD_FOUR_PROFILING, off by default, and also set from the user's preferences at startup. |
var framePresentationLog |
Start the frame-presentation log with the process rather than when something asks for it — afield.four.framePresentationLog or AFIELD_FOUR_FRAME_PRESENTATION_LOG. From a box, frameLogStart and frameLogStop start and stop the same log without a restart, which is what to reach for when frames are janky. |
var framePresentationLogPath: String? |
Where to write that log. afield.four.framePresentationLogPath or AFIELD_FOUR_FRAME_PRESENTATION_LOG_PATH; null or blank lets FramePresentationLog choose a timestamped path and report it. |
var framePresentationLogWaitAfterPresent |
Where the swapchain's wait for a present goes, which changes what the log measures rather than how it reports it. Off, each acquire first waits for the previous present, so a frame's presentation is observed a frame late; on, every present is waited for as soon as it is made, which dates it to the frame it belongs to and costs throughput. afield.four.framePresentationLogWaitAfterPresent or AFIELD_FOUR_FRAME_PRESENTATION_LOG_WAIT_AFTER_PRESENT. |
var framePresentationLogFlushEvery |
How many lines the log buffers before flushing to disk. Default 120, floored at 1; 1 costs a write per line and is what to use when the run may end in a crash. |
var framePresentationLogPresentWaitTimeoutNs |
How long vkWaitForPresentKHR is given, in nanoseconds. Default 50 ms, floored at 1 ns; a wait that times out warns and the frame goes on, so this bounds a stalled compositor rather than tuning anything. |
var presentTimingQueueSize |
How many past-presentation-timing slots the swapchain reserves for VK_EXT_present_timing. Default 8, floored at 1. Raise it if a high-frequency drain misses entries. afield.four.presentTimingQueueSize or AFIELD_FOUR_PRESENT_TIMING_QUEUE_SIZE. |
var logShadercSource |
Log the line-numbered GLSL handed to shaderc, on the debug,graphics channel, for every stage compiled. On by default, since a compiler error names a line in the assembled source rather than in anything you wrote. afield.four.logShadercSource or AFIELD_FOUR_LOG_SHADERC_SOURCE. |
var computeOncePerFrame |
Record the compute pass once per frame rather than once per window. On by default: a simulation stepped per window advances two or three times a frame when the same document has several windows open on it. afield.four.computeOncePerFrame or AFIELD_FOUR_COMPUTE_ONCE_PER_FRAME. |
var unifiedImageLayouts |
Ask for VK_KHR_unified_image_layouts where the device offers it, which lets every layout but UNDEFINED, PREINITIALIZED and PRESENT_SRC go to Vulkan as GENERAL (ImageLayouts). On by default on macOS only, where the GPU sweep has run with it; elsewhere it is afield.four.unifiedLayouts or AFIELD_FOUR_UNIFIED_LAYOUTS=true. |
var oneTimeSubmitFrames |
Record the renderer's per-frame command buffers with ONE_TIME_SUBMIT, telling the driver each is submitted once and can be discarded after. On by default, and a switch rather than a constant so the gain can be measured on a driver without a rebuild. afield.four.oneTimeSubmit or AFIELD_FOUR_ONE_TIME_SUBMIT. |
var secondaryGraphicsCommandBuffers |
Record a window's graphics work into secondary command buffers, so several can be recorded in parallel. Asked for by default, and used only where Device.supportsMaintenance7 also holds, so on a device without that extension this reads true and nothing changes. afield.four.secondaryGraphicsCommandBuffers or AFIELD_FOUR_SECONDARY_GRAPHICS_COMMAND_BUFFERS. |
var captureCompatibility |
Run as a GPU capture tool expects: it clears validate, dumpToolingInfo, debugObjectNames, debugLabels and shaderDebugPrintf, every one of which either injects a layer of its own or annotates the stream a capture is trying to read. afield.four.captureCompatibility or AFIELD_FOUR_CAPTURE_COMPAT. |
var dumpToolingInfo |
Report the tooling layers attached to the device, which answers "is a capture tool in this stream". Off by default, because the call that asks — vkGetPhysicalDeviceToolPropertiesEXT — runs inside whatever layer injected itself, and under Nsight that has taken the JVM down at startup. afield.four.dumpToolingInfo or AFIELD_FOUR_DUMP_TOOLING_INFO, and captureCompatibility holds it off. |
var debugObjectNames |
Attach names to Vulkan handles, so a validation message or a RenderDoc capture says which buffer rather than which address. On by default, and inert unless validate is on and the driver exposes VK_EXT_debug_utils. afield.four.debugObjectNames or AFIELD_FOUR_DEBUG_OBJECT_NAMES. |
var debugLabels |
Bracket regions of a command buffer with labels, which is what gives a frame debugger its nesting. Same conditions as debugObjectNames, and additionally skipped on macOS. afield.four.debugLabels or AFIELD_FOUR_DEBUG_LABELS. |
var enableVR |
Bring up OpenXR alongside Vulkan: the instance and device ask for the runtime's required extensions, the renderer creates an XR session, and the swapchain picks a format the runtime will take. Off by default and set from the user's preferences at startup, so it is read before the instance exists and changing it afterwards does not reach one. |
fun doVulkanSDK() |
Choose the Vulkan loader and driver. Must run before LWJGL first loads Vulkan, after which VULKAN_LIBRARY_NAME is ignored. Window and Instance call it themselves, because an entrypoint that does not silently gets LWJGL's bundled MoltenVK: no loader, no validation layers, no KosmicKrisp. Repeat calls do nothing. |
var defaultDepthFormat: Int |
The depth format new depth attachments are created with. D32_SFLOAT until chooseDefaultDepthFormat has asked a physical device, after which it is the first of D32_SFLOAT, D16_UNORM, D24_UNORM_S8_UINT, D32_SFLOAT_S8_UINT that device can use as a depth attachment — unless afield.four.depthFormat or AFIELD_FOUR_DEPTH_FORMAT named one (D16, D32, D24S8, D32S8), which pins it with no support check at all. |
fun chooseDefaultDepthFormat(physicalDevice: VkPhysicalDevice): Int |
|
fun depthHasStencil(format: Int): Boolean |
|
fun depthAspectMask(format: Int): Int |
|
fun depthFormatName(format: Int): String |
|
val validateOverride: Boolean? |
What afield.validate or AFIELD_VALIDATE asked for, or null when neither is set to a strict true or false. Kept apart from validate so that "nobody said" is distinguishable from "someone said no": the server takes the user's preference only in the null case, so a command-line answer outranks the preference without having to be re-stated. |
var validate |
Ask the instance for VK_LAYER_KHRONOS_validation. On in a bare entrypoint, and under the server whatever validateOverride says, or the user's preference when it says nothing; captureCompatibility clears it either way. |
var validateOpenXR |
Separate from Vulkan validation: vendor OpenXR extensions can outpace installed API layers. |
var shaderDebugPrintf |
Enable debugPrintfEXT in shaders without turning all of validate on, since the printf plumbing rides on the same validation layer. Off by default; with neither this nor validate on, a shader's printf compiles and prints nothing, and the compiler warns once that it will. afield.four.shaderDebugPrintf or AFIELD_FOUR_SHADER_DEBUG_PRINTF. |
var preferredDevice: String? |
Pick a physical device by its exact reported name rather than by the default rule. Null, and nothing sets it from the environment, so this is for code that has read the device names and wants one of them. A name that matches nothing, or matches a device missing a required extension, is not an error: selection falls through to the default, which on macOS is the device belonging to the driver resolvedMacVulkanDriver names and elsewhere the first that qualifies. |
var enableRayTracing: Boolean |
Ask the device for the ray-tracing pipeline and acceleration-structure extensions. On, and skipped on a device that does not offer them, so the cost of leaving it on is a feature query. |
var requestedImages |
How many swapchain images to ask for, 3 by default. It is an upper bound rather than a count: the swapchain clamps the request to the surface's maximum and then raises it to the surface's minimum, and a surface that reports no maximum drops the request altogether and gives its minimum, which is usually 2. A window that will not triple-buffer is generally that, rather than a driver refusing. |
var vsync |
Prefer a present mode that waits for vertical blank. On, which on macOS is FIFO, on Windows mailbox and on Linux relaxed FIFO; off puts immediate first, which tears and is what a frame-rate measurement wants. enableVR overrides it either way, because the desktop window is then a mirror on the same loop as xrWaitFrame and a blocking desktop present would cap the headset at the monitor's rate. |
var maxFrameRate: Double |
Upper bound on renderer frames per second; 0 (the default) leaves the loop uncapped. A window the compositor is not showing is not throttled by vsync and can run at thousands of frames a second, so anything measured per frame (motion vectors, frame-to-frame displacement) shrinks towards zero. A capture that shows per-frame motion sets this, in its renderer box or through AFIELD_MAX_FRAME_RATE. |
val fov: Double |
Default vertical field of view, in radians — Projection and SuperProjection hand it straight to setPerspective(fovy, …) and tan(fov / 2) respectively. |
val zNear: Double |
Default near clip distance, in world units, which every Projection and SuperProjection starts from. 0.001 is close enough that a camera a metre from its subject does not clip it, and near enough zero to spend most of the depth buffer's precision on the first few units; push it out if depth fighting matters more than close geometry. |
val zFar: Double |
Default far clip distance, in world units. 1000, with zNear giving the ratio the depth buffer's precision follows from. |
var openXRClearColor: FloatArray |
The colour the OpenXR target clears each eye to, as RGBA. Opaque black. The desktop window has its own clear colour and does not read this. |
var enableHDR: Boolean |
Prefer an HDR surface format — scRGB or HDR10 — over the default 8-bit sRGB, which needs VK_EXT_swapchain_colorspace on the instance and a display advertising an HDR colorspace. Where either is missing the swapchain is sRGB and nothing says so, so check the chosen surface format rather than this flag. |
var openXRDebugInput: Boolean |
Print what the OpenXR runtime bound: interaction profiles and the sources behind each action. On, since a controller doing nothing is usually a profile that did not bind. |
var openXRWaitForGpuBeforeRelease: Boolean |
Wait for the GPU to finish rendering into an XR swapchain image before releasing it. Off: the frame is submitted and released at once, which is what most runtimes expect and is much the faster of the two, but it leaves the runtime's own synchronisation to be relied on. |
OrbitControls.kt
Mouse orbit controls for a Camera in a Vulkan window: rotate, pan and dolly around the camera's target, after Three.js's OrbitControls.
src/jvmMain/kotlin/afield/graphics/vulkan/util/OrbitControls.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun orbitControls(renderer: Renderer = fromContext(), camera: Camera = fromContext()): OrbitControlsDelegateProvider |
Factory function to create a delegate for OrbitControls using the property's name. |
OrbitControls
Mouse-driven orbit controls inspired by Three.js OrbitControls.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
The renderer whose window supplies the mouse events, the key state for the drag modifiers and the poll loop that damping runs in. |
val camera: Camera |
The camera these controls move. Its at, target and up are written on every update. |
val name: String |
The name the mouse handlers and the poll-loop entry are registered under, so a second set of controls on the same window does not displace the first. |
val mouse: MouseEvents |
The mouse stream these controls listen on: the window's own MouseEvents when the constructor was given one, otherwise a private stream named <name>__mouse__. |
var enabled |
Whether drags and scrolls move the camera at all. While false the handlers return early and damping stops advancing, so the camera holds its pose. |
var enableRotate |
Whether a plain left-drag orbits the camera around the camera's target. |
var enablePan |
Whether a right-drag, or shift and left-drag, slides camera and target together. |
var enableZoom |
Whether the scroll wheel, a middle-drag and ctrl (or alt) with a left-drag change the distance to the target. |
var rotateSpeed |
Radians of orbit per pixel dragged, as a multiple of a full turn across the window: at 1.0, dragging the window's own height turns 360° about either axis. |
var zoomSpeed |
How sharply scrolling and dollying change the distance to the target: an event of d units multiplies it by 0.95 raised to zoomSpeed * d / 100, inwards or outwards according to the sign. |
var panSpeed |
Pan distance per pixel dragged, as a multiple of what the camera can see: at 1.0, dragging the window's own height moves the target by the full visible height at the target's distance, so the scene keeps pace with the pointer. |
var enableDamping |
Whether a drag or scroll leaves momentum behind. While it is set, update keeps being called from the window's poll loop after the mouse stops, each call applying and then shrinking what is left, until the pending angles are under a millionth of a radian and the pending pan under a thousandth of a world unit. |
var dampingFactor |
The fraction of the pending orbit, pan and zoom that each damped update discards: 0.1 keeps nine tenths of the momentum per frame, 1.0 spends it all in one frame, and 0.0 never settles. Read only while enableDamping is set. |
var minDistance |
The closest the camera may come to its target; the distance is clamped into this range by every update, not only by the drag that crossed it. |
var maxDistance |
The farthest the camera may sit from its target, infinite by default; clamped like minDistance. |
var minPolarAngle |
The smallest angle, in radians, between upAxis and the direction from target to camera: 0 is directly overhead, and the camera is held a millionth of a radian inside the limit so the pole itself stays unreachable. |
var maxPolarAngle |
The largest such angle, PI (directly underneath) by default; held just inside the limit like minPolarAngle, which is what keeps the picture from flipping over at the pole. |
var screenSpacePanning |
Which plane a pan moves the target in. Set, the drag moves it in the plane of the screen, along the camera's own up; clear, it moves in the plane that upAxis is normal to, so a vertical drag travels along the ground rather than lifting the target off it. |
val upAxis |
The axis the camera orbits about and the one the polar limits are measured from: the camera's up as it stood when the controls were built, normalized. Every update writes it back into the camera, so the horizon stays level however far the orbit has travelled. |
fun saveState() |
Records the camera's present position, target, up and upAxis as the pose reset returns to. The constructor records it once, so a reset with no save behind it returns the camera to where it stood when the controls were built. |
fun reset() |
Puts the camera back to the pose saveState recorded and drops whatever orbit, pan or zoom was still pending. |
fun update(): Boolean |
Applies the pending orbit, pan and zoom to the camera, clamping the distance and the polar angle on the way, and returns whether damping has left anything worth another call — which is what keeps the poll loop going while the camera coasts to a stop. The drag and scroll handlers call it themselves; with nothing pending it still clamps, so it also brings a camera that other code has moved back inside the limits. |
OrbitControlsDelegateProvider
Delegate provider that creates OrbitControls whose name equals the property name.
| Declaration | Summary |
|---|---|
operator fun provideDelegate(thisRef: Any?, property: kotlin.reflect.KProperty<*>): kotlin.properties.ReadWriteProperty<Any?, OrbitControls> |
Projection.kt
Projection matrices for the Vulkan renderer: a plain perspective Projection and a SuperProjection that blends perspective into orthographic.
SuperProjection is what Camera uses. Its orthoness runs from 0 (perspective) to 1 (orthographic) along a matrix-log path, with matchDepth (or the camera's distance to its target) as the depth where the two footprints agree, and ox/oy shifting the principal point.
The remaining functions are the matrix tools behind that blend: matrix4fToSimple and simpleToMatrix4f convert to EJML, expmEigen and logmEigen take the matrix exponential and logarithm, lieInterpolateProjectionEJML interpolates two projections, and renormDepth01 and renormVerticalFovAtDepth rescale a matrix's depth range and its field of view at a chosen depth.
src/jvmMain/kotlin/afield/graphics/vulkan/util/Projection.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun matrix4fToSimple(m: Matrix4f): SimpleMatrix |
|
fun simpleToMatrix4f(s: SimpleMatrix): Matrix4f |
|
fun expmEigen(A: SimpleMatrix): SimpleMatrix |
Matrix exponential via eigen decomposition: A = V * diag(λ) * V^{-1} exp(A) = V * diag(exp(λ)) * V^{-1} |
fun logmEigen(A: SimpleMatrix): SimpleMatrix |
Matrix logarithm via eigen decomposition (real principal log): A = V * diag(λ) * V^{-1} log(A) = V * diag(log(λ)) * V^{-1} |
fun lieInterpolateProjectionEJML(P0: Matrix4f, P1: Matrix4f, t: Float): Matrix4f |
Lie-group interpolation in GL(4, R) between P0 and P1: |
fun Matrix4f.renormDepth01(near: Float, far: Float): Matrix4f |
Remap depth so that: z = -near → z_ndc = 0 z = -far → z_ndc = 1 |
fun Matrix4f.renormVerticalFovAtDepth(referencePersp: Matrix4f, zRef: Float, sampleY: Float = 1f, weight: Float = 1f): Matrix4f |
Isotropic FOV renormalization at a given depth zRef. |
Projection
class Projection : HasUniform<Matrix4f>
Projection matrices for the Vulkan renderer: a plain perspective Projection and a SuperProjection that blends perspective into orthographic.
| Declaration | Summary |
|---|---|
val projectionMatrix: Matrix4f |
The matrix itself, rebuilt in place by resize and returned by evaluateUniform. Editing fov, zNear or zFar does not touch it until the next resize. |
var fov |
Vertical field of view in radians, and it must be positive. |
var zNear |
Distance to the near clip plane, in view-space units; depth maps to 0 here. Defaults to Options.zNear. |
var zFar |
Distance to the far clip plane, where depth maps to 1 — Vulkan's [0,1] range rather than OpenGL's [-1,1]. Defaults to Options.zFar. |
var aspect |
Width over height of the viewport this was last sized for. resize computes it; assigning it alone changes nothing until the next resize. |
var auto: () -> Unit |
Run by evaluateUniform immediately before the matrix is handed to a shader. The renderer constructor sets it to re-size to the window, so a projection built that way follows the window; one built from an explicit width and height leaves it empty and must be resized by hand. |
fun resize(width: Int, height: Int): Projection |
|
override fun evaluateUniform(): Matrix4f |
SuperProjection
class SuperProjection : HasUniform<Matrix4f>
| Declaration | Summary |
|---|---|
val projectionMatrix: Matrix4f |
The blended matrix, rebuilt in place by resize and setAspect and returned by evaluateUniform. Every dial below is read when it is rebuilt, not when it is assigned. |
var fov |
Vertical field of view in radians, and must be positive — see Projection.fov. |
var zNear |
Distance to the near clip plane, in view-space units; the blended matrix is renormalized so depth is 0 here. Defaults to Options.zNear. |
var zFar |
Distance to the far clip plane, where the renormalized depth reaches 1. Defaults to Options.zFar. |
var aspect |
Width over height of the viewport, set by resize from a window size or by setAspect directly. |
var ox |
Sideways offset of the principal point, in half-widths of what is visible at matchDepth: 0 is centred, 1 moves the centre of the picture a half-width towards +X. |
var oy |
The same offset vertically, in half-heights at matchDepth. Off-centre projections of this kind are what tiled renders and stereo pairs are built from. |
var orthoness |
Where the projection sits between perspective at 0 and orthographic at 1. Values between the two are interpolated along a matrix-logarithm path rather than by blending the two matrices entry by entry, and anything outside [0,1] is clamped. |
var matchDepth |
The view-space depth, measured along -Z, at which the perspective and orthographic ends of the blend frame exactly the same rectangle. The blend is also renormalized to hold the vertical scale there, so what the camera frames at this distance stays put as orthoness is turned and only nearer and farther geometry changes size. Zero or less falls back to the midpoint between zNear and zFar. |
var autoMatchDistance: () -> Double |
Where auto gets matchDepth from each frame — a focus distance, say, or the camera's distance to its subject. Both constructors default it to returning matchDepth unchanged, and only the renderer constructor installs an auto that calls it. |
var auto: () -> Unit |
Run by evaluateUniform immediately before the matrix is handed to a shader. The renderer constructor sets it to read autoMatchDistance and re-size to the window, which is what makes a projection built that way track both the window and the focus distance; one built from an explicit width and height leaves it empty. |
fun resize(width: Int, height: Int): SuperProjection |
|
fun setAspect(aspect: Float): SuperProjection |
|
override fun evaluateUniform(): Matrix4f |
VulkanProbe.kt
Reports what Vulkan this machine offers: loader, instance, devices, validation layers, the SDK and, on macOS, the KosmicKrisp driver.
VulkanProbe runs the check in a child JVM and returns a VulkanStatus, which the Vulkan panel on /welcome shows along with install steps for the current OS. VulkanDeviceInfo describes one physical device.
src/jvmMain/kotlin/afield/graphics/vulkan/util/VulkanProbe.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun main() |
Entry point for VulkanProbe.probeInChildJvm; prints one prefixed JSON line. |
VulkanDeviceInfo
One physical device as the loader reports it.
| Declaration | Summary |
|---|---|
val name: String |
The device name the driver reports, which is what the Vulkan panel lists. |
val type: String |
discrete, integrated, virtual, cpu or other. A machine whose only device is cpu has a software rasteriser and no GPU driver. |
val apiVersion: String |
The highest Vulkan version this device supports, as major.minor.patch. |
val driverName: String |
The driver's own name, from VkPhysicalDeviceDriverProperties. Empty on a driver that does not fill it in. |
val driverInfo: String |
The driver's version string, from the same properties and equally optional. |
val driverId: Int |
The VK_DRIVER_ID_* the driver reports, which is what tells a MoltenVK device from a KosmicKrisp one on macOS. 0 when the driver reports none. PhysicalDevice matches this number against Options.MacVulkanDriver when it picks a device. |
VulkanStatus
What a graphics box on this machine would get, and what is missing. Shown by the Vulkan panel on /welcome, where a fresh clone first lands, so the user finds out before opening a window.
| Declaration | Summary |
|---|---|
val os: String |
macos, windows or linux. |
val arch: String |
The JVM's os.arch. On macOS it is one of the conditions on KosmicKrisp, which runs on Apple silicon only. |
val ok: Boolean |
A loader, an instance, and at least one device with a platform surface. False leaves error saying which of those was missing. |
val loaderSource: String |
sdk (a LunarG SDK's loader), system (whatever the OS resolves), lwjgl-moltenvk (the MoltenVK inside LWJGL's jar, no loader), or none. |
val loaderPath: String? |
The loader Options pinned, on macOS. Null everywhere else, where the loader is whatever the OS resolves and has no path worth printing. |
val sdkPath: String? |
The SDK root the probe found: Options.macVulkanSdk on macOS, VULKAN_SDK elsewhere. Null when there is none, which is not by itself a failure. |
val sdkVersion: String? |
The version read out of sdkPath's own directory name or its parent's, whichever looks like a version. Null for an SDK installed under a path that names none. |
val instanceVersion: String? |
The highest instance API version the loader supports, as major.minor.patch. Null when the loader did not load. |
val surfaceExtension: String? |
The platform surface extension the instance offers beside VK_KHR_surface, such as VK_EXT_metal_surface. Null means no window can be opened, and is one of the two things that clears ok. |
val validationLayer: Boolean |
Whether VK_LAYER_KHRONOS_validation is installed. Field asks for it by default and runs without it, so false is a weaker diagnostic story rather than a broken one. |
val devices: List<VulkanDeviceInfo> |
Every physical device the instance enumerated. Empty is the other thing that clears ok: a loader loaded, and no GPU driver offered it an ICD. |
val requestedDriver: String? |
macOS: the driver Options asked the loader for, and whether that SDK has a KosmicKrisp to ask for. |
val kosmicKrispAvailable: Boolean |
Whether the SDK at sdkPath ships a KosmicKrisp manifest to ask for. That it is there does not mean it is in use — requestedDriver says which one was asked for. |
val error: String? |
What went wrong, phrased for the Vulkan panel to show, or null when nothing did. A probe that crashed loading a driver, or ran past its timeout, reports it here rather than throwing, so a failed probe still returns a status. |
val probeMs: Long |
How long the probe took, in milliseconds. A cached status carries the duration of the probe that produced it, not of the lookup. |
VulkanProbe
| Declaration | Summary |
|---|---|
fun status(refresh: Boolean = false): VulkanStatus |
The last probe, or a new one when there is none or refresh asks. One probe at a time. |
fun probeInChildJvm(timeoutSec: Long = 30): VulkanStatus |
Run probeInProcess in a child JVM, because it loads every ICD the loader finds. A broken driver can crash the process that does that — Nsight already crashed it once (see Options.dumpToolingInfo) — and the process asking here is the Field server. |
fun probeInProcess(): VulkanStatus |
Load the loader the way Field does, create a bare instance, list devices, tear it all down. |
Window.kt
The GLFW window a Vulkan renderer draws into, with its input listeners and a framebuffer-to-PNG capture.
Window creates a client-API-less GLFW window (decorated, or borderless when fullscreen),
tracks its size and the Renderer attached to it, and routes key, character, cursor, mouse
button, scroll and focus events to listeners. Window.saveFramebufferToTempFile writes the
framebuffer to a PNG; openWindowsSnapshot lists the windows currently open.
src/jvmMain/kotlin/afield/graphics/vulkan/util/Window.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun openWindowsSnapshot(): List<Window> |
Top-level values
| Declaration | Summary |
|---|---|
var lastWindowToOpen: Window? |
The GLFW window a Vulkan renderer draws into, with its input listeners and a framebuffer-to-PNG capture. |
Window
class Window(val title : String, val fullscreen : Boolean = false, initialDimensions : Rect? = null)
| Declaration | Summary |
|---|---|
val windowHandle: Long |
The GLFWwindow* this wraps, for GLFW calls this class does not cover. cleanup destroys it, after which it is stale. |
var height: Int |
The framebuffer's height in pixels, which is what a swapchain or a render target is sized from. See width. |
var isResized: Boolean |
Set when the framebuffer size changes, and when a present reports that the swapchain is out of date. The renderer reads it to decide to rebuild the swapchain and calls resetResized; reading it does not clear it. |
var width: Int |
The framebuffer's width in pixels — twice the points on a Retina display, so it is not the frame a pointer position is measured in. size gives the window's position and size in points, and WindowPointer reports the pointer in the same points as that. |
var associatedRenderer: Renderer? |
The renderer drawing into this window, which claims the slot when it builds its context here. A window nothing renders to leaves it null. |
var presentationEnabled: Boolean |
Stop submitting this window while retaining its swapchain, for an optional preview. |
var bestEffortPresentation: Boolean |
Optional mirrors skip busy swapchain images instead of pacing the other windows or OpenXR. Set before constructing the renderer. |
var mouseEvents : MouseEvents? |
The first MouseEvents built for this window, which puts itself here and is then what OrbitControls and the stereo targets reuse rather than opening a second poller. |
var keyEvents : KeyEvents? |
The matching slot for keyboard handlers. KeyEvents does not claim it, so unless something assigns it this stays null — construct a KeyEvents and keep it yourself. |
val cursor: Vec2 |
Where the pointer is now, in points from the window's top-left corner, asked of GLFW on every read. The position on its own does not say that the pointer is over this window, which is what WindowPointer.inside answers. |
fun size() : Rect |
|
fun keyCallback(key: Int, scancode: Int, action: Int, mods: Int) |
|
fun charCallback(codepoint: Int) |
|
fun cleanup() |
|
fun isKeyPressed(keyCode: Int): Boolean |
|
fun addKeyListener(listener: (Int, Int, Int, Int) -> Unit): () -> Unit |
|
fun addCharListener(listener: (Int) -> Unit): () -> Unit |
|
fun addCursorListener(listener: (Double, Double) -> Unit): () -> Unit |
|
fun addMouseButtonListener(listener: (Int, Int, Int) -> Unit): () -> Unit |
|
fun addScrollListener(listener: (Double, Double) -> Unit): () -> Unit |
|
fun addFocusListener(listener: (Boolean) -> Unit): () -> Unit |
|
fun isMouseButtonPressed(buttonCode: Int): Boolean |
|
fun setDimensions(rect : Rect) |
|
fun consumeScrollDelta(): Vec2 |
|
var onPoll |
Per-frame work on the thread that polls this window, keyed by name. pollEvents drains GLFW's queue and then steps each sequence one step. This is how MouseEvents, KeyCamera and WindowPointer get their frame, and it is the place to put a capture or a drag that has to see every frame rather than sample when it happens to look. |
fun pollEvents() |
|
fun resetResized() |
|
fun resize(width: Int, height: Int) |
|
fun setShouldClose() |
|
fun shouldClose(): Boolean |
|
fun saveFramebufferToTempFile(imageData: ByteBuffer? = null, width: Int = this.width, height: Int = this.height): File |
Save the current framebuffer content to a temporary PNG file. |
fun getFramebufferAsBase64(imageData: ByteBuffer? = null, width: Int = this.width, height: Int = this.height): String? |
Get the current framebuffer as a base64-encoded PNG string. Useful for sending image data over the network. |
WindowPointer.kt
The pointer over a renderer window and the window's size, in window points, read once a frame.
src/jvmMain/kotlin/afield/graphics/vulkan/util/WindowPointer.kt — read the source
WindowPointer
class WindowPointer(val window: Window, val name: String, var onPoll: (WindowPointer) -> Unit = {})
The pointer and the window's size in points (the cursor's units, half the framebuffer's pixels on a Retina display), read on the window's poll, where GLFW allows it, and readable from any thread afterwards. A sketch that works in canvas pixels, origin top left, y down, uses width and height as its world and x, y as its mouse.
| Declaration | Summary |
|---|---|
val window: Window |
The window whose pointer and size are read. Registering adds a sequence to its poll map, so nothing is read until the window is polled. |
val name: String |
The key this registration takes in the window's poll map; a second pointer under the same name on the same window replaces the first. |
var onPoll: (WindowPointer) -> Unit |
Run on every poll, after the fields below are updated, for pointer logic that has to see each frame rather than sample whenever it happens to look. |
var width: Float |
The window's width in points; its framebuffer width until the first poll. |
var height: Float |
The window's height in points; its framebuffer height until the first poll. |
var x: Float |
The pointer, in points from the window's top-left corner. |
var y: Float |
The pointer's distance down from the window's top edge, in points. |
var inside: Boolean |
The window is under the pointer and the pointer is within it. |
var down: Boolean |
The left button is held. |
var polled: Boolean |
Whether the window has been polled yet. Until it has, width and height are the framebuffer's pixels, twice the points on a Retina display, so a sketch that places things by the window's size waits for this. |
fmchirp.kt
FM chirp curves: an eight-parameter family of 0..1 remapping curves, packed as half floats, that evaluate the same way in Kotlin and in GLSL.
FMChirpCurves holds the parameter type, evaluation, sampling, measures such as roughness and foldover, and least-squares fitting to target points. FM_CHIRP_GLSL is the matching shader source, registered as the fmchirp shader template by registerFMChirpShaderTemplate; registerFMChirpVisualizers gives the parameter type an HTML summary when it is printed in a box.
src/jvmMain/kotlin/afield/graphics/vulkan/util/fmchirp.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun registerFMChirpShaderTemplate() |
|
fun registerFMChirpVisualizers() |
Top-level values
| Declaration | Summary |
|---|---|
val FM_CHIRP_GLSL |
FM chirp curves: an eight-parameter family of 0..1 remapping curves, packed as half floats, that evaluate the same way in Kotlin and in GLSL. |
val FMChirpCurves.Params.h: String |
|
val FMChirpCurves.Params.h2: String |
FMChirpCurves
superpc.kt
Push constants as a Kotlin class, and the GLSL that reads them back. A subclass of
PushConstants declares its members as ordinary var properties; reflection turns those into a
byte layout with explicit offsets, glsl() emits the push-constant block and accessors that match
it, and writeTo serialises the current values into a buffer by those same offsets.
The reflection runs once per class and the layout is cached, so the per-frame cost is the write.
Members are laid out in @PcOrder order where that annotation is given and alphabetically where
it is not, which is what makes a layout stable from one run to the next.
What the generated GLSL looks like
One push-constant block, a layout(offset = …) on every member, a float register file loaded out
of that block by loadRegs(), and then accessors under the names the Kotlin class used:
{{ pc.dec }}
void main() {
loadRegs();
uFragColor = baseColor();
}
A scalar member becomes a macro, so time in Kotlin reads as time in shader code. A vector
member becomes a function of no arguments under its own name, so baseColor reads as
baseColor(). To keep those macros clear of the block's own members, a member inside the block
carries a leading underscore: Kotlin's time is declared as _time and the bare name is the
macro that reads it out of the register file.
Instruction payloads
A property whose name already begins with an underscore is an instruction payload: it is declared in the push-constant block under exactly that name and nothing else happens to it. It is not loaded into the register file and gets no accessor, so a shader reads it from the block itself and a frame pays nothing for payloads it is not reading.
Member types
Float, Vector2f, Vector3f and Vector4f become a float, vec2, vec3 and vec4. The
half-precision types travel packed into unsigned integers, to be unpacked in the shader: Vec2h
as a uint, Vec3h and Vec4h as a uvec2, and the eight-lane Vector8h as a uvec4. Every
member has to be a var, since the layout reads and writes through the property itself, and any
other type fails when the layout is built rather than at draw time. otherDeclarations is the way
around all of this: raw GLSL inserted into the block that reflection never sees, which you place
yourself at nextFreeOffset(…).
class MaterialPC : PushConstants<MaterialPC>() {
@PcOrder(0) var time: Float = 0f
@PcOrder(1) var baseColor: Vector4f = Vector4f(1f, 1f, 1f, 1f)
@PcOrder(2) var uvScale: Vector2f = Vector2f(1f, 1f)
@PcOrder(100) var _patch0: Vec4h = Vector4f().half
}
val pc = MaterialPC()
pc.time = 1.0f
pc["uvScale"] = Vector2f(2f, 2f)
shaderTemplate["pc"] = mapOf("dec" to Resolver { pc.glsl() })
mesh.push.bindAll { pc }
The block reaches the shader through the template engine, as above, and the instance is bound by
handing it to a mesh's push-constant structure, which writes it out each time that mesh's push
constants are assembled for a draw. The buffer it is written into has to be little-endian, and a
ByteBuffer is big-endian until it is told otherwise.
src/jvmMain/kotlin/afield/graphics/vulkan/util/superpc.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun main() |
Prints SmokePC's layout, its generated GLSL and a word dump of the bytes it writes. |
PcOrder
annotation class PcOrder(val value: Int)
Fixes where a push-constant member sits in the block, lowest value first.
PushConstants
abstract class PushConstants<T : Any> : WritesToBuffer
Base class for a push-constant block written as Kotlin properties, which generates its own matching GLSL declaration.
| Declaration | Summary |
|---|---|
open var otherDeclarations: String |
Optional raw GLSL declarations inserted into the push-constant block. |
fun pcSizeBytes(): Int |
How many bytes this block occupies, rounded up to a multiple of 16. |
fun regFloatCount(): Int |
How many floats the generated register file holds: the REG_COUNT the emitted GLSL declares. |
fun nextFreeOffset(alignment: Int = 4): Int |
First free byte offset after all reflected fields, optionally aligned. |
fun regIndexOf(name: String): Int |
Where name starts in the generated register file: the value of the REG_<name> constant the emitted GLSL declares. |
fun pcOffsetOf(name: String): Int |
The byte offset of name within the push-constant block, which is the offset the generated layout(offset = …) carries. |
operator fun get(name: String): Any? |
Dynamic getter by field name. |
operator fun set(name: String, value: Any?) |
Dynamic setter by field name. |
override fun writeTo(b: ByteBuffer, baseOffset: Int) |
Writes every member into b at baseOffset plus its own offset, leaving the buffer's position where it found it. |
fun glsl(emitScalarLayoutQualifier: Boolean = false, regsName: String = "regs", pcInstanceName: String = "pc"): String |
Emits the GLSL side of this block: the push-constant declaration, a float register file, a loadRegs() that fills it, and an accessor per member under the member's own Kotlin name. |
GlslGen
Builds the GLSL text for a push-constant block from a layout that reflection has already worked out.
SmokePC
class SmokePC : PushConstants<SmokePC>()
A worked example of a push-constant class, covering each supported member type and both payload conventions.
| Declaration | Summary |
|---|---|
var time: Float |
|
var baseColor: Vector4f |
|
var roughMetal: Vec2h |
|
var uvScale: Vector2f |
|
var emissive: Vector3f |
|
var _patch0: Vec4h |
|
var _patch1: Vec4h |
templating.kt
The template engine behind {{ slot }} in a Field shader: a Jinja-shaped expander written against
the Kotlin standard library alone, which a shader stage's source runs through before shaderc
sees it. Box.shaderTemplate is where a box puts the values it resolves against.
A template may contain:
{{ expr }}, replaced by whateverexprresolves to.{% if expr %}…{% else %}…{% endif %}, nested freely.not exprand!exprnegate. An empty string, a zero, an empty map and an empty list are false; a resolver is true.{% property "name" %}, which expands another named shader property in place and needs an include resolver to find it.{% include "name" %}is the same tag under its older spelling, kept so that saved documents still compile.
Everything a template reads comes from a Resolver, one function from a key to a value, and what
comes back is read by value rather than by type. null means not resolved, so the next resolver in
the chain is asked. A String is the answer, and so is anything else through its toString. A
Resolver, a Map or a list of Resolvers is structural instead: the rest of the path is
resolved against it, to a depth of fifty.
{{ a.b.c }} is matched longest prefix first, so a.b.c is offered to every resolver, then
a.b, then a, then the empty key. A resolver may therefore own a whole dotted namespace or just
its root, and where a answers with something structural, b.c is resolved against that. A path
that resolves nowhere stops the shader rather than silently emitting nothing, and says where: the
line and column, the line itself under a caret, and every key that was tried against every
resolver with what each returned.
runShaderTemplate is runTemplate plus #line directives around any expansion that spans
lines, so a compiler error after one still names the shader line that was written rather than the
line it grew into.
src/jvmMain/kotlin/afield/graphics/vulkan/util/templating.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun registerGlobalShaderTemplate(name: String, template: Any) |
Publishes template under name for every box in the process, as the last fallback behind each box's own shaderTemplate map. |
fun unregisterGlobalShaderTemplate(name: String): Any? |
Removes the global template registered under name and returns what was there, or null if nothing was. |
fun listGlobalShaderTemplateNames(): Set<String> |
The names currently in the global template registry, sorted. |
fun getShaderTemplateResolverForBox(b : Box): List<Resolver> |
The resolver chain a box's shader templates are expanded against: that box's own slots first, then the process-global registry. |
fun runTemplate(template: String, resolver: Resolver, includeResolver: IncludeResolver? = null): String |
Expands template against a single Resolver, and returns the result. |
fun runTemplate(template: String, resolver: ResolverFn, includeResolver: IncludeResolver? = null): String |
Expands template against a single lambda resolver, which is the same as the Resolver overload with the lambda wrapped. |
fun runTemplate(template: String, resolver: Any?, includeResolver: IncludeResolver? = null): String |
Expands template, taking the resolver in whatever shape the caller has it. |
fun runShaderTemplate(template: String, resolver: Any?, includeResolver: IncludeResolver? = null): String |
Expands template the way a shader stage is expanded: as runTemplate, plus #line directives around any expansion that spans more than one line. |
fun main() |
Expands a small template against two maps and a facade resolver, and prints the result to the info,templating log channel. |
Resolver
One source of named values for a template: given a key, either a value or null for "ask the next one". A key arrives dotted and whole (a.b.c before a.b before a), so a resolver may answer for a namespace as readily as for a name, and a resolver returned as the answer resolves the rest of the path.
| Declaration | Summary |
|---|---|
fun resolve(key: String): Any? |
Answers for key, or returns null to pass the question to the next resolver in the chain. |
ResolverFn
typealias ResolverFn = (String) -> Any?
A resolver written as a plain lambda, (String) -> Any?, which the template entrypoints accept anywhere a Resolver is wanted.
IncludeResolver
Finds the source text for a {% property "name" %} tag, given the name and where the tag was written.
| Declaration | Summary |
|---|---|
fun include(path: String, line: Int, col: Int): String |
Returns the text to splice in for the property path, which was named by a tag at line and col of the template. |
MapResolver
class MapResolver(private val map: Map<String, Any?>) : Resolver
Map-backed resolver. - Looks up exact keys. - If the stored value is a Map, it returns a MapResolver to enable hierarchical resolution. - If the stored value is already a Resolver or Iterable of resolvers, returns it as-is.
| Declaration | Summary |
|---|---|
override fun resolve(key: String): Any? |
Looks key up in the map, wrapping a nested Map as another MapResolver so the rest of a dotted path resolves against it. An absent key is null, which hands the question on. |
CompositeResolver
class CompositeResolver(private val resolvers: List<Resolver>) : Resolver
A resolver that asks each of its children in turn and takes the first non-null answer.
| Declaration | Summary |
|---|---|
override fun resolve(key: String): Any? |
Asks each child in turn and returns the first non-null answer, or null if none of them answers. |
TemplateException
class TemplateException(message: String) : RuntimeException(message)
The one exception this engine throws, for every fault from an unclosed {{ to a path that no resolver answered.
util.kt
Small helpers shared by the Vulkan renderer: the host OS, memory-type selection and result checking.
updateForever builds a sequence for a mesh's update map, OS reports the platform, memoryTypeFromProperties picks a device memory type, and vkCheck throws on a failed Vulkan call.
src/jvmMain/kotlin/afield/graphics/vulkan/util/util.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun updateForever(function: () -> Boolean) |
A Sequence that calls function once per step, for mesh.update["name"] = updateForever { ... }. |
fun memoryTypeFromProperties(physDevice: PhysicalDevice, _typeBits: Int, reqsMask: Int): Int |
Picks a device memory type index for an allocation, given the types Vulkan says are allowed for it and the properties it needs. |
fun vkCheck(err: Int, errMsg: String) |
Throws unless err is VK_SUCCESS, which is how every Vulkan call in the renderer is checked. |
Top-level values
| Declaration | Summary |
|---|---|
val OS: OSType |
Which of the four platforms Field recognises this JVM is running on, read from the os.name system property. |
OSType
The host platforms the renderer distinguishes, as reported by OS.
Constants: WINDOWS, MACOS, LINUX, OTHER.
Syphon.kt
Field's end of Syphon, the macOS standard for sharing GPU frames between
applications: a SyphonServer publishes frames that Resolume, TouchDesigner, MadMapper, OBS
(with its Syphon source), VDMX or Max can receive, and a SyphonClient receives what they publish.
val out = syphonServer("Field") // appears in every Syphon client's list
out.publish(image) // a BufferedImage, RGBA bytes, or a RenderToTexture
val inp = syphonClient("Resolume Arena") // a server's name, app name or uuid
display("main", inp.png()) // the latest frame
Field reaches Syphon through a small Objective-C library, native/field-syphon, that is optional
and macOS-only — build it once with ./gradlew buildSyphonNative (needs Xcode).
Every frame crosses the boundary through memory today: a RenderToTexture is read back from the
GPU and copied into Syphon's shared IOSurface, and a received frame is read back into a
ByteArray. At 1920 x 1080 that is about 8 MB a frame each way. Publishing an IOSurface that
already exists (SyphonServer.publishIOSurface) skips the CPU entirely.
src/jvmMain/kotlin/afield/io/syphon/Syphon.kt — read the source
Used in: Syphon: sharing frames on macOS
Top-level functions
| Declaration | Summary |
|---|---|
fun syphonServer(name: String = "Field"): SyphonServer |
The open Syphon server called name, or a new one. Other applications list it under Field's app name and this name. |
fun syphonClient(match: String = "", waitMs: Int = 2000): SyphonClient |
Connect to the first server whose uuid, name, or app name (tried in that order) is match; an empty match takes the first server announced. Waits up to waitMs for it to appear, then throws, listing the servers it could see. Reuses the open client cached under exactly match; a missing or closed client is recreated. Calls with different matches have separate cache slots. Clients persist across box runs; close one explicitly when its shared connection is no longer needed. |
Syphon
Process-wide Syphon state: whether the bridge is built, the server list, and the run-loop pump.
| Declaration | Summary |
|---|---|
var mainLoopTurns: Long |
Run-loop turns given to Syphon on the process's main thread (see ensurePump). |
val cpuCopies: Long |
Frames this JVM has read back through memory from any Syphon client (latest(), png(), and a SyphonTexture on its copy path). A zero-copy SyphonTexture leaves it unchanged. |
var offMainCalls: Long |
Pump calls that found themselves off the main thread, and so did nothing. |
val isAvailable: Boolean |
Whether the native bridge is built (and this is a Mac). |
fun describe(): String |
A sentence saying where the bridge is, or how to build it. |
fun servers(waitMs: Int = 500): List<SyphonServerInfo> |
Every Syphon server announced on this machine — Field's own included. The first call waits up to waitMs for announcements to arrive. |
SyphonServerInfo
data class SyphonServerInfo(val name: String, val appName: String, val uuid: String)
One announced server: what SyphonClient can connect to.
| Declaration | Summary |
|---|---|
override fun toString(): String |
SyphonServer
class SyphonServer internal constructor(val name: String) : AutoCloseable
A Syphon server: frames published here are visible to every Syphon client on the machine.
| Declaration | Summary |
|---|---|
var framesPublished: Long |
Frames published so far. |
val info: SyphonServerInfo |
This server as other applications see it. |
val hasClients: Boolean |
Whether any application is receiving. |
fun publish(pixels: ByteArray, width: Int, height: Int, bgra: Boolean = false) |
Publish width x height pixels, four bytes each, top row first: RGBA order, or BGRA when bgra is true (what a B8G8R8A8 render target reads back as). The bytes are copied; reuse the array at once. |
fun publish(argb: IntArray, width: Int, height: Int) |
Publish ARGB ints (as BufferedImage.getRGB and afield.media.image.util.PngEncoder use), top row first. |
fun publish(image: BufferedImage) |
Publish a BufferedImage (any type; it is converted to ARGB). |
fun publish(target: RenderToTexture) |
Publish the next frame target renders: its colour attachment is read back once that frame's GPU work completes, then handed to Syphon. Returns at once. The target needs 8-bit RGBA or BGRA colour (the default B8G8R8A8_SRGB is fine) and colorTransferSrc = true (the default). |
fun follow(target: RenderToTexture) |
Publish every frame target renders, until stopFollowing, close, or another call to follow. One readback is in flight at a time, so a slow consumer drops frames instead of queueing them. |
fun stopFollowing() |
Stop following and followOnGpu. |
fun followOnGpu(target: RenderToTexture, renderer: Renderer = fromContext()): SyphonGpuPublisher |
Publish every frame target renders without a CPU readback: each frame is copied on the GPU into an IOSurface and Syphon takes it from there. Needs a B8G8R8A8 target (the default) and a driver with VK_EXT_external_memory_host (KosmicKrisp and MoltenVK on Apple silicon); call it after target is in the renderer's list, since it renders after whatever is already there. Replaces any earlier follow or followOnGpu; stopFollowing ends it. |
fun publishIOSurface(ioSurfaceRef: Long, flipped: Boolean = false) |
Publish an existing BGRA8 IOSurface by its IOSurfaceRef address, without touching the CPU: Syphon copies it GPU-side into its own shared surface. flipped says the surface's first row is the bottom of the picture. |
override fun close() |
Stop publishing; clients see the server disappear. |
override fun toString(): String |
SyphonClient
class SyphonClient internal constructor(private val handle: MemorySegment) : AutoCloseable
A connection to another application's Syphon server. latest returns its most recent frame. Holds native resources until close.
| Declaration | Summary |
|---|---|
val isClosed: Boolean |
Whether this client's native connection has been released. |
val server: SyphonServerInfo |
The server this client is connected to. |
val frameCount: Long |
Frames the server has sent since this client connected. |
fun waitForFrame(after: Long = frameCount, waitMs: Int = 1000): Long |
Wait up to waitMs for a frame after the afterth; returns the frame count then. |
fun latest(): SyphonFrame? |
The latest frame, or null if none has arrived yet. |
fun texture(renderer: Renderer = fromContext(), waitMs: Int = 2000, zeroCopy: Boolean = true): SyphonTexture |
This client's frames as a Vulkan texture that follows the server: bind the returned SyphonTexture as a sampler2D like any other texture. Where the Vulkan driver allows it the shader samples the sender's IOSurface itself, with nothing copied (SyphonTexture.path); zeroCopy = false asks for the old route through memory. Waits up to waitMs for a first frame. Registered in renderer's list, which is what refreshes it each frame. |
fun png(): ByteArray? |
The latest frame as a PNG (for display), or null if none has arrived yet. |
override fun close() |
|
override fun toString(): String |
SyphonFrame
class SyphonFrame(val rgba: ByteArray, val width: Int, val height: Int)
One received frame: rgba is width x height x 4 bytes, top row first.
| Declaration | Summary |
|---|---|
fun rgbaAt(x: Int, y: Int): IntArray |
The RGBA of pixel (x, y), y down from the top, as 0..255 ints. |
fun argb(): IntArray |
ARGB ints, one per pixel, top row first. |
fun image(): BufferedImage |
|
fun png(): ByteArray |
|
override fun toString(): String |
SyphonGpu.kt
Publishes every frame a RenderToTexture draws to a SyphonServer without the CPU touching the
pixels: the frame is copied GPU-side into an IOSurface (on the same command buffer that drew it),
and once that frame's GPU work has completed Syphon blits the IOSurface into its own shared one.
Made by SyphonServer.followOnGpu; a Target in the renderer's list, placed after the source so
it runs after the source has drawn. The IOSurface reaches Vulkan as host memory
(VK_EXT_external_memory_host, see IOSurfaceHostBuffer), which both KosmicKrisp and MoltenVK
offer on Apple silicon. Two surfaces alternate; if Syphon is still copying the one due next, the
frame is skipped rather than overwritten under it.
src/jvmMain/kotlin/afield/io/syphon/SyphonGpu.kt — read the source
SyphonGpuPublisher
A Target that copies source's frame into an IOSurface and hands it to server each frame.
| Declaration | Summary |
|---|---|
val server: SyphonServer |
|
val source: RenderToTexture |
|
override val rendersAfter: Target |
Renders after source whatever order the two were added in. |
var published: Long |
Frames handed to Syphon, and frames skipped because the next surface was still in use. |
var skipped: Long |
|
override fun render(command: CommandBuffer, index: Int) |
|
override fun close() |
Stop publishing. The surfaces are freed once the GPU and Syphon are done with them, which is at most a frame or two later. |
override fun toString(): String |
SyphonTexture.kt
A SyphonClient's frames as a texture for a shader to sample: bind this object itself as a
sampler2D (prop<SyphonTexture>(name) = client.texture()). Made by SyphonClient.texture, and
kept current by the renderer's list, which calls render each frame.
Syphon's frame is an IOSurface that the sending process draws into. Where the driver allows it,
Vulkan samples that IOSurface's own memory, with no copy of any kind (path says which route):
Path.IMPORTED_IMAGE: MoltenVK. The IOSurface backs aVkImage(VK_EXT_metal_objects).Path.HOST_LINEAR_IMAGE: KosmicKrisp (and MoltenVK if the first is refused). The IOSurface's pages are imported as host memory (VK_EXT_external_memory_host) and bound to a linearly tiledVkImagewhose row pitch is the surface's own.Path.HOST_BUFFER_COPY: the pages are imported as a buffer and copied into an image on the GPU, once per new frame; used when the driver will not lay a linear image over them.Path.CPU_COPY: the fallback: a blit into a staging buffer, a swizzle on the CPU, and an upload. Also whattexture(zeroCopy = false)asks for.
The texture is B8G8R8A8_SRGB on the zero-copy paths and R8G8B8A8_SRGB on the copy path; either
way the shader sees RGBA, top row first (v = 0 is the top of the sender's picture). When the sender
changes size Syphon replaces the IOSurface, and the texture re-imports the new one; the old one is
released only after the frame that last sampled it has completed. (The copy path keeps the first
frame's size and skips frames of another.) Like every Syphon client, the zero-copy paths
read the sender's surface while it may be drawing the next frame into it; Syphon has no cross-process
fence to wait on.
src/jvmMain/kotlin/afield/io/syphon/SyphonTexture.kt — read the source
SyphonTexture
A sampled texture that tracks a SyphonClient's latest frame; see the file overview for its paths.
| Declaration | Summary |
|---|---|
val client: SyphonClient |
|
enum class Path |
|
var path: Path |
How the current frame reaches the shader. |
var fallbackReason: String |
Why the zero-copy routes were not taken, when path is not one of them; else empty. |
var frames: Long |
New frames seen, frames that went through memory (CPU copies), and IOSurface (re-)imports. |
var cpuCopies: Long |
|
var imports: Long |
|
var recordNanos: Long |
Nanoseconds this object spent on the render thread handling new frames, summed over frames: on the copy path the blit, wait, swizzle and staging write; on the zero-copy paths asking Syphon for the surface and comparing its id. |
var skipped: Long |
Frames skipped on the copy path because their size changed. |
val width: Int |
|
val height: Int |
|
override var defaultSampler: TextureSampler? |
|
override val imageView: ImageView |
|
override val imageViewArray: List<HasImageView> |
|
override fun render(command: CommandBuffer, index: Int) |
|
override fun prepareSampledImagesFor(command: CommandBuffer) |
|
override fun close() |
|
override fun toString(): String |
SolidAgreement.kt
The measurement this package exists for: one written shape, evaluated two completely independent ways, and the disagreement reported as a number.
val bracket = cuboid(4, 3, 1) - cylinder(2, 0.5).at(1, 0, 0)
println(bracket.crossCheck(SolidQuality(edgeLength = 0.02, segments = 256)).report())
Two implementations that share no code
Almost nothing in a graphics codebase gets to do this. The usual options are to assert against a remembered constant (which pins the current behaviour, not the correct one) or against a closed form (which only exists for a handful of shapes). Here both sides are implementations, and they share no code whatsoever:
| side A | side B | |
|---|---|---|
| the shape becomes | a distance field over Doubles |
a CSG tree over Manifold handles |
| the surface comes from | afield.geometry.mesh.marchingCubes, in Kotlin |
Manifold's exact boolean kernel, in C++ |
| the volume comes from | a sum of signed tetrahedra over Float triangles |
Manifold's own integration, in double |
| the genus comes from | edge/vertex adjacency in mesh_topology.kt |
Manifold's Euler characteristic |
There is no path by which a bug in one produces the same wrong answer in the other. Five-figure agreement is therefore strong evidence that both are right, in a way that any number of assertions against constants would not be.
The same argument covers a third reading, the differentiable one in
SolidGradient.kt, which is toScalarField's arithmetic replaced by
afield.math.autodiff.AD and shares no line of code with it. It is measured by
SolidExpr.gradientAgreement rather than folded in here, and the reason is that the two compare
different objects: this file compares surfaces — volume, area, genus, Hausdorff deviation —
and the differentiable reading produces a field, exactly as toScalarField does. Folding it in
would have meant inventing a volume for something that has none. What the two share is the
argument, so each cross-references the other.
Agreement does not converge to zero
There are two regimes:
- Polyhedral shapes — cuboids, polygon-profile extrusions, and booleans of those. The exact
lowering is the truth, so the marched one converges to it and the residual goes to zero as
SolidQuality.edgeLengthshrinks. Expect the relative volume error to fall roughly ash². - Curved shapes — anything with a sphere, a cylinder, a circular profile or a round offset.
Manifold's circle is an inscribed n-gon, whose area falls short of the true one by
1 − (n/2π)·sin(2π/n) ≈ 2π²/3n². The field lowering describes the true curve. So agreement converges to that deficit and not to zero, however fine the march.
facetingDeficit computes the second one from the segment count alone, so a curved shape's
residual can be checked against a predicted floor rather than a remembered one. That is the
difference between "these two numbers are close" and "these two numbers are as close as the
geometry permits, and here is the formula".
src/jvmMain/kotlin/afield/lang/solidsl/SolidAgreement.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun SolidExpr.crossCheck(quality: SolidQuality = SolidQuality()): SolidAgreement |
Build this shape both ways and measure the disagreement. |
fun facetingDeficit(n: Int): Double |
The relative volume a regular inscribed n-gon loses against its circle: 1 − (n / 2π)·sin(2π / n), which is 2π²/3n² to leading order. |
fun effectiveSegments(radius: Double, requested: Int): Int |
How many segments Manifold will actually give a circle of this radius under the current global quality, when a node asked for 0. |
fun SolidExpr.marchedMesh(quality: SolidQuality = SolidQuality()): TriMesh |
TriMesh from the marched side of a cross-check, for a render or a second opinion. |
AgreementKind
Which two things were compared — see the note on SolidAgreement about the empty bucket.
Constants: EXACT_VS_MARCHED, MARCHED_VS_MARCHED.
SolidAgreement
What the two lowerings of one SolidExpr said, and how far apart they were.
| Declaration | Summary |
|---|---|
val expr: SolidExpr |
|
val kind: AgreementKind |
|
val edgeLength: Double |
|
val segments: Int |
|
val refusals: List<String> |
|
val solidVolume: Double |
Manifold's own integration of the exact (or level-set) solid. |
val marchedVolume: Double |
afield.geometry.mesh.signedVolume over the marched triangles — a sum of signed tetrahedra. |
val solidArea: Double |
|
val marchedArea: Double |
|
val solidGenus: Int |
|
val marchedGenus: Int? |
null when the marched mesh came out open, which is itself a failure worth seeing. |
val marchedIsWatertight: Boolean |
|
val solidTriangles: Long |
|
val marchedTriangles: Int |
|
val deviation: Double |
Symmetric vertex-sampled Hausdorff distance between the two surfaces, in model units. |
val scale: Double |
The longest axis of the shape's bound — what deviation is relative to. |
val relativeVolumeError: Double |
\|Va − Vb\| / max(\|Va\|, \|Vb\|). |
val relativeAreaError: Double |
\|Aa − Ab\| / max(\|Aa\|, \|Ab\|). |
val relativeDeviation: Double |
deviation as a fraction of the shape's longest axis, so it compares across sizes. |
val genusAgrees: Boolean |
true when both sides closed and agreed on how many holes there are. |
fun line(): String |
One line, for a sweep printed as a table. |
fun report(): String |
The whole story, for a println in a box. |
SolidExact.kt
Lowering (b): a SolidExpr as an exact Manifold Solid — or a stated refusal.
when (val l = (cuboid(2, 2, 1) - cylinder(3, 0.3)).exactSolid()) {
is SolidLowering.Lowered -> println("volume ${l.value.volume}, genus ${l.value.genus}")
is SolidLowering.Refused -> println("no exact form: ${l.why}")
}
Refusals are in the type
The discipline is afield.lang.rectsl.RectSLFnLowering's, wholesale, and for the same reason it gives:
a wrong answer is worse than no answer, so the lowering never guesses. Every node either maps to
the corresponding Manifold call or refuses with a reason, and the refusal propagates up.
A null would invite a caller to substitute something plausible; a thrown exception loses the
reason at the first runCatching. A stated reason survives into a test assertion, a diagnostic and
a docs page — which is why the refusals in this package are enumerable and enumerated:
| node | why there is no exact form | flavour |
|---|---|---|
SolidExpr.SmoothUnion |
the fillet surface lies on neither operand, so no sequence of unions, intersections and differences of the two can produce it | permanent |
SolidExpr.SmoothDifference |
the same, for the filleted cut | permanent |
SolidExpr.Gyroid |
a trigonometric field on all of space, not a bounded volume | permanent |
SolidExpr.Round |
minkowskiSum with a sphere exists, but that sphere is an inscribed polyhedron, so the result is strictly inside the true offset by up to its sagitta — and refining the march does not close a gap that is not the march's |
would not converge |
SolidExpr.Shell |
grow − erode exists as two Minkowski operations, with SolidExpr.Round's problem twice over and a concave decomposition between them |
would not converge |
The last two are the interesting ones. They are not "we did not get to it": an exact-looking answer
is available and is the wrong answer, systematically and in a direction that does not shrink.
Filing them beside the smooth blends would flatten a real distinction, so the type carries
SolidLowering.Refused.closable and the reason says which kind it is.
Note what is not on the list. Every Profile node lowers exactly — including Profile.Offset,
because Section.offset(d, JoinType.ROUND) is definitionally the Minkowski sum with a disc, which
is exactly what sdf − d means. So a rounded slot has an exact form and a rounded solid does not,
and the asymmetry is real: Clipper offsets a 2D region as a region, where the 3D story has to go
through a polyhedral ball.
src/jvmMain/kotlin/afield/lang/solidsl/SolidExact.kt — read the source
Used in: A solid, written down: afield.lang.solidsl
Top-level functions
| Declaration | Summary |
|---|---|
fun <T> SolidLowering<T>.valueOrNull(): T? |
The lowered value, or null for a refusal. |
fun SolidLowering<*>.refusalOrNull(): String? |
The refusal reason, or null when this did lower. |
fun <T> SolidLowering<T>.orThrow(): T |
The lowered value, or a thrown IllegalStateException carrying the refusal. |
fun SolidExpr.exactSolid(quality: SolidQuality = SolidQuality()): SolidLowering<Solid> |
The exact Manifold Solid for this shape, or the refusal that explains why there is not one. |
fun Profile.exactSection(quality: SolidQuality = SolidQuality()): Section |
The exact Manifold Section for this region. Every Profile node has one, so this never refuses. |
fun SolidExpr.exactRefusal(): SolidLowering.Refused? |
The refusal this shape's exact lowering would produce, or null if it has an exact form — answered from the expression, with no native call and nothing built. |
fun SolidExpr.children(): List<SolidExpr> |
This node's immediate SolidExpr children, in written order. The one place the shape of the tree is listed. |
fun SolidExpr.allRefusals(): List<SolidLowering.Refused> |
Every refusal in this expression, not just the first — the catalogue a docs page or a report wants. |
fun SolidExpr.buildSolid(quality: SolidQuality = SolidQuality()): SolidBuild |
A Solid built from this expression, taking the exact path everywhere it exists and the level set only where it does not — at the smallest subtree that needs it. |
SolidLowering
sealed interface SolidLowering<out T>
Either a lowering into Manifold's world, or a refusal that says why.
| Declaration | Summary |
|---|---|
data class Lowered<out T>(val value: T) : SolidLowering<T> |
The lowering exists: value is the Solid or Section. |
data class Refused(val why: String, val node: String = "", val closable: Boolean = false, val missing: String = "") : SolidLowering<Nothing> |
There is no faithful exact form, why is the reason in one sentence, and closable says whether that is a gap in ambition or a gap in mathematics. |
SolidQuality
How finely the two evaluations are asked to work.
| Declaration | Summary |
|---|---|
val segments: Int |
|
val edgeLength: Double? |
|
val edgeLengthFraction: Double |
|
val levelSetTolerance: Double |
|
fun edgeLengthFor(bounds: AxisAlignedBoundingBox3D): Double |
The edge length to march bounds at: edgeLength if it was given, else a fraction of the longest axis. |
SolidBuild
What buildSolid produced, and how much of it was exact.
| Declaration | Summary |
|---|---|
val solid: Solid |
The solid. The caller owns it — close() it, or build inside a Solid.scope. |
val marchedSubtrees: List<MarchedSubtree> |
Every subtree that had to be marched, with the reason it could not be built exactly. |
val isExact: Boolean |
true when nothing had to be marched. |
fun report(): String |
A one-paragraph account of what happened, for a println in a box. |
override fun close() |
MarchedSubtree
One subtree that the exact lowering refused and the level set took instead.
| Declaration | Summary |
|---|---|
val node: SolidExpr |
|
val why: String |
|
val edgeLength: Double |
|
val bounds: AxisAlignedBoundingBox3D |
SolidExpr.kt
The SolidSL algebra: 3D solids and 2D profiles as immutable data values, with the builder functions that write them and the bounds that enclose them.
A SolidExpr is a tree of primitives (sphere, cuboid, cylinder, cone, torus,
gyroid), transforms (at, turned, sized, mirrored), booleans (+, -, unionOf,
differenceOf, intersectionOf, smoothUnion, smoothDifference) and modifiers (rounded,
shelled). A Profile is the 2D counterpart, turned into a solid by extruded or revolved.
val bracket = cuboid(60, 40, 8) - Profile.rect(26, 9).offset(4.5).extruded(30).at(0, 0, -15)
val field = bracket.toScalarField()
val exact = bracket.exactSolid()
This file only builds and measures the tree. Reading it is done elsewhere in the package:
toScalarField lowers it to a sampled distance field, exactSolid to Manifold's exact CSG,
toGlsl to a shader function, and buildSolid mixes the first two.
src/jvmMain/kotlin/afield/lang/solidsl/SolidExpr.kt — read the source
Used in: A solid, written down: afield.lang.solidsl, Solids that cannot fail: Manifold in Field
Top-level functions
SolidExpr
A 3D solid written down as a value instead of as a closure — the symbolic notation that afield.geometry.mesh's signed-distance family and afield.geometry.solid.manifold's exact CSG kernel can both evaluate.
Profile
A 2D region, written down the same way — the thing SolidExpr.Extrude sweeps and SolidExpr.Revolve turns.
P2
data class P2(val x: Double, val y: Double)
An immutable 2D point, in Double.
| Declaration | Summary |
|---|---|
fun toVec2(): Vec2 |
Rect2
data class Rect2(val minX: Double, val minY: Double, val maxX: Double, val maxY: Double)
A 2D bounding rectangle, in Double — Profile's counterpart of AxisAlignedBoundingBox3D.
| Declaration | Summary |
|---|---|
fun union(other: Rect2) |
|
fun intersect(other: Rect2) |
|
fun expandedBy(m: Double) |
|
fun radius(): Double |
The distance from the origin to the farthest corner — the radius a twist sweeps through. |
SolidField.kt
Lowering (a): a SolidExpr as a signed distance field, so everything mesh_sdf.kt,
marchingCubes and Solid.levelSetOf already do keeps working unchanged.
val mesh = (cuboid(2, 2, 1) - cylinder(3, 0.3)).march(resolution = 96)
This lowering always succeeds
Unlike exactSolid, this lowering always succeeds. Every node in the algebra has a field form —
that is where the algebra came from. What it does not always have is an exact distance
function, and the difference matters to a ray marcher (which steps by the field's value and
overshoots if the value is an overestimate) even though it does not matter to a marching cubes
(which only reads signs). fieldNotes enumerates the places where the field is sign-correct but
not metric-correct, so the gap is a fact rather than a surprise.
It is thread-safe, where the sdf… family is not
mesh_sdf.kt is explicit that a ScalarField3D "must read the vector it is handed and not
write to it", and its own combinators capture a single scratch Vec3 to transform into. That makes
them fast and single-threaded.
This lowering does not do that. Internally it builds a tree of Field3 — a fun interface over
three primitive Doubles, so there is no boxing and nothing to alias — and only wraps a Vec3 at
the very top, in toScalarField. So the field is re-entrant and safe to call from any thread.
src/jvmMain/kotlin/afield/lang/solidsl/SolidField.kt — read the source
Used in: A solid, written down: afield.lang.solidsl
Top-level functions
| Declaration | Summary |
|---|---|
fun SolidExpr.toScalarField(): ScalarField3D |
This shape as a ScalarField3D, negative inside — the convention sdfSphere and friends use, so this composes straight into sdfUnion, marchingCubes and Solid.levelSetOf. |
fun SolidExpr.toLevelSetField(): Field3 |
This shape's field in the sign convention Manifold's levelSet wants — positive inside — and over primitive doubles, so no Vec3 is ever constructed. |
fun SolidExpr.march(resolution: Int = 64, bounds: AxisAlignedBoundingBox3D? = null, margin: Double = 0.0): TriMesh |
marchingCubes on toScalarField, with the shape's own bounds as the window. |
fun SolidExpr.fieldNotes(): List<String> |
Every place in this expression where the field lowering is sign-correct but not a true distance function, in the order they appear. |
fun SolidExpr.toField3(): Field3 |
This shape as a Field3, negative inside. The compiled form both other entry points share. |
fun Profile.toField2(): Field2 |
This region as a Field2, negative inside. |
Field3
A scalar field over three primitive doubles.
| Declaration | Summary |
|---|---|
fun at(x: Double, y: Double, z: Double): Double |
Field2
A scalar field over two primitive doubles — Field3's 2D counterpart, for Profile.
| Declaration | Summary |
|---|---|
fun at(x: Double, y: Double): Double |
SolidGlsl.kt
Lowering (c): the same SolidExpr as GLSL, so the shape that produces the fabricable mesh can
also be ray-marched on a GPU at sixty frames a second.
val part = cuboid(2, 1.2, 0.4) - cylinder(2, 0.3).at(0.5, 0, 0)
println(part.toGlsl().glsl) // float sdSolid(vec3 p) { … }
println(part.toRaymarchFragment()) // a complete fragment shader that draws it
This is the lowering that only a symbolic notation can have. A (Vec3) -> Float runs on the JVM
and nowhere else; an expression tree can be walked into whatever language you like. RectSL does
the same in 2D, in RectSLGlsl.emitSdf.
What is emitted, and the unusual shape of it
Straight-line, single-assignment, entirely scalar GLSL: no vec2, no vec3 locals, no
swizzles, no loops, no branches except the conditional operator.
float sdSolid(vec3 p) {
float x0 = p.x;
float y0 = p.y;
float z0 = p.z;
float q1 = abs(x0) - 1.0;
…
return d7;
}
That looks like a strange thing to hand a shader compiler. Any driver will re-vectorise this in
its first pass, so nothing is lost at run time. What is gained is that the emitted text falls
inside a grammar small enough to write a reader for — which is what
SolidSLGlslTest does. It parses the shader back and evaluates it at tens of thousands of points
against SolidExpr.toField3, so the claim "the GLSL computes the same field" is measured
rather than asserted from the fact that both came from the same tree walk. A dropped negation, a
swapped min and max, an argument in the wrong position: all of those compile perfectly and all
of them are caught. Compilation alone — which is where the 2D precedent stops — proves only that
the text is valid.
A raymarcher needs to know when the field lies
Sphere tracing steps along the ray by the field's value, which is only safe when that value is a
lower bound on the true distance. Several nodes here return something larger than the true distance
(SolidExpr.Intersect and SolidExpr.Difference are pointwise maxima; a twisted SolidExpr.Extrude
shears space), and stepping by an over-estimate walks a ray straight through a thin wall.
So GlslSolid.stepScale is part of the result rather than a magic number in the shader: the
emitter reads fieldNotes and reports the multiplier the march should use. 1.0 means the field
is a true distance function and sphere tracing is exact; anything less says how much this
particular expression has to be slowed down, and GlslSolid.notes says which node caused it.
src/jvmMain/kotlin/afield/lang/solidsl/SolidGlsl.kt — read the source
Used in: A solid, written down: afield.lang.solidsl
Top-level functions
| Declaration | Summary |
|---|---|
fun SolidExpr.toGlsl(functionName: String = "sdSolid"): GlslSolid |
This shape as a GLSL distance function. |
fun SolidExpr.toRaymarchFragment(maxSteps: Int = 128, functionName: String = "sdSolid"): String |
A complete fragment shader that draws this shape: sphere tracing, a central-difference normal and two lights, on a flat dark background. |
Top-level values
| Declaration | Summary |
|---|---|
const val SOLID_RAYMARCH_VERTEX: String |
The vertex shader that goes with toRaymarchFragment: one triangle covering the target, its positions taken from gl_VertexIndex, so it needs no vertex buffers and suits fullscreenTriangle. |
GlslSolid
The emitted shader, plus what a marcher needs to know about the field it describes.
| Declaration | Summary |
|---|---|
val glsl: String |
The function source: float <functionName>(vec3 p) { … }. |
val functionName: String |
|
val statementCount: Int |
How many float assignments it took: a cheap proxy for shader cost, and what the simplifier compares before and after. |
val stepScale: Double |
The safe sphere-tracing step, as a fraction of the field's value. |
val notes: List<String> |
SolidExpr.fieldNotes, carried through, because they are exactly what stepScale is derived from. |
override fun toString(): String |
SolidGradient.kt
Lowering (c): a SolidExpr as a value and an exact gradient — with respect to its own named
parameters, not only to position.
val template = (sphere(0.8) + cylinder(1.6, 0.3))
.parameterize {
param("r") { it.node == "Sphere" && it.field == "radius" }
param("h") { it.node == "Cylinder" && it.field == "height" }
}
.orThrow()
template.gradientAt(0.9, 0.0, 0.0, doubleArrayOf(0.8, 1.6))
// SolidGradientSample(value = 0.1, dPosition = [1, 0, 0], dParameters = [-1, 0])
Why a third reading
SolidField.kt opens by saying a lambda "cannot be printed, compared, serialized, rewritten, or
emitted as GLSL", and this package answered that with two readings that can:
toScalarField and exactSolid. Both are evaluations. Neither changes what you can ask the
notation for.
afield.lang.rectsl.RectSLFn made the same argument one step further in 2D and is the model here,
verbatim: "a track that lowers to an Fn is a track you can take the derivative of, and therefore
a track you can solve: declare where a rect should be at some instant and let a gradient find
the numbers that put it there." That is what is missing in 3D, and this file plus
SolidInverse.kt is it.
The 2D answer does not port. afield.math.functions.Functions.Fn is (Double) -> Double — one input, and
a solid is a function of (x, y, z) and of however many named parameters the author declared.
The tool that does fit is afield.math.autodiff.AD: reverse-mode automatic differentiation over Double,
with the whole elementary set SolidField.kt needs already present. So this lowering is a
line-for-line transposition of SolidExpr.toField3 into AD arithmetic, and the two are meant to
be read side by side.
Reverse mode, and not forward
The quantity that matters is one scalar loss over many sample points and a handful of parameters.
Reverse mode gets every parameter derivative from one forward pass and one backward sweep;
forward mode costs one pass per parameter, and autodiff_forward.kt says so itself — its Dual2
is for the opposite shape, one input and many outputs. The position gradient is only three inputs
and falls off the same tape for free, so there was never a reason to run two modes.
The differentiable reading is total on the algebra
SolidExact.kt puts every refusal in the type and enumerates five of them. This file has none
of that flavour: the differentiable reading is
total on the algebra for exactly the reason toScalarField is total on it — it is the same algebra
evaluated in a different arithmetic. Every node that has a field form has a differentiable form,
because AD is closed under everything Field3 uses.
In fact the shape of it inverts the exact lowering's:
| node | toScalarField |
exactSolid |
this reading |
|---|---|---|---|
SolidExpr.SmoothUnion |
native | refused — the fillet lies on neither operand | native, and C¹: the one boolean with no kink at all |
SolidExpr.SmoothDifference |
native | refused | native, C¹ |
SolidExpr.Gyroid |
native | refused — not a bounded volume | native; one abs kink on the mid-sheet |
SolidExpr.Round |
native | refused — would not converge | native, and exactly f − r: the cleanest derivative here |
SolidExpr.Union |
native | native | subgradient on the seam, split 0.5/0.5 |
The constructs the exact kernel refuses hardest are the ones this reading is best at. A smooth
blend is differentiable by construction; a hard union is not. If you are going to fit a shape,
smoothUnion and smoothDifference are the operators to write it with, and
SolidExpr.gradientNotes will tell you so about your own expression.
What is refused, always with a stated reason, is binding a parameter to a slot — see
SolidExpr.parameterize. They fall into three groups: the address is wrong, two names claim one
number, or the seed is singular before the first step — from "that path does not exist, here are
the ones that do" to "segments is an integer facet count that toScalarField ignores entirely,
so there is no derivative to take". Nothing guesses; a wrong derivative is worse than no
derivative.
Kinks and the 0.5/0.5 tie rule
A distance field is full of kinks. min and max are CSG; abs is SolidExpr.Shell and
SolidExpr.Gyroid; sqrt at zero is every radial primitive's own centre. A derivative at a kink
is a choice, and autodiff.kt's file KDoc has already made it: abs, min and max split the
derivative 0.5/0.5 at an exact tie, and it says why — that is "precisely what a central
difference reports at a kink, so the analytic gradient and the finite-difference oracle agree
there too".
That matters here. It means the CSG seam does not have to be carved out of the finite-difference census: a point sitting exactly on the boundary between two united children is a point where the analytic answer and the numerical oracle still match. The convention is inherited rather than re-chosen, so there is one place it is written down.
Every remaining place where the gradient is a subgradient, is zero when you might have expected
otherwise, or is genuinely singular, is enumerated by SolidExpr.gradientNotes — the third
notes function in this package, beside SolidExpr.fieldNotes and SolidExpr.allRefusals. The
singular ones matter: √(x²+y²+z²) has an infinite position gradient at the origin, so a
SolidExpr.Sphere's field is not differentiable at its own centre. Its parameter gradient
∂/∂r = −1 is exact everywhere, which is the asymmetry that makes fitting work anyway.
@see SolidExpr.parameterize to name the numbers you want derivatives with respect to
@see afield.lang.solidsl.fitParameters for the inverse problem this exists to serve
@see SolidExpr.gradientAgreement for this reading measured against toScalarField, which shares
no code with it — SolidAgreement's own argument applied to a third column
src/jvmMain/kotlin/afield/lang/solidsl/SolidGradient.kt — read the source
Used in: A solid, written down: afield.lang.solidsl
Top-level functions
| Declaration | Summary |
|---|---|
fun SolidExpr.numericSlots(): List<SolidNumericSlot> |
Every Double in this expression that could be a parameter, in traversal order. |
fun SolidExpr.withNumericValues(values: Map<String, Double>): SolidLowering<SolidExpr> |
This expression with the numbers at the given values paths replaced — an ordinary SolidExpr, so the result marches, exact-solids, emits GLSL and prints like anything else in the algebra. |
fun SolidExpr.parameterize(spec: SolidParamSpec.() -> Unit): SolidLowering<SolidTemplate> |
Promote some of this expression's numbers into named parameters, or refuse and say why. |
fun SolidExpr.gradientNotes(): List<String> |
Every place in this expression where the gradient is a subgradient, is structurally zero, or is genuinely singular, in the order they appear — beside fieldNotes and allRefusals as the third disclosure this package owes a reader. |
fun SolidExpr.toADField(numerics: SolidNumerics, prefix: String = ""): ADField3 |
This shape as an ADField3, negative inside — the differentiable transposition of toField3. |
fun Profile.toADField2(numerics: SolidNumerics, prefix: String = ""): ADField2 |
This region as an ADField2, negative inside — the differentiable transposition of toField2. |
ADField3
A scalar field over three tracked coordinates — Field3's differentiable twin.
| Declaration | Summary |
|---|---|
fun at(x: AD, y: AD, z: AD): AD |
ADField2
A scalar field over two tracked coordinates — Field2's differentiable twin.
| Declaration | Summary |
|---|---|
fun at(x: AD, y: AD): AD |
SolidNumerics
class SolidNumerics(private val tape: ADTape, private val bound: Map<String, AD> = emptyMap())
Where a differentiable lowering gets each of an expression's numeric leaves, and the record of which ones it asked for.
| Declaration | Summary |
|---|---|
val pathsAsked: Set<String> |
Every path this lowering read, in the order it read them. |
fun at(path: String, literal: Double): AD |
The bound parameter at path, or a tape constant holding literal. |
fun literal(value: Double): AD |
A number that is not a slot, and is therefore not recorded: the sentinel an empty subtree reads as. |
SolidNumericSlot
One Double inside an expression that a parameter can be bound to.
| Declaration | Summary |
|---|---|
val path: String |
|
val node: String |
|
val field: String |
|
val value: Double |
|
override fun toString(): String |
SolidTemplate
A SolidExpr plus the named parameters some of its numbers have been promoted into — the thing that can be differentiated with respect to something a human named, and therefore the thing that can be solved.
| Declaration | Summary |
|---|---|
val base: SolidExpr |
|
val names: List<String> |
|
val slotsOf: Map<String, List<String>> |
|
val seed: DoubleArray |
|
val size: Int |
How many parameters — the length of every vector this template takes and returns. |
fun withValues(values: DoubleArray): SolidExpr |
This template with values substituted, as an ordinary SolidExpr. |
fun adFieldWith(parameters: List<AD>): ADField3 |
The differentiable field with these already-tracked parameters bound to the named slots. |
fun gradientAt(x: Double, y: Double, z: Double, values: DoubleArray): SolidGradientSample |
The value and the whole gradient at one point — position and parameters. |
fun report(): String |
Which numbers these parameters actually reach, for a println in a box. |
override fun equals(other: Any?): Boolean |
|
override fun hashCode(): Int |
SolidGradientSample
A field value at a point, and its exact derivatives in both directions that matter.
| Declaration | Summary |
|---|---|
val value: Double |
The signed distance, negative inside — the same number toScalarField gives. |
val dPosition: DoubleArray |
∂value/∂(x, y, z) — the surface normal, unnormalised, and unit-length where the field is exact. |
val dParameters: DoubleArray |
∂value/∂pᵢ for each named parameter, in the template's order. |
override fun equals(other: Any?): Boolean |
|
override fun hashCode(): Int |
|
override fun toString(): String |
SolidParamSpec
class SolidParamSpec internal constructor(val slots: List<SolidNumericSlot>)
The builder SolidExpr.parameterize runs — one param call per name, either against explicit paths or against a predicate over the slot census.
| Declaration | Summary |
|---|---|
val slots: List<SolidNumericSlot> |
The whole census, so a predicate has something to select from and an error something to list. |
fun param(name: String, vararg paths: String, seed: Double? = null) |
Bind name to exactly these structural paths. |
fun param(name: String, seed: Double? = null, where: (SolidNumericSlot) -> Boolean) |
Bind name to every slot matching where — the form that says "all of them", and therefore the form that expresses a symmetry. |
SolidInverse.kt
The payoff of lowering (c): inverse design. Declare what a solid should look like and let a gradient find the numbers that make it so.
val truth = (sphere(0.80) + cylinder(1.60, 0.30)).let { it }
val target = truth.sampleField(count = 3000, seed = 7)
val template = truth.parameterize {
param("r") { it.node == "Sphere" && it.field == "radius" }
param("h") { it.node == "Cylinder" && it.field == "height" }
}.orThrow()
val fit = template.fitParameters(target, start = doubleArrayOf(0.35, 2.60))
println(fit.report())
fit.fitted.march(96) // an ordinary SolidExpr again
This is afield.lang.rectsl.Track.fitParameters in three dimensions, and it is the reason
SolidGradient.kt exists. RectSLFn's KDoc: "a track that lowers to an Fn is a track you can take
the derivative of, and therefore a track you can solve: declare where a rect should be at some
instant and let a gradient find the numbers that put it there."
The target is a sampled field, not a mesh
A SolidSamples is a bag of points with a signed distance at each. Three things recommend it over
fitting against a mesh:
- It has a gradient. A mesh-to-mesh distance goes through a nearest-triangle search whose
result is a discontinuous function of the parameters — the nearest triangle changes — and
marching cubes has to be re-run at every step. A field residual is
pred − targetat a fixed point, and its derivative is exactly whatSolidTemplate.adFieldWithhands over. - The target need not be a solid at all.
sampleFieldbuilds one from anotherSolidExpr, which is what a recovery test wants; but a scan, a level set from somewhere else, or a hand- written list of "this point must be 2mm outside" constraints is the same type. - It is cheap and fixed. Sample once; every iteration reads the same array. A 3000-point target costs one march-free forward pass and one backward sweep per step.
The cost is that it fits the field and not the surface, and those are different objectives. Two
shapes can have the same zero set and different fields — SolidExpr.Intersect's own
SolidExpr.fieldNotes entry says it over-estimates distance outside a concave corner — so a field
residual is a stricter target than "look the same". Strictly speaking that is a feature for
recovery (there is one right answer and the loss knows it) and a hazard for design (you may not
care about the field away from the surface). SolidFitOptions.band is the dial: clamp both sides
into a narrow band around zero and the objective becomes almost purely about the surface.
What makes a parameter refuse to move
This is the failure mode of inverse design and it is worth naming before it happens, because it presents as a converged optimisation:
- A hard
SolidExpr.Uniongives the whole derivative to whichever child is nearest. A child that is nowhere nearest — swallowed inside another — receives exactly zero from every sample, and its parameters have a zero gradient. Write the shape withsmoothUnionand every child contributes withinkof the seam. - A
SolidExpr.Differencecutter entirely inside the body is the same story. - A
SolidExpr.Emptysubtree, or anExtrudesample past the end of the sweep, is a structural zero.
SolidFit.deadParameters reports exactly which parameters had a zero gradient at the start, so
that reads as a diagnosis rather than as a mysteriously immediate convergence. And
SolidExpr.gradientNotes says the same things about an expression before it is ever fitted.
src/jvmMain/kotlin/afield/lang/solidsl/SolidInverse.kt — read the source
Used in: A solid, written down: afield.lang.solidsl
Top-level functions
SolidSamples
A bag of points with a signed distance at each — the thing a parametric solid is fitted to.
| Declaration | Summary |
|---|---|
val xs: DoubleArray |
|
val ys: DoubleArray |
|
val zs: DoubleArray |
|
val distances: DoubleArray |
|
val size: Int |
|
val insideCount: Int |
How many of these points are inside the shape they were sampled from — a sanity number. |
val rms: Double |
The root-mean-square distance in this target — the scale a loss should be read against. |
override fun toString(): String |
SolidFieldAgreement
Lowering (c)'s value against lowering (a)'s, over one shape.
| Declaration | Summary |
|---|---|
val expr: SolidExpr |
|
val samples: Int |
|
val maxAbsoluteError: Double |
Largest \|c − a\| over the samples, in model units. |
val maxRelativeError: Double |
Largest \|c − a\| / max(1, \|a\|) — relative, but floored so a near-zero value is not a blow-up. |
val worstAt: Triple<Double, Double, Double> |
Where the worst disagreement was, for a println that can be re-evaluated by hand. |
val nonFinite: Int |
How many samples produced a non-finite value on either side — must be 0. |
fun report(): String |
SolidGradientCheck
An analytic gradient beside a central-difference one, component by component.
| Declaration | Summary |
|---|---|
val analytic: DoubleArray |
|
val numerical: DoubleArray |
|
val numericalHalfStep: DoubleArray |
The same central difference at half the step — what usableComponents is decided from. |
val step: Double |
|
val size: Int |
How many components there are. |
val usableComponents: BooleanArray |
Which components the numerical oracle is prepared to have an opinion about. |
val nonFiniteAnalytic: List<Int> |
Components where the oracle is trustworthy and the analytic gradient is not a number. |
val usableCount: Int |
How many of them, i.e. how much evidence this check actually carries. |
val usable: Boolean |
Whether there is any verdict here at all. |
val maxError: Double |
The disagreement over the usable components, measured against a floored scale — max(1, \|analytic\|, \|numerical\|). |
val worstComponent: Int |
Which component disagreed the most, or -1 when there is no verdict. |
override fun equals(other: Any?): Boolean |
|
override fun hashCode(): Int |
|
override fun toString(): String |
SolidFitOptions
The dials on SolidTemplate.fitParameters.
| Declaration | Summary |
|---|---|
val band: Double? |
|
val method: OptimizeMethod |
|
val iterations: Int |
|
val gradientTolerance: Double |
|
val learningRate: Double |
|
val memory: Int |
SolidFit
What a fit did, and what it started from.
| Declaration | Summary |
|---|---|
val template: SolidTemplate |
|
val start: DoubleArray |
Where the descent started. |
val values: DoubleArray |
Where it finished. |
val initialLoss: Double |
The loss at start, measured now. |
val loss: Double |
The loss at values, measured now. |
val fitted: SolidExpr |
The fitted parameters substituted back in — an ordinary SolidExpr. |
val run: OptimizeRun |
The whole descent, for a loss curve or a convergence reason. |
val deadParameters: List<String> |
Parameters whose gradient was exactly zero at start, or that the whole descent left exactly where they began — the ones that could not, or did not, move. The second catches a child that is never the nearest surface anywhere: a cube wholly inside a bar is invisible to the loss, and its parameter comes back bit-identical to the seed. |
val improvementFactor: Double |
initialLoss / loss — how many times better the answer is than the seed. |
fun shapeAt(index: Int): SolidExpr |
The shape the descent was holding at iteration index — an ordinary SolidExpr, marchable like any other. |
fun shapesAt(indices: List<Int>): List<SolidExpr> |
shapeAt for a whole strip, in the order given. |
val rmsError: Double |
The residual as an RMS distance, which is comparable with SolidSamples.rms. |
fun report(): String |
The whole story, for a println in a box. |
override fun equals(other: Any?): Boolean |
|
override fun hashCode(): Int |
SolidPointCloudFit.kt
Fitting a parametric solid to a point cloud — unoriented samples on a surface, with no signed distances, no normals and no correspondence.
val cloud = someTarget.samplePointCloud(count = 3000, seed = 4, noise = 0.004)
val fit = template.fitToPointCloud(cloud, start = doubleArrayOf(0.3, 1.1, 0.4))
Why a point cloud is a different problem from fitParameters
fitParameters regresses against a SolidSamples — a bag of points with a signed distance at
each. That is a rich target and it is also a slightly circular one: the distances come from
toScalarField, so the oracle already knows the answer in the same representation the model
speaks. It is the right test of whether the gradient works. It is not the problem anybody has.
The problem anybody has is a point cloud: a .psub plane subdivision rendered as points, a
compressed-splat PLY, a depth scan. Points on a boundary. No sign, no inside/outside, no normals
you trust, no correspondence to anything, some noise, and — nearly always — only the part of the
surface that faced the sensor.
The loss is the obvious one, and the three failure modes below all follow from what it does not contain:
L(θ) = (1/N) · Σ f(pᵢ; θ)²
The model's own field, squared, at each cloud point. A surface point should be on the surface, so
f should be zero there. That is the whole objective.
The missing interior term
There is no term that wants the interior filled. L is minimised by putting the model's
zero level set through the points, and a shape that puts some surface through them scores as well
as the shape that generated them. Three consequences, each measured in SolidSLPointCloudTest:
- A full cloud recovers well. Points all round a closed surface pin it from every side, and the missing interior term does not get a chance to matter.
- A partial view does not, and it fails in a specific direction. Crop the cloud to one hemisphere and every parameter that only shows itself on the missing side is unconstrained. The loss does not go up when they wander, because there are no points there to notice. This is not a solver failure — the solver finds a genuine minimum of the objective it was given — and no amount of restarting fixes it. It is the objective that is wrong for that data.
ShellandRoundare dangerous under this loss in a way they are not underfitParameters.abs(f) − tis zero on two surfaces, so a shell can satisfy a cloud with the wrong one of them.
SolidCloudFit.unconstrainedParameters reports the first-order version of the second bullet: a
parameter whose gradient stays near zero across the whole cloud is a parameter the data does
not see. It is the point-cloud analogue of SolidFit.deadParameters and it exists for the same
reason — the failure presents as a converged optimisation.
The target generation shares no code with the model
SolidExpr.samplePointCloud marches the shape with afield.geometry.mesh.marchingCubes and samples its
triangles by area. That path touches nothing in SolidGradient.kt: no AD, no tape, no
toADField. So a recovery here is two independent implementations agreeing, in the same sense
SolidAgreement means it, rather than one implementation checking its own arithmetic.
It is also what a real cloud is like. Triangle-area sampling is uniform over the surface, so (unlike vertex sampling) a coarsely marched flat face is not under-represented relative to a finely marched curved one.
src/jvmMain/kotlin/afield/lang/solidsl/SolidPointCloudFit.kt — read the source
Used in: A solid, written down: afield.lang.solidsl
Top-level functions
| Declaration | Summary |
|---|---|
fun SolidExpr.samplePointCloud(count: Int = 3000, seed: Int = 0, noise: Double = 0.0, resolution: Int = 110): SolidPointCloud |
Sample count points uniformly over this shape's surface, by marching it and sampling its triangles by area. |
fun TriMesh.samplePoints(count: Int, seed: Int = 0, noise: Double = 0.0): SolidPointCloud |
count points scattered uniformly over this mesh's triangles, by area. |
fun SolidTemplate.fitToPointCloud(cloud: SolidPointCloud, start: DoubleArray, options: SolidFitOptions = SolidFitOptions(method = OptimizeMethod.LBFGS, iterations = 500)): SolidCloudFit |
Fit this template's named parameters so its surface passes through cloud, by gradient descent on the mean squared field value. |
SolidPointCloud
class SolidPointCloud(val xs: DoubleArray, val ys: DoubleArray, val zs: DoubleArray)
Unoriented points on a surface — the target SolidTemplate.fitToPointCloud takes.
| Declaration | Summary |
|---|---|
val xs: DoubleArray |
|
val ys: DoubleArray |
|
val zs: DoubleArray |
|
val size: Int |
|
fun extent(): DoubleArray |
The cloud's axis-aligned extent, as (minX, minY, minZ, maxX, maxY, maxZ). |
val scale: Double |
The longest axis of extent — the scale a residual should be read against. |
fun filter(keep: (Double, Double, Double) -> Boolean): SolidPointCloud |
This cloud with only the points keep accepts — how a partial view is made. |
override fun toString(): String |
SolidCloudFit
What a point-cloud fit did, and what the cloud could not tell it.
| Declaration | Summary |
|---|---|
val template: SolidTemplate |
|
val start: DoubleArray |
|
val values: DoubleArray |
|
val initialLoss: Double |
Mean squared field value over the cloud at start, measured now. |
val loss: Double |
The same at values. |
val fitted: SolidExpr |
|
val run: OptimizeRun |
|
val unconstrainedParameters: List<String> |
Parameters whose gradient was negligible across the whole cloud at the start, or that the descent never moved off their seed — the ones this data cannot see. |
val improvementFactor: Double |
initialLoss / loss. |
val rmsDistance: Double |
The RMS field value over the cloud — an approximate distance from the cloud to the fitted surface, in model units, and the number to read as "how far off is it". |
fun report(): String |
|
override fun equals(other: Any?): Boolean |
|
override fun hashCode(): Int |
SolidSimplify.kt
Rewriting a SolidExpr into a smaller one that means the same thing — the other half of what
making the 3D family symbolic bought, beside a second and third backend.
val messy = unionOf(listOf(sphere(1), sphere(1))).at(0, 0, 0).at(1, 0, 0) - SolidExpr.Empty
println(messy.simplified()) // Translate(child=Sphere(radius=1.0, segments=0), x=1.0, …)
None of these rewrites is available to a closure. Two sdfSphere(1.0) lambdas are different
objects and always will be; two SolidExpr.Sphere(1.0) are ==. Nothing can ask a (Vec3) -> Float
whether it is ever non-zero, or where; a node knows its own SolidExpr.bounds without sampling.
This file is what those two facts are worth.
The rules
The dull ones first, because a notation accumulates them the moment anyone generates code into it:
identity transforms drop out, adjacent SolidExpr.Translates and SolidExpr.Scales fold, nested
booleans of the same kind flatten into one n-ary node (which matters more here than in most
algebras, because n-ary is exactly what reaches Manifold's batch boolean rather than a fold), and
SolidExpr.Empty is absorbed.
Two of them are worth more than that:
-
Disjoint operands.
a ∩ bwhere the two bounds do not overlap isSolidExpr.Empty;a − bwherebis nowhere nearais justa. That is a fact about the expression, decided from symbolic bounds with nothing sampled and nothing built. On a generated assembly — a hundred bores drilled through one plate of which twelve actually meet it — this is the difference between a shader that runs and one that does not. -
A vanishing blend removes a refusal.
SmoothUnion(a, b, k)withkat or below zero is a plain union, and a plain union has an exact CSG form where a smooth blend never will. So simplification can move an expression from the level-set fallback onto the exact path, which is a strange and rather nice thing for a peephole optimiser to be able to do.simplifiedis therefore worth calling beforebuildSolidand not only beforetoGlsl.
What it does not do
It does not compose two SolidExpr.Rotates unless they turn about the same single axis. Two
general Euler rotations compose to a matrix, and a matrix does not always come back out as three
Euler angles without a gimbal case to get wrong — and the whole discipline of this package is that
a rewrite which is nearly right is worse than no rewrite. It also does not reassociate
SolidExpr.SmoothUnion, which is genuinely non-associative, or reorder a
SolidExpr.Difference's operands, which is genuinely non-commutative.
Every rule is checked by sampling: SolidSLSimplifyTest asserts that the simplified expression's
field agrees with the original's at thousands of points, for every rule and every combination it
has. A rewrite that changes the shape is the failure this file exists to rule out, so it is the
most heavily sampled.
src/jvmMain/kotlin/afield/lang/solidsl/SolidSimplify.kt — read the source
Used in: A solid, written down: afield.lang.solidsl
Top-level functions
| Declaration | Summary |
|---|---|
fun SolidExpr.simplified(maxPasses: Int = 8): SolidExpr |
This expression, rewritten smaller. Bottom-up, then repeated until it stops changing — a fixed point, because flattening a boolean can expose a fold that exposes an identity. |
fun Profile.simplified(maxPasses: Int = 8): Profile |
simplified for a 2D region. |
fun SolidExpr.nodeCount(): Int |
How many nodes this expression has — the number simplified is trying to reduce. |
fun SolidExpr.simplificationReport(): String |
A one-line account of what simplified did to this expression, for a println in a box. |
StackSLBox.kt
Host-side entry points for StackSL: compiling the stack language to GLSL, and evaluating it on
the GPU sample by sample. The language, its compiler and its diagnostics live in
afield.lang.stacksl; this file is the Kotlin surface a box calls.
stackSL holds a box's StackSL source, falling back to a parent box's. compileStackSL,
stackSLToGlsl and the standalone slGlsl are pure compilation and need no device. slEval and
slSweep actually run the compiled function: they wrap it in a small compute shader and dispatch
it through a Renderer that defaults to fromContext(), so they need a live graphics context.
Neither binds any texture sampler, so a source that needs one is rejected.
slSweep returns a StackSLSweep, which implements ToHTML and draws itself as an inline SVG
plot of the four output channels with per-channel min/median/max; StackSLSweep.h is that HTML.
src/jvmMain/kotlin/afield/lang/stacksl/StackSLBox.kt — read the source
Used in: StackSL cheatsheet, StackSL examples
Top-level functions
Top-level values
| Declaration | Summary |
|---|---|
var Box.stackSL: String? |
This box's StackSL source text, the property every helper here defaults to. Inherited, so a box that has none falls back to the nearest ancestor that does — a parent can hold the program and its children compile variations of it. |
val StackSLSweep.h: String |
The sweep's StackSLSweep.toHTML plot, for display() and anywhere else HTML is wanted. |
StackSLSweep
What slSweep measured: the inputs it used and the vec4 the program returned for each, kept side by side so the response can be plotted or asserted on.
| Declaration | Summary |
|---|---|
val source: String |
The StackSL program that was evaluated, exactly as it was passed in. |
val start: Vec4 |
The first input vector of the sweep — range.first as given to slSweep. |
val end: Vec4 |
The last input vector of the sweep; it is evaluated, the range being closed at both ends. |
val inputs: List<Vec4> |
Every input vector actually dispatched, in order, from start to end inclusive. |
val values: List<Vec4> |
The program's output for each entry of inputs, index for index. May contain NaN or infinity. |
val size: Int |
How many samples the sweep holds — the samples slSweep was asked for. |
operator fun get(index: Int): Vec4 |
The output for sample index; throws IndexOutOfBoundsException past size, as a list does. |
fun channel(channel: Int): List<Float> |
One output channel across the whole sweep, as a list ready for a plot: 0 is x/r, 1 y/g, 2 z/b, 3 w/a. Any other index throws IllegalArgumentException. Non-finite samples are handed back as they are — it is the plot, not this, that filters them. |
override fun toHTML(): String |
The sweep drawn as a self-contained SVG plot: one polyline per output channel, dots and guide lines on each channel's extremes, the first and last input/output pair, and a per-channel avg/min/median/max table. |
override fun toString(): String |
A one-line summary — sample count plus the first and last output — for logs and assertions. |
StackSLFeedback.kt
StackSL with its own previous frame: Box.stackFeedback draws a box's stackSL program
fullscreen every frame into a float ping-pong target, with the last frame bound as
sampler2D state, so prev, sample, gather and the store words (0-> … 3->) read what the
program drew a frame ago. StackSLFeedback is the running host; StackSLLowering is the panel it
shows in the box's output — the lowered GLSL, its helpers, wires, stack depth and diagnostics.
StackSLFeedbackTest covers the shader it builds and the panel.
src/jvmMain/kotlin/afield/lang/stacksl/StackSLFeedback.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.stackFeedback(renderer: Renderer = fromContext(), reset: Boolean = true, configure: StackSLFeedbackSettings.() -> Unit = {}): StackSLFeedback |
Draw this box's StackSL fullscreen with its previous frame as state: the StackTrinket renderer in one call. The box needs a renderer above it, and its program in the stackSL property. |
Top-level values
| Declaration | Summary |
|---|---|
const val STACKSL_LOWERING_CHANNEL |
The output channel Box.stackFeedback shows its StackSLLowering in. |
const val STACKSL_GLSL_CHANNEL |
The output channel Box.stackFeedback shows the lowered GLSL in; it starts closed. |
StackSLFeedbackSettings
Settings for a StackSLFeedback. All but the size can change while it runs.
| Declaration | Summary |
|---|---|
var name: String |
Names what the host puts in the window. Every feedback box in a document uses the same name by default, so launching one replaces whichever was showing — the boxes work as a preset menu. |
var width: Int |
The state's size in pixels; 0 takes the window's. Fixed once the host is built. |
var height: Int |
|
var fps: Double |
How many times a second the program steps. A feedback program's decays are per step (prev 0.965 * keeps 96.5% each frame), so its look depends on this rate, and the window may draw far faster than a browser does; 60 is what StackTrinket gets in a browser at 60 Hz. 0 steps on every frame the window draws. |
var timeScale: Double |
Seconds of program time per second of wall time — what time and 0.1t count in. |
var paused: Boolean |
Hold the picture: no frame is drawn and time stops until this is false again. |
var displayGamma: Double |
The exponent the window applies to what the program drew. A browser shows a WebGL canvas's numbers as sRGB, and Field's window expects linear light, so 2.2 shows a program the way the StackTrinket page and the StackSL gallery show it. The state itself is never changed by this. |
var showLowering: Boolean |
Show the StackSLLowering panel in the box's lowering output channel. |
var viewport: Rect? |
Where in the window the picture goes; null fills it. |
val inputs: MutableMap<String, HasImageView> |
More pictures the program can read beside state, in order: the first is sample1 / gather1 / texelSize1, the second sample2, up to three. Any texture will do — a classmate's WebRTC stream (webrtcSource), a camera, another render target. Each is copied, the right way up and at the state's size, once per step, so 0 sample1 reads it at this pixel with y up, exactly as prev reads the last frame. See input. |
fun input(name: String, texture: HasImageView) |
Add texture as the next input, read by sample1, sample2, …; name is its GLSL sampler name. |
StackSLFeedback
A StackSL program running against its own previous frame — the StackTrinket renderer.
| Declaration | Summary |
|---|---|
val renderer: Renderer |
|
val settings: StackSLFeedbackSettings |
|
var source: () -> String |
Where the program comes from; read every frame. |
val width: Int |
Width of the state in pixels. |
val height: Int |
Height of the state in pixels. |
val target |
The two images: one is drawn while the other is state. |
var lowering: StackSLLowering? |
The last compile, good or bad. |
var running: String? |
The program the running pipeline was built from. |
var frame: Long |
Frames drawn since the last reset. |
var seconds: Double |
Program time, in seconds, since the last reset. |
val picture: HasImageView |
The picture as Field's other textures keep one: top row first, 8-bit display values (what the window shows, before its display gamma). Hand it to anything that samples a texture — webrtcPublish(feedback.picture, "stacktrinket") publishes the program to the class. The state itself is stored bottom row first and in float, which suits prev but nothing else. Made the first time it is asked for, and updated on every step after that. |
override fun toString(): String |
|
fun reset() |
Clear both images to black and start time and the frame count again from zero. |
fun show(): ModulePointer |
Put the picture in the window, replacing whatever was shown under the same name. |
override fun render(command: CommandBuffer, index: Int) |
StackSLFeedbackShader
The shaders a StackSLFeedback draws with: a fullscreen triangle, and a fragment shader that hands the compiled program vec4(uv, hash, seconds) with the previous frame bound as STATE.
| Declaration | Summary |
|---|---|
const val STATE |
The sampler the previous frame is bound as, and the texture StackSL's sample words read. |
const val PREFIX |
The GLSL name of the compiled program. |
val compileOptions: StackSLCompileOptions |
How a feedback program is compiled: sampling STATE, with its numbers as dangling wires. |
fun compileOptions(inputs: List<String>) |
compileOptions with inputs as the textures sample1, sample2, … read. |
val PICTURE_VERTEX: String |
The fullscreen triangle as Field draws one elsewhere: fragUV.y = 1 lands in row 0. |
val PICTURE_FRAGMENT: String |
Reads the state at fragUV; drawn with PICTURE_VERTEX it comes out top row first. |
val COPY_FRAGMENT: String |
Copies an input texture into an image stored like the state: drawn with VERTEX, it reads source with v flipped, because Field's textures keep their top row first. With encode set (an sRGB source, such as a WebRTC stream's browser paints) it re-encodes what sampling decoded, so the program reads the numbers the picture holds — the values a browser ShaderTrinket's iChannel gives, and the space state is in. Without it a stream reads darker, by the gamma. |
val VERTEX: String |
The fullscreen-triangle vertex shader: fragUV runs 0..1 with y up, and the image is written so that image row 0 holds uv.y = 0. That is the row texture(state, vec2(u, 0)) reads, so prev reads back this pixel. Field's render targets put clip y = +1 in row 0, so the usual gl_Position = pos * 2 - 1 would store the picture upside down relative to its own sampling and every feedback step would flip it; StackSLFeedback.show flips it back for the window. |
fun fragment(lowered: String, inputs: List<String> = emptyList()): String |
The fragment shader around lowered, the GLSL the compiler made for a program. params is (seconds, frame, width, height); a negative frame draws black, which is how the state is cleared. The hash is StackTrinket's, so a program's grain looks as it does there. |
StackSLLowering
What one compile of a feedback program came to, as StackTrinket's lowering panel shows it: whether it is drawing, the stack depth with the leftover values under the returned top, the helpers it pulled in, how many dangling wires (tunable numbers) it has, each diagnostic at its line and column; glslHtml is the lowered GLSL line by line. It is a ToHTML, so display("lowering", it) draws the summary.
| Declaration | Summary |
|---|---|
val source: String |
|
val result: StackSLCompileResult |
|
val glslError: String? |
Why the GPU refused GLSL the compiler accepted, or null. |
val running: Boolean |
Whether a program is drawing — this one if it compiled, otherwise the last good one. |
val ok: Boolean |
The program compiled and the GPU took it. |
val depth: Int |
How many values the program leaves on the stack; the top is what is drawn. |
val guidance: String |
StackTrinket's one-line reading of depth. |
val numberedGlsl: String |
The lowered GLSL with three-digit line numbers, as the panel shows it. |
override fun toHTML(): String |
|
val glslHtml: String |
The numbered GLSL as HTML, scrolling inside a fixed 500 by 300 box. It is a panel of its own (STACKSL_GLSL_CHANNEL): a panel is sized to all of its content, folded or not, so inside the summary it held the summary open at full height. |
stackAutoNumbers.kt
Live numbers for a {{ stack.auto.foo }} slot: every literal in the StackSL becomes a member of a
uniform block, foo_numbers, that sliders, Kotlin or a keyframe can turn without a recompile.
StackAutoNumbers holds the block's layout and the calls that seed it and switch it live.
StackAutoNumbersTest covers it.
src/jvmMain/kotlin/afield/lang/stacksl/stackAutoNumbers.kt — read the source
StackAutoNumbers
The numbers in a {{ stack.auto.foo }} slot, live on the GPU: each number typed in the StackSL is compiled as a member of a uniform block of the slot's own, foo_numbers, so it can be turned without a recompile — from the box's sliders, from Kotlin, or from a keyframe.
| Declaration | Summary |
|---|---|
const val CAPACITY |
How many numbers a slot's block holds. |
fun blockName(slot: String) |
The uniform block a slot's numbers live in. |
fun member(slot: String, index: Int) |
The block member holding number index of slot. |
fun liveMember(slot: String) |
The block member that turns slot's numbers on. |
fun isNumbersBlock(name: String, members: Collection<String>) |
Whether a uniform block name with these members is some slot's numbers: foo_numbers, holding foo_live. |
data class Number(val index: Int, val initial: Double, val wire: StackSLDanglingWire) |
One number of a slot as compiled: its index, the literal it was compiled from and where it is. |
class Slot(val box: Box, val name: String) |
A slot of a box as last compiled: its source, its numbers (empty when baked), and whether it has gone live. |
fun slots(box: Box): Map<String, Slot> |
box's slots, by name, as last compiled. |
fun slot(box: Box, name: String): Slot |
box's slot name, created on first use. |
val onCompiled |
Listeners told when a slot of a box compiles with new source — the box's card redraws. |
fun binding(slot: String, shaderTexts: List<String>): Int |
The binding slot's block takes in set 0: 20 on, one per slot, in the order the slots first appear across shaderTexts (all of the box's stages, so every stage agrees), skipping any binding the texts already declare. |
fun block(slot: String, binding: Int): String |
slot's uniform block, at binding in set 0: foo_live and CAPACITY numbers, whatever the program. |
fun compileLive(slot: String, source: String, sampleTexture: String, entrypoint: String?): Pair<String, StackSLCompileResult>? |
source compiled for slot with its numbers read from the slot's block: the function a template expands to (after the block), and the compile; null when the program has more than CAPACITY numbers (compile it baked). A failed compile comes back with an empty function. |
fun compiled(box: Box, slot: String, source: String, entrypoint: String?, wires: List<StackSLDanglingWire>) |
Record that box's slot compiled source with these wires; a live slot whose source changed is re-seeded from it. |
fun baked(box: Box, slot: String, source: String) |
Record that box's slot compiled with its numbers baked in (too many, or not a stage that can hold them). |
fun structures(box: Box, slot: String): List<Structure> |
The structures box's modules hold for slot's block, one per module that declares it. |
var moduleStructures: (Box) -> List<Map<String, Structure>> |
Every live-uniform map of the modules box owns, found through the resolver Field registers when it builds them. |
fun value(box: Box, slot: String, index: Int): Double? |
Number index of box's slot as the GPU sees it: what was set, else its literal. |
fun live(box: Box, slot: String) |
Make box's slot live — every member seeded from what was set or the literals, foo_live on — without changing a number. |
fun set(box: Box, slot: String, index: Int, value: Double) |
Turn number index of box's slot to value, on every module that holds the slot, live from now on. |
fun reset(box: Box, slot: String) |
Put every number of box's slot back to its literal. |
fun created(structures: Map<String, Structure>) |
A module has just made its uniform structures: each numbers block's foo_live is written 0, so its numbers read as their literals. A uniform buffer starts as whatever memory it was given; left unwritten, foo_live was garbage, and a slot nobody had touched drew black. |
fun carry(box: Box?, rebuild: afield.graphics.render.ModuleRebuild.Rebuild) |
A module of box (null when its owner is not known) was rebuilt, and the generic carry-over has already run (afield.graphics.render.ModuleRebuild): it moved every value and keyframe binding of a numbers block that still fits, and dropped the rest. This only fills in after it, for a live slot: a block the generic carry had nothing for is seeded whole; a carried block gets, for each number it left without a value or binding, what was set on the slot or its literal. Nothing the generic carry put there is overwritten. |
modelling.kt
A self-tuning controller for pushing the distribution of a scalar signal towards a target
shape. You hand AdaptiveDistributionController.pushSamples a batch of samples each frame and it
returns a ControlOutput whose appliedParams are the next parameter values to send to whatever
produced them — a shader, a simulation, a tone curve.
The error signal comes from a FeatureExtractor; UniformQuantileFeatureExtractor is the
built-in one and measures how far a set of sample quantiles sit from their targets. Internally
each feature gets a recursive-least-squares row (with forgetting) that learns a local Jacobian,
the step is a Tikhonov-regularised Gauss-Newton solve scaled by a confidence estimate, then
softened near the parameter bounds, rate-limited by maxStep, clipped, and finally offset by a
per-parameter sinusoidal dither so the estimator keeps seeing excitation.
ControllerConfig.observationDelayFrames is the important knob: it models pipeline latency
explicitly by pairing each observation with the command issued that many pushes earlier, so the
loop does not have to be slowed down to learn correctly. Use AdaptiveDistributionControllerBank
for one controller per scene, and AdaptiveDistributionLoop to drive observe/apply from a
Runloop hook.
This is plain DoubleArray math with no GPU or Box dependency; every array in
ControllerConfig must be parameterNames.size long. main runs a synthetic drifting-Gaussian
plant against the controller and prints a trace.
src/jvmMain/kotlin/afield/math/control/modelling.kt — read the source
Top-level functions
FeatureExtractor
| Declaration | Summary |
|---|---|
val featureCount: Int |
|
fun errorFromSamples(samples: DoubleArray): DoubleArray |
UniformQuantileFeatureExtractor
| Declaration | Summary |
|---|---|
override val featureCount: Int |
|
override fun errorFromSamples(samples: DoubleArray): DoubleArray |
ControllerConfig
| Declaration | Summary |
|---|---|
val parameterNames: List<String> |
|
val paramMin: DoubleArray |
|
val paramMax: DoubleArray |
|
val initialParams: DoubleArray |
|
val maxStep: DoubleArray |
|
val ditherAmplitude: DoubleArray |
|
val ditherOmega: DoubleArray |
|
val ditherPhase: DoubleArray |
|
val forgetting: Double |
|
val initialCovariance: Double |
|
val gainBase: Double |
|
val tikhonov: Double |
|
val emaAlpha: Double |
|
val clipPenalty: Double |
|
val observationDelayFrames: Int |
|
val covarianceConfidenceScale: Double |
|
val residualConfidenceScale: Double |
|
val minConfidence: Double |
|
val boundarySoftZone: Double |
|
val parameterCount: Int |
ControllerDiagnostics
ControlOutput
| Declaration | Summary |
|---|---|
val nominalParams: DoubleArray |
|
val appliedParams: DoubleArray |
|
val diagnostics: ControllerDiagnostics |
ScalarSampleBatch
data class ScalarSampleBatch(val samples: DoubleArray, val clipRate: Double = 0.0)
| Declaration | Summary |
|---|---|
val samples: DoubleArray |
|
val clipRate: Double |
AdaptiveDistributionController
AdaptiveDistributionControllerBank
AdaptiveDistributionLoop
quantilewarp.kt
Reshapes a scalar signal's distribution by fitting a monotone 8-knot PCHIP curve from the
measured quantiles of recent samples onto the quantiles of a chosen target distribution —
TargetDistributions.UnitNormal by default, or any QuantileFunction.
AdaptiveQuantileWarp.update takes a batch of samples, merges it with the last
historyFrames batches, and returns a QuantileWarpState: the knot pairs, an
QuantileWarpState.evaluate for CPU-side use, and the same 16 knots packed into four vec4s
ready for push constants via QuantileWarpState.applyToPushConstants. Successive states are
smoothed towards each other by smoothingAlpha and forced strictly increasing, so the curve
stays invertible even when the input degenerates.
fieldQuantileWarpIncludeSource is the matching GLSL — fieldQuantileWarp8 and
fieldQuantileWarpMix evaluate the identical PCHIP curve on the GPU — and is reachable from
shader source as the built-in include named by FIELD_QUANTILE_WARP_INCLUDE_NAME.
Box.fieldProbeQuantileWarp closes the loop: it installs a runloop hook that drains a
ShaderFieldProbe, updates the warp, calls your apply callback and renders a knot table into a
box display channel.
Two constraints: exactly eight strictly increasing probabilities inside (0, 1), and
Box.fieldProbeQuantileWarp needs a live Box plus a probe configured for continuous snapshot
reads.
src/jvmMain/kotlin/afield/math/statistics/quantilewarp.kt — read the source
Used in: Measuring dependence between two signals, Field 4 Graphics
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.fieldProbeQuantileWarp(probe: ShaderFieldProbe, warp: AdaptiveQuantileWarp = AdaptiveQuantileWarp(), config: ShaderProbeQuantileWarpConfig = ShaderProbeQuantileWarpConfig(), install: Boolean = true, runloop: Runloop = Runloop.runLoop, apply: (warp: QuantileWarpState, batch: FieldProbeScalarBatch) -> Unit): ShaderFieldQuantileWarpController |
Close the loop: let a shader's own output decide how that shader's values are scaled, every frame. |
fun inverseStandardNormalCdf(p: Double): Double |
The standard normal quantile function Φ⁻¹(p) — Acklam's rational approximation, in three branches that meet at p = 0.02425 and its reflection. |
Top-level values
| Declaration | Summary |
|---|---|
const val FIELD_QUANTILE_WARP_INCLUDE_NAME |
The name shader source uses to pull in the GLSL half of this file: #include "quantileWarp". |
var Box._fieldProbeQuantileWarpController: ShaderFieldQuantileWarpController? |
The controller Box.fieldProbeQuantileWarp installed on this box, if any. |
QuantileFunction
fun interface QuantileFunction
The inverse CDF of the distribution you want a signal reshaped into: given a probability, the value at that quantile. A SAM interface, so QuantileFunction { p -> ... } is enough.
| Declaration | Summary |
|---|---|
fun quantile(p: Double): Double |
The value at probability p. Called only with p strictly inside (0, 1). |
TargetDistributions
The ready-made QuantileFunction targets: UnitNormal (the default target of AdaptiveQuantileWarp), Uniform01, and uniform for an arbitrary range.
| Declaration | Summary |
|---|---|
val Uniform01 |
The identity: warp a signal into a flat [0, 1] distribution. Accepts the closed range and throws outside it. |
val UnitNormal |
Standard normal, mean 0 and standard deviation 1 — the default target, and the one that makes a skewed signal usable with symmetric thresholds. Rejects p at exactly 0 or 1. |
fun uniform(min: Double, max: Double) |
A flat distribution over [min, max] — the target to use when a shader wants a value it can index or threshold in known bounds. |
QuantileWarpConfig
How AdaptiveQuantileWarp fits: which eight quantiles to measure, how much history to pool, and how fast to move.
QuantileWarpState
One fitted warp: eight inputKnots measured from the signal, eight targetKnots from the target distribution, and the monotone PCHIP curve through them.
| Declaration | Summary |
|---|---|
val historySampleCount: Int |
How many pooled samples these quantiles were measured from. |
val probabilities: DoubleArray |
The eight probabilities the knots were measured at. A copy. |
val inputKnots: DoubleArray |
The measured value of the signal at each probability — the x axis of the curve. Strictly increasing. |
val targetKnots: DoubleArray |
The target distribution's value at each probability — the y axis of the curve. |
val packedPushConstants: FloatArray |
The sixteen numbers a shader needs: eight inputKnots then eight targetKnots, as Float. |
fun evaluate(x: Double): Double |
The warp itself: a signal value in, its position in the target distribution out. |
fun xLo(): Vector4f |
Input knots 0..3, ready for a vec4 push constant. |
fun xHi(): Vector4f |
Input knots 4..7. |
fun yLo(): Vector4f |
Target knots 0..3. |
fun yHi(): Vector4f |
Target knots 4..7. |
fun applyToPushConstants(setVec4: (name: String, value: Vector4f) -> Unit, xLoName: String = "warpXLo", xHiName: String = "warpXHi", yLoName: String = "warpYLo", yHiName: String = "warpYHi") |
Push all four vec4s through one setVec4 callback, under the names the GLSL include expects. |
override fun toString(): String |
The sample count and both knot arrays to four decimal places — a warp you can read in a log line. |
AdaptiveQuantileWarp
Reshapes a signal's distribution into a target one, adaptively: hand update a batch of samples and get back a QuantileWarpState whose QuantileWarpState.evaluate maps that signal onto the target — TargetDistributions.UnitNormal unless you pass another.
| Declaration | Summary |
|---|---|
fun currentStateOrNull(): QuantileWarpState? |
The last state update produced, or null before the first one. Does not recompute anything. |
fun reset(seed: QuantileWarpState? = null): AdaptiveQuantileWarp |
Forget the pooled history and the current state, optionally starting again from seed. |
fun update(samples: DoubleArray): QuantileWarpState |
Fold a batch of samples in and return the new warp. |
ShaderProbeQuantileWarpConfig
How Box.fieldProbeQuantileWarp drives a warp off a GPU probe: when a batch is big enough to be worth fitting, what to clip, and where to draw the knots.
ShaderFieldQuantileWarpController
The running loop: drain a ShaderFieldProbe, refit the warp, hand it to your apply callback, draw the knots.
| Declaration | Summary |
|---|---|
val box: Box |
The box that owns the runloop hook and the display channel. |
val probe: ShaderFieldProbe |
Where the samples come from. Must be configured for continuous snapshot reads. |
val warp: AdaptiveQuantileWarp |
The fitter being driven. Its config decides the pooling and smoothing. |
val config: ShaderProbeQuantileWarpConfig |
How this controller reads the probe and what it draws. |
var tickCount: Int |
How many times a new probe snapshot has been fitted — not how many frames have passed. |
var lastBatch: FieldProbeScalarBatch? |
The most recent batch that was fitted, or null if none has been yet. |
var lastWarp: QuantileWarpState? |
The warp fitted from lastBatch, or null if none has been yet. |
fun tick(): QuantileWarpState? |
One attempt: read the probe, and if there is a new snapshot with enough samples, refit and apply. |
fun install(runloop: Runloop = Runloop.runLoop): ShaderFieldQuantileWarpController |
Add the pre-run hook that calls tick every frame. Idempotent. |
fun stop(runloop: Runloop = Runloop.runLoop, closeDisplay: Boolean = config.closeDisplayOnStop, clearDisplay: Boolean = config.clearDisplayOnStop): ShaderFieldQuantileWarpController |
Remove the hook, optionally tidy the display, and forget this controller on the box. |
ShaderTransitionNotes.kt
Notes and markers that report how a shader reload changed a box's uniforms, buffers and push constants.
src/jvmMain/kotlin/afield/ui/output/ShaderTransitionNotes.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun markShaderReferenceLines(box: Box, names: Map<String, String>): Int |
Put an error marker on each line of box's code that uses one of names — keyed by the shader name, valued by why it no longer means what it did — with a suggestion to re-run. At most a dozen lines per name, so a common member name cannot paint the whole box. Returns how many were marked. |
ShaderTransitionNotes
What the last full shader reload of a box did to its interface, said where the person editing it is looking.
| Declaration | Summary |
|---|---|
fun summary(box: Box, property: String): String? |
The summary the last reload of box recorded against property, or null. |
fun record(box: Box, properties: Collection<String>, transition: ShaderInterfaceTransition?, carryNotes: List<String> = emptyList()): String |
Record transition as what the reload just did to box's shader properties (the properties the person edits — fragment, or fragment_s, or the shared vertex_fragment), log its summary, and mark the lines of code that use a name it made stale. carryNotes are the values the reload could not carry. Returns the summary, "" when nothing changed. |
FLineInspect.kt
Make a figure answerable: inspect draws FLines the way lines does and then names whatever you
click on, in the figure's own coordinates.
The problem it solves is the one the panel documents ran into — a figure was drawn, and then the
numbers about it were fished back out with println because there was no way to ask the picture
"which edge is this, and how far along it am I?". inspect wires FLine.onClick and
FLine.hoverColor for every line it is given, so a click reports the element under the pointer
(see FLineIntersection) and writes a readable card into a second output channel.
src/jvmMain/kotlin/afield/ui/visualization/FLineInspect.kt — read the source
Top-level functions
pointing_render.kt
Seeing what the pointer found: the picked part lit, a crosshair where the pointer was aimed, and a caption that names what it hit.
Why this is in the layer and not left to the caller
Because "it compiles" and "a hit record came back non-null" are both worthless as evidence for something whose whole job is to tell a person what they are touching. The failure mode is not a crash; it is the wrong part lit, or a pointer that is confidently off by the height of a caption band. Neither shows up in a unit test's return value, and both are obvious in a picture.
So the layer ships its own way of drawing itself, SpatialPointerPixelTest decodes the PNG back
out and reads samples from it, and the demo transcript uses the same call a person would.
What the picture is
TriMesh.renderPng's rasterizer, over a mesh whose per-vertex colours have been repainted by part
(TriMesh.coloredByPart), with a crosshair composited on afterwards at the pointer's pixel. All of
it CPU: no GPU, no window, no AWT — so it runs inside a live Field box, inside a test, and inside a
headless docs capture, identically.
val scene = pickScene { add("model", mesh, parts = builder.groups()) }
val camera = MeshView().rayCamera(scene.mergedMesh())
val at = Vec2(360f, 270f)
display(scene.pointerFigureHtml(camera, at))
src/jvmMain/kotlin/afield/ui/visualization/pointing_render.kt — read the source
Top-level functions
PointerRaster
A finished picture of a pick: ARGB pixels, the size, and what the pointer found.
| Declaration | Summary |
|---|---|
val pixels: IntArray |
ARGB, row-major, width x height. |
val width: Int |
|
val height: Int |
|
val hit: PickHit? |
What the pointer found, or null. |
val pointerX: Double |
Where the pointer was aimed, in pixels. |
val pointerY: Double |
|
fun toPng(): ByteArray |
pixels as PNG bytes, via afield.media.image.util.PngEncoder. No AWT. |
fun caption(): String |
One line naming what was hit, for a caption or a log. |