Skip to content

alife_walls.kt

src/jvmMain/kotlin/afield/simulation/agents/alife_walls.kt · 1010 lines

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

package afield.simulation.agents

import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.times
import afield.data.graph.rules
import afield.document.context.Why
import afield.document.context.why
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.clear
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.geometry.mesh.gradient
import afield.geometry.spatial.closest
import afield.graphics.render.circle
import afield.graphics.render.rangeTo
import afield.graphics.shader.mesh
import afield.graphics.shader.miss
import afield.graphics.text.distanceTo
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.mean
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.round
import afield.math.autodiff.sum
import afield.math.autodiff.times
import afield.math.functions.behaviour
import afield.math.linalg.f
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.channel
import afield.media.image.close
import afield.runtime.execution.cell
import afield.runtime.execution.end
import afield.runtime.execution.go
import afield.runtime.lifecycle.cached
import afield.runtime.lifecycle.once
import afield.simulation.automata.step

import afield.animation.keyframes.h
import afield.audio.synth.*
import afield.data.collections.m
import afield.data.collections.r
import afield.data.collections.s
import afield.document.workspace.path
import afield.graphics.render.lineTo
import afield.graphics.render.moveTo
import afield.graphics.vulkan.buffers.add
import afield.lang.stacksl.h
import afield.math.linalg.d
import afield.math.linalg.gr
import afield.math.linalg.i
import afield.math.linalg.l
import afield.math.linalg.x
import afield.media.image.open
import afield.runtime.execution.start
import afield.ui.output.h
import afield.geometry.path.FLine
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt

Walls for alife_script.kt: obstacles, swept circle collision, and radius-aware routing around them.

See alife_script.kt for the scripting layer this completes, and alife.kt for the family overview.

Why this exists

ScriptWorld shipped as a bare rectangle. The only spatial thing an agent could say was BehaviourSteering.avoidEdges, which pushes away from the four sides of the arena; everything else a scripted agent could steer around was another agent. That rules out the whole category of behaviour that is about the environment — a corridor, a room with a door, a barrier you have to go round rather than through — and it rules it out silently, because an agent pressing into a wall that does not exist just flies on.

Three representations

Obstacles are a flat soup of line segments (ScriptObstacle, ObstacleField) and not polygons-as-solids. The only predicate collision needs is "is this swept circle crossing a wall", which a segment answers directly; making obstacles solid would mean a point-in-polygon test, a winding rule, and an answer for what two overlapping solids mean. A soup accepts an open wall, a concave room, a fence with a gap in it and two shapes that cross, all without a special case. The price is stated rather than hidden: there is no "inside", so nothing stops an agent that starts inside a closed shape from staying there — ScriptWorld.spawn pushes it out to the nearest surface instead of teleporting it somewhere arbitrary.

Collision is swept, not discrete (ObstacleField.sweep). A discrete test asks whether the agent's new position overlaps a wall, which is false for any agent whose per-step displacement is larger than the wall is thick — and a wall here is a segment, so it is infinitely thin and a discrete test can miss it at any speed at all. The swept test solves for the first fraction of the step at which the moving circle touches, so a projectile at 600 units/s stops at the wall it would otherwise have crossed in one tick. AlifeWallsGallery's tunnelling figure runs both.

Navigation is a uniform occupancy grid inflated by the agent's radius, searched with A*, then string-pulled against the exact segment test (NavGrid). The obvious alternative is a visibility graph, which is exact and gives shorter paths from a much smaller search. It was rejected on construction, not on search: a visibility graph over a segment soup needs its corners offset outward by the agent radius, and "the offset corner" is not defined where two segments cross, nearly touch, or are collinear — which is precisely the geometry that appears when a user draws a wall twice or butts two rectangles together. A grid has no corners to reason about: it is built by one distance query per cell per segment, radius-awareness is a single inflation number, and the one thing it gets wrong — paths that staircase along cell centres — is removed afterwards by NavGrid.stringPull, which uses the same exact swept test the physics does. A coarse grid can still over-block a narrow gap; NavGrid.coarse says when that happened rather than leaving the caller to wonder why there was no route.

Determinism

The same property the rest of alife_script.kt depends on, and the same discipline. Segments are held in insertion order in flat DoubleArrays. A's open set is ordered by (f, cellIndex), a total* order, so two cells with equal estimated cost expand in a fixed order rather than in whichever order a heap happened to leave them. Neighbours are visited through one fixed eight-element offset table. The nav-grid cache is a LinkedHashMap keyed by the quantised inflation radius. The per-step route budget is consumed in agent-id order, because drives are evaluated in agent-id order. Nothing here reads a clock or a hash set.

Obstacles

enum class WallResponse { SLIDE, BOUNCE, STOP }

What an agent does when its swept path meets a wall.

@property SLIDE keep the component of the motion along the wall and drop the component into it — the default, and the only one of the three that lets an agent get anywhere while touching something. An agent that slides along a barrier and round its end has not been told to do that by any steering verb; it is what "keep going, minus the impossible part" looks like. @property BOUNCE reflect, scaling the normal component by ScriptActor.restitution. For anything thrown. @property STOP stop dead at the contact. For anything that should be caught by a wall rather than deflected by it.

