feat: Ipad app features and Dream Weaver for Velocity WebOS
Some checks failed
Production Readiness / backend-contracts (pull_request) Has been cancelled
Production Readiness / webos-typecheck (pull_request) Has been cancelled
Production Readiness / ipad-parse (pull_request) Has been cancelled

This commit is contained in:
Sayan Datta
2026-04-28 10:59:07 +05:30
parent 184bfa77f8
commit fefe8373ec
117 changed files with 19510 additions and 6383 deletions

View File

@@ -0,0 +1,276 @@
import ARKit
import CoreLocation
import CoreMotion
import SceneKit
import SwiftUI
// MARK: - ARSunOverlayView
struct ARSunOverlayView: UIViewRepresentable {
@Binding var sunNodesReady: Bool
let vm: SunseekerViewModel
func makeCoordinator() -> Coordinator {
Coordinator(sunNodesReady: $sunNodesReady, vm: vm)
}
func makeUIView(context: Context) -> ARSCNView {
let view = ARSCNView(frame: .zero)
view.delegate = context.coordinator
view.scene = SCNScene()
view.automaticallyUpdatesLighting = true
let config = ARWorldTrackingConfiguration()
config.worldAlignment = .gravityAndHeading // north = -Z axis
view.session.run(config)
context.coordinator.attach(to: view)
return view
}
func updateUIView(_ uiView: ARSCNView, context: Context) {}
static func dismantleUIView(_ uiView: ARSCNView, coordinator: Coordinator) {
uiView.session.pause()
coordinator.stop()
}
// MARK: - Coordinator
final class Coordinator: NSObject, ARSCNViewDelegate, CLLocationManagerDelegate {
private weak var sceneView: ARSCNView?
private let vm: SunseekerViewModel
@Binding private var sunNodesReady: Bool
// Scene node containers (replaced on each rebuild)
private var arcRootNode = SCNNode()
private var currentSunNode = SCNNode()
private var isSceneBuilt = false
// Fallback timer for CoreMotion-only mode
private var fallbackTimer: Timer?
private var limitedTrackingStart: Date?
init(sunNodesReady: Binding<Bool>, vm: SunseekerViewModel) {
_sunNodesReady = sunNodesReady
self.vm = vm
}
func attach(to sceneView: ARSCNView) {
self.sceneView = sceneView
sceneView.scene.rootNode.addChildNode(arcRootNode)
sceneView.scene.rootNode.addChildNode(currentSunNode)
}
func stop() {
vm.stop()
fallbackTimer?.invalidate()
}
// MARK: - ARSCNViewDelegate per-frame update
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
guard vm.isReady else { return }
// Build arc once
if !isSceneBuilt {
DispatchQueue.main.async { self.buildScene() }
}
// Update current sun orb every frame
if let cur = vm.currentPosition {
let pos = vm.worldPosition(for: cur, radius: 1.8)
currentSunNode.position = pos
}
}
func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) {
switch camera.trackingState {
case .limited(let reason):
print("[Sunseeker] Tracking limited: \(reason)")
if limitedTrackingStart == nil {
limitedTrackingStart = Date()
// After 5s of limited tracking, switch to CoreMotion attitude fallback
fallbackTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in
self?.activateCoreMotionFallback()
}
}
case .normal:
limitedTrackingStart = nil
fallbackTimer?.invalidate()
fallbackTimer = nil
case .notAvailable:
break
@unknown default:
break
}
}
// MARK: - Scene Building
private func buildScene() {
guard sceneView != nil else { return }
// Remove old nodes
arcRootNode.childNodes.forEach { $0.removeFromParentNode() }
currentSunNode.childNodes.forEach { $0.removeFromParentNode() }
let arc = vm.arc
let radius: Float = 1.8
var positions: [SCNVector3] = []
// Hourly marker spheres + time labels
for (date, pos) in arc {
guard pos.elevation > -5 else { continue }
let worldPos = vm.worldPosition(for: pos, radius: radius)
positions.append(worldPos)
let sphere = SCNSphere(radius: 0.018)
sphere.firstMaterial?.diffuse.contents = UIColor.systemYellow.withAlphaComponent(0.85)
sphere.firstMaterial?.lightingModel = .constant
let markerNode = SCNNode(geometry: sphere)
markerNode.position = worldPos
arcRootNode.addChildNode(markerNode)
// Time label (only on even hours to avoid clutter)
let calendar = Calendar.current
let hour = calendar.component(.hour, from: date)
if hour % 2 == 0 {
let labelNode = makeTextNode(text: hourLabel(from: date), color: .white, fontSize: 0.04)
labelNode.position = SCNVector3(worldPos.x, worldPos.y + 0.06, worldPos.z)
arcRootNode.addChildNode(labelNode)
}
}
// Continuous arc line
if positions.count >= 2 {
let lineNode = makeLineNode(through: positions, color: UIColor.systemYellow.withAlphaComponent(0.55))
arcRootNode.addChildNode(lineNode)
}
// Sunrise marker
if let riseDate = vm.riseSet.rise {
let risePos = SunMath.calculateSunPosition(date: riseDate, coordinate: vm.coordinate!)
let wPos = vm.worldPosition(for: risePos, radius: radius)
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemOrange, label: "Sunrise \(hourLabel(from: riseDate))"))
}
// Sunset marker
if let setDate = vm.riseSet.set {
let setPos = SunMath.calculateSunPosition(date: setDate, coordinate: vm.coordinate!)
let wPos = vm.worldPosition(for: setPos, radius: radius)
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemRed, label: "Sunset \(hourLabel(from: setDate))"))
}
// Current sun orb (large, animated glow)
if let cur = vm.currentPosition {
let orb = SCNSphere(radius: 0.055)
orb.firstMaterial?.diffuse.contents = UIColor.systemOrange
orb.firstMaterial?.emission.contents = UIColor.systemYellow
orb.firstMaterial?.lightingModel = .constant
let orbNode = SCNNode(geometry: orb)
orbNode.position = vm.worldPosition(for: cur, radius: radius)
// Pulse animation
let pulse = CABasicAnimation(keyPath: "scale")
pulse.fromValue = SCNVector3(1, 1, 1)
pulse.toValue = SCNVector3(1.3, 1.3, 1.3)
pulse.duration = 1.2
pulse.autoreverses = true
pulse.repeatCount = .infinity
orbNode.addAnimation(pulse, forKey: "pulse")
let label = makeTextNode(text: "Now", color: .systemYellow, fontSize: 0.05)
label.position = SCNVector3(0, 0.09, 0)
orbNode.addChildNode(label)
currentSunNode.addChildNode(orbNode)
}
isSceneBuilt = true
sunNodesReady = true
}
// MARK: - Node Factories
private func makeSpecialMarker(at pos: SCNVector3, color: UIColor, label: String) -> SCNNode {
let root = SCNNode()
let sphere = SCNSphere(radius: 0.035)
sphere.firstMaterial?.diffuse.contents = color
sphere.firstMaterial?.emission.contents = color.withAlphaComponent(0.5)
sphere.firstMaterial?.lightingModel = .constant
let markerNode = SCNNode(geometry: sphere)
markerNode.position = pos
root.addChildNode(markerNode)
let labelNode = makeTextNode(text: label, color: .white, fontSize: 0.04)
labelNode.position = SCNVector3(pos.x, pos.y + 0.07, pos.z)
root.addChildNode(labelNode)
return root
}
/// Creates a billboard SCNText node that always faces the camera.
private func makeTextNode(text: String, color: UIColor, fontSize: CGFloat) -> SCNNode {
let scnText = SCNText(string: text, extrusionDepth: 0)
scnText.font = UIFont.systemFont(ofSize: fontSize * 100, weight: .medium)
scnText.firstMaterial?.diffuse.contents = color
scnText.firstMaterial?.lightingModel = .constant
scnText.isWrapped = false
let textNode = SCNNode(geometry: scnText)
textNode.scale = SCNVector3(fontSize / 100, fontSize / 100, fontSize / 100)
// Billboard constraint always face camera
let constraint = SCNBillboardConstraint()
constraint.freeAxes = .Y
textNode.constraints = [constraint]
// Centre text
let (min, max) = textNode.boundingBox
textNode.pivot = SCNMatrix4MakeTranslation((max.x - min.x) / 2, 0, 0)
return textNode
}
/// Builds a line strip SCNNode connecting all positions.
private func makeLineNode(through positions: [SCNVector3], color: UIColor) -> SCNNode {
guard positions.count >= 2 else { return SCNNode() }
let vertices: [SCNVector3] = positions
var indices: [Int32] = []
for i in 0..<(vertices.count - 1) {
indices.append(Int32(i))
indices.append(Int32(i + 1))
}
let vertexSource = SCNGeometrySource(vertices: vertices)
let element = SCNGeometryElement(
indices: indices,
primitiveType: .line
)
let geometry = SCNGeometry(sources: [vertexSource], elements: [element])
geometry.firstMaterial?.diffuse.contents = color
geometry.firstMaterial?.lightingModel = .constant
return SCNNode(geometry: geometry)
}
private func hourLabel(from date: Date) -> String {
let fmt = DateFormatter()
fmt.dateFormat = "ha"
fmt.amSymbol = "am"
fmt.pmSymbol = "pm"
return fmt.string(from: date)
}
// MARK: - CoreMotion Fallback
private func activateCoreMotionFallback() {
// In fallback mode we rely on CMMotionManager attitude (already running in SunseekerViewModel)
// and just keep the scene nodes updated via the 1s tick in the VM.
print("[Sunseeker] Switched to CoreMotion fallback — ARKit tracking unavailable.")
}
}
}
// MARK: - Degree helpers
private extension Double {
var radians: Double { self * .pi / 180.0 }
}

