| @@ -2,6 +2,7 @@ import Feeds |
| 2 | 2 | import Library |
| 3 | 3 | import RealityKit |
| 4 | 4 | import SkyEngine |
| 5 | import SkyRenderer |
| 5 | 6 | import SwiftUI |
| 6 | 7 | import UIKit |
| 7 | 8 | |
| @@ -32,6 +33,11 @@ struct SolarPlanet: Identifiable, Sendable { |
| 32 | 33 | return SolarPlanet(id: id, body: body, radiusKm: d.radiusKm ?? 6371, color: d.color ?? "#cccccc", tiltDeg: d.axialTiltDeg ?? 0, periodDays: period) |
| 33 | 34 | } |
| 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 | |
| 35 | 41 | /// Heliocentric position on J2000 ecliptic axes, AU. |
| 36 | 42 | func position(at date: Date) -> SIMD3<Double> { |
| 37 | 43 | if let body { return (try? SkyEngine.heliocentricEcliptic(body, at: date)) ?? .zero } |
| @@ -39,6 +45,36 @@ struct SolarPlanet: Identifiable, Sendable { |
| 39 | 45 | } |
| 40 | 46 | } |
| 41 | 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. |
| 50 | struct 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 | |
| 42 | 78 | /// Maps ecliptic AU to scene metres (y up) and gives drawn radii for each scale. |
| 43 | 79 | struct SolarMapping { |
| 44 | 80 | var scale: SolarScale |
| @@ -54,10 +90,20 @@ struct SolarMapping { |
| 54 | 90 | return SIMD3(Float(u.x), Float(u.z), Float(-u.y)) |
| 55 | 91 | } |
| 56 | 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 | |
| 57 | 96 | func radius(km: Double) -> Float { |
| 58 | 97 | scale == .compressed ? Float(0.09 * pow(km / 6371, 0.45)) : Float(km / Self.auKm) |
| 59 | 98 | } |
| 60 | 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 | |
| 61 | 107 | var sunRadius: Float { scale == .compressed ? 0.35 : Float(Self.sunRadiusKm / Self.auKm) } |
| 62 | 108 | } |
| 63 | 109 | |
| @@ -116,16 +162,41 @@ struct SolarWeather: Equatable { |
| 116 | 162 | } |
| 117 | 163 | } |
| 118 | 164 | |
| 165 | /// Which optional layers are drawn. |
| 166 | struct 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. |
| 174 | struct 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 | |
| 119 | 185 | /// Entities created once and moved on each update. |
| 120 | 186 | @MainActor |
| 121 | 187 | final class SolarSceneEntities { |
| 122 | 188 | let root = Entity() |
| 123 | 189 | let camera = PerspectiveCamera() |
| 124 | 190 | let sun = ModelEntity() |
| 125 | | var planets: [String: ModelEntity] = [:] |
| 191 | var bodies: [String: ModelEntity] = [:] |
| 126 | 192 | var nights: [String: ModelEntity] = [:] |
| 193 | var tails: [String: ModelEntity] = [:] |
| 127 | 194 | var orbits: [String: ModelEntity] = [:] |
| 128 | 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)? |
| 129 | 200 | let flare = ModelEntity() |
| 130 | 201 | let aurora = Entity() |
| 131 | 202 | } |
| @@ -147,8 +218,21 @@ struct SolarSystem3DView: View { |
| 147 | 218 | @State private var flight: Task<Void, Never>? |
| 148 | 219 | @State private var pulse = false |
| 149 | 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 |
| 150 | 225 | |
| 151 | 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 | } |
| 152 | 236 | |
| 153 | 237 | var body: some View { |
| 154 | 238 | GeometryReader { geo in |
| @@ -165,12 +249,16 @@ struct SolarSystem3DView: View { |
| 165 | 249 | labels(size: geo.size).allowsHitTesting(false) |
| 166 | 250 | |
| 167 | 251 | VStack(spacing: 8) { |
| 168 | | if let s = selected, let planet = planetOrSun(s) { card(planet) } |
| 252 | if let s = selected { card(s) } |
| 169 | 253 | Spacer() |
| 170 | | Text(live ? weather.summary : "Live space weather is shown only for today.") |
| 171 | | .font(.caption2).dimmed(0.75) |
| 172 | | .padding(.horizontal, 10).padding(.vertical, 6) |
| 173 | | .background(Color.black.opacity(0.6), in: Capsule()) |
| 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 | } |
| 174 | 262 | } |
| 175 | 263 | .padding(12) |
| 176 | 264 | } |
| @@ -179,7 +267,7 @@ struct SolarSystem3DView: View { |
| 179 | 267 | .onChange(of: geo.size) { viewSize = geo.size } |
| 180 | 268 | } |
| 181 | 269 | .accessibilityElement(children: .contain) |
| 182 | | .accessibilityLabel("3D solar system. Drag to turn, pinch to zoom, tap a planet to learn about it.") |
| 270 | .accessibilityLabel("3D solar system. Drag to turn, pinch to zoom, tap a body to learn about it.") |
| 183 | 271 | .onChange(of: scale) { resetView() } |
| 184 | 272 | .task { await loadWeather() } |
| 185 | 273 | .task { |
| @@ -190,6 +278,102 @@ struct SolarSystem3DView: View { |
| 190 | 278 | } |
| 191 | 279 | } |
| 192 | 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 | |
| 193 | 377 | // MARK: Scene |
| 194 | 378 | |
| 195 | 379 | private func build(_ content: RealityViewCameraContent) { |
| @@ -210,37 +394,63 @@ struct SolarSystem3DView: View { |
| 210 | 394 | let a = CGFloat(0.09 * pow(0.78, Double(i - 1))) |
| 211 | 395 | var g = UnlitMaterial(color: UIColor(red: 1, green: 0.72, blue: 0.32, alpha: a)) |
| 212 | 396 | g.blending = .transparent(opacity: .init(floatLiteral: Float(a))) |
| 213 | | let shell = ModelEntity(mesh: .generateSphere(radius: r), materials: [g]) |
| 214 | | e.sun.addChild(shell) |
| 397 | e.sun.addChild(ModelEntity(mesh: .generateSphere(radius: r), materials: [g])) |
| 215 | 398 | } |
| 216 | 399 | |
| 217 | | |
| 218 | | for p in SolarPlanet.all { |
| 219 | | let planet = ModelEntity() |
| 220 | | // Unlit, with a night-side shell turned away from the Sun, so day and night are exact |
| 221 | | // without depending on scene lighting. |
| 400 | // Planets, dwarf planets and moons: textured where there is a map, with a night side. |
| 401 | var spheres: [(id: String, color: String, tilt: Double)] = SolarPlanet.all.map { ($0.id, $0.color, $0.tiltDeg) } |
| 402 | if let pluto = SolarPlanet.pluto { spheres.append(("pluto", pluto.color, pluto.tiltDeg)) } |
| 403 | spheres += KeplerOrbit.dwarfPlanets.map { ($0.id, Library.page(id: $0.id)?.drawing?.color ?? "#999999", 0) } |
| 404 | spheres += SolarMoon.all.map { ($0.id, $0.color, 0) } |
| 405 | for s in spheres { |
| 406 | let body = ModelEntity() |
| 222 | 407 | var m = UnlitMaterial() |
| 223 | | if let url = GlobeView.mapURL(p.id), let texture = try? TextureResource.load(contentsOf: url) { |
| 408 | if let url = GlobeView.mapURL(s.id), let texture = try? TextureResource.load(contentsOf: url) { |
| 224 | 409 | m.color = .init(texture: .init(texture)) |
| 225 | 410 | } else { |
| 226 | | m.color = .init(tint: UIColor(Color(hex: p.color) ?? .gray)) |
| 411 | m.color = .init(tint: UIColor(Color(hex: s.color) ?? .gray)) |
| 227 | 412 | } |
| 228 | | planet.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [m]) |
| 413 | body.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [m]) |
| 414 | body.orientation = simd_quatf(angle: Float(s.tilt * .pi / 180), axis: [1, 0, 0]) |
| 415 | 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))) } |
| 416 | e.bodies[s.id] = body |
| 417 | e.root.addChild(body) |
| 229 | 418 | let night = Self.nightShell() |
| 230 | | night.name = "night" |
| 231 | | e.nights[p.id] = night |
| 419 | e.nights[s.id] = night |
| 232 | 420 | e.root.addChild(night) |
| 233 | | planet.orientation = simd_quatf(angle: Float(p.tiltDeg * .pi / 180), axis: [1, 0, 0]) |
| 234 | | if p.id == "saturn" { planet.addChild(Self.rings(inner: 1.24, outer: 2.27)) } |
| 235 | | e.planets[p.id] = planet |
| 236 | | e.root.addChild(planet) |
| 237 | 421 | } |
| 238 | 422 | |
| 423 | // Spacecraft and comet nuclei: dots sized each frame to stay visible. |
| 424 | for craft in Spacecraft.all { |
| 425 | let dot = ModelEntity(mesh: .generateSphere(radius: 1), materials: [UnlitMaterial(color: Self.craftColor)]) |
| 426 | e.bodies["craft:\(craft.name)"] = dot |
| 427 | e.root.addChild(dot) |
| 428 | } |
| 429 | for c in KeplerOrbit.comets { |
| 430 | let nucleus = ModelEntity(mesh: .generateSphere(radius: 1), materials: [UnlitMaterial(color: UIColor(white: 0.92, alpha: 1))]) |
| 431 | e.bodies["comet:\(c.id)"] = nucleus |
| 432 | e.root.addChild(nucleus) |
| 433 | // A soft tail: nested cones, wider and fainter outward. |
| 434 | let tail = ModelEntity() |
| 435 | for (radius, alpha) in [(0.025, 0.3), (0.06, 0.14), (0.11, 0.06)] as [(Float, Float)] { |
| 436 | var t = UnlitMaterial(color: UIColor(red: 0.7, green: 0.88, blue: 1, alpha: 1)) |
| 437 | t.blending = .transparent(opacity: .init(floatLiteral: alpha)) |
| 438 | tail.addChild(ModelEntity(mesh: .generateCone(height: 1, radius: radius), materials: [t])) |
| 439 | } |
| 440 | e.tails[c.id] = tail |
| 441 | e.root.addChild(tail) |
| 442 | } |
| 443 | |
| 444 | var debrisMaterial = UnlitMaterial(color: UIColor(red: 1, green: 0.88, blue: 0.65, alpha: 0.55)) |
| 445 | debrisMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.55)) |
| 446 | debrisMaterial.faceCulling = .none |
| 447 | e.debris.model = ModelComponent(mesh: .generateBox(size: 0.0001), materials: [debrisMaterial]) |
| 448 | e.root.addChild(e.debris) |
| 449 | |
| 239 | 450 | var flareMaterial = UnlitMaterial(color: UIColor(red: 1, green: 1, blue: 0.85, alpha: 0.9)) |
| 240 | 451 | flareMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.9)) |
| 241 | 452 | e.flare.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [flareMaterial]) |
| 242 | 453 | e.root.addChild(e.flare) |
| 243 | | // Two faint bands per pole, a wider glow and a brighter core, just above the surface. |
| 244 | 454 | for y: Float in [0.86, -0.86] { |
| 245 | 455 | for (inner, outer, alpha) in [(0.36, 0.66, 0.22), (0.45, 0.56, 0.5)] as [(Float, Float, CGFloat)] { |
| 246 | 456 | let ring = Self.annulus(inner: inner, outer: outer, color: UIColor(red: 0.35, green: 1, blue: 0.55, alpha: alpha)) |
| @@ -248,37 +458,43 @@ struct SolarSystem3DView: View { |
| 248 | 458 | e.aurora.addChild(ring) |
| 249 | 459 | } |
| 250 | 460 | } |
| 251 | | e.planets["earth"]?.addChild(e.aurora) |
| 461 | e.bodies["earth"]?.addChild(e.aurora) |
| 252 | 462 | place() |
| 253 | 463 | } |
| 254 | 464 | |
| 255 | | /// Moves everything for the current date, scale and camera. |
| 465 | /// Moves everything for the current date, scale, layers and camera. |
| 256 | 466 | private func place() { |
| 257 | 467 | let e = entities |
| 258 | 468 | let map = mapping |
| 259 | 469 | e.sun.scale = .init(repeating: map.sunRadius) |
| 260 | | for p in SolarPlanet.all { |
| 261 | | guard let planet = e.planets[p.id] else { continue } |
| 262 | | planet.position = map.point(p.position(at: date)) |
| 263 | | planet.scale = .init(repeating: map.radius(km: p.radiusKm)) |
| 264 | | if let night = e.nights[p.id] { |
| 265 | | night.position = planet.position |
| 266 | | night.scale = .init(repeating: map.radius(km: p.radiusKm) * 1.004) |
| 267 | | // The shell's open side (+y) faces the Sun. |
| 268 | | let toSun = simd_normalize(-planet.position) |
| 269 | | night.orientation = simd_quatf(from: SIMD3(0, 1, 0), to: toSun) |
| 470 | for m in markers() where m.kind != .sun { |
| 471 | guard let body = e.bodies[m.id] else { continue } |
| 472 | body.isEnabled = m.visible |
| 473 | body.position = m.scene |
| 474 | body.scale = .init(repeating: m.radius) |
| 475 | if let night = e.nights[m.id] { |
| 476 | night.isEnabled = m.visible |
| 477 | night.position = m.scene |
| 478 | night.scale = .init(repeating: m.radius * 1.004) |
| 479 | night.orientation = simd_quatf(from: SIMD3(0, 1, 0), to: simd_normalize(-m.scene)) |
| 480 | } |
| 481 | if m.kind == .comet, let tail = e.tails[String(m.id.dropFirst(6))] { |
| 482 | place(tail: tail, at: m, map: map) |
| 270 | 483 | } |
| 271 | 484 | } |
| 272 | 485 | rebuildOrbitsIfNeeded() |
| 273 | | // Orbits are a guide at system scale; close to a planet they only cut across it. |
| 486 | rebuildPathsIfNeeded() |
| 487 | rebuildDebrisIfNeeded() |
| 488 | // Planet orbits are a guide at system scale; close to a planet they only cut across it. |
| 274 | 489 | let farEnough = cam.distance > map.radius(km: 70_000) * 25 |
| 275 | | for o in e.orbits.values { o.isEnabled = farEnough } |
| 490 | for (id, o) in e.orbits { |
| 491 | o.isEnabled = farEnough && orbitShown(id) |
| 492 | } |
| 493 | for p in e.paths.values { p.isEnabled = farEnough && layers.craft } |
| 276 | 494 | |
| 277 | | // Flare: a bright bulge on the side of the Sun facing Earth, pulsing. |
| 278 | | if live, weather.showsFlare, let earth = e.planets["earth"] { |
| 495 | if live, weather.showsFlare, let earth = e.bodies["earth"] { |
| 279 | 496 | e.flare.isEnabled = true |
| 280 | | let dir = simd_normalize(earth.position) |
| 281 | | e.flare.position = dir * map.sunRadius * 1.02 |
| 497 | e.flare.position = simd_normalize(earth.position) * map.sunRadius * 1.02 |
| 282 | 498 | e.flare.scale = .init(repeating: map.sunRadius * (pulse ? 0.22 : 0.16)) |
| 283 | 499 | } else { |
| 284 | 500 | e.flare.isEnabled = false |
| @@ -287,55 +503,148 @@ struct SolarSystem3DView: View { |
| 287 | 503 | |
| 288 | 504 | e.camera.look(at: cam.target, from: cam.position, relativeTo: nil) |
| 289 | 505 | e.camera.camera.near = max(cam.distance * 0.001, 1e-9) |
| 290 | | e.camera.camera.far = cam.distance * 100 + 200 |
| 506 | e.camera.camera.far = cam.distance * 100 + 400 |
| 507 | } |
| 508 | |
| 509 | /// A comet's tail points away from the Sun and grows as the comet nears it; none beyond 4 AU. |
| 510 | private func place(tail: ModelEntity, at m: SolarMarker, map: SolarMapping) { |
| 511 | guard let c = KeplerOrbit.comets.first(where: { "comet:\($0.id)" == m.id }) else { return } |
| 512 | let au = (c.position(at: date) * c.position(at: date)).sum().squareRoot() |
| 513 | guard m.visible, au < 4 else { tail.isEnabled = false; return } |
| 514 | tail.isEnabled = true |
| 515 | let away = simd_normalize(m.scene) |
| 516 | let length = (map.scale == .compressed ? 0.9 : 0.4) * Float(min(1 / au, 2.5)) |
| 517 | // The cone's apex is at +y: point it at the Sun so the tail widens away from it. |
| 518 | tail.orientation = simd_quatf(from: SIMD3(0, 1, 0), to: -away) |
| 519 | tail.scale = .init(repeating: length) |
| 520 | tail.position = m.scene + away * length / 2 |
| 291 | 521 | } |
| 292 | 522 | |
| 293 | | /// Orbit ribbons, rebuilt when the scale changes or the zoom changes enough that their width would look wrong. |
| 523 | private func orbitShown(_ id: String) -> Bool { |
| 524 | if id.hasPrefix("comet:") { |
| 525 | let c = String(id.dropFirst(6)) |
| 526 | return layers.comets && (Self.mainComets.contains(c) || activeShowers.contains { $0.parent == c }) |
| 527 | } |
| 528 | if id.hasPrefix("dwarf:") { return layers.dwarfs } |
| 529 | return true |
| 530 | } |
| 531 | |
| 532 | /// Orbit ribbons for planets, dwarf planets and comets; rebuilt when the scale changes or the zoom |
| 533 | /// changes enough that their width would look wrong. |
| 294 | 534 | private func rebuildOrbitsIfNeeded() { |
| 295 | 535 | let e = entities |
| 296 | 536 | let width = cam.distance * 0.0022 |
| 297 | 537 | if let (s, w) = e.orbitsBuilt, s == scale, w / width < 1.6, width / w < 1.6 { return } |
| 298 | 538 | e.orbitsBuilt = (scale, width) |
| 299 | 539 | let map = mapping |
| 300 | | for p in SolarPlanet.all { |
| 301 | | let steps = 240 |
| 302 | | let points = (0..<steps).map { i in |
| 303 | | map.point(p.position(at: date.addingTimeInterval(Double(i) / Double(steps) * p.periodDays * 86_400))) |
| 304 | | } |
| 305 | | let ribbon = Self.ribbon(points, width: width) |
| 306 | | if let existing = e.orbits[p.id] { |
| 307 | | existing.model?.mesh = ribbon |
| 540 | func set(_ id: String, _ points: [SIMD3<Float>], color: UIColor, opacity: Float) { |
| 541 | let mesh = Self.ribbon(points, width: width, closed: true) |
| 542 | if let existing = e.orbits[id] { existing.model?.mesh = mesh; return } |
| 543 | var m = UnlitMaterial(color: color) |
| 544 | m.blending = .transparent(opacity: .init(floatLiteral: opacity)) |
| 545 | m.faceCulling = .none |
| 546 | let orbit = ModelEntity(mesh: mesh, materials: [m]) |
| 547 | e.orbits[id] = orbit |
| 548 | e.root.addChild(orbit) |
| 549 | } |
| 550 | for p in SolarPlanet.all + [SolarPlanet.pluto].compactMap({ $0 }) { |
| 551 | let pts = (0..<240).map { i in map.point(p.position(at: date.addingTimeInterval(Double(i) / 240 * p.periodDays * 86_400))) } |
| 552 | set(p.id == "pluto" ? "dwarf:pluto" : p.id, pts, color: .white, opacity: p.id == "pluto" ? 0.12 : 0.22) |
| 553 | } |
| 554 | for k in KeplerOrbit.dwarfPlanets { |
| 555 | set("dwarf:\(k.id)", k.orbitPath().map(map.point), color: .white, opacity: 0.12) |
| 556 | } |
| 557 | for c in KeplerOrbit.comets { |
| 558 | 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) |
| 559 | } |
| 560 | } |
| 561 | |
| 562 | /// Spacecraft paths from launch to the slider's date, rebuilt every 10 days of date change. |
| 563 | private func rebuildPathsIfNeeded() { |
| 564 | let e = entities |
| 565 | let width = cam.distance * 0.0022 |
| 566 | let bucket = Int(date.timeIntervalSince1970 / 864_000) |
| 567 | let built = e.pathsBuilt |
| 568 | if let built, built.0 == scale, built.1 / width < 1.6, width / built.1 < 1.6, built.2 == bucket { return } |
| 569 | e.pathsBuilt = (scale, width, bucket) |
| 570 | let map = mapping |
| 571 | let day = date.timeIntervalSince1970 / 86_400 |
| 572 | for craft in Spacecraft.all { |
| 573 | var pts = craft.points.filter { $0[0] <= day }.map { map.point(SIMD3($0[1], $0[2], $0[3])) } |
| 574 | if let now = craft.position3(at: date) { pts.append(map.point(now)) } |
| 575 | let mesh: MeshResource = pts.count > 1 ? Self.ribbon(pts, width: width, closed: false) : .generateBox(size: 0.0001) |
| 576 | if let existing = e.paths[craft.name] { |
| 577 | existing.model?.mesh = mesh |
| 308 | 578 | } else { |
| 309 | | var m = UnlitMaterial(color: UIColor(white: 1, alpha: 1)) |
| 310 | | m.blending = .transparent(opacity: .init(floatLiteral: 0.22)) |
| 579 | var m = UnlitMaterial(color: Self.craftColor) |
| 580 | m.blending = .transparent(opacity: .init(floatLiteral: 0.4)) |
| 311 | 581 | m.faceCulling = .none |
| 312 | | let orbit = ModelEntity(mesh: ribbon, materials: [m]) |
| 313 | | e.orbits[p.id] = orbit |
| 314 | | e.root.addChild(orbit) |
| 582 | let path = ModelEntity(mesh: mesh, materials: [m]) |
| 583 | e.paths[craft.name] = path |
| 584 | e.root.addChild(path) |
| 315 | 585 | } |
| 316 | 586 | } |
| 317 | 587 | } |
| 318 | 588 | |
| 589 | /// Debris along an active meteor shower's parent orbit: small specks, densest where it crosses Earth's orbit. |
| 590 | private func rebuildDebrisIfNeeded() { |
| 591 | let e = entities |
| 592 | let showers = layers.comets ? activeShowers.compactMap(\.parent).sorted().joined(separator: ",") : "" |
| 593 | let size = cam.distance * 0.0016 |
| 594 | if let (s, w, key) = e.debrisBuilt, s == scale, key == showers, w / size < 1.6, size / w < 1.6 { return } |
| 595 | e.debrisBuilt = (scale, size, showers) |
| 596 | guard !showers.isEmpty else { e.debris.isEnabled = false; return } |
| 597 | e.debris.isEnabled = true |
| 598 | let map = mapping |
| 599 | var rng = SeededRandom(seed: 42) |
| 600 | var positions: [SIMD3<Float>] = [] |
| 601 | var indices: [UInt32] = [] |
| 602 | for id in showers.split(separator: ",") { |
| 603 | guard let c = KeplerOrbit.comets.first(where: { $0.id == id }) else { continue } |
| 604 | for p in c.orbitPath(steps: 360) { |
| 605 | let r = (p * p).sum().squareRoot() |
| 606 | // More specks near 1 AU, where Earth meets the stream. |
| 607 | let copies = abs(r - 1) < 0.15 ? 4 : 1 |
| 608 | for _ in 0..<copies { |
| 609 | let jitter = SIMD3(rng.next() - 0.5, rng.next() - 0.5, rng.next() - 0.5) * 0.08 * max(r, 0.3) |
| 610 | let c = map.point(p + jitter) |
| 611 | let base = UInt32(positions.count) |
| 612 | positions += [c + SIMD3(size, 0, 0), c + SIMD3(-size / 2, size, 0), c + SIMD3(-size / 2, -size / 2, size)] |
| 613 | indices += [base, base + 1, base + 2] |
| 614 | } |
| 615 | } |
| 616 | } |
| 617 | var d = MeshDescriptor(name: "debris") |
| 618 | d.positions = MeshBuffer(positions) |
| 619 | d.primitives = .triangles(indices) |
| 620 | if let mesh = try? MeshResource.generate(from: [d]) { e.debris.model?.mesh = mesh } |
| 621 | } |
| 622 | |
| 319 | 623 | // MARK: Meshes |
| 320 | 624 | |
| 321 | | /// A closed flat ribbon through `points`, lying in the plane of the orbit. |
| 322 | | private static func ribbon(_ points: [SIMD3<Float>], width: Float) -> MeshResource { |
| 625 | /// A flat ribbon through `points`, lying in the plane of the orbit. |
| 626 | private static func ribbon(_ points: [SIMD3<Float>], width: Float, closed: Bool) -> MeshResource { |
| 323 | 627 | var positions: [SIMD3<Float>] = [] |
| 324 | 628 | var indices: [UInt32] = [] |
| 325 | 629 | let n = points.count |
| 326 | 630 | for i in 0..<n { |
| 327 | | let t = simd_normalize(points[(i + 1) % n] - points[(i + n - 1) % n]) |
| 328 | | var side = simd_cross(t, SIMD3(0, 1, 0)) |
| 631 | let next = closed ? points[(i + 1) % n] : points[min(i + 1, n - 1)] |
| 632 | let prev = closed ? points[(i + n - 1) % n] : points[max(i - 1, 0)] |
| 633 | var t = next - prev |
| 634 | if simd_length(t) < 1e-12 { t = SIMD3(1, 0, 0) } |
| 635 | var side = simd_cross(simd_normalize(t), SIMD3(0, 1, 0)) |
| 329 | 636 | if simd_length(side) < 1e-6 { side = SIMD3(1, 0, 0) } |
| 330 | 637 | side = simd_normalize(side) * width / 2 |
| 331 | 638 | positions += [points[i] - side, points[i] + side] |
| 332 | | let a = UInt32(2 * i), b = UInt32(2 * ((i + 1) % n)) |
| 333 | | indices += [a, a + 1, b, b, a + 1, b + 1] |
| 639 | if closed || i < n - 1 { |
| 640 | let a = UInt32(2 * i), b = UInt32(2 * ((i + 1) % n)) |
| 641 | indices += [a, a + 1, b, b, a + 1, b + 1] |
| 642 | } |
| 334 | 643 | } |
| 335 | 644 | var d = MeshDescriptor(name: "orbit") |
| 336 | 645 | d.positions = MeshBuffer(positions) |
| 337 | 646 | d.primitives = .triangles(indices) |
| 338 | | return (try? MeshResource.generate(from: [d])) ?? .generateBox(size: 0.001) |
| 647 | return (try? MeshResource.generate(from: [d])) ?? .generateBox(size: 0.0001) |
| 339 | 648 | } |
| 340 | 649 | |
| 341 | 650 | /// A flat ring in the local x-z plane, radii in units of the parent's radius. |
| @@ -358,7 +667,7 @@ struct SolarSystem3DView: View { |
| 358 | 667 | return ModelEntity(mesh: (try? MeshResource.generate(from: [d])) ?? .generateBox(size: 0.001), materials: [m]) |
| 359 | 668 | } |
| 360 | 669 | |
| 361 | | /// The half of a unit sphere below y = 0, drawn dark and mostly opaque over the planet's night side. |
| 670 | /// The half of a unit sphere below y = 0, drawn dark and mostly opaque over a body's night side. |
| 362 | 671 | private static func nightShell() -> ModelEntity { |
| 363 | 672 | let rings = 24, segments = 64 |
| 364 | 673 | var positions: [SIMD3<Float>] = [] |
| @@ -385,25 +694,12 @@ struct SolarSystem3DView: View { |
| 385 | 694 | return ModelEntity(mesh: (try? MeshResource.generate(from: [d])) ?? .generateBox(size: 0.001), materials: [m]) |
| 386 | 695 | } |
| 387 | 696 | |
| 388 | | private static func rings(inner: Float, outer: Float) -> ModelEntity { |
| 389 | | annulus(inner: inner, outer: outer, color: UIColor(red: 0.85, green: 0.78, blue: 0.62, alpha: 0.7)) |
| 390 | | } |
| 391 | | |
| 392 | 697 | // MARK: Labels, taps and the card |
| 393 | 698 | |
| 394 | | private func screenPoints(size: CGSize) -> [(id: String, point: CGPoint, radius: CGFloat)] { |
| 395 | | let e = entities |
| 396 | | var out: [(String, CGPoint, CGFloat)] = [] |
| 397 | | if let p = cam.project(.zero, in: size) { out.append(("sun", p, pixelRadius(mapping.sunRadius, at: .zero, size: size))) } |
| 398 | | for planet in SolarPlanet.all { |
| 399 | | guard let entity = e.planets[planet.id] else { continue } |
| 400 | | let pos = mapping.point(planet.position(at: date)) |
| 401 | | if let p = cam.project(pos, in: size) { |
| 402 | | out.append((planet.id, p, pixelRadius(mapping.radius(km: planet.radiusKm), at: pos, size: size))) |
| 403 | | } |
| 404 | | _ = entity |
| 699 | private func screenPoints(size: CGSize) -> [(marker: SolarMarker, point: CGPoint, radius: CGFloat)] { |
| 700 | markers().filter(\.visible).compactMap { m in |
| 701 | cam.project(m.scene, in: size).map { (m, $0, pixelRadius(m.radius, at: m.scene, size: size)) } |
| 405 | 702 | } |
| 406 | | return out |
| 407 | 703 | } |
| 408 | 704 | |
| 409 | 705 | private func pixelRadius(_ r: Float, at p: SIMD3<Float>, size: CGSize) -> CGFloat { |
| @@ -411,34 +707,53 @@ struct SolarSystem3DView: View { |
| 411 | 707 | return CGFloat(r / max(cam.distance(to: p), 1e-9) * k) |
| 412 | 708 | } |
| 413 | 709 | |
| 414 | | /// Points to label: larger bodies first, skipping a label that would sit on one already placed. |
| 415 | | private func labelled(size: CGSize) -> [(id: String, point: CGPoint, radius: CGFloat, showName: Bool)] { |
| 416 | | let order = ["sun", "jupiter", "saturn", "uranus", "neptune", "earth", "venus", "mars", "mercury"] |
| 710 | /// Points to label, most important first, skipping a label that would sit on one already placed. |
| 711 | private func labelled(size: CGSize) -> [(marker: SolarMarker, point: CGPoint, radius: CGFloat, showName: Bool)] { |
| 712 | let planetOrder = ["jupiter", "saturn", "uranus", "neptune", "earth", "venus", "mars", "mercury"] |
| 713 | func rank(_ m: SolarMarker) -> Int { |
| 714 | switch m.kind { |
| 715 | case .sun: 0 |
| 716 | case .planet: 1 + (planetOrder.firstIndex(of: m.id) ?? 9) |
| 717 | case .craft: 20 |
| 718 | case .dwarf: 30 |
| 719 | case .comet: 40 |
| 720 | case .moon: 50 |
| 721 | } |
| 722 | } |
| 417 | 723 | var placed: [CGPoint] = [] |
| 418 | | return screenPoints(size: size) |
| 419 | | .sorted { (order.firstIndex(of: $0.id) ?? 99) < (order.firstIndex(of: $1.id) ?? 99) } |
| 724 | let points = screenPoints(size: size) |
| 725 | // Anything whose centre falls on the disc of a nearer body is hidden behind it. |
| 726 | let discs = points.filter { $0.radius > 4 }.map { ($0.point, $0.radius, cam.distance(to: $0.marker.scene)) } |
| 727 | return points |
| 728 | .filter { item in |
| 729 | let d = cam.distance(to: item.marker.scene) |
| 730 | return !discs.contains { $0.2 < d && hypot($0.0.x - item.point.x, $0.0.y - item.point.y) < $0.1 } |
| 731 | } |
| 732 | .sorted { rank($0.marker) < rank($1.marker) } |
| 420 | 733 | .map { item in |
| 421 | 734 | let at = CGPoint(x: item.point.x, y: item.point.y - max(item.radius, 5) - 9) |
| 422 | | let clear = item.id == selected || placed.allSatisfy { abs($0.x - at.x) > 44 || abs($0.y - at.y) > 14 } |
| 735 | let clear = item.marker.id == selected || placed.allSatisfy { abs($0.x - at.x) > 48 || abs($0.y - at.y) > 14 } |
| 423 | 736 | if clear { placed.append(at) } |
| 424 | | return (item.id, item.point, item.radius, clear) |
| 737 | return (item.marker, item.point, item.radius, clear) |
| 425 | 738 | } |
| 426 | 739 | } |
| 427 | 740 | |
| 428 | 741 | private func labels(size: CGSize) -> some View { |
| 429 | 742 | ZStack { |
| 430 | | ForEach(labelled(size: size), id: \.id) { item in |
| 431 | | let name = item.id == "sun" ? "Sun" : (Library.page(id: item.id)?.title ?? item.id) |
| 432 | | let tiny = item.radius < 3 |
| 743 | ForEach(labelled(size: size), id: \.marker.id) { item in |
| 744 | let m = item.marker |
| 745 | let isSelected = m.id == selected |
| 746 | 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) |
| 433 | 747 | ZStack { |
| 434 | | if tiny { |
| 435 | | Circle().stroke(item.id == selected ? accent : ink.opacity(0.7), lineWidth: 1.2).frame(width: 10, height: 10) |
| 748 | if item.radius < 3, m.kind != .craft, m.kind != .comet { |
| 749 | Circle().stroke(isSelected ? accent : ink.opacity(0.6), lineWidth: 1.2).frame(width: 10, height: 10) |
| 436 | 750 | } |
| 437 | 751 | if item.showName { |
| 438 | | Text(name).font(.caption2.weight(item.id == selected ? .semibold : .regular)) |
| 439 | | .foregroundStyle(item.id == selected ? accent : ink.opacity(0.85)) |
| 440 | | .shadow(color: .black, radius: 2) |
| 441 | | .offset(y: -max(item.radius, 5) - 9) |
| 752 | Text(m.name).font(.caption2.weight(isSelected ? .semibold : .regular)) |
| 753 | .foregroundStyle(color) |
| 754 | .shadow(color: .black, radius: 2) |
| 755 | .fixedSize() |
| 756 | .offset(y: -max(item.radius, 5) - 9) |
| 442 | 757 | } |
| 443 | 758 | } |
| 444 | 759 | .position(item.point) |
| @@ -448,7 +763,7 @@ struct SolarSystem3DView: View { |
| 448 | 763 | |
| 449 | 764 | private func tap(_ location: CGPoint, size: CGSize) { |
| 450 | 765 | let hit = screenPoints(size: size) |
| 451 | | .map { ($0.id, hypot($0.point.x - location.x, $0.point.y - location.y) - max($0.radius, 0)) } |
| 766 | .map { ($0.marker.id, hypot($0.point.x - location.x, $0.point.y - location.y) - max($0.radius, 0)) } |
| 452 | 767 | .filter { $0.1 < 28 } |
| 453 | 768 | .min { $0.1 < $1.1 } |
| 454 | 769 | guard let id = hit?.0 else { selected = nil; return } |
| @@ -456,14 +771,54 @@ struct SolarSystem3DView: View { |
| 456 | 771 | fly(to: id) |
| 457 | 772 | } |
| 458 | 773 | |
| 459 | | private func planetOrSun(_ id: String) -> LibraryPage? { Library.page(id: id) } |
| 774 | /// The selected body's Library page, title, one-line description and position note. |
| 775 | private func details(_ id: String) -> (page: LibraryPage?, title: String, subtitle: String, note: String?) { |
| 776 | func fromSun(_ v: SIMD3<Double>) -> String { |
| 777 | let au = (v * v).sum().squareRoot() |
| 778 | return String(format: "%.2f AU from the Sun · sunlight takes %@", au, lightTime(au)) |
| 779 | } |
| 780 | if id.hasPrefix("craft:") { |
| 781 | let name = String(id.dropFirst(6)) |
| 782 | let page = Library.page(spacecraft: name) |
| 783 | let pos = Spacecraft.all.first { $0.name == name }?.position3(at: date) |
| 784 | return (page, name, page?.subtitle ?? "Spacecraft", pos.map(fromSun)) |
| 785 | } |
| 786 | if id.hasPrefix("comet:"), let c = KeplerOrbit.comets.first(where: { "comet:\($0.id)" == id }) { |
| 787 | let years = c.periodDays / 365.25 |
| 788 | let last = Date(timeIntervalSince1970: (c.perihelionJD - 2_440_587.5) * 86_400) |
| 789 | var sub = String(format: "Goes round the Sun every %.0f years.", years) |
| 790 | if years < 10 { |
| 791 | sub += " Next closest to the Sun \(c.nextPerihelion(after: .now).formatted(.dateTime.month(.wide).year()))." |
| 792 | } else { |
| 793 | sub += " Last closest to the Sun in \(last.formatted(.dateTime.year()))." |
| 794 | } |
| 795 | if let shower = MeteorShower.major.first(where: { $0.parent == c.id }) { |
| 796 | sub += " Its debris makes the \(shower.name)." |
| 797 | } |
| 798 | return (c.page.flatMap { Library.page(id: $0) }, c.name, sub, fromSun(c.position(at: date))) |
| 799 | } |
| 800 | let page = Library.page(id: id) |
| 801 | let title = page?.title ?? id |
| 802 | let subtitle = page?.subtitle ?? "" |
| 803 | if let m = SolarMoon.all.first(where: { $0.id == id }) { |
| 804 | let parentName = Library.page(id: m.parent)?.title ?? m.parent |
| 805 | let note = "\(Int(m.distanceKm).formatted()) km from \(parentName)" + (m.isExact ? "" : " · position illustrative") |
| 806 | return (page, title, subtitle, note) |
| 807 | } |
| 808 | if let p = SolarPlanet.all.first(where: { $0.id == id }) ?? (id == "pluto" ? SolarPlanet.pluto : nil) { |
| 809 | return (page, title, subtitle, fromSun(p.position(at: date))) |
| 810 | } |
| 811 | if let k = KeplerOrbit.dwarfPlanets.first(where: { $0.id == id }) { |
| 812 | return (page, title, subtitle, fromSun(k.position(at: date))) |
| 813 | } |
| 814 | return (page, title, subtitle, nil) |
| 815 | } |
| 460 | 816 | |
| 461 | | private func card(_ page: LibraryPage) -> some View { |
| 462 | | let planet = SolarPlanet.all.first { $0.id == page.id } |
| 463 | | let au = planet.map { p -> Double in let v = p.position(at: date); return (v * v).sum().squareRoot() } |
| 817 | private func card(_ id: String) -> some View { |
| 818 | let d = details(id) |
| 464 | 819 | return VStack(alignment: .leading, spacing: 6) { |
| 465 | 820 | HStack { |
| 466 | | Text(page.title).font(.headline) |
| 821 | Text(d.title).font(.headline) |
| 467 | 822 | Spacer() |
| 468 | 823 | Button { selected = nil; resetView() } label: { |
| 469 | 824 | Image(systemName: "xmark").font(.footnote.weight(.semibold)).frame(width: 44, height: 44).contentShape(Rectangle()) |
| @@ -472,11 +827,11 @@ struct SolarSystem3DView: View { |
| 472 | 827 | .padding(-12) |
| 473 | 828 | .accessibilityLabel("Close") |
| 474 | 829 | } |
| 475 | | Text(page.subtitle).font(.footnote).dimmed(0.75) |
| 476 | | if let au { |
| 477 | | Text(String(format: "%.2f AU from the Sun · sunlight takes %@", au, lightTime(au))).font(.footnote.monospacedDigit()).dimmed(0.85) |
| 830 | if !d.subtitle.isEmpty { Text(d.subtitle).font(.footnote).dimmed(0.75).fixedSize(horizontal: false, vertical: true) } |
| 831 | if let note = d.note { Text(note).font(.footnote.monospacedDigit()).dimmed(0.85) } |
| 832 | if let page = d.page { |
| 833 | Button("Read more") { onOpen(page) }.font(.subheadline.weight(.medium)).foregroundStyle(accent) |
| 478 | 834 | } |
| 479 | | Button("Read more") { onOpen(page) }.font(.subheadline.weight(.medium)).foregroundStyle(accent) |
| 480 | 835 | } |
| 481 | 836 | .padding(14) |
| 482 | 837 | .background(Color(white: 0.06), in: RoundedRectangle(cornerRadius: 14)) |
| @@ -508,7 +863,7 @@ struct SolarSystem3DView: View { |
| 508 | 863 | .onChanged { g in |
| 509 | 864 | flight?.cancel() |
| 510 | 865 | if pinchStart == nil { pinchStart = cam.distance } |
| 511 | | let limits: ClosedRange<Float> = scale == .compressed ? 0.25...60 : 1e-6...200 |
| 866 | let limits: ClosedRange<Float> = scale == .compressed ? 0.02...80 : 1e-6...400 |
| 512 | 867 | cam.distance = min(max(pinchStart! / Float(g.magnification), limits.lowerBound), limits.upperBound) |
| 513 | 868 | } |
| 514 | 869 | .onEnded { _ in pinchStart = nil } |
| @@ -526,17 +881,15 @@ struct SolarSystem3DView: View { |
| 526 | 881 | return neptune * 1.22 / min(tanV, tanH) |
| 527 | 882 | } |
| 528 | 883 | |
| 529 | | /// Centres on a body and zooms to a few times its drawn radius. |
| 884 | /// Centres on a body and zooms to a few times its drawn radius (or a fixed distance for dots). |
| 530 | 885 | private func fly(to id: String) { |
| 531 | | let target: SIMD3<Float>, radius: Float |
| 532 | | if id == "sun" { |
| 533 | | target = .zero |
| 534 | | radius = mapping.sunRadius |
| 535 | | } else if let p = SolarPlanet.all.first(where: { $0.id == id }) { |
| 536 | | target = mapping.point(p.position(at: date)) |
| 537 | | radius = mapping.radius(km: p.radiusKm) * (p.id == "saturn" ? 2.3 : 1) |
| 538 | | } else { return } |
| 539 | | animateCamera(to: SolarCamera(target: target, yaw: cam.yaw, pitch: max(cam.pitch, 0.25), distance: radius * 7)) |
| 886 | guard let m = markers().first(where: { $0.id == id }) else { return } |
| 887 | let distance: Float |
| 888 | switch m.kind { |
| 889 | case .craft, .comet: distance = scale == .compressed ? 1.2 : 0.6 |
| 890 | default: distance = m.radius * (m.id == "saturn" ? 2.3 : 1) * 7 |
| 891 | } |
| 892 | animateCamera(to: SolarCamera(target: m.scene, yaw: cam.yaw, pitch: max(cam.pitch, 0.25), distance: distance)) |
| 540 | 893 | } |
| 541 | 894 | |
| 542 | 895 | /// Moves the camera over about a second, interpolating distance on a log scale. |
| @@ -577,3 +930,13 @@ struct SolarSystem3DView: View { |
| 577 | 930 | } |
| 578 | 931 | } |
| 579 | 932 | } |
| 933 | |
| 934 | /// A small deterministic generator, so debris looks the same each time it is rebuilt. |
| 935 | private struct SeededRandom { |
| 936 | var state: UInt64 |
| 937 | init(seed: UInt64) { state = seed } |
| 938 | mutating func next() -> Double { |
| 939 | state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 |
| 940 | return Double(state >> 11) / Double(1 << 53) |
| 941 | } |
| 942 | } |