class ScriptObstacle(val name: String, points: DoubleArray, val closed: Boolean)

A wall: a run of points, open (a polyline) or closed (a polygon outline).

Not solid — see the file KDoc. A closed obstacle is exactly its outline, so "inside" is a region an agent cannot enter rather than a region it cannot be.

Build these with ScriptWorld.addWall, ScriptWorld.addRect, ScriptWorld.addPolygon, ScriptWorld.addPolyline and ScriptWorld.addDisc rather than directly; the world has to see them to index them.

@property name shows up in ScriptWorld.obstacles and in a figure's legend; not required to be unique, because two walls called "barrier" is a reasonable thing for a script to build in a loop @property points x0, y0, x1, y1, …, copied on the way in @property closed whether a final segment joins the last point back to the first

class ScriptObstacle(val name: String, points: DoubleArray, val closed: Boolean) {

val points: DoubleArray = points.copyOf()

x0, y0, x1, y1, …. A defensive copy; mutating what you passed in cannot move a wall.

init

    init {
        require(points.size >= 4 && points.size % 2 == 0) {
            "an obstacle needs at least two points as x,y pairs, got ${points.size} doubles"
        }
        for (v in points) require(v.isFinite()) { "obstacle '$name' has a non-finite coordinate" }
    }

val vertexCount: Int get() = points.size / 2

How many points the outline has.

val segmentCount: Int get() = if (closed) vertexCount else vertexCount - 1

How many segments it contributes: one fewer than the vertices, or the same if closed.

fun outline(z: Number = 0): FLine

The outline as an FLine, for drawing the world the agents are actually in.

    fun outline(z: Number = 0): FLine = FLine().also { l ->
        l.moveTo(points[0], points[1], z)
        for (i in 1 until vertexCount) l.lineTo(points[i * 2], points[i * 2 + 1], z)
        if (closed) l.lineTo(points[0], points[1], z)
    }

override fun toString(): String

    override fun toString(): String =
        "ScriptObstacle('$name', $vertexCount points, ${if (closed) "closed" else "open"})"
}

class WallHit internal constructor

One swept contact: how far along the displacement it happened, and which way the wall faces there.

@property t the fraction of the requested displacement travelled before touching, in 0..1. 0 means the agent was already in contact at the start of the step. @property nx x of the unit surface normal, pointing back toward the side the agent came from @property ny y of the same normal @property obstacle which wall @property segment which of that wall's segments, counting from 0

class WallHit internal constructor(
    val t: Double,
    val nx: Double,
    val ny: Double,
    val obstacle: ScriptObstacle,
    val segment: Int
) {

override fun toString(): String

    override fun toString(): String =
        "WallHit(t=%.4f n=(%.3f, %.3f) '%s'#%d)".format(t, nx, ny, obstacle.name, segment)
}

class ObstacleField

Every wall in a world, indexed as one flat array of segments — the thing the physics and the navigation both ask.

Flat DoubleArrays rather than a list of objects, and rebuilt lazily rather than appended to, because sweep is called once per agent per step (more, with a slide) and NavGrid calls the distance query once per cell per segment. Insertion order is preserved, which is what makes "the earliest hit, ties to the first-added wall" a reproducible sentence.

class ObstacleField {
    private val walls = ArrayList<ScriptObstacle>()
    private var packed = DoubleArray(0)
    private var owner = IntArray(0)
    private var localIndex = IntArray(0)
    private var dirty = true

val obstacles: List<ScriptObstacle> get() = walls

The walls, in the order they were added.

val segmentCount: Int get() { rebuild(); return owner.size }

How many segments all the walls come to.

val isEmpty: Boolean get() = walls.isEmpty()

True when there is nothing to collide with — the fast path the integrator takes.

fun add(o: ScriptObstacle)

Add o. Invalidates the packed index and any NavGrid built from this field.

    fun add(o: ScriptObstacle) {
        walls.add(o)
        dirty = true
    }

fun segments(): DoubleArray { rebuild(); return packed }

The packed segments, ax, ay, bx, by per segment, in insertion order.

Handed out live rather than copied, because the callers are per-step loops. Read it; writing to it moves walls without telling the navigation cache, which is the one way to make this class lie.

fun ownerOf(i: Int): ScriptObstacle { rebuild(); return walls[owner[i]] }

The obstacle that owns segment i of segments.

