forked from sagnik/Project_Velocity
Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#41
141 lines
4.8 KiB
Swift
141 lines
4.8 KiB
Swift
import CoreLocation
|
|
import CoreMotion
|
|
import Foundation
|
|
import Observation
|
|
import SceneKit
|
|
|
|
// MARK: - SunseekerViewModel
|
|
|
|
/// Owns all sensor state for the Sunseeker AR overlay.
|
|
/// Separates CoreLocation / CoreMotion concerns from the ARKit view layer.
|
|
@Observable
|
|
final class SunseekerViewModel: NSObject, CLLocationManagerDelegate {
|
|
|
|
// MARK: - Published State
|
|
|
|
/// True once we have both a GPS fix and a valid heading.
|
|
private(set) var isReady = false
|
|
|
|
/// Latest GPS coordinate. nil until first fix.
|
|
private(set) var coordinate: CLLocationCoordinate2D?
|
|
|
|
/// Latest true heading (0 = North, clockwise).
|
|
private(set) var heading: Double = 0
|
|
|
|
/// Dense hourly arc for today.
|
|
private(set) var arc: [(date: Date, position: SunPosition)] = []
|
|
|
|
/// Current real-time sun position (updated every second).
|
|
private(set) var currentPosition: SunPosition?
|
|
|
|
/// Sunrise and sunset for today.
|
|
private(set) var riseSet: (rise: Date?, set: Date?) = (nil, nil)
|
|
|
|
/// Diagnostic string for the UI when location access is denied.
|
|
private(set) var locationError: String?
|
|
|
|
// MARK: - Private
|
|
|
|
private let locationManager = CLLocationManager()
|
|
private let motionManager = CMMotionManager()
|
|
private var updateTimer: Timer?
|
|
|
|
// MARK: - Init / Deinit
|
|
|
|
override init() {
|
|
super.init()
|
|
locationManager.delegate = self
|
|
locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
|
locationManager.headingFilter = 1.0
|
|
locationManager.requestWhenInUseAuthorization()
|
|
locationManager.startUpdatingLocation()
|
|
locationManager.startUpdatingHeading()
|
|
startMotionUpdates()
|
|
startRealTimeTick()
|
|
}
|
|
|
|
deinit {
|
|
stop()
|
|
}
|
|
|
|
// MARK: - Control
|
|
|
|
func stop() {
|
|
motionManager.stopDeviceMotionUpdates()
|
|
locationManager.stopUpdatingLocation()
|
|
locationManager.stopUpdatingHeading()
|
|
updateTimer?.invalidate()
|
|
updateTimer = nil
|
|
}
|
|
|
|
// MARK: - World-Space Transform
|
|
|
|
/// Converts a solar `SunPosition` into a SceneKit world-space position on a sphere of given `radius`.
|
|
/// Orientation is relative to ARWorldTrackingConfiguration(.gravityAndHeading), so north = -Z axis.
|
|
func worldPosition(for sun: SunPosition, radius: Float) -> SCNVector3 {
|
|
let elev = Float(sun.elevation.radians)
|
|
let az = Float(sun.azimuth.radians) // clockwise from north
|
|
let x = radius * cos(elev) * sin(az)
|
|
let y = radius * sin(elev)
|
|
let z = -radius * cos(elev) * cos(az) // -Z = north in ARKit gravity+heading
|
|
return SCNVector3(x, y, z)
|
|
}
|
|
|
|
// MARK: - Private helpers
|
|
|
|
private func startMotionUpdates() {
|
|
guard motionManager.isDeviceMotionAvailable else { return }
|
|
motionManager.deviceMotionUpdateInterval = 0.05
|
|
motionManager.startDeviceMotionUpdates()
|
|
}
|
|
|
|
private func startRealTimeTick() {
|
|
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
guard let self, let coord = self.coordinate else { return }
|
|
self.currentPosition = SunMath.calculateSunPosition(date: Date(), coordinate: coord)
|
|
}
|
|
}
|
|
|
|
private func refreshArc() {
|
|
guard let coord = coordinate else { return }
|
|
arc = SunMath.sunPathArc(for: Date(), coordinate: coord)
|
|
riseSet = SunMath.sunRiseSet(for: Date(), coordinate: coord)
|
|
currentPosition = SunMath.calculateSunPosition(date: Date(), coordinate: coord)
|
|
isReady = true
|
|
}
|
|
|
|
// MARK: - CLLocationManagerDelegate
|
|
|
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
guard coordinate == nil, let loc = locations.last else { return }
|
|
coordinate = loc.coordinate
|
|
refreshArc()
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
|
|
heading = newHeading.trueHeading > 0 ? newHeading.trueHeading : newHeading.magneticHeading
|
|
if coordinate != nil { isReady = true }
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
print("[Sunseeker] Location error: \(error.localizedDescription)")
|
|
}
|
|
|
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
switch manager.authorizationStatus {
|
|
case .denied, .restricted:
|
|
locationError = "Location access needed to calculate the sun path. Please enable it in Settings."
|
|
case .notDetermined:
|
|
manager.requestWhenInUseAuthorization()
|
|
default:
|
|
locationError = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Degree helpers (internal to this file)
|
|
|
|
private extension Double {
|
|
var radians: Double { self * .pi / 180.0 }
|
|
}
|