User Story #44 » 0007-feat-ui-wire-MeshAimTracker-into-the-FPV-scene-per-o.patch
| androidApp/src/main/java/com/aether/mofe/platform/AppSettingsStore.kt | ||
|---|---|---|
|
prefs.edit { putBoolean("scene_show_legend", on) }
|
||
|
}
|
||
|
// First-person per-object aim markers (OFF by default — opt-in overlay, FPV only).
|
||
|
private val _showAimTracks = MutableStateFlow(prefs.getBoolean("scene_show_aim_tracks", false))
|
||
|
val showAimTracks: StateFlow<Boolean> = _showAimTracks.asStateFlow()
|
||
|
fun setShowAimTracks(on: Boolean) {
|
||
|
_showAimTracks.value = on
|
||
|
prefs.edit { putBoolean("scene_show_aim_tracks", on) }
|
||
|
}
|
||
|
// ── Startup behavior (persisted; survive app restart / update push) ──────────
|
||
|
// Volatile session state — the selected mesh and whether it was uplinking — is
|
||
|
// restored on next launch per these toggles, so the user doesn't re-select and
|
||
| androidApp/src/main/java/com/aether/mofe/ui/AppShell.kt | ||
|---|---|---|
|
val showNodes by app.appSettingsStore.showNodes.collectAsState()
|
||
|
val showPredicates by app.appSettingsStore.showPredicates.collectAsState()
|
||
|
val showLegend by app.appSettingsStore.showLegend.collectAsState()
|
||
|
val showAimTracks by app.appSettingsStore.showAimTracks.collectAsState()
|
||
|
val view = LocalView.current
|
||
|
DisposableEffect(keepScreenOn) {
|
||
|
view.keepScreenOn = keepScreenOn
|
||
| ... | ... | |
|
nodes = showNodes,
|
||
|
predicates = showPredicates,
|
||
|
legend = showLegend,
|
||
|
aimTracks = showAimTracks,
|
||
|
),
|
||
|
onToggleLayer = { layer, on ->
|
||
|
when (layer) {
|
||
| ... | ... | |
|
SceneLayer.Nodes -> app.appSettingsStore.setShowNodes(on)
|
||
|
SceneLayer.Predicates -> app.appSettingsStore.setShowPredicates(on)
|
||
|
SceneLayer.Legend -> app.appSettingsStore.setShowLegend(on)
|
||
|
SceneLayer.AimTracks -> app.appSettingsStore.setShowAimTracks(on)
|
||
|
}
|
||
|
},
|
||
|
facing = { hud.latestFacing() },
|
||
|
tracks = { cone -> hud.aimTracks(cone) },
|
||
|
modifier = Modifier.fillMaxSize(),
|
||
|
)
|
||
|
}
|
||
| androidApp/src/main/java/com/aether/mofe/ui/scene/MeshSceneScreen.kt | ||
|---|---|---|
|
import androidx.compose.material3.Switch
|
||
|
import androidx.compose.material3.Text
|
||
|
import androidx.compose.runtime.Composable
|
||
|
import androidx.compose.runtime.LaunchedEffect
|
||
|
import androidx.compose.runtime.getValue
|
||
|
import androidx.compose.runtime.mutableStateOf
|
||
|
import androidx.compose.runtime.remember
|
||
|
import androidx.compose.runtime.setValue
|
||
|
import androidx.compose.runtime.withFrameNanos
|
||
|
import androidx.compose.ui.Alignment
|
||
|
import androidx.compose.ui.Modifier
|
||
|
import androidx.compose.ui.draw.clip
|
||
| ... | ... | |
|
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.model.MeshOperatingMode
|
||
|
import com.aether.mofe.model.Vector3D
|
||
|
import com.aether.mofe.model.mesh.MeshSnapshot
|
||
| ... | ... | |
|
import com.aether.mofe.ui.theme.AetherShapes
|
||
|
/** The toggleable scene layers, surfaced in the in-scene Layers control (bottom-right). */
|
||
|
enum class SceneLayer { Grid, Connections, Nodes, Predicates, Legend }
|
||
|
enum class SceneLayer { Grid, Connections, Nodes, Predicates, Legend, AimTracks }
|
||
|
/** On/off state per [SceneLayer]; defaults mirror AppSettingsStore's scene prefs. */
|
||
|
data class SceneLayers(
|
||
| ... | ... | |
|
val nodes: Boolean = true,
|
||
|
val predicates: Boolean = true,
|
||
|
val legend: Boolean = true,
|
||
|
val aimTracks: Boolean = false,
|
||
|
)
|
||
|
/**
|
||
| ... | ... | |
|
layers: SceneLayers = SceneLayers(),
|
||
|
onToggleLayer: (SceneLayer, Boolean) -> Unit = { _, _ -> },
|
||
|
facing: () -> Vector3D? = { null },
|
||
|
tracks: (Double) -> List<MeshAimTracker.Track> = { emptyList() },
|
||
|
modifier: Modifier = Modifier,
|
||
|
) {
|
||
|
var selectedId by remember { mutableStateOf<String?>(null) }
|
||
| ... | ... | |
|
modifier = Modifier.fillMaxSize(),
|
||
|
)
|
||
|
// First-person per-object aim markers (opt-in layer): every mesh object placed on the
|
||
|
// reticle from its interpolated pose, so the aim reads at a glance. Drawn under the reticle
|
||
|
// so the crosshair stays crisp; (0,0) == on-aim, coinciding with it by construction.
|
||
|
if (firstPerson && layers.aimTracks) {
|
||
|
AimMarkersOverlay(
|
||
|
tracks = tracks,
|
||
|
coneRadians = 3.0 * kotlin.math.PI / 180.0,
|
||
|
modifier = Modifier.fillMaxSize(),
|
||
|
)
|
||
|
}
|
||
|
// First-person aim reticle — a small centred crosshair, so the phone's pointing direction
|
||
|
// reads at a glance. Kept tiny so it never blocks scene gestures elsewhere.
|
||
|
if (firstPerson) {
|
||
| ... | ... | |
|
LayerRow(Icons.Filled.ScatterPlot, "Nodes", layers.nodes) { onToggle(SceneLayer.Nodes, it) }
|
||
|
LayerRow(Icons.Filled.Category, "Predicates", layers.predicates) { onToggle(SceneLayer.Predicates, it) }
|
||
|
LayerRow(Icons.Filled.Label, "Legend", layers.legend) { onToggle(SceneLayer.Legend, it) }
|
||
|
LayerRow(Icons.Filled.CenterFocusStrong, "Aim tracks", layers.aimTracks) { onToggle(SceneLayer.AimTracks, it) }
|
||
|
}
|
||
|
}
|
||
| ... | ... | |
|
drawCircle(color = AetherColors.Accent, radius = w, center = Offset(cx, cy))
|
||
|
}
|
||
|
}
|
||
|
/**
|
||
|
* 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.
|
||
|
*
|
||
|
* 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.
|
||
|
*/
|
||
|
@Composable
|
||
|
private fun AimMarkersOverlay(
|
||
|
tracks: (Double) -> List<MeshAimTracker.Track>,
|
||
|
coneRadians: Double,
|
||
|
modifier: Modifier = Modifier,
|
||
|
) {
|
||
|
val frame = remember { mutableStateOf(0L) }
|
||
|
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
|
||
|
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
|
||
|
if (t.onReticle) {
|
||
|
drawCircle(AetherColors.Accent, radius = 7.dp.toPx(), center = Offset(x, y))
|
||
|
} else {
|
||
|
drawCircle(
|
||
|
AetherColors.Accent.copy(alpha = 0.55f), radius = 5.dp.toPx(),
|
||
|
center = Offset(x, y), style = Stroke(width = 2.dp.toPx()),
|
||
|
)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
| androidApp/src/main/java/com/aether/mofe/viewmodel/HudViewModel.kt | ||
|---|---|---|
|
import androidx.lifecycle.viewModelScope
|
||
|
import com.aether.mofe.AetherApp
|
||
|
import com.aether.mofe.data.*
|
||
|
import com.aether.mofe.engine.netcode.MeshAimTracker
|
||
|
import com.aether.mofe.engine.netcode.NetcodeSession
|
||
|
import com.aether.mofe.engine.netcode.toTemporalPose
|
||
|
import com.aether.mofe.model.DeviceId
|
||
|
import com.aether.mofe.model.Timestamp
|
||
|
import com.aether.mofe.model.Vector3D
|
||
|
import com.aether.mofe.model.mesh.ImuSample
|
||
|
import com.aether.mofe.model.mesh.MeshPredicate
|
||
| ... | ... | |
|
private val _mesh = MutableStateFlow(MeshSnapshot())
|
||
|
val mesh: StateFlow<MeshSnapshot> = _mesh.asStateFlow()
|
||
|
// ── Client-local aim tracker ──────────────────────────────────────────────
|
||
|
// A per-client NetcodeSession fed from the engine's own fused world poses (no peer transport —
|
||
|
// that is the separate mesh-netcode go-live). MeshAimTracker then places every mesh object on
|
||
|
// the reticle from entity-interpolated poses, so the aim reads self-consistent regardless of
|
||
|
// absolute-heading accuracy. Local single-observer ⇒ the mesh clock is identity, so each
|
||
|
// FusedState's own timestamp is its mesh time.
|
||
|
private val netcode = NetcodeSession(baseInterpDelayMicros = 60_000L)
|
||
|
private val aim = MeshAimTracker(netcode)
|
||
|
@Volatile private var latestMeshMicros: Long = 0L
|
||
|
init {
|
||
|
viewModelScope.launch {
|
||
|
combine(repo.snapshot, repo.sharedState) { snapshot, shared ->
|
||
| ... | ... | |
|
_recentEvents.update { (listOf(PredicateEventLog(e.timestampMs, e.message)) + it).take(30) }
|
||
|
}
|
||
|
}
|
||
|
// Feed the aim tracker from the engine's fused world poses (~10 Hz solve).
|
||
|
viewModelScope.launch {
|
||
|
app.mofeEngineHost.fusedStates.collect { states ->
|
||
|
for ((id, state) in states) {
|
||
|
netcode.recordSample(DeviceId(id), state.timestamp, state.toTemporalPose())
|
||
|
val us = state.timestamp.microseconds
|
||
|
if (us > latestMeshMicros) latestMeshMicros = us
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
startImuUpdates()
|
||
|
}
|
||
| ... | ... | |
|
return if (m > 1e-6f) Vector3D(fx / m, fy / m, fz / m) else Vector3D(0.0, 1.0, 0.0)
|
||
|
}
|
||
|
/**
|
||
|
* Per-object aim tracks for the first-person reticle: every mesh object placed on the
|
||
|
* observer's facing frame from its entity-interpolated pose ([MeshAimTracker]). Pull-based,
|
||
|
* mirroring [latestFacing]; empty until a self pose + facing exist. Observer position is the
|
||
|
* engine's WORLD-frame self pose (the same frame as the recorded fused poses), so a track's
|
||
|
* (0,0) reticle offset coincides with the crosshair by construction. [coneRadians] is the
|
||
|
* on-reticle "locked" half-angle. Queried at the freshest fused mesh time, so renderPose
|
||
|
* interpolates one interp-delay behind the latest solve.
|
||
|
*/
|
||
|
fun aimTracks(coneRadians: Double): List<MeshAimTracker.Track> {
|
||
|
val observer = app.mofeEngineHost.latestSelfPose()?.position ?: return emptyList()
|
||
|
val facing = latestFacing() ?: return emptyList()
|
||
|
val up = latestTopEdge() ?: Vector3D(0.0, 1.0, 0.0)
|
||
|
return aim.tracks(observer, facing, up, Timestamp(latestMeshMicros), coneRadians)
|
||
|
}
|
||
|
/**
|
||
|
* Synthetic target points to render in the 3D viewport, expressed relative to self
|
||
|
* (the snapshot frame, self at origin) so the viewport can draw a device→target aim
|
||