    private fun rebuild() {
        if (!dirty) return
        var n = 0
        for (w in walls) n += w.segmentCount
        val p = DoubleArray(n * 4)
        val ow = IntArray(n)
        val li = IntArray(n)
        var k = 0
        for ((wi, w) in walls.withIndex()) {
            val v = w.vertexCount
            for (s in 0 until w.segmentCount) {
                val a = s
                val b = (s + 1) % v
                p[k * 4] = w.points[a * 2]; p[k * 4 + 1] = w.points[a * 2 + 1]
                p[k * 4 + 2] = w.points[b * 2]; p[k * 4 + 3] = w.points[b * 2 + 1]
                ow[k] = wi; li[k] = s
                k++
            }
        }
        packed = p; owner = ow; localIndex = li
        dirty = false
    }

fun sweep(px: Double, py: Double, dx: Double, dy: Double, radius: Double): WallHit?

The first wall a circle of radius at (px, py) touches while moving by (dx, dy), or null if it touches none.

Solved, not stepped. For each segment the earliest t is the smaller of two roots: the one where the circle centre reaches the offset line ±radius from the segment's own line (and lands within the segment's extent), and the ones where it reaches an endpoint's radius circle. That is the whole of moving-circle-versus-capsule, and it is exact — there is no substep size to be too coarse.

A circle that is already overlapping a wall at t = 0 is not reported here: the roots it would produce are behind the start of the step, and a hit at a negative t is a depenetration problem rather than a collision. pushOut is that.

Ties — two walls hit at the identical t — go to the earlier-added segment.

    fun sweep(px: Double, py: Double, dx: Double, dy: Double, radius: Double): WallHit? {
        rebuild()
        if (owner.isEmpty()) return null
        if (dx == 0.0 && dy == 0.0) return null
        var bestT = Double.MAX_VALUE
        var bestNx = 0.0
        var bestNy = 0.0
        var bestSeg = -1
        val scratch = DoubleArray(3)
        for (i in owner.indices) {
            val ax = packed[i * 4]; val ay = packed[i * 4 + 1]
            val bx = packed[i * 4 + 2]; val by = packed[i * 4 + 3]
            if (sweepSegment(px, py, dx, dy, radius, ax, ay, bx, by, scratch)) {
                if (scratch[0] < bestT) {
                    bestT = scratch[0]; bestNx = scratch[1]; bestNy = scratch[2]; bestSeg = i
                }
            }
        }
        if (bestSeg < 0) return null
        return WallHit(bestT, bestNx, bestNy, walls[owner[bestSeg]], localIndex[bestSeg])
    }

fun nearestInto(px: Double, py: Double, range: Double, out: DoubleArray): Boolean

The nearest wall point to (px, py) within range, written into out as distance, wallX, wallY, normalX, normalY — the normal pointing from the wall toward the query. Returns false and leaves out alone when nothing is in range.

Writes into a caller-owned array rather than returning an object because the two steering verbs that use it (BehaviourSteering.cling, BehaviourSteering.avoidWalls) call it every step for every agent.

    fun nearestInto(px: Double, py: Double, range: Double, out: DoubleArray): Boolean {
        require(out.size >= 5) { "nearestInto needs an out array of at least 5, got ${out.size}" }
        rebuild()
        var bestD = range
        var found = false
        val scratch = DoubleArray(2)
        for (i in owner.indices) {
            closestPointOnSegment(
                px, py,
                packed[i * 4], packed[i * 4 + 1], packed[i * 4 + 2], packed[i * 4 + 3],
                scratch
            )
            val d = hypot(px - scratch[0], py - scratch[1])
            if (d <= bestD) {
                // <= rather than <, plus insertion order, so an exact tie takes the LATER segment
                // consistently rather than whichever the loop saw first at an equal distance.
                bestD = d; found = true
                out[0] = d; out[1] = scratch[0]; out[2] = scratch[1]
                if (d > 1e-12) { out[3] = (px - scratch[0]) / d; out[4] = (py - scratch[1]) / d }
                else { out[3] = 0.0; out[4] = 0.0 }
            }
        }
        return found
    }

fun distanceTo(px: Double, py: Double): Double

The distance from (px, py) to the nearest wall, or Double.MAX_VALUE if there are none.

    fun distanceTo(px: Double, py: Double): Double {
        rebuild()
        var best = Double.MAX_VALUE
        val scratch = DoubleArray(2)
        for (i in owner.indices) {
            closestPointOnSegment(
                px, py,
                packed[i * 4], packed[i * 4 + 1], packed[i * 4 + 2], packed[i * 4 + 3],
                scratch
            )
            val d = hypot(px - scratch[0], py - scratch[1])
            if (d < best) best = d
        }
        return best
    }

fun clearPath(x0: Double, y0: Double, x1: Double, y1: Double, radius: Double): Boolean

Whether a circle of radius could stand anywhere along the straight line from (x0, y0) to (x1, y1) — the test NavGrid.stringPull shortens a grid path with, and the one BehaviourSteering.route decides it has passed a waypoint on.

The swept capsule against every wall, computed as a segment-to-segment distance and deliberately not as sweep(…) == null. The two agree everywhere except one case, and that case is the one that matters here: sweep reports nothing for a circle that is already overlapping a wall at the start of its move, because a contact at a negative time is a depenetration problem rather than a collision. Asking sweep for clearance from a position that is already too close therefore gets the answer "clear" about the very wall you are pressed against.

