Novolis.Physics — Design Review Findings
Force-first textbook physics
Review date: 2026-05-16
Version reviewed: 0.1.0-alpha (net10.0) — remediation shipped in 0.2.0-alpha
Scope: Full review per library design review plan (API stability, architecture, consumer ergonomics, correctness, performance spot-check)
Test run: dotnet run --project tests/Novolis.Physics.Unit -c Release — 46/46 passed (967 ms)
Executive summary
Novolis.Physics delivers a coherent force-first core (IForceModel → SimulationPipeline → IIntegrator) with well-factored packages and strong scenario tests. Before stable release, address orphan public API and integration-path documentation. KspLite was removed (2026-05-16): it was an example-only DI shim, not a product package; registration patterns live in examples/dependency-injection.md. No physics blockers were found in the test suite.
Phase 1 — Public API inventory
Package dependency graph
Numerics
└── Abstractions
├── Motion, Gravity, Aerodynamics, Collision.Simple
├── Ballistics (+ Collision.Simple)
└── Orbits (Numerics only)
Novolis.Physics (meta) → all product packagesPublic type classification (49 source files, ~52 public types)
| Package | Type | Role |
|---|---|---|
| **Numerics** | `Vector3d`, `Quaterniond`, `Ray3d`, `Sphere3d`, `Capsule3d`, `AxisAlignedBox3d` | Value / geometry |
| **Abstractions** | `IForceModel<>`, `IIntegrator<>`, `IStaticWorld` | Contract |
| **Abstractions** | ~~`IContactResolver<>`~~ | Removed in 0.2.0-alpha |
| **Abstractions** | `RigidBodyState`, `ForceSample`, `HitInfo` | State / sample |
| **Motion** | `SimulationPipeline<>`, `SemiImplicitEulerRigidBodyIntegrator`, `FixedStepAccumulator` | Algorithm / orchestration |
| **Motion** | `UniformAccelerationEnergy` | Helper |
| **Gravity** | `PointMassField`, `PatchedConicPairField` | Environment |
| **Gravity** | `PointMassGravityModel`, `PatchedConicGravityModel` | Algorithm (`IForceModel`) |
| **Aerodynamics** | `IAtmosphereModel`, `ExponentialAtmosphereModel` | Contract / algorithm |
| **Aerodynamics** | `SimpleAeroEnvironment`, `SimpleLiftDragModel` | Environment / algorithm |
| **Collision.Simple** | `TriangleMesh` (Math) + `BvhStaticWorld`, `EmptyStaticWorld` | Environment / algorithm |
| **Collision.Simple** | `BvhStaticSphereIntegrator`, `SphereContactKinematics` | Standalone integrator / helper |
| **Ballistics** | `ProjectileState`, `ProjectileProfile`, `*Environment` | State / environment |
| **Ballistics** | `ProjectileQuadraticDragModel`, `ProjectileSemiImplicitIntegrator` | Pipeline building blocks |
| **Ballistics** | `ProjectileBallisticSimulation` | Facade (monolithic step) |
| **Ballistics** | `ProjectileMath`, `BallisticsQueries`, `GroundImpact` | Helper / query |
| **Orbits** | `OrbitState`, `LeapfrogCentralBodySoA`, `CentralOrbitSimulator` | Parallel integration stack |
| **Orbits** | `OrbitalMath`, `KernelMode` | Helper |
| **TestSupport** | `OrbitalTestConstants`, `OrbitalTestState` | Test fixtures (not published) |
| **Novolis.Physics** | (none) | Meta-package only |
Former `Novolis.Physics.KspLite` package removed — see [examples/dependency-injection.md](./examples/dependency-injection.html).
Internal (non-public): TriangleRay in Collision.Simple.
Orphan and unused-contract flags
| Item | Status |
|---|---|
| ~~`IContactResolver<TBody>`~~ | **Removed** in 0.2.0-alpha; contact via `SphereContactKinematics` + `BvhStaticSphereIntegrator` |
| `IForceModel.Evaluate(..., timeSeconds)` | Documented as simulation time; shipped models are time-invariant |
| ~~`OrbitalTestConstants` / `OrbitalTestState`~~ | **Moved** to `Novolis.Physics.TestSupport.Orbits` in 0.2.0-alpha |
Phase 2 — Architecture coherence
Force-first alignment by package
| Package | Aligns with pipeline? | Notes |
|---|---|---|
| Numerics | N/A (foundation) | Right-handed 3D; no axis convention doc at type level |
| Abstractions | Yes | Clean split: state vs `ForceSample` vs `HitInfo` |
| Motion | Yes | Canonical orchestration; semi-implicit Euler for rigid bodies |
| Gravity | Yes | `PointMassField` uses `ReadOnlyMemory` + `Span` safely |
| Aerodynamics | Yes | `SimpleLiftDragModel` is proper `IForceModel`; atmosphere via `IAtmosphereModel` |
| Collision.Simple | Partial | Query-only `IStaticWorld`; integration via separate `BvhStaticSphereIntegrator` |
| Ballistics | Partial | Pipeline path exists; `ProjectileBallisticSimulation` duplicates gravity+drag inline |
| Orbits | **No** | Leapfrog SoA; does not use `IForceModel` / `SimulationPipeline` |
| ~~KspLite~~ | Removed | DI example moved to docs |
Integration paths (validated)
Four distinct simulation styles coexist:
- `SimulationPipeline` + `IForceModel` + `IIntegrator` — rigid body and projectile (with adapters).
- `ProjectileBallisticSimulation.Step` — convenience facade; parity-tested against pipeline (
ProjectileDragPipelineParityTests). - `BvhStaticSphereIntegrator` — sphere vs static mesh with contact resolution; used in room/billiards tests; not wired through
IContactResolver. - `CentralOrbitSimulator` / `LeapfrogCentralBodySoA` — central-body leapfrog; scalar/vectorized kernels.
Architecture decision records (recommended)
ADR-1: Orbits remains a separate stack (ACCEPT)
Decision: Keep Novolis.Physics.Orbits as a leapfrog SoA integrator, not folded into IForceModel for v1.
Rationale: Different numerical method (symplectic leapfrog vs semi-implicit Euler), SoA layout for N bodies, and existing energy/angular-momentum tests. PointMassGravityModel already covers pipeline-style gravity for games.
Follow-up: Document when to use Orbits vs Gravity+Motion; consider moving OrbitalTestConstants/OrbitalTestState to test assembly or renaming to OrbitalReferenceOrbit before stable.
ADR-2: Collision stays query-only in v1 (ACCEPT)
Decision: IStaticWorld provides ray/sweep queries only; no full rigid-body contact solver in the pipeline.
Rationale: Matches scope; BvhStaticSphereIntegrator covers sphere-in-room scenarios. XML docs already flag approximate sweeps.
Follow-up: Add consumer doc section on sweep limitations and when BvhStaticSphereIntegrator vs manual pipeline stepping applies.
ADR-3: Ballistics — pipeline is canonical; facade is supported (ACCEPT)
Decision: Recommend SimulationPipeline + ProjectileQuadraticDragModel + ProjectileSemiImplicitIntegrator for extensibility; keep ProjectileBallisticSimulation as ergonomic entry point.
Rationale: ProjectileDragPipelineParityTests proves equivalence for gravity + quadratic drag. Facade reduces boilerplate for cannon-style problems.
Follow-up: README example showing both patterns and when to add custom IForceModel instances.
ADR-4: `IContactResolver` — remove or defer before stable (ACCEPT removal)
Decision: Remove from public API in next breaking window, or move to Novolis.Physics.Abstractions.Experimental if contact pipeline is planned.
Rationale: Misleading contract; actual API is SphereContactKinematics.ReflectWithRestitution inside BvhStaticSphereIntegrator.
Phase 3 — Consumer ergonomics
Persona walkthroughs
Minimal (point mass + Euler)
Path: MinimalSimulationExampleTests — direct construction of FixedStepAccumulator, SimulationPipeline, and PointMassGravityModel.
Friction: Low; see README quick start and INTEGRATION.md.
Game room (gravity + collision + bounce)
Path: BasketballEarthRoomCollisionTests, BouncingBallCollisionTests — build BvhStaticWorld from mesh, loop BvhStaticSphereIntegrator.AdvanceOneStep or AdvanceWithUniformAccelerationAndLinearDrag with UniformAccelerationEnergy for traces.
Friction: Collision is outside SimulationPipeline; gravity/drag applied inside integrator helper, not as IForceModel. Two mental models required.
Ballistics (drag + ground impact)
Path: Most tests use ProjectileBallisticSimulation; parity tests prove pipeline equivalence. BallisticsQueries wraps IStaticWorld sweeps.
Friction: Low for ballistics-only users; unclear which path to choose without reading ProjectileDragPipelineParityTests.
Ergonomics rubric (1 = poor, 5 = excellent)
| Criterion | Score | Evidence |
|---|---|---|
| Discoverability | **4** | README quick start + INTEGRATION.md (post-review) |
| Composition | **4** | `SimulationPipeline` constructor is simple; README + INTEGRATION.md cover wiring |
| Type clarity | **4** | `TBody`/`TEnvironment` pairs are consistent; projectile vs rigid body types are distinct |
| Convention docs | **3** | +Y up, −Y gravity documented on `ProjectileBallisticSimulation`; not centralized |
| Error surfaces | **3** | `FixedStepAccumulator` and mesh ctor validate inputs; zero mass/inertia guarded in integrator |
Documentation gaps
- TestSupport README — references StarConflictsRevolt paths and project names.
- No worked example of sweep failure modes (fast sphere, thin geometry, capsule endpoint sampling).
Phase 4 — Correctness and performance
Test → invariant mapping
| Test class | Invariant proven |
|---|---|
| `SimulationPipelineTests` | Forces sum before integration |
| `ProjectileDragPipelineParityTests` | Facade ≡ pipeline (1 and 200 steps) |
| `PatchedConicGravityTests` | SOI switching uses correct μ and source |
| `EllipticalOrbitTwoBodyTests` | Closed/half-orbit geometry; energy drift ≤ 1e-5; Lz drift ≤ 1e-6; scalar ≡ vectorized |
| `CollisionSweepScenarioTests` | Sweep hits ground before full displacement |
| `FixedStepAccumulatorTests` | Deterministic step count; fractional carry across frames |
| `AnalyticalProjectileTests` / `BallisticPropertyTests` | Vacuum/drag trajectories vs analytic expectations |
| `BvhStaticWorldTests` | Raycast/sweep basics on simple meshes |
| `AerodynamicsModelTests` | Lift/drag direction and magnitude sanity |
| `MinimalSimulationExampleTests` | Fixed step + pipeline + gravity end-to-end |
| `BasketballEarthRoomCollisionTests` | Long-run stability; energy traces (scenario, not strict conservation) |
Performance spot-check (hot `Step` paths)
| Location | Heap allocation in steady-state step? | Notes |
|---|---|---|
| `SimulationPipeline.Step` | **No** | foreach over force array |
| `SemiImplicitEulerRigidBodyIntegrator.Step` | **No** | stack `Vector3d` / `Quaterniond` |
| `ProjectileSemiImplicitIntegrator.Step` | **No** | struct return |
| `ProjectileBallisticSimulation.Step` | **No** | reuses instance integrator |
| `BvhStaticSphereIntegrator.AdvanceOneStep` | **No** per iteration | `Sphere3d` on stack; loop bounded by `maxReflectionsPerStep` |
| `LeapfrogCentralBodySoA.Step` (scalar) | **No** | SoA arrays allocated at ctor |
| `LeapfrogCentralBodySoA.Step` (vectorized) | **No** | `stackalloc` lanes + `Vector<double>` (struct, no heap) |
| `BvhStaticWorld` ctor | **Yes (once)** | `List<BvhNode>` → `ToArray()` at build |
| `CentralOrbitSimulator.SimulateFor` | **Yes per call** | new `LeapfrogCentralBodySoA` each invocation |
Conclusion: Sim-loop paths are allocation-conscious. Avoid calling CentralOrbitSimulator.SimulateFor inside per-frame hot paths without pooling/reuse.
Findings register
| ID | Severity | Area | Finding | Recommendation | Effort |
|---|---|---|---|---|---|
| DR-001 | ~~Major~~ **Resolved** | Abstractions | `IContactResolver<TBody>` removed; contact API documented in INTEGRATION.md | Fixed in 0.2.0-alpha | — |
| DR-002 | ~~Major~~ **Resolved** | Architecture | Four integration styles | [INTEGRATION.md](./INTEGRATION.html) added | — |
| DR-003 | ~~Major~~ **Resolved** | KspLite | Removed example package | README + INTEGRATION.md + optional DI doc | — |
| DR-004 | ~~Major~~ **Resolved** | Packaging | KspLite/meta mismatch | KspLite removed; meta = product packages only | — |
| DR-005 | ~~Major~~ **Resolved** | Orbits | Fixtures moved to `Novolis.Physics.TestSupport.Orbits` | Fixed in 0.2.0-alpha | — |
| DR-006 | ~~Major~~ **Resolved** | Collision | Sweep limitations documented; `SweepLimitationScenarioTests` added | Fixed in 0.2.0-alpha | — |
| DR-007 | ~~Minor~~ **Resolved** | Abstractions | `timeSeconds` documented on `IForceModel` and shipped models | Fixed in 0.2.0-alpha | — |
| DR-008 | ~~Minor~~ **Resolved** | Docs | Root README had no code example | README quick start added | — |
| DR-009 | ~~Minor~~ **Resolved** | Docs | TestSupport README rewritten for Novolis.Physics | Fixed in 0.2.0-alpha | — |
| DR-010 | ~~Minor~~ **Resolved** | Ballistics | Dual path documented in INTEGRATION.md and README | Fixed in 0.2.0-alpha | — |
| DR-011 | ~~Minor~~ **Resolved** | Motion | Caller time advance documented on `SimulationPipeline.Step` and INTEGRATION.md | Fixed in 0.2.0-alpha | — |
| DR-012 | ~~Minor~~ **Resolved** | KspLite | N/A | Package removed | — |
| DR-013 | ~~Nit~~ **Resolved** | Orbits | Reuse overload added; convenience overload documented | Fixed in 0.2.0-alpha | — |
| DR-014 | ~~Nit~~ **Resolved** | Collision | Documented in INTEGRATION.md | Fixed in 0.2.0-alpha | — |
| DR-015 | ~~Nit~~ **Resolved** | Numerics | README Conventions + Numerics package description | Fixed in 0.2.0-alpha | — |
Blockers: None identified in this review.
Prioritized post-review backlog
P0 — Before stable (0.2.0) — **Complete**
- ~~DR-001~~ —
IContactResolverremoved. - ~~DR-005~~ — Orbit fixtures moved to TestSupport.
P1 — Early stable — **Complete**
- ~~DR-006~~ — Sweep limitations doc + test.
- ~~DR-010~~ — Ballistics path documentation.
- ~~Semver policy~~ — VERSIONING.md.
P2 — Nice to have — **Complete**
- ~~DR-007~~ —
timeSecondsdocumented. - ~~DR-013~~ — Orbit simulator reuse API.
- ~~DR-009~~ — TestSupport README cleanup.
- ~~DR-011, DR-014, DR-015~~ — Documented in 0.2.0-alpha.
Appendix A — Verified pre-seeded hypotheses
| Hypothesis | Result |
|---|---|
| `IContactResolver` has zero implementations | **Confirmed** |
| Multiple integration styles coexist | **Confirmed** (four paths) |
| KspLite was example-only | **Removed**; DI recipe in docs/examples |
| Sweeps are approximate | **Confirmed** (XML on `IStaticWorld`) |
Appendix B — Suggested README example (for DR-008)
using Novolis.Physics.Abstractions;
using Novolis.Physics.Gravity;
using Novolis.Physics.Motion;
using Novolis.Physics.Numerics;
var integrator = new SemiImplicitEulerRigidBodyIntegrator();
var gravity = new PointMassGravityModel();
var pipeline = new SimulationPipeline<RigidBodyState, PointMassField>(integrator, gravity);
var field = new PointMassField([(Vector3d.Zero, 3.986e14)]);
var body = new RigidBodyState(
new Vector3d(6_771_000, 0, 0),
new Vector3d(0, 7_500, 0),
Quaterniond.Identity, Vector3d.Zero, mass: 1.0,
inertiaDiagonalBody: new Vector3d(1, 1, 1));
body = pipeline.Step(body, field, dtSeconds: 1.0);End of design review findings.