View File

@@ -0,0 +1,35 @@
import Foundation
enum InventoryModeAvailability {
static let dollhouseAssetCandidates: [(name: String, ext: String)] = [
("Building", "usdz"),
("Building", "scn"),
]
static func hasShippedDollhouseAsset(in bundle: Bundle = .main) -> Bool {
dollhouseAssetCandidates.contains { candidate in
bundle.url(forResource: candidate.name, withExtension: candidate.ext) != nil
}
}
static func productionVisibleModes(hasDollhouseAsset: Bool) -> [InventoryStore.Mode] {
var modes: [InventoryStore.Mode] = [.sunseeker, .dreamWeaver]
if hasDollhouseAsset {
modes.append(.dollhouse)
}
return modes
}
static func sanitizedProductionSelection(
_ candidate: InventoryStore.Mode,
hasDollhouseAsset: Bool
) -> InventoryStore.Mode {
productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset).contains(candidate) ? candidate : .sunseeker
}
static func modeSummaryText(hasDollhouseAsset: Bool) -> String {
productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset)
.map(\.rawValue)
.joined(separator: " · ")
}
}

View File

@@ -0,0 +1,852 @@
import AVFoundation
import Observation
import SceneKit
import SwiftUI
import UIKit
@Observable
final class InventoryStore {
enum Mode: String, CaseIterable, Identifiable {
case sunseeker = "Sunseeker"
case dreamWeaver = "Dream Weaver"
case dollhouse = "Dollhouse"
var id: String { rawValue }
}
var mode: Mode = .sunseeker
var sourceImage: UIImage?
var generatedImage: UIImage?
var isProcessing: Bool = false
var sunNodesReady: Bool = false
var dollhouseHour: Double = 12
// Error message shown in the DreamWeaver panel
var errorMessage: String?
}
struct InventoryView: View {
@State private var store = InventoryStore()
@State private var showCamera = false
@State private var sliderTickHour = 12
@State private var showShareSheet = false
@State private var shareImage: UIImage? = nil
private let hasDollhouseAsset = InventoryModeAvailability.hasShippedDollhouseAsset()
private let haptics = UIImpactFeedbackGenerator(style: .light)
private var visibleModes: [InventoryStore.Mode] {
InventoryModeAvailability.productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset)
}
private var selectedMode: InventoryStore.Mode {
InventoryModeAvailability.sanitizedProductionSelection(store.mode, hasDollhouseAsset: hasDollhouseAsset)
}
private var modeSelection: Binding<InventoryStore.Mode> {
Binding(
get: { selectedMode },
set: { newValue in
store.mode = InventoryModeAvailability.sanitizedProductionSelection(
newValue,
hasDollhouseAsset: hasDollhouseAsset
)
}
)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
// Page header share button sits on the same baseline as the title
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("Inventory")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text(InventoryModeAvailability.modeSummaryText(hasDollhouseAsset: hasDollhouseAsset))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
if let img = store.generatedImage {
Button {
shareImage = img
showShareSheet = true
} label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(VelocityTheme.foreground)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.transition(.opacity.combined(with: .scale))
}
}
.animation(.easeInOut(duration: 0.2), value: store.generatedImage != nil)
.padding(.horizontal, 20)
.padding(.top, 20)
Picker("Mode", selection: modeSelection) {
ForEach(visibleModes) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 20)
.padding(.top, 12)
if !hasDollhouseAsset {
ProductionScopeCard(
icon: "cube.transparent",
title: "Dollhouse hidden in this production build",
message: "Dollhouse stays out of the production iPad scope until a verified Building.usdz or Building.scn asset is shipped in the app bundle."
)
.padding(.horizontal, 20)
}
Group {
switch selectedMode {
case .sunseeker:
#if targetEnvironment(simulator)
SimulatorUnavailableCard(
icon: "arkit",
title: "Sunseeker requires a real device",
message: "The production build no longer renders a simulated AR sun path with fake location or heading data. Use a physical iPad to inspect the live camera-based overlay."
)
#else
SunseekerPanel(sunNodesReady: $store.sunNodesReady)
#endif
case .dreamWeaver:
// No simulator guard here CameraPicker automatically falls back
// to the photo library when no camera is available (e.g. Simulator),
// so the full Capture Reimagine API flow is testable without a device.
DreamWeaverPanel(
sourceImage: $store.sourceImage,
generatedImage: $store.generatedImage,
isProcessing: $store.isProcessing,
errorMessage: $store.errorMessage,
showCamera: $showCamera
)
case .dollhouse:
DollhousePanel(hour: $store.dollhouseHour, tickHour: $sliderTickHour, haptics: haptics)
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
.animation(.easeInOut(duration: 0.25), value: store.mode)
}
.background(VelocityTheme.background)
.simultaneousGesture(
TapGesture().onEnded {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil
)
}
)
.onAppear {
store.mode = selectedMode
UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(
red: 0.231, green: 0.510, blue: 0.965, alpha: 0.85)
UISegmentedControl.appearance().setTitleTextAttributes(
[.foregroundColor: UIColor.white], for: .selected)
UISegmentedControl.appearance().setTitleTextAttributes(
[.foregroundColor: UIColor(white: 0.62, alpha: 1)], for: .normal)
UISegmentedControl.appearance().backgroundColor = UIColor(
red: 0.031, green: 0.039, blue: 0.071, alpha: 1)
}
.sheet(isPresented: $showCamera) {
CameraPicker(isPresented: $showCamera) { captured in
// Normalise orientation immediately on capture
store.sourceImage = captured.fixedOrientation()
// Clear previous result and error when a new photo is taken
store.generatedImage = nil
store.errorMessage = nil
}
}
.sheet(item: $shareImage) { img in
ShareSheet(image: img)
.ignoresSafeArea()
}
}
}
// MARK: - Shared simulator placeholder
private struct SimulatorUnavailableCard: View {
let icon: String
let title: String
let message: String
var body: some View {
ZStack {
VStack(spacing: 14) {
Image(systemName: icon)
.font(.system(size: 40))
.foregroundStyle(VelocityTheme.mutedFg)
Text(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(message)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.multilineTextAlignment(.center)
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
)
)
}
}
private struct ProductionScopeCard: View {
let icon: String
let title: String
let message: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(message)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
)
)
}
}
// MARK: - Sunseeker
private struct SunseekerPanel: View {
@Binding var sunNodesReady: Bool
@State private var vm = SunseekerViewModel()
var body: some View {
ZStack(alignment: .topLeading) {
ARSunOverlayView(sunNodesReady: $sunNodesReady, vm: vm)
.clipShape(RoundedRectangle(cornerRadius: 20))
// Retained as a stylistic design element framing the AR view
DashedSunLine()
.stroke(Color.yellow.opacity(0.9), style: StrokeStyle(lineWidth: 3, dash: [10, 8]))
.padding(.horizontal, 24)
.padding(.vertical, 80)
VStack(alignment: .leading, spacing: 12) {
// Info block
VStack(alignment: .leading, spacing: 8) {
Text("Sunseeker")
.font(.headline)
Text("Point the iPad toward windows to inspect yearly sun-entry path.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(14)
.background {
GlassBlurView(style: .systemThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
if !vm.isReady && vm.locationError == nil {
// Loading state
HStack(spacing: 8) {
ProgressView().tint(.white)
Text("Looking for the Sun...")
.font(.footnote)
.foregroundStyle(.white)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color.black.opacity(0.6).clipShape(Capsule()))
}
// Error banner (e.g. Location Denied)
if let error = vm.locationError {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
Text(error)
.font(.subheadline)
.foregroundStyle(.white)
Spacer()
Button("Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.buttonStyle(.borderedProminent)
.tint(.white.opacity(0.2))
}
.padding(14)
.background {
RoundedRectangle(cornerRadius: 14)
.fill(Color.red.opacity(0.8))
}
}
}
.padding(20)
}
}
}
// MARK: - Dream Weaver
/// Available room types from integration guide §2
private struct RoomType: Identifiable {
let id: String // sent as the `room_type` form field
let displayName: String
let icon: String // SF Symbol
}
private let roomTypes: [RoomType] = [
RoomType(id: "bedroom", displayName: "Bedroom", icon: "bed.double"),
RoomType(id: "living_room", displayName: "Living Rm", icon: "sofa"),
RoomType(id: "bathroom", displayName: "Bathroom", icon: "drop"),
RoomType(id: "kitchen", displayName: "Kitchen", icon: "refrigerator"),
RoomType(id: "dining_room", displayName: "Dining Rm", icon: "fork.knife"),
RoomType(id: "home_office", displayName: "Office", icon: "desktopcomputer"),
RoomType(id: "hallway", displayName: "Hallway", icon: "door.left.hand.open"),
RoomType(id: "balcony", displayName: "Balcony", icon: "sun.max"),
]
private struct DreamWeaverPanel: View {
@Binding var sourceImage: UIImage?
@Binding var generatedImage: UIImage?
@Binding var isProcessing: Bool
@Binding var errorMessage: String?
@Binding var showCamera: Bool
/// Selected room type ID sent as `room_type` field. nil = none chosen yet.
@State private var selectedRoomType: String? = nil
/// Optional extra keywords sent as `keywords` field (§3.2)
@State private var keywords: String = ""
/// Server health: nil = checking, true = online, false = offline
@State private var serverOnline: Bool? = nil
var body: some View {
VStack(spacing: 14) {
// Preview card
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(Color.black.opacity(0.9))
if let sourceImage {
Image(uiImage: sourceImage)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(12)
} else {
ContentUnavailableView(
"No Capture",
systemImage: "camera.viewfinder",
description: Text("Tap Capture to snap a room.")
)
.foregroundStyle(.white)
}
if let generatedImage {
Image(uiImage: generatedImage)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(12)
.transition(.opacity)
}
if isProcessing { ProcessingOverlay() }
// Server health badge top-right corner
VStack {
HStack {
Spacer()
HStack(spacing: 5) {
Circle()
.fill(serverOnline == true ? Color.green : serverOnline == false ? Color.red : Color.gray)
.frame(width: 7, height: 7)
Text(serverOnline == true ? "Server Online" : serverOnline == false ? "Server Offline" : "Checking...")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.black.opacity(0.45))
.clipShape(Capsule())
.padding(14)
}
Spacer()
}
}
.frame(maxWidth: .infinity, minHeight: 420)
.animation(.easeInOut(duration: 0.35), value: generatedImage)
// Error banner
if let errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
Text(errorMessage)
.font(.footnote)
.foregroundStyle(VelocityTheme.foreground)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.red.opacity(0.15))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.red.opacity(0.35), lineWidth: 1)
)
)
.transition(.move(edge: .top).combined(with: .opacity))
}
// Room Type picker
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(roomTypes) { room in
Button {
withAnimation(.spring(response: 0.3)) {
// Tap again to deselect
selectedRoomType = selectedRoomType == room.id ? nil : room.id
}
} label: {
HStack(spacing: 6) {
Image(systemName: room.icon)
.font(.system(size: 11, weight: .medium))
Text(room.displayName)
.font(.system(size: 13, weight: .medium))
}
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(
Capsule()
.fill(selectedRoomType == room.id
? Color(red: 0.231, green: 0.510, blue: 0.965)
: Color.white.opacity(0.08))
)
.foregroundStyle(selectedRoomType == room.id ? .white : .white.opacity(0.6))
.overlay(
Capsule()
.stroke(selectedRoomType == room.id
? Color.clear
: Color.white.opacity(0.12), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 2)
}
// Keywords input
PromptInputBar(
text: $keywords,
isDisabled: sourceImage == nil || isProcessing || serverOnline == false
) {
Task { await generate() }
}
// Capture / Retake
Button(sourceImage == nil ? "Capture" : "Retake") {
showCamera = true
}
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
.disabled(isProcessing)
}
.padding(16)
.background {
GlassBlurView(style: .systemThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 18))
.contentShape(RoundedRectangle(cornerRadius: 18))
.onTapGesture {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil
)
}
}
.animation(.easeInOut(duration: 0.3), value: errorMessage)
.task { serverOnline = await ComfyClient.shared.checkHealth() }
}
@MainActor
private func generate() async {
guard let sourceImage, !isProcessing else { return }
if serverOnline == false {
errorMessage = "Server is currently offline. Please try again later."
return
}
isProcessing = true
errorMessage = nil
do {
let result = try await ComfyClient.shared.generateImage(
source: sourceImage,
roomType: selectedRoomType ?? roomTypes[0].id, // default: bedroom
keywords: keywords.trimmingCharacters(in: .whitespaces)
)
withAnimation(.easeInOut(duration: 0.4)) {
generatedImage = result
}
} catch {
errorMessage = error.localizedDescription
}
isProcessing = false
}
}
// MARK: - Prompt Input Bar
private struct PromptInputBar: View {
@Binding var text: String
let isDisabled: Bool
let onSubmit: () -> Void
@FocusState private var isFocused: Bool
@State private var shimmer = false
private let placeholder = "gold, marble, luxury, etc."
var body: some View {
HStack(spacing: 10) {
ZStack(alignment: .leading) {
if text.isEmpty {
Text(placeholder)
.font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.35))
.padding(.leading, 4)
.allowsHitTesting(false) // let taps pass through to the gesture below
}
TextField("", text: $text, axis: .vertical)
.font(.system(size: 14))
.foregroundStyle(Color.white)
.lineLimit(1...3)
.focused($isFocused)
.submitLabel(.send)
.onSubmit {
guard !isDisabled, !text.trimmingCharacters(in: .whitespaces).isEmpty else { return }
onSubmit()
}
.tint(Color(red: 0.231, green: 0.510, blue: 0.965))
}
.contentShape(Rectangle()) // expand hit area to full ZStack bounds
.onTapGesture { isFocused = true } // focus immediately on any tap
// Send arrow button
Button {
isFocused = false
onSubmit()
} label: {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [
Color(red: 0.231, green: 0.510, blue: 0.965),
Color(red: 0.40, green: 0.25, blue: 0.95)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 36, height: 36)
Image(systemName: "arrow.up")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
}
}
.disabled(isDisabled || text.trimmingCharacters(in: .whitespaces).isEmpty)
.opacity(isDisabled || text.trimmingCharacters(in: .whitespaces).isEmpty ? 0.4 : 1.0)
.animation(.easeInOut(duration: 0.2), value: text.isEmpty)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color.white.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(
isFocused
? Color(red: 0.231, green: 0.510, blue: 0.965).opacity(0.8)
: Color.white.opacity(0.12),
lineWidth: 1
)
)
)
.animation(.easeInOut(duration: 0.2), value: isFocused)
}
}
// MARK: - Dollhouse
private struct DollhousePanel: View {
@Binding var hour: Double
@Binding var tickHour: Int
let haptics: UIImpactFeedbackGenerator
var body: some View {
VStack(spacing: 12) {
SceneKitDollhouseView(hour: $hour)
.clipShape(RoundedRectangle(cornerRadius: 20))
.frame(maxWidth: .infinity, minHeight: 460)
VStack(alignment: .leading, spacing: 8) {
Text(String(format: "Time: %02d:00", Int(hour.rounded())))
.font(.headline)
Slider(value: $hour, in: 0...24, step: 0.25)
.onChange(of: hour) { _, newValue in
let rounded = Int(newValue.rounded())
if rounded != tickHour {
tickHour = rounded
haptics.impactOccurred(intensity: 0.7)
}
}
}
.padding(14)
.background {
GlassBlurView(style: .systemThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
}
}
// MARK: - SceneKit Dollhouse
private struct SceneKitDollhouseView: UIViewRepresentable {
@Binding var hour: Double
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> SCNView {
let view = SCNView()
view.scene = context.coordinator.scene
view.autoenablesDefaultLighting = false
view.allowsCameraControl = true
view.backgroundColor = UIColor.systemBackground
context.coordinator.setupScene()
context.coordinator.updateSunLight(hour: hour)
return view
}
func updateUIView(_ uiView: SCNView, context: Context) {
context.coordinator.updateSunLight(hour: hour)
}
final class Coordinator {
let scene = SCNScene()
private let sunNode = SCNNode()
func setupScene() {
let modelScene = InventoryModeAvailability.dollhouseAssetCandidates
.compactMap { candidate in
SCNScene(named: "\(candidate.name).\(candidate.ext)")
}
.first
if let modelScene {
let container = SCNNode()
for child in modelScene.rootNode.childNodes {
container.addChildNode(child.clone())
}
scene.rootNode.addChildNode(container)
} else {
let fallback = SCNFloor()
fallback.firstMaterial?.diffuse.contents = UIColor.secondarySystemBackground
scene.rootNode.addChildNode(SCNNode(geometry: fallback))
}
let camera = SCNCamera()
let cameraNode = SCNNode()
cameraNode.camera = camera
cameraNode.position = SCNVector3(0, 4, 10)
scene.rootNode.addChildNode(cameraNode)
let light = SCNLight()
light.type = .directional
light.intensity = 1_200
light.castsShadow = true
sunNode.light = light
scene.rootNode.addChildNode(sunNode)
let ambient = SCNLight()
ambient.type = .ambient
ambient.intensity = 200
let ambientNode = SCNNode()
ambientNode.light = ambient
scene.rootNode.addChildNode(ambientNode)
}
func updateSunLight(hour: Double) {
let normalized = (hour / 24.0) * (2 * Double.pi)
let x = Float(cos(normalized) * 8.0)
let y = Float(max(sin(normalized) * 8.0, 1.0))
let z = Float(sin(normalized + .pi / 3) * 6.0)
sunNode.position = SCNVector3(x, y, z)
sunNode.look(at: SCNVector3(0, 0, 0))
}
}
}
// MARK: - ProcessingOverlay
private struct ProcessingOverlay: View {
@State private var animate = false
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 16)
.fill(.black.opacity(0.45))
Text("AI Processing...")
.font(.headline.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background {
GlassBlurView(style: .systemUltraThinMaterialDark)
.clipShape(Capsule())
}
.overlay(
Rectangle()
.fill(
LinearGradient(
colors: [.clear, .white.opacity(0.6), .clear],
startPoint: .leading,
endPoint: .trailing
)
)
.rotationEffect(.degrees(18))
.offset(x: animate ? 160 : -160)
.animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: animate)
.blendMode(.screen)
.mask(Capsule().frame(height: 44))
)
}
.padding(12)
.onAppear { animate = true }
}
}
// MARK: - DashedSunLine
private struct DashedSunLine: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.maxY * 0.75))
path.addQuadCurve(
to: CGPoint(x: rect.maxX, y: rect.maxY * 0.25),
control: CGPoint(x: rect.midX, y: rect.minY + 30)
)
return path
}
}
// MARK: - CameraPicker
/// UIImagePickerController wrapper that delivers the captured image via a callback,
/// triggering orientation fix and clearing stale state immediately on capture.
private struct CameraPicker: UIViewControllerRepresentable {
@Binding var isPresented: Bool
let onCapture: (UIImage) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
#if targetEnvironment(simulator)
// Newer Simulators report camera as available but the shutter never
// delivers an image. Force photo library so testing actually works.
picker.sourceType = .photoLibrary
#else
if UIImagePickerController.isSourceTypeAvailable(.camera) {
picker.sourceType = .camera
picker.cameraCaptureMode = .photo
} else {
picker.sourceType = .photoLibrary
}
#endif
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
private let parent: CameraPicker
init(_ parent: CameraPicker) {
self.parent = parent
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.isPresented = false
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let captured = info[.originalImage] as? UIImage {
parent.onCapture(captured)
}
parent.isPresented = false
}
}
}
// MARK: - Share Sheet
/// Wraps UIActivityViewController to match the native iOS Photos share experience.
/// Natively includes: Save Image, AirDrop, Messages, Mail, Copy, and all installed share extensions.
private struct ShareSheet: UIViewControllerRepresentable {
let image: UIImage
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: [image], applicationActivities: nil)
}
func updateUIViewController(_ uvc: UIActivityViewController, context: Context) {}
}
// MARK: - UIImage + Identifiable
// Required to use UIImage as the `item` in .sheet(item:)
extension UIImage: @retroactive Identifiable {
public var id: ObjectIdentifier { ObjectIdentifier(self) }
}

