import ARKit import CoreLocation import CoreMotion import SceneKit import SwiftUI import UIKit // 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 config.planeDetection = [.horizontal, .vertical] if ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) { config.sceneReconstruction = .mesh } if ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) { config.frameSemantics.insert(.sceneDepth) } 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 measurementRootNode = SCNNode() private var isSceneBuilt = false private var pendingMeasurementPoint: SCNVector3? // Fallback timer for CoreMotion-only mode private var fallbackTimer: Timer? private var limitedTrackingStart: Date? init(sunNodesReady: Binding, 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) sceneView.scene.rootNode.addChildNode(measurementRootNode) let tap = UITapGestureRecognizer(target: self, action: #selector(handleMeasurementTap(_:))) sceneView.addGestureRecognizer(tap) } 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: - Measurement @objc private func handleMeasurementTap(_ recognizer: UITapGestureRecognizer) { guard let sceneView else { return } let point = recognizer.location(in: sceneView) guard let query = sceneView.raycastQuery( from: point, allowing: .estimatedPlane, alignment: .any ), let result = sceneView.session.raycast(query).first else { return } let transform = result.worldTransform let worldPoint = SCNVector3(transform.columns.3.x, transform.columns.3.y, transform.columns.3.z) addMeasurementPoint(worldPoint) } private func addMeasurementPoint(_ point: SCNVector3) { measurementRootNode.addChildNode(makeMeasurementMarker(at: point)) if let start = pendingMeasurementPoint { let distance = start.distance(to: point) measurementRootNode.addChildNode(makeLineNode(through: [start, point], color: UIColor.white.withAlphaComponent(0.82))) let midpoint = SCNVector3( (start.x + point.x) / 2, (start.y + point.y) / 2 + 0.045, (start.z + point.z) / 2 ) let label = makeTextNode( text: "\(String(format: "%.2f m", Double(distance))) \(String(format: "%.1f ft", Double(distance * 3.28084)))", color: .white, fontSize: 0.052 ) label.position = midpoint measurementRootNode.addChildNode(label) pendingMeasurementPoint = nil UIImpactFeedbackGenerator(style: .rigid).impactOccurred() } else { pendingMeasurementPoint = point UIImpactFeedbackGenerator(style: .soft).impactOccurred() } } private func makeMeasurementMarker(at position: SCNVector3) -> SCNNode { let sphere = SCNSphere(radius: 0.018) sphere.firstMaterial?.diffuse.contents = UIColor.white sphere.firstMaterial?.emission.contents = UIColor.systemBlue.withAlphaComponent(0.65) sphere.firstMaterial?.lightingModel = .constant let node = SCNNode(geometry: sphere) node.position = position return node } // 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 } } private extension SCNVector3 { func distance(to other: SCNVector3) -> Float { let dx = other.x - x let dy = other.y - y let dz = other.z - z return sqrtf(dx * dx + dy * dy + dz * dz) } }