diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml new file mode 100644 index 0000000..4e1fcc7 --- /dev/null +++ b/.idea/material_theme_project_new.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 2742c22..22749ce 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,9 @@ For more control, you can subscribe to the stream directly: super.initState(); orientationSubscription = RotationSensor.orientationStream.listen((event) { final azimuth = event.eulerAngles.azimuth; - // Print azimuth: 0 for North, π/2 for East, π for South, -π/2 for West + // Print azimuth: 0 for North, π/2 for East, π for South, -π/2 for West. + // On iOS, an azimuth of 0 only points to north when a north-referenced + // reference frame is set (see the Reference Frame section below). print(azimuth); }); } @@ -101,6 +103,9 @@ void initState() { // Set the sampling period for the rotation sensor RotationSensor.samplingPeriod = SensorInterval.uiInterval; + // Set the reference frame the azimuth is measured from + RotationSensor.referenceFrame = ReferenceFrame.magneticNorth; + // Set the coordinate system for the rotation sensor RotationSensor.coordinateSystem = CoordinateSystem.transformed(Axis3.X, Axis3.Z); } @@ -128,6 +133,34 @@ void config() { Events may arrive at a rate faster or slower than the sampling period, which is only a hint to the system. The actual rate depends on the system's event queue and sensor hardware capabilities. +### Reference Frame + +The [RotationSensor.referenceFrame](https://pub.dev/documentation/flutter_rotation_sensor/latest/flutter_rotation_sensor/RotationSensor/referenceFrame.html) +property controls the world reference the azimuth is measured from. Whatever the value, the +orientation is always returned in the plugin's east-north-up world convention, so an azimuth of 0 +means the device points north. Here are the values you can use: + +- `ReferenceFrame.device`: *(default value)* The platform default, with no guarantee of a north + reference. On iOS the horizontal reference is arbitrary (the direction the device happened to + point when the sensor started, no compass). On Android the rotation vector sensor is already + referenced to magnetic north. +- `ReferenceFrame.magneticNorth`: The azimuth is referenced to magnetic north on both platforms, + without depending on location services. +- `ReferenceFrame.trueNorth`: The azimuth is referenced to true (geographic) north. Requires + location services to be available. + +```dart +void config() { + RotationSensor.referenceFrame = ReferenceFrame.magneticNorth; +} +``` + +> [!NOTE] +> Reference frame selection is currently implemented on iOS only. On Android the rotation vector is +> always referenced to magnetic north, so `device` and `magneticNorth` already behave as expected, +> while `trueNorth` currently behaves like `magneticNorth`. Android support for `trueNorth` is +> tracked in a separate issue. + ### Coordinate System The [RotationSensor.coordinateSystem](https://pub.dev/documentation/flutter_rotation_sensor/latest/flutter_rotation_sensor/RotationSensor/coordinateSystem.html) diff --git a/ios/Classes/FlutterRotationSensorPlugin.swift b/ios/Classes/FlutterRotationSensorPlugin.swift index 4029fb3..95f328e 100644 --- a/ios/Classes/FlutterRotationSensorPlugin.swift +++ b/ios/Classes/FlutterRotationSensorPlugin.swift @@ -6,6 +6,14 @@ public class FlutterRotationSensorPlugin: NSObject, FlutterPlugin, FlutterStream private var eventChannel: FlutterEventChannel private let motionManager: CMMotionManager + private var referenceFrame: CMAttitudeReferenceFrame = .xArbitraryZVertical + private var eventSink: FlutterEventSink? + + // CoreMotion expresses a north reference frame as (north, west, up), while + // this plugin's world convention is (east, north, up) like Android. The two + // differ by a fixed 90° rotation about the vertical axis, applied to the + // attitude quaternion so the azimuth stays 0 = north on every platform. + private let northToEastNorthUp = 0.7071067811865476 // sin(45°) = cos(45°) public static func register(with registrar: FlutterPluginRegistrar) { let methodChannel = FlutterMethodChannel(name: "rotation_sensor/method", binaryMessenger: registrar.messenger()) @@ -25,38 +33,73 @@ public class FlutterRotationSensorPlugin: NSObject, FlutterPlugin, FlutterStream public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "getOrientationStream": - if let args = call.arguments as? [String: Any], - let samplingPeriod = args["samplingPeriod"] as? Double { - motionManager.deviceMotionUpdateInterval = samplingPeriod * 0.000001 + let args = call.arguments as? [String: Any] + if let samplingPeriod = args?["samplingPeriod"] as? Double { + motionManager.deviceMotionUpdateInterval = samplingPeriod * 0.000001 } + updateReferenceFrame(args?["referenceFrame"] as? String) result(nil) default: result(FlutterMethodNotImplemented) } } - public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { - if motionManager.isDeviceMotionAvailable { - motionManager.startDeviceMotionUpdates(to: OperationQueue()) { (motion, error) in - guard let motion = motion, error == nil else { - events(FlutterError(code: "UNAVAILABLE", message: "Device motion updates unavailable", details: nil)) - return - } + private func updateReferenceFrame(_ name: String?) { + let newFrame = attitudeReferenceFrame(from: name) + guard newFrame != referenceFrame else { return } + referenceFrame = newFrame + if motionManager.isDeviceMotionActive, let sink = eventSink { + motionManager.stopDeviceMotionUpdates() + startUpdates(sink) + } + } + + private func attitudeReferenceFrame(from name: String?) -> CMAttitudeReferenceFrame { + switch name { + case "magneticNorth": return .xMagneticNorthZVertical + case "trueNorth": return .xTrueNorthZVertical + default: return .xArbitraryZVertical + } + } + + private func isNorthReferenced(_ frame: CMAttitudeReferenceFrame) -> Bool { + return frame == .xMagneticNorthZVertical || frame == .xTrueNorthZVertical + } - let quaternion = motion.attitude.quaternion - let rotationVector = [quaternion.x, quaternion.y, quaternion.z, quaternion.w, -1.0, Int64((motion.timestamp * 1000000000).rounded())] - DispatchQueue.main.async { - events(rotationVector) - } - } - return nil - } else { - return FlutterError(code: "NO_SENSOR", message: "Rotation vector sensor unavailable", details: nil) + private func startUpdates(_ events: @escaping FlutterEventSink) { + let correctToEastNorthUp = isNorthReferenced(referenceFrame) + motionManager.startDeviceMotionUpdates(using: referenceFrame, to: OperationQueue()) { (motion, error) in + guard let motion = motion, error == nil else { + events(FlutterError(code: "UNAVAILABLE", message: "Device motion updates unavailable", details: nil)) + return } + + let q = motion.attitude.quaternion + let k = self.northToEastNorthUp + let qx = correctToEastNorthUp ? k * (q.x - q.y) : q.x + let qy = correctToEastNorthUp ? k * (q.x + q.y) : q.y + let qz = correctToEastNorthUp ? k * (q.z + q.w) : q.z + let qw = correctToEastNorthUp ? k * (q.w - q.z) : q.w + + let rotationVector = [qx, qy, qz, qw, -1.0, Int64((motion.timestamp * 1000000000).rounded())] as [Any] + DispatchQueue.main.async { + events(rotationVector) + } + } + } + + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + guard motionManager.isDeviceMotionAvailable else { + return FlutterError(code: "NO_SENSOR", message: "Rotation vector sensor unavailable", details: nil) + } + eventSink = events + startUpdates(events) + return nil } public func onCancel(withArguments arguments: Any?) -> FlutterError? { motionManager.stopDeviceMotionUpdates() + eventSink = nil return nil } diff --git a/lib/flutter_rotation_sensor.dart b/lib/flutter_rotation_sensor.dart index 816e0c5..0ad866f 100644 --- a/lib/flutter_rotation_sensor.dart +++ b/lib/flutter_rotation_sensor.dart @@ -6,5 +6,6 @@ export 'src/math/matrix3.dart'; export 'src/math/quaternion.dart'; export 'src/math/vector3.dart'; export 'src/orientation_event.dart'; +export 'src/reference_frame.dart'; export 'src/rotation_sensor.dart'; export 'src/sensor_interval.dart'; diff --git a/lib/src/reference_frame.dart b/lib/src/reference_frame.dart new file mode 100644 index 0000000..a159663 --- /dev/null +++ b/lib/src/reference_frame.dart @@ -0,0 +1,22 @@ +/// The world reference frame the device orientation is expressed against. +/// +/// This controls what the azimuth is measured from. Whatever the value, the +/// orientation is always returned in the package's east-north-up world +/// convention, so an azimuth of 0 means the device points north. +enum ReferenceFrame { + /// The platform default, with no guarantee of a north reference. + /// + /// On iOS the horizontal reference is arbitrary (the direction the device + /// happened to point when the sensor started, no compass). On Android the + /// rotation vector sensor is already referenced to magnetic north. + device, + + /// The azimuth is referenced to magnetic north on both platforms. No + /// dependency on location services. + magneticNorth, + + /// The azimuth is referenced to true (geographic) north. Requires location + /// services to be available. On Android, where the rotation vector is only + /// magnetic, this currently behaves like [magneticNorth]. + trueNorth; +} diff --git a/lib/src/rotation_sensor.dart b/lib/src/rotation_sensor.dart index 0d0345c..37bcc84 100644 --- a/lib/src/rotation_sensor.dart +++ b/lib/src/rotation_sensor.dart @@ -2,6 +2,7 @@ import 'package:meta/meta.dart'; import 'coordinate_system.dart'; import 'orientation_event.dart'; +import 'reference_frame.dart'; import 'rotation_sensor_method_channel.dart'; import 'rotation_sensor_platform.dart'; import 'sensor_interval.dart'; @@ -44,4 +45,16 @@ class RotationSensor { /// existing listeners will receive [OrientationEvent] in the new coordinate /// system. static CoordinateSystem coordinateSystem = DisplayCoordinateSystem(); + + /// The world [ReferenceFrame] the azimuth is measured from. + /// + /// Defaults to [ReferenceFrame.device]. Set it to + /// [ReferenceFrame.magneticNorth] or [ReferenceFrame.trueNorth] to get a real + /// compass heading (azimuth 0 = north). When changing this value, all + /// existing listeners will be affected. + static ReferenceFrame get referenceFrame => + RotationSensorPlatform.instance.referenceFrame; + + static set referenceFrame(ReferenceFrame value) => + RotationSensorPlatform.instance.referenceFrame = value; } diff --git a/lib/src/rotation_sensor_method_channel.dart b/lib/src/rotation_sensor_method_channel.dart index 173a446..b69a303 100644 --- a/lib/src/rotation_sensor_method_channel.dart +++ b/lib/src/rotation_sensor_method_channel.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'environment.dart'; import 'math/quaternion.dart'; import 'orientation_event.dart'; +import 'reference_frame.dart'; import 'rotation_sensor.dart'; import 'rotation_sensor_platform.dart'; @@ -38,6 +39,7 @@ class RotationSensorMethodChannel extends RotationSensorPlatform { } methodChannel.invokeMethod('getOrientationStream', { 'samplingPeriod': samplingMicroseconds, + 'referenceFrame': referenceFrameValue.name, }); final broadcastStream = eventChannel.receiveBroadcastStream(); return _orientationStream = broadcastStream.map((event) { @@ -56,6 +58,16 @@ class RotationSensorMethodChannel extends RotationSensorPlatform { void updateSamplingPeriod(int value) { methodChannel.invokeMethod('getOrientationStream', { 'samplingPeriod': value, + 'referenceFrame': referenceFrameValue.name, + }); + } + + @override + @protected + void updateReferenceFrame(ReferenceFrame value) { + methodChannel.invokeMethod('getOrientationStream', { + 'samplingPeriod': samplingMicroseconds, + 'referenceFrame': value.name, }); } } diff --git a/lib/src/rotation_sensor_platform.dart b/lib/src/rotation_sensor_platform.dart index cbc208e..4267ff5 100644 --- a/lib/src/rotation_sensor_platform.dart +++ b/lib/src/rotation_sensor_platform.dart @@ -3,6 +3,7 @@ import 'package:meta/meta.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'orientation_event.dart'; +import 'reference_frame.dart'; import 'rotation_sensor_method_channel.dart'; import 'rotation_sensor_unsupported.dart'; import 'sensor_interval.dart'; @@ -79,4 +80,23 @@ abstract class RotationSensorPlatform extends PlatformInterface { void updateSamplingPeriod(int value) { // no-op } + + @protected + ReferenceFrame referenceFrameValue = ReferenceFrame.device; + + /// The world [ReferenceFrame] the azimuth is measured from. + /// + /// Defaults to [ReferenceFrame.device]. When changing this value, all + /// existing listeners will be affected. + ReferenceFrame get referenceFrame => referenceFrameValue; + + set referenceFrame(ReferenceFrame value) { + referenceFrameValue = value; + updateReferenceFrame(value); + } + + @protected + void updateReferenceFrame(ReferenceFrame value) { + // no-op + } } diff --git a/test/rotation_sensor_method_channel_test.dart b/test/rotation_sensor_method_channel_test.dart index 78cbe30..f3d2a32 100644 --- a/test/rotation_sensor_method_channel_test.dart +++ b/test/rotation_sensor_method_channel_test.dart @@ -9,9 +9,11 @@ void main() { const methodChannel = RotationSensorMethodChannel.methodChannel; const orientationChannel = RotationSensorMethodChannel.eventChannel; late int expectedSamplingPeriod; + late ReferenceFrame expectedReferenceFrame; setUp(() { expectedSamplingPeriod = platform.samplingPeriod.inMicroseconds; + expectedReferenceFrame = platform.referenceFrame; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(methodChannel, (methodCall) async { switch (methodCall.method) { @@ -19,6 +21,8 @@ void main() { final arguments = methodCall.arguments as Map; final samplingPeriod = arguments['samplingPeriod'] as int; expect(samplingPeriod, expectedSamplingPeriod); + final referenceFrame = arguments['referenceFrame'] as String; + expect(referenceFrame, expectedReferenceFrame.name); return null; default: throw UnsupportedError(methodCall.method); @@ -69,4 +73,15 @@ void main() { expect(await platform.orientationStream.first, isA()); }, ); + + test( + 'orientationStream forwards the configured reference frame to the platform', + () async { + expectedReferenceFrame = ReferenceFrame.magneticNorth; + platform.referenceFrame = ReferenceFrame.magneticNorth; + expect(platform.referenceFrame, equals(ReferenceFrame.magneticNorth)); + await Future.microtask(() => null); + expect(await platform.orientationStream.first, isA()); + }, + ); } diff --git a/test/rotation_sensor_test.dart b/test/rotation_sensor_test.dart index 310f5aa..3fb76c0 100644 --- a/test/rotation_sensor_test.dart +++ b/test/rotation_sensor_test.dart @@ -61,4 +61,16 @@ void main() { RotationSensor.coordinateSystem = CoordinateSystem.display(); expect(RotationSensor.coordinateSystem, same(CoordinateSystem.display())); }); + + test('referenceFrame defaults to device', () { + RotationSensorPlatform.instance = MockRotationSensorPlatform(); + expect(RotationSensor.referenceFrame, equals(ReferenceFrame.device)); + }); + + test('referenceFrame can be set and retrieved correctly', () { + RotationSensor.referenceFrame = ReferenceFrame.magneticNorth; + expect(RotationSensor.referenceFrame, equals(ReferenceFrame.magneticNorth)); + RotationSensor.referenceFrame = ReferenceFrame.trueNorth; + expect(RotationSensor.referenceFrame, equals(ReferenceFrame.trueNorth)); + }); }