View File

@@ -0,0 +1,254 @@
import CoreLocation
import SceneKit
import SwiftUI
#if targetEnvironment(simulator)
/// A non-AR 3D view for testing the Sunseeker path logic on the iOS Simulator.
/// Uses a synthetic camera, fake location, and mock heading instead of ARKit.
struct SimulatorSunOverlayView: UIViewRepresentable {
@Binding var sunNodesReady: Bool
// Fake location (e.g. San Francisco)
private let mockLocation = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
private let mockHeading: Double = 0 // North
func makeCoordinator() -> Coordinator {
Coordinator(sunNodesReady: $sunNodesReady, mockLocation: mockLocation, mockHeading: mockHeading)
}
func makeUIView(context: Context) -> SCNView {
let view = SCNView(frame: .zero)
view.scene = SCNScene()
view.allowsCameraControl = true // Swipe around the 3D space
view.autoenablesDefaultLighting = true
view.backgroundColor = UIColor(white: 0.1, alpha: 1.0)
view.isPlaying = true // Force render loop
view.showsStatistics = true // Prove it's rendering
// Setup synthetic camera
let cameraNode = SCNNode()
cameraNode.camera = SCNCamera()
cameraNode.camera?.zFar = 100
cameraNode.position = SCNVector3(x: 0, y: 0, z: 0) // Centered
view.scene?.rootNode.addChildNode(cameraNode)
context.coordinator.attach(to: view)
return view
}
func updateUIView(_ uiView: SCNView, context: Context) {}
final class Coordinator: NSObject {
@Binding private var sunNodesReady: Bool
private let mockLocation: CLLocationCoordinate2D
private let mockHeading: Double
private var arcRootNode = SCNNode()
private var currentSunNode = SCNNode()
private var updateTimer: Timer?
init(sunNodesReady: Binding<Bool>, mockLocation: CLLocationCoordinate2D, mockHeading: Double) {
_sunNodesReady = sunNodesReady
self.mockLocation = mockLocation
self.mockHeading = mockHeading
super.init()
}
func attach(to view: SCNView) {
view.scene?.rootNode.addChildNode(arcRootNode)
view.scene?.rootNode.addChildNode(currentSunNode)
buildScene()
startRealTimeTick()
}
deinit {
updateTimer?.invalidate()
}
private func startRealTimeTick() {
// Update current sun position every second
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
let cur = SunMath.calculateSunPosition(date: Date(), coordinate: self.mockLocation)
// Need to remove previous child as we are completely replacing it
self.currentSunNode.childNodes.forEach { $0.removeFromParentNode() }
let radius: Float = 1.8
let orb = SCNSphere(radius: 0.055)
orb.firstMaterial?.diffuse.contents = UIColor.systemOrange
orb.firstMaterial?.emission.contents = UIColor.systemYellow
orb.firstMaterial?.lightingModel = .constant
let orbNode = SCNNode(geometry: orb)
orbNode.position = self.worldPosition(for: cur, radius: radius)
let pulse = CABasicAnimation(keyPath: "scale")
pulse.fromValue = SCNVector3(1, 1, 1)
pulse.toValue = SCNVector3(1.3, 1.3, 1.3)
pulse.duration = 1.2
pulse.autoreverses = true
pulse.repeatCount = .infinity
orbNode.addAnimation(pulse, forKey: "pulse")
let label = self.makeTextNode(text: "Now", color: .systemYellow, fontSize: 0.05)
label.position = SCNVector3(0, 0.09, 0)
orbNode.addChildNode(label)
self.currentSunNode.addChildNode(orbNode)
}
}
private func buildScene() {
let arc = SunMath.sunPathArc(for: Date(), coordinate: mockLocation)
let riseSet = SunMath.sunRiseSet(for: Date(), coordinate: mockLocation)
let radius: Float = 1.8
var positions: [SCNVector3] = []
// Hourly blocks
for (date, pos) in arc {
guard pos.elevation > -5 else { continue }
let worldPos = worldPosition(for: pos, radius: radius)
positions.append(worldPos)
let sphere = SCNSphere(radius: 0.018)
sphere.firstMaterial?.diffuse.contents = UIColor.systemYellow.withAlphaComponent(0.85)
sphere.firstMaterial?.lightingModel = .constant
let markerNode = SCNNode(geometry: sphere)
markerNode.position = worldPos
arcRootNode.addChildNode(markerNode)
let calendar = Calendar.current
let hour = calendar.component(.hour, from: date)
if hour % 2 == 0 {
let labelNode = makeTextNode(text: hourLabel(from: date), color: .white, fontSize: 0.04)
labelNode.position = SCNVector3(worldPos.x, worldPos.y + 0.06, worldPos.z)
arcRootNode.addChildNode(labelNode)
}
}
if positions.count >= 2 {
let lineNode = makeLineNode(through: positions, color: UIColor.systemYellow.withAlphaComponent(0.55))
arcRootNode.addChildNode(lineNode)
}
if let riseDate = riseSet.rise {
let risePos = SunMath.calculateSunPosition(date: riseDate, coordinate: mockLocation)
let wPos = worldPosition(for: risePos, radius: radius)
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemOrange, label: "Sunrise \(hourLabel(from: riseDate))"))
}
if let setDate = riseSet.set {
let setPos = SunMath.calculateSunPosition(date: setDate, coordinate: mockLocation)
let wPos = worldPosition(for: setPos, radius: radius)
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemRed, label: "Sunset \(hourLabel(from: setDate))"))
}
// Generate current sun node synchronously for first frame
updateTimer?.fire()
DispatchQueue.main.async {
self.sunNodesReady = true
}
}
// MARK: Math equivalent from SunseekerViewModel
private func worldPosition(for sun: SunPosition, radius: Float) -> SCNVector3 {
let elev = Float(sun.elevation * .pi / 180.0)
let az = Float(sun.azimuth * .pi / 180.0)
let x = radius * cos(elev) * sin(az)
let y = radius * sin(elev)
let z = -radius * cos(elev) * cos(az)
return SCNVector3(x, y, z)
}
// MARK: SceneKit Factories
private func makeSpecialMarker(at pos: SCNVector3, color: UIColor, label: String) -> SCNNode {
let root = SCNNode()
let sphere = SCNSphere(radius: 0.035)
sphere.firstMaterial?.diffuse.contents = color
sphere.firstMaterial?.emission.contents = color.withAlphaComponent(0.5)
sphere.firstMaterial?.lightingModel = .constant
let markerNode = SCNNode(geometry: sphere)
markerNode.position = pos
root.addChildNode(markerNode)
let labelNode = makeTextNode(text: label, color: .white, fontSize: 0.04)
labelNode.position = SCNVector3(pos.x, pos.y + 0.07, pos.z)
root.addChildNode(labelNode)
return root
}
private func makeTextNode(text: String, color: UIColor, fontSize: CGFloat) -> SCNNode {
// SCNText is buggy in Simulator. Render text to a UIImage instead.
let font = UIFont.systemFont(ofSize: 40, weight: .bold)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: color
]
let size = (text as NSString).size(withAttributes: attributes)
// Add some padding
let paddedSize = CGSize(width: size.width + 10, height: size.height + 10)
let renderer = UIGraphicsImageRenderer(size: paddedSize)
let image = renderer.image { context in
(text as NSString).draw(
in: CGRect(x: 5, y: 5, width: size.width, height: size.height),
withAttributes: attributes
)
}
// Map the image onto an SCNPlane
// A 100x50 image becomes a 0.1 x 0.05 meter plane
let plane = SCNPlane(width: paddedSize.width / 1000.0, height: paddedSize.height / 1000.0)
plane.firstMaterial?.diffuse.contents = image
plane.firstMaterial?.isDoubleSided = true
plane.firstMaterial?.lightingModel = .constant
let textNode = SCNNode(geometry: plane)
// Statically scale the plane up so it is readable next to the spheres
textNode.scale = SCNVector3(1.5, 1.5, 1.5)
let constraint = SCNBillboardConstraint()
constraint.freeAxes = .all
textNode.constraints = [constraint]
return textNode
}
private func makeLineNode(through positions: [SCNVector3], color: UIColor) -> SCNNode {
guard positions.count >= 2 else { return SCNNode() }
let vertices: [SCNVector3] = positions
var indices: [Int32] = []
for i in 0..<(vertices.count - 1) {
indices.append(Int32(i))
indices.append(Int32(i + 1))
}
let vertexSource = SCNGeometrySource(vertices: vertices)
let element = SCNGeometryElement(
indices: indices,
primitiveType: .line
)
let geometry = SCNGeometry(sources: [vertexSource], elements: [element])
geometry.firstMaterial?.diffuse.contents = color
geometry.firstMaterial?.lightingModel = .constant
return SCNNode(geometry: geometry)
}
private func hourLabel(from date: Date) -> String {
let fmt = DateFormatter()
fmt.dateFormat = "ha"
fmt.amSymbol = "am"
fmt.pmSymbol = "pm"
return fmt.string(from: date)
}
}
}
#endif

