From e9c401785242f014c4e6f58a30c321541f299382 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 16:46:11 +0000 Subject: [PATCH] feat(netcode): carry peer orientation through the fused wire sample (Gap 1) Remote peers rendered with identity facing: NetcodeAdapter built every inbound TemporalPose as Quaternion.IDENTITY because WireFusedSample carried no orientation. Only the local device's FusedState.toTemporalPose kept its attitude, so every other object in the mesh view faced the same way. - Make Quaternion @Serializable (mirrors Vector3D) so it can ride the wire. - Add WireFusedSample.orientation, defaulted to identity for schema evolution: a pre-orientation sender omits it and decodes to identity, and the mesh codec already ignores unknown keys, so a newer sender stays compatible with an older receiver. FusedState.toWireSample populates it; NetcodeAdapter records it. - Tests: orientation survives the adapter (not identity) and round-trips through the CBOR codec. Verified: :common:jvmTest green (NetcodeAdapterTest 4/4, MeshCodecTest 6/6). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WppuiKZt4CuQxX4N7k6SVR --- .../mofe/engine/netcode/NetcodeAdapter.kt | 8 +++---- .../kotlin/com/aether/mofe/model/CoreTypes.kt | 1 + .../mofe/model/messaging/WireFragments.kt | 7 ++++++ .../mofe/engine/netcode/NetcodeAdapterTest.kt | 24 ++++++++++++++++++- .../aether/mofe/messaging/MeshCodecTest.kt | 19 +++++++++++++++ 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/NetcodeAdapter.kt b/common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/NetcodeAdapter.kt index 03ae14f..6347f83 100644 --- a/common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/NetcodeAdapter.kt +++ b/common/src/commonMain/kotlin/com/aether/mofe/engine/netcode/NetcodeAdapter.kt @@ -1,7 +1,6 @@ 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.messaging.FusedStateSample import com.aether.mofe.model.messaging.MeshPayload @@ -34,15 +33,16 @@ class NetcodeAdapter( /** * Record every sample in a fused-state batch into its device's history. The wire * timestamp is treated as mesh time (the sender stamps it — companion edit #2). - * The fused wire sample carries no orientation, so remote poses use identity; the - * geometric predicates are positional, so this is sufficient for them. + * The fused wire sample now carries the sender's orientation, so remote poses render + * with real facing (Gap 1); pre-orientation senders decode to identity via the field + * default, leaving the positional predicates unaffected. */ fun onFusedSample(sample: FusedStateSample) { for (w in sample.samples) { session.recordSample( DeviceId(w.targetId), Timestamp(w.timestampMicros), - TemporalPose(w.position, w.velocity, Quaternion.IDENTITY), + TemporalPose(w.position, w.velocity, w.orientation), ) } } diff --git a/common/src/commonMain/kotlin/com/aether/mofe/model/CoreTypes.kt b/common/src/commonMain/kotlin/com/aether/mofe/model/CoreTypes.kt index e84572d..8bd84d2 100644 --- a/common/src/commonMain/kotlin/com/aether/mofe/model/CoreTypes.kt +++ b/common/src/commonMain/kotlin/com/aether/mofe/model/CoreTypes.kt @@ -191,6 +191,7 @@ data class EulerAngles( * Quaternion for 3D rotation representation. * Avoids gimbal lock and provides smooth interpolation. */ +@Serializable data class Quaternion( val w: Double, val x: Double, diff --git a/common/src/commonMain/kotlin/com/aether/mofe/model/messaging/WireFragments.kt b/common/src/commonMain/kotlin/com/aether/mofe/model/messaging/WireFragments.kt index a6f5143..70af92d 100644 --- a/common/src/commonMain/kotlin/com/aether/mofe/model/messaging/WireFragments.kt +++ b/common/src/commonMain/kotlin/com/aether/mofe/model/messaging/WireFragments.kt @@ -3,6 +3,7 @@ package com.aether.mofe.model.messaging import com.aether.mofe.model.AnchorObservation import com.aether.mofe.model.DeviceId import com.aether.mofe.model.FusedState +import com.aether.mofe.model.Quaternion import com.aether.mofe.model.Timestamp import com.aether.mofe.model.Vector3D import kotlinx.serialization.Serializable @@ -57,6 +58,11 @@ data class WireFusedSample( val velocity: Vector3D, /** 1-σ per axis — lets receivers render confidence without the full EKF state. */ val positionSigma: Vector3D, + /** Body-frame orientation (unit quaternion) so peers render with real facing + * instead of identity. Defaulted for schema evolution: a pre-orientation sender + * omits it and decodes to identity, and the mesh codec ignores unknown keys, so + * a newer sender stays compatible with an older receiver too. */ + val orientation: Quaternion = Quaternion.IDENTITY, ) /** @@ -71,4 +77,5 @@ fun FusedState.toWireSample(meshOffsetMicros: Long = 0L) = WireFusedSample( position = position, velocity = velocity, positionSigma = positionUncertainty, + orientation = orientation, ) \ No newline at end of file diff --git a/common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/NetcodeAdapterTest.kt b/common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/NetcodeAdapterTest.kt index 942612c..50113be 100644 --- a/common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/NetcodeAdapterTest.kt +++ b/common/src/commonTest/kotlin/com/aether/mofe/engine/netcode/NetcodeAdapterTest.kt @@ -1,6 +1,7 @@ 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 com.aether.mofe.model.messaging.FusedStateSample @@ -9,6 +10,7 @@ import com.aether.mofe.model.messaging.WireFusedSample import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertTrue /** @@ -17,12 +19,18 @@ import kotlin.test.assertTrue */ class NetcodeAdapterTest { - private fun wire(id: String, tMicros: Long, x: Double) = WireFusedSample( + private fun wire( + id: String, + tMicros: Long, + x: Double, + orientation: Quaternion = Quaternion.IDENTITY, + ) = WireFusedSample( targetId = id, timestampMicros = tMicros, position = Vector3D(x, 0.0, 0.0), velocity = Vector3D.ZERO, positionSigma = Vector3D.ZERO, + orientation = orientation, ) @Test @@ -37,6 +45,20 @@ class NetcodeAdapterTest { assertTrue(session.trackedDevices().containsAll(listOf(DeviceId("A"), DeviceId("B")))) } + @Test + fun onFusedSampleCarriesPeerOrientationNotIdentity() { + val session = NetcodeSession() + val adapter = NetcodeAdapter(session) + // A real facing (180° about Z) — must survive the wire instead of collapsing to identity (Gap 1). + val facing = Quaternion(0.0, 0.0, 0.0, 1.0) + + adapter.onFusedSample(FusedStateSample(listOf(wire("A", 500_000, 1.0, facing)))) + + val pose = session.poseAt(DeviceId("A"), Timestamp(500_000))!! + assertEquals(facing, pose.orientation) + assertNotEquals(Quaternion.IDENTITY, pose.orientation) + } + @Test fun onTimeSyncResponseFeedsTheClock() { val session = NetcodeSession() diff --git a/common/src/commonTest/kotlin/com/aether/mofe/messaging/MeshCodecTest.kt b/common/src/commonTest/kotlin/com/aether/mofe/messaging/MeshCodecTest.kt index f1dbf89..89b8ed9 100644 --- a/common/src/commonTest/kotlin/com/aether/mofe/messaging/MeshCodecTest.kt +++ b/common/src/commonTest/kotlin/com/aether/mofe/messaging/MeshCodecTest.kt @@ -1,6 +1,9 @@ package com.aether.mofe.messaging +import com.aether.mofe.model.Quaternion +import com.aether.mofe.model.Vector3D import com.aether.mofe.model.messaging.DeviceCredential +import com.aether.mofe.model.messaging.FusedStateSample import com.aether.mofe.model.messaging.MeshChannel import com.aether.mofe.model.messaging.MeshPayload import com.aether.mofe.model.messaging.MessageEnvelope @@ -10,6 +13,7 @@ import com.aether.mofe.model.messaging.RegistryValue import com.aether.mofe.model.messaging.SecHelloDone import com.aether.mofe.model.messaging.SecHelloInit import com.aether.mofe.model.messaging.StateDelta +import com.aether.mofe.model.messaging.WireFusedSample import com.aether.mofe.model.messaging.WireObservation import kotlin.test.Test import kotlin.test.assertEquals @@ -43,6 +47,21 @@ class MeshCodecTest { assertEquals(batch, MeshCodec.decodeOrNull(MeshCodec.encode(envelope(batch)))?.payload) } + @Test + fun fusedSampleRoundTripsPreservingOrientation() { + // A peer's real facing must survive the wire (Gap 1) — this exercises the + // @Serializable on Quaternion end-to-end through the CBOR codec. + val facing = Quaternion(0.0, 0.0, 0.0, 1.0) // 180° about Z, non-identity + val sample = FusedStateSample(listOf(WireFusedSample( + targetId = "t", timestampMicros = 42, + position = Vector3D(1.0, 2.0, 3.0), velocity = Vector3D.ZERO, + positionSigma = Vector3D.ZERO, orientation = facing, + ))) + val decoded = MeshCodec.decodeOrNull(MeshCodec.encode(envelope(sample)))?.payload as? FusedStateSample + assertEquals(facing, decoded?.samples?.single()?.orientation, + "orientation must round-trip through CBOR, not reset to identity") + } + @Test fun garbageDecodesToNullWithoutThrowing() { assertNull(MeshCodec.decodeOrNull(byteArrayOf(1, 2, 3, 4, 5))) -- 2.43.0