That cost a whole evening once. An agent that clipped a corner and ended up against a wall replanned, and the string-pull — starting from the agent's real position, one unit from the wall — was told the shortcut straight through that wall was clear. It took it, and the agent spent the rest of the run grinding along the face of a wall its own route went through. A distance test has no such hole: the distance from a segment to a wall it starts on is zero, whichever end you measure from.

    fun clearPath(x0: Double, y0: Double, x1: Double, y1: Double, radius: Double): Boolean {
        rebuild()
        for (i in owner.indices) {
            val d = segmentDistance(
                x0, y0, x1, y1,
                packed[i * 4], packed[i * 4 + 1], packed[i * 4 + 2], packed[i * 4 + 3]
            )
            if (d < radius - 1e-9) return false
        }
        return true
    }

fun lineOfSight(x0: Double, y0: Double, x1: Double, y1: Double): Boolean

Whether the open segment from (x0, y0) to (x1, y1) crosses any wall — line of sight for a point, with no radius.

A separate routine from clearPath rather than clearPath(radius = 0) because the swept test divides by the side the circle centre starts on, and with a zero radius an eye exactly on the plane of a wall has no side. This is the straight segment-segment crossing test, which does not care.

    fun lineOfSight(x0: Double, y0: Double, x1: Double, y1: Double): Boolean {
        rebuild()
        for (i in owner.indices) {
            if (segmentsCross(
                    x0, y0, x1, y1,
                    packed[i * 4], packed[i * 4 + 1], packed[i * 4 + 2], packed[i * 4 + 3]
                )
            ) return false
        }
        return true
    }

fun pushOut(px: Double, py: Double, radius: Double, out: DoubleArray): Boolean

Move (px, py) to the nearest position where a circle of radius is not overlapping a wall, writing x, y into out. Returns true if it had to move.

Up to PUSH_OUT_PASSES passes, because pushing out of one wall can push into another (an agent in the corner of a room). It converges for any convex corner and gives up quietly in a crack narrower than the agent, which is the honest outcome: there is no non-overlapping position to find.

    fun pushOut(px: Double, py: Double, radius: Double, out: DoubleArray): Boolean {
        require(out.size >= 2) { "pushOut needs an out array of at least 2, got ${out.size}" }
        rebuild()
        var x = px
        var y = py
        var moved = false
        val near = DoubleArray(5)
        var pass = 0
        while (pass < PUSH_OUT_PASSES) {
            if (!nearestInto(x, y, radius, near)) break
            val d = near[0]
            var nx = near[3]
            var ny = near[4]
            if (d <= 1e-12) {
                // Dead on the wall: no normal to read off the offset, so take the segment's own.
                nx = 0.0; ny = 1.0
            }
            x = near[1] + nx * (radius + WALL_SKIN)
            y = near[2] + ny * (radius + WALL_SKIN)
            moved = true
            pass++
        }
        out[0] = x; out[1] = y
        return moved
    }

override fun toString(): String = "ObstacleField(${walls.size} walls, $segmentCount segments)"

const val WALL_SKIN: Double = 1e-6

How far outside a wall a contact is resolved to.

Resting exactly on the surface makes the next step's sweep start at t = 0 against the wall it is already touching, which either stalls the agent or costs it every response iteration it has. A skin this size is invisible at any drawing scale and is three orders of magnitude above the rounding of a coordinate in a 200-unit arena.

const val PUSH_OUT_PASSES: Int = 4

How many times ObstacleField.pushOut will try to find non-overlapping ground.

const val PROGRESS_EPSILON: Double = 1e-3

How much closer to its waypoint BehaviourSteering.route has to get to count as having moved.

An agent at any usable speed closes tenths of a unit per step, so a thousandth is far below real progress and far above the rounding of a coordinate. Its only job is to stop an agent that is grinding on a wall from banking a millionth of a unit a step and never being called stalled.

const val WALL_RESPONSE_ITERATIONS: Int = 4

How many wall responses one integration step may resolve.

Four is enough to slide along one wall, into a corner, along the second, and out; past that the remaining displacement is dropped rather than applied, because an agent wedged in a crack should stop, not squeeze through on the iteration the loop ran out of.

fun closestPointOnSegment

The closest point of the segment a–b to (px, py), written into out as x, y.

A degenerate segment (both endpoints the same) answers with that point, which is the right answer and not an error — a "wall" of zero length is a post, and an agent can still be next to it.

fun closestPointOnSegment(
    px: Double, py: Double,
    ax: Double, ay: Double, bx: Double, by: Double,
    out: DoubleArray
) {
    val ex = bx - ax
    val ey = by - ay
    val l2 = ex * ex + ey * ey
    if (l2 < 1e-18) { out[0] = ax; out[1] = ay; return }
    var t = ((px - ax) * ex + (py - ay) * ey) / l2
    if (t < 0.0) t = 0.0
    if (t > 1.0) t = 1.0
    out[0] = ax + ex * t
    out[1] = ay + ey * t
}

fun segmentDistance

