Skip to content

Volume

Navigable volumes — a structure a ship can fly inside, modelled as the space rather than the walls.

Overview

The engine has exactly one collision primitive: exclusion_radius, a keep-out sphere ("other objects cannot be closer to me than this"). A hollow structure is the complement of a sphere, so it cannot be built out of solid objects — approximating a shell with many spheres is not just expensive, the boundary never lines up with the art. Inscribe the spheres and corners are passable; circumscribe them and the ship stops in empty space.

So a volume describes the navigable space instead:

primitive is authored as
chamber a sphere centre + radius
passage a capsule two endpoints + radius
box an axis-aligned rectangle centre + half-extents
solid any of the above, subtracted the pillar in the middle of the room

A ship is inside if it is inside any navigable primitive and outside every solid. A dozen branching chambers is ~30 primitives rather than ~10,000 voxels, the boundary is smooth, and no engine collision is involved at all — every prop that dresses the structure ships exclusion_radius = 0.

Because containment is our own signed-distance maths, the engine's sphere-only limit does not apply. Any shape with a distance function is available.

A fly-through prop must be TERRAIN

An AI behavior (behav_npcship, behav_typhon, …) carrying exclusion_radius = 0 NaNs the engine and asserts — measured on 1.3.5 as both Simulation.cpp:739 !isnan(so->pos.x) and SpaceObjectAITyphon.cpp:111 !isnan(obj->rotQuat.x). Terrain is passive, so it has no steering or rotation solve to go wrong, and a zero radius is safe there.

The same graph doubles as a navmesh: brains steer by writing target_pos_* and know nothing about walls, so volume_path is what keeps them out of the rock.

Quick example

volume_define("relic", chambers, passages)
volume_solid("relic", "sphere", 0, 0, 0, 320)
volume_watch("relic", margin=60, block_jump=True)
volume_load("relic", layout, origin=(12000, 0, -8000))
volume_watch("relic", margin=60)

Containment returns a signed depth — negative inside — so the response is graded rather than a wall-slam:

depth tier what happens
< 0 inside free flight
0 .. scrape_band scrape signalled; the ship can still fly out
>= scrape_band breach throttle governed, then held inside

The hold defaults to an engine-side tractor, not a set_pos clamp. A clamp is correct on the server and looks wrong from the helm seat — the client predicts its own position, so the ship visibly leaves and snaps back.

Damage is not applied here. The library emits volume_scrape, volume_breach and volume_recovered on tier change; what they mean is the mission's call.

The numbers behind the defaults

Engine 1.3.5: the MAST task cadence is ~15 Hz, not the 5 Hz usually quoted, and a playership travels 60 units per tick at full warp. That is the whole tunneling budget, and it is why scrape_band defaults to 120 — two ticks of travel, so a warping ship cannot skip the scrape tier entirely.

For authoring a relic as data rather than code, see Relic interiors.

API

Navigable volumes - model the VOID, not the walls.

For a structure a ship can fly INTO (a relic interior, a canyon, a docking throat), the instinct is to build the walls out of solid objects. The engine cannot do that well: exclusion_radius is a single KEEP-OUT sphere per object ("other objects cannot be closer to me than this"), and a hollow shell is the complement of a sphere. Approximating one with many spheres is not merely expensive - the boundary never lines up with the art. Inscribe the spheres and corners are passable; circumscribe them and the ship stops in empty space.

So this module describes the navigable space instead, as signed distance fields:

chamber  = a sphere   (center, radius)
passage  = a capsule  (endpoint A, endpoint B, radius)
box      = an axis-aligned rectangle (center, half-extents)
solid    = any of the above, SUBTRACTED - the pillar in the middle of the room

A ship is inside the volume if it is inside any navigable primitive and outside every solid. A dozen branching chambers is ~30 primitives rather than ~10,000 voxels, the boundary is smooth, and no engine collision is involved at all - every prop that dresses the structure ships exclusion_radius = 0.

The engine's sphere-only limit does not apply here. exclusion_radius is the engine's single collider, but nothing in this module uses engine collision, so the shape vocabulary is only limited by what has a signed distance function. Spheres and capsules came first because they match rooms and corridors; boxes read as BUILT rather than eroded; subtraction is the one thing a union can never fake.

Two consequences worth knowing:

  • Props must be TERRAIN, never an AI behavior. A behav_* AI object carrying exclusion_radius = 0 NaNs the engine and asserts (measured 1.3.5: both Simulation.cpp:739 !isnan(so->pos.x) and SpaceObjectAITyphon.cpp:111 !isnan(obj->rotQuat.x)). Terrain is passive - no steering, no rotation solve, nothing to NaN - so a zero radius is safe there.
  • The same graph is a navmesh. Brains steer by writing target_pos_* and know nothing about walls, so inside a voxel structure they fly straight through it. Chamber-to-chamber routing here is a plain graph search (volume_path).

