feat: Ipad app production readiness, Colony orchestration, Social posting (#44)
#38 Ipad app production readiness, Colony orchestration, Social posting Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: #44
This commit was merged in pull request #44.
This commit is contained in:
@@ -3,6 +3,7 @@ import CoreLocation
|
||||
import CoreMotion
|
||||
import SceneKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - ARSunOverlayView
|
||||
|
||||
@@ -22,6 +23,13 @@ struct ARSunOverlayView: UIViewRepresentable {
|
||||
|
||||
let config = ARWorldTrackingConfiguration()
|
||||
config.worldAlignment = .gravityAndHeading // north = -Z axis
|
||||
config.planeDetection = [.horizontal, .vertical]
|
||||
if ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) {
|
||||
config.sceneReconstruction = .mesh
|
||||
}
|
||||
if ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) {
|
||||
config.frameSemantics.insert(.sceneDepth)
|
||||
}
|
||||
view.session.run(config)
|
||||
|
||||
context.coordinator.attach(to: view)
|
||||
@@ -46,7 +54,9 @@ struct ARSunOverlayView: UIViewRepresentable {
|
||||
// Scene node containers (replaced on each rebuild)
|
||||
private var arcRootNode = SCNNode()
|
||||
private var currentSunNode = SCNNode()
|
||||
private var measurementRootNode = SCNNode()
|
||||
private var isSceneBuilt = false
|
||||
private var pendingMeasurementPoint: SCNVector3?
|
||||
|
||||
// Fallback timer for CoreMotion-only mode
|
||||
private var fallbackTimer: Timer?
|
||||
@@ -61,6 +71,10 @@ struct ARSunOverlayView: UIViewRepresentable {
|
||||
self.sceneView = sceneView
|
||||
sceneView.scene.rootNode.addChildNode(arcRootNode)
|
||||
sceneView.scene.rootNode.addChildNode(currentSunNode)
|
||||
sceneView.scene.rootNode.addChildNode(measurementRootNode)
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleMeasurementTap(_:)))
|
||||
sceneView.addGestureRecognizer(tap)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
@@ -107,6 +121,59 @@ struct ARSunOverlayView: UIViewRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Measurement
|
||||
|
||||
@objc private func handleMeasurementTap(_ recognizer: UITapGestureRecognizer) {
|
||||
guard let sceneView else { return }
|
||||
let point = recognizer.location(in: sceneView)
|
||||
guard let query = sceneView.raycastQuery(
|
||||
from: point,
|
||||
allowing: .estimatedPlane,
|
||||
alignment: .any
|
||||
),
|
||||
let result = sceneView.session.raycast(query).first else { return }
|
||||
let transform = result.worldTransform
|
||||
let worldPoint = SCNVector3(transform.columns.3.x, transform.columns.3.y, transform.columns.3.z)
|
||||
addMeasurementPoint(worldPoint)
|
||||
}
|
||||
|
||||
private func addMeasurementPoint(_ point: SCNVector3) {
|
||||
measurementRootNode.addChildNode(makeMeasurementMarker(at: point))
|
||||
|
||||
if let start = pendingMeasurementPoint {
|
||||
let distance = start.distance(to: point)
|
||||
measurementRootNode.addChildNode(makeLineNode(through: [start, point], color: UIColor.white.withAlphaComponent(0.82)))
|
||||
|
||||
let midpoint = SCNVector3(
|
||||
(start.x + point.x) / 2,
|
||||
(start.y + point.y) / 2 + 0.045,
|
||||
(start.z + point.z) / 2
|
||||
)
|
||||
let label = makeTextNode(
|
||||
text: "\(String(format: "%.2f m", Double(distance))) \(String(format: "%.1f ft", Double(distance * 3.28084)))",
|
||||
color: .white,
|
||||
fontSize: 0.052
|
||||
)
|
||||
label.position = midpoint
|
||||
measurementRootNode.addChildNode(label)
|
||||
pendingMeasurementPoint = nil
|
||||
UIImpactFeedbackGenerator(style: .rigid).impactOccurred()
|
||||
} else {
|
||||
pendingMeasurementPoint = point
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMeasurementMarker(at position: SCNVector3) -> SCNNode {
|
||||
let sphere = SCNSphere(radius: 0.018)
|
||||
sphere.firstMaterial?.diffuse.contents = UIColor.white
|
||||
sphere.firstMaterial?.emission.contents = UIColor.systemBlue.withAlphaComponent(0.65)
|
||||
sphere.firstMaterial?.lightingModel = .constant
|
||||
let node = SCNNode(geometry: sphere)
|
||||
node.position = position
|
||||
return node
|
||||
}
|
||||
|
||||
// MARK: - Scene Building
|
||||
|
||||
private func buildScene() {
|
||||
@@ -274,3 +341,12 @@ struct ARSunOverlayView: UIViewRepresentable {
|
||||
private extension Double {
|
||||
var radians: Double { self * .pi / 180.0 }
|
||||
}
|
||||
|
||||
private extension SCNVector3 {
|
||||
func distance(to other: SCNVector3) -> Float {
|
||||
let dx = other.x - x
|
||||
let dy = other.y - y
|
||||
let dz = other.z - z
|
||||
return sqrtf(dx * dx + dy * dy + dz * dz)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,7 @@ enum InventoryModeAvailability {
|
||||
}
|
||||
|
||||
static func productionVisibleModes(hasDollhouseAsset: Bool) -> [InventoryStore.Mode] {
|
||||
var modes: [InventoryStore.Mode] = [.sunseeker, .dreamWeaver]
|
||||
if hasDollhouseAsset {
|
||||
modes.append(.dollhouse)
|
||||
}
|
||||
return modes
|
||||
[.sunseeker, .dreamWeaver]
|
||||
}
|
||||
|
||||
static func sanitizedProductionSelection(
|
||||
@@ -28,8 +24,9 @@ enum InventoryModeAvailability {
|
||||
}
|
||||
|
||||
static func modeSummaryText(hasDollhouseAsset: Bool) -> String {
|
||||
productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset)
|
||||
.map(\.rawValue)
|
||||
.joined(separator: " · ")
|
||||
let base = productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset).map(\.rawValue)
|
||||
return hasDollhouseAsset
|
||||
? (base + ["Map-to-Dollhouse"]).joined(separator: " · ")
|
||||
: base.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,252 +1,18 @@
|
||||
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 {
|
||||
struct SimulatorSunOverlayView: View {
|
||||
@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)
|
||||
var body: some View {
|
||||
ContentUnavailableView(
|
||||
"Sunseeker Unavailable",
|
||||
systemImage: "arkit",
|
||||
description: Text("Run on a physical iPad to use live location, heading, and ARKit camera data.")
|
||||
)
|
||||
.onAppear {
|
||||
sunNodesReady = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user