M1: device-motion pointing !6
merged
merged by cmc on 2026-10-18 01:54 UTC
· krz/moonrise:m1-motion into main
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 | 5 | struct SkyScreen: View { |
| 6 | 6 | @State private var model = SkyModel() |
| 7 | @State private var pointer = MotionPointer() |
| 7 | 8 | @State private var azimuth = 180.0 |
| 8 | 9 | @State private var altitude = 45.0 |
| 9 | 10 | @State private var fieldOfView = 100.0 |
| @@ -67,6 +68,7 @@ struct SkyScreen: View { |
| 67 | 68 | /// The view direction with any in-progress gestures applied. |
| 68 | 69 | private func current(width: CGFloat) -> (azimuth: Double, altitude: Double, fieldOfView: Double) { |
| 69 | 70 | let fov = min(max(fieldOfView / Double(zoom), 30), 140) |
| 71 | if pointer.active { return (pointer.direction.azimuth, pointer.direction.altitude, fov) } |
| 70 | 72 | let degreesPerPoint = fov / Double(max(width, 1)) |
| 71 | 73 | let az = (azimuth - Double(drag.width) * degreesPerPoint).truncatingRemainder(dividingBy: 360) |
| 72 | 74 | let alt = min(max(altitude + Double(drag.height) * degreesPerPoint, 0), 90) |
| @@ -75,6 +77,13 @@ struct SkyScreen: View { |
| 75 | 77 | |
| 76 | 78 | private func panGesture(width: CGFloat) -> some Gesture { |
| 77 | 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 | 87 | .updating($drag) { value, state, _ in state = value.translation } |
| 79 | 88 | .onEnded { value in |
| 80 | 89 | let degreesPerPoint = fieldOfView / Double(max(width, 1)) |
| @@ -102,6 +111,16 @@ struct SkyScreen: View { |
| 102 | 111 | } |
| 103 | 112 | } |
| 104 | 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 | 124 | Button { |
| 106 | 125 | night.toggle() |
| 107 | 126 | } label: { |
PLAN.org
+19
| @@ -310,6 +310,25 @@ The Library is the largest piece of work. |
| 310 | 310 | after the test. A real push still needs an APNs key and a device token. |
| 311 | 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 | 332 | * roadmap |
| 314 | 333 | ** v1: now |
| 315 | 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 | } |