Project

General

Profile

User Story #63 » 0009-feat-engine-F3-accuracy-shared-FPV-camera-calibratio.patch

knight8241, 08/07/2026 18:32

View differences:

common/src/commonMain/kotlin/com/aether/mofe/engine/render/FpvCameraCalibration.kt
package com.aether.mofe.engine.render
import com.aether.mofe.model.Vector3D
import kotlin.math.PI
import kotlin.math.tan
/**
* The ONE first-person-camera projection contract shared by the Filament FPV camera and every 2-D
* overlay drawn on top of it (the aim-reticle markers, the node-to-node edges, the floor grid, the
* predicate wireframes, and the hand-rolled `Mesh3DCanvas` first-person projector).
*
* WHY THIS EXISTS — F3 · accuracy. The FPV renders through two independent paths that must land a
* peer at the SAME pixel:
* - the 3-D scene — a world position → the Filament Y-up scene ([worldToFilament]) → the Filament
* perspective camera → screen;
* - the 2-D overlays — a world position expressed as a facing-frame offset
* `(right, up) = (dir·right / dir·forward, dir·up / dir·forward)` — the TANGENT of the off-axis
* angle in each screen axis (see `AimEvaluator.facingFrameOffset` / `MeshAimTracker.reticleOffset`)
* — mapped to a pixel by [offsetToScreen].
* The two coincide off-centre only when BOTH use the same vertical field of view. Before F3 they did
* not: the Filament camera used SceneView's built-in default (a 28 mm-focal lens ≈ 65° *horizontal*),
* while the overlays used `focal = min(width, height) * 0.9` — a guess tied to the SHORT screen axis,
* so the implied *vertical* FOV was wildly off and every off-axis object drifted away from its marker.
* That divergence is the "inaccurate" half of the real-time FPV milestone.
*
* The fix routes every path through this single [verticalFovRadians]: the Filament camera is set with
* `Camera.Fov.VERTICAL = `[verticalFovDegrees], and each overlay uses [focalPx] / [offsetToScreen].
* On-aim placement (offset `(0,0)` → the screen centre) is exact for any focal — it is the OFF-axis
* placement this makes correct, so a marker now slides onto its object as the phone pans, and the 3-D
* sphere and its overlay ring stay glued together.
*
* The absolute FOV is the single on-device tuning knob (F4). With no camera passthrough yet there is
* no physical sensor to match, so [DEFAULT_VERTICAL_FOV_DEGREES] is a portrait phone's typical
* long-axis (vertical) camera FOV — a sensible starting point a future passthrough can be aligned to.
*/
data class FpvCameraCalibration(
val verticalFovRadians: Double = DEFAULT_VERTICAL_FOV_RADIANS,
) {
init {
require(verticalFovRadians > 0.0 && verticalFovRadians < PI) {
"verticalFovRadians must be in (0, PI); was $verticalFovRadians"
}
}
/** [verticalFovRadians] in degrees — the unit Filament's `setProjection(fovInDegrees, …)` wants. */
val verticalFovDegrees: Double get() = verticalFovRadians * 180.0 / PI
/**
* Pixel focal length for a viewport [heightPx] tall: `f = (height / 2) / tan(vFov / 2)`. This is
* the single value both the Filament camera (implicitly, via its vertical FOV) and the overlays
* use. A facing-frame offset lands on the vertical FOV edge (screen top/bottom) exactly when its
* `up` component is `±tan(vFov / 2)`; solving that boundary for the pixel scale gives this `f`.
* Horizontal placement uses the SAME `f`, so the horizontal FOV follows from the viewport aspect —
* the correct pinhole behaviour, and precisely why keying the old guess off the short axis was
* wrong.
*/
fun focalPx(heightPx: Double): Double {
require(heightPx > 0.0) { "heightPx must be positive; was $heightPx" }
return (heightPx / 2.0) / tan(verticalFovRadians / 2.0)
}
/**
* Map a facing-frame offset `(right, up)` to a screen pixel in a [widthPx] × [heightPx] viewport.
* Screen-y is DOWN, so a positive `up` moves the pixel toward the top. Offset `(0,0)` → the exact
* viewport centre (the reticle), which is why an object at the observer's aim sits on the
* crosshair. Both axes scale by the same [focalPx], so the mapping is a true pinhole projection.
*/
fun offsetToScreen(
offset: Pair<Double, Double>,
widthPx: Double,
heightPx: Double,
): Pair<Double, Double> {
val f = focalPx(heightPx)
return (widthPx / 2.0 + offset.first * f) to (heightPx / 2.0 - offset.second * f)
}
companion object {
/** Portrait phone long-axis (vertical) FOV — the single F4 tuning knob (see the class KDoc). */
const val DEFAULT_VERTICAL_FOV_DEGREES: Double = 68.0
val DEFAULT_VERTICAL_FOV_RADIANS: Double = DEFAULT_VERTICAL_FOV_DEGREES * PI / 180.0
/** Shared default — the FPV camera and every overlay reference this so they cannot diverge. */
val DEFAULT: FpvCameraCalibration = FpvCameraCalibration()
/**
* MOFE world (Z-up) → Filament scene (Y-up): `(x, y, z) → (x, z, −y)`. This is a proper
* rotation (−90° about the X axis, determinant +1), so it preserves lengths, dot products and
* handedness. It maps world-up (+Z) → Filament up (+Y); the two world horizontal axes
* (+X, +Y) → Filament (+X, −Z), i.e. onto the ground plane the Y-up scene renders as
* horizontal. Because it is a rotation, for ANY camera facing the facing-frame offset (built
* in world coordinates) and the Filament projection (built in scene coordinates) yield
* identical screen tangents — that is what makes the 2-D overlays align with the 3-D nodes.
* Kept here as the ONE definition; the UI's `toFilament` mirrors it exactly.
*/
fun worldToFilament(v: Vector3D): Vector3D = Vector3D(v.x, v.z, -v.y)
}
}
common/src/commonTest/kotlin/com/aether/mofe/engine/render/FpvCameraCalibrationTest.kt
package com.aether.mofe.engine.render
import com.aether.mofe.engine.netcode.AimEvaluator
import com.aether.mofe.model.Vector3D
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.tan
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* F3 · accuracy — the FPV projection contract. Proves the pure math that makes the 3-D scene path and
* the 2-D overlay path land a point on the SAME pixel: the focal ↔ vertical-FOV relationship, the
* on-aim-to-centre / edge-to-FOV-boundary screen mapping, and — the crux F3 calls for — the world→
* scene coordinate swap verified END TO END against [AimEvaluator.facingFrameOffset], the offset the
* overlays actually consume.
*/
class FpvCameraCalibrationTest {
private fun assertClose(expected: Double, actual: Double, eps: Double = 1e-9, msg: String = "") {
assertTrue(abs(expected - actual) <= eps, "$msg expected=$expected actual=$actual (eps=$eps)")
}
private val WORLD_UP = Vector3D(0.0, 0.0, 1.0)
// ---- focal length ↔ vertical FOV -------------------------------------------------------------
@Test
fun focal_is_half_height_at_90_degrees() {
// tan(45°) = 1 ⇒ f = (h/2)/1 = h/2.
val calib = FpvCameraCalibration(PI / 2.0)
assertClose(1000.0, calib.focalPx(2000.0), 1e-6, "90° vFov")
}
@Test
fun focal_recovers_the_fov_edge() {
// The defining identity: an offset of tan(vFov/2) up must sit exactly at the top edge (y=0),
// i.e. offset*f = h/2 ⇒ f = (h/2)/tan(vFov/2). Hold across several FOVs and heights.
for (deg in listOf(35.0, 50.0, 68.0, 95.0, 120.0)) {
val calib = FpvCameraCalibration(deg * PI / 180.0)
for (h in listOf(720.0, 1080.0, 2340.0)) {
val f = calib.focalPx(h)
assertClose(h / 2.0, f * tan(calib.verticalFovRadians / 2.0), 1e-6, "deg=$deg h=$h")
}
}
}
@Test
fun wider_fov_gives_shorter_focal() {
val narrow = FpvCameraCalibration(40.0 * PI / 180.0).focalPx(1080.0)
val wide = FpvCameraCalibration(90.0 * PI / 180.0).focalPx(1080.0)
assertTrue(wide < narrow, "wider FOV ⇒ shorter focal: wide=$wide narrow=$narrow")
}
@Test
fun degrees_radians_round_trip() {
assertClose(68.0, FpvCameraCalibration.DEFAULT.verticalFovDegrees, 1e-9)
assertClose(
FpvCameraCalibration.DEFAULT_VERTICAL_FOV_RADIANS,
FpvCameraCalibration.DEFAULT.verticalFovRadians, 1e-12,
)
}
// ---- offset → screen mapping -----------------------------------------------------------------
@Test
fun on_aim_offset_maps_to_exact_centre() {
val (x, y) = FpvCameraCalibration.DEFAULT.offsetToScreen(0.0 to 0.0, 1080.0, 2340.0)
assertClose(540.0, x, 1e-9, "centre x")
assertClose(1170.0, y, 1e-9, "centre y")
}
@Test
fun vertical_fov_edges_map_to_top_and_bottom() {
val calib = FpvCameraCalibration(68.0 * PI / 180.0)
val h = 2340.0
val edge = tan(calib.verticalFovRadians / 2.0)
val (_, yTop) = calib.offsetToScreen(0.0 to edge, 1080.0, h) // +up ⇒ toward the top
val (_, yBot) = calib.offsetToScreen(0.0 to -edge, 1080.0, h)
assertClose(0.0, yTop, 1e-6, "top edge")
assertClose(h, yBot, 1e-6, "bottom edge")
}
@Test
fun horizontal_placement_uses_the_same_focal() {
// A right offset of tan(vFov/2) must move exactly focal*tan(vFov/2) = h/2 px — proving both
// axes share one focal, so horizontal FOV follows from aspect (the pinhole property the old
// min(w,h)*0.9 guess broke by keying the scale off the short axis).
val calib = FpvCameraCalibration(68.0 * PI / 180.0)
val w = 1080.0; val h = 2340.0
val edge = tan(calib.verticalFovRadians / 2.0)
val (xRight, _) = calib.offsetToScreen(edge to 0.0, w, h)
assertClose(w / 2.0 + h / 2.0, xRight, 1e-6, "right offset px")
}
// ---- world → Filament swap is a proper rotation ----------------------------------------------
private val swapSamples = listOf(
Vector3D(1.0, 0.0, 0.0), Vector3D(0.0, 1.0, 0.0), Vector3D(0.0, 0.0, 1.0),
Vector3D(0.4, -1.3, 2.1), Vector3D(-2.7, 0.9, -0.5), Vector3D(3.1, 2.2, -1.8),
)
@Test
fun swap_preserves_length_and_dot() {
for (v in swapSamples) {
assertClose(v.magnitude, FpvCameraCalibration.worldToFilament(v).magnitude, 1e-12, "len")
}
for (a in swapSamples) for (b in swapSamples) {
val lhs = a.dot(b)
val rhs = FpvCameraCalibration.worldToFilament(a).dot(FpvCameraCalibration.worldToFilament(b))
assertClose(lhs, rhs, 1e-9, "dot")
}
}
@Test
fun swap_preserves_handedness() {
// A proper rotation commutes with the cross product; a reflection would flip its sign.
for (a in swapSamples) for (b in swapSamples) {
val crossThenSwap = FpvCameraCalibration.worldToFilament(a.cross(b))
val swapThenCross =
FpvCameraCalibration.worldToFilament(a).cross(FpvCameraCalibration.worldToFilament(b))
assertClose(crossThenSwap.x, swapThenCross.x, 1e-9, "x")
assertClose(crossThenSwap.y, swapThenCross.y, 1e-9, "y")
assertClose(crossThenSwap.z, swapThenCross.z, 1e-9, "z")
}
}
@Test
fun swap_maps_axes_up_forward_right() {
// Component-wise (not data-class equals): the swap yields a signed −0.0 in the zeroed slots,
// and Double.equals treats −0.0 ≠ 0.0, so assertEquals on the whole vector would spuriously
// fail on a numerically exact result.
fun assertVec(ex: Vector3D, ac: Vector3D, msg: String) {
assertClose(ex.x, ac.x, 1e-12, "$msg.x"); assertClose(ex.y, ac.y, 1e-12, "$msg.y")
assertClose(ex.z, ac.z, 1e-12, "$msg.z")
}
// world-up (+Z) → Filament up (+Y)
assertVec(Vector3D(0.0, 1.0, 0.0), FpvCameraCalibration.worldToFilament(Vector3D(0.0, 0.0, 1.0)), "up")
// world +Y (a horizontal axis) → Filament −Z (into the scene depth)
assertVec(Vector3D(0.0, 0.0, -1.0), FpvCameraCalibration.worldToFilament(Vector3D(0.0, 1.0, 0.0)), "fwd")
// world +X (a horizontal axis) → Filament +X (screen right)
assertVec(Vector3D(1.0, 0.0, 0.0), FpvCameraCalibration.worldToFilament(Vector3D(1.0, 0.0, 0.0)), "right")
}
// ---- END TO END: the overlay offset == the analytic Filament-frame projection ----------------
@Test
fun facing_frame_offset_equals_filament_frame_tangents() {
// The overlays place a target from AimEvaluator.facingFrameOffset (world frame). The 3-D scene
// places the SAME target by swapping to the Filament frame and projecting through the camera.
// Because the swap is a rotation, both must produce identical screen tangents — THIS is the
// coordinate swap verified END TO END.
//
// Each target is built IN FRONT of the observer by construction — placed along that facing's
// own basis at known lateral tangents (a, b) — so it is never behind the horizon and its
// expected offset is exactly (a, b). We then confirm BOTH the world-frame offset AND the
// independently-computed swapped Filament-frame tangents recover (a, b).
val facings = listOf(
Vector3D(0.0, 1.0, 0.0), Vector3D(1.0, 0.0, 0.0), Vector3D(1.0, 1.0, 0.0),
Vector3D(-1.0, 2.0, 0.3), Vector3D(0.5, -1.0, 0.0), Vector3D(-2.0, -1.5, -0.4),
)
val laterals = listOf(-0.7, -0.2, 0.3, 0.8)
val observer = Vector3D(0.3, -0.2, 0.1) // arbitrary, non-origin observer
var checked = 0
for (facing in facings) {
val fwdW = facing.normalize()
val rightW = fwdW.cross(WORLD_UP).normalize()
val upW = rightW.cross(fwdW).normalize()
val camFwd = FpvCameraCalibration.worldToFilament(fwdW)
val camRight = FpvCameraCalibration.worldToFilament(rightW)
val camUp = FpvCameraCalibration.worldToFilament(upW)
for (a in laterals) for (b in laterals) {
// depth 1.5 forward, plus (a,b) laterally ⇒ tangents (a, b), always in front.
val target = observer + (fwdW + rightW * a + upW * b) * 1.5
val offset = AimEvaluator.facingFrameOffset(observer, facing, WORLD_UP, target)!!
assertClose(a, offset.first, 1e-9, "world.right a=$a b=$b facing=$facing")
assertClose(b, offset.second, 1e-9, "world.up a=$a b=$b facing=$facing")
// Independent 3-D-scene path: swap the relative vector, project in the Filament frame.
val relF = FpvCameraCalibration.worldToFilament(target - observer)
val depth = relF.dot(camFwd)
assertTrue(depth > 1e-6, "target must be in front: depth=$depth")
assertClose(offset.first, relF.dot(camRight) / depth, 1e-9, "swap.right a=$a b=$b")
assertClose(offset.second, relF.dot(camUp) / depth, 1e-9, "swap.up a=$a b=$b")
checked++
}
}
assertEquals(facings.size * laterals.size * laterals.size, checked, "all cases in front")
}
@Test
fun a_right_and_up_target_lands_right_of_and_above_centre() {
// Concrete sanity: observer at origin facing world-north (+Y), up = world-up (+Z). A target
// offset right (+X) and up (+Z) must render right of and above the reticle by its tangents.
val offset = AimEvaluator.facingFrameOffset(
Vector3D.ZERO, Vector3D(0.0, 1.0, 0.0), WORLD_UP, Vector3D(0.20, 1.0, 0.15),
)!!
assertClose(0.20, offset.first, 1e-9, "right tangent")
assertClose(0.15, offset.second, 1e-9, "up tangent")
val w = 1080.0; val h = 2340.0
val f = FpvCameraCalibration.DEFAULT.focalPx(h)
val (x, y) = FpvCameraCalibration.DEFAULT.offsetToScreen(offset, w, h)
assertClose(w / 2.0 + 0.20 * f, x, 1e-6, "screen x")
assertClose(h / 2.0 - 0.15 * f, y, 1e-6, "screen y")
assertTrue(x > w / 2.0 && y < h / 2.0, "right of and above centre: x=$x y=$y")
}
}
(1-1/2)