M1: device-motion pointing !6
5 files changed, +124 −0
Layout: unified · split
App/Sources/MotionPointer.swift added +39
| @@ -0,0 +1,39 @@ | |||
| 1 | import CoreMotion | ||
| 2 | import Observation | ||
| 3 | import SkyEngine | ||
| 4 | import SkyRenderer | ||
| 5 | |||
| 6 | /// Points the sky where the back of the phone points, using true north. | ||
| 7 | @MainActor | ||
| 8 | @Observable | ||
| 9 | final class MotionPointer { | ||
| 10 | private(set) var active = false | ||
| 11 | private(set) var direction = Horizontal(altitude: 45, azimuth: 180) | ||
| 12 | var available: Bool { CMMotionManager.availableAttitudeReferenceFrames().contains(.xTrueNorthZVertical) } | ||
| 13 | |||
| 14 | private let manager = CMMotionManager() | ||
| 15 | |||
| 16 | func start() { | ||
| 17 | guard available, !active else { return } | ||
| 18 | active = true | ||
| 19 | manager.deviceMotionUpdateInterval = 1.0 / 60 | ||
| 20 | manager.startDeviceMotionUpdates(using: .xTrueNorthZVertical, to: .main) { [weak self] motion, _ in | ||
| 21 | guard let self, let m = motion?.attitude.rotationMatrix else { return } | ||
| 22 | MainActor.assumeIsolated { self.update(Pointing.direction(m31: m.m31, m32: m.m32, m33: m.m33)) } | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | func stop() { | ||
| 27 | manager.stopDeviceMotionUpdates() | ||
| 28 | active = false | ||
| 29 | } | ||
| 30 | |||
| 31 | /// Light low-pass filter; azimuth is smoothed across the 0/360 wrap. | ||
| 32 | private func update(_ h: Horizontal) { | ||
| 33 | let k = 0.25 | ||
| 34 | let dAz = remainder(h.azimuth - direction.azimuth, 360) | ||
| 35 | var az = direction.azimuth + k * dAz | ||
| 36 | if az < 0 { az += 360 } else if az >= 360 { az -= 360 } | ||
| 37 | direction = Horizontal(altitude: direction.altitude + k * (h.altitude - direction.altitude), azimuth: az) | ||
| 38 | } | ||
| 39 | } | ||
App/Sources/SkyScreen.swift +19
| @@ -4,6 +4,7 @@ import SwiftUI | |||
| 4 | 4 | ||
| 5 | struct SkyScreen: View { | 5 | struct SkyScreen: View { |
| 6 | @State private var model = SkyModel() | 6 | @State private var model = SkyModel() |
| 7 | @State private var pointer = MotionPointer() | ||
| 7 | @State private var azimuth = 180.0 | 8 | @State private var azimuth = 180.0 |
| 8 | @State private var altitude = 45.0 | 9 | @State private var altitude = 45.0 |
| 9 | @State private var fieldOfView = 100.0 | 10 | @State private var fieldOfView = 100.0 |
| @@ -67,6 +68,7 @@ struct SkyScreen: View { | |||
| 67 | /// The view direction with any in-progress gestures applied. | 68 | /// The view direction with any in-progress gestures applied. |
| 68 | private func current(width: CGFloat) -> (azimuth: Double, altitude: Double, fieldOfView: Double) { | 69 | private func current(width: CGFloat) -> (azimuth: Double, altitude: Double, fieldOfView: Double) { |
| 69 | let fov = min(max(fieldOfView / Double(zoom), 30), 140) | 70 | let fov = min(max(fieldOfView / Double(zoom), 30), 140) |
| 71 | if pointer.active { return (pointer.direction.azimuth, pointer.direction.altitude, fov) } | ||
| 70 | let degreesPerPoint = fov / Double(max(width, 1)) | 72 | let degreesPerPoint = fov / Double(max(width, 1)) |
| 71 | let az = (azimuth - Double(drag.width) * degreesPerPoint).truncatingRemainder(dividingBy: 360) | 73 | let az = (azimuth - Double(drag.width) * degreesPerPoint).truncatingRemainder(dividingBy: 360) |
| 72 | let alt = min(max(altitude + Double(drag.height) * degreesPerPoint, 0), 90) | 74 | let alt = min(max(altitude + Double(drag.height) * degreesPerPoint, 0), 90) |
| @@ -75,6 +77,13 @@ struct SkyScreen: View { | |||
| 75 | 77 | ||
| 76 | private func panGesture(width: CGFloat) -> some Gesture { | 78 | private func panGesture(width: CGFloat) -> some Gesture { |
| 77 | DragGesture(minimumDistance: 6) | 79 | DragGesture(minimumDistance: 6) |
| 80 | .onChanged { _ in | ||
| 81 | if pointer.active { | ||
| 82 | azimuth = pointer.direction.azimuth | ||
| 83 | altitude = min(max(pointer.direction.altitude, 0), 90) | ||
| 84 | pointer.stop() | ||
| 85 | } | ||
| 86 | } | ||
| 78 | .updating($drag) { value, state, _ in state = value.translation } | 87 | .updating($drag) { value, state, _ in state = value.translation } |
| 79 | .onEnded { value in | 88 | .onEnded { value in |
| 80 | let degreesPerPoint = fieldOfView / Double(max(width, 1)) | 89 | let degreesPerPoint = fieldOfView / Double(max(width, 1)) |
| @@ -102,6 +111,16 @@ struct SkyScreen: View { | |||
| 102 | } | 111 | } |
| 103 | } | 112 | } |
| 104 | Spacer() | 113 | Spacer() |
| 114 | if pointer.available { | ||
| 115 | Button { | ||
| 116 | pointer.active ? pointer.stop() : pointer.start() | ||
| 117 | } label: { | ||
| 118 | Image(systemName: pointer.active ? "scope" : "hand.draw") | ||
| 119 | .font(.system(size: 17)) | ||
| 120 | .frame(width: 44, height: 44) | ||
| 121 | } | ||
| 122 | .accessibilityLabel(pointer.active ? "Stop following the phone" : "Point the phone at the sky") | ||
| 123 | } | ||
| 105 | Button { | 124 | Button { |
| 106 | night.toggle() | 125 | night.toggle() |
| 107 | } label: { | 126 | } label: { |
PLAN.org +19
| @@ -310,6 +310,25 @@ The Library is the largest piece of work. | |||
| 310 | after the test. A real push still needs an APNs key and a device token. | 310 | after the test. A real push still needs an APNs key and a device token. |
| 311 | - Name reserved in App Store Connect; APNs key created (key ID 644XS4F2RG). | 311 | - Name reserved in App Store Connect; APNs key created (key ID 644XS4F2RG). |
| 312 | 312 | ||
| 313 | ** M1 status (2026-10-17) | ||
| 314 | Stacked MRs: !2 app shell, !3 interaction, !4 Today sheet, !5 widgets, | ||
| 315 | !6 device pointing. | ||
| 316 | |||
| 317 | Done and checked in the iOS 27 simulator: live sky with location, drag | ||
| 318 | and pinch, object cards, time scrubber, night mode, Today sheet (moon, | ||
| 319 | planets, sun, live SWPC Kp), sandbox APNs token. | ||
| 320 | |||
| 321 | Built but not checked: | ||
| 322 | - Widgets: build and embed; not viewed (simulator widget gallery could | ||
| 323 | not be driven by automation). | ||
| 324 | - Device pointing: maths unit-tested; needs a real phone. | ||
| 325 | |||
| 326 | Not done: | ||
| 327 | - Draw time on a real iPhone (decides Canvas vs Metal). | ||
| 328 | - ISS passes: need an SGP4 propagator and CelesTrak data via the backend. | ||
| 329 | - Launches and Voyager in the Today sheet: backend (M2, M3). | ||
| 330 | - Automatic night mode after astronomical dusk. | ||
| 331 | |||
| 313 | * roadmap | 332 | * roadmap |
| 314 | ** v1: now | 333 | ** v1: now |
| 315 | Sky, System, Sun, Launches, Library core (above). | 334 | Sky, System, Sun, Launches, Library core (above). |
Packages/MoonriseKit/Sources/SkyRenderer/Pointing.swift added +15
| @@ -0,0 +1,15 @@ | |||
| 1 | import Foundation | ||
| 2 | import SkyEngine | ||
| 3 | |||
| 4 | public enum Pointing { | ||
| 5 | /// Where the back of the phone points, from the third row of a Core Motion | ||
| 6 | /// rotation matrix in the x-true-north, z-vertical reference frame | ||
| 7 | /// (x north, y west, z up). The camera looks along the device's −z axis. | ||
| 8 | public static func direction(m31: Double, m32: Double, m33: Double) -> Horizontal { | ||
| 9 | let north = -m31, west = -m32, up = -m33 | ||
| 10 | let altitude = asin(max(-1, min(1, up))) * 180 / .pi | ||
| 11 | var azimuth = atan2(-west, north) * 180 / .pi | ||
| 12 | if azimuth < 0 { azimuth += 360 } | ||
| 13 | return Horizontal(altitude: altitude, azimuth: azimuth) | ||
| 14 | } | ||
| 15 | } | ||
Packages/MoonriseKit/Tests/SkyRendererTests/PointingTests.swift added +32
| @@ -0,0 +1,32 @@ | |||
| 1 | import Testing | ||
| 2 | import SkyEngine | ||
| 3 | @testable import SkyRenderer | ||
| 4 | |||
| 5 | // Rows of the rotation matrix are the device axes in reference coordinates | ||
| 6 | // (x north, y west, z up); the third row is the device z axis, toward the user. | ||
| 7 | |||
| 8 | @Test func uprightFacingNorth() { | ||
| 9 | // Screen faces south, so device z = south = (-1, 0, 0). | ||
| 10 | let h = Pointing.direction(m31: -1, m32: 0, m33: 0) | ||
| 11 | #expect(abs(h.altitude) < 1e-9) | ||
| 12 | #expect(abs(h.azimuth) < 1e-9) | ||
| 13 | } | ||
| 14 | |||
| 15 | @Test func uprightFacingEast() { | ||
| 16 | // Screen faces west: device z = (0, 1, 0). | ||
| 17 | let h = Pointing.direction(m31: 0, m32: 1, m33: 0) | ||
| 18 | #expect(abs(h.azimuth - 90) < 1e-9) | ||
| 19 | } | ||
| 20 | |||
| 21 | @Test func flatFaceUpPointsDown() { | ||
| 22 | let h = Pointing.direction(m31: 0, m32: 0, m33: 1) | ||
| 23 | #expect(abs(h.altitude + 90) < 1e-9) | ||
| 24 | } | ||
| 25 | |||
| 26 | @Test func tiltedUpToward45DegreesSouth() { | ||
| 27 | // Camera toward south and 45° up: −z = (−s, 0, s), so z = (s, 0, −s). | ||
| 28 | let s = 0.5.squareRoot() | ||
| 29 | let h = Pointing.direction(m31: s, m32: 0, m33: -s) | ||
| 30 | #expect(abs(h.altitude - 45) < 1e-9) | ||
| 31 | #expect(abs(h.azimuth - 180) < 1e-9) | ||
| 32 | } | ||