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 7efb6a812b

7efb6a812b0a56b546150c111d7c37fc87cf676c

parent: ae45cdb97e

Unsigned

cmc <hello@cleberg.net> · 2026-10-18 22:05 UTC

v3: Globes and Vision Pro (!39)

- Library planet, dwarf-planet, moon and Sun pages › View as a globe: a RealityKit sphere, tilted by the body's axial tilt, lit from the side, turned by dragging.
- Maps (public domain, NASA/USGS, at most 2048 px, 3.9 MB total) for Mercury, Venus (Magellan radar topography), Earth, Moon, Mars, Jupiter, Pluto, Io, Europa, Ganymede, Callisto, Titan, Enceladus. Other bodies, including Saturn (no ready-made public map), use the page colour. Credits shown under each globe and in THIRD_PARTY.org.
- visionOS app (=MoonriseVision=): the globes in a window, an orrery volume with today's planet positions (square-root distances), and an immersive space with stars brighter than magnitude 4.5 placed for now. Compiled for the visionOS simulator; not run, because no visionOS simulator runtime is installed here.
- =Color(hex:)= moved to its own file so the visionOS target can share it.
- Checked: Mars (colour fallback) and Earth (map) globes on the iPhone simulator; Earth is not mirrored.

Layout: unified · split

App/Globes/callisto.jpg added

Binary file not shown.

App/Globes/earth.jpg added

Binary file not shown.

App/Globes/enceladus.jpg added

Binary file not shown.

App/Globes/europa.jpg added

Binary file not shown.

App/Globes/ganymede.jpg added

Binary file not shown.

App/Globes/io.jpg added

Binary file not shown.

App/Globes/jupiter.jpg added

Binary file not shown.

App/Globes/mars.jpg added

Binary file not shown.

App/Globes/mercury.jpg added

Binary file not shown.

App/Globes/moon.jpg added

Binary file not shown.

App/Globes/pluto.jpg added

Binary file not shown.

App/Globes/titan.jpg added

Binary file not shown.

App/Globes/venus.jpg added

Binary file not shown.

