Files
Project_Velocity/iOS/velocity-ipad/velocity/Features/Inventory/ARSunOverlayView.swift

277 lines
11 KiB
Swift

import ARKit
import CoreLocation
import CoreMotion
import SceneKit
import SwiftUI
// MARK: - ARSunOverlayView
struct ARSunOverlayView: UIViewRepresentable {
@Binding var sunNodesReady: Bool
let vm: SunseekerViewModel
func makeCoordinator() -> Coordinator {
Coordinator(sunNodesReady: $sunNodesReady, vm: vm)
}
func makeUIView(context: Context) -> ARSCNView {
let view = ARSCNView(frame: .zero)
view.delegate = context.coordinator
view.scene = SCNScene()
view.automaticallyUpdatesLighting = true
let config = ARWorldTrackingConfiguration()
config.worldAlignment = .gravityAndHeading // north = -Z axis
view.session.run(config)
context.coordinator.attach(to: view)
return view
}
func updateUIView(_ uiView: ARSCNView, context: Context) {}
static func dismantleUIView(_ uiView: ARSCNView, coordinator: Coordinator) {
uiView.session.pause()
coordinator.stop()
}
// MARK: - Coordinator
final class Coordinator: NSObject, ARSCNViewDelegate, CLLocationManagerDelegate {
private weak var sceneView: ARSCNView?
private let vm: SunseekerViewModel
@Binding private var sunNodesReady: Bool
// Scene node containers (replaced on each rebuild)
private var arcRootNode = SCNNode()
private var currentSunNode = SCNNode()
private var isSceneBuilt = false
// Fallback timer for CoreMotion-only mode
private var fallbackTimer: Timer?
private var limitedTrackingStart: Date?
init(sunNodesReady: Binding<Bool>, vm: SunseekerViewModel) {
_sunNodesReady = sunNodesReady
self.vm = vm
}
func attach(to sceneView: ARSCNView) {
self.sceneView = sceneView
sceneView.scene.rootNode.addChildNode(arcRootNode)
sceneView.scene.rootNode.addChildNode(currentSunNode)
}
func stop() {
vm.stop()
fallbackTimer?.invalidate()
}
// MARK: - ARSCNViewDelegate per-frame update
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
guard vm.isReady else { return }
// Build arc once
if !isSceneBuilt {
DispatchQueue.main.async { self.buildScene() }
}
// Update current sun orb every frame
if let cur = vm.currentPosition {
let pos = vm.worldPosition(for: cur, radius: 1.8)
currentSunNode.position = pos
}
}
func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) {
switch camera.trackingState {
case .limited(let reason):
print("[Sunseeker] Tracking limited: \(reason)")
if limitedTrackingStart == nil {
limitedTrackingStart = Date()
// After 5s of limited tracking, switch to CoreMotion attitude fallback
fallbackTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in
self?.activateCoreMotionFallback()
}
}
case .normal:
limitedTrackingStart = nil
fallbackTimer?.invalidate()
fallbackTimer = nil
case .notAvailable:
break
@unknown default:
break
}
}
// MARK: - Scene Building
private func buildScene() {
guard sceneView != nil else { return }
// Remove old nodes
arcRootNode.childNodes.forEach { $0.removeFromParentNode() }
currentSunNode.childNodes.forEach { $0.removeFromParentNode() }
let arc = vm.arc
let radius: Float = 1.8
var positions: [SCNVector3] = []
// Hourly marker spheres + time labels
for (date, pos) in arc {
guard pos.elevation > -5 else { continue }
let worldPos = vm.worldPosition(for: pos, radius: radius)
positions.append(worldPos)
let sphere = SCNSphere(radius: 0.018)
sphere.firstMaterial?.diffuse.contents = UIColor.systemYellow.withAlphaComponent(0.85)
sphere.firstMaterial?.lightingModel = .constant
let markerNode = SCNNode(geometry: sphere)
markerNode.position = worldPos
arcRootNode.addChildNode(markerNode)
// Time label (only on even hours to avoid clutter)
let calendar = Calendar.current
let hour = calendar.component(.hour, from: date)
if hour % 2 == 0 {
let labelNode = makeTextNode(text: hourLabel(from: date), color: .white, fontSize: 0.04)
labelNode.position = SCNVector3(worldPos.x, worldPos.y + 0.06, worldPos.z)
arcRootNode.addChildNode(labelNode)
}
}
// Continuous arc line
if positions.count >= 2 {
let lineNode = makeLineNode(through: positions, color: UIColor.systemYellow.withAlphaComponent(0.55))
arcRootNode.addChildNode(lineNode)
}
// Sunrise marker
if let riseDate = vm.riseSet.rise {
let risePos = SunMath.calculateSunPosition(date: riseDate, coordinate: vm.coordinate!)
let wPos = vm.worldPosition(for: risePos, radius: radius)
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemOrange, label: "Sunrise \(hourLabel(from: riseDate))"))
}
// Sunset marker
if let setDate = vm.riseSet.set {
let setPos = SunMath.calculateSunPosition(date: setDate, coordinate: vm.coordinate!)
let wPos = vm.worldPosition(for: setPos, radius: radius)
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemRed, label: "Sunset \(hourLabel(from: setDate))"))
}
// Current sun orb (large, animated glow)
if let cur = vm.currentPosition {
let orb = SCNSphere(radius: 0.055)
orb.firstMaterial?.diffuse.contents = UIColor.systemOrange
orb.firstMaterial?.emission.contents = UIColor.systemYellow
orb.firstMaterial?.lightingModel = .constant
let orbNode = SCNNode(geometry: orb)
orbNode.position = vm.worldPosition(for: cur, radius: radius)
// Pulse animation
let pulse = CABasicAnimation(keyPath: "scale")
pulse.fromValue = SCNVector3(1, 1, 1)
pulse.toValue = SCNVector3(1.3, 1.3, 1.3)
pulse.duration = 1.2
pulse.autoreverses = true
pulse.repeatCount = .infinity
orbNode.addAnimation(pulse, forKey: "pulse")
let label = makeTextNode(text: "Now", color: .systemYellow, fontSize: 0.05)
label.position = SCNVector3(0, 0.09, 0)
orbNode.addChildNode(label)
currentSunNode.addChildNode(orbNode)
}
isSceneBuilt = true
sunNodesReady = true
}
// MARK: - Node Factories
private func makeSpecialMarker(at pos: SCNVector3, color: UIColor, label: String) -> SCNNode {
let root = SCNNode()
let sphere = SCNSphere(radius: 0.035)
sphere.firstMaterial?.diffuse.contents = color
sphere.firstMaterial?.emission.contents = color.withAlphaComponent(0.5)
sphere.firstMaterial?.lightingModel = .constant
let markerNode = SCNNode(geometry: sphere)
markerNode.position = pos
root.addChildNode(markerNode)
let labelNode = makeTextNode(text: label, color: .white, fontSize: 0.04)
labelNode.position = SCNVector3(pos.x, pos.y + 0.07, pos.z)
root.addChildNode(labelNode)
return root
}
/// Creates a billboard SCNText node that always faces the camera.
private func makeTextNode(text: String, color: UIColor, fontSize: CGFloat) -> SCNNode {
let scnText = SCNText(string: text, extrusionDepth: 0)
scnText.font = UIFont.systemFont(ofSize: fontSize * 100, weight: .medium)
scnText.firstMaterial?.diffuse.contents = color
scnText.firstMaterial?.lightingModel = .constant
scnText.isWrapped = false
let textNode = SCNNode(geometry: scnText)
textNode.scale = SCNVector3(fontSize / 100, fontSize / 100, fontSize / 100)
// Billboard constraint always face camera
let constraint = SCNBillboardConstraint()
constraint.freeAxes = .Y
textNode.constraints = [constraint]
// Centre text
let (min, max) = textNode.boundingBox
textNode.pivot = SCNMatrix4MakeTranslation((max.x - min.x) / 2, 0, 0)
return textNode
}
/// Builds a line strip SCNNode connecting all positions.
private func makeLineNode(through positions: [SCNVector3], color: UIColor) -> SCNNode {
guard positions.count >= 2 else { return SCNNode() }
let vertices: [SCNVector3] = positions
var indices: [Int32] = []
for i in 0..<(vertices.count - 1) {
indices.append(Int32(i))
indices.append(Int32(i + 1))
}
let vertexSource = SCNGeometrySource(vertices: vertices)
let element = SCNGeometryElement(
indices: indices,
primitiveType: .line
)
let geometry = SCNGeometry(sources: [vertexSource], elements: [element])
geometry.firstMaterial?.diffuse.contents = color
geometry.firstMaterial?.lightingModel = .constant
return SCNNode(geometry: geometry)
}
private func hourLabel(from date: Date) -> String {
let fmt = DateFormatter()
fmt.dateFormat = "ha"
fmt.amSymbol = "am"
fmt.pmSymbol = "pm"
return fmt.string(from: date)
}
// MARK: - CoreMotion Fallback
private func activateCoreMotionFallback() {
// In fallback mode we rely on CMMotionManager attitude (already running in SunseekerViewModel)
// and just keep the scene nodes updated via the 1s tick in the VM.
print("[Sunseeker] Switched to CoreMotion fallback — ARKit tracking unavailable.")
}
}
}
// MARK: - Degree helpers
private extension Double {
var radians: Double { self * .pi / 180.0 }
}