User Story #63 » 0010-feat-ui-F3-accuracy-route-the-FPV-camera-overlays-th.patch
| 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())
|
||
|
}
|
||
- « Previous
- 1
- 2
- Next »