forked from sagnik/Project_Velocity
#38 Ipad app production readiness, Colony orchestration, Social posting Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#44
353 lines
14 KiB
Swift
353 lines
14 KiB
Swift
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<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)
|
|
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)
|
|
}
|
|
}
|