Containment is enforced by script, on a signed depth rather than a boolean, so a caller can scale its response: scrape damage near the wall, a playerThrottle governor further out, and a hard projection back inside as the backstop. Engine 1.3.5 measurements behind that design: a playership travels 60 units per MAST tick at full warp (the tick is ~15 Hz, not the 5 Hz usually quoted), and script position writes do stick on a playership - clamping to a 3000u sphere held a ship at 3058.6, one tick of travel and no more.

Geometry here is plain Python. sbs.distance_point_line exists and is documented "for checking potential collisions", but its tuple is an UNCLAMPED line measure whose contract has not been verified against a capsule, and a wrong clamp is worse than ten float ops. The cost does not matter: 8 players x 30 primitives at 15 Hz is ~3600 distance tests a second, which is noise.

Volume

A navigable space built from primitives, minus any solids carved out of it.

Navigable: chambers (spheres), passages (capsules), boxes (axis-aligned). solids are SUBTRACTED - the pillar in the middle of the room.

add_box(name, x, y, z, hx, hy, hz)

An axis-aligned rectangular space. Half-extents, so hx is HALF the width.

A box reads as BUILT rather than eroded - a vault with flat walls and real corners, which spheres and capsules cannot express at all. Not rotatable: arbitrary orientation needs a quaternion in the SDF, and every relic so far wanted axis-aligned rooms.

add_passage(a, b, radius)

Join two chambers by name, or two explicit points, with a capsule.

add_solid(prim)

SUBTRACT a shape from the navigable space - a pillar, a spire, a solid hub.

Union alone can only ever ADD space, so without this a chamber with a column in the middle has to be faked by routing capsules around where the column goes. Takes any primitive tuple; see volume_solid for the friendly form.

bound()

Bounding sphere of the whole volume.

Used to size a relic's nebula. NOT wired into nearest as an early-out - at tens of primitives the walk is already noise, and a stale claim that it was would be worse than none. If a volume ever reaches thousands, this is the hook.

depth(pos)

Signed distance to the boundary. NEGATIVE inside, positive outside.

An empty volume is all wall, reporting +inf rather than pretending everything is contained.

inside(pos, margin=0.0)

Whether pos is inside by at least margin - the same question as depth(pos) <= -margin, answered without measuring how far.

depth has to scan every primitive to find the nearest wall. A visibility walk only ever asks "is this sample in the clear", and a sample is usually deep inside one room - so the first primitive that swallows it settles the question. That early-out is the difference between a rail web being solvable and not.

named_primitives()

Every navigable primitive as (name, prim).

primitives() drops the names, which is right for sampling and wrong for dressing: a relic wants THIS hall plated and THAT cave left as rock, and the only handle an author has on a room is the name they gave it. A passage is named for the two rooms it joins, since it never had a name of its own.

nearest(pos)

(depth, anchor, rope) - signed depth plus a tractor hold target.

Depth is the SDF of the whole volume: the union of the navigable primitives, with every solid subtracted. In SDF algebra, subtracting S is max(d_union, -d_S) - so a point inside a pillar reports positive (outside the navigable space), and a point near one correctly measures its distance to the pillar as its distance to the nearest wall.

A point inside a solid is anchored AGAINST THE SOLID, not the room it sits in: the way out of a pillar is away from the pillar.

nearest_inside(pos, margin=0.0)

The closest point that is inside by at least margin.

Returns the position unchanged when it already satisfies that, so this is safe to call every tick. A HARD geometric projection, deliberately - not a proportional pull. orbit.py measured a proportional-only controller spiralling against real engine 1.3.5 rather than settling.

path(start, goal)

Chamber names from start to goal inclusive, or [] if unreachable.

Breadth-first: passages have no meaningful cost yet, and with a dozen chambers a weighted search would be ceremony.

primitives()

Every NAVIGABLE primitive, uniformly tagged.

CACHED, and the cache is what makes the geometry affordable. This is called from nearest, so it used to rebuild a fresh list on every containment test, every depth sample and every step of every visibility walk - building a relic's rail web is millions of those. Invalidated by each add_*, beside _bound and _graph.

The list is shared, not copied: every caller in the library reads it. Do not mutate what comes back.

volume_align_quat(direction, roll=0.0)

A quaternion (w, x, y, z) rotating local +Z onto direction.

