Commit e3e43611b0
Verified · cmc
Layout: unified · split
App/Sources/ColorHex.swift added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | extension 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 +4
| @@ -0,0 +1,4 @@ | ||
| 1 | /// Credits for the global maps in Globes/, by Library page id. | |
| 2 | enum GlobeMaps { | |
| 3 | static let credit: [String: String] = [:] | |
| 4 | } | |
App/Sources/GlobeView.swift added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | import Library | |
| 2 | import RealityKit | |
| 3 | import 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. | |
| 7 | struct 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
| @@ -98,6 +98,12 @@ struct LibraryPageView: View { | ||
| 98 | 98 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 16) |
| 99 | 99 | |
| 100 | 100 | PageDrawing(page: page, ink: ink, accent: accent) |
| 101 | if page.drawing?.kind == "planet" { | |
| 102 | NavigationLink { GlobeView(page: page, credit: GlobeMaps.credit[page.id]) } label: { | |
| 103 | Label("View as a globe", systemImage: "globe").font(.subheadline) | |
| 104 | } | |
| 105 | .padding(.top, 10) | |
| 106 | } | |
| 101 | 107 | |
| 102 | 108 | facts.padding(.top, 16) |
| 103 | 109 | |
App/Sources/PageDrawing.swift −7
| @@ -228,10 +228,3 @@ struct PageDrawing: View { | ||
| 228 | 228 | } |
| 229 | 229 | } |
| 230 | 230 | } |
| 231 | ||
| 232 | extension 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 @@ | ||
| 1 | import Library | |
| 2 | import RealityKit | |
| 3 | import SkyEngine | |
| 4 | import SkyRenderer | |
| 5 | import SwiftUI | |
| 6 | ||
| 7 | @main | |
| 8 | struct 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. | |
| 21 | struct 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. | |
| 57 | private let orreryPlanets = Body.planets | |
| 58 | ||
| 59 | /// The Sun and planets at today's heliocentric positions. Distances are square-rooted so Neptune fits. | |
| 60 | struct 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. | |
| 82 | struct 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 | 4 | deploymentTarget: |
| 5 | 5 | iOS: "26.0" |
| 6 | 6 | watchOS: "26.0" |
| 7 | visionOS: "26.0" | |
| 7 | 8 | packages: |
| 8 | 9 | MoonriseKit: |
| 9 | 10 | path: ../Packages/MoonriseKit |
| @@ -13,7 +14,11 @@ targets: | ||
| 13 | 14 | platform: iOS |
| 14 | 15 | scheme: |
| 15 | 16 | testTargets: [MoonriseUITests] |
| 16 | sources: [Sources, Shared] | |
| 17 | sources: | |
| 18 | - Sources | |
| 19 | - Shared | |
| 20 | - path: Globes | |
| 21 | type: folder | |
| 17 | 22 | dependencies: |
| 18 | 23 | - package: MoonriseKit |
| 19 | 24 | product: SkyEngine |
| @@ -141,6 +146,34 @@ targets: | ||
| 141 | 146 | SWIFT_VERSION: "6.0" |
| 142 | 147 | MARKETING_VERSION: "1.0" |
| 143 | 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 | 177 | MoonriseUITests: |
| 145 | 178 | type: bundle.ui-testing |
| 146 | 179 | platform: iOS |
Packages/MoonriseKit/Package.swift +1 −1
| @@ -3,7 +3,7 @@ import PackageDescription | ||
| 3 | 3 | |
| 4 | 4 | let package = Package( |
| 5 | 5 | name: "MoonriseKit", |
| 6 | platforms: [.iOS(.v26), .macOS(.v26), .watchOS(.v26)], | |
| 6 | platforms: [.iOS(.v26), .macOS(.v26), .watchOS(.v26), .visionOS(.v26)], | |
| 7 | 7 | products: [ |
| 8 | 8 | .library(name: "SkyEngine", targets: ["SkyEngine"]), |
| 9 | 9 | .library(name: "SkyRenderer", targets: ["SkyRenderer"]), |