From c2d998608c1831a3602fb29b0f314c00fd924202 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 17:09:14 +0000 Subject: [PATCH] aiming: MeshAimTracker pan-around validation (Gap 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MeshAimTracker is the semi-real-time client aim tracker: it places every mesh object on the observer's reticle from entity-interpolated renderPose, so aiming at an object's solved location shows it dead-center by construction, decoupled from absolute self-heading. Add the pan-around validation the roadmap names as the Phase-0 exit for the view (AimEvaluatorTest covers only the pure geometry): - dead-center holds on an INTERPOLATED pose (now - delay lands between samples), - a ±40° pan sweep crosses the reticle monotonically (offset = tan(pan angle)), - 10 Hz position jitter is dissolved into a continuous track (bounded 2 ms step, no snap-to-sample stutter), - the tracked pose lags now by exactly the quality-derived interp delay. Scenarios stay inside the buffer's 1 s retention window so renderPose interpolates rather than clamping to the retained edge. Verified: :common:jvmTest green (MeshAimTrackerTest 6/6, module 0 failures). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../mofe/engine/netcode/MeshAimTracker.kt | 80 +++++++++ .../mofe/engine/netcode/MeshAimTrackerTest.kt | 166 ++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/MeshAimTracker.kt create mode 100644 common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/MeshAimTrackerTest.kt diff --git a/common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/MeshAimTracker.kt b/common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/MeshAimTracker.kt new file mode 100644 index 0000000..eebab6a --- /dev/null +++ b/common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/MeshAimTracker.kt @@ -0,0 +1,80 @@ +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?, + /** 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 = 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), + ) + } +} diff --git a/common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/MeshAimTrackerTest.kt b/common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/MeshAimTrackerTest.kt new file mode 100644 index 0000000..71f1ff1 --- /dev/null +++ b/common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/MeshAimTrackerTest.kt @@ -0,0 +1,166 @@ +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") + } +} -- 2.43.0