import CoreLocation import SceneKit import SwiftUI #if targetEnvironment(simulator) /// A non-AR 3D view for testing the Sunseeker path logic on the iOS Simulator. /// Uses a synthetic camera, fake location, and mock heading instead of ARKit. struct SimulatorSunOverlayView: UIViewRepresentable { @Binding var sunNodesReady: Bool // Fake location (e.g. San Francisco) private let mockLocation = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194) private let mockHeading: Double = 0 // North func makeCoordinator() -> Coordinator { Coordinator(sunNodesReady: $sunNodesReady, mockLocation: mockLocation, mockHeading: mockHeading) } func makeUIView(context: Context) -> SCNView { let view = SCNView(frame: .zero) view.scene = SCNScene() view.allowsCameraControl = true // Swipe around the 3D space view.autoenablesDefaultLighting = true view.backgroundColor = UIColor(white: 0.1, alpha: 1.0) view.isPlaying = true // Force render loop view.showsStatistics = true // Prove it's rendering // Setup synthetic camera let cameraNode = SCNNode() cameraNode.camera = SCNCamera() cameraNode.camera?.zFar = 100 cameraNode.position = SCNVector3(x: 0, y: 0, z: 0) // Centered view.scene?.rootNode.addChildNode(cameraNode) context.coordinator.attach(to: view) return view } func updateUIView(_ uiView: SCNView, context: Context) {} final class Coordinator: NSObject { @Binding private var sunNodesReady: Bool private let mockLocation: CLLocationCoordinate2D private let mockHeading: Double private var arcRootNode = SCNNode() private var currentSunNode = SCNNode() private var updateTimer: Timer? init(sunNodesReady: Binding, mockLocation: CLLocationCoordinate2D, mockHeading: Double) { _sunNodesReady = sunNodesReady self.mockLocation = mockLocation self.mockHeading = mockHeading super.init() } func attach(to view: SCNView) { view.scene?.rootNode.addChildNode(arcRootNode) view.scene?.rootNode.addChildNode(currentSunNode) buildScene() startRealTimeTick() } deinit { updateTimer?.invalidate() } private func startRealTimeTick() { // Update current sun position every second updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in guard let self = self else { return } let cur = SunMath.calculateSunPosition(date: Date(), coordinate: self.mockLocation) // Need to remove previous child as we are completely replacing it self.currentSunNode.childNodes.forEach { $0.removeFromParentNode() } let radius: Float = 1.8 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 = self.worldPosition(for: cur, radius: radius) 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 = self.makeTextNode(text: "Now", color: .systemYellow, fontSize: 0.05) label.position = SCNVector3(0, 0.09, 0) orbNode.addChildNode(label) self.currentSunNode.addChildNode(orbNode) } } private func buildScene() { let arc = SunMath.sunPathArc(for: Date(), coordinate: mockLocation) let riseSet = SunMath.sunRiseSet(for: Date(), coordinate: mockLocation) let radius: Float = 1.8 var positions: [SCNVector3] = [] // Hourly blocks for (date, pos) in arc { guard pos.elevation > -5 else { continue } let worldPos = 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) 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) } } if positions.count >= 2 { let lineNode = makeLineNode(through: positions, color: UIColor.systemYellow.withAlphaComponent(0.55)) arcRootNode.addChildNode(lineNode) } if let riseDate = riseSet.rise { let risePos = SunMath.calculateSunPosition(date: riseDate, coordinate: mockLocation) let wPos = worldPosition(for: risePos, radius: radius) arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemOrange, label: "Sunrise \(hourLabel(from: riseDate))")) } if let setDate = riseSet.set { let setPos = SunMath.calculateSunPosition(date: setDate, coordinate: mockLocation) let wPos = worldPosition(for: setPos, radius: radius) arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemRed, label: "Sunset \(hourLabel(from: setDate))")) } // Generate current sun node synchronously for first frame updateTimer?.fire() DispatchQueue.main.async { self.sunNodesReady = true } } // MARK: Math equivalent from SunseekerViewModel private func worldPosition(for sun: SunPosition, radius: Float) -> SCNVector3 { let elev = Float(sun.elevation * .pi / 180.0) let az = Float(sun.azimuth * .pi / 180.0) let x = radius * cos(elev) * sin(az) let y = radius * sin(elev) let z = -radius * cos(elev) * cos(az) return SCNVector3(x, y, z) } // MARK: SceneKit 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 } private func makeTextNode(text: String, color: UIColor, fontSize: CGFloat) -> SCNNode { // SCNText is buggy in Simulator. Render text to a UIImage instead. let font = UIFont.systemFont(ofSize: 40, weight: .bold) let attributes: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: color ] let size = (text as NSString).size(withAttributes: attributes) // Add some padding let paddedSize = CGSize(width: size.width + 10, height: size.height + 10) let renderer = UIGraphicsImageRenderer(size: paddedSize) let image = renderer.image { context in (text as NSString).draw( in: CGRect(x: 5, y: 5, width: size.width, height: size.height), withAttributes: attributes ) } // Map the image onto an SCNPlane // A 100x50 image becomes a 0.1 x 0.05 meter plane let plane = SCNPlane(width: paddedSize.width / 1000.0, height: paddedSize.height / 1000.0) plane.firstMaterial?.diffuse.contents = image plane.firstMaterial?.isDoubleSided = true plane.firstMaterial?.lightingModel = .constant let textNode = SCNNode(geometry: plane) // Statically scale the plane up so it is readable next to the spheres textNode.scale = SCNVector3(1.5, 1.5, 1.5) let constraint = SCNBillboardConstraint() constraint.freeAxes = .all textNode.constraints = [constraint] return textNode } 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) } } } #endif