Skip to content
Merged
36 changes: 24 additions & 12 deletions IOSAccessAssessment.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

12 changes: 4 additions & 8 deletions IOSAccessAssessment/ARCamera/ARCameraManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,8 @@ final class ARCameraManager: NSObject, ObservableObject, ARSessionCameraProcessi
self.cameraOutputImageCallback = cameraOutputImageCallback
self.isConfigured = true

Task {
await MainActor.run {
self.capturedMeshSnapshotGenerator = CapturedMeshSnapshotGenerator()
}
Task { @MainActor in
self.capturedMeshSnapshotGenerator = CapturedMeshSnapshotGenerator()
}
}

Expand All @@ -268,10 +266,8 @@ final class ARCameraManager: NSObject, ObservableObject, ARSessionCameraProcessi
}

func setOrientation(_ orientation: UIInterfaceOrientation) {
Task {
await MainActor.run {
self.interfaceOrientation = orientation
}
Task { @MainActor in
self.interfaceOrientation = orientation
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,34 @@
//

import CoreLocation
import UIKit
import MapKit

struct BBox {
let minLat: Double
let maxLat: Double
let minLon: Double
let maxLon: Double

func toQueryString() -> String {
return "\(minLon.roundedTo7Digits()),\(minLat.roundedTo7Digits()),\(maxLon.roundedTo7Digits()),\(maxLat.roundedTo7Digits())"
}
}

class LocationHelpers {
static func boundingBoxAroundLocation(location: CLLocationCoordinate2D, radius: CLLocationDistance) -> BBox {
let region = MKCoordinateRegion(center: location, latitudinalMeters: radius, longitudinalMeters: radius)
let center = region.center
let span = region.span
let minLat = center.latitude - span.latitudeDelta
let maxLat = center.latitude + span.latitudeDelta
let minLon = center.longitude - span.longitudeDelta
let maxLon = center.longitude + span.longitudeDelta

return BBox(minLat: minLat, maxLat: maxLat, minLon: minLon, maxLon: maxLon)
}
}


enum LocationManagerError: Error, LocalizedError {
case locationUnavailable
Expand All @@ -24,49 +52,80 @@ enum LocationManagerError: Error, LocalizedError {
/**
A wrapper around CLLocationManager to manage location and heading updates in a more controlled and safe manner.
*/
class LocationManager {
let locationManager: CLLocationManager
@MainActor
class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
private let locationManager: CLLocationManager = CLLocationManager()

@Published var currentLocation: CLLocation?
@Published var currentHeading: CLHeading?

override init() {
super.init()
}

init() {
self.locationManager = CLLocationManager()
self.setupLocationManager()
func startLocationUpdates() {
setupLocationManager()
}

private func setupLocationManager() {
locationManager.delegate = self

locationManager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
locationManager.distanceFilter = kCLDistanceFilterNone

// TODO: Sync heading with the device orientation
locationManager.headingOrientation = .portrait
locationManager.headingFilter = kCLHeadingFilterNone

locationManager.pausesLocationUpdatesAutomatically = false // Prevent auto-pausing

locationManager.requestWhenInUseAuthorization()
locationManager.startUpdatingLocation()
locationManager.startUpdatingHeading()
}

func getLocation() throws -> CLLocation {
guard let location = locationManager.location,
location.horizontalAccuracy > 0, location.verticalAccuracy > 0 else {
throw LocationManagerError.locationUnavailable
/**
Updates the heading orientation of the location manager based on the current device orientation. This ensures that heading data is accurate and consistent with the user's perspective.
*/
public func updateOrientation(_ orientation: UIInterfaceOrientation) {
switch orientation {
case .portrait:
locationManager.headingOrientation = .portrait
case .portraitUpsideDown:
locationManager.headingOrientation = .portraitUpsideDown
/// Flipped because the heading is relative to the device's top, which is opposite in landscape orientations
case .landscapeLeft:
locationManager.headingOrientation = .landscapeRight
case .landscapeRight:
locationManager.headingOrientation = .landscapeLeft
default:
locationManager.headingOrientation = .portrait
}
return location
}

private func getHeading() throws -> CLHeading {
guard let heading = locationManager.heading,
heading.headingAccuracy > 0 else {
throw LocationManagerError.headingUnavailable
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let latestLocation = locations.last else { return }
guard let horizontalAccuracy = latestLocation.horizontalAccuracy as CLLocationAccuracy?,
let verticalAccuracy = latestLocation.verticalAccuracy as CLLocationAccuracy?,
horizontalAccuracy > 0, verticalAccuracy > 0 else {
return
}
Task { @MainActor in
self.currentLocation = latestLocation
}
return heading
}

func getLocationCoordinate() throws -> CLLocationCoordinate2D {
let location = try getLocation()
return location.coordinate
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
guard let headingAccuracy = newHeading.headingAccuracy as CLLocationDirection?,
headingAccuracy > 0 else {
return
}
Task { @MainActor in
self.currentHeading = newHeading
}
}

func getHeadingDegrees() throws -> CLLocationDirection {
let heading = try getHeading()
return heading.trueHeading
func stopLocationUpdates() {
locationManager.stopUpdatingLocation()
}
}
4 changes: 4 additions & 0 deletions IOSAccessAssessment/Shared/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ struct Constants {
// ["1463"]
// ["288", "349", "1411"]
// "252", "322", "368", "374", "378", "381", "384", "323", "369", "156", "375", "379"]

static let fetchRadiusInMeters: Double = 100.0
static let fetchUpdateRadiusThresholdInMeters: Double = 50.0
static let updateElementDistanceThresholdInMeters: Double = 20.0
}

struct OtherConstants {
Expand Down
116 changes: 116 additions & 0 deletions IOSAccessAssessment/Shared/Definitions/CurrentMappingData.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//
// CurrentMappingData.swift
// IOSAccessAssessment
//
// Created by Himanshu on 11/9/25.
//

import Foundation
import CoreLocation

enum CurrentMappingDataError: Error, LocalizedError {
}

class CurrentMappingData: CustomStringConvertible {

var featuresMap: [AccessibilityFeatureClass: [any OSWElement]] = [:]
// var otherFeatures: [OSWElement] = []

init() {

}

var description: String {
var description = "CurrentMappingData:\n"
for (featureClass, features) in featuresMap {
description += "- \(featureClass.name): \(features.count) features\n"
}
return description
}

init(osmMapDataResponse: OSMMapDataResponse, accessibilityFeatureClasses: [AccessibilityFeatureClass]) {
self.featuresMap = getFeatures(with: osmMapDataResponse, accessibilityFeatureClasses: accessibilityFeatureClasses)
print("Initialized features map with OSM data. \n\(description)")
}

/// Note: Replaces the feature map instead of incrementally updating it.
func update(osmMapDataResponse: OSMMapDataResponse, accessibilityFeatureClasses: [AccessibilityFeatureClass]) {
self.featuresMap = getFeatures(with: osmMapDataResponse, accessibilityFeatureClasses: accessibilityFeatureClasses)
print("Updated features map with new OSM data. \n\(description)")
}

func getFeatures(
with osmMapDataResponse: OSMMapDataResponse, accessibilityFeatureClasses: [AccessibilityFeatureClass]
) -> [AccessibilityFeatureClass: [any OSWElement]] {
var featuresMap: [AccessibilityFeatureClass: [any OSWElement]] = [:]
let osmMapDataResponseElements: [OSMMapDataResponseElement] = osmMapDataResponse.elements
let osmElements: [any OSMElement] = osmMapDataResponseElements.compactMap { element in
return element.toOSMElement()
}
var featureNodes: [String: OSMNode] = [:]
var featureWays: [String: OSMWay] = [:]
var featureRelations: [String: OSMRelation] = [:]
osmElements.forEach { osmElement in
if let osmNode = osmElement as? OSMNode {
featureNodes[osmElement.id] = osmNode
} else if let osmWay = osmElement as? OSMWay {
featureWays[osmElement.id] = osmWay
} else if let osmRelation = osmElement as? OSMRelation {
featureRelations[osmElement.id] = osmRelation
}
}

for featureClass in accessibilityFeatureClasses {
if featuresMap[featureClass] == nil {
featuresMap[featureClass] = []
}
let oswElementClass = featureClass.oswPolicy.oswElementClass
let geometry = oswElementClass.geometry
let identifyingFieldTags: [String: String] = oswElementClass.identifyingFieldTags

switch geometry.osmElementType {
case .node:
let matchingOSWPoints: [OSWPoint] = featureNodes.values.filter { node in
return identifyingFieldTags.allSatisfy { tagKey, tagValue in
return node.tags[tagKey] == tagValue
}
}.compactMap { node in
return OSWPoint(osmNode: node, oswElementClass: oswElementClass)
}
featuresMap[featureClass]?.append(contentsOf: matchingOSWPoints)
case .way:
let matchingOSWLineStrings: [OSWLineString] = featureWays.values.filter { way in
return identifyingFieldTags.allSatisfy { tagKey, tagValue in
return way.tags[tagKey] == tagValue
}
}.compactMap { way in
return OSWLineString(
osmWay: way, oswElementClass: oswElementClass,
osmNodes: Array(featureNodes.values)
)
}
featuresMap[featureClass]?.append(contentsOf: matchingOSWLineStrings)
case .relation:
let matchingOSWPolygons: [OSWPolygon] = featureRelations.values.filter { relation in
return identifyingFieldTags.allSatisfy { tagKey, tagValue in
return relation.tags[tagKey] == tagValue
}
}.compactMap { relation in
return OSWPolygon(
osmRelation: relation, oswElementClass: oswElementClass,
osmMemberElements: osmElements
)
}
featuresMap[featureClass]?.append(contentsOf: matchingOSWPolygons)
}
}
return featuresMap
}

func getNearestFeature(
to location: CLLocationCoordinate2D, featureClass: AccessibilityFeatureClass,
distanceThreshold: CLLocationDistance = 50.0
) -> (any OSWElement)? {
return nil
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
//
// MapData.swift
// LiveMappingData.swift
// IOSAccessAssessment
//
// Created by Himanshu on 11/9/25.
//

import Foundation

enum MappingDataError: Error, LocalizedError {
enum LiveMappingDataError: Error, LocalizedError {
case accessibilityFeatureClassNotWay(AccessibilityFeatureClass)
case noActiveWayForFeatureClass(AccessibilityFeatureClass)
case accessibilityFeatureNodeNotPresent(AccessibilityFeatureClass)
Expand All @@ -24,7 +24,7 @@ enum MappingDataError: Error, LocalizedError {
}
}

class MappingData: CustomStringConvertible {
class LiveMappingData: CustomStringConvertible {
var featuresMap: [AccessibilityFeatureClass: [MappedAccessibilityFeature]] = [:]
var featureIdToIndexDictMap: [AccessibilityFeatureClass: [UUID: Int]] = [:]

Expand All @@ -51,7 +51,7 @@ class MappingData: CustomStringConvertible {
}

var description: String {
var desc = "MappingData:\n"
var desc = "LiveMappingData:\n"
desc += "Feature Nodes:\n"
featuresMap.forEach { (featureClass, featureData) in
return featureData.forEach { feature in
Expand Down
5 changes: 4 additions & 1 deletion IOSAccessAssessment/Shared/SharedAppData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ final class SharedAppData: ObservableObject {
var captureDataQueue: SafeDeque<CaptureImageData>
var captureDataCapacity: Int

var mappingData: MappingData = MappingData()
var currentMappingData: CurrentMappingData = CurrentMappingData()
var liveMappingData: LiveMappingData = LiveMappingData()

init(captureDataCapacity: Int = 5) {
self.captureDataCapacity = captureDataCapacity
Expand All @@ -37,6 +38,8 @@ final class SharedAppData: ObservableObject {
self.currentDatasetEncoder = nil
self.currentDatasetDecoder = nil
self.currentCaptureDataRecord = nil
self.currentMappingData = CurrentMappingData()
self.liveMappingData = LiveMappingData()
}

func saveCaptureData(_ data: CaptureData) {
Expand Down
14 changes: 14 additions & 0 deletions IOSAccessAssessment/Shared/Utils/Extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// Extensions.swift
// IOSAccessAssessment
//
// Created by Himanshu on 3/28/26.
//

import Foundation

extension Double {
func roundedTo7Digits() -> Double {
(self * 1_000_0000).rounded() / 1_000_0000
}
}
5 changes: 5 additions & 0 deletions IOSAccessAssessment/TDEI/Config/APIEndpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ struct APIEndpoint {
return baseURL?.appending(path: "workspaces/mine")
}

static let getMapData = { (environment: APIEnvironment) in
let baseURL = URL(string: environment.osmBaseURL)
return baseURL?.appending(path: "map.json")
}

static let createChangeset = { (environment: APIEnvironment) in
let baseURL = URL(string: environment.osmBaseURL)
return baseURL?.appending(path: "changeset/create")
Expand Down
2 changes: 1 addition & 1 deletion IOSAccessAssessment/TDEI/Config/APIEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Foundation
/**
TODO: Use this to replace the environment-agnostic APIConstants
*/
enum APIEnvironment: String, CaseIterable, Hashable {
enum APIEnvironment: String, CaseIterable, Sendable, Hashable {
// case development = "Development"
case staging = "Staging"
// case production = "Production"
Expand Down
Loading