Novolis Inertial Frame Stack — adapted specification
Policies that keep the org coherent
Maps the Inertial Frame Stack (IFS) to current Novolis repos, NuGet packages, and types. Use this when introducing novolis-spatial and the first IFS implementation.
Status: specification (no novolis-spatial repo yet).
Related: simulation-layer-policy.md, naming.md.
Purpose
Inertial Frame Stack is the spatial locality layer.
It defines where a simulation frame is evaluated from, and provides coordinate projection to express relevant world-space state in a local, origin-stable frame.
It does not decide what participates, simulate physics, render, or replicate network state.
IFS = where
PreCulling = what (future: Novolis.Spatial.PreCulling)
FrameScene = prepared work (future: Novolis.Spatial.FrameScene)
Adapters = execution (render / physics / net hosts)Placement in the Novolis stack
flowchart TB
subgraph math [novolis-math]
Geo[Novolis.Math.Geometry]
Arr[Novolis.Math.Arrays]
end
subgraph physics [novolis-physics]
Num[Novolis.Physics.Numerics]
Abs[Novolis.Physics.Abstractions]
end
subgraph sim [novolis-simulation]
SimAbs[Novolis.Simulation.Abstractions NEW]
View[Novolis.Simulation.View]
World[Novolis.Simulation.World]
end
subgraph spatial [novolis-spatial NEW]
SpAbs[Novolis.Spatial]
IFS[Novolis.InertialFrames]
Pre[PreCulling future]
end
Num --> SpAbs
SimAbs --> IFS
SpAbs --> IFS
IFS --> Pre
View -.->|ViewPose to anchor only| IFS
Geo -.->|float matrices at adapter edge| Adapters| Layer | Owns IFS concern? |
|---|---|
| **Math** | Float meshes, `Camera`, `Transform`, view/projection matrices — **not** world snapshots or frames |
| **Physics.Numerics** | `Vector3d`, `Quaterniond`, `AxisAlignedBox3d` — frame-space numerics |
| **Physics (domains)** | Integration, forces, collision — **must not** build inertial frames |
| **Simulation** | Worlds, kinematics, view controllers — **may consume** `FrameContext`; **does not** own projection |
| **Spatial (new)** | World-space types, IFS, pre-cull pipeline |
| **Raylib / apps** | Draw and product rules — consume prepared local work via adapters |
Novolis.Simulation.View.ViewPose is an observer convenience (float Vector3 + yaw/pitch). It is not FrameContext. Hosts may convert ViewPose → FrameAnchor.Pose at a double-precision boundary when building a frame.
Repositories and packages
Target repo
novolis-spatialPer naming.md: domain repo novolis-spatial, packages Novolis.Spatial.*.
Packages (v1)
| Package | Responsibility |
|---|---|
| `Novolis.Spatial` | World-space primitives: `WorldPosition`, `WorldVector`, `WorldPose`, `WorldRegion`, spatial query surface |
| `Novolis.Simulation.Abstractions` | Tick + entity identity + read-only world snapshot contract (thin; lives in `novolis-simulation`) |
| `Novolis.InertialFrames` | IFS: `InertialFrame`, `FrameContext`, projection, bands, builders |
Future (same repo, not v1):
| Package | Responsibility |
|---|---|
| `Novolis.Spatial.PreCulling` | `WorldSnapshot` + `FrameContext` → `PreCullResult` |
| `Novolis.Spatial.FrameScene` | Prepared local packets for adapters |
Must not depend on
Novolis.Raylib.*
Novolis.Physics.Collision.*
Novolis.Physics.Motion / Gravity / Ballistics / …
Novolis.Messaging.*
Rendering hosts, Stride, Vulkan
Game-specific assemblies (SCR product code, DoomLite3D)Novolis.InertialFrames may reference:
Novolis.Physics.Numerics (double 3D algebra only)
Novolis.Spatial
Novolis.Simulation.AbstractionsDo not reference the aggregate Novolis.Physics metapackage or simulation world/kinematics packages (avoids cycles and hidden physics coupling).
Type mapping (spec → Novolis)
| Original spec | Novolis today / v1 choice |
|---|---|
| `Novolis.Mathematics` | **`Novolis.Physics.Numerics`** — `Vector3d`, `Quaterniond` already shipped |
| `Bounds3d` | **`AxisAlignedBox3d`** (`Novolis.Physics.Numerics`) for axis-aligned interest regions |
| `Matrix4x4d` | **Not in platform v1.** `IFrameProjector` uses translation + inverse rotation (same pattern as `RigidBodyState` / `Quaterniond.Rotate` in physics). Float `System.Numerics.Matrix4x4` stays at **adapter** boundary via `Novolis.Math.Geometry.Camera` when rendering. |
| `Distance` | **`Vector3d.LengthSquared()`** / `Length()` — no separate distance type |
| `Novolis.Spatial.Abstractions` | **`Novolis.Spatial`** (single abstractions package; split later only if needed) |
| `Novolis.Simulation.Abstractions` | **New** packable project under `novolis-simulation` |
| `WorldSnapshot`, `EntityId`, … | **New** in `Novolis.Simulation.Abstractions` |
| `Novolis.InertialFrames` | **New** under `novolis-spatial` |
Precision split (aligned with existing code)
| Space | Types | Home |
|---|---|---|
| Authoritative world | `WorldPosition`, `WorldPose`, … | `Novolis.Spatial` (double-backed; large-world safe) |
| Frame-local cache | `FramePosition`, `FramePose`, … | `Novolis.InertialFrames` (uses `Vector3d` / `Quaterniond`) |
| GPU / debug float | `Vector3`, `Quaternion`, `Camera` | `Novolis.Math.Geometry` (adapters only) |
World-space is truth. Frame-space is cache.
Dependencies (concrete)
Novolis.InertialFrames
→ Novolis.Physics.Numerics
→ Novolis.Spatial
→ Novolis.Simulation.Abstractions
Novolis.Spatial
→ Novolis.Physics.Numerics
Novolis.Simulation.Abstractions
→ (none required for v1; optional reference to Novolis.Spatial for WorldPosition on EntityView)If EntityView carries positions, prefer Novolis.Simulation.Abstractions → Novolis.Spatial rather than duplicating coordinates in simulation.
Core responsibility
Given:
IWorldSnapshot (Novolis.Simulation.Abstractions)
FrameAnchor (entity / pose / position)
FrameBands + interest radiusProduce:
FrameContext (Novolis.InertialFrames)FrameContext supplies origin, orientation, optional velocity baseline, interest region, distance bands, IFrameProjector, and band classification helpers.
Core types
Namespaces shown as intended layout.
Identifiers and frame state (`Novolis.InertialFrames`)
namespace Novolis.InertialFrames;
public readonly record struct InertialFrameId(Guid Value);
public readonly record struct InertialFrame(
InertialFrameId Id,
WorldPose Origin,
WorldVector Velocity);World-space (`Novolis.Spatial`)
namespace Novolis.Spatial;
/// <summary>Authoritative world-space point. v1: wraps <see cref="Novolis.Physics.Numerics.Vector3d"/>; later may add sector/origin metadata.</summary>
public readonly record struct WorldPosition(Vector3d Value);
public readonly record struct WorldVector(Vector3d Value);
public readonly record struct WorldPose(WorldPosition Position, Quaterniond Rotation);
/// <summary>Interest/query region in world space. v1: axis-aligned box.</summary>
public readonly record struct WorldRegion(AxisAlignedBox3d Bounds)
{
public bool Contains(WorldPosition position) => Bounds.Contains(position.Value);
}ISpatialQuery (optional v1): minimal read surface used by builders — e.g. try get pose/position by EntityId. Implementations live in simulation or product hosts, not in IFS.
Frame-space (`Novolis.InertialFrames`)
Uses numerics directly (no duplicate vector types):
public readonly record struct FramePosition(Vector3d Value);
public readonly record struct FrameVector(Vector3d Value);
public readonly record struct FramePose(FramePosition Position, Quaterniond Rotation);Bands
public readonly record struct FrameBand(
string Name,
double EnterDistance,
double ExitDistance);
public sealed record FrameBands(
FrameBand Interactive,
FrameBand Tactical,
FrameBand Strategic,
FrameBand Background)
{
public static FrameBands Default { get; } = /* governed defaults */;
}Simulation contracts (`Novolis.Simulation.Abstractions`)
namespace Novolis.Simulation.Abstractions;
public readonly record struct SimulationTick(ulong Value);
public readonly record struct EntityId(Guid Value);
public readonly record struct EntityView(
EntityId Id,
WorldPosition Position,
WorldPose? Pose = null);
public interface IWorldSnapshot
{
SimulationTick Tick { get; }
bool TryGetEntity(EntityId id, out EntityView view);
}Sealed WorldSnapshot record implementations belong in simulation or app layers, not in IFS.
`FrameContext`
public sealed record FrameContext(
InertialFrame Frame,
WorldRegion InterestRegion,
FrameBands Bands,
SimulationTick Tick,
IFrameProjector Projector)
{
public double InterestRadiusSquared { get; init; }
public FrameBandKind Classify(WorldPosition position)
=> FrameBandClassifier.Default.Classify(this, position);
}Projection API (v1)
No Matrix4x4d. Projector holds origin pose and cached inverse orientation.
public interface IFrameProjector
{
FramePosition Project(WorldPosition position);
WorldPosition Unproject(FramePosition position);
FrameVector Project(WorldVector vector);
WorldVector Unproject(FrameVector vector);
FramePose Project(WorldPose pose);
WorldPose Unproject(WorldPose pose);
double DistanceSquaredTo(WorldPosition position);
}Implementation sketch: Project(position) => origin.Rotation⁻¹ * (position - origin.Position) using Quaterniond from Novolis.Physics.Numerics (same inverse-rotate approach as SemiImplicitEulerRigidBodyIntegrator).
v2 optional: ToSinglePrecisionTransform() → Novolis.Math.Geometry.Transform for render adapters only.
Frame building API
public interface IFrameContextBuilder
{
FrameContext Build(IWorldSnapshot snapshot, FrameRequest request);
}
public sealed record FrameRequest(
FrameAnchor Anchor,
FrameBands Bands,
double InterestRadius);
public abstract record FrameAnchor
{
public sealed record Entity(EntityId EntityId) : FrameAnchor;
public sealed record Pose(WorldPose Pose) : FrameAnchor;
public sealed record Position(WorldPosition Position) : FrameAnchor;
}Convenience:
public static class FrameRequests
{
public static FrameRequest CenteredOn(EntityId entityId, double interestRadius = 100_000)
=> new(
Anchor: new FrameAnchor.Entity(entityId),
Bands: FrameBands.Default,
InterestRadius: interestRadius);
}Simple usage
var frame = frameBuilder.Build(snapshot, FrameRequests.CenteredOn(playerShipId));
var local = frame.Projector.Project(enemy.Position);
var band = frame.Classify(enemy.Position);Band classification
IFS classifies distance, not participation.
public enum FrameBandKind
{
Interactive,
Tactical,
Strategic,
Background,
Outside
}
public interface IFrameBandClassifier
{
FrameBandKind Classify(FrameContext frame, WorldPosition position);
}Hysteresis: use EnterDistance / ExitDistance per FrameBand (e.g. enter tactical 10 km, exit 12 km). Classifier reads prior band from host state if needed; classifier itself stays pure given that state input.
Relationship to PreCulling (future)
IWorldSnapshot
→ IFrameContextBuilder.Build
FrameContext
→ IPreCullPipeline.Run
PreCullResult
→ FrameScene
→ Adapters (Raylib, physics islands, replication)Novolis.Spatial.PreCulling depends on Novolis.InertialFrames + Novolis.Simulation.Abstractions. It must not reference Raylib.
What IFS may / must not do
Unchanged from the conceptual spec:
May: resolve origin/orientation, interest region, bands, project/unproject, distance classification.
Must not: select renderables, physics bodies, replication sets; integrate motion; collision; draw calls; renderer/physics APIs; client/server roles.
Determinism
Same IWorldSnapshot contents
Same FrameRequest
Same FrameBands configuration
Same tick
= same FrameContextNo wall-clock time, RNG, renderer feedback, physics engine internals, network latency, or mutable statics in the build path.
Performance
- Prefer
readonly struct/ records andIFrameProjectorinstances that cache inverse orientation. - Squared distance comparisons in band classifier.
- No LINQ in hot paths; allocation-free steady state for
Build+Project. AxisAlignedBox3dfor interest region tests.
Debug invariants (v1)
Debug.Assert(frame.Projector.Project(frame.Frame.Origin.Position).Value.LengthSquared() < 1e-12);
Debug.Assert(frame.InterestRegion.Contains(frame.Frame.Origin.Position));
// Monotonic enter distances: Interactive < Tactical < Strategic < BackgroundMinimal v1 surface (ship first)
| Deliverable | Package |
|---|---|
| `WorldPosition`, `WorldVector`, `WorldPose`, `WorldRegion` | `Novolis.Spatial` |
| `SimulationTick`, `EntityId`, `EntityView`, `IWorldSnapshot` | `Novolis.Simulation.Abstractions` |
| `IFrameContextBuilder`, `IFrameProjector`, `FrameContext`, `FrameRequest`, `FrameBands`, default classifier | `Novolis.InertialFrames` |
| TUnit tests: round-trip project/unproject, origin at zero, band hysteresis, determinism | `Novolis.InertialFrames.Tests` |
Defer: FrameVector project/unproject if unused, ISpatialQuery, Novolis.Spatial.PreCulling, GPU matrix helpers.
Integration notes for current repos
| Existing API | Relationship to IFS |
|---|---|
| `Novolis.Simulation.View.ViewPose` | Observer input; convert to `WorldPose` / `FrameAnchor.Pose` when building a frame |
| `Novolis.Math.Geometry.Camera` | Downstream of adapters; built from projected or float-converted poses |
| `Novolis.Physics.Abstractions.RigidBodyState` | Physics island input; positions are world-space — project with `IFrameProjector` inside adapter, not inside integrators |
| `Novolis.Simulation.World.PlanarOccupancy` | Grid queries stay 2D float; orthogonal concern unless extended to 3D world |
Wave checklist (suggested)
- Add
Novolis.Simulation.Abstractionstonovolis-simulation(packable, no raylib). - Create
novolis-spatialfrom template; addNovolis.Spatial+Novolis.InertialFrames+ TUnit tests. - Local NuGet pack; dogfood from one host with a fake
IWorldSnapshot. - Add governance cross-link from simulation-layer-policy.md (one paragraph: spatial layer between simulation truth and adapters).
- Implement
Novolis.Spatial.PreCullingwhen a second consumer needs shared “what” selection.
Original → adapted quick reference
| Original | Adapted |
|---|---|
| `Novolis.Mathematics` | `Novolis.Physics.Numerics` |
| `Bounds3d` | `AxisAlignedBox3d` |
| `Matrix4x4d` | Dropped for v1; quaternion + translation projector |
| `Novolis.Spatial.Abstractions` | `Novolis.Spatial` |
| `WorldSnapshot` (concrete) | `IWorldSnapshot` + host implementations |
| `EntityView` | `Novolis.Simulation.Abstractions` |
| Package only `Novolis.InertialFrames` | Repo `novolis-spatial`; abstractions split as above |