v3: Globes and Vision Pro !39

merged merged by cmc on 2026-10-18 22:05 UTC · krz/moonrise:v3-globes into main

23 files changed, +266 −10

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 {
104104 }
105105 .padding(.top, 10)
106106 }
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 }
107113
108114 facts.padding(.top, 16)
109115
App/Sources/PageDrawing.swift −7
@@ -228,10 +228,3 @@ struct PageDrawing: View {
228228 }
229229 }
230230}
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:
44 deploymentTarget:
55 iOS: "26.0"
66 watchOS: "26.0"
7 visionOS: "26.0"
78packages:
89 MoonriseKit:
910 path: ../Packages/MoonriseKit
@@ -13,7 +14,11 @@ targets:
1314 platform: iOS
1415 scheme:
1516 testTargets: [MoonriseUITests]
16 sources: [Sources, Shared]
17 sources:
18 - Sources
19 - Shared
20 - path: Globes
21 type: folder
1722 dependencies:
1823 - package: MoonriseKit
1924 product: SkyEngine
@@ -141,6 +146,34 @@ targets:
141146 SWIFT_VERSION: "6.0"
142147 MARKETING_VERSION: "1.0"
143148 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
144177 MoonriseUITests:
145178 type: bundle.ui-testing
146179 platform: iOS
PLAN.org +5 −1
@@ -413,7 +413,11 @@ Sky, System, Sun, Launches, Library core (above).
413413 to Stellarium).
414414- Historical sky for any date and place, with precession. Done: 3000 BCE
415415 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).
417421- Guided seasonal sky tours and mission stories. Tours done: four, by
418422 months, 5 to 8 stops each, in Library/Resources/tours. Mission stories
419423 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
33
44let package = Package(
55 name: "MoonriseKit",
6 platforms: [.iOS(.v26), .macOS(.v26), .watchOS(.v26)],
6 platforms: [.iOS(.v26), .macOS(.v26), .watchOS(.v26), .visionOS(.v26)],
77 products: [
88 .library(name: "SkyEngine", targets: ["SkyEngine"]),
99 .library(name: "SkyRenderer", targets: ["SkyRenderer"]),
THIRD_PARTY.org +1
@@ -7,6 +7,7 @@
77| BSC5 proper motions (pmRA, pmDE) | same file, columns 6 and 7 | as above | https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50 |
88| 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 |
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 |
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 |
1011| 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 |
1112| Natural Earth 1:110m coastline v5.1.2 | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/coastline.json= | Public domain | https://www.naturalearthdata.com |
1213| Launch Library 2 (The Space Devs) | fetched at runtime by =server/= | Free API; credit shown in the app | https://thespacedevs.com/llapi |