This is what turns a sampled normal into an oriented prop. The flat generic meshes - rectangle, disk, hexagon - are thin in local +Z, so aligning +Z to a wall's INWARD normal turns the face towards the crew and the slab lies on the wall.

roll spins the prop about that axis, in radians. A shell of panels all rolled identically reads as printed wallpaper; a random roll per prop reads as plating.

Geometry, not decoration: it converts a direction into a rotation and knows nothing about art, which is why it belongs beside the sampler rather than in the dresser.

volume_anchor(volume, pos)

(depth, anchor, radius) of the nearest primitive - the local containment sphere. Exposed because a tractor hold needs the anchor, not just the projection.

volume_anchor_count()

Live tractor anchor objects. The reset-ledger probe.

volume_box(volume, name, x, y, z, hx, hy, hz)

Add an axis-aligned rectangular space. Half-extents, not widths.

volume_chamber(volume, name, x, y, z, radius)

Add one chamber to a volume (by object or by name).

volume_chamber_at(volume, pos)

The name of the chamber containing pos, else the nearest chamber's, else None.

The other half of volume_path, which needs two chamber names and is handed two positions. NEAREST rather than strictly-containing on purpose: a relic's navigable space is chambers, passages AND boxes, so a ship can legitimately be inside the volume while inside no chamber at all - standing in a passage, or in a box that no chamber covers. Answering None there would refuse to route from a perfectly ordinary place.

volume_chamber_pos(volume, name)

The centre of a named chamber, or None.

The navmesh answers in NAMES - volume_path returns a route of them - and anything steering along that route needs somewhere to aim. Reading vol.chambers for it would put the storage shape in every caller.

volume_clear()

Drop every volume and stop every watcher. Called by reset_mission_state().

volume_containment_tick(t=None)

One containment pass over every watched volume. Also directly callable.

volume_contains(volume, pos)

True if the position is inside any chamber or passage.

volume_count()

Number of defined volumes. The reset-ledger probe.

volume_define(name, chambers=None, passages=None, boxes=None, solids=None, origin=None)

Create (or replace) a named volume.

Declarative form - chambers maps a name to (x, y, z, radius), passages is a sequence of (a, b, radius) where a and b are chamber names or explicit points:

volume_define("relic",
              chambers={"hub": (0, 0, 0, 1200)},
              passages=[("hub", "spine", 300)])

origin PLACES the whole layout: every coordinate is treated as relative to it. That is what lets one authored layout be dropped at two different points in a system - without it a layout is welded to the absolute coordinates it was written at, and a second copy means editing every number. Radii and half-extents are sizes, not positions, so they are never shifted; a passage naming a chamber needs no shift either, since the chamber it names has already moved.

volume_depth(volume, pos)

Signed distance to the wall: NEGATIVE inside, positive outside.

The number the graded response is built on - scrape near zero, govern the throttle further out, clamp as the backstop.

volume_doorways(volume)

Every point where two navigable primitives meet - the openings between rooms.

Waypoint material for :func:volume_route, not a route in itself.

volume_engaged(volume)

The ids containment is currently applying to - the ships that are IN this relic.

Also the answer to a question missions ask for their own reasons: is the crew inside the ruin yet? A quest that starts when they arrive, a door that closes behind them, an ambush that waits until they are committed - all of them want this set, and computing it from depth per tick would duplicate the latch the watcher already keeps.

Empty for an unwatched volume, and for engage="always", where the question does not apply because containment is not gated on having been inside.

volume_get(name)

The named volume, or None.

volume_inside(volume, pos, margin=0.0)

Whether pos is inside by at least margin - depth(pos) <= -margin, without measuring how far.

The cheap half of volume_depth, and the right question for anything that only needs a yes or no: a visibility sample, a camera looking for somewhere it fits. The first primitive that swallows the point settles it, where depth has to scan them all.

volume_inside_points(volume, n, seed=None, margin=0.0, tries=40)

n points INSIDE the volume - the fill, for debris, cargo, anything floating.

Rejection sampled in the bounding sphere against depth(p) < -margin, which is wasteful in principle and exact in practice: it is the only test that respects subtracted solids, so nothing lands inside a pillar. A relic is mostly empty bounding sphere, so tries caps the work rather than looping forever on a layout with no room in it; a short return means the volume could not hold that many.

Deterministic in (seed, n), like the shell.

volume_load(name, data, origin=None)

Define a volume from one parsed block - a MAST metadata: yaml section, or a mission yaml file.

Takes the shape yaml actually parses to (lists, not tuples), so a relic can be authored declaratively rather than as a wall of calls::