The shortest distance between the two closed segments p–q and r–s. Zero if they cross.

Four point-to-segment queries and a crossing test, which is the whole of it: for two segments that do not cross, the closest pair always has at least one endpoint in it, so the minimum over the four "endpoint of one against the other" distances is exact rather than approximate.

fun segmentDistance(
    px: Double, py: Double, qx: Double, qy: Double,
    rx: Double, ry: Double, sx: Double, sy: Double
): Double {
    if (segmentsCross(px, py, qx, qy, rx, ry, sx, sy)) return 0.0
    val o = DoubleArray(2)
    var best = Double.MAX_VALUE
    closestPointOnSegment(px, py, rx, ry, sx, sy, o); best = min(best, hypot(px - o[0], py - o[1]))
    closestPointOnSegment(qx, qy, rx, ry, sx, sy, o); best = min(best, hypot(qx - o[0], qy - o[1]))
    closestPointOnSegment(rx, ry, px, py, qx, qy, o); best = min(best, hypot(rx - o[0], ry - o[1]))
    closestPointOnSegment(sx, sy, px, py, qx, qy, o); best = min(best, hypot(sx - o[0], sy - o[1]))
    return best
}

fun segmentsCross

Whether the two closed segments p–q and r–s cross, including touching at a point.

fun segmentsCross(
    px: Double, py: Double, qx: Double, qy: Double,
    rx: Double, ry: Double, sx: Double, sy: Double
): Boolean {
    fun orient(ax: Double, ay: Double, bx: Double, by: Double, cx: Double, cy: Double): Double =
        (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)

    fun onSpan(ax: Double, ay: Double, bx: Double, by: Double, cx: Double, cy: Double): Boolean =
        cx >= min(ax, bx) - 1e-12 && cx <= max(ax, bx) + 1e-12 &&
            cy >= min(ay, by) - 1e-12 && cy <= max(ay, by) + 1e-12

    val d1 = orient(px, py, qx, qy, rx, ry)
    val d2 = orient(px, py, qx, qy, sx, sy)
    val d3 = orient(rx, ry, sx, sy, px, py)
    val d4 = orient(rx, ry, sx, sy, qx, qy)
    if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) return true
    if (abs(d1) < 1e-12 && onSpan(px, py, qx, qy, rx, ry)) return true
    if (abs(d2) < 1e-12 && onSpan(px, py, qx, qy, sx, sy)) return true
    if (abs(d3) < 1e-12 && onSpan(rx, ry, sx, sy, px, py)) return true
    if (abs(d4) < 1e-12 && onSpan(rx, ry, sx, sy, qx, qy)) return true
    return false
}

fun sweepSegment

The earliest touch of a circle of r centred at (px, py) moving by (dx, dy) against the one segment a–b, written into out as t, normalX, normalY. Returns false when it misses.

Exposed because it is the primitive the whole file rests on and a test that wants to pin a time-of-impact by arithmetic should be able to call it without building a world.

body — 71 lines, from line 569
fun sweepSegment(
    px: Double, py: Double, dx: Double, dy: Double, r: Double,
    ax: Double, ay: Double, bx: Double, by: Double,
    out: DoubleArray
): Boolean {
    require(out.size >= 3) { "sweepSegment needs an out array of at least 3, got ${out.size}" }
    var bestT = Double.MAX_VALUE
    var bestNx = 0.0
    var bestNy = 0.0
    var found = false

    val ex = bx - ax
    val ey = by - ay
    val l2 = ex * ex + ey * ey

    if (l2 > 1e-18) {
        val l = sqrt(l2)
        val ux = ex / l
        val uy = ey / l
        // Unit normal to the segment; which of the two is "outward" depends on which side we start.
        val gx = -uy
        val gy = ux
        val s0 = (px - ax) * gx + (py - ay) * gy
        val sd = dx * gx + dy * gy
        if (abs(s0) >= r && abs(sd) > 1e-18) {
            val side = if (s0 >= 0.0) 1.0 else -1.0
            val t = (side * r - s0) / sd
            if (t >= 0.0 && t <= 1.0) {
                val cx = px + dx * t
                val cy = py + dy * t
                val proj = (cx - ax) * ux + (cy - ay) * uy
                if (proj >= 0.0 && proj <= l) {
                    bestT = t; bestNx = side * gx; bestNy = side * gy; found = true
                }
            }
        }
    }

    // The two endpoint caps. A circle rounding the end of a wall meets a circle, not a line.
    val a2 = dx * dx + dy * dy
    if (a2 > 1e-18) {
        var e = 0
        while (e < 2) {
            val cxE = if (e == 0) ax else bx
            val cyE = if (e == 0) ay else by
            val fx = px - cxE
            val fy = py - cyE
            val c = fx * fx + fy * fy - r * r
            if (c >= 0.0) {
                val bq = 2.0 * (fx * dx + fy * dy)
                val disc = bq * bq - 4.0 * a2 * c
                if (disc >= 0.0) {
                    val t = (-bq - sqrt(disc)) / (2.0 * a2)
                    if (t >= 0.0 && t <= 1.0 && t < bestT) {
                        val hx = px + dx * t - cxE
                        val hy = py + dy * t - cyE
                        val m = hypot(hx, hy)
                        if (m > 1e-12) {
                            bestT = t; bestNx = hx / m; bestNy = hy / m; found = true
                        }
                    }
                }
            }
            e++
        }
    }

    if (!found) return false
    out[0] = bestT; out[1] = bestNx; out[2] = bestNy
    return true
}

