krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

App/Sources/GlobeView.swift

v1.0.0
moonrise/App/Sources/GlobeView.swift history · blame · raw

89 lines · 4005 bytes

 1import Library
 2import RealityKit
 3import SwiftUI
 4
 5/// A Library body as a sphere you can turn with a drag: a global map from the app bundle
 6/// (Globes/<page id>.jpg) when there is one, otherwise the page's colour. Tilted by its axial tilt.
 7struct GlobeView: View {
 8    var page: LibraryPage
 9    var credit: String?
10    @State private var yaw: Float = 0
11    @State private var pitch: Float = 0
12    @State private var dragStart: (Float, Float)?
13
14    /// Pitch from the drag, then the axial tilt, then the spin.
15    private var orientation: simd_quatf {
16        let degrees: Double = page.drawing?.axialTiltDeg ?? 0
17        let tilt = simd_quatf(angle: Float(degrees * .pi / 180), axis: SIMD3<Float>(0, 0, 1))
18        let spin = simd_quatf(angle: yaw, axis: SIMD3<Float>(0, 1, 0))
19        return simd_quatf(angle: pitch, axis: SIMD3<Float>(1, 0, 0)) * tilt * spin
20    }
21
22    static func mapURL(_ id: String) -> URL? { Bundle.main.url(forResource: id, withExtension: "jpg", subdirectory: "Globes") }
23
24    var body: some View {
25        VStack(spacing: 12) {
26            RealityView { content in
27                let globe = await Self.makeGlobe(page)
28                #if os(visionOS)
29                // A 30 cm globe in the window; the room's lighting shades it.
30                globe.scale = .init(repeating: 0.3)
31                content.add(globe)
32                #else
33                content.camera = .virtual
34                content.add(globe)
35
36                let camera = PerspectiveCamera()
37                camera.position = [0, 0, 1.9]
38                content.add(camera)
39
40                let sun = DirectionalLight()
41                sun.light.intensity = 4000
42                sun.look(at: .zero, from: [-1.5, 0.3, 0.5], relativeTo: nil)
43                content.add(sun)
44                #endif
45            } update: { content in
46                guard let globe = content.entities.first(where: { $0.name == "globe" }) else { return }
47                globe.orientation = orientation
48            }
49            .gesture(DragGesture()
50                .onChanged { g in
51                    if dragStart == nil { dragStart = (yaw, pitch) }
52                    yaw = dragStart!.0 + Float(g.translation.width) * 0.01
53                    pitch = min(max(dragStart!.1 + Float(g.translation.height) * 0.01, -1.2), 1.2)
54                }
55                .onEnded { _ in dragStart = nil })
56            .accessibilityLabel("\(page.title) as a globe. Drag to turn it.")
57            Text(Self.mapURL(page.id) == nil
58                 ? "No global map yet; shown in its average colour. Drag to turn."
59                 : "Drag to turn. Map: \(credit ?? "NASA").")
60                .font(.caption2).foregroundStyle(.secondary)
61                .padding(.horizontal)
62        }
63        .background(Color.black.ignoresSafeArea())
64        .navigationTitle(page.title)
65        .navigationBarTitleDisplayMode(.inline)
66    }
67
68    @MainActor
69    static func makeGlobe(_ page: LibraryPage) async -> Entity {
70        var material = PhysicallyBasedMaterial()
71        material.roughness = .init(floatLiteral: 1)
72        material.metallic = .init(floatLiteral: 0)
73        if let url = mapURL(page.id), let texture = try? await TextureResource(contentsOf: url) {
74            material.baseColor = .init(texture: .init(texture))
75        } else {
76            material.baseColor = .init(tint: UIColor(Color(hex: page.drawing?.color ?? "") ?? .gray))
77        }
78        if page.id == "sun" {
79            var glow = UnlitMaterial(color: UIColor(Color(hex: page.drawing?.color ?? "") ?? .yellow))
80            if let url = mapURL(page.id), let texture = try? await TextureResource(contentsOf: url) { glow.color = .init(texture: .init(texture)) }
81            let sun = ModelEntity(mesh: .generateSphere(radius: 0.5), materials: [glow])
82            sun.name = "globe"
83            return sun
84        }
85        let globe = ModelEntity(mesh: .generateSphere(radius: 0.5), materials: [material])
86        globe.name = "globe"
87        return globe
88    }
89}