View File

@@ -0,0 +1,140 @@
import CoreLocation
import CoreMotion
import Foundation
import Observation
import SceneKit
// MARK: - SunseekerViewModel
/// Owns all sensor state for the Sunseeker AR overlay.
/// Separates CoreLocation / CoreMotion concerns from the ARKit view layer.
@Observable
final class SunseekerViewModel: NSObject, CLLocationManagerDelegate {
// MARK: - Published State
/// True once we have both a GPS fix and a valid heading.
private(set) var isReady = false
/// Latest GPS coordinate. nil until first fix.
private(set) var coordinate: CLLocationCoordinate2D?
/// Latest true heading (0 = North, clockwise).
private(set) var heading: Double = 0
/// Dense hourly arc for today.
private(set) var arc: [(date: Date, position: SunPosition)] = []
/// Current real-time sun position (updated every second).
private(set) var currentPosition: SunPosition?
/// Sunrise and sunset for today.
private(set) var riseSet: (rise: Date?, set: Date?) = (nil, nil)
/// Diagnostic string for the UI when location access is denied.
private(set) var locationError: String?
// MARK: - Private
private let locationManager = CLLocationManager()
private let motionManager = CMMotionManager()
private var updateTimer: Timer?
// MARK: - Init / Deinit
override init() {
super.init()
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyBest
locationManager.headingFilter = 1.0
locationManager.requestWhenInUseAuthorization()
locationManager.startUpdatingLocation()
locationManager.startUpdatingHeading()
startMotionUpdates()
startRealTimeTick()
}
deinit {
stop()
}
// MARK: - Control
func stop() {
motionManager.stopDeviceMotionUpdates()
locationManager.stopUpdatingLocation()
locationManager.stopUpdatingHeading()
updateTimer?.invalidate()
updateTimer = nil
}
// MARK: - World-Space Transform
/// Converts a solar `SunPosition` into a SceneKit world-space position on a sphere of given `radius`.
/// Orientation is relative to ARWorldTrackingConfiguration(.gravityAndHeading), so north = -Z axis.
func worldPosition(for sun: SunPosition, radius: Float) -> SCNVector3 {
let elev = Float(sun.elevation.radians)
let az = Float(sun.azimuth.radians) // clockwise from north
let x = radius * cos(elev) * sin(az)
let y = radius * sin(elev)
let z = -radius * cos(elev) * cos(az) // -Z = north in ARKit gravity+heading
return SCNVector3(x, y, z)
}
// MARK: - Private helpers
private func startMotionUpdates() {
guard motionManager.isDeviceMotionAvailable else { return }
motionManager.deviceMotionUpdateInterval = 0.05
motionManager.startDeviceMotionUpdates()
}
private func startRealTimeTick() {
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self, let coord = self.coordinate else { return }
self.currentPosition = SunMath.calculateSunPosition(date: Date(), coordinate: coord)
}
}
private func refreshArc() {
guard let coord = coordinate else { return }
arc = SunMath.sunPathArc(for: Date(), coordinate: coord)
riseSet = SunMath.sunRiseSet(for: Date(), coordinate: coord)
currentPosition = SunMath.calculateSunPosition(date: Date(), coordinate: coord)
isReady = true
}
// MARK: - CLLocationManagerDelegate
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard coordinate == nil, let loc = locations.last else { return }
coordinate = loc.coordinate
refreshArc()
}
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
heading = newHeading.trueHeading > 0 ? newHeading.trueHeading : newHeading.magneticHeading
if coordinate != nil { isReady = true }
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("[Sunseeker] Location error: \(error.localizedDescription)")
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
switch manager.authorizationStatus {
case .denied, .restricted:
locationError = "Location access needed to calculate the sun path. Please enable it in Settings."
case .notDetermined:
manager.requestWhenInUseAuthorization()
default:
locationError = nil
}
}
}
// MARK: - Degree helpers (internal to this file)
private extension Double {
var radians: Double { self * .pi / 180.0 }
}