From c70a3a749bc4241316b68fe5355616838655b73f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 21:14:14 +0000 Subject: [PATCH 10/10] =?UTF-8?q?feat(ui):=20F3=20accuracy=20=E2=80=94=20r?= =?UTF-8?q?oute=20the=20FPV=20camera=20+=20overlays=20through=20the=20shar?= =?UTF-8?q?ed=20calibration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the three first-person projection paths to FpvCameraCalibration so a peer's 3-D sphere and its 2-D overlay land on the SAME pixel off-centre, not just on the reticle: - MeshSceneView (Filament FPV camera): pin the vertical FOV to the shared calibration via setProjection(Fov.VERTICAL), re-asserted each FPV frame — where the camera is already driven — because CameraNode.updateProjection reapplies its default 28 mm lens on any viewport resize and would clobber a one-shot FOV. On leaving FPV the default lens (focalLength = 28 mm) is restored so orbit framing is unchanged. toFilament() now delegates to the unit-tested worldToFilament so the render swap IS the tested definition. - AimMarkersOverlay: replace focal = min(w,h)*0.9 (keyed to the SHORT screen axis, so the implied vertical FOV was wrong and off-centre markers drifted off their objects) with offsetToScreen — same centre/sign formula, correct (h/2)/tan(vFov/2) focal from the same vFov the camera uses. On-aim (0,0) placement is unchanged. - Mesh3DCanvas first-person projector: same calibrated focal (orbit's aesthetic focal left as-is), so both first-person renderers agree. Draw-path calls to focalPx guard against a pre-layout zero-height canvas (early-out / coerce) so the h > 0 contract can't throw during a draw phase. androidApp is not compilable in this env; reviewed against the verified SceneView 4.25.0 CameraNode API (adversarial review: SHIP, 0 blockers). The engine calibration + coord-swap are jvmTest-verified. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../aether/mofe/ui/components/Mesh3DCanvas.kt | 16 ++++++++-- .../aether/mofe/ui/scene/MeshSceneScreen.kt | 29 +++++++++++-------- .../com/aether/mofe/ui/scene/MeshSceneView.kt | 23 +++++++++++++-- 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/androidApp/src/main/java/com/aether/mofe/ui/components/Mesh3DCanvas.kt b/androidApp/src/main/java/com/aether/mofe/ui/components/Mesh3DCanvas.kt index b0f726c..d53f1df 100644 --- a/androidApp/src/main/java/com/aether/mofe/ui/components/Mesh3DCanvas.kt +++ b/androidApp/src/main/java/com/aether/mofe/ui/components/Mesh3DCanvas.kt @@ -27,6 +27,7 @@ import com.aether.mofe.model.mesh.MeshPredicate import com.aether.mofe.model.mesh.MeshSnapshot import com.aether.mofe.model.mesh.SelectedNode import com.aether.mofe.model.mesh.ShapeGeometry +import com.aether.mofe.engine.render.FpvCameraCalibration import com.aether.mofe.model.Vector3D import com.aether.mofe.model.WORLD_UP import com.aether.mofe.ui.theme.AetherColors @@ -217,9 +218,18 @@ fun Mesh3DCanvas( } private fun projectorFor(firstPerson: Boolean, cam: Cam3D, target: Vector3D, facing: Vector3D?, w: Float, h: Float): Projector { - val focal = minOf(w, h) * 0.9f - return if (firstPerson) Projector.firstPerson(Vector3D.ZERO, facing ?: Vector3D(0.0, 0.0, -1.0), focal, w / 2f, h / 2f) - else Projector.orbit(cam, target, focal, w / 2f, h / 2f) + return if (firstPerson) { + // F3 accuracy: this is a real device-lens view, so its focal must be the calibrated + // (h/2)/tan(vFov/2) — sharing the FPV vertical FOV — not the min(w,h)*0.9 orbit guess, which + // keyed the scale to the short axis and misplaced off-centre objects. Same convention as the + // Filament FPV camera + aim-marker overlay, so all first-person views agree. + val focal = FpvCameraCalibration.DEFAULT.focalPx(h.toDouble().coerceAtLeast(1.0)).toFloat() + Projector.firstPerson(Vector3D.ZERO, facing ?: Vector3D(0.0, 0.0, -1.0), focal, w / 2f, h / 2f) + } else { + // Orbit (3rd-person) focal is an aesthetic framing choice, not a device lens — leave it. + val focal = minOf(w, h) * 0.9f + Projector.orbit(cam, target, focal, w / 2f, h / 2f) + } } diff --git a/androidApp/src/main/java/com/aether/mofe/ui/scene/MeshSceneScreen.kt b/androidApp/src/main/java/com/aether/mofe/ui/scene/MeshSceneScreen.kt index 78745a1..6d2a5f9 100644 --- a/androidApp/src/main/java/com/aether/mofe/ui/scene/MeshSceneScreen.kt +++ b/androidApp/src/main/java/com/aether/mofe/ui/scene/MeshSceneScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.aether.mofe.engine.netcode.MeshAimTracker +import com.aether.mofe.engine.render.FpvCameraCalibration import com.aether.mofe.model.MeshOperatingMode import com.aether.mofe.model.Vector3D import com.aether.mofe.model.mesh.MeshSnapshot @@ -337,14 +338,16 @@ private fun FpvReticle(modifier: Modifier = Modifier) { /** * First-person per-object aim markers. Each [MeshAimTracker.Track] carries a reticleOffset * (right, up) that is (0,0) — the crosshair — exactly when the phone is aimed at that object, and - * grows off-axis. Map it to the screen with the same centre + focal convention as the FPV - * projector, so a marker slides onto [FpvReticle] precisely when its object is on-aim. onReticle - * objects render filled (locked); the rest as a ring (tracking). Repaints on the frame clock so - * markers follow the ~60 Hz hand motion, not the ~10 Hz solve. + * grows off-axis. [FpvCameraCalibration.offsetToScreen] maps it to a pixel with the SAME vertical + * FOV the Filament FPV camera now projects against, so a marker slides onto [FpvReticle] precisely + * when its object is on-aim AND tracks the object's 3-D sphere off-axis. onReticle objects render + * filled (locked); the rest as a ring (tracking). Repaints on the frame clock so markers follow the + * ~60 Hz hand motion, not the ~10 Hz solve. * - * NOTE (UI): focal = min(w,h)*0.9 matches the hand-rolled FPV projector (Mesh3DCanvas). For - * pixel-exact OFF-centre alignment with the Filament FPV camera, set focal = (h/2)/tan(vFov/2) - * from that camera; the on-aim (0,0) placement is exact regardless. + * F3 accuracy: the focal is now (h/2)/tan(vFov/2) from the shared calibration — not the old + * min(w,h)*0.9 guess, which keyed the scale to the SHORT screen axis and pulled off-centre markers + * away from their objects. The on-aim (0,0) placement was exact regardless; this fixes OFF-centre. + * (Assumes this overlay Canvas spans the FPV viewport, so its height is the camera's vertical span.) */ @Composable private fun AimMarkersOverlay( @@ -356,13 +359,15 @@ private fun AimMarkersOverlay( LaunchedEffect(Unit) { while (true) { withFrameNanos { frame.value = it } } } Canvas(modifier) { frame.value // frame-clock read → redraw each frame - val cx = size.width / 2f - val cy = size.height / 2f - val focal = kotlin.math.min(size.width, size.height) * 0.9f + if (size.height <= 0f) return@Canvas // degenerate (pre-layout) canvas — focalPx needs h > 0 for (t in tracks(coneRadians)) { val off = t.reticleOffset ?: continue // behind the horizon — nothing to place - val x = cx + off.first.toFloat() * focal - val y = cy - off.second.toFloat() * focal // screen-y is down + // Shared FPV projection: same vFov as the Filament camera ⇒ marker + 3-D sphere coincide. + val (sx, sy) = FpvCameraCalibration.DEFAULT.offsetToScreen( + off, size.width.toDouble(), size.height.toDouble(), + ) + val x = sx.toFloat() + val y = sy.toFloat() if (t.onReticle) { drawCircle(AetherColors.Accent, radius = 7.dp.toPx(), center = Offset(x, y)) } else { diff --git a/androidApp/src/main/java/com/aether/mofe/ui/scene/MeshSceneView.kt b/androidApp/src/main/java/com/aether/mofe/ui/scene/MeshSceneView.kt index 68733dd..6b62aeb 100644 --- a/androidApp/src/main/java/com/aether/mofe/ui/scene/MeshSceneView.kt +++ b/androidApp/src/main/java/com/aether/mofe/ui/scene/MeshSceneView.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.dp +import com.aether.mofe.engine.render.FpvCameraCalibration import com.aether.mofe.model.Vector3D import com.aether.mofe.model.mesh.MeshSnapshot import com.aether.mofe.model.mesh.ShapeGeometry @@ -145,6 +146,10 @@ fun MeshSceneView( if (wasFirstPerson.value && !firstPerson) { cameraNode.position = Position(x = 0.0f, y = 2.0f, z = 4.5f) cameraNode.lookTowards(Position(x = 0.0f, y = -2.0f, z = -4.5f), smooth = false) + // Restore SceneView's default lens (28 mm focal) that the FPV projection overrode, so the + // orbit view's framing is exactly as before this change. Setting focalLength re-runs the + // node's updateProjection, and 28 mm is CameraNode's built-in default. + cameraNode.focalLength = 28.0 } wasFirstPerson.value = firstPerson } @@ -187,6 +192,14 @@ fun MeshSceneView( val lens = f.toFilament() cameraNode.position = Position(x = 0.0f, y = 0.0f, z = 0.0f) cameraNode.lookTowards(lens, smooth = false) + // F3 accuracy: pin the Filament vertical FOV to the shared calibration so the 3-D + // nodes and the 2-D overlays (which derive their focal from the SAME vFov) project a + // peer to the same pixel OFF-centre, not just on the reticle. Re-asserted every frame + // on purpose: CameraNode.updateProjection reapplies its default 28 mm lens on any + // viewport resize, which would silently clobber a one-shot FOV; we already drive the + // camera each frame, so re-setting the projection is free and always wins. Uses the + // node's current viewport aspect + near/far (setProjection defaults) with Fov.VERTICAL. + cameraNode.setProjection(fovInDegrees = FpvCameraCalibration.DEFAULT.verticalFovDegrees) // FPV honors grid + predicates, but node-to-node EDGES are forced off (they'd clutter // the lens). The front-cull needs the lens direction as "forward" — the orbit heuristic // (−camera) is degenerate here because the eye sits at the origin. @@ -459,6 +472,10 @@ private fun buildOverlay( SceneOverlay(grid, edges, predicates, selPos?.let { project(it) }) }.getOrDefault(SceneOverlay()) -/** MOFE +Z-up world position → Filament +Y-up scene position. */ -private fun Vector3D.toFilament(): Position = - Position(x = x.toFloat(), y = z.toFloat(), z = -y.toFloat()) +/** MOFE +Z-up world position → Filament +Y-up scene position — the ONE swap defined and unit-tested + * in [FpvCameraCalibration.worldToFilament], kept in lock-step so the render matches the calibration + * the FPV camera + overlays project against. */ +private fun Vector3D.toFilament(): Position { + val f = FpvCameraCalibration.worldToFilament(this) + return Position(x = f.x.toFloat(), y = f.y.toFloat(), z = f.z.toFloat()) +} -- 2.43.0