| @@ -2,6 +2,7 @@ import Feeds |
| 2 | import Library |
2 | import Library |
| 3 | import RealityKit |
3 | import RealityKit |
| 4 | import SkyEngine |
4 | import SkyEngine |
| |
5 | import SkyRenderer |
| 5 | import SwiftUI |
6 | import SwiftUI |
| 6 | import UIKit |
7 | import UIKit |
| 7 | |
8 | |
| @@ -32,6 +33,11 @@ struct SolarPlanet: Identifiable, Sendable { |
| 32 | return SolarPlanet(id: id, body: body, radiusKm: d.radiusKm ?? 6371, color: d.color ?? "#cccccc", tiltDeg: d.axialTiltDeg ?? 0, periodDays: period) |
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 | /// Heliocentric position on J2000 ecliptic axes, AU. |
41 | /// Heliocentric position on J2000 ecliptic axes, AU. |
| 36 | func position(at date: Date) -> SIMD3<Double> { |
42 | func position(at date: Date) -> SIMD3<Double> { |
| 37 | if let body { return (try? SkyEngine.heliocentricEcliptic(body, at: date)) ?? .zero } |
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 | /// Maps ecliptic AU to scene metres (y up) and gives drawn radii for each scale. |
78 | /// Maps ecliptic AU to scene metres (y up) and gives drawn radii for each scale. |
| 43 | struct SolarMapping { |
79 | struct SolarMapping { |
| 44 | var scale: SolarScale |
80 | var scale: SolarScale |
| @@ -54,10 +90,20 @@ struct SolarMapping { |
| 54 | return SIMD3(Float(u.x), Float(u.z), Float(-u.y)) |
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 | func radius(km: Double) -> Float { |
96 | func radius(km: Double) -> Float { |
| 58 | scale == .compressed ? Float(0.09 * pow(km / 6371, 0.45)) : Float(km / Self.auKm) |
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 | var sunRadius: Float { scale == .compressed ? 0.35 : Float(Self.sunRadiusKm / Self.auKm) } |
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 | /// Entities created once and moved on each update. |
185 | /// Entities created once and moved on each update. |
| 120 | @MainActor |
186 | @MainActor |
| 121 | final class SolarSceneEntities { |
187 | final class SolarSceneEntities { |
| 122 | let root = Entity() |
188 | let root = Entity() |
| 123 | let camera = PerspectiveCamera() |
189 | let camera = PerspectiveCamera() |
| 124 | let sun = ModelEntity() |
190 | let sun = ModelEntity() |
| 125 | var planets: [String: ModelEntity] = [:] |
191 | var bodies: [String: ModelEntity] = [:] |
| 126 | var nights: [String: ModelEntity] = [:] |
192 | var nights: [String: ModelEntity] = [:] |
| |
193 | var tails: [String: ModelEntity] = [:] |
| 127 | var orbits: [String: ModelEntity] = [:] |
194 | var orbits: [String: ModelEntity] = [:] |
| 128 | var orbitsBuilt: (SolarScale, Float)? |
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 | let flare = ModelEntity() |
200 | let flare = ModelEntity() |
| 130 | let aurora = Entity() |
201 | let aurora = Entity() |
| 131 | } |
202 | } |
| @@ -147,8 +218,21 @@ struct SolarSystem3DView: View { |
| 147 | @State private var flight: Task<Void, Never>? |
218 | @State private var flight: Task<Void, Never>? |
| 148 | @State private var pulse = false |
219 | @State private var pulse = false |
| 149 | @State private var viewSize = CGSize(width: 400, height: 600) |
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 | private var mapping: SolarMapping { SolarMapping(scale: scale) } |
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 | var body: some View { |
237 | var body: some View { |
| 154 | GeometryReader { geo in |
238 | GeometryReader { geo in |
| @@ -165,12 +249,16 @@ struct SolarSystem3DView: View { |
| 165 | labels(size: geo.size).allowsHitTesting(false) |
249 | labels(size: geo.size).allowsHitTesting(false) |
| 166 | |
250 | |
| 167 | VStack(spacing: 8) { |
251 | VStack(spacing: 8) { |
| 168 | if let s = selected, let planet = planetOrSun(s) { card(planet) } |
252 | if let s = selected { card(s) } |
| 169 | Spacer() |
253 | Spacer() |
| 170 | Text(live ? weather.summary : "Live space weather is shown only for today.") |
254 | HStack(alignment: .bottom) { |
| 171 | .font(.caption2).dimmed(0.75) |
255 | Text(summary) |
| 172 | .padding(.horizontal, 10).padding(.vertical, 6) |
256 | .font(.caption2).dimmed(0.8) |
| 173 | .background(Color.black.opacity(0.6), in: Capsule()) |
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 | .padding(12) |
263 | .padding(12) |
| 176 | } |
264 | } |
| @@ -179,7 +267,7 @@ struct SolarSystem3DView: View { |
| 179 | .onChange(of: geo.size) { viewSize = geo.size } |
267 | .onChange(of: geo.size) { viewSize = geo.size } |
| 180 | } |
268 | } |
| 181 | .accessibilityElement(children: .contain) |
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 | .onChange(of: scale) { resetView() } |
271 | .onChange(of: scale) { resetView() } |
| 184 | .task { await loadWeather() } |
272 | .task { await loadWeather() } |
| 185 | .task { |
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 | // MARK: Scene |
377 | // MARK: Scene |
| 194 | |
378 | |
| 195 | private func build(_ content: RealityViewCameraContent) { |
379 | private func build(_ content: RealityViewCameraContent) { |
| @@ -210,37 +394,63 @@ struct SolarSystem3DView: View { |
| 210 | let a = CGFloat(0.09 * pow(0.78, Double(i - 1))) |
394 | let a = CGFloat(0.09 * pow(0.78, Double(i - 1))) |
| 211 | var g = UnlitMaterial(color: UIColor(red: 1, green: 0.72, blue: 0.32, alpha: a)) |
395 | var g = UnlitMaterial(color: UIColor(red: 1, green: 0.72, blue: 0.32, alpha: a)) |
| 212 | g.blending = .transparent(opacity: .init(floatLiteral: Float(a))) |
396 | g.blending = .transparent(opacity: .init(floatLiteral: Float(a))) |
| 213 | let shell = ModelEntity(mesh: .generateSphere(radius: r), materials: [g]) |
397 | e.sun.addChild(ModelEntity(mesh: .generateSphere(radius: r), materials: [g])) |
| 214 | e.sun.addChild(shell) |
| |
| 215 | } |
398 | } |
| 216 | |
399 | |
| 217 | |
400 | // Planets, dwarf planets and moons: textured where there is a map, with a night side. |
| 218 | for p in SolarPlanet.all { |
401 | var spheres: [(id: String, color: String, tilt: Double)] = SolarPlanet.all.map { ($0.id, $0.color, $0.tiltDeg) } |
| 219 | let planet = ModelEntity() |
402 | if let pluto = SolarPlanet.pluto { spheres.append(("pluto", pluto.color, pluto.tiltDeg)) } |
| 220 | // Unlit, with a night-side shell turned away from the Sun, so day and night are exact |
403 | spheres += KeplerOrbit.dwarfPlanets.map { ($0.id, Library.page(id: $0.id)?.drawing?.color ?? "#999999", 0) } |
| 221 | // without depending on scene lighting. |
404 | spheres += SolarMoon.all.map { ($0.id, $0.color, 0) } |
| |
405 | for s in spheres { |
| |
406 | let body = ModelEntity() |
| 222 | var m = UnlitMaterial() |
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 | m.color = .init(texture: .init(texture)) |
409 | m.color = .init(texture: .init(texture)) |
| 225 | } else { |
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 | let night = Self.nightShell() |
418 | let night = Self.nightShell() |
| 230 | night.name = "night" |
419 | e.nights[s.id] = night |
| 231 | e.nights[p.id] = night |
| |
| 232 | e.root.addChild(night) |
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 | var flareMaterial = UnlitMaterial(color: UIColor(red: 1, green: 1, blue: 0.85, alpha: 0.9)) |
450 | var flareMaterial = UnlitMaterial(color: UIColor(red: 1, green: 1, blue: 0.85, alpha: 0.9)) |
| 240 | flareMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.9)) |
451 | flareMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.9)) |
| 241 | e.flare.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [flareMaterial]) |
452 | e.flare.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [flareMaterial]) |
| 242 | e.root.addChild(e.flare) |
453 | e.root.addChild(e.flare) |
| 243 | // Two faint bands per pole, a wider glow and a brighter core, just above the surface. |
| |
| 244 | for y: Float in [0.86, -0.86] { |
454 | for y: Float in [0.86, -0.86] { |
| 245 | for (inner, outer, alpha) in [(0.36, 0.66, 0.22), (0.45, 0.56, 0.5)] as [(Float, Float, CGFloat)] { |
455 | for (inner, outer, alpha) in [(0.36, 0.66, 0.22), (0.45, 0.56, 0.5)] as [(Float, Float, CGFloat)] { |
| 246 | let ring = Self.annulus(inner: inner, outer: outer, color: UIColor(red: 0.35, green: 1, blue: 0.55, alpha: alpha)) |
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 | e.aurora.addChild(ring) |
458 | e.aurora.addChild(ring) |
| 249 | } |
459 | } |
| 250 | } |
460 | } |
| 251 | e.planets["earth"]?.addChild(e.aurora) |
461 | e.bodies["earth"]?.addChild(e.aurora) |
| 252 | place() |
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 | private func place() { |
466 | private func place() { |
| 257 | let e = entities |
467 | let e = entities |
| 258 | let map = mapping |
468 | let map = mapping |
| 259 | e.sun.scale = .init(repeating: map.sunRadius) |
469 | e.sun.scale = .init(repeating: map.sunRadius) |
| 260 | for p in SolarPlanet.all { |
470 | for m in markers() where m.kind != .sun { |
| 261 | guard let planet = e.planets[p.id] else { continue } |
471 | guard let body = e.bodies[m.id] else { continue } |
| 262 | planet.position = map.point(p.position(at: date)) |
472 | body.isEnabled = m.visible |
| 263 | planet.scale = .init(repeating: map.radius(km: p.radiusKm)) |
473 | body.position = m.scene |
| 264 | if let night = e.nights[p.id] { |
474 | body.scale = .init(repeating: m.radius) |
| 265 | night.position = planet.position |
475 | if let night = e.nights[m.id] { |
| 266 | night.scale = .init(repeating: map.radius(km: p.radiusKm) * 1.004) |
476 | night.isEnabled = m.visible |
| 267 | // The shell's open side (+y) faces the Sun. |
477 | night.position = m.scene |
| 268 | let toSun = simd_normalize(-planet.position) |
478 | night.scale = .init(repeating: m.radius * 1.004) |
| 269 | night.orientation = simd_quatf(from: SIMD3(0, 1, 0), to: toSun) |
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 | rebuildOrbitsIfNeeded() |
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 | let farEnough = cam.distance > map.radius(km: 70_000) * 25 |
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. |
495 | if live, weather.showsFlare, let earth = e.bodies["earth"] { |
| 278 | if live, weather.showsFlare, let earth = e.planets["earth"] { |
| |
| 279 | e.flare.isEnabled = true |
496 | e.flare.isEnabled = true |
| 280 | let dir = simd_normalize(earth.position) |
497 | e.flare.position = simd_normalize(earth.position) * map.sunRadius * 1.02 |
| 281 | e.flare.position = dir * map.sunRadius * 1.02 |
| |
| 282 | e.flare.scale = .init(repeating: map.sunRadius * (pulse ? 0.22 : 0.16)) |
498 | e.flare.scale = .init(repeating: map.sunRadius * (pulse ? 0.22 : 0.16)) |
| 283 | } else { |
499 | } else { |
| 284 | e.flare.isEnabled = false |
500 | e.flare.isEnabled = false |
| @@ -287,55 +503,148 @@ struct SolarSystem3DView: View { |
| 287 | |
503 | |
| 288 | e.camera.look(at: cam.target, from: cam.position, relativeTo: nil) |
504 | e.camera.look(at: cam.target, from: cam.position, relativeTo: nil) |
| 289 | e.camera.camera.near = max(cam.distance * 0.001, 1e-9) |
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 | private func rebuildOrbitsIfNeeded() { |
534 | private func rebuildOrbitsIfNeeded() { |
| 295 | let e = entities |
535 | let e = entities |
| 296 | let width = cam.distance * 0.0022 |
536 | let width = cam.distance * 0.0022 |
| 297 | if let (s, w) = e.orbitsBuilt, s == scale, w / width < 1.6, width / w < 1.6 { return } |
537 | if let (s, w) = e.orbitsBuilt, s == scale, w / width < 1.6, width / w < 1.6 { return } |
| 298 | e.orbitsBuilt = (scale, width) |
538 | e.orbitsBuilt = (scale, width) |
| 299 | let map = mapping |
539 | let map = mapping |
| 300 | for p in SolarPlanet.all { |
540 | func set(_ id: String, _ points: [SIMD3<Float>], color: UIColor, opacity: Float) { |
| 301 | let steps = 240 |
541 | let mesh = Self.ribbon(points, width: width, closed: true) |
| 302 | let points = (0..<steps).map { i in |
542 | if let existing = e.orbits[id] { existing.model?.mesh = mesh; return } |
| 303 | map.point(p.position(at: date.addingTimeInterval(Double(i) / Double(steps) * p.periodDays * 86_400))) |
543 | var m = UnlitMaterial(color: color) |
| 304 | } |
544 | m.blending = .transparent(opacity: .init(floatLiteral: opacity)) |
| 305 | let ribbon = Self.ribbon(points, width: width) |
545 | m.faceCulling = .none |
| 306 | if let existing = e.orbits[p.id] { |
546 | let orbit = ModelEntity(mesh: mesh, materials: [m]) |
| 307 | existing.model?.mesh = ribbon |
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 | } else { |
578 | } else { |
| 309 | var m = UnlitMaterial(color: UIColor(white: 1, alpha: 1)) |
579 | var m = UnlitMaterial(color: Self.craftColor) |
| 310 | m.blending = .transparent(opacity: .init(floatLiteral: 0.22)) |
580 | m.blending = .transparent(opacity: .init(floatLiteral: 0.4)) |
| 311 | m.faceCulling = .none |
581 | m.faceCulling = .none |
| 312 | let orbit = ModelEntity(mesh: ribbon, materials: [m]) |
582 | let path = ModelEntity(mesh: mesh, materials: [m]) |
| 313 | e.orbits[p.id] = orbit |
583 | e.paths[craft.name] = path |
| 314 | e.root.addChild(orbit) |
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 | // MARK: Meshes |
623 | // MARK: Meshes |
| 320 | |
624 | |
| 321 | /// A closed flat ribbon through `points`, lying in the plane of the orbit. |
625 | /// A flat ribbon through `points`, lying in the plane of the orbit. |
| 322 | private static func ribbon(_ points: [SIMD3<Float>], width: Float) -> MeshResource { |
626 | private static func ribbon(_ points: [SIMD3<Float>], width: Float, closed: Bool) -> MeshResource { |
| 323 | var positions: [SIMD3<Float>] = [] |
627 | var positions: [SIMD3<Float>] = [] |
| 324 | var indices: [UInt32] = [] |
628 | var indices: [UInt32] = [] |
| 325 | let n = points.count |
629 | let n = points.count |
| 326 | for i in 0..<n { |
630 | for i in 0..<n { |
| 327 | let t = simd_normalize(points[(i + 1) % n] - points[(i + n - 1) % n]) |
631 | let next = closed ? points[(i + 1) % n] : points[min(i + 1, n - 1)] |
| 328 | var side = simd_cross(t, SIMD3(0, 1, 0)) |
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 | if simd_length(side) < 1e-6 { side = SIMD3(1, 0, 0) } |
636 | if simd_length(side) < 1e-6 { side = SIMD3(1, 0, 0) } |
| 330 | side = simd_normalize(side) * width / 2 |
637 | side = simd_normalize(side) * width / 2 |
| 331 | positions += [points[i] - side, points[i] + side] |
638 | positions += [points[i] - side, points[i] + side] |
| 332 | let a = UInt32(2 * i), b = UInt32(2 * ((i + 1) % n)) |
639 | if closed || i < n - 1 { |
| 333 | indices += [a, a + 1, b, b, a + 1, b + 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 | var d = MeshDescriptor(name: "orbit") |
644 | var d = MeshDescriptor(name: "orbit") |
| 336 | d.positions = MeshBuffer(positions) |
645 | d.positions = MeshBuffer(positions) |
| 337 | d.primitives = .triangles(indices) |
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 | /// A flat ring in the local x-z plane, radii in units of the parent's radius. |
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 | return ModelEntity(mesh: (try? MeshResource.generate(from: [d])) ?? .generateBox(size: 0.001), materials: [m]) |
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 | private static func nightShell() -> ModelEntity { |
671 | private static func nightShell() -> ModelEntity { |
| 363 | let rings = 24, segments = 64 |
672 | let rings = 24, segments = 64 |
| 364 | var positions: [SIMD3<Float>] = [] |
673 | var positions: [SIMD3<Float>] = [] |
| @@ -385,25 +694,12 @@ struct SolarSystem3DView: View { |
| 385 | return ModelEntity(mesh: (try? MeshResource.generate(from: [d])) ?? .generateBox(size: 0.001), materials: [m]) |
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 | // MARK: Labels, taps and the card |
697 | // MARK: Labels, taps and the card |
| 393 | |
698 | |
| 394 | private func screenPoints(size: CGSize) -> [(id: String, point: CGPoint, radius: CGFloat)] { |
699 | private func screenPoints(size: CGSize) -> [(marker: SolarMarker, point: CGPoint, radius: CGFloat)] { |
| 395 | let e = entities |
700 | markers().filter(\.visible).compactMap { m in |
| 396 | var out: [(String, CGPoint, CGFloat)] = [] |
701 | cam.project(m.scene, in: size).map { (m, $0, pixelRadius(m.radius, at: m.scene, size: size)) } |
| 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 |
| |
| 405 | } |
702 | } |
| 406 | return out |
| |
| 407 | } |
703 | } |
| 408 | |
704 | |
| 409 | private func pixelRadius(_ r: Float, at p: SIMD3<Float>, size: CGSize) -> CGFloat { |
705 | private func pixelRadius(_ r: Float, at p: SIMD3<Float>, size: CGSize) -> CGFloat { |
| @@ -411,34 +707,53 @@ struct SolarSystem3DView: View { |
| 411 | return CGFloat(r / max(cam.distance(to: p), 1e-9) * k) |
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. |
710 | /// Points to label, most important 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)] { |
711 | private func labelled(size: CGSize) -> [(marker: SolarMarker, point: CGPoint, radius: CGFloat, showName: Bool)] { |
| 416 | let order = ["sun", "jupiter", "saturn", "uranus", "neptune", "earth", "venus", "mars", "mercury"] |
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 | var placed: [CGPoint] = [] |
723 | var placed: [CGPoint] = [] |
| 418 | return screenPoints(size: size) |
724 | let points = screenPoints(size: size) |
| 419 | .sorted { (order.firstIndex(of: $0.id) ?? 99) < (order.firstIndex(of: $1.id) ?? 99) } |
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 | .map { item in |
733 | .map { item in |
| 421 | let at = CGPoint(x: item.point.x, y: item.point.y - max(item.radius, 5) - 9) |
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 | if clear { placed.append(at) } |
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 | private func labels(size: CGSize) -> some View { |
741 | private func labels(size: CGSize) -> some View { |
| 429 | ZStack { |
742 | ZStack { |
| 430 | ForEach(labelled(size: size), id: \.id) { item in |
743 | ForEach(labelled(size: size), id: \.marker.id) { item in |
| 431 | let name = item.id == "sun" ? "Sun" : (Library.page(id: item.id)?.title ?? item.id) |
744 | let m = item.marker |
| 432 | let tiny = item.radius < 3 |
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 | ZStack { |
747 | ZStack { |
| 434 | if tiny { |
748 | if item.radius < 3, m.kind != .craft, m.kind != .comet { |
| 435 | Circle().stroke(item.id == selected ? accent : ink.opacity(0.7), lineWidth: 1.2).frame(width: 10, height: 10) |
749 | Circle().stroke(isSelected ? accent : ink.opacity(0.6), lineWidth: 1.2).frame(width: 10, height: 10) |
| 436 | } |
750 | } |
| 437 | if item.showName { |
751 | if item.showName { |
| 438 | Text(name).font(.caption2.weight(item.id == selected ? .semibold : .regular)) |
752 | Text(m.name).font(.caption2.weight(isSelected ? .semibold : .regular)) |
| 439 | .foregroundStyle(item.id == selected ? accent : ink.opacity(0.85)) |
753 | .foregroundStyle(color) |
| 440 | .shadow(color: .black, radius: 2) |
754 | .shadow(color: .black, radius: 2) |
| 441 | .offset(y: -max(item.radius, 5) - 9) |
755 | .fixedSize() |
| |
756 | .offset(y: -max(item.radius, 5) - 9) |
| 442 | } |
757 | } |
| 443 | } |
758 | } |
| 444 | .position(item.point) |
759 | .position(item.point) |
| @@ -448,7 +763,7 @@ struct SolarSystem3DView: View { |
| 448 | |
763 | |
| 449 | private func tap(_ location: CGPoint, size: CGSize) { |
764 | private func tap(_ location: CGPoint, size: CGSize) { |
| 450 | let hit = screenPoints(size: size) |
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 | .filter { $0.1 < 28 } |
767 | .filter { $0.1 < 28 } |
| 453 | .min { $0.1 < $1.1 } |
768 | .min { $0.1 < $1.1 } |
| 454 | guard let id = hit?.0 else { selected = nil; return } |
769 | guard let id = hit?.0 else { selected = nil; return } |
| @@ -456,14 +771,54 @@ struct SolarSystem3DView: View { |
| 456 | fly(to: id) |
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 { |
817 | private func card(_ id: String) -> some View { |
| 462 | let planet = SolarPlanet.all.first { $0.id == page.id } |
818 | let d = details(id) |
| 463 | let au = planet.map { p -> Double in let v = p.position(at: date); return (v * v).sum().squareRoot() } |
| |
| 464 | return VStack(alignment: .leading, spacing: 6) { |
819 | return VStack(alignment: .leading, spacing: 6) { |
| 465 | HStack { |
820 | HStack { |
| 466 | Text(page.title).font(.headline) |
821 | Text(d.title).font(.headline) |
| 467 | Spacer() |
822 | Spacer() |
| 468 | Button { selected = nil; resetView() } label: { |
823 | Button { selected = nil; resetView() } label: { |
| 469 | Image(systemName: "xmark").font(.footnote.weight(.semibold)).frame(width: 44, height: 44).contentShape(Rectangle()) |
824 | Image(systemName: "xmark").font(.footnote.weight(.semibold)).frame(width: 44, height: 44).contentShape(Rectangle()) |
| @@ -472,11 +827,11 @@ struct SolarSystem3DView: View { |
| 472 | .padding(-12) |
827 | .padding(-12) |
| 473 | .accessibilityLabel("Close") |
828 | .accessibilityLabel("Close") |
| 474 | } |
829 | } |
| 475 | Text(page.subtitle).font(.footnote).dimmed(0.75) |
830 | if !d.subtitle.isEmpty { Text(d.subtitle).font(.footnote).dimmed(0.75).fixedSize(horizontal: false, vertical: true) } |
| 476 | if let au { |
831 | if let note = d.note { Text(note).font(.footnote.monospacedDigit()).dimmed(0.85) } |
| 477 | Text(String(format: "%.2f AU from the Sun · sunlight takes %@", au, lightTime(au))).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 | .padding(14) |
836 | .padding(14) |
| 482 | .background(Color(white: 0.06), in: RoundedRectangle(cornerRadius: 14)) |
837 | .background(Color(white: 0.06), in: RoundedRectangle(cornerRadius: 14)) |
| @@ -508,7 +863,7 @@ struct SolarSystem3DView: View { |
| 508 | .onChanged { g in |
863 | .onChanged { g in |
| 509 | flight?.cancel() |
864 | flight?.cancel() |
| 510 | if pinchStart == nil { pinchStart = cam.distance } |
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 | cam.distance = min(max(pinchStart! / Float(g.magnification), limits.lowerBound), limits.upperBound) |
867 | cam.distance = min(max(pinchStart! / Float(g.magnification), limits.lowerBound), limits.upperBound) |
| 513 | } |
868 | } |
| 514 | .onEnded { _ in pinchStart = nil } |
869 | .onEnded { _ in pinchStart = nil } |
| @@ -526,17 +881,15 @@ struct SolarSystem3DView: View { |
| 526 | return neptune * 1.22 / min(tanV, tanH) |
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 | private func fly(to id: String) { |
885 | private func fly(to id: String) { |
| 531 | let target: SIMD3<Float>, radius: Float |
886 | guard let m = markers().first(where: { $0.id == id }) else { return } |
| 532 | if id == "sun" { |
887 | let distance: Float |
| 533 | target = .zero |
888 | switch m.kind { |
| 534 | radius = mapping.sunRadius |
889 | case .craft, .comet: distance = scale == .compressed ? 1.2 : 0.6 |
| 535 | } else if let p = SolarPlanet.all.first(where: { $0.id == id }) { |
890 | default: distance = m.radius * (m.id == "saturn" ? 2.3 : 1) * 7 |
| 536 | target = mapping.point(p.position(at: date)) |
891 | } |
| 537 | radius = mapping.radius(km: p.radiusKm) * (p.id == "saturn" ? 2.3 : 1) |
892 | animateCamera(to: SolarCamera(target: m.scene, yaw: cam.yaw, pitch: max(cam.pitch, 0.25), distance: distance)) |
| 538 | } else { return } |
| |
| 539 | animateCamera(to: SolarCamera(target: target, yaw: cam.yaw, pitch: max(cam.pitch, 0.25), distance: radius * 7)) |
| |
| 540 | } |
893 | } |
| 541 | |
894 | |
| 542 | /// Moves the camera over about a second, interpolating distance on a log scale. |
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 | } |