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/SolarSystem3D.swift

main
moonrise/App/Sources/SolarSystem3D.swift history · blame · raw

953 lines · 44947 bytes

50 symbols in this file
  1import Feeds
  2import Library
  3import RealityKit
  4import SkyEngine
  5import SkyRenderer
  6import SwiftUI
  7import UIKit
  8
  9/// How distances and sizes are drawn in the 3D solar system.
 10enum SolarScale: String, CaseIterable, Identifiable {
 11    /// Distances raised to the power 0.4 and sizes enlarged, so everything fits and can be seen.
 12    case compressed
 13    /// Distances and sizes both true (1 AU = 1 scene metre); planets need markers to be found.
 14    case trueScale
 15    var id: String { rawValue }
 16    var name: String { self == .compressed ? "Compressed" : "True scale" }
 17}
 18
 19/// One of the Sun's planets, with what the scene needs to draw it.
 20struct SolarPlanet: Identifiable, Sendable {
 21    var id: String
 22    var body: Body?
 23    var radiusKm: Double
 24    var color: String
 25    var tiltDeg: Double
 26    var periodDays: Double
 27
 28    static let all: [SolarPlanet] = [
 29        ("mercury", Body.mercury, 88.0), ("venus", .venus, 224.7), ("earth", nil, 365.25), ("mars", .mars, 687.0),
 30        ("jupiter", .jupiter, 4332.6), ("saturn", .saturn, 10_759.2), ("uranus", .uranus, 30_688.5), ("neptune", .neptune, 60_182.0),
 31    ].compactMap { id, body, period in
 32        guard let d = Library.page(id: id)?.drawing else { return nil }
 33        return SolarPlanet(id: id, body: body, radiusKm: d.radiusKm ?? 6371, color: d.color ?? "#cccccc", tiltDeg: d.axialTiltDeg ?? 0, periodDays: period)
 34    }
 35
 36    /// Pluto, which Astronomy Engine covers, drawn with the dwarf planets.
 37    static let pluto: SolarPlanet? = Library.page(id: "pluto")?.drawing.map {
 38        SolarPlanet(id: "pluto", body: .pluto, radiusKm: $0.radiusKm ?? 1188, color: $0.color ?? "#cdb79e", tiltDeg: $0.axialTiltDeg ?? 0, periodDays: 90_560)
 39    }
 40
 41    /// Heliocentric position on J2000 ecliptic axes, AU.
 42    func position(at date: Date) -> SIMD3<Double> {
 43        if let body { return (try? SkyEngine.heliocentricEcliptic(body, at: date)) ?? .zero }
 44        return (try? SkyEngine.heliocentricEarth(at: date)) ?? .zero
 45    }
 46}
 47
 48/// A moon from its Library page: exact positions for Earth's Moon and Jupiter's four big moons,
 49/// a circular orbit in the planet's equator plane (at an arbitrary starting point) for the rest.
 50struct SolarMoon: Identifiable, Sendable {
 51    var id: String
 52    var parent: String
 53    var distanceKm: Double
 54    var periodDays: Double
 55    var radiusKm: Double
 56    var color: String
 57
 58    static let galilean = ["io", "europa", "ganymede", "callisto"]
 59
 60    static let all: [SolarMoon] = Library.pages(in: .moons).compactMap { p in
 61        guard let dist = p.facts.first(where: { $0.label.hasPrefix("Mean distance from") }),
 62              let period = p.facts.first(where: { $0.label.hasPrefix("Orbital period") }),
 63              let km = number(dist.value), let days = number(period.value) else { return nil }
 64        let parent = dist.label.replacingOccurrences(of: "Mean distance from ", with: "").lowercased()
 65        let retrograde = p.facts.contains { $0.value == "Retrograde" }
 66        return SolarMoon(id: p.id, parent: parent, distanceKm: km, periodDays: retrograde ? -days : days,
 67                         radiusKm: p.drawing?.radiusKm ?? 100, color: p.drawing?.color ?? "#999999")
 68    }
 69
 70    /// 1070400 from "1,070,400 km"; 0.319 from "0.319 days (7 h 39 m)".
 71    static func number(_ s: String) -> Double? {
 72        s.split(separator: " ").first.flatMap { Double($0.replacingOccurrences(of: ",", with: "")) }
 73    }
 74
 75    var isExact: Bool { id == "moon" || Self.galilean.contains(id) }
 76}
 77
 78/// Maps ecliptic AU to scene metres (y up) and gives drawn radii for each scale.
 79struct SolarMapping {
 80    var scale: SolarScale
 81
 82    static let auKm = 1.495978707e8
 83    static let sunRadiusKm = 695_700.0
 84
 85    func point(_ v: SIMD3<Double>) -> SIMD3<Float> {
 86        let r = (v * v).sum().squareRoot()
 87        guard r > 0 else { return .zero }
 88        let d = scale == .compressed ? 1.6 * pow(r, 0.4) : r
 89        let u = v / r * d
 90        return SIMD3(Float(u.x), Float(u.z), Float(-u.y))
 91    }
 92
 93    /// Ecliptic axes to scene axes without changing length.
 94    static func axes(_ v: SIMD3<Double>) -> SIMD3<Float> { SIMD3(Float(v.x), Float(v.z), Float(-v.y)) }
 95
 96    func radius(km: Double) -> Float {
 97        scale == .compressed ? Float(0.09 * pow(km / 6371, 0.45)) : Float(km / Self.auKm)
 98    }
 99
100    /// How far from its planet a moon is drawn: log-squeezed in compressed scale, true otherwise.
101    func moonDistance(km: Double, planetRadiusKm: Double) -> Float {
102        scale == .compressed
103            ? radius(km: planetRadiusKm) * Float(1.2 + 1.6 * log10(max(km / planetRadiusKm, 1.5)))
104            : Float(km / Self.auKm)
105    }
106
107    var sunRadius: Float { scale == .compressed ? 0.35 : Float(Self.sunRadiusKm / Self.auKm) }
108}
109
110/// Orbit camera around a target point.
111struct SolarCamera: Equatable {
112    var target: SIMD3<Float> = .zero
113    var yaw: Float = 0.4
114    var pitch: Float = 0.55
115    var distance: Float = 14
116    static let fovDegrees: Float = 50
117
118    var position: SIMD3<Float> {
119        target + distance * SIMD3(cos(pitch) * sin(yaw), sin(pitch), cos(pitch) * cos(yaw))
120    }
121
122    /// Screen position of a scene point, or nil if it is behind the camera.
123    func project(_ p: SIMD3<Float>, in size: CGSize) -> CGPoint? {
124        let pos = position
125        let f = simd_normalize(target - pos)
126        let r = simd_normalize(simd_cross(f, SIMD3(0, 1, 0)))
127        let u = simd_cross(r, f)
128        let v = p - pos
129        let z = simd_dot(v, f)
130        guard z > 0 else { return nil }
131        let k = Float(size.height / 2) / tan(Self.fovDegrees * .pi / 360)
132        return CGPoint(x: size.width / 2 + CGFloat(simd_dot(v, r) / z * k), y: size.height / 2 - CGFloat(simd_dot(v, u) / z * k))
133    }
134
135    func distance(to p: SIMD3<Float>) -> Float { simd_length(p - position) }
136}
137
138/// The live space weather the scene shows: a flare on the Sun and an aurora ring on Earth.
139struct SolarWeather: Equatable {
140    var kp: Double?
141    /// Strongest X-ray flux in the last three hours and when it peaked.
142    var flare: (flux: Double, time: Date)?
143
144    static func == (a: SolarWeather, b: SolarWeather) -> Bool { a.kp == b.kp && a.flare?.flux == b.flare?.flux }
145
146    var showsFlare: Bool { (flare?.flux ?? 0) >= 1e-5 }
147    var showsAurora: Bool { (kp ?? 0) >= 4 }
148
149    var summary: String {
150        var parts: [String] = []
151        if let f = flare {
152            parts.append(showsFlare
153                ? "\(XRay.flareClass(f.flux)) solar flare at \(f.time.formatted(date: .omitted, time: .shortened))"
154                : "No large flares in the last 3 h (peak \(XRay.flareClass(f.flux)))")
155        }
156        if let kp {
157            parts.append(showsAurora
158                ? String(format: "Kp %.1f: aurora ring around Earth's poles", kp)
159                : String(format: "Kp %.1f: geomagnetic field quiet", kp))
160        }
161        return parts.isEmpty ? "Loading space weather" : parts.joined(separator: " · ")
162    }
163}
164
165/// Which optional layers are drawn.
166struct SolarLayers: Equatable {
167    var moons = true
168    var dwarfs = true
169    var craft = true
170    var comets = true
171}
172
173/// Anything the scene draws and can label or fly to.
174struct SolarMarker {
175    enum Kind { case sun, planet, moon, dwarf, craft, comet }
176    var id: String
177    var name: String
178    var kind: Kind
179    var scene: SIMD3<Float>
180    /// Drawn radius in scene units (for dots, the dot's size at the current zoom).
181    var radius: Float
182    var visible: Bool
183}
184
185/// Entities created once and moved on each update.
186@MainActor
187final class SolarSceneEntities {
188    let root = Entity()
189    let camera = PerspectiveCamera()
190    let sun = ModelEntity()
191    var bodies: [String: ModelEntity] = [:]
192    var nights: [String: ModelEntity] = [:]
193    var tails: [String: ModelEntity] = [:]
194    var orbits: [String: ModelEntity] = [:]
195    var orbitsBuilt: (SolarScale, Float)?
196    var paths: [String: ModelEntity] = [:]
197    var pathsBuilt: (SolarScale, Float, Int)?
198    let debris = ModelEntity()
199    var debrisBuilt: (SolarScale, Float, String)?
200    let flare = ModelEntity()
201    let aurora = Entity()
202}
203
204struct SolarSystem3DView: View {
205    var date: Date
206    var live: Bool
207    @Binding var scale: SolarScale
208    var ink: Color
209    var accent: Color
210    var onOpen: (LibraryPage) -> Void
211
212    @State private var entities = SolarSceneEntities()
213    @State private var cam = SolarCamera()
214    @State private var dragStart: SolarCamera?
215    @State private var pinchStart: Float?
216    @State private var selected: String?
217    @State private var weather = SolarWeather()
218    @State private var flight: Task<Void, Never>?
219    @State private var pulse = false
220    @State private var viewSize = CGSize(width: 400, height: 600)
221    @AppStorage("solar.moons") private var showMoons = true
222    @AppStorage("solar.dwarfs") private var showDwarfs = true
223    @AppStorage("solar.craft") private var showCraft = true
224    @AppStorage("solar.comets") private var showComets = true
225
226    private var mapping: SolarMapping { SolarMapping(scale: scale) }
227    private var layers: SolarLayers { SolarLayers(moons: showMoons, dwarfs: showDwarfs, craft: showCraft, comets: showComets) }
228
229    private static let mainComets: Set<String> = ["halley", "encke", "67p"]
230    private static let craftColor = UIColor(red: 0.88, green: 0.24, blue: 0.19, alpha: 1)
231
232    /// Meteor showers within a week of their peak, with a parent orbit to draw.
233    private var activeShowers: [MeteorShower] {
234        MeteorShower.major.filter { $0.parent != nil && abs($0.daysToPeak(from: date)) <= 7 }
235    }
236
237    var body: some View {
238        GeometryReader { geo in
239            ZStack {
240                RealityView { content in
241                    content.camera = .virtual
242                    build(content)
243                } update: { _ in
244                    place()
245                }
246                .gesture(dragGesture.simultaneously(with: pinchGesture))
247                .onTapGesture(coordinateSpace: .local) { tap($0, size: geo.size) }
248
249                labels(size: geo.size).allowsHitTesting(false)
250
251                VStack(spacing: 8) {
252                    if let s = selected { card(s) }
253                    Spacer()
254                    HStack(alignment: .bottom) {
255                        Text(summary)
256                            .font(.caption2).dimmed(0.8)
257                            .padding(.horizontal, 10).padding(.vertical, 6)
258                            .background(Color.black.opacity(0.7), in: RoundedRectangle(cornerRadius: 10))
259                        Spacer(minLength: 8)
260                        layerMenu
261                    }
262                }
263                .padding(12)
264            }
265            .clipped()
266            .onAppear { viewSize = geo.size; cam.distance = fitDistance() }
267            .onChange(of: geo.size) { viewSize = geo.size }
268        }
269        .accessibilityElement(children: .contain)
270        .accessibilityLabel("3D solar system. Drag to turn, pinch to zoom, tap a body to learn about it.")
271        .onChange(of: scale) { resetView() }
272        .task { await loadWeather() }
273        .task {
274            while !Task.isCancelled {
275                try? await Task.sleep(for: .milliseconds(700))
276                pulse.toggle()
277            }
278        }
279    }
280
281    private var summary: String {
282        var lines: [String] = []
283        if let s = activeShowers.first, let parent = KeplerOrbit.comets.first(where: { $0.id == s.parent }) {
284            let d = s.daysToPeak(from: date)
285            let when = abs(d) < 1 ? "peak today" : d > 0 ? "peak in \(Int(d.rounded())) days" : "peaked \(Int((-d).rounded())) days ago"
286            lines.append("\(s.name) \(when): Earth is crossing debris from \(parent.name).")
287        }
288        lines.append(live ? weather.summary : "Live space weather is shown only for today.")
289        return lines.joined(separator: "\n")
290    }
291
292    private var layerMenu: some View {
293        Menu {
294            Toggle("Moons", isOn: $showMoons)
295            Toggle("Dwarf planets", isOn: $showDwarfs)
296            Toggle("Spacecraft", isOn: $showCraft)
297            Toggle("Comets and meteor showers", isOn: $showComets)
298        } label: {
299            Image(systemName: "square.3.layers.3d").font(.body).frame(width: 44, height: 44)
300                .background(Color.black.opacity(0.7), in: Circle())
301        }
302        .accessibilityLabel("Layers")
303    }
304
305    // MARK: Markers
306
307    /// Every body's scene position and drawn size for the current date, scale, layers and camera.
308    private func markers() -> [SolarMarker] {
309        let map = mapping
310        var out: [SolarMarker] = [SolarMarker(id: "sun", name: "Sun", kind: .sun, scene: .zero, radius: map.sunRadius, visible: true)]
311        var parents: [String: (scene: SIMD3<Float>, radiusKm: Double, tiltDeg: Double)] = [:]
312        func dot(_ p: SIMD3<Float>) -> Float { cam.distance(to: p) * 0.006 }
313
314        for p in SolarPlanet.all {
315            let s = map.point(p.position(at: date))
316            parents[p.id] = (s, p.radiusKm, p.tiltDeg)
317            out.append(SolarMarker(id: p.id, name: Library.page(id: p.id)?.title ?? p.id, kind: .planet, scene: s, radius: map.radius(km: p.radiusKm), visible: true))
318        }
319        if let pluto = SolarPlanet.pluto {
320            let s = map.point(pluto.position(at: date))
321            parents["pluto"] = (s, pluto.radiusKm, pluto.tiltDeg)
322            out.append(SolarMarker(id: "pluto", name: "Pluto", kind: .dwarf, scene: s, radius: map.radius(km: pluto.radiusKm), visible: layers.dwarfs))
323        }
324        for k in KeplerOrbit.dwarfPlanets {
325            let r = Library.page(id: k.id)?.drawing?.radiusKm ?? 500
326            out.append(SolarMarker(id: k.id, name: k.name, kind: .dwarf, scene: map.point(k.position(at: date)), radius: map.radius(km: r), visible: layers.dwarfs))
327        }
328
329        // Moons appear when the camera is near their planet (or the planet or moon is selected).
330        let galilean = SkyEngine.galileanMoons(at: date)
331        let jd = date.timeIntervalSince1970 / 86_400 + 2_440_587.5
332        var reach: [String: Float] = [:]
333        var moonMarkers: [SolarMarker] = []
334        for m in SolarMoon.all {
335            guard let parent = parents[m.parent] else { continue }
336            let dist = map.moonDistance(km: m.distanceKm, planetRadiusKm: parent.radiusKm)
337            reach[m.parent] = max(reach[m.parent] ?? 0, dist)
338            var dir: SIMD3<Float>
339            if m.id == "moon" {
340                dir = simd_normalize(SolarMapping.axes(SkyEngine.geocentricMoonEcliptic(at: date)))
341            } else if let i = SolarMoon.galilean.firstIndex(of: m.id) {
342                dir = simd_normalize(SolarMapping.axes(galilean[i]))
343            } else {
344                let phase = Double(m.id.unicodeScalars.reduce(0) { $0 + Int($1.value) } % 97) / 97
345                let a = 2 * Double.pi * ((jd - 2_451_545) / m.periodDays + phase)
346                let flat = SIMD3(Float(cos(a)), 0, Float(sin(a)))
347                dir = simd_quatf(angle: Float(parent.tiltDeg * .pi / 180), axis: [1, 0, 0]).act(flat)
348            }
349            moonMarkers.append(SolarMarker(id: m.id, name: Library.page(id: m.id)?.title ?? m.id, kind: .moon,
350                                           scene: parent.scene + dir * dist, radius: map.radius(km: m.radiusKm), visible: false))
351        }
352        for i in moonMarkers.indices {
353            let m = SolarMoon.all.first { $0.id == moonMarkers[i].id }!
354            guard let parent = parents[m.parent], let r = reach[m.parent] else { continue }
355            let near = cam.distance(to: parent.scene) < r * 16
356            let chosen = selected == m.id || selected == m.parent
357            let parentShown = m.parent != "pluto" || layers.dwarfs
358            moonMarkers[i].visible = layers.moons && parentShown && (near || chosen)
359        }
360        out += moonMarkers
361
362        for craft in Spacecraft.all {
363            guard let p = craft.position3(at: date) else { continue }
364            let s = map.point(p)
365            out.append(SolarMarker(id: "craft:\(craft.name)", name: craft.name, kind: .craft, scene: s, radius: dot(s), visible: layers.craft))
366        }
367
368        let showerParents = Set(activeShowers.compactMap(\.parent))
369        for c in KeplerOrbit.comets {
370            let s = map.point(c.position(at: date))
371            let shown = layers.comets && (Self.mainComets.contains(c.id) || showerParents.contains(c.id))
372            out.append(SolarMarker(id: "comet:\(c.id)", name: c.name, kind: .comet, scene: s, radius: dot(s), visible: shown))
373        }
374        return out
375    }
376
377    // MARK: Scene
378
379    private func build(_ content: RealityViewCameraContent) {
380        let e = entities
381        content.add(e.root)
382        e.camera.camera.fieldOfViewInDegrees = SolarCamera.fovDegrees
383        e.root.addChild(e.camera)
384
385        let sunMaterial = UnlitMaterial(color: UIColor(red: 1, green: 0.86, blue: 0.5, alpha: 1))
386        e.sun.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [sunMaterial])
387        e.root.addChild(e.sun)
388        // Glow: nested translucent shells, fainter outward.
389        for i in 1...10 {
390            let r = 1 + Float(i) * 0.07
391            let a = CGFloat(0.09 * pow(0.78, Double(i - 1)))
392            var g = UnlitMaterial(color: UIColor(red: 1, green: 0.72, blue: 0.32, alpha: a))
393            g.blending = .transparent(opacity: .init(floatLiteral: Float(a)))
394            e.sun.addChild(ModelEntity(mesh: .generateSphere(radius: r), materials: [g]))
395        }
396
397        // Planets, dwarf planets and moons: textured where there is a map, with a night side.
398        var spheres: [(id: String, color: String, tilt: Double)] = SolarPlanet.all.map { ($0.id, $0.color, $0.tiltDeg) }
399        if let pluto = SolarPlanet.pluto { spheres.append(("pluto", pluto.color, pluto.tiltDeg)) }
400        spheres += KeplerOrbit.dwarfPlanets.map { ($0.id, Library.page(id: $0.id)?.drawing?.color ?? "#999999", 0) }
401        spheres += SolarMoon.all.map { ($0.id, $0.color, 0) }
402        for s in spheres {
403            let body = ModelEntity()
404            // Plain colour first; the map, if there is one, is swapped in once it has loaded.
405            let m = UnlitMaterial(color: UIColor(Color(hex: s.color) ?? .gray))
406            body.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [m])
407            body.orientation = simd_quatf(angle: Float(s.tilt * .pi / 180), axis: [1, 0, 0])
408            if s.id == "saturn" { body.addChild(Self.annulus(inner: 1.24, outer: 2.27, color: UIColor(red: 0.85, green: 0.78, blue: 0.62, alpha: 0.7))) }
409            e.bodies[s.id] = body
410            e.root.addChild(body)
411            let night = Self.nightShell()
412            e.nights[s.id] = night
413            e.root.addChild(night)
414        }
415
416        // Spacecraft and comet nuclei: dots sized each frame to stay visible.
417        for craft in Spacecraft.all {
418            let dot = ModelEntity(mesh: .generateSphere(radius: 1), materials: [UnlitMaterial(color: Self.craftColor)])
419            e.bodies["craft:\(craft.name)"] = dot
420            e.root.addChild(dot)
421        }
422        for c in KeplerOrbit.comets {
423            let nucleus = ModelEntity(mesh: .generateSphere(radius: 1), materials: [UnlitMaterial(color: UIColor(white: 0.92, alpha: 1))])
424            e.bodies["comet:\(c.id)"] = nucleus
425            e.root.addChild(nucleus)
426            // A soft tail: nested cones, wider and fainter outward.
427            let tail = ModelEntity()
428            for (radius, alpha) in [(0.025, 0.3), (0.06, 0.14), (0.11, 0.06)] as [(Float, Float)] {
429                var t = UnlitMaterial(color: UIColor(red: 0.7, green: 0.88, blue: 1, alpha: 1))
430                t.blending = .transparent(opacity: .init(floatLiteral: alpha))
431                tail.addChild(ModelEntity(mesh: .generateCone(height: 1, radius: radius), materials: [t]))
432            }
433            e.tails[c.id] = tail
434            e.root.addChild(tail)
435        }
436
437        var debrisMaterial = UnlitMaterial(color: UIColor(red: 1, green: 0.88, blue: 0.65, alpha: 0.55))
438        debrisMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.55))
439        debrisMaterial.faceCulling = .none
440        e.debris.model = ModelComponent(mesh: .generateBox(size: 0.0001), materials: [debrisMaterial])
441        e.root.addChild(e.debris)
442
443        var flareMaterial = UnlitMaterial(color: UIColor(red: 1, green: 1, blue: 0.85, alpha: 0.9))
444        flareMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.9))
445        e.flare.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [flareMaterial])
446        e.root.addChild(e.flare)
447        for y: Float in [0.86, -0.86] {
448            for (inner, outer, alpha) in [(0.36, 0.66, 0.22), (0.45, 0.56, 0.5)] as [(Float, Float, CGFloat)] {
449                let ring = Self.annulus(inner: inner, outer: outer, color: UIColor(red: 0.35, green: 1, blue: 0.55, alpha: alpha))
450                ring.position.y = y * 1.02
451                e.aurora.addChild(ring)
452            }
453        }
454        e.bodies["earth"]?.addChild(e.aurora)
455        place()
456        loadTextures()
457    }
458
459    /// Loads surface maps off the main thread (about 0.6 s for all of them) and applies each as it arrives.
460    private func loadTextures() {
461        let e = entities
462        var jobs: [(entity: ModelEntity, url: URL, tint: UIColor)] = e.bodies.compactMap { id, entity in
463            GlobeView.mapURL(id).map { (entity, $0, .white) }
464        }
465        if let sun = Bundle.main.url(forResource: "sun", withExtension: "jpg", subdirectory: "Globes") { jobs.append((e.sun, sun, .white)) }
466        for job in jobs {
467            Task {
468                guard let texture = try? await TextureResource(contentsOf: job.url) else { return }
469                var m = UnlitMaterial()
470                m.color = .init(tint: job.tint, texture: .init(texture))
471                job.entity.model?.materials = [m]
472            }
473        }
474    }
475
476    /// Moves everything for the current date, scale, layers and camera.
477    private func place() {
478        let e = entities
479        let map = mapping
480        e.sun.scale = .init(repeating: map.sunRadius)
481        for m in markers() where m.kind != .sun {
482            guard let body = e.bodies[m.id] else { continue }
483            body.isEnabled = m.visible
484            body.position = m.scene
485            body.scale = .init(repeating: m.radius)
486            if let night = e.nights[m.id] {
487                night.isEnabled = m.visible
488                night.position = m.scene
489                night.scale = .init(repeating: m.radius * 1.004)
490                night.orientation = simd_quatf(from: SIMD3(0, 1, 0), to: simd_normalize(-m.scene))
491            }
492            if m.kind == .comet, let tail = e.tails[String(m.id.dropFirst(6))] {
493                place(tail: tail, at: m, map: map)
494            }
495        }
496        rebuildOrbitsIfNeeded()
497        rebuildPathsIfNeeded()
498        rebuildDebrisIfNeeded()
499        // Planet orbits are a guide at system scale; close to a planet they only cut across it.
500        let farEnough = cam.distance > map.radius(km: 70_000) * 25
501        for (id, o) in e.orbits {
502            o.isEnabled = farEnough && orbitShown(id)
503        }
504        for p in e.paths.values { p.isEnabled = farEnough && layers.craft }
505
506        if live, weather.showsFlare, let earth = e.bodies["earth"] {
507            e.flare.isEnabled = true
508            e.flare.position = simd_normalize(earth.position) * map.sunRadius * 1.02
509            e.flare.scale = .init(repeating: map.sunRadius * (pulse ? 0.22 : 0.16))
510        } else {
511            e.flare.isEnabled = false
512        }
513        e.aurora.isEnabled = live && weather.showsAurora
514
515        e.camera.look(at: cam.target, from: cam.position, relativeTo: nil)
516        e.camera.camera.near = max(cam.distance * 0.001, 1e-9)
517        e.camera.camera.far = cam.distance * 100 + 400
518    }
519
520    /// A comet's tail points away from the Sun and grows as the comet nears it; none beyond 4 AU.
521    private func place(tail: ModelEntity, at m: SolarMarker, map: SolarMapping) {
522        guard let c = KeplerOrbit.comets.first(where: { "comet:\($0.id)" == m.id }) else { return }
523        let au = (c.position(at: date) * c.position(at: date)).sum().squareRoot()
524        guard m.visible, au < 4 else { tail.isEnabled = false; return }
525        tail.isEnabled = true
526        let away = simd_normalize(m.scene)
527        let length = (map.scale == .compressed ? 0.9 : 0.4) * Float(min(1 / au, 2.5))
528        // The cone's apex is at +y: point it at the Sun so the tail widens away from it.
529        tail.orientation = simd_quatf(from: SIMD3(0, 1, 0), to: -away)
530        tail.scale = .init(repeating: length)
531        tail.position = m.scene + away * length / 2
532    }
533
534    private func orbitShown(_ id: String) -> Bool {
535        if id.hasPrefix("comet:") {
536            let c = String(id.dropFirst(6))
537            return layers.comets && (Self.mainComets.contains(c) || activeShowers.contains { $0.parent == c })
538        }
539        if id.hasPrefix("dwarf:") { return layers.dwarfs }
540        return true
541    }
542
543    /// Orbit ribbons for planets, dwarf planets and comets; rebuilt when the scale changes or the zoom
544    /// changes enough that their width would look wrong.
545    private func rebuildOrbitsIfNeeded() {
546        let e = entities
547        let width = cam.distance * 0.0022
548        if let (s, w) = e.orbitsBuilt, s == scale, w / width < 1.6, width / w < 1.6 { return }
549        e.orbitsBuilt = (scale, width)
550        let map = mapping
551        func set(_ id: String, _ points: [SIMD3<Float>], color: UIColor, opacity: Float) {
552            let mesh = Self.ribbon(points, width: width, closed: true)
553            if let existing = e.orbits[id] { existing.model?.mesh = mesh; return }
554            var m = UnlitMaterial(color: color)
555            m.blending = .transparent(opacity: .init(floatLiteral: opacity))
556            m.faceCulling = .none
557            let orbit = ModelEntity(mesh: mesh, materials: [m])
558            e.orbits[id] = orbit
559            e.root.addChild(orbit)
560        }
561        for p in SolarPlanet.all + [SolarPlanet.pluto].compactMap({ $0 }) {
562            let pts = (0..<240).map { i in map.point(p.position(at: date.addingTimeInterval(Double(i) / 240 * p.periodDays * 86_400))) }
563            set(p.id == "pluto" ? "dwarf:pluto" : p.id, pts, color: .white, opacity: p.id == "pluto" ? 0.12 : 0.22)
564        }
565        for k in KeplerOrbit.dwarfPlanets {
566            set("dwarf:\(k.id)", k.orbitPath().map(map.point), color: .white, opacity: 0.12)
567        }
568        for c in KeplerOrbit.comets {
569            set("comet:\(c.id)", c.orbitPath(steps: 400).map(map.point), color: UIColor(red: 0.6, green: 0.8, blue: 1, alpha: 1), opacity: 0.22)
570        }
571    }
572
573    /// Spacecraft paths from launch to the slider's date, rebuilt every 10 days of date change.
574    private func rebuildPathsIfNeeded() {
575        let e = entities
576        let width = cam.distance * 0.0022
577        let bucket = Int(date.timeIntervalSince1970 / 864_000)
578        let built = e.pathsBuilt
579        if let built, built.0 == scale, built.1 / width < 1.6, width / built.1 < 1.6, built.2 == bucket { return }
580        e.pathsBuilt = (scale, width, bucket)
581        let map = mapping
582        let day = date.timeIntervalSince1970 / 86_400
583        for craft in Spacecraft.all {
584            var pts = craft.points.filter { $0[0] <= day }.map { map.point(SIMD3($0[1], $0[2], $0[3])) }
585            if let now = craft.position3(at: date) { pts.append(map.point(now)) }
586            let mesh: MeshResource = pts.count > 1 ? Self.ribbon(pts, width: width, closed: false) : .generateBox(size: 0.0001)
587            if let existing = e.paths[craft.name] {
588                existing.model?.mesh = mesh
589            } else {
590                var m = UnlitMaterial(color: Self.craftColor)
591                m.blending = .transparent(opacity: .init(floatLiteral: 0.4))
592                m.faceCulling = .none
593                let path = ModelEntity(mesh: mesh, materials: [m])
594                e.paths[craft.name] = path
595                e.root.addChild(path)
596            }
597        }
598    }
599
600    /// Debris along an active meteor shower's parent orbit: small specks, densest where it crosses Earth's orbit.
601    private func rebuildDebrisIfNeeded() {
602        let e = entities
603        let showers = layers.comets ? activeShowers.compactMap(\.parent).sorted().joined(separator: ",") : ""
604        let size = cam.distance * 0.0016
605        if let (s, w, key) = e.debrisBuilt, s == scale, key == showers, w / size < 1.6, size / w < 1.6 { return }
606        e.debrisBuilt = (scale, size, showers)
607        guard !showers.isEmpty else { e.debris.isEnabled = false; return }
608        e.debris.isEnabled = true
609        let map = mapping
610        var rng = SeededRandom(seed: 42)
611        var positions: [SIMD3<Float>] = []
612        var indices: [UInt32] = []
613        for id in showers.split(separator: ",") {
614            guard let c = KeplerOrbit.comets.first(where: { $0.id == id }) else { continue }
615            for p in c.orbitPath(steps: 360) {
616                let r = (p * p).sum().squareRoot()
617                // More specks near 1 AU, where Earth meets the stream.
618                let copies = abs(r - 1) < 0.15 ? 4 : 1
619                for _ in 0..<copies {
620                    let jitter = SIMD3(rng.next() - 0.5, rng.next() - 0.5, rng.next() - 0.5) * 0.08 * max(r, 0.3)
621                    let c = map.point(p + jitter)
622                    let base = UInt32(positions.count)
623                    positions += [c + SIMD3(size, 0, 0), c + SIMD3(-size / 2, size, 0), c + SIMD3(-size / 2, -size / 2, size)]
624                    indices += [base, base + 1, base + 2]
625                }
626            }
627        }
628        var d = MeshDescriptor(name: "debris")
629        d.positions = MeshBuffer(positions)
630        d.primitives = .triangles(indices)
631        if let mesh = try? MeshResource.generate(from: [d]) { e.debris.model?.mesh = mesh }
632    }
633
634    // MARK: Meshes
635
636    /// A flat ribbon through `points`, lying in the plane of the orbit.
637    private static func ribbon(_ points: [SIMD3<Float>], width: Float, closed: Bool) -> MeshResource {
638        var positions: [SIMD3<Float>] = []
639        var indices: [UInt32] = []
640        let n = points.count
641        for i in 0..<n {
642            let next = closed ? points[(i + 1) % n] : points[min(i + 1, n - 1)]
643            let prev = closed ? points[(i + n - 1) % n] : points[max(i - 1, 0)]
644            var t = next - prev
645            if simd_length(t) < 1e-12 { t = SIMD3(1, 0, 0) }
646            var side = simd_cross(simd_normalize(t), SIMD3(0, 1, 0))
647            if simd_length(side) < 1e-6 { side = SIMD3(1, 0, 0) }
648            side = simd_normalize(side) * width / 2
649            positions += [points[i] - side, points[i] + side]
650            if closed || i < n - 1 {
651                let a = UInt32(2 * i), b = UInt32(2 * ((i + 1) % n))
652                indices += [a, a + 1, b, b, a + 1, b + 1]
653            }
654        }
655        var d = MeshDescriptor(name: "orbit")
656        d.positions = MeshBuffer(positions)
657        d.primitives = .triangles(indices)
658        return (try? MeshResource.generate(from: [d])) ?? .generateBox(size: 0.0001)
659    }
660
661    /// A flat ring in the local x-z plane, radii in units of the parent's radius.
662    private static func annulus(inner: Float, outer: Float, color: UIColor) -> ModelEntity {
663        let n = 96
664        var positions: [SIMD3<Float>] = []
665        var indices: [UInt32] = []
666        for i in 0..<n {
667            let a = Float(i) / Float(n) * 2 * .pi
668            positions += [SIMD3(cos(a) * inner, 0, sin(a) * inner), SIMD3(cos(a) * outer, 0, sin(a) * outer)]
669            let p = UInt32(2 * i), q = UInt32(2 * ((i + 1) % n))
670            indices += [p, p + 1, q, q, p + 1, q + 1]
671        }
672        var d = MeshDescriptor(name: "ring")
673        d.positions = MeshBuffer(positions)
674        d.primitives = .triangles(indices)
675        var m = UnlitMaterial(color: color)
676        m.blending = .transparent(opacity: .init(floatLiteral: Float(color.cgColor.alpha)))
677        m.faceCulling = .none
678        return ModelEntity(mesh: (try? MeshResource.generate(from: [d])) ?? .generateBox(size: 0.001), materials: [m])
679    }
680
681    /// The half of a unit sphere below y = 0, drawn dark and mostly opaque over a body's night side.
682    private static func nightShell() -> ModelEntity {
683        let rings = 24, segments = 64
684        var positions: [SIMD3<Float>] = []
685        var indices: [UInt32] = []
686        for i in 0...rings {
687            let lat = -Float.pi / 2 * Float(i) / Float(rings)
688            for j in 0...segments {
689                let lon = Float(j) / Float(segments) * 2 * .pi
690                positions.append(SIMD3(cos(lat) * cos(lon), sin(lat), cos(lat) * sin(lon)))
691            }
692        }
693        for i in 0..<rings {
694            for j in 0..<segments {
695                let a = UInt32(i * (segments + 1) + j), b = a + UInt32(segments + 1)
696                indices += [a, b, a + 1, a + 1, b, b + 1]
697            }
698        }
699        var d = MeshDescriptor(name: "night")
700        d.positions = MeshBuffer(positions)
701        d.primitives = .triangles(indices)
702        var m = UnlitMaterial(color: .black)
703        m.blending = .transparent(opacity: .init(floatLiteral: 0.88))
704        m.faceCulling = .none
705        return ModelEntity(mesh: (try? MeshResource.generate(from: [d])) ?? .generateBox(size: 0.001), materials: [m])
706    }
707
708    // MARK: Labels, taps and the card
709
710    private func screenPoints(size: CGSize) -> [(marker: SolarMarker, point: CGPoint, radius: CGFloat)] {
711        markers().filter(\.visible).compactMap { m in
712            cam.project(m.scene, in: size).map { (m, $0, pixelRadius(m.radius, at: m.scene, size: size)) }
713        }
714    }
715
716    private func pixelRadius(_ r: Float, at p: SIMD3<Float>, size: CGSize) -> CGFloat {
717        let k = Float(size.height / 2) / tan(SolarCamera.fovDegrees * .pi / 360)
718        return CGFloat(r / max(cam.distance(to: p), 1e-9) * k)
719    }
720
721    /// Points to label, most important first, skipping a label that would sit on one already placed.
722    private func labelled(size: CGSize) -> [(marker: SolarMarker, point: CGPoint, radius: CGFloat, showName: Bool)] {
723        let planetOrder = ["jupiter", "saturn", "uranus", "neptune", "earth", "venus", "mars", "mercury"]
724        func rank(_ m: SolarMarker) -> Int {
725            switch m.kind {
726            case .sun: 0
727            case .planet: 1 + (planetOrder.firstIndex(of: m.id) ?? 9)
728            case .craft: 20
729            case .dwarf: 30
730            case .comet: 40
731            case .moon: 50
732            }
733        }
734        var placed: [CGPoint] = []
735        let points = screenPoints(size: size)
736        // Anything whose centre falls on the disc of a nearer body is hidden behind it.
737        let discs = points.filter { $0.radius > 4 }.map { ($0.point, $0.radius, cam.distance(to: $0.marker.scene)) }
738        return points
739            .filter { item in
740                let d = cam.distance(to: item.marker.scene)
741                return !discs.contains { $0.2 < d && hypot($0.0.x - item.point.x, $0.0.y - item.point.y) < $0.1 }
742            }
743            .sorted { rank($0.marker) < rank($1.marker) }
744            .map { item in
745                let at = CGPoint(x: item.point.x, y: item.point.y - max(item.radius, 5) - 9)
746                let clear = item.marker.id == selected || placed.allSatisfy { abs($0.x - at.x) > 48 || abs($0.y - at.y) > 14 }
747                if clear { placed.append(at) }
748                return (item.marker, item.point, item.radius, clear)
749            }
750    }
751
752    private func labels(size: CGSize) -> some View {
753        ZStack {
754            ForEach(labelled(size: size), id: \.marker.id) { item in
755                let m = item.marker
756                let isSelected = m.id == selected
757                let color: Color = isSelected ? accent : m.kind == .craft ? accent.opacity(0.9) : m.kind == .comet ? Color(red: 0.7, green: 0.85, blue: 1) : ink.opacity(m.kind == .moon ? 0.7 : 0.85)
758                ZStack {
759                    if item.radius < 3, m.kind != .craft, m.kind != .comet {
760                        Circle().stroke(isSelected ? accent : ink.opacity(0.6), lineWidth: 1.2).frame(width: 10, height: 10)
761                    }
762                    if item.showName {
763                        Text(m.name).font(.caption2.weight(isSelected ? .semibold : .regular))
764                            .foregroundStyle(color)
765                            .shadow(color: .black, radius: 2)
766                            .fixedSize()
767                            .offset(y: -max(item.radius, 5) - 9)
768                    }
769                }
770                .position(item.point)
771            }
772        }
773    }
774
775    private func tap(_ location: CGPoint, size: CGSize) {
776        let hit = screenPoints(size: size)
777            .map { ($0.marker.id, hypot($0.point.x - location.x, $0.point.y - location.y) - max($0.radius, 0)) }
778            .filter { $0.1 < 28 }
779            .min { $0.1 < $1.1 }
780        guard let id = hit?.0 else { selected = nil; return }
781        selected = id
782        fly(to: id)
783    }
784
785    /// The selected body's Library page, title, one-line description and position note.
786    private func details(_ id: String) -> (page: LibraryPage?, title: String, subtitle: String, note: String?) {
787        func fromSun(_ v: SIMD3<Double>) -> String {
788            let au = (v * v).sum().squareRoot()
789            return String(format: "%.2f AU from the Sun · sunlight takes %@", au, lightTime(au))
790        }
791        if id.hasPrefix("craft:") {
792            let name = String(id.dropFirst(6))
793            let page = Library.page(spacecraft: name)
794            let pos = Spacecraft.all.first { $0.name == name }?.position3(at: date)
795            return (page, name, page?.subtitle ?? "Spacecraft", pos.map(fromSun))
796        }
797        if id.hasPrefix("comet:"), let c = KeplerOrbit.comets.first(where: { "comet:\($0.id)" == id }) {
798            let years = c.periodDays / 365.25
799            let last = Date(timeIntervalSince1970: (c.perihelionJD - 2_440_587.5) * 86_400)
800            var sub = String(format: "Goes round the Sun every %.0f years.", years)
801            if years < 10 {
802                sub += " Next closest to the Sun \(c.nextPerihelion(after: .now).formatted(.dateTime.month(.wide).year()))."
803            } else {
804                sub += " Last closest to the Sun in \(last.formatted(.dateTime.year()))."
805            }
806            if let shower = MeteorShower.major.first(where: { $0.parent == c.id }) {
807                sub += " Its debris makes the \(shower.name)."
808            }
809            return (c.page.flatMap { Library.page(id: $0) }, c.name, sub, fromSun(c.position(at: date)))
810        }
811        let page = Library.page(id: id)
812        let title = page?.title ?? id
813        let subtitle = page?.subtitle ?? ""
814        if let m = SolarMoon.all.first(where: { $0.id == id }) {
815            let parentName = Library.page(id: m.parent)?.title ?? m.parent
816            let note = "\(Int(m.distanceKm).formatted()) km from \(parentName)" + (m.isExact ? "" : " · position illustrative")
817            return (page, title, subtitle, note)
818        }
819        if let p = SolarPlanet.all.first(where: { $0.id == id }) ?? (id == "pluto" ? SolarPlanet.pluto : nil) {
820            return (page, title, subtitle, fromSun(p.position(at: date)))
821        }
822        if let k = KeplerOrbit.dwarfPlanets.first(where: { $0.id == id }) {
823            return (page, title, subtitle, fromSun(k.position(at: date)))
824        }
825        return (page, title, subtitle, nil)
826    }
827
828    private func card(_ id: String) -> some View {
829        let d = details(id)
830        return VStack(alignment: .leading, spacing: 6) {
831            HStack {
832                Text(d.title).font(.headline)
833                Spacer()
834                Button { selected = nil; resetView() } label: {
835                    Image(systemName: "xmark").font(.footnote.weight(.semibold)).frame(width: 44, height: 44).contentShape(Rectangle())
836                }
837                .buttonStyle(.plain)
838                .padding(-12)
839                .accessibilityLabel("Close")
840            }
841            if !d.subtitle.isEmpty { Text(d.subtitle).font(.footnote).dimmed(0.75).fixedSize(horizontal: false, vertical: true) }
842            if let note = d.note { Text(note).font(.footnote.monospacedDigit()).dimmed(0.85) }
843            if let page = d.page {
844                Button("Read more") { onOpen(page) }.font(.subheadline.weight(.medium)).foregroundStyle(accent)
845            }
846        }
847        .padding(14)
848        .background(Color(white: 0.06), in: RoundedRectangle(cornerRadius: 14))
849        .contentShape(RoundedRectangle(cornerRadius: 14))
850        .overlay(RoundedRectangle(cornerRadius: 14).stroke(ink.opacity(0.15), lineWidth: 0.5))
851    }
852
853    private func lightTime(_ au: Double) -> String {
854        let s = au * 499.004784
855        let h = Int(s / 3600), m = Int(s.truncatingRemainder(dividingBy: 3600) / 60)
856        return h > 0 ? "\(h) h \(m) min" : "\(m) min"
857    }
858
859    // MARK: Camera
860
861    private var dragGesture: some Gesture {
862        DragGesture(minimumDistance: 4)
863            .onChanged { g in
864                flight?.cancel()
865                if dragStart == nil { dragStart = cam }
866                cam.yaw = dragStart!.yaw - Float(g.translation.width) * 0.008
867                cam.pitch = min(max(dragStart!.pitch + Float(g.translation.height) * 0.008, -1.45), 1.45)
868            }
869            .onEnded { _ in dragStart = nil }
870    }
871
872    private var pinchGesture: some Gesture {
873        MagnifyGesture()
874            .onChanged { g in
875                flight?.cancel()
876                if pinchStart == nil { pinchStart = cam.distance }
877                let limits: ClosedRange<Float> = scale == .compressed ? 0.02...80 : 1e-6...400
878                cam.distance = min(max(pinchStart! / Float(g.magnification), limits.lowerBound), limits.upperBound)
879            }
880            .onEnded { _ in pinchStart = nil }
881    }
882
883    private func resetView() {
884        animateCamera(to: SolarCamera(target: .zero, yaw: cam.yaw, pitch: 0.55, distance: fitDistance()))
885    }
886
887    /// Camera distance at which Neptune's orbit fits the narrower side of the view.
888    private func fitDistance() -> Float {
889        let neptune = Float(mapping.scale == .compressed ? 1.6 * pow(30.1, 0.4) : 30.1)
890        let tanV = tan(SolarCamera.fovDegrees * .pi / 360)
891        let tanH = tanV * Float(viewSize.width / max(viewSize.height, 1))
892        return neptune * 1.22 / min(tanV, tanH)
893    }
894
895    /// Centres on a body and zooms to a few times its drawn radius (or a fixed distance for dots).
896    private func fly(to id: String) {
897        guard let m = markers().first(where: { $0.id == id }) else { return }
898        let distance: Float
899        switch m.kind {
900        case .craft, .comet: distance = scale == .compressed ? 1.2 : 0.6
901        default: distance = m.radius * (m.id == "saturn" ? 2.3 : 1) * 7
902        }
903        animateCamera(to: SolarCamera(target: m.scene, yaw: cam.yaw, pitch: max(cam.pitch, 0.25), distance: distance))
904    }
905
906    /// Moves the camera over about a second, interpolating distance on a log scale.
907    private func animateCamera(to end: SolarCamera) {
908        flight?.cancel()
909        let start = cam
910        flight = Task {
911            let frames = 50
912            for f in 1...frames {
913                try? await Task.sleep(for: .milliseconds(18))
914                if Task.isCancelled { return }
915                let x = Float(f) / Float(frames)
916                let t = x < 0.5 ? 4 * x * x * x : 1 - pow(-2 * x + 2, 3) / 2
917                cam.target = start.target + (end.target - start.target) * t
918                cam.pitch = start.pitch + (end.pitch - start.pitch) * t
919                cam.distance = exp(Foundation.log(start.distance) + (Foundation.log(end.distance) - Foundation.log(start.distance)) * t)
920            }
921        }
922    }
923
924    // MARK: Weather
925
926    private func loadWeather() async {
927        while !Task.isCancelled {
928            async let kp = try? SpaceWeather.fetch()
929            async let xray = try? XRay.fetch()
930            let (k, x) = await (kp, xray)
931            var w = SolarWeather(kp: k?.kpNow)
932            if let samples = x {
933                let recent = samples.filter { $0.time > .now.addingTimeInterval(-3 * 3600) }
934                if let peak = recent.max(by: { $0.flux < $1.flux }) { w.flare = (peak.flux, peak.time) }
935            }
936            #if DEBUG
937            if UserDefaults.standard.bool(forKey: "debugSolarEvents") { w.kp = 6.3; w.flare = (2.4e-5, .now) }
938            #endif
939            weather = w
940            try? await Task.sleep(for: .seconds(600))
941        }
942    }
943}
944
945/// A small deterministic generator, so debris looks the same each time it is rebuilt.
946private struct SeededRandom {
947    var state: UInt64
948    init(seed: UInt64) { state = seed }
949    mutating func next() -> Double {
950        state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
951        return Double(state >> 11) / Double(1 << 53)
952    }
953}