krz/moonrise

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

Commit 8592cd1a6d

8592cd1a6de1ce83d21c53dd06f22c618d79d0e6

parent: 913cbaccdb

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-19 06:15 UTC

3D solar system: moons, dwarf planets, spacecraft, comets and meteor showers

Layout: unified · split

App/Sources/SolarSystem3D.swift +481 −118
@@ -2,6 +2,7 @@ import Feeds
22import Library
33import RealityKit
44import SkyEngine
5import SkyRenderer
56import SwiftUI
67import UIKit
78
@@ -32,6 +33,11 @@ struct SolarPlanet: Identifiable, Sendable {
3233 return SolarPlanet(id: id, body: body, radiusKm: d.radiusKm ?? 6371, color: d.color ?? "#cccccc", tiltDeg: d.axialTiltDeg ?? 0, periodDays: period)
3334 }
3435
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
3541 /// Heliocentric position on J2000 ecliptic axes, AU.
3642 func position(at date: Date) -> SIMD3<Double> {
3743 if let body { return (try? SkyEngine.heliocentricEcliptic(body, at: date)) ?? .zero }
@@ -39,6 +45,36 @@ struct SolarPlanet: Identifiable, Sendable {
3945 }
4046}
4147
48/// A moon from its Library page: exact positions for Earth's Moon and Jupiter's four big moons,
49/// a circular orbit in the planet's equator plane (at an arbitrary starting point) for the rest.
50struct SolarMoon: Identifiable, Sendable {
51 var id: String
52 var parent: String
53 var distanceKm: Double
54 var periodDays: Double
55 var radiusKm: Double
56 var color: String
57
58 static let galilean = ["io", "europa", "ganymede", "callisto"]
59
60 static let all: [SolarMoon] = Library.pages(in: .moons).compactMap { p in
61 guard let dist = p.facts.first(where: { $0.label.hasPrefix("Mean distance from") }),
62 let period = p.facts.first(where: { $0.label.hasPrefix("Orbital period") }),
63 let km = number(dist.value), let days = number(period.value) else { return nil }
64 let parent = dist.label.replacingOccurrences(of: "Mean distance from ", with: "").lowercased()
65 let retrograde = p.facts.contains { $0.value == "Retrograde" }
66 return SolarMoon(id: p.id, parent: parent, distanceKm: km, periodDays: retrograde ? -days : days,
67 radiusKm: p.drawing?.radiusKm ?? 100, color: p.drawing?.color ?? "#999999")
68 }
69
70 /// 1070400 from "1,070,400 km"; 0.319 from "0.319 days (7 h 39 m)".
71 static func number(_ s: String) -> Double? {
72 s.split(separator: " ").first.flatMap { Double($0.replacingOccurrences(of: ",", with: "")) }
73 }
74
75 var isExact: Bool { id == "moon" || Self.galilean.contains(id) }
76}
77
4278/// Maps ecliptic AU to scene metres (y up) and gives drawn radii for each scale.
4379struct SolarMapping {
4480 var scale: SolarScale
@@ -54,10 +90,20 @@ struct SolarMapping {
5490 return SIMD3(Float(u.x), Float(u.z), Float(-u.y))
5591 }
5692
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
5796 func radius(km: Double) -> Float {
5897 scale == .compressed ? Float(0.09 * pow(km / 6371, 0.45)) : Float(km / Self.auKm)
5998 }
6099
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
61107 var sunRadius: Float { scale == .compressed ? 0.35 : Float(Self.sunRadiusKm / Self.auKm) }
62108}
63109
@@ -116,16 +162,41 @@ struct SolarWeather: Equatable {
116162 }
117163}
118164
165/// Which optional layers are drawn.
166struct SolarLayers: Equatable {
167 var moons = true
168 var dwarfs = true
169 var craft = true
170 var comets = true
171}
172
173/// Anything the scene draws and can label or fly to.
174struct SolarMarker {
175 enum Kind { case sun, planet, moon, dwarf, craft, comet }
176 var id: String
177 var name: String
178 var kind: Kind
179 var scene: SIMD3<Float>
180 /// Drawn radius in scene units (for dots, the dot's size at the current zoom).
181 var radius: Float
182 var visible: Bool
183}
184
119185/// Entities created once and moved on each update.
120186@MainActor
121187final class SolarSceneEntities {
122188 let root = Entity()
123189 let camera = PerspectiveCamera()
124190 let sun = ModelEntity()
125 var planets: [String: ModelEntity] = [:]
191 var bodies: [String: ModelEntity] = [:]
126192 var nights: [String: ModelEntity] = [:]
193 var tails: [String: ModelEntity] = [:]
127194 var orbits: [String: ModelEntity] = [:]
128195 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)?
129200 let flare = ModelEntity()
130201 let aurora = Entity()
131202}
@@ -147,8 +218,21 @@ struct SolarSystem3DView: View {
147218 @State private var flight: Task<Void, Never>?
148219 @State private var pulse = false
149220 @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
150225
151226 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 }
152236
153237 var body: some View {
154238 GeometryReader { geo in
@@ -165,12 +249,16 @@ struct SolarSystem3DView: View {
165249 labels(size: geo.size).allowsHitTesting(false)
166250
167251 VStack(spacing: 8) {
168 if let s = selected, let planet = planetOrSun(s) { card(planet) }
252 if let s = selected { card(s) }
169253 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 }
174262 }
175263 .padding(12)
176264 }
@@ -179,7 +267,7 @@ struct SolarSystem3DView: View {
179267 .onChange(of: geo.size) { viewSize = geo.size }
180268 }
181269 .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.")
183271 .onChange(of: scale) { resetView() }
184272 .task { await loadWeather() }
185273 .task {
@@ -190,6 +278,102 @@ struct SolarSystem3DView: View {
190278 }
191279 }
192280
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
193377 // MARK: Scene
194378
195379 private func build(_ content: RealityViewCameraContent) {
@@ -210,37 +394,63 @@ struct SolarSystem3DView: View {
210394 let a = CGFloat(0.09 * pow(0.78, Double(i - 1)))
211395 var g = UnlitMaterial(color: UIColor(red: 1, green: 0.72, blue: 0.32, alpha: a))
212396 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]))
215398 }
216399
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()
222407 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) {
224409 m.color = .init(texture: .init(texture))
225410 } else {
226 m.color = .init(tint: UIColor(Color(hex: p.color) ?? .gray))
411 m.color = .init(tint: UIColor(Color(hex: s.color) ?? .gray))
227412 }
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)
229418 let night = Self.nightShell()
230 night.name = "night"
231 e.nights[p.id] = night
419 e.nights[s.id] = night
232420 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)
237421 }
238422
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
239450 var flareMaterial = UnlitMaterial(color: UIColor(red: 1, green: 1, blue: 0.85, alpha: 0.9))
240451 flareMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.9))
241452 e.flare.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [flareMaterial])
242453 e.root.addChild(e.flare)
243 // Two faint bands per pole, a wider glow and a brighter core, just above the surface.
244454 for y: Float in [0.86, -0.86] {
245455 for (inner, outer, alpha) in [(0.36, 0.66, 0.22), (0.45, 0.56, 0.5)] as [(Float, Float, CGFloat)] {
246456 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 {
248458 e.aurora.addChild(ring)
249459 }
250460 }
251 e.planets["earth"]?.addChild(e.aurora)
461 e.bodies["earth"]?.addChild(e.aurora)
252462 place()
253463 }
254464
255 /// Moves everything for the current date, scale and camera.
465 /// Moves everything for the current date, scale, layers and camera.
256466 private func place() {
257467 let e = entities
258468 let map = mapping
259469 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)
270483 }
271484 }
272485 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.
274489 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 }
276494
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"] {
279496 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
282498 e.flare.scale = .init(repeating: map.sunRadius * (pulse ? 0.22 : 0.16))
283499 } else {
284500 e.flare.isEnabled = false
@@ -287,55 +503,148 @@ struct SolarSystem3DView: View {
287503
288504 e.camera.look(at: cam.target, from: cam.position, relativeTo: nil)
289505 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
291521 }
292522
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.
294534 private func rebuildOrbitsIfNeeded() {
295535 let e = entities
296536 let width = cam.distance * 0.0022
297537 if let (s, w) = e.orbitsBuilt, s == scale, w / width < 1.6, width / w < 1.6 { return }
298538 e.orbitsBuilt = (scale, width)
299539 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
308578 } 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))
311581 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)
315585 }
316586 }
317587 }
318588
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
319623 // MARK: Meshes
320624
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 {
323627 var positions: [SIMD3<Float>] = []
324628 var indices: [UInt32] = []
325629 let n = points.count
326630 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))
329636 if simd_length(side) < 1e-6 { side = SIMD3(1, 0, 0) }
330637 side = simd_normalize(side) * width / 2
331638 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 }
334643 }
335644 var d = MeshDescriptor(name: "orbit")
336645 d.positions = MeshBuffer(positions)
337646 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)
339648 }
340649
341650 /// A flat ring in the local x-z plane, radii in units of the parent's radius.
@@ -358,7 +667,7 @@ struct SolarSystem3DView: View {
358667 return ModelEntity(mesh: (try? MeshResource.generate(from: [d])) ?? .generateBox(size: 0.001), materials: [m])
359668 }
360669
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.
362671 private static func nightShell() -> ModelEntity {
363672 let rings = 24, segments = 64
364673 var positions: [SIMD3<Float>] = []
@@ -385,25 +694,12 @@ struct SolarSystem3DView: View {
385694 return ModelEntity(mesh: (try? MeshResource.generate(from: [d])) ?? .generateBox(size: 0.001), materials: [m])
386695 }
387696
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
392697 // MARK: Labels, taps and the card
393698
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)) }
405702 }
406 return out
407703 }
408704
409705 private func pixelRadius(_ r: Float, at p: SIMD3<Float>, size: CGSize) -> CGFloat {
@@ -411,34 +707,53 @@ struct SolarSystem3DView: View {
411707 return CGFloat(r / max(cam.distance(to: p), 1e-9) * k)
412708 }
413709
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 }
417723 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) }
420733 .map { item in
421734 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 }
423736 if clear { placed.append(at) }
424 return (item.id, item.point, item.radius, clear)
737 return (item.marker, item.point, item.radius, clear)
425738 }
426739 }
427740
428741 private func labels(size: CGSize) -> some View {
429742 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)
433747 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)
436750 }
437751 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)
442757 }
443758 }
444759 .position(item.point)
@@ -448,7 +763,7 @@ struct SolarSystem3DView: View {
448763
449764 private func tap(_ location: CGPoint, size: CGSize) {
450765 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)) }
452767 .filter { $0.1 < 28 }
453768 .min { $0.1 < $1.1 }
454769 guard let id = hit?.0 else { selected = nil; return }
@@ -456,14 +771,54 @@ struct SolarSystem3DView: View {
456771 fly(to: id)
457772 }
458773
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 }
460816
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)
464819 return VStack(alignment: .leading, spacing: 6) {
465820 HStack {
466 Text(page.title).font(.headline)
821 Text(d.title).font(.headline)
467822 Spacer()
468823 Button { selected = nil; resetView() } label: {
469824 Image(systemName: "xmark").font(.footnote.weight(.semibold)).frame(width: 44, height: 44).contentShape(Rectangle())
@@ -472,11 +827,11 @@ struct SolarSystem3DView: View {
472827 .padding(-12)
473828 .accessibilityLabel("Close")
474829 }
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)
478834 }
479 Button("Read more") { onOpen(page) }.font(.subheadline.weight(.medium)).foregroundStyle(accent)
480835 }
481836 .padding(14)
482837 .background(Color(white: 0.06), in: RoundedRectangle(cornerRadius: 14))
@@ -508,7 +863,7 @@ struct SolarSystem3DView: View {
508863 .onChanged { g in
509864 flight?.cancel()
510865 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
512867 cam.distance = min(max(pinchStart! / Float(g.magnification), limits.lowerBound), limits.upperBound)
513868 }
514869 .onEnded { _ in pinchStart = nil }
@@ -526,17 +881,15 @@ struct SolarSystem3DView: View {
526881 return neptune * 1.22 / min(tanV, tanH)
527882 }
528883
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).
530885 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))
540893 }
541894
542895 /// Moves the camera over about a second, interpolating distance on a log scale.
@@ -577,3 +930,13 @@ struct SolarSystem3DView: View {
577930 }
578931 }
579932}
933
934/// A small deterministic generator, so debris looks the same each time it is rebuilt.
935private 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}
App/Sources/SystemScreen.swift +1 −1
@@ -59,7 +59,7 @@ struct SystemScreen: View {
5959 }
6060 .pickerStyle(.segmented)
6161 Text(solarScale == .compressed
62 ? "Distances squeezed and planets enlarged so everything fits. Drag to turn, pinch to zoom, tap a planet."
62 ? "Distances squeezed and bodies enlarged so everything fits. Drag to turn, pinch to zoom, tap anything. Moons appear as you zoom in to their planet."
6363 : "Real distances and sizes: the planets are specks, so circles mark them. Tap one to fly to it.")
6464 .font(.caption).dimmed(0.7).padding(.top, 8)
6565 }
Packages/MoonriseKit/Sources/SkyEngine/Kepler.swift added +114
@@ -0,0 +1,114 @@
1import CAstronomy
2import Foundation
3
4/// A two-body orbit around the Sun from osculating elements (J2000 ecliptic), for small bodies the
5/// engine doesn't cover. Planetary perturbations are ignored, so positions drift away from the epoch:
6/// fine for drawing, not for observing.
7public struct KeplerOrbit: Sendable, Identifiable {
8 public var id: String
9 public var name: String
10 /// Library page, if there is one.
11 public var page: String?
12 public var a: Double, e: Double, i: Double, node: Double, peri: Double
13 /// Time of perihelion (TDB Julian date). Propagating from it rather than from the epoch's mean
14 /// anomaly keeps comets right near perihelion, where they are worth drawing.
15 public var perihelionJD: Double
16 public var isComet: Bool
17
18 /// Orbital period in days.
19 public var periodDays: Double { 365.256898 * pow(a, 1.5) }
20
21 /// Heliocentric position in AU on J2000 ecliptic axes.
22 public func position(at date: Date) -> SIMD3<Double> {
23 let jd = date.timeIntervalSince1970 / 86_400 + 2_440_587.5
24 let n = 360 / periodDays
25 let M = ((n * (jd - perihelionJD)).truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360) * .pi / 180
26 var E = e < 0.8 ? M : .pi
27 for _ in 0..<50 {
28 let d = (E - e * sin(E) - M) / (1 - e * cos(E))
29 E -= d
30 if abs(d) < 1e-12 { break }
31 }
32 let x = a * (cos(E) - e), y = a * (1 - e * e).squareRoot() * sin(E)
33 let r = Double.pi / 180
34 let (cO, sO) = (cos(node * r), sin(node * r)), (cw, sw) = (cos(peri * r), sin(peri * r)), (ci, si) = (cos(i * r), sin(i * r))
35 return SIMD3(
36 (cO * cw - sO * sw * ci) * x + (-cO * sw - sO * cw * ci) * y,
37 (sO * cw + cO * sw * ci) * x + (-sO * sw + cO * cw * ci) * y,
38 (sw * si) * x + (cw * si) * y
39 )
40 }
41
42 /// One full orbit, sampled evenly in eccentric anomaly so long, thin comet orbits stay smooth near the Sun.
43 public func orbitPath(steps: Int = 240) -> [SIMD3<Double>] {
44 let r = Double.pi / 180
45 let (cO, sO) = (cos(node * r), sin(node * r)), (cw, sw) = (cos(peri * r), sin(peri * r)), (ci, si) = (cos(i * r), sin(i * r))
46 return (0..<steps).map { k in
47 let E = Double(k) / Double(steps) * 2 * .pi
48 let x = a * (cos(E) - e), y = a * (1 - e * e).squareRoot() * sin(E)
49 return SIMD3(
50 (cO * cw - sO * sw * ci) * x + (-cO * sw - sO * cw * ci) * y,
51 (sO * cw + cO * sw * ci) * x + (-sO * sw + cO * cw * ci) * y,
52 (sw * si) * x + (cw * si) * y
53 )
54 }
55 }
56
57 /// The next perihelion after `date`.
58 public func nextPerihelion(after date: Date) -> Date {
59 let jd = date.timeIntervalSince1970 / 86_400 + 2_440_587.5
60 let k = ((jd - perihelionJD) / periodDays).rounded(.up)
61 return Date(timeIntervalSince1970: (perihelionJD + k * periodDays - 2_440_587.5) * 86_400)
62 }
63
64 /// JPL Small-Body Database osculating elements (full precision), fetched 2026-10-19.
65 public static let dwarfPlanets: [KeplerOrbit] = [
66 KeplerOrbit(id: "ceres", name: "Ceres", page: "ceres", a: 2.765552595034094, e: 0.07969229514816586, i: 10.58802780183462, node: 80.24862682043221, peri: 73.29421453021587, perihelionJD: 2461599.841466614, isComet: false),
67 KeplerOrbit(id: "haumea", name: "Haumea", page: "haumea", a: 43.06029023650952, e: 0.1944430148898797, i: 28.20847393040364, node: 121.7860561329425, peri: 240.6905472508661, perihelionJD: 2500416.599615283, isComet: false),
68 KeplerOrbit(id: "makemake", name: "Makemake", page: "makemake", a: 45.57093317300052, e: 0.1588889953992523, i: 29.02785603743067, node: 79.2948338209406, peri: 297.0922733397207, perihelionJD: 2408158.6940986165, isComet: false),
69 KeplerOrbit(id: "eris", name: "Eris", page: "eris", a: 67.93394687853566, e: 0.4382385347971672, i: 43.9258279471791, node: 36.00477044417249, peri: 150.7949235840312, perihelionJD: 2545407.716847013, isComet: false),
70 ]
71
72 public static let comets: [KeplerOrbit] = [
73 KeplerOrbit(id: "halley", name: "Halley's Comet", page: nil, a: 17.92863504856923, e: 0.9679359956953211, i: 162.1905300439129, node: 59.09894720612437, peri: 112.2414314637764, perihelionJD: 2446469.9736161465, isComet: true),
74 KeplerOrbit(id: "encke", name: "Comet Encke", page: nil, a: 2.218624763741819, e: 0.846940430888432, i: 11.33675914978793, node: 334.0171400057891, peri: 187.2881998732444, perihelionJD: 2460240.0285326503, isComet: true),
75 KeplerOrbit(id: "67p", name: "67P/Churyumov–Gerasimenko", page: "rosetta", a: 3.462249490129549, e: 0.6409081308996354, i: 7.040294937543767, node: 50.13557377155012, peri: 12.79824970228189, perihelionJD: 2457247.588657812, isComet: true),
76 KeplerOrbit(id: "swift-tuttle", name: "Comet Swift–Tuttle", page: nil, a: 26.0920694978266, e: 0.963225755046038, i: 113.453816997171, node: 139.3811920815948, peri: 152.9821676305871, perihelionJD: 2448968.499784556, isComet: true),
77 KeplerOrbit(id: "tempel-tuttle", name: "Comet Tempel–Tuttle", page: nil, a: 10.3383382297577, e: 0.905552720972412, i: 162.486575379434, node: 235.270989149082, peri: 172.5002736828059, perihelionJD: 2450872.5977341123, isComet: true),
78 KeplerOrbit(id: "phaethon", name: "3200 Phaethon", page: nil, a: 1.271464620762422, e: 0.8896722844001859, i: 22.31052728977475, node: 265.0988060326684, peri: 322.3001684954223, perihelionJD: 2461285.6164380596, isComet: true),
79 KeplerOrbit(id: "tuttle", name: "Comet 8P/Tuttle", page: nil, a: 5.700291361749948, e: 0.8197876162331501, i: 54.98196882510133, node: 270.3421615706648, peri: 207.5115175085094, perihelionJD: 2454492.5180154694, isComet: true),
80 ]
81}
82
83extension MeteorShower {
84 /// The comet (or asteroid) whose debris Earth passes through, by KeplerOrbit id.
85 public var parent: String? {
86 switch name {
87 case "Perseids": "swift-tuttle"
88 case "Leonids": "tempel-tuttle"
89 case "Orionids", "Eta Aquariids": "halley"
90 case "Geminids": "phaethon"
91 case "Ursids": "tuttle"
92 default: nil
93 }
94 }
95
96 /// Days from `date` to the nearest peak (negative once it has passed), in the given calendar.
97 public func daysToPeak(from date: Date, calendar: Calendar = .current) -> Double {
98 let year = calendar.component(.year, from: date)
99 return (year - 1...year + 1).compactMap { y in
100 calendar.date(from: DateComponents(year: y, month: month, day: day, hour: 12)).map { $0.timeIntervalSince(date) / 86_400 }
101 }
102 .min { abs($0) < abs($1) } ?? .infinity
103 }
104}
105
106extension SkyEngine {
107 /// Io, Europa, Ganymede and Callisto relative to Jupiter, AU on J2000 ecliptic axes.
108 public static func galileanMoons(at date: Date) -> [SIMD3<Double>] {
109 lock.lock()
110 defer { lock.unlock() }
111 let m = Astronomy_JupiterMoons(time(date))
112 return [m.io, m.europa, m.ganymede, m.callisto].map { ecliptic(SIMD3($0.x, $0.y, $0.z)) }
113 }
114}
Packages/MoonriseKit/Tests/SkyEngineTests/KeplerTests.swift added +52
@@ -0,0 +1,52 @@
1import Foundation
2import Testing
3@testable import SkyEngine
4
5private func jd(_ jd: Double) -> Date { Date(timeIntervalSince1970: (jd - 2_440_587.5) * 86_400) }
6private func auDist(_ a: SIMD3<Double>, _ b: SIMD3<Double>) -> Double { ((a - b) * (a - b)).sum().squareRoot() }
7
8@Test func ceresMatchesHorizons() throws {
9 // JPL Horizons, heliocentric J2000 ecliptic, 2026-10-19 00:00 TDB.
10 let ceres = try #require(KeplerOrbit.dwarfPlanets.first { $0.id == "ceres" })
11 let p = ceres.position(at: jd(2461332.5))
12 #expect(auDist(p, SIMD3(0.09160098, 2.65726102, 0.06724773)) < 0.003)
13}
14
15@Test func halleyMatchesHorizons() throws {
16 let halley = try #require(KeplerOrbit.comets.first { $0.id == "halley" })
17 // At its 1986 perihelion: within 0.05 AU, about a day of its motion there (Horizons uses a perturbed solution).
18 #expect(auDist(halley.position(at: jd(2446470.5)), SIMD3(0.34233380, -0.44659254, 0.16779698)) < 0.05)
19 // ...and near aphelion now, where two-body drift is a few tenths of an AU at 36 AU.
20 #expect(auDist(halley.position(at: jd(2461332.5)), SIMD3(-19.34593976, 27.47052259, -9.85160687)) < 1)
21}
22
23@Test func showersFindParentsAndPeaks() {
24 let perseids = MeteorShower.major.first { $0.name == "Perseids" }!
25 #expect(perseids.parent == "swift-tuttle")
26 var cal = Calendar(identifier: .gregorian)
27 cal.timeZone = TimeZone(identifier: "UTC")!
28 let aug10 = cal.date(from: DateComponents(year: 2027, month: 8, day: 10, hour: 12))!
29 #expect(abs(perseids.daysToPeak(from: aug10, calendar: cal) - 2) < 0.01)
30 let dec30 = cal.date(from: DateComponents(year: 2026, month: 12, day: 30, hour: 12))!
31 let quadrantids = MeteorShower.major.first { $0.name == "Quadrantids" }!
32 #expect(abs(quadrantids.daysToPeak(from: dec30, calendar: cal) - 4) < 0.01)
33}
34
35@Test func galileanMoonDistances() {
36 let km = SkyEngine.galileanMoons(at: Date(timeIntervalSince1970: 1_792_400_000)).map { ($0 * $0).sum().squareRoot() * 1.495978707e8 }
37 // Mean distances 421,800 / 671,100 / 1,070,400 / 1,882,700 km; orbits are nearly circular.
38 for (d, mean) in zip(km, [421_800.0, 671_100, 1_070_400, 1_882_700]) { #expect(abs(d - mean) / mean < 0.02) }
39}
40
41@Test func enckeReturnsFeb2027() throws {
42 // Short periods are where two-body prediction holds; JPL lists Encke's next perihelion as 2027-02-10.
43 let encke = try #require(KeplerOrbit.comets.first { $0.id == "encke" })
44 let next = encke.nextPerihelion(after: Date(timeIntervalSince1970: 1_792_400_000))
45 #expect(abs(next.timeIntervalSince(Date(timeIntervalSince1970: 1_802_217_600)) / 86_400) < 3)
46}
47
48@Test func halleyPathSpansItsOrbit() throws {
49 let halley = try #require(KeplerOrbit.comets.first { $0.id == "halley" })
50 let r = halley.orbitPath().map { ($0 * $0).sum().squareRoot() }
51 #expect(abs(r.min()! - 0.575) < 0.001 && abs(r.max()! - 35.28) < 0.01)
52}
THIRD_PARTY.org +1
@@ -9,6 +9,7 @@
99| Hipparcos main catalogue positions (used only to match HIP to HR when building skycultures.json; not bundled) | — | ESA | https://cdsarc.cds.unistra.fr/viz-bin/cat/I/239 |
1010| Global maps for globes (Mercury PIA15063; Venus Magellan C3-MDIR colourised topography; Earth Blue Marble Next Generation, December 2004; Moon SVS 4720 LROC colour 2K; Mars Viking MDIM 2.1 colour; Jupiter PIA07782; Pluto New Horizons global mosaic; Io, Europa, Ganymede, Callisto USGS Galileo/Voyager mosaics; Titan PIA22770; Enceladus Schenk 2024), downscaled to at most 2048 px | =App/Globes/*.jpg= | Public domain (NASA, USGS); credit per map in =App/Sources/GlobeMaps.swift= | https://science.nasa.gov/photojournal, https://svs.gsfc.nasa.gov/4720/, https://astrogeology.usgs.gov |
1111| Sun texture for the 3D solar system (=App/Globes/sun.jpg=) | generated noise, made for this app | — | — |
12| Orbital elements for Ceres, Haumea, Makemake, Eris and seven comets (=Packages/MoonriseKit/Sources/SkyEngine/Kepler.swift=) | JPL Small-Body Database, fetched 2026-10-19 | NASA/JPL, no published restrictions | https://ssd.jpl.nasa.gov/tools/sbdb_lookup.html |
1213| ConstellationLines v1.4, Copyright (c) 2005-2026 Marc van der Sluys | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/constellations.json= | CC BY 4.0 | https://github.com/MarcvdSluys/ConstellationLines, doi:10.5281/zenodo.10397192 |
1314| Natural Earth 1:110m coastline v5.1.2 | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/coastline.json= | Public domain | https://www.naturalearthdata.com |
1415| Launch Library 2 (The Space Devs) | fetched at runtime by =server/= | Free API; credit shown in the app | https://thespacedevs.com/llapi |