metadata: ``` yaml
chambers:
    hub:   [0, 0, 0, 1200]
    spine: [4000, 0, 0, 900]
passages:
    - [hub, spine, 300]
```

then volume_load("relic", {"chambers": chambers, "passages": passages}) - or pass the whole parsed mapping straight in.

Two more keys, both optional::

boxes:                             # axis-aligned, x y z then HALF-extents
    vault: [4000, 0, 0, 900, 400, 900]
solids:                            # SUBTRACTED - pillars, spires, solid hubs
    - [sphere, 4000, 0, 0, 250]
    - [box, 0, 0, 0, 100, 800, 100]

volume_names()

Every defined volume name.

volume_nearest_inside(volume, pos, margin=0.0)

Closest point inside by at least margin; the position itself if already so.

volume_passage(volume, a, b, radius)

Join two chambers (or two points) with a capsule.

volume_path(volume, start, goal)

Chamber names from start to goal inclusive, or [] if unreachable.

volume_remove(name)

Drop ONE volume: stop its watcher, let go of anything it held, forget the geometry.

volume_clear() drops every volume, which is right for a mission reset and wrong for a galaxy. An Open Universe cell is torn down while the next one is already being built, so clearing everything there would delete the relic the crew is standing in. Returns True if there was something to remove.

volume_route(volume, start, goal, waypoints=None, margin=0.0, strict=False)

Waypoints from start to goal that stay inside the volume.

Parameters:

Name Type Description Default
waypoints

the places worth routing through - a relic's authored Point: list. Defaults to the centre of every primitive, which is a poor substitute and only there so a caller with no places still gets something.

None
margin

how far inside the wall a leg has to stay.

0.0
strict bool

answer [] when there is no route, instead of the straight line. Use this whenever flying the answer can hurt.

False

Returns [goal] when the way is already clear, and [] when the volume is unknown.

THE STRAIGHT-LINE FALLBACK IS A TRAP FOR A FLYER, which is why strict exists. There are four ways to fail here - no waypoints, nothing in sight of the start, nothing in sight of the goal, no chain between them - and every one of them used to answer with the same [goal] a genuinely clear run gives. So a caller could not tell "one clean leg" from "I could not find a way", and a relic has no engine collision at all: the suit flew the straight line THROUGH THE ROCK, which is the one thing this whole mode exists to prevent. Owner-reported from a bridge 2026-09-17: "it seems like it flies directly toward it which can take it through walls."

The default is unchanged, because a caller that only wants a heading is better served by a guess than by nothing - but anything that will actually FLY the result should ask for strict and refuse the trip.

volume_sight_count(volume, waypoints, margin=0.0)

How many legs the visibility graph found. Zero on a relic whose places cannot see one another means every route through it will be a straight line.

volume_skirts(volume, per_solid=14, clearance=None)

Points that let a route get AROUND each subtracted mass.

Doorways connect one room to the next; nothing connects one side of a pillar to the other. voice.amd's transmitter bay holds a subtracted cradle, and with doorways alone the bay's own places could see nothing at all - the route into the deepest room of the relic simply did not exist, and the router fell back to a straight line through the ruin.

Each solid gets a ring of candidates just clear of its surface, kept only where they are really navigable. They are waypoint material for :func:volume_route; a solid a route never needs to pass contributes nodes nobody visits, which costs one visibility test each.

volume_solid(volume, kind, *args)

SUBTRACT a shape from the navigable space.

Three forms - a pillar, a bar, a block::

volume_solid(v, "sphere",  x, y, z, radius)
volume_solid(v, "capsule", (ax, ay, az), (bx, by, bz), radius)
volume_solid(v, "box",     x, y, z, hx, hy, hz)

Union alone can only ADD space, so this is what buys a column in the middle of a chamber, a spire, or a torus with a genuinely solid hub.

volume_solid_points(volume, n, seed=None, inward=0.94, with_spacing=False, only=None)

n points on the surface of the SUBTRACTED masses, facing outward.

A solid that is not dressed is an invisible obstacle: containment stops you at something with nothing there to see, which reads as the relic being broken rather than as a pillar. inward keeps the props just inside the solid's own surface, because you look at a pillar from outside it - the opposite of the shell, where they sit just outside the space you fly in.

only narrows to particular solid primitives. A caller that can build some masses from a single primitive - a box mass is just the cube - uses it to ask for a shell over the rest rather than over everything.

volume_surface_points(volume, n, seed=None, out=1.06, kinds=None, clip=True, with_spacing=False, names=None)

n points spread over the volume's BOUNDARY, with an outward normal each.

Returns [(x, y, z, nx, ny, nz)] in world coordinates. This is the shell: what a mission scatters props over to turn a described space into a visible one.

