From ecb6a1c2036a071d1eeebd3aeb77435ab964559e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 21:07:33 +0000 Subject: [PATCH 09/10] =?UTF-8?q?feat(engine):=20F3=20accuracy=20=E2=80=94?= =?UTF-8?q?=20shared=20FPV=20camera=20calibration=20(vFov=20=E2=86=94=20fo?= =?UTF-8?q?cal=20+=20coord-swap)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FPV renders a peer through two paths that must land on the SAME pixel: the 3-D scene (world → Filament Y-up scene → perspective camera) and the 2-D overlays (world → facing-frame tangent offset → focal*offset + centre). They coincide off-centre only when both use one vertical FOV. They did not: the Filament camera used SceneView's default lens (~65° horizontal) while the overlays used focal = min(w,h)*0.9 — a guess keyed to the SHORT screen axis, so the implied vertical FOV was wildly off and every off-axis object drifted from its marker. That is the 'inaccurate' half of the real-time FPV milestone. FpvCameraCalibration is the ONE projection contract both paths import: - verticalFovRadians/Degrees — the single knob (F4 tunes it on-device; a future camera passthrough aligns to it). Default 68° = a portrait phone's long-axis FOV. - focalPx(h) = (h/2)/tan(vFov/2) — the pixel scale the overlays use, derived so an offset of tan(vFov/2) lands exactly on the vertical FOV edge; both axes share it, so horizontal FOV follows from aspect (the pinhole property the old guess broke). - offsetToScreen — facing-frame offset → pixel; (0,0) → exact centre (the reticle). - worldToFilament — the (x,y,z)→(x,z,−y) swap kept as ONE definition; a proper rotation (det +1), which is WHY the two paths agree for any facing. Pure common, 10 jvmTests: focal↔FOV identity, edge/centre mapping, the swap is a rotation (length/dot/handedness preserved) with up→+Y, forward→−Z, right→+X, and — the crux — facingFrameOffset (what the overlays consume) verified END TO END against the independently-swapped Filament-frame tangents across many facings and targets. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../engine/render/FpvCameraCalibration.kt | 97 ++++++++ .../engine/render/FpvCameraCalibrationTest.kt | 209 ++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 common/src/commonMain/kotlin/com/aether/mofe/engine/render/FpvCameraCalibration.kt create mode 100644 common/src/commonTest/kotlin/com/aether/mofe/engine/render/FpvCameraCalibrationTest.kt diff --git a/common/src/commonMain/kotlin/com/aether/mofe/engine/render/FpvCameraCalibration.kt b/common/src/commonMain/kotlin/com/aether/mofe/engine/render/FpvCameraCalibration.kt new file mode 100644 index 0000000..1c4d8fb --- /dev/null +++ b/common/src/commonMain/kotlin/com/aether/mofe/engine/render/FpvCameraCalibration.kt @@ -0,0 +1,97 @@ +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, + widthPx: Double, + heightPx: Double, + ): Pair { + 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) + } +} diff --git a/common/src/commonTest/kotlin/com/aether/mofe/engine/render/FpvCameraCalibrationTest.kt b/common/src/commonTest/kotlin/com/aether/mofe/engine/render/FpvCameraCalibrationTest.kt new file mode 100644 index 0000000..1f23ce8 --- /dev/null +++ b/common/src/commonTest/kotlin/com/aether/mofe/engine/render/FpvCameraCalibrationTest.kt @@ -0,0 +1,209 @@ +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") + } +} -- 2.43.0