Project

General

Profile

User Story #63 » 0010-feat-ui-F3-accuracy-route-the-FPV-camera-overlays-th.patch

knight8241, 08/07/2026 18:32

View differences:

androidApp/src/main/java/com/aether/mofe/ui/components/Mesh3DCanvas.kt
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
......
}
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)
}
}
androidApp/src/main/java/com/aether/mofe/ui/scene/MeshSceneScreen.kt
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
......
/**
* 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(
......
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 {
androidApp/src/main/java/com/aether/mofe/ui/scene/MeshSceneView.kt
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
......
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
}
......
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.
......
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-2/2)