The budget is split between primitives BY AREA, so a chamber twice the size gets about twice the props and density reads as uniform across the whole relic rather than per part. out pushes points just clear of the true surface - props belong outside the space you fly in, or they are obstacles you cannot see coming.

Deterministic in (seed, n): the same call gives the same shell, so a relic looks the same every time a mission runs, and a rebuild after an edit only changes what the edit changed.

kinds narrows to some of "sphere", "capsule", "box" - the chambers alone, say. names narrows to particular PARTS, which is how one room is dressed differently from the next. Clipping still sees the whole volume, so a wall sampled for one room is still dropped where the room next door has opened it up.

with_spacing=True appends how far apart the points are on that primitive, giving (x, y, z, nx, ny, nz, spacing). That is what a prop sizes itself to: sized to the ROOM instead, a shell puts 400-unit boulders 385 units apart and the wall becomes a gravel field you fly through rather than past.

clip drops points that are BURIED - inside the union rather than on the outside of it. Each primitive is sampled on its own surface, so where two overlap, one shape's wall runs through the other's open space: the ring where a passage meets a chamber puts rock in the middle of the corridor. The shell wanted is the outside of the union, not the union of the surfaces. Costs one depth() per point and returns fewer than n, which is the honest trade - ask for more if a count matters.

volume_tier(volume, pos, scrape_band=120.0, radius=0.0)

Which response tier a position falls in. Pure - the testable seam.

scrape_band defaults to 120u: two ticks of warp travel at the measured 60 u/tick, so a glancing clip scrapes while a determined exit breaches.

radius is the SHIP, not a point. A hull is a sphere of its exclusion_radius, and testing only its centre lets half the ship stand in the wall before anything reacts - 50 units of light cruiser against a 60-unit scrape band, so the hull is 110 units through the plating at the moment the centre first counts as breaching. That is the difference between scraping a wall and visibly passing through it.

volume_unwatch(name)

Stop enforcing containment for a volume.

Anything this watcher was HOLDING is let go first. A tractor hold is an anchor object plus a live engine connection, and neither belongs to the watcher's task - so dropping the task alone leaves a ship roped to an invisible post that nothing will ever release, which reads in play as a ship that cannot fly after the ruin around it is gone.

volume_visible(volume, a, b, margin=0.0, step=_VOL_SIGHT_STEP)

Whether the straight line from a to b stays inside the volume.

Samples along the segment, so a passage narrower than step can be missed - the default is well under the tightest thing the shipped relics are built from.

volume_watch(volume, agents=None, scrape_band=120.0, margin=0.0, govern=True, clamp=True, seconds=0, hold=HOLD_TRACTOR, speed_limit=None, block_jump=False, engage=ENGAGE_ENTERED)

Start enforcing containment for a volume. Replaces any existing watch.

ENGAGEMENT - who this applies to, which is not the same question as who is watched.

A tier is a pure depth test, so a ship that has never been near the relic reads BREACH exactly like one that just punched through a wall: measured, a ship 80,000 units away came back BREACH and, under the default agent set of every player, was tractored toward the relic. That is not containment, it is a fishing net.

So a ship is contained ONCE IT HAS BEEN INSIDE, and released when it leaves the bounding sphere. Fly in through a mouth - a chamber or passage that reaches out past the hull - and you are inside the volume before you are deep in it, so the latch catches without a breach ever happening. Fly out and away and it lets go. A ship that never entered is never touched, which is what makes an entrance possible at all and what stops a relic in one corner of a system grabbing everything in it.

engage="always" restores the old behaviour for a volume that IS the playfield.

Parameters:

Name Type Description Default
volume

Volume or its name.

required
agents

None for players+fighters (the default set), a CALLABLE returning a set - re-evaluated every tick, so arrivals and departures need no wiring - or a static set.

None
scrape_band float

how far past the wall a scrape becomes a breach.

120.0
margin float

how far inside the wall the clamp puts a breached ship.

0.0
govern bool

cap playerThrottle to impulse while breached.

True
clamp bool

project a breached ship back inside.

True
seconds int

tick interval; 0 = every tick, which is what containment wants - it is a handful of float ops per agent.

0

Signals fire on tier CHANGE only, never per tick, each carrying {"volume": name, "id": agent_id, "depth": float}: volume_scrape - entered the wall volume_breach - went past the scrape band volume_recovered - back inside

Route the consequences: //shared/signal/volume_scrape for damage or scoring (server-once), //signal/volume_scrape only for per-console display.

volume_watch_count()

Number of live watchers. The reset-ledger probe.

volume_watching(name)

True if a volume is currently enforced.