Navigation

class NavGrid internal constructor

A radius-aware occupancy grid over a world's walls, and the A* that searches it.

Built once per (world geometry, inflation radius) pair and cached by ScriptWorld; see the file KDoc for why a grid rather than a visibility graph. A cell is blocked when its centre is within inflate of any wall segment or of the arena boundary, so a route through free cells is a route a circle of inflate can follow — given that the cells are small enough that a wall cannot slip between two centres, which cellSize is chosen to guarantee.

@property inflate the agent radius plus whatever clearance the route asked for @property coarse true when cellSize had to be enlarged to stay under the cell cap, which means the grid may have closed a gap the agent would in fact have fitted through. A route that fails on a coarse grid is worth retrying with a smaller world or a bigger cap; a route that fails on a fine one really is blocked.

class NavGrid internal constructor(
    val width: Double,
    val height: Double,
    val cols: Int,
    val rows: Int,
    val cellSize: Double,
    val inflate: Double,
    val coarse: Boolean,
    private val blocked: BooleanArray
) {

val blockedCells: Int get() = blocked.count { it }

How many of the grid's cells a circle of inflate cannot stand in.

val cellCount: Int get() = blocked.size

Total cells.

fun isBlocked(c: Int, r: Int): Boolean

Whether the cell at column c, row r is unusable (out of range counts as blocked).

    fun isBlocked(c: Int, r: Int): Boolean =
        c < 0 || r < 0 || c >= cols || r >= rows || blocked[r * cols + c]

fun colOf(x: Double): Int = ((x / cellSize).toInt()).coerceIn(0, cols - 1)

The column containing world x x, clamped into the grid.

fun rowOf(y: Double): Int = ((y / cellSize).toInt()).coerceIn(0, rows - 1)

The row containing world y y, clamped into the grid.

fun centreX(c: Int): Double = (c + 0.5) * cellSize

The world x of the centre of column c.

fun centreY(r: Int): Double = (r + 0.5) * cellSize

The world y of the centre of row r.

fun nearestFree(x: Double, y: Double, searchCells: Int = 24): Int

The nearest free cell to (x, y) within searchCells rings, as a packed row * cols + col, or -1.

Needed at both ends of a search. A start point can legitimately be inside a blocked cell — an agent standing against a wall is within inflate of it by definition — and a goal can be a doorway the caller nominated by eye. Refusing to route in either case would make routing useless exactly where it is wanted. Rings are scanned outward and, within a ring, in a fixed order, so "the nearest free cell" is one cell and not a coin flip.

    fun nearestFree(x: Double, y: Double, searchCells: Int = 24): Int {
        val c0 = colOf(x)
        val r0 = rowOf(y)
        if (!isBlocked(c0, r0)) return r0 * cols + c0
        var ring = 1
        while (ring <= searchCells) {
            var best = -1
            var bestD = Double.MAX_VALUE
            var dr = -ring
            while (dr <= ring) {
                var dc = -ring
                while (dc <= ring) {
                    if (abs(dr) == ring || abs(dc) == ring) {
                        val c = c0 + dc
                        val r = r0 + dr
                        if (!isBlocked(c, r)) {
                            val d = hypot(centreX(c) - x, centreY(r) - y)
                            if (d < bestD) { bestD = d; best = r * cols + c }
                        }
                    }
                    dc++
                }
                dr++
            }
            if (best >= 0) return best
            ring++
        }
        return -1
    }

fun route(sx: Double, sy: Double, gx: Double, gy: Double, maxExpansions: Int = 20_000): IntArray?

A* from (sx, sy) to (gx, gy), returning the cell indices of the path including both ends, or null if there is no route inside maxExpansions.

Eight-connected with octile costs and an octile heuristic, which is admissible on this graph, so the path is a shortest one on the grid. Diagonal moves are refused when either of the two cells they cut between is blocked: without that the path clips wall corners, and it clips them by up to the cell diagonal, which is more than the string-pull can recover because the corner really is in the way.

The open set is ordered by (f, cellIndex) — a total order over distinct cells, so ties do not depend on how the heap rebalanced.

body — 69 lines, from line 745
    fun route(sx: Double, sy: Double, gx: Double, gy: Double, maxExpansions: Int = 20_000): IntArray? {
        val start = nearestFree(sx, sy)
        val goal = nearestFree(gx, gy)
        if (start < 0 || goal < 0) return null
        if (start == goal) return intArrayOf(start)

        val n = blocked.size
        val g = DoubleArray(n) { Double.MAX_VALUE }
        val from = IntArray(n) { -1 }
        val closed = BooleanArray(n)
        val gc = goal % cols
        val gr = goal / cols

        fun h(index: Int): Double {
            val dc = abs(index % cols - gc).toDouble()
            val dr = abs(index / cols - gr).toDouble()
            return (dc + dr) + (SQRT2 - 2.0) * min(dc, dr)
        }

        val open = java.util.PriorityQueue<DoubleArray>(
            compareBy<DoubleArray>({ it[0] }, { it[1] })
        )
        g[start] = 0.0
        open.add(doubleArrayOf(h(start), start.toDouble()))

        var expansions = 0
        while (open.isNotEmpty()) {
            val head = open.poll()
            val cur = head[1].toInt()
            if (closed[cur]) continue
            closed[cur] = true
            if (cur == goal) break
            if (++expansions > maxExpansions) return null
            val cc = cur % cols
            val cr = cur / cols
            var k = 0
            while (k < 8) {
                val dc = NEIGHBOUR_DC[k]
                val dr = NEIGHBOUR_DR[k]
                k++
                val nc = cc + dc
                val nr = cr + dr
                if (isBlocked(nc, nr)) continue
                if (dc != 0 && dr != 0) {
                    // No corner cutting: both orthogonal companions must be free.
                    if (isBlocked(cc + dc, cr) || isBlocked(cc, cr + dr)) continue
                }
                val ni = nr * cols + nc
                if (closed[ni]) continue
                val stepCost = if (dc != 0 && dr != 0) SQRT2 else 1.0
                val tentative = g[cur] + stepCost
                if (tentative < g[ni]) {
                    g[ni] = tentative
                    from[ni] = cur
                    open.add(doubleArrayOf(tentative + h(ni), ni.toDouble()))
                }
            }
        }

        if (!closed[goal]) return null
        var count = 1
        var walk = goal
        while (walk != start) { walk = from[walk]; count++ }
        val out = IntArray(count)
        walk = goal
        var i = count - 1
        while (i >= 0) { out[i] = walk; walk = from[walk]; i-- }
        return out
    }

fun stringPull

Turn a cell path into a short list of world waypoints, dropping every one a circle of inflate could have skipped.

This is where the grid's staircase goes. The test is ObstacleField.clearPath against the real wall geometry, not a grid ray, so a smoothed leg is passable by a criterion the grid itself is only an approximation of. Greedy from the front, which is O(waypoints²) in the worst case and a few dozen tests in practice; the alternative (funnel) is only worth its complexity on a navigation mesh, where the channel is a sequence of portals rather than a sequence of squares.

startX/startY and goalX/goalY replace the first and last cell centres, so a route ends at the place that was asked for rather than at the middle of the cell containing it. The start may legitimately be closer to a wall than inflate — an agent replanning from against a wall is the ordinary case — and when it is, every shortcut from it fails and the first leg is the short hop to the first free cell centre. That is the right answer and it is why ObstacleField.clearPath has to be honest about a start that is already too close.

    fun stringPull(
        path: IntArray,
        field: ObstacleField,
        startX: Double, startY: Double,
        goalX: Double, goalY: Double
    ): DoubleArray {
        val n = path.size
        val xs = DoubleArray(n)
        val ys = DoubleArray(n)
        for (i in 0 until n) {
            xs[i] = centreX(path[i] % cols)
            ys[i] = centreY(path[i] / cols)
        }
        xs[0] = startX; ys[0] = startY
        xs[n - 1] = goalX; ys[n - 1] = goalY

        val out = ArrayList<Double>()
        out.add(xs[0]); out.add(ys[0])
        var i = 0
        while (i < n - 1) {
            var j = n - 1
            while (j > i + 1) {
                if (field.clearPath(xs[i], ys[i], xs[j], ys[j], inflate)) break
                j--
            }
            out.add(xs[j]); out.add(ys[j])
            i = j
        }
        val r = DoubleArray(out.size)
        for (k in out.indices) r[k] = out[k]
        return r
    }

fun blockedFLines(z: Number = 0): List<FLine>

The blocked cells as one FLine per cell outline — for looking at what the router saw.

    fun blockedFLines(z: Number = 0): List<FLine> {
        val out = ArrayList<FLine>()
        for (r in 0 until rows) for (c in 0 until cols) {
            if (!blocked[r * cols + c]) continue
            val x = c * cellSize
            val y = r * cellSize
            out.add(
                FLine().also {
                    it.moveTo(x, y, z); it.lineTo(x + cellSize, y, z)
                    it.lineTo(x + cellSize, y + cellSize, z); it.lineTo(x, y + cellSize, z)
                    it.lineTo(x, y, z)
                }
            )
        }
        return out
    }

override fun toString(): String

    override fun toString(): String =
        "NavGrid(${cols}x$rows @ %.2f, inflate %.2f, %d blocked%s)"
            .format(cellSize, inflate, blockedCells, if (coarse) ", COARSE" else "")

companion object

    companion object {

fun build

Build a grid over a width x height arena, blocking everything within inflate of a wall or of the boundary.

cellSize is inflate * 1.2, which is below inflate * sqrt(2) and therefore below the point at which a segment could pass through a cell without coming within inflate of its centre. That is the property that makes "free cells are standable" true rather than probable. The size is enlarged — and coarse set — only when the cap maxCells would otherwise be exceeded.

body — 47 lines, from line 899
        fun build(
            width: Double,
            height: Double,
            field: ObstacleField,
            inflate: Double,
            maxCells: Int = 40_000
        ): NavGrid {
            require(width > 0.0 && height > 0.0) { "nav grid needs a positive arena" }
            require(inflate > 0.0 && inflate.isFinite()) { "nav inflate must be > 0, got $inflate" }
            require(maxCells >= 64) { "nav maxCells must be >= 64, got $maxCells" }
            var cs = inflate * 1.2
            var cols = max(1, ceil(width / cs).toInt())
            var rows = max(1, ceil(height / cs).toInt())
            var coarse = false
            while (cols.toLong() * rows.toLong() > maxCells) {
                cs *= 1.25
                cols = max(1, ceil(width / cs).toInt())
                rows = max(1, ceil(height / cs).toInt())
                coarse = true
            }
            val blocked = BooleanArray(cols * rows)
            val segs = field.segments()
            val scratch = DoubleArray(2)
            for (r in 0 until rows) {
                val cy = (r + 0.5) * cs
                for (c in 0 until cols) {
                    val cx = (c + 0.5) * cs
                    if (cx < inflate || cy < inflate || cx > width - inflate || cy > height - inflate) {
                        blocked[r * cols + c] = true
                        continue
                    }
                    var hit = false
                    var i = 0
                    while (i < segs.size) {
                        closestPointOnSegment(cx, cy, segs[i], segs[i + 1], segs[i + 2], segs[i + 3], scratch)
                        if (hypot(cx - scratch[0], cy - scratch[1]) <= inflate) { hit = true; break }
                        i += 4
                    }
                    blocked[r * cols + c] = hit
                }
            }
            return NavGrid(width, height, cols, rows, cs, inflate, coarse, blocked)
        }
    }
}

