Summary
On iOS, for the north-referenced frames (magneticNorth / trueNorth), the Core Motion → ENU world-frame conversion is applied on the wrong side of the rotation. It is applied as a device-axis remap (right-multiplication) when it needs to be a world-frame change of basis (left-multiplication). As a result the azimuth/heading is correct only when the device is flat, and is exactly 90° off when the device is held upright. Android is unaffected.
This makes the fix from #95 correct on a table but wrong in the common upright/portrait "point the camera at something" pose.
Where
lib/src/rotation_sensor_method_channel.dart (around lines 51–56):
// Core Motion uses a world frame with X = north and Z = up. For iOS
// north-referenced frames, convert it here to Y = north and Z = up to
// match Android and this package's convention.
.magneticNorth || .trueNorth =>
defaultTargetPlatform == TargetPlatform.iOS
? orientationEvent.remapCoordinateSystem(Axis3.Y, -Axis3.X)
: orientationEvent,
remapCoordinateSystem (in lib/src/orientation_event.dart, ~line 105) right-multiplies the quaternion:
final transformMatrix = Matrix3.columns(newX, newY, newZ);
return OrientationEvent._(
quaternion.multiply(transformMatrix.toQuaternion()), // q ⊗ q_T (right-multiply)
...
);
and it is documented as remapping the device coordinate system — which is what it's correctly used for in DisplayCoordinateSystem.apply (portrait-down / landscape). It is the wrong operation for a world-frame conversion.
Why it's wrong
The rotation matrix maps device → world (columns are the device axes expressed in world coordinates). CMAttitude follows this same device→reference convention as Android's rotation vector — e.g. CardboardSDK-iOS names GLKMatrix4Transpose(attitude.rotationMatrix) as deviceFromInertialReferenceFrame, so the untransposed rotationMatrix is reference-from-device… i.e. it takes a device vector to the reference/world frame.
The two world frames differ:
- Core Motion
xMagneticNorthZVertical: X = north, Z = up ⇒ Y = Z×X = west.
- This package (Android/ENU): X = east, Y = north, Z = up.
Converting world coordinates from the iOS frame to ENU is a fixed change of basis C = Rz(90°):
C = [ 0 -1 0 ]
[ 1 0 0 ]
[ 0 0 1 ]
For a device vector d, its ENU world coords are C · (R_ios · d) = (C · R_ios) · d, so the corrected matrix is:
R_enu = C · R_ios (LEFT-multiply)
or, as quaternions:
q_enu = q_C ⊗ q_ios (NOT q_ios ⊗ q_C)
The package currently computes R_ios · T / q_ios ⊗ q_T. Numerically T == C == Rz(90°), so the two orderings agree only when R_ios commutes with a rotation about the vertical axis — i.e. when the device lies flat (screen up). That's why flat-on-table azimuth tests pass but the upright pose is 90° off.
Reproduce
Hold the phone upright (portrait), back camera pointing magnetic north.
- Expected: the lens direction is device
−Z, so the rotation matrix's third column should be ≈ (0, 1, 0) (north) in ENU.
- Actual on iOS: the third column reads ≈
(1, 0, 0) (east) — a 90° clockwise error. Android reports it correctly.
(Flat on the table, top edge pointing any direction, reads correctly on both platforms — which is why this is easy to miss.)
Suggested fix
For the magneticNorth / trueNorth frames on iOS, apply the world-frame change of basis by left-multiplying instead of using remapCoordinateSystem, e.g.:
// transform = Rz(90°) as a quaternion; left-multiply to change the world frame.
quaternion: transformQuaternion.multiply(event.quaternion), // q_C ⊗ q_ios
(and correspondingly C.multiply(coordinateSystem) for the stored coordinateSystem).
Notes / workaround
Found while building an in-app camera compass. As a temporary workaround we conjugate every event on iOS — R' = C · R · Cᵀ — which is exactly equivalent to the left-multiply fix once the package's subsequent display-orientation remap (a device-Z rotation, so it commutes with C) is accounted for, and it's exact in every pose and display orientation. We've pinned the dependency to 0.3.0 so that when this is fixed upstream our compensation doesn't double-correct.
Thanks for the package!
Summary
On iOS, for the north-referenced frames (
magneticNorth/trueNorth), the Core Motion → ENU world-frame conversion is applied on the wrong side of the rotation. It is applied as a device-axis remap (right-multiplication) when it needs to be a world-frame change of basis (left-multiplication). As a result the azimuth/heading is correct only when the device is flat, and is exactly 90° off when the device is held upright. Android is unaffected.This makes the fix from #95 correct on a table but wrong in the common upright/portrait "point the camera at something" pose.
Where
lib/src/rotation_sensor_method_channel.dart(around lines 51–56):remapCoordinateSystem(inlib/src/orientation_event.dart, ~line 105) right-multiplies the quaternion:and it is documented as remapping the device coordinate system — which is what it's correctly used for in
DisplayCoordinateSystem.apply(portrait-down / landscape). It is the wrong operation for a world-frame conversion.Why it's wrong
The rotation matrix maps device → world (columns are the device axes expressed in world coordinates).
CMAttitudefollows this same device→reference convention as Android's rotation vector — e.g. CardboardSDK-iOS namesGLKMatrix4Transpose(attitude.rotationMatrix)asdeviceFromInertialReferenceFrame, so the untransposedrotationMatrixis reference-from-device… i.e. it takes a device vector to the reference/world frame.The two world frames differ:
xMagneticNorthZVertical: X = north, Z = up ⇒ Y = Z×X = west.Converting world coordinates from the iOS frame to ENU is a fixed change of basis
C = Rz(90°):For a device vector
d, its ENU world coords areC · (R_ios · d) = (C · R_ios) · d, so the corrected matrix is:or, as quaternions:
The package currently computes
R_ios · T/q_ios ⊗ q_T. NumericallyT == C == Rz(90°), so the two orderings agree only whenR_ioscommutes with a rotation about the vertical axis — i.e. when the device lies flat (screen up). That's why flat-on-table azimuth tests pass but the upright pose is 90° off.Reproduce
Hold the phone upright (portrait), back camera pointing magnetic north.
−Z, so the rotation matrix's third column should be ≈(0, 1, 0)(north) in ENU.(1, 0, 0)(east) — a 90° clockwise error. Android reports it correctly.(Flat on the table, top edge pointing any direction, reads correctly on both platforms — which is why this is easy to miss.)
Suggested fix
For the
magneticNorth/trueNorthframes on iOS, apply the world-frame change of basis by left-multiplying instead of usingremapCoordinateSystem, e.g.:(and correspondingly
C.multiply(coordinateSystem)for the storedcoordinateSystem).Notes / workaround
Found while building an in-app camera compass. As a temporary workaround we conjugate every event on iOS —
R' = C · R · Cᵀ— which is exactly equivalent to the left-multiply fix once the package's subsequent display-orientation remap (a device-Z rotation, so it commutes withC) is accounted for, and it's exact in every pose and display orientation. We've pinned the dependency to0.3.0so that when this is fixed upstream our compensation doesn't double-correct.Thanks for the package!