User Story #40 » 0006-aiming-MeshAimTracker-pan-around-validation-Gap-3.patch
| common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/MeshAimTracker.kt | ||
|---|---|---|
|
package com.aether.mofe.engine.netcode
|
||
|
import com.aether.mofe.model.DeviceId
|
||
|
import com.aether.mofe.model.Timestamp
|
||
|
import com.aether.mofe.model.Vector3D
|
||
|
/**
|
||
|
* Client-side, semi-real-time aim tracker over a shared mesh reality — the *initial*
|
||
|
* goal of the aim work, and the foundation the aim *event* handler builds on.
|
||
|
*
|
||
|
* For an individual client it places every mesh object on the observer's reticle from
|
||
|
* **entity-interpolated** positions ([NetcodeSession.renderPose]): aiming at an object's
|
||
|
* solved location shows it dead-center (reticle offset (0,0)) **by construction**, from
|
||
|
* any range or angle, and the object's ~10 Hz position jitter is dissolved by
|
||
|
* interpolation so the track feels near-real-time and does not stutter. Absolute
|
||
|
* self-heading accuracy is a separate axis — this guarantees the viewer is
|
||
|
* self-consistent: aim at the solved location, see the target there.
|
||
|
*
|
||
|
* This is the *tracker* — continuous, per-client rendering. Detecting that a device is
|
||
|
* aiming at another object and arbitrating that as a discrete claim/verdict is a distinct
|
||
|
* concern that *follows* from this; it does not belong here.
|
||
|
*
|
||
|
* Pure: it reads the interpolation buffers plus the observer's supplied aim basis — no IO,
|
||
|
* no clock. [facing] is the observer's aim ray (body +Y, the engine's forward convention);
|
||
|
* [up] is the viewer's up vector.
|
||
|
*/
|
||
|
class MeshAimTracker(private val session: NetcodeSession) {
|
||
|
data class Track(
|
||
|
val deviceId: DeviceId,
|
||
|
/** Angle between the aim ray and the direction to the object; 0 = dead-center. */
|
||
|
val aimErrorRadians: Double,
|
||
|
/** (right, up) reticle offset normalized to the forward component — (0,0) when
|
||
|
* aimed at the object's solved location; null when it is at/behind the horizon. */
|
||
|
val reticleOffset: Pair<Double, Double>?,
|
||
|
/** Whether the object falls within the reticle cone of [track]/[tracks]. */
|
||
|
val onReticle: Boolean,
|
||
|
)
|
||
|
/** Track a single [target] at [nowMesh]; null until it has interpolable history. */
|
||
|
fun track(
|
||
|
target: DeviceId,
|
||
|
observerPos: Vector3D,
|
||
|
facing: Vector3D,
|
||
|
up: Vector3D,
|
||
|
nowMesh: Timestamp,
|
||
|
coneRadians: Double,
|
||
|
): Track? {
|
||
|
val pose = session.renderPose(target, nowMesh) ?: return null
|
||
|
return placement(target, observerPos, facing, up, pose.position, coneRadians)
|
||
|
}
|
||
|
/** Track every mesh object the session has history for, at [nowMesh]. */
|
||
|
fun tracks(
|
||
|
observerPos: Vector3D,
|
||
|
facing: Vector3D,
|
||
|
up: Vector3D,
|
||
|
nowMesh: Timestamp,
|
||
|
coneRadians: Double,
|
||
|
): List<Track> = session.trackedDevices().mapNotNull { id ->
|
||
|
track(id, observerPos, facing, up, nowMesh, coneRadians)
|
||
|
}
|
||
|
private fun placement(
|
||
|
id: DeviceId,
|
||
|
observerPos: Vector3D,
|
||
|
facing: Vector3D,
|
||
|
up: Vector3D,
|
||
|
targetPos: Vector3D,
|
||
|
coneRadians: Double,
|
||
|
): Track {
|
||
|
val err = AimEvaluator.aimErrorRadians(observerPos, facing, targetPos)
|
||
|
return Track(
|
||
|
deviceId = id,
|
||
|
aimErrorRadians = err,
|
||
|
reticleOffset = AimEvaluator.facingFrameOffset(observerPos, facing, up, targetPos),
|
||
|
onReticle = AimEvaluator.withinCone(err, coneRadians),
|
||
|
)
|
||
|
}
|
||
|
}
|
||
| common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/MeshAimTrackerTest.kt | ||
|---|---|---|
|
package com.aether.mofe.engine.netcode
|
||
|
import com.aether.mofe.model.DeviceId
|
||
|
import com.aether.mofe.model.Quaternion
|
||
|
import com.aether.mofe.model.Timestamp
|
||
|
import com.aether.mofe.model.Vector3D
|
||
|
import kotlin.math.PI
|
||
|
import kotlin.math.abs
|
||
|
import kotlin.math.cos
|
||
|
import kotlin.math.sin
|
||
|
import kotlin.math.tan
|
||
|
import kotlin.test.Test
|
||
|
import kotlin.test.assertEquals
|
||
|
import kotlin.test.assertFalse
|
||
|
import kotlin.test.assertNull
|
||
|
import kotlin.test.assertTrue
|
||
|
/**
|
||
|
* Pan-around validation for [MeshAimTracker] — the roadmap's Phase-0 exit criterion for the
|
||
|
* view. [AimEvaluatorTest] covers the pure aim geometry; this exercises the *tracker's*
|
||
|
* integration of entity-interpolated [NetcodeSession.renderPose] with that geometry:
|
||
|
* - the by-construction dead-center guarantee holds on an *interpolated* pose,
|
||
|
* - a pan sweep crosses the reticle smoothly and monotonically,
|
||
|
* - ~10 Hz position jitter is dissolved into a continuous (non-stutter) track,
|
||
|
* - the tracked pose lags "now" by exactly the quality-derived interpolation delay.
|
||
|
*
|
||
|
* The buffer retains [TemporalStateBuffer.DEFAULT_RETENTION_MICROS] = 1 s, so every scenario
|
||
|
* is kept inside a [0, 0.9 s] window and queried at `now ≤ 0.85 s`; `now − interpDelay` then
|
||
|
* lands strictly between samples (interpolating), never clamped to the retained edge.
|
||
|
*/
|
||
|
class MeshAimTrackerTest {
|
||
|
private val up = Vector3D(0.0, 0.0, 1.0) // world up; facing is kept in XY so the basis is non-degenerate
|
||
|
private val deg = PI / 180.0
|
||
|
private val base = 100_000L // interp-delay base; an unsynced session sits at POOR ⇒ 2.5× this
|
||
|
/** Record device [id] moving linearly (p0 + vel·t) over [t0,t1] at [stepMicros] cadence. */
|
||
|
private fun NetcodeSession.recordLinear(
|
||
|
id: String, p0: Vector3D, vel: Vector3D, t0: Long, t1: Long, stepMicros: Long,
|
||
|
) {
|
||
|
var t = t0
|
||
|
while (t <= t1) {
|
||
|
val p = p0 + vel * ((t - t0) / 1_000_000.0)
|
||
|
recordSample(DeviceId(id), Timestamp(t), TemporalPose(p, vel, Quaternion.IDENTITY))
|
||
|
t += stepMicros
|
||
|
}
|
||
|
}
|
||
|
@Test
|
||
|
fun trackIsNullForADeviceWithNoHistory() {
|
||
|
val tracker = MeshAimTracker(NetcodeSession())
|
||
|
assertNull(
|
||
|
tracker.track(DeviceId("ghost"), Vector3D.ZERO, Vector3D(1.0, 0.0, 0.0), up, Timestamp(1_000), 0.1),
|
||
|
"a device the session has never seen has no interpolable pose",
|
||
|
)
|
||
|
}
|
||
|
@Test
|
||
|
fun deadCenterWhenAimedAtTheInterpolatedPosition() {
|
||
|
val s = NetcodeSession()
|
||
|
// A target gliding along +Y at 2 m/s, ~10 m out along +X; `now − delay` lands off a sample
|
||
|
// boundary so renderPose must interpolate, not echo a raw sample.
|
||
|
s.recordLinear("t", Vector3D(10.0, -1.0, 0.0), Vector3D(0.0, 2.0, 0.0), 0, 900_000, 100_000)
|
||
|
val tracker = MeshAimTracker(s)
|
||
|
val now = Timestamp(630_000) // now − 250 ms delay = 380 ms, strictly between 100 ms samples
|
||
|
val observer = Vector3D.ZERO
|
||
|
val interp = s.renderPose(DeviceId("t"), now)!!.position // the entity-interpolated location
|
||
|
val track = tracker.track(DeviceId("t"), observer, interp - observer, up, now, coneRadians = 0.01)!!
|
||
|
assertTrue(track.aimErrorRadians < 1e-6, "aim error ~0 when aimed at the interpolated location")
|
||
|
val (right, upOff) = track.reticleOffset!!
|
||
|
assertEquals(0.0, right, 1e-6, "dead-center (right) by construction")
|
||
|
assertEquals(0.0, upOff, 1e-6, "dead-center (up) by construction")
|
||
|
assertTrue(track.onReticle)
|
||
|
}
|
||
|
@Test
|
||
|
fun panSweepCrossesTheReticleSmoothlyAndMonotonically() {
|
||
|
val s = NetcodeSession()
|
||
|
s.recordLinear("t", Vector3D(10.0, 0.0, 0.0), Vector3D.ZERO, 0, 900_000, 100_000)
|
||
|
val tracker = MeshAimTracker(s)
|
||
|
val now = Timestamp(650_000)
|
||
|
val observer = Vector3D.ZERO
|
||
|
val cone = 5.0 * deg
|
||
|
var prevRight = Double.NEGATIVE_INFINITY
|
||
|
for (i in -20..20) {
|
||
|
val theta = i * 2.0 * deg // pan −40°..+40° about world up
|
||
|
val facing = Vector3D(cos(theta), sin(theta), 0.0)
|
||
|
val tr = tracker.track(DeviceId("t"), observer, facing, up, now, cone)!!
|
||
|
assertEquals(abs(theta), tr.aimErrorRadians, 1e-6, "aim error equals the pan magnitude")
|
||
|
val (right, upOff) = tr.reticleOffset!!
|
||
|
assertEquals(tan(theta), right, 1e-6, "reticle offset (right) = tan(pan angle)")
|
||
|
assertEquals(0.0, upOff, 1e-6, "pan about up leaves the up-offset at 0")
|
||
|
assertTrue(right > prevRight, "reticle offset sweeps monotonically through 0")
|
||
|
prevRight = right
|
||
|
assertEquals(abs(theta) <= cone, tr.onReticle, "onReticle gates on the 5° cone")
|
||
|
}
|
||
|
}
|
||
|
@Test
|
||
|
fun tenHzJitterIsDissolvedIntoAContinuousTrack() {
|
||
|
val s = NetcodeSession()
|
||
|
// A target ~10 m out drifting +Y at 1 m/s, sampled at 10 Hz with ±5 cm deterministic jitter.
|
||
|
var t = 0L
|
||
|
while (t <= 900_000) {
|
||
|
val settled = Vector3D(10.0, 1.0 * (t / 1_000_000.0), 0.0)
|
||
|
val jitter = Vector3D(0.0, 0.05 * sin(t / 60_000.0), 0.03 * cos(t / 71_000.0))
|
||
|
s.recordSample(DeviceId("t"), Timestamp(t), TemporalPose(settled + jitter, Vector3D(0.0, 1.0, 0.0), Quaternion.IDENTITY))
|
||
|
t += 100_000 // 10 Hz
|
||
|
}
|
||
|
val tracker = MeshAimTracker(s)
|
||
|
val observer = Vector3D.ZERO
|
||
|
val facing = Vector3D(1.0, 0.0, 0.0)
|
||
|
// Advance "now" in fine 2 ms steps (interp delay is constant, so `now − delay` sweeps
|
||
|
// between the same 10 Hz samples). A tracker that snapped to the nearest raw sample would
|
||
|
// jump ~atan(0.1/10) ≈ 1e-2 rad at each 100 ms boundary; linear interpolation stays far below.
|
||
|
var prev: Double? = null
|
||
|
var maxStep = 0.0
|
||
|
var now = 600_000L
|
||
|
while (now <= 750_000) {
|
||
|
val err = tracker.track(DeviceId("t"), observer, facing, up, Timestamp(now), 0.1)!!.aimErrorRadians
|
||
|
prev?.let { maxStep = maxOf(maxStep, abs(err - it)) }
|
||
|
prev = err
|
||
|
now += 2_000
|
||
|
}
|
||
|
assertTrue(maxStep < 2e-3, "tracked bearing is continuous — largest 2 ms step was $maxStep rad (no stutter)")
|
||
|
}
|
||
|
@Test
|
||
|
fun tracksEveryDeviceAndGatesEachOnItsCone() {
|
||
|
val s = NetcodeSession()
|
||
|
s.recordLinear("ahead", Vector3D(10.0, 0.0, 0.0), Vector3D.ZERO, 0, 900_000, 100_000) // on-axis
|
||
|
s.recordLinear("beside", Vector3D(0.0, 10.0, 0.0), Vector3D.ZERO, 0, 900_000, 100_000) // 90° off
|
||
|
val tracker = MeshAimTracker(s)
|
||
|
val tracks = tracker.tracks(Vector3D.ZERO, Vector3D(1.0, 0.0, 0.0), up, Timestamp(650_000), coneRadians = 10.0 * deg)
|
||
|
assertEquals(setOf(DeviceId("ahead"), DeviceId("beside")), tracks.map { it.deviceId }.toSet())
|
||
|
assertTrue(tracks.first { it.deviceId == DeviceId("ahead") }.onReticle, "on-axis target is on the reticle")
|
||
|
assertFalse(tracks.first { it.deviceId == DeviceId("beside") }.onReticle, "90°-off target is not")
|
||
|
}
|
||
|
@Test
|
||
|
fun trackedPoseLagsNowByExactlyTheInterpDelay() {
|
||
|
val s = NetcodeSession(baseInterpDelayMicros = base)
|
||
|
val vel = Vector3D(0.0, 4.0, 0.0) // 4 m/s so the lag shows up as a boundable position offset
|
||
|
s.recordLinear("t", Vector3D(10.0, 0.0, 0.0), vel, 0, 900_000, 50_000)
|
||
|
val tracker = MeshAimTracker(s)
|
||
|
val now = Timestamp(650_000)
|
||
|
// Derive the delay from the session's OWN quality tier + base — no hardcoded multiplier.
|
||
|
val delayMicros = MeshQuality.interpDelayMicros(s.quality(now), base)
|
||
|
assertTrue(delayMicros > 0, "an unsynced session renders in the past (POOR tier widens the delay)")
|
||
|
val tracked = s.renderPose(DeviceId("t"), now)!!.position
|
||
|
val truthNow = Vector3D(10.0, 4.0 * (now.microseconds / 1_000_000.0), 0.0) // where it really is at `now`
|
||
|
val lagMeters = (truthNow - tracked).magnitude
|
||
|
val expectedLag = vel.magnitude * (delayMicros / 1_000_000.0)
|
||
|
assertEquals(expectedLag, lagMeters, 1e-6, "tracked pose lags now by exactly the interp delay")
|
||
|
}
|
||
|
}
|
||