private val SQRT2 = sqrt(2.0)

Fixed neighbour order: E, NE, N, NW, W, SW, S, SE. Fixed, so expansion order is fixed.

private val NEIGHBOUR_DC = intArrayOf(1, 1, 0, -1, -1, -1, 0, 1)
private val NEIGHBOUR_DR = intArrayOf(0, 1, 1, 1, 0, -1, -1, -1)

Scalar environment fields

fun interface ScalarField

A scalar quantity spread over the arena — light, a chemical, temperature — that an agent can sense and climb with BehaviourSteering.gradient.

A function rather than a raster, because most of the interesting ones are analytic (a point source falls off as 1/(1+d²)) and a raster of a function is a resolution to argue about. Wrap a raster in one if you have a raster. Must be finite everywhere an agent can stand; a field that returns a NaN takes its drive out for that agent and logs, like any other bad drive.

fun interface ScalarField {

fun at(x: Double, y: Double): Double

The value at (x, y).

fun pointSourceField(vararg sources: Double, falloff: Double = 20.0): ScalarField

A field that is the sum of inverse-square-ish point sources — the standard "some lamps in a room".

strength / (1 + (d / falloff)²), which is bounded at the source instead of infinite, so an agent that reaches a lamp gets a finite reading and a finite gradient rather than a division by zero one step before it arrives.

@param sources x, y, strength triples

fun pointSourceField(vararg sources: Double, falloff: Double = 20.0): ScalarField {
    require(sources.size % 3 == 0 && sources.isNotEmpty()) {
        "pointSourceField takes x, y, strength triples, got ${sources.size} doubles"
    }
    require(falloff > 0.0 && falloff.isFinite()) { "falloff must be > 0, got $falloff" }
    val s = sources.copyOf()
    return ScalarField { x, y ->
        var total = 0.0
        var i = 0
        while (i < s.size) {
            val dx = x - s[i]
            val dy = y - s[i + 1]
            val q = (dx * dx + dy * dy) / (falloff * falloff)
            total += s[i + 2] / (1.0 + q)
            i += 3
        }
        total
    }
}

internal fun discPoints(cx: Double, cy: Double, radius: Double, segments: Int): DoubleArray

A disc of segments points, centred on (cx, cy) — the polygon a "round pillar" wants.

internal fun discPoints(cx: Double, cy: Double, radius: Double, segments: Int): DoubleArray {
    require(segments >= 3) { "a disc needs at least 3 segments, got $segments" }
    require(radius > 0.0 && radius.isFinite()) { "disc radius must be > 0, got $radius" }
    val p = DoubleArray(segments * 2)
    for (i in 0 until segments) {
        val a = 2.0 * Math.PI * i / segments
        p[i * 2] = cx + cos(a) * radius
        p[i * 2 + 1] = cy + sin(a) * radius
    }
    return p
}