App/Sources/GlobeView.swift
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}