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.

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.

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_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_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_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_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_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.