App/Sources/ColorHex.swift added +8
@@ -0,0 +1,8 @@
1import SwiftUI
2
3extension Color {
4 init?(hex: String?) {
5 guard let hex, hex.hasPrefix("#"), hex.count == 7, let v = UInt32(hex.dropFirst(), radix: 16) else { return nil }
6 self.init(red: Double(v >> 16 & 0xFF) / 255, green: Double(v >> 8 & 0xFF) / 255, blue: Double(v & 0xFF) / 255)
7 }
8}
App/Sources/GlobeMaps.swift added +19
@@ -0,0 +1,19 @@
1/// Credits for the global maps in Globes/, by Library page id. All are public domain (NASA, USGS);
2/// sources and dates are in THIRD_PARTY.org.
3enum GlobeMaps {
4 static let credit: [String: String] = [
5 "mercury": "NASA/JHU APL/Carnegie Institution of Washington (MESSENGER, PIA15063)",
6 "venus": "NASA/JPL/USGS (Magellan radar topography, colourised)",
7 "earth": "NASA Earth Observatory (Blue Marble Next Generation, December)",
8 "moon": "NASA's Scientific Visualization Studio (LRO LROC and LOLA)",
9 "mars": "NASA/JPL/USGS (Viking MDIM 2.1 colour mosaic)",
10 "jupiter": "NASA/JPL/Space Science Institute (Cassini, PIA07782)",
11 "pluto": "NASA/JHU APL/SwRI; USGS Astrogeology (New Horizons)",
12 "io": "NASA/JPL/USGS (Galileo SSI colour merge)",
13 "europa": "NASA/JPL/USGS (Voyager and Galileo SSI)",
14 "ganymede": "NASA/JPL/USGS (Voyager and Galileo SSI)",
15 "callisto": "NASA/JPL/USGS (Voyager and Galileo SSI)",
16 "titan": "NASA/JPL-Caltech/University of Arizona (Cassini VIMS, near-infrared, PIA22770)",
17 "enceladus": "NASA/JPL-Caltech/SSI; mosaic by P. Schenk and W. McKinnon, LPI/USRA",
18 ]
19}
App/Sources/GlobeView.swift added +89
@@ -0,0 +1,89 @@
1import Library
2import RealityKit
3import SwiftUI
4
5/// A Library body as a sphere you can turn with a drag: a global map from the app bundle
6/// (Globes/<page id>.jpg) when there is one, otherwise the page's colour. Tilted by its axial tilt.
7struct GlobeView: View {
8 var page: LibraryPage
9 var credit: String?
10 @State private var yaw: Float = 0
11 @State private var pitch: Float = 0
12 @State private var dragStart: (Float, Float)?
13
14 /// Pitch from the drag, then the axial tilt, then the spin.
15 private var orientation: simd_quatf {
16 let degrees: Double = page.drawing?.axialTiltDeg ?? 0
17 let tilt = simd_quatf(angle: Float(degrees * .pi / 180), axis: SIMD3<Float>(0, 0, 1))
18 let spin = simd_quatf(angle: yaw, axis: SIMD3<Float>(0, 1, 0))
19 return simd_quatf(angle: pitch, axis: SIMD3<Float>(1, 0, 0)) * tilt * spin
20 }
21
22 static func mapURL(_ id: String) -> URL? { Bundle.main.url(forResource: id, withExtension: "jpg", subdirectory: "Globes") }
23
24 var body: some View {
25 VStack(spacing: 12) {
26 RealityView { content in
27 let globe = await Self.makeGlobe(page)
28 #if os(visionOS)
29 // A 30 cm globe in the window; the room's lighting shades it.
30 globe.scale = .init(repeating: 0.3)
31 content.add(globe)
32 #else
33 content.camera = .virtual
34 content.add(globe)
35
36 let camera = PerspectiveCamera()
37 camera.position = [0, 0, 1.9]
38 content.add(camera)
39
40 let sun = DirectionalLight()
41 sun.light.intensity = 4000
42 sun.look(at: .zero, from: [-1.5, 0.3, 0.5], relativeTo: nil)
43 content.add(sun)
44 #endif
45 } update: { content in
46 guard let globe = content.entities.first(where: { $0.name == "globe" }) else { return }
47 globe.orientation = orientation
48 }
49 .gesture(DragGesture()
50 .onChanged { g in
51 if dragStart == nil { dragStart = (yaw, pitch) }
52 yaw = dragStart!.0 + Float(g.translation.width) * 0.01
53 pitch = min(max(dragStart!.1 + Float(g.translation.height) * 0.01, -1.2), 1.2)
54 }
55 .onEnded { _ in dragStart = nil })
56 .accessibilityLabel("\(page.title) as a globe. Drag to turn it.")
57 Text(Self.mapURL(page.id) == nil
58 ? "No global map yet; shown in its average colour. Drag to turn."
59 : "Drag to turn. Map: \(credit ?? "NASA").")
60 .font(.caption2).foregroundStyle(.secondary)
61 .padding(.horizontal)
62 }
63 .background(Color.black.ignoresSafeArea())
64 .navigationTitle(page.title)
65 .navigationBarTitleDisplayMode(.inline)
66 }
67
68 @MainActor
69 static func makeGlobe(_ page: LibraryPage) async -> Entity {
70 var material = PhysicallyBasedMaterial()
71 material.roughness = .init(floatLiteral: 1)
72 material.metallic = .init(floatLiteral: 0)
73 if let url = mapURL(page.id), let texture = try? await TextureResource(contentsOf: url) {
74 material.baseColor = .init(texture: .init(texture))
75 } else {
76 material.baseColor = .init(tint: UIColor(Color(hex: page.drawing?.color ?? "") ?? .gray))
77 }
78 if page.id == "sun" {
79 var glow = UnlitMaterial(color: UIColor(Color(hex: page.drawing?.color ?? "") ?? .yellow))
80 if let url = mapURL(page.id), let texture = try? await TextureResource(contentsOf: url) { glow.color = .init(texture: .init(texture)) }
81 let sun = ModelEntity(mesh: .generateSphere(radius: 0.5), materials: [glow])
82 sun.name = "globe"
83 return sun
84 }
85 let globe = ModelEntity(mesh: .generateSphere(radius: 0.5), materials: [material])
86 globe.name = "globe"
87 return globe
88 }
89}
App/Sources/LibraryScreen.swift +6
@@ -104,6 +104,12 @@ struct LibraryPageView: View {
104 } 104 }
105 .padding(.top, 10) 105 .padding(.top, 10)
106 } 106 }
107 if page.drawing?.kind == "planet" {
108 NavigationLink { GlobeView(page: page, credit: GlobeMaps.credit[page.id]) } label: {
109 Label("View as a globe", systemImage: "globe").font(.subheadline)
110 }
111 .padding(.top, 10)
112 }
107 113
108 facts.padding(.top, 16) 114 facts.padding(.top, 16)
109 115
App/Sources/PageDrawing.swift −7
@@ -228,10 +228,3 @@ struct PageDrawing: View {
228 } 228 }
229 } 229 }
230} 230}
231
232extension Color {
233 init?(hex: String?) {
234 guard let hex, hex.hasPrefix("#"), hex.count == 7, let v = UInt32(hex.dropFirst(), radix: 16) else { return nil }
235 self.init(red: Double(v >> 16 & 0xFF) / 255, green: Double(v >> 8 & 0xFF) / 255, blue: Double(v & 0xFF) / 255)
236 }
237}
App/Vision/MoonriseVisionApp.swift added +103
@@ -0,0 +1,103 @@
1import Library
2import RealityKit
3import SkyEngine
4import SkyRenderer
5import SwiftUI
6
7@main
8struct MoonriseVisionApp: App {
9 var body: some SwiftUI.Scene {
10 WindowGroup { VisionHome() }
11
12 WindowGroup(id: "orrery") { OrreryVolume() }
13 .windowStyle(.volumetric)
14 .defaultSize(width: 0.8, height: 0.4, depth: 0.8, in: .meters)
15
16 ImmersiveSpace(id: "sky") { ImmersiveSky() }
17 }
18}
19
20/// Library globes, plus buttons for the orrery volume and the immersive sky.
21struct VisionHome: View {
22 @Environment(\.openWindow) private var openWindow
23 @Environment(\.openImmersiveSpace) private var openImmersiveSpace
24 @Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace
25 @State private var immersed = false
26
27 private var bodies: [LibraryPage] {
28 [LibrarySection.sun, .planets, .dwarfPlanets, .moons].flatMap { Library.pages(in: $0) }
29 }
30
31 var body: some View {
32 NavigationSplitView {
33 List(bodies) { page in
34 NavigationLink(page.title) { GlobeView(page: page, credit: GlobeMaps.credit[page.id]) }
35 }
36 .navigationTitle("Moonrise")
37 .toolbar {
38 ToolbarItem(placement: .bottomOrnament) {
39 HStack {
40 Button("Orrery") { openWindow(id: "orrery") }
41 Button(immersed ? "Leave the sky" : "Tonight's sky") {
42 Task {
43 if immersed { await dismissImmersiveSpace() } else { _ = await openImmersiveSpace(id: "sky") }
44 immersed.toggle()
45 }
46 }
47 }
48 }
49 }
50 } detail: {
51 Text("Choose a body to see it as a globe.").foregroundStyle(.secondary)
52 }
53 }
54}
55
56/// Outside the view, where `Body` is SkyEngine's rather than the view's body type.
57private let orreryPlanets = Body.planets
58
59/// The Sun and planets at today's heliocentric positions. Distances are square-rooted so Neptune fits.
60struct OrreryVolume: View {
61 var body: some View {
62 RealityView { content in
63 let sun = ModelEntity(mesh: .generateSphere(radius: 0.025), materials: [UnlitMaterial(color: .yellow)])
64 content.add(sun)
65 for body in orreryPlanets {
66 guard let p = try? SkyEngine.heliocentricEcliptic(body, at: .now) else { continue }
67 let au = (p * p).sum().squareRoot()
68 let scale = 0.065 * au.squareRoot() / au
69 let page = Library.page(body: body.rawValue)
70 let colour = UIColor(Color(hex: page?.drawing?.color) ?? .white)
71 let planet = ModelEntity(mesh: .generateSphere(radius: 0.008), materials: [SimpleMaterial(color: colour, isMetallic: false)])
72 // Ecliptic x, y in the horizontal plane; ecliptic north up.
73 planet.position = [Float(p.x * scale), Float(p.z * scale), Float(-p.y * scale)]
74 content.add(planet)
75 }
76 }
77 }
78}
79
80/// Stars brighter than magnitude 4.5 on a 20 m sphere, placed for now from Chicago,
81/// with the horizon as a faint disc.
82struct ImmersiveSky: View {
83 var body: some View {
84 RealityView { content in
85 let observer = Observer(latitude: 41.8781, longitude: -87.6298)
86 let stars = Catalog.stars.filter { $0.magnitude < 4.5 }
87 let positions = SkyEngine.horizontal(j2000: stars.map { $0.position(at: .now) }, at: .now, from: observer)
88 let mesh = MeshResource.generateSphere(radius: 0.04)
89 for (star, h) in zip(stars, positions) where h.altitude > -2 {
90 let alt = Float(h.altitude * .pi / 180), az = Float(h.azimuth * .pi / 180)
91 let r: Float = 20
92 let s = ModelEntity(mesh: mesh, materials: [UnlitMaterial(color: .white)])
93 s.scale = .init(repeating: max(0.3, Float(2.2 - 0.4 * star.magnitude)))
94 // Azimuth from north (−z) towards east (+x).
95 s.position = [r * cos(alt) * sin(az), r * sin(alt), -r * cos(alt) * cos(az)]
96 content.add(s)
97 }
98 let ground = ModelEntity(mesh: .generatePlane(width: 40, depth: 40), materials: [UnlitMaterial(color: UIColor(white: 0.05, alpha: 0.9))])
99 ground.position.y = -0.01
100 content.add(ground)
101 }
102 }
103}
App/project.yml +34 −1
@@ -4,6 +4,7 @@ options:
4 deploymentTarget: 4 deploymentTarget:
5 iOS: "26.0" 5 iOS: "26.0"
6 watchOS: "26.0" 6 watchOS: "26.0"
7 visionOS: "26.0"
7packages: 8packages:
8 MoonriseKit: 9 MoonriseKit:
9 path: ../Packages/MoonriseKit 10 path: ../Packages/MoonriseKit
@@ -13,7 +14,11 @@ targets:
13 platform: iOS 14 platform: iOS
14 scheme: 15 scheme:
15 testTargets: [MoonriseUITests] 16 testTargets: [MoonriseUITests]
16 sources: [Sources, Shared] 17 sources:
18 - Sources
19 - Shared
20 - path: Globes
21 type: folder
17 dependencies: 22 dependencies:
18 - package: MoonriseKit 23 - package: MoonriseKit
19 product: SkyEngine 24 product: SkyEngine
@@ -141,6 +146,34 @@ targets:
141 SWIFT_VERSION: "6.0" 146 SWIFT_VERSION: "6.0"
142 MARKETING_VERSION: "1.0" 147 MARKETING_VERSION: "1.0"
143 CURRENT_PROJECT_VERSION: "1" 148 CURRENT_PROJECT_VERSION: "1"
149 MoonriseVision:
150 type: application
151 platform: visionOS
152 sources:
153 - Vision
154 - Sources/GlobeView.swift
155 - Sources/GlobeMaps.swift
156 - Sources/ColorHex.swift
157 - path: Globes
158 type: folder
159 dependencies:
160 - package: MoonriseKit
161 product: SkyEngine
162 - package: MoonriseKit
163 product: SkyRenderer
164 - package: MoonriseKit
165 product: Library
166 settings:
167 base:
168 PRODUCT_BUNDLE_IDENTIFIER: sh.krz.moonrise.vision
169 DEVELOPMENT_TEAM: ZCNAX3VL9D
170 CODE_SIGN_STYLE: Automatic
171 SWIFT_VERSION: "6.0"
172 MARKETING_VERSION: "1.0"
173 CURRENT_PROJECT_VERSION: "1"
174 GENERATE_INFOPLIST_FILE: YES
175 INFOPLIST_KEY_CFBundleDisplayName: Moonrise
176 INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
144 MoonriseUITests: 177 MoonriseUITests:
145 type: bundle.ui-testing 178 type: bundle.ui-testing
146 platform: iOS 179 platform: iOS
PLAN.org +5 −1
@@ -413,7 +413,11 @@ Sky, System, Sun, Launches, Library core (above).
413 to Stellarium). 413 to Stellarium).
414- Historical sky for any date and place, with precession. Done: 3000 BCE 414- Historical sky for any date and place, with precession. Done: 3000 BCE
415 to 3000 CE, with BSC5 proper motions and the pole star of the year. 415 to 3000 CE, with BSC5 proper motions and the pole star of the year.
416- 3D planets from USGS maps; immersive sky and orrery on Vision Pro. 416- 3D planets from USGS maps; immersive sky and orrery on Vision Pro. Done:
417 globes for the Sun, planets, dwarf planets and moons (NASA/USGS maps for 13,
418 colour for the rest); visionOS app with globes, an orrery volume and an
419 immersive star field. visionOS is compiled but not yet run (no simulator
420 runtime installed).
417- Guided seasonal sky tours and mission stories. Tours done: four, by 421- Guided seasonal sky tours and mission stories. Tours done: four, by
418 months, 5 to 8 stops each, in Library/Resources/tours. Mission stories 422 months, 5 to 8 stops each, in Library/Resources/tours. Mission stories
419 done for Voyager 1 and 2, Pioneer 10 and 11 and New Horizons, on the orrery. 423 done for Voyager 1 and 2, Pioneer 10 and 11 and New Horizons, on the orrery.
Packages/MoonriseKit/Package.swift +1 −1
@@ -3,7 +3,7 @@ import PackageDescription
3 3
4let package = Package( 4let package = Package(
5 name: "MoonriseKit", 5 name: "MoonriseKit",
6 platforms: [.iOS(.v26), .macOS(.v26), .watchOS(.v26)], 6 platforms: [.iOS(.v26), .macOS(.v26), .watchOS(.v26), .visionOS(.v26)],
7 products: [ 7 products: [
8 .library(name: "SkyEngine", targets: ["SkyEngine"]), 8 .library(name: "SkyEngine", targets: ["SkyEngine"]),
9 .library(name: "SkyRenderer", targets: ["SkyRenderer"]), 9 .library(name: "SkyRenderer", targets: ["SkyRenderer"]),
THIRD_PARTY.org +1
@@ -7,6 +7,7 @@
7| BSC5 proper motions (pmRA, pmDE) | same file, columns 6 and 7 | as above | https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50 | 7| BSC5 proper motions (pmRA, pmDE) | same file, columns 6 and 7 | as above | https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50 |
8| Stellarium sky cultures: Chinese, Indian, Mongolian, Japanese moon stations, Hawaiian starlines, Māori, Tongan, Boorong, Sámi, Norse (lines and names, converted to HR numbers by =scripts/make-skycultures.py=) | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/skycultures.json= | CC BY-SA (per culture, stated in each culture's description.md); the converted file is under the same licence | https://github.com/Stellarium/stellarium-skycultures | 8| Stellarium sky cultures: Chinese, Indian, Mongolian, Japanese moon stations, Hawaiian starlines, Māori, Tongan, Boorong, Sámi, Norse (lines and names, converted to HR numbers by =scripts/make-skycultures.py=) | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/skycultures.json= | CC BY-SA (per culture, stated in each culture's description.md); the converted file is under the same licence | https://github.com/Stellarium/stellarium-skycultures |
9| 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 | 9| 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 |
10| 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 |
10| 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 | 11| 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 |
11| Natural Earth 1:110m coastline v5.1.2 | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/coastline.json= | Public domain | https://www.naturalearthdata.com | 12| Natural Earth 1:110m coastline v5.1.2 | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/coastline.json= | Public domain | https://www.naturalearthdata.com |
12| Launch Library 2 (The Space Devs) | fetched at runtime by =server/= | Free API; credit shown in the app | https://thespacedevs.com/llapi | 13| Launch Library 2 (The Space Devs) | fetched at runtime by =server/= | Free API; credit shown in the app | https://thespacedevs.com/llapi |