User Story #61 » 0006-feat-ui-render-the-3-D-scene-nodes-from-interpolated.patch
| androidApp/src/main/java/com/aether/mofe/ui/AppShell.kt | ||
|---|---|---|
|
},
|
||
|
facing = { hud.latestFacing() },
|
||
|
tracks = { cone -> hud.aimTracks(cone) },
|
||
|
renderPositions = { hud.renderNodePositions() },
|
||
|
modifier = Modifier.fillMaxSize(),
|
||
|
)
|
||
|
}
|
||
| androidApp/src/main/java/com/aether/mofe/ui/scene/MeshSceneScreen.kt | ||
|---|---|---|
|
onToggleLayer: (SceneLayer, Boolean) -> Unit = { _, _ -> },
|
||
|
facing: () -> Vector3D? = { null },
|
||
|
tracks: (Double) -> List<MeshAimTracker.Track> = { emptyList() },
|
||
|
renderPositions: () -> Map<String, Vector3D> = { emptyMap() },
|
||
|
modifier: Modifier = Modifier,
|
||
|
) {
|
||
|
var selectedId by remember { mutableStateOf<String?>(null) }
|
||
| ... | ... | |
|
showPredicates = layers.predicates,
|
||
|
firstPerson = firstPerson,
|
||
|
facing = facing,
|
||
|
renderPositions = renderPositions,
|
||
|
recenterSignal = recenterSignal,
|
||
|
modifier = Modifier.fillMaxSize(),
|
||
|
)
|
||
| androidApp/src/main/java/com/aether/mofe/ui/scene/MeshSceneView.kt | ||
|---|---|---|
|
import androidx.compose.foundation.layout.Box
|
||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||
|
import androidx.compose.runtime.Composable
|
||
|
import androidx.compose.runtime.DisposableEffect
|
||
|
import androidx.compose.runtime.LaunchedEffect
|
||
|
import androidx.compose.runtime.key
|
||
|
import androidx.compose.runtime.mutableStateOf
|
||
| ... | ... | |
|
import io.github.sceneview.gesture.CameraGestureDetector
|
||
|
import io.github.sceneview.math.Position
|
||
|
import io.github.sceneview.node.CameraNode
|
||
|
import io.github.sceneview.node.Node
|
||
|
import io.github.sceneview.rememberCameraManipulator
|
||
|
import io.github.sceneview.rememberCameraNode
|
||
|
import io.github.sceneview.rememberEngine
|
||
| ... | ... | |
|
* material instances are native resources, and churning them per frame was crashing the render
|
||
|
* thread under sustained pinch/rotate.
|
||
|
*
|
||
|
* Smooth positions (F1): the node WORLD positions no longer come from the raw ~10 Hz [snapshot] (which
|
||
|
* jitters/jumps). Each SphereNode captures its own reference in `apply`, and the frame callback slews
|
||
|
* those retained nodes to the entity-interpolated [renderPositions] every frame — the same imperative,
|
||
|
* no-recomposition path the camera uses, so smoothing costs nothing extra on the render thread. The
|
||
|
* [snapshot] still supplies the roster, roles and predicate geometry; only the device POSITIONS are
|
||
|
* interpolated. With no session feeding [renderPositions] the map is empty and the nodes keep their
|
||
|
* raw snapshot positions — identical to the prior behaviour.
|
||
|
*
|
||
|
* The snapshot keeps only SELF in `anchors`; every other device — including the constellation
|
||
|
* anchors — arrives in `meshPoints`, coloured as an ANCHOR when its id is in [anchorIds]. A peer
|
||
|
* that shares an id already drawn in the anchors pass is skipped, so self can never double-draw as
|
||
| ... | ... | |
|
showPredicates: Boolean = true,
|
||
|
firstPerson: Boolean = false,
|
||
|
facing: () -> Vector3D? = { null },
|
||
|
renderPositions: () -> Map<String, Vector3D> = { emptyMap() },
|
||
|
recenterSignal: Int = 0,
|
||
|
modifier: Modifier = Modifier,
|
||
|
) {
|
||
| ... | ... | |
|
// scene. Empty in first-person.
|
||
|
val overlay = remember { mutableStateOf(SceneOverlay()) }
|
||
|
// One combined frame callback. First-person seats the eye at self and aims down the lens vector;
|
||
|
// orbit projects the edges + floor grid to screen space for the 2D overlay (zero scene geometry).
|
||
|
// Retained scene-node references, keyed by device id, captured as each SphereNode is created (see
|
||
|
// the `apply` blocks below). The frame callback pushes the interpolated [renderPositions] onto
|
||
|
// them IMPERATIVELY — exactly like the camera is driven — so the nodes track the smooth mesh at
|
||
|
// frame rate WITHOUT recomposing the scene per frame (which churned native resources; see the
|
||
|
// class note). Absent a session the map stays unused and nodes keep their raw-snapshot positions.
|
||
|
val liveNodes = remember { mutableMapOf<String, Node>() }
|
||
|
// One combined frame callback. First it slews the retained nodes to their entity-interpolated
|
||
|
// positions (F1: kills the raw ~10 Hz jitter). First-person then seats the eye at self and aims
|
||
|
// down the lens vector; orbit projects the edges + floor grid to screen space for the 2D overlay.
|
||
|
val onFrame: (Long) -> Unit = { _ ->
|
||
|
// Interpolated, self-relative device positions for THIS frame, shared by the nodes and the
|
||
|
// overlay so edges/selection stay glued to where the smooth nodes actually are.
|
||
|
val live = renderPositions()
|
||
|
if (live.isNotEmpty()) {
|
||
|
liveNodes.forEach { (id, node) -> live[id]?.let { node.position = it.toFilament() } }
|
||
|
}
|
||
|
if (firstPerson) {
|
||
|
val f = facing()
|
||
|
if (f != null) {
|
||
| ... | ... | |
|
// 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.
|
||
|
overlay.value = buildOverlay(cameraNode, snapshot, anchorIds, showGrid,
|
||
|
overlay.value = buildOverlay(cameraNode, snapshot, live, anchorIds, showGrid,
|
||
|
showEdges = false, showPredicates, forward = lens, dropGridBelowEye = true,
|
||
|
selectedId = selectedId)
|
||
|
} else {
|
||
|
overlay.value = SceneOverlay()
|
||
|
}
|
||
|
} else {
|
||
|
overlay.value = buildOverlay(cameraNode, snapshot, anchorIds, showGrid, showEdges, showPredicates,
|
||
|
overlay.value = buildOverlay(cameraNode, snapshot, live, anchorIds, showGrid, showEdges, showPredicates,
|
||
|
selectedId = selectedId)
|
||
|
}
|
||
|
}
|
||
| ... | ... | |
|
) {
|
||
|
// NODES layer — the constellation spheres (self + peers), gated by the Layers control.
|
||
|
if (showNodes) {
|
||
|
// Interpolated positions to SEED each node at composition (~10 Hz), so a recomposition never
|
||
|
// resets it to the raw jittery spot between the 60 Hz onFrame slews. Empty ⇒ raw snapshot,
|
||
|
// i.e. exactly the prior behaviour when no session feeds [renderPositions].
|
||
|
val seed = renderPositions()
|
||
|
// SELF is the only entry in `anchors`; render it in the self colour (pink) — but never in
|
||
|
// first-person, where the eye sits inside self and the sphere would fill the lens.
|
||
|
snapshot.anchors.forEach { anchor ->
|
||
| ... | ... | |
|
// Constant radius — selection is shown as a 2D overlay ring instead. Resizing
|
||
|
// the sphere on select/deselect rebuilt its geometry and flashed it flat.
|
||
|
radius = 0.13f,
|
||
|
position = anchor.spatial.position.toFilament(),
|
||
|
position = (seed[anchor.id] ?: anchor.spatial.position).toFilament(),
|
||
|
materialInstance = material,
|
||
|
apply = {
|
||
|
name = anchor.id
|
||
|
// Retain the ref so onFrame can slew it to the interpolated position.
|
||
|
liveNodes[anchor.id] = this
|
||
|
onSingleTapConfirmed = { onNodeSelected(anchor.id); true }
|
||
|
},
|
||
|
)
|
||
|
// Drop the retained ref when this node leaves composition (peer left, or self on
|
||
|
// the FPV switch) so onFrame never slews a destroyed Filament node.
|
||
|
DisposableEffect(anchor.id) { onDispose { liveNodes.remove(anchor.id) } }
|
||
|
}
|
||
|
}
|
||
|
}
|
||
| ... | ... | |
|
val base = if (role == NodeRole.Anchor) 0.12f else 0.09f
|
||
|
SphereNode(
|
||
|
radius = base,
|
||
|
position = point.spatial.position.toFilament(),
|
||
|
position = (seed[point.id] ?: point.spatial.position).toFilament(),
|
||
|
materialInstance = material,
|
||
|
apply = {
|
||
|
name = point.id
|
||
|
liveNodes[point.id] = this
|
||
|
onSingleTapConfirmed = { onNodeSelected(point.id); true }
|
||
|
},
|
||
|
)
|
||
|
DisposableEffect(point.id) { onDispose { liveNodes.remove(point.id) } }
|
||
|
}
|
||
|
}
|
||
|
}
|
||
| ... | ... | |
|
private fun buildOverlay(
|
||
|
cameraNode: CameraNode,
|
||
|
snapshot: MeshSnapshot,
|
||
|
live: Map<String, Vector3D>,
|
||
|
anchorIds: Set<String>,
|
||
|
showGrid: Boolean,
|
||
|
showEdges: Boolean,
|
||
| ... | ... | |
|
val s = cameraNode.worldToScreenPoint(Vector3(p.x, p.y, p.z))
|
||
|
return Offset(s.x / vw, s.y / vh)
|
||
|
}
|
||
|
// A device's Filament position, preferring its interpolated spot so the edges + selection ring
|
||
|
// stay glued to where the smooth nodes actually are; falls back to the raw snapshot position.
|
||
|
fun devPos(id: String, fallback: Vector3D): Position = (live[id] ?: fallback).toFilament()
|
||
|
// Grid (toggleable): a square that reaches just past the farthest node, on the y = 0 plane.
|
||
|
val grid = ArrayList<Pair<Offset, Offset>>()
|
||
| ... | ... | |
|
}
|
||
|
}
|
||
|
// Edges (toggleable): self → each anchor.
|
||
|
// Edges (toggleable): self → each anchor. Both endpoints prefer the interpolated node position.
|
||
|
val edges = ArrayList<Pair<Offset, Offset>>()
|
||
|
if (showEdges) {
|
||
|
val selfPos = snapshot.anchors.firstOrNull { it.isLocalDevice }?.spatial?.position?.toFilament()
|
||
|
val selfScreen = selfPos?.let { project(it) }
|
||
|
val self = snapshot.anchors.firstOrNull { it.isLocalDevice }
|
||
|
val selfScreen = self?.let { project(devPos(it.id, it.spatial.position)) }
|
||
|
if (selfScreen != null) {
|
||
|
snapshot.meshPoints.forEach { point ->
|
||
|
if (point.id in anchorIds && (point.positionSolved || point.bearingKnown)) {
|
||
|
project(point.spatial.position.toFilament())?.let { edges.add(selfScreen to it) }
|
||
|
project(devPos(point.id, point.spatial.position))?.let { edges.add(selfScreen to it) }
|
||
|
}
|
||
|
}
|
||
|
}
|
||
| ... | ... | |
|
}
|
||
|
}
|
||
|
// Selected node → a screen-space ring drawn in the Canvas (instead of resizing the sphere).
|
||
|
// Prefer the interpolated position so the ring tracks the smooth node, not the raw snapshot spot.
|
||
|
val selPos = selectedId?.let { id ->
|
||
|
(snapshot.anchors.firstOrNull { it.id == id }?.spatial?.position
|
||
|
?: snapshot.meshPoints.firstOrNull { it.id == id }?.spatial?.position)?.toFilament()
|
||
|
val fallback = snapshot.anchors.firstOrNull { it.id == id }?.spatial?.position
|
||
|
?: snapshot.meshPoints.firstOrNull { it.id == id }?.spatial?.position
|
||
|
fallback?.let { devPos(id, it) }
|
||
|
}
|
||
|
SceneOverlay(grid, edges, predicates, selPos?.let { project(it) })
|
||
|
}.getOrDefault(SceneOverlay())
|
||
| androidApp/src/main/java/com/aether/mofe/viewmodel/HudViewModel.kt | ||
|---|---|---|
|
return aim.tracks(observer, facing, up, Timestamp(latestMeshMicros), coneRadians)
|
||
|
}
|
||
|
/**
|
||
|
* Entity-interpolated, self-relative render POSITIONS for the 3-D scene NODES — the smooth
|
||
|
* source that replaces the raw ~10 Hz snapshot positions the scene draws today (F1). Same
|
||
|
* client-local [netcode] session and interp delay as [aimTracks], so the nodes and the aim
|
||
|
* markers agree. [NetcodeSession.renderPositions] are WORLD-frame; the scene is self-relative
|
||
|
* (self at the snapshot origin), so subtract the live self world position — the identical pure
|
||
|
* translation [syntheticTargets] uses (no rotation: the snapshot frame is world-translated, not
|
||
|
* world-rotated). Pull-based like [latestFacing]/[aimTracks] and read on the same (main) thread,
|
||
|
* so it shares their session access safely; empty until a self pose exists. Keyed by device-id
|
||
|
* string, the identity the scene nodes carry.
|
||
|
*/
|
||
|
fun renderNodePositions(): Map<String, Vector3D> {
|
||
|
val selfWorld = app.mofeEngineHost.latestSelfPose()?.position ?: return emptyMap()
|
||
|
return netcode.renderPositions(Timestamp(latestMeshMicros))
|
||
|
.entries.associate { (id, p) -> id.value to (p - selfWorld) }
|
||
|
}
|
||
|
/**
|
||
|
* 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
|
||
- « Previous
- 1
- 2
- Next »