From 7c9f4a7d51e79cddcc6ac29ca6377fc2d847148f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 13 Aug 2025 04:01:15 +0000 Subject: [PATCH 1/2] Add session persistence and restore functionality Co-authored-by: erdman --- RoomPlanSimple.xcodeproj/project.pbxproj | 8 + RoomPlanSimple/ARVisualizationManager.swift | 10 + RoomPlanSimple/FloorPlanRenderer.swift | 101 +++++++-- RoomPlanSimple/FloorPlanViewController.swift | 15 +- RoomPlanSimple/OnboardingViewController.swift | 101 ++++----- .../RoomCaptureViewController.swift | 117 ++++++++++- .../SessionListViewController.swift | 46 ++++ RoomPlanSimple/SessionManager.swift | 197 ++++++++++++++++++ RoomPlanSimple/WiFiSurveyManager.swift | 4 +- 9 files changed, 526 insertions(+), 73 deletions(-) create mode 100644 RoomPlanSimple/SessionListViewController.swift create mode 100644 RoomPlanSimple/SessionManager.swift diff --git a/RoomPlanSimple.xcodeproj/project.pbxproj b/RoomPlanSimple.xcodeproj/project.pbxproj index e5042ef..c8d2f9b 100644 --- a/RoomPlanSimple.xcodeproj/project.pbxproj +++ b/RoomPlanSimple.xcodeproj/project.pbxproj @@ -24,6 +24,8 @@ NETWORK001100220033044 /* NetworkDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = NETWORK001100220033055 /* NetworkDeviceManager.swift */; }; NETWORK001100220033045 /* NetworkDevice3DModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = NETWORK001100220033056 /* NetworkDevice3DModels.swift */; }; FLOORPLAN12345678901 /* FloorPlanRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FLOORPLAN12345678900 /* FloorPlanRenderer.swift */; }; + SESSIONSRC0001 /* SessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = SESSIONREF0001 /* SessionManager.swift */; }; + SESSIONLISTSRC0002 /* SessionListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = SESSIONREF0002 /* SessionListViewController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -49,6 +51,8 @@ NETWORK001100220033055 /* NetworkDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkDeviceManager.swift; sourceTree = ""; }; NETWORK001100220033056 /* NetworkDevice3DModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkDevice3DModels.swift; sourceTree = ""; }; FLOORPLAN12345678900 /* FloorPlanRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloorPlanRenderer.swift; sourceTree = ""; }; + SESSIONREF0001 /* SessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManager.swift; sourceTree = ""; }; + SESSIONREF0002 /* SessionListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionListViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -110,6 +114,8 @@ NETWORK001100220033055 /* NetworkDeviceManager.swift */, NETWORK001100220033056 /* NetworkDevice3DModels.swift */, FLOORPLAN12345678900 /* FloorPlanRenderer.swift */, + SESSIONREF0001 /* SessionManager.swift */, + SESSIONREF0002 /* SessionListViewController.swift */, ); path = RoomPlanSimple; sourceTree = ""; @@ -208,6 +214,8 @@ NETWORK001100220033044 /* NetworkDeviceManager.swift in Sources */, NETWORK001100220033045 /* NetworkDevice3DModels.swift in Sources */, FLOORPLAN12345678901 /* FloorPlanRenderer.swift in Sources */, + SESSIONSRC0001 /* SessionManager.swift in Sources */, + SESSIONLISTSRC0002 /* SessionListViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/RoomPlanSimple/ARVisualizationManager.swift b/RoomPlanSimple/ARVisualizationManager.swift index a37e787..0afaa91 100644 --- a/RoomPlanSimple/ARVisualizationManager.swift +++ b/RoomPlanSimple/ARVisualizationManager.swift @@ -893,6 +893,16 @@ class ARVisualizationManager: NSObject, ObservableObject { // Simplified inverse transformation - just return as-is for now return roomPosition } + + func relocalize(with worldMap: ARWorldMap) { + guard let sceneView = sceneView else { return } + let configuration = ARWorldTrackingConfiguration() + configuration.initialWorldMap = worldMap + configuration.sceneReconstruction = .mesh + configuration.environmentTexturing = .none + sceneView.session.run(configuration, options: [.resetTracking, .removeExistingAnchors]) + print("โœ… ARVisualizationManager relocalized with provided world map") + } } extension ARVisualizationManager: ARSCNViewDelegate { diff --git a/RoomPlanSimple/FloorPlanRenderer.swift b/RoomPlanSimple/FloorPlanRenderer.swift index 6c40697..31089c0 100644 --- a/RoomPlanSimple/FloorPlanRenderer.swift +++ b/RoomPlanSimple/FloorPlanRenderer.swift @@ -5,6 +5,7 @@ class FloorPlanRenderer: UIView { // MARK: - Properties private var rooms: [RoomAnalyzer.IdentifiedRoom] = [] + private var persistedRooms: [PersistedRoom] = [] private var heatmapData: WiFiHeatmapData? private var networkDevices: [NetworkDevice] = [] private var showHeatmap = false @@ -41,6 +42,15 @@ class FloorPlanRenderer: UIView { } return true } + // Clear any persisted set when we have runtime rooms + self.persistedRooms = [] + setNeedsDisplay() + } + + func updatePersistedRooms(_ rooms: [PersistedRoom]) { + self.persistedRooms = rooms + // Also clear runtime rooms to avoid ambiguity + self.rooms = [] setNeedsDisplay() } @@ -84,48 +94,83 @@ class FloorPlanRenderer: UIView { } private func drawRooms(in context: CGContext, rect: CGRect) { - guard !rooms.isEmpty else { - // Draw placeholder room if no rooms available + // Prefer runtime rooms if available; else use persisted + if rooms.isEmpty && persistedRooms.isEmpty { drawPlaceholderRoom(in: context, rect: rect) return } - // Calculate bounds for all rooms to fit them in the view - let allPoints = rooms.flatMap { $0.wallPoints } - guard !allPoints.isEmpty else { return } + if !rooms.isEmpty { + // Calculate bounds for all rooms to fit them in the view + let allPoints = rooms.flatMap { $0.wallPoints } + guard !allPoints.isEmpty else { return } + + let minX = allPoints.map { $0.x }.min() ?? 0 + let maxX = allPoints.map { $0.x }.max() ?? 0 + let minY = allPoints.map { $0.y }.min() ?? 0 + let maxY = allPoints.map { $0.y }.max() ?? 0 + + let roomWidth = maxX - minX + let roomHeight = maxY - minY + + // Prevent division by zero + guard roomWidth > 0 && roomHeight > 0 else { + drawPlaceholderRoom(in: context, rect: rect) + return + } + + // Calculate scale to fit room in view with padding + let padding: CGFloat = 20 + let availableWidth = rect.width - (padding * 2) + let availableHeight = rect.height - (padding * 2) + + let scaleX = availableWidth / CGFloat(roomWidth) + let scaleY = availableHeight / CGFloat(roomHeight) + let scale = min(scaleX, scaleY) + + // Calculate offset to center the room + let scaledRoomWidth = CGFloat(roomWidth) * scale + let scaledRoomHeight = CGFloat(roomHeight) * scale + let offsetX = (rect.width - scaledRoomWidth) / 2 - CGFloat(minX) * scale + let offsetY = (rect.height - scaledRoomHeight) / 2 - CGFloat(minY) * scale + + // Draw each room + for (index, room) in rooms.enumerated() { + drawRoom(room, in: context, scale: scale, offsetX: offsetX, offsetY: offsetY, roomIndex: index) + } + return + } + // Fallback: draw from persistedRooms + let allPoints = persistedRooms.flatMap { $0.wallPoints.map { $0.simd } } + guard !allPoints.isEmpty else { + drawPlaceholderRoom(in: context, rect: rect) + return + } let minX = allPoints.map { $0.x }.min() ?? 0 let maxX = allPoints.map { $0.x }.max() ?? 0 let minY = allPoints.map { $0.y }.min() ?? 0 let maxY = allPoints.map { $0.y }.max() ?? 0 - let roomWidth = maxX - minX let roomHeight = maxY - minY - - // Prevent division by zero guard roomWidth > 0 && roomHeight > 0 else { drawPlaceholderRoom(in: context, rect: rect) return } - - // Calculate scale to fit room in view with padding let padding: CGFloat = 20 let availableWidth = rect.width - (padding * 2) let availableHeight = rect.height - (padding * 2) - let scaleX = availableWidth / CGFloat(roomWidth) let scaleY = availableHeight / CGFloat(roomHeight) let scale = min(scaleX, scaleY) - - // Calculate offset to center the room let scaledRoomWidth = CGFloat(roomWidth) * scale let scaledRoomHeight = CGFloat(roomHeight) * scale let offsetX = (rect.width - scaledRoomWidth) / 2 - CGFloat(minX) * scale let offsetY = (rect.height - scaledRoomHeight) / 2 - CGFloat(minY) * scale - // Draw each room - for (index, room) in rooms.enumerated() { - drawRoom(room, in: context, scale: scale, offsetX: offsetX, offsetY: offsetY, roomIndex: index) + for (index, room) in persistedRooms.enumerated() { + let points = room.wallPoints.map { $0.simd } + drawRoom(points: points, label: room.type.rawValue, in: context, scale: scale, offsetX: offsetX, offsetY: offsetY, roomIndex: index) } } @@ -175,6 +220,30 @@ class FloorPlanRenderer: UIView { } } + private func drawRoom(points: [simd_float2], label: String, in context: CGContext, scale: CGFloat, offsetX: CGFloat, offsetY: CGFloat, roomIndex: Int) { + guard points.count >= 3 else { return } + let viewPoints = points.map { point in + CGPoint(x: CGFloat(point.x) * scale + offsetX, + y: CGFloat(point.y) * scale + offsetY) + } + let path = CGMutablePath() + if let first = viewPoints.first { + path.move(to: first) + for i in 1.. 2 { path.closeSubpath() } + } + context.setFillColor(UIColor.systemGray5.withAlphaComponent(0.3).cgColor) + context.addPath(path) + context.fillPath() + context.setStrokeColor(UIColor.systemBlue.cgColor) + context.setLineWidth(roomStrokeWidth) + context.addPath(path) + context.strokePath() + if let center = calculateRoomCenter(viewPoints) { + drawRoomLabel(label, at: center, in: context) + } + } + private func drawPlaceholderRoom(in context: CGContext, rect: CGRect) { // Draw a simple placeholder room let padding: CGFloat = 40 diff --git a/RoomPlanSimple/FloorPlanViewController.swift b/RoomPlanSimple/FloorPlanViewController.swift index 58a7f03..f52c78f 100644 --- a/RoomPlanSimple/FloorPlanViewController.swift +++ b/RoomPlanSimple/FloorPlanViewController.swift @@ -247,7 +247,9 @@ class FloorPlanViewController: UIViewController { DispatchQueue.main.async { // Update the renderer with the new data - self.floorPlanRenderer.updateRooms(roomAnalyzer.identifiedRooms) + if !roomAnalyzer.identifiedRooms.isEmpty { + self.floorPlanRenderer.updateRooms(roomAnalyzer.identifiedRooms) + } self.floorPlanRenderer.updateHeatmap(heatmapData) if let devices = self.networkDeviceManager?.getAllDevices() { // Convert NetworkDeviceManager.NetworkDevice to our NetworkDevice type @@ -262,6 +264,17 @@ class FloorPlanViewController: UIViewController { } } + func updateWithPersistedSession(_ saved: SavedSession) { + self.wifiHeatmapData = WiFiHeatmapData(measurements: SessionManager.shared.runtimeMeasurements(from: saved.measurements), coverageMap: [:], optimalRouterPlacements: []) + DispatchQueue.main.async { + self.floorPlanRenderer.updatePersistedRooms(saved.rooms) + self.floorPlanRenderer.updateHeatmap(self.wifiHeatmapData) + self.floorPlanRenderer.setShowHeatmap(self.heatmapToggle.isOn) + self.measurements = self.wifiHeatmapData?.measurements ?? [] + self.measurementsList.reloadData() + } + } + @objc private func toggleHeatmap() { floorPlanRenderer.setShowHeatmap(heatmapToggle.isOn) } diff --git a/RoomPlanSimple/OnboardingViewController.swift b/RoomPlanSimple/OnboardingViewController.swift index e6f62db..06f7bc3 100644 --- a/RoomPlanSimple/OnboardingViewController.swift +++ b/RoomPlanSimple/OnboardingViewController.swift @@ -31,62 +31,50 @@ class OnboardingViewController: UIViewController { } private func setupSpectrumBrandedView() { - // Create main logo container - let logoContainer = UIView() - logoContainer.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(logoContainer) - - // Spectrum logo/title section + let container = UIView() + container.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(container) + let titleLabel = SpectrumBranding.createSpectrumLabel(text: "Spectrum", style: .title) titleLabel.textAlignment = .center titleLabel.font = UIFont.systemFont(ofSize: 48, weight: .bold) - + let subtitleLabel = SpectrumBranding.createSpectrumLabel(text: "WiFi Analyzer", style: .headline) subtitleLabel.textAlignment = .center subtitleLabel.font = UIFont.systemFont(ofSize: 24, weight: .medium) - - let loadingLabel = SpectrumBranding.createSpectrumLabel(text: "Loading...", style: .body) - loadingLabel.textAlignment = .center - loadingLabel.alpha = 0.7 - - // Create activity indicator - let activityIndicator = UIActivityIndicatorView(style: .large) - activityIndicator.color = SpectrumBranding.Colors.spectrumBlue - activityIndicator.translatesAutoresizingMaskIntoConstraints = false - activityIndicator.startAnimating() - - // Stack view for centered content + + let newSessionButton = SpectrumBranding.createSpectrumButton(title: "Start New Session", style: .primary) + newSessionButton.addTarget(self, action: #selector(startNewSessionTapped), for: .touchUpInside) + newSessionButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true + + let loadButton = SpectrumBranding.createSpectrumButton(title: "Load Previous Session", style: .secondary) + loadButton.addTarget(self, action: #selector(loadPreviousTapped), for: .touchUpInside) + loadButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true + let stackView = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel, createSpacer(height: 40), - activityIndicator, - createSpacer(height: 16), - loadingLabel + newSessionButton, + loadButton ]) stackView.axis = .vertical stackView.spacing = 16 stackView.alignment = .center stackView.translatesAutoresizingMaskIntoConstraints = false - - logoContainer.addSubview(stackView) - + + container.addSubview(stackView) + NSLayoutConstraint.activate([ - // Logo container centered - logoContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor), - logoContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor), - logoContainer.leadingAnchor.constraint(greaterThanOrEqualTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 40), - logoContainer.trailingAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -40), - - // Stack view fills container - stackView.topAnchor.constraint(equalTo: logoContainer.topAnchor), - stackView.leadingAnchor.constraint(equalTo: logoContainer.leadingAnchor), - stackView.trailingAnchor.constraint(equalTo: logoContainer.trailingAnchor), - stackView.bottomAnchor.constraint(equalTo: logoContainer.bottomAnchor), - - // Activity indicator size - activityIndicator.widthAnchor.constraint(equalToConstant: 40), - activityIndicator.heightAnchor.constraint(equalToConstant: 40) + container.centerXAnchor.constraint(equalTo: view.centerXAnchor), + container.centerYAnchor.constraint(equalTo: view.centerYAnchor), + container.leadingAnchor.constraint(greaterThanOrEqualTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 40), + container.trailingAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -40), + + stackView.topAnchor.constraint(equalTo: container.topAnchor), + stackView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: container.bottomAnchor) ]) } @@ -111,24 +99,17 @@ class OnboardingViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - - // Skip splash screen entirely for better user experience - DispatchQueue.main.async { - self.transitionToRoomCapture() - } + // Do nothing; wait for user to select New or Load } private func transitionToRoomCapture() { - #if targetEnvironment(simulator) - print("๐ŸŽญ Simulator: Transitioning to RoomCaptureViewController with mock data") - #else + #if !targetEnvironment(simulator) guard RoomCaptureSession.isSupported else { showUnsupportedDeviceAlert() return } #endif - // Create RoomCaptureViewController directly without navigation controller if let roomCaptureVC = self.storyboard?.instantiateViewController( withIdentifier: "RoomCaptureViewController") as? RoomCaptureViewController { roomCaptureVC.modalPresentationStyle = .fullScreen @@ -171,4 +152,26 @@ class OnboardingViewController: UIViewController { present(viewController, animated: true) } } + + @objc private func startNewSessionTapped() { + transitionToRoomCapture() + } + + @objc private func loadPreviousTapped() { + let list = SessionListViewController() + let nav = UINavigationController(rootViewController: list) + list.onSelect = { [weak self] saved in + self?.presentSavedSession(saved) + } + nav.modalPresentationStyle = .formSheet + present(nav, animated: true) + } + + private func presentSavedSession(_ saved: SavedSession) { + guard let roomCaptureVC = self.storyboard?.instantiateViewController( + withIdentifier: "RoomCaptureViewController") as? RoomCaptureViewController else { return } + roomCaptureVC.modalPresentationStyle = .fullScreen + roomCaptureVC.applySavedSession(saved) + present(roomCaptureVC, animated: true) + } } diff --git a/RoomPlanSimple/RoomCaptureViewController.swift b/RoomPlanSimple/RoomCaptureViewController.swift index 153ee80..ee62b73 100644 --- a/RoomPlanSimple/RoomCaptureViewController.swift +++ b/RoomPlanSimple/RoomCaptureViewController.swift @@ -1,5 +1,5 @@ /* -See LICENSE folder for this sampleโ€™s licensing information. +See LICENSE folder for this sample's licensing information. Abstract: The sample app's main view controller that manages the scanning process. @@ -18,6 +18,10 @@ class RoomCaptureViewController: UIViewController, RoomCaptureViewDelegate, Room private var networkDeviceManager = NetworkDeviceManager() private var arSceneView: ARSCNView! + // Session restoration + private var pendingSavedSession: SavedSession? + private var pendingWorldMapData: Data? + // iOS 17+ Custom ARSession for perfect coordinate alignment private lazy var sharedARSession: ARSession = { let session = ARSession() @@ -100,6 +104,11 @@ class RoomCaptureViewController: UIViewController, RoomCaptureViewDelegate, Room setupWiFiSurvey() setupBottomNavigation() setupHapticFeedback() + + if let saved = pendingSavedSession { + restoreFromSavedSession(saved) + } + updateButtonStates() } @@ -608,13 +617,13 @@ class RoomCaptureViewController: UIViewController, RoomCaptureViewDelegate, Room wifiSurveyManager.startSurvey() // iOS 17+: Set shared session mode BEFORE switching to AR mode to preserve coordinates - if isIOS17Available { + if isIOS17Available && pendingSavedSession == nil { print("๐ŸŽฏ Enabling shared ARSession mode for perfect coordinate alignment") arVisualizationManager.setSharedARSessionMode(true) } else { - // iOS 16: Disable shared session mode + // iOS 16 or when restoring from world map: use separate session arVisualizationManager.setSharedARSessionMode(false) - print("โš ๏ธ Using separate ARSession (iOS 16)") + print("โš ๏ธ Using separate ARSession (iOS 16 or world-map restore)") } // Switch to AR mode for WiFi visualization (this will respect shared session mode) @@ -788,7 +797,7 @@ class RoomCaptureViewController: UIViewController, RoomCaptureViewDelegate, Room startCameraPreview() // Auto-start scanning immediately since we bypassed the instruction screen - if !isScanning && capturedRoomData == nil { + if !isScanning && capturedRoomData == nil && pendingSavedSession == nil { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { print("๐Ÿš€ Auto-starting room scan...") self.startSession() @@ -828,6 +837,9 @@ class RoomCaptureViewController: UIViewController, RoomCaptureViewDelegate, Room stopStatusUpdateTimer() stopScanningProgressHaptics() + // Auto-save snapshot on exit (non-blocking) + autoSaveSessionSnapshot() + // Clean up haptic generators to free memory cleanupHapticGenerators() } @@ -1094,12 +1106,24 @@ class RoomCaptureViewController: UIViewController, RoomCaptureViewDelegate, Room currentMode = .completed print("๐Ÿ“Š User requested results view - setting completed mode") + // Trigger background save of current session snapshot + autoSaveSessionSnapshot() + // In simulator mode, allow viewing results with mock data even without captured room data if isSimulatorMode { viewSimulatorResults() return } + if let saved = pendingSavedSession { + // Allow viewing floor plan directly from saved session + let floorPlanVC = FloorPlanViewController() + floorPlanVC.updateWithPersistedSession(saved) + floorPlanVC.modalPresentationStyle = .fullScreen + present(floorPlanVC, animated: true) + return + } + guard capturedRoomData != nil else { currentMode = .scanning // Reset if no data showAlert(title: "No Room Data", message: "Please complete room scanning first.") @@ -1615,6 +1639,89 @@ class RoomCaptureViewController: UIViewController, RoomCaptureViewDelegate, Room optimalRouterPlacements: optimalPlacements ) } + + func applySavedSession(_ saved: SavedSession) { + pendingSavedSession = saved + pendingWorldMapData = SessionManager.shared.worldMapData(for: saved.id) + } + + private func captureCurrentWorldMap(completion: @escaping (ARWorldMap?) -> Void) { + #if targetEnvironment(simulator) + completion(nil) + #else + sharedARSession.getCurrentWorldMap { worldMap, error in + if let error = error { print("โš ๏ธ WorldMap capture error: \(error.localizedDescription)") } + completion(worldMap) + } + #endif + } + + private func autoSaveSessionSnapshot() { + // Do not save if no data + let hasAnyData = !roomAnalyzer.identifiedRooms.isEmpty || !wifiSurveyManager.measurements.isEmpty + guard hasAnyData else { return } + + captureCurrentWorldMap { [weak self] worldMap in + guard let self = self else { return } + let name = "Session " + DateFormatter.reportDateFormatter.string(from: Date()) + do { + _ = try SessionManager.shared.saveSession( + name: name, + rooms: self.roomAnalyzer.identifiedRooms, + measurements: self.wifiSurveyManager.measurements, + worldMap: worldMap + ) + print("๐Ÿ’พ Auto-saved session: \(name)") + } catch { + print("โŒ Failed to auto-save session: \(error)") + } + } + } + + private func restoreFromSavedSession(_ saved: SavedSession) { + // Restore rooms into analyzer as lightweight polygons for floor plan use + let simpleRooms = SessionManager.shared.simpleRooms(from: saved.rooms) + let identified: [RoomAnalyzer.IdentifiedRoom] = simpleRooms.map { s in + // bounds is required by struct but not used by renderer anymore; create minimal placeholder + let placeholderSurface = CapturedRoom.Surface( + transform: matrix_identity_float4x4, + dimensions: simd_float3( max(1.0, sqrt(s.area)), 2.5, max(1.0, sqrt(s.area)) ), + confidence: .medium + ) + return RoomAnalyzer.IdentifiedRoom( + type: s.type, + bounds: placeholderSurface, + center: s.center, + area: s.area, + confidence: 0.5, + wallPoints: s.wallPoints, + doorways: [] + ) + } + roomAnalyzer.identifiedRooms = identified + + // Restore WiFi measurements + let restoredMeasurements = SessionManager.shared.runtimeMeasurements(from: saved.measurements) + wifiSurveyManager.measurements = restoredMeasurements + + // If we have a world map, attempt relocalization + if let data = pendingWorldMapData, + let worldMap = try? NSKeyedUnarchiver.unarchivedObject(ofClass: ARWorldMap.self, from: data) { + arVisualizationManager.relocalize(with: worldMap) + } + + // Show AR mode ready for continued surveying + currentMode = .surveying + isScanning = false + roomPlanPaused = true + switchToARMode() + updateButtonStates() + } + + private func relocalizeARSession(with data: Data) { + guard let worldMap = try? NSKeyedUnarchiver.unarchivedObject(ofClass: ARWorldMap.self, from: data) else { return } + arVisualizationManager.relocalize(with: worldMap) + } } // MARK: - RoomCaptureSessionDelegate diff --git a/RoomPlanSimple/SessionListViewController.swift b/RoomPlanSimple/SessionListViewController.swift new file mode 100644 index 0000000..67138c7 --- /dev/null +++ b/RoomPlanSimple/SessionListViewController.swift @@ -0,0 +1,46 @@ +import UIKit + +final class SessionListViewController: UITableViewController { + private var sessions: [SavedSessionIndexItem] = [] + var onSelect: ((SavedSession) -> Void)? + + override func viewDidLoad() { + super.viewDidLoad() + title = "Saved Sessions" + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .refresh, target: self, action: #selector(reload)) + reload() + } + + @objc private func reload() { + sessions = SessionManager.shared.listSessions() + tableView.reloadData() + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + sessions.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) + let s = sessions[indexPath.row] + var content = cell.defaultContentConfiguration() + content.text = s.name + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + content.secondaryText = formatter.string(from: s.updatedAt) + cell.contentConfiguration = content + cell.accessoryType = .disclosureIndicator + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let item = sessions[indexPath.row] + if let full = SessionManager.shared.loadSession(id: item.id) { + onSelect?(full) + } + dismiss(animated: true) + } +} \ No newline at end of file diff --git a/RoomPlanSimple/SessionManager.swift b/RoomPlanSimple/SessionManager.swift new file mode 100644 index 0000000..7529c7f --- /dev/null +++ b/RoomPlanSimple/SessionManager.swift @@ -0,0 +1,197 @@ +import Foundation +import ARKit + +// MARK: - Persisted Vector Types +struct PersistedVector3: Codable { + var x: Float + var y: Float + var z: Float + + init(_ v: simd_float3) { + self.x = v.x + self.y = v.y + self.z = v.z + } + + var simd: simd_float3 { simd_float3(x, y, z) } +} + +struct PersistedVector2: Codable { + var x: Float + var y: Float + + init(_ v: simd_float2) { + self.x = v.x + self.y = v.y + } + + var simd: simd_float2 { simd_float2(x, y) } +} + +// MARK: - Persisted Models +struct PersistedWiFiMeasurement: Codable { + var location: PersistedVector3 + var timestamp: Date + var signalStrength: Int + var networkName: String + var speed: Double + var frequency: String + var roomType: RoomType? +} + +struct PersistedRoom: Codable { + var type: RoomType + var wallPoints: [PersistedVector2] + var center: PersistedVector3 + var area: Float +} + +struct SavedSession: Codable { + var id: UUID + var name: String + var createdAt: Date + var updatedAt: Date + var rooms: [PersistedRoom] + var measurements: [PersistedWiFiMeasurement] +} + +struct SavedSessionIndexItem: Codable { + var id: UUID + var name: String + var createdAt: Date + var updatedAt: Date +} + +// MARK: - Session Manager +class SessionManager { + static let shared = SessionManager() + private init() {} + + private let sessionsDirectoryName = "Sessions" + + private var sessionsDirectoryURL: URL { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let dir = docs.appendingPathComponent(sessionsDirectoryName, isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func sessionJSONURL(for id: UUID) -> URL { + sessionsDirectoryURL.appendingPathComponent("\(id.uuidString).json") + } + + private func worldMapURL(for id: UUID) -> URL { + sessionsDirectoryURL.appendingPathComponent("\(id.uuidString).worldmap") + } + + // MARK: - Save + func saveSession(name: String, + rooms: [RoomAnalyzer.IdentifiedRoom], + measurements: [WiFiMeasurement], + worldMap: ARWorldMap?) throws -> SavedSession { + let now = Date() + let id = UUID() + let persistedRooms: [PersistedRoom] = rooms.map { room in + PersistedRoom( + type: room.type, + wallPoints: room.wallPoints.map { PersistedVector2($0) }, + center: PersistedVector3(room.center), + area: room.area + ) + } + let persistedMeasurements: [PersistedWiFiMeasurement] = measurements.map { m in + PersistedWiFiMeasurement( + location: PersistedVector3(m.location), + timestamp: m.timestamp, + signalStrength: m.signalStrength, + networkName: m.networkName, + speed: m.speed, + frequency: m.frequency, + roomType: m.roomType + ) + } + let session = SavedSession( + id: id, + name: name, + createdAt: now, + updatedAt: now, + rooms: persistedRooms, + measurements: persistedMeasurements + ) + try write(session) + if let map = worldMap { + try saveWorldMap(map, for: id) + } + return session + } + + func updateSession(_ session: SavedSession) throws { + try write(session) + } + + private func write(_ session: SavedSession) throws { + let url = sessionJSONURL(for: session.id) + let data = try JSONEncoder().encode(session) + try data.write(to: url, options: .atomic) + } + + // MARK: - Load + func listSessions() -> [SavedSessionIndexItem] { + let dir = sessionsDirectoryURL + guard let contents = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return [] } + let jsonFiles = contents.filter { $0.pathExtension.lowercased() == "json" } + var items: [SavedSessionIndexItem] = [] + for file in jsonFiles { + if let data = try? Data(contentsOf: file), + let session = try? JSONDecoder().decode(SavedSession.self, from: data) { + items.append(SavedSessionIndexItem(id: session.id, name: session.name, createdAt: session.createdAt, updatedAt: session.updatedAt)) + } + } + return items.sorted { $0.updatedAt > $1.updatedAt } + } + + func loadSession(id: UUID) -> SavedSession? { + let url = sessionJSONURL(for: id) + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(SavedSession.self, from: data) + } + + // MARK: - World Map + func saveWorldMap(_ map: ARWorldMap, for id: UUID) throws { + let data = try NSKeyedArchiver.archivedData(withRootObject: map, requiringSecureCoding: true) + try data.write(to: worldMapURL(for: id), options: .atomic) + } + + func worldMapData(for id: UUID) -> Data? { + let url = worldMapURL(for: id) + return try? Data(contentsOf: url) + } + + // MARK: - Conversion Helpers + func runtimeMeasurements(from persisted: [PersistedWiFiMeasurement]) -> [WiFiMeasurement] { + return persisted.map { p in + WiFiMeasurement( + location: p.location.simd, + timestamp: p.timestamp, + signalStrength: p.signalStrength, + networkName: p.networkName, + speed: p.speed, + frequency: p.frequency, + roomType: p.roomType + ) + } + } + + struct SimpleRoom { + let type: RoomType + let wallPoints: [simd_float2] + let center: simd_float3 + let area: Float + } + + func simpleRooms(from persisted: [PersistedRoom]) -> [SimpleRoom] { + persisted.map { p in + SimpleRoom(type: p.type, wallPoints: p.wallPoints.map { $0.simd }, center: p.center.simd, area: p.area) + } + } +} \ No newline at end of file diff --git a/RoomPlanSimple/WiFiSurveyManager.swift b/RoomPlanSimple/WiFiSurveyManager.swift index 407c0f8..4132606 100644 --- a/RoomPlanSimple/WiFiSurveyManager.swift +++ b/RoomPlanSimple/WiFiSurveyManager.swift @@ -5,7 +5,7 @@ import simd import SystemConfiguration.CaptiveNetwork import NetworkExtension -struct WiFiMeasurement { +struct WiFiMeasurement: Codable { let location: simd_float3 let timestamp: Date let signalStrength: Int @@ -21,7 +21,7 @@ struct WiFiHeatmapData { let optimalRouterPlacements: [simd_float3] } -enum RoomType: String, CaseIterable { +enum RoomType: String, CaseIterable, Codable { case kitchen = "Kitchen" case livingRoom = "Living Room" case bedroom = "Bedroom" From 01b39a557ad717dc678b4e6cbdd5696a18aaf403 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 13 Aug 2025 04:19:46 +0000 Subject: [PATCH 2/2] Remove Codable conformance from WiFiMeasurement struct Co-authored-by: erdman --- RoomPlanSimple/WiFiSurveyManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RoomPlanSimple/WiFiSurveyManager.swift b/RoomPlanSimple/WiFiSurveyManager.swift index 4132606..e847a98 100644 --- a/RoomPlanSimple/WiFiSurveyManager.swift +++ b/RoomPlanSimple/WiFiSurveyManager.swift @@ -5,7 +5,7 @@ import simd import SystemConfiguration.CaptiveNetwork import NetworkExtension -struct WiFiMeasurement: Codable { +struct WiFiMeasurement { let location: simd_float3 let timestamp: Date let signalStrength: Int