diff --git a/Package.resolved b/Package.resolved index 470b0036..cc571caa 100644 --- a/Package.resolved +++ b/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stadiamaps/maplibre-swiftui-dsl-playground", "state" : { - "revision" : "02f5a62009bc991a9dc59011785c83c347d7eea6", - "version" : "0.0.23" + "revision" : "a789bbee505a1344a87d9a5f999455ed55acdcde", + "version" : "0.0.28" } }, { @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Kolos65/Mockable.git", "state" : { - "revision" : "81ccaead99a3c038c09345caa2888ae74b644ee9", - "version" : "0.0.9" + "revision" : "da977ecb20974c4b1cf185f5fd38771b2d4674fb", + "version" : "0.0.10" } }, { diff --git a/Package.swift b/Package.swift index a2343cdb..6b71659b 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ if useLocalFramework { path: "./common/target/ios/libferrostar-rs.xcframework" ) } else { - let releaseTag = "0.9.1" + let releaseTag = "0.10.0" let releaseChecksum = "d06ba13bd12262b91ecec20a80c96649a8507a43b2d041086ca04c6bcee2ba2f" binaryTarget = .binaryTarget( name: "FerrostarCoreRS", diff --git a/android/build.gradle b/android/build.gradle index f0e01127..8b847e67 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -12,5 +12,5 @@ plugins { allprojects { group = "com.stadiamaps.ferrostar" - version = "0.9.2" + version = "0.10.0" } diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/mock/MockNavigationState.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/mock/MockNavigationState.kt index 3ca9e220..116612d8 100644 --- a/android/core/src/main/java/com/stadiamaps/ferrostar/core/mock/MockNavigationState.kt +++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/mock/MockNavigationState.kt @@ -33,6 +33,7 @@ fun NavigationState.Companion.pedestrianExample(): NavigationState { return NavigationState( tripState = TripState.Navigating( + currentStepGeometryIndex = 0u, snappedUserLocation = UserLocation.pedestrianExample(), remainingSteps = listOf(), remainingWaypoints = listOf(), diff --git a/apple/DemoApp/Ferrostar Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apple/DemoApp/Ferrostar Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1600eef9..b59e42cd 100644 --- a/apple/DemoApp/Ferrostar Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apple/DemoApp/Ferrostar Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git", "state" : { - "revision" : "cf66f087af489ebc091c03cbd4f38d0540135871", - "version" : "6.5.3" + "revision" : "abe762f1e19e03a4c6943d2aad92c219da384b29", + "version" : "6.5.4" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stadiamaps/maplibre-swiftui-dsl-playground", "state" : { - "revision" : "02f5a62009bc991a9dc59011785c83c347d7eea6", - "version" : "0.0.23" + "revision" : "a789bbee505a1344a87d9a5f999455ed55acdcde", + "version" : "0.0.28" } }, { @@ -33,14 +33,14 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Kolos65/Mockable.git", "state" : { - "revision" : "81ccaead99a3c038c09345caa2888ae74b644ee9", - "version" : "0.0.9" + "revision" : "da977ecb20974c4b1cf185f5fd38771b2d4674fb", + "version" : "0.0.10" } }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-syntax.git", + "location" : "https://github.com/swiftlang/swift-syntax.git", "state" : { "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", "version" : "509.1.1" diff --git a/apple/Sources/FerrostarCore/FerrostarCore.swift b/apple/Sources/FerrostarCore/FerrostarCore.swift index 9b38ffc4..44760e8e 100644 --- a/apple/Sources/FerrostarCore/FerrostarCore.swift +++ b/apple/Sources/FerrostarCore/FerrostarCore.swift @@ -274,6 +274,7 @@ public protocol FerrostarCoreDelegate: AnyObject { switch newState { case let .navigating( + currentStepGeometryIndex: _, snappedUserLocation: _, remainingSteps: _, remainingWaypoints: remainingWaypoints, diff --git a/apple/Sources/FerrostarCore/Mock/MockNavigationState.swift b/apple/Sources/FerrostarCore/Mock/MockNavigationState.swift index f46955f7..033e864b 100644 --- a/apple/Sources/FerrostarCore/Mock/MockNavigationState.swift +++ b/apple/Sources/FerrostarCore/Mock/MockNavigationState.swift @@ -5,6 +5,7 @@ import Foundation public extension NavigationState { static let pedestrianExample = NavigationState( tripState: .navigating( + currentStepGeometryIndex: 0, snappedUserLocation: UserLocation( latitude: samplePedestrianWaypoints.first!.lat, longitude: samplePedestrianWaypoints.first!.lng, @@ -36,6 +37,7 @@ public extension NavigationState { return NavigationState( tripState: .navigating( + currentStepGeometryIndex: 0, snappedUserLocation: UserLocation( coordinates: samplePedestrianWaypoints.first!, horizontalAccuracy: 10, diff --git a/apple/Sources/FerrostarMapLibreUI/Views/DynamicallyOrientingNavigationView.swift b/apple/Sources/FerrostarMapLibreUI/Views/DynamicallyOrientingNavigationView.swift index 0b892eb2..75ddf6ba 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/DynamicallyOrientingNavigationView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/DynamicallyOrientingNavigationView.swift @@ -134,7 +134,7 @@ public struct DynamicallyOrientingNavigationView: View, CustomizableNavigatingIn formatter.locale = Locale(identifier: "en-US") formatter.units = .imperial - guard case let .navigating(snappedUserLocation: userLocation, _, _, _, _, _, _) = state.tripState else { + guard case let .navigating(_, snappedUserLocation: userLocation, _, _, _, _, _, _) = state.tripState else { return EmptyView() } @@ -153,7 +153,7 @@ public struct DynamicallyOrientingNavigationView: View, CustomizableNavigatingIn formatter.locale = Locale(identifier: "en-US") formatter.units = .metric - guard case let .navigating(snappedUserLocation: userLocation, _, _, _, _, _, _) = state.tripState else { + guard case let .navigating(_, snappedUserLocation: userLocation, _, _, _, _, _, _) = state.tripState else { return EmptyView() } diff --git a/apple/Sources/FerrostarMapLibreUI/Views/LandscapeNavigationView.swift b/apple/Sources/FerrostarMapLibreUI/Views/LandscapeNavigationView.swift index 514085f4..f929c1df 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/LandscapeNavigationView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/LandscapeNavigationView.swift @@ -103,7 +103,7 @@ public struct LandscapeNavigationView: View { formatter.locale = Locale(identifier: "en-US") formatter.units = .imperial - guard case let .navigating(snappedUserLocation: userLocation, _, _, _, _, _, _) = state.tripState else { + guard case let .navigating(_, snappedUserLocation: userLocation, _, _, _, _, _, _) = state.tripState else { return EmptyView() } @@ -124,7 +124,7 @@ public struct LandscapeNavigationView: View { formatter.locale = Locale(identifier: "en-US") formatter.units = .metric - guard case let .navigating(snappedUserLocation: userLocation, _, _, _, _, _, _) = state.tripState else { + guard case let .navigating(_, snappedUserLocation: userLocation, _, _, _, _, _, _) = state.tripState else { return EmptyView() } diff --git a/apple/Sources/FerrostarMapLibreUI/Views/NavigationMapView.swift b/apple/Sources/FerrostarMapLibreUI/Views/NavigationMapView.swift index 16f7f8ca..56165e77 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/NavigationMapView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/NavigationMapView.swift @@ -83,7 +83,7 @@ public struct NavigationMapView: View { } private func updateCameraIfNeeded() { - if case let .navigating(snappedUserLocation: userLocation, _, _, _, _, _, _) = navigationState?.tripState, + if case let .navigating(_, snappedUserLocation: userLocation, _, _, _, _, _, _) = navigationState?.tripState, // There is no reason to push an update if the coordinate and heading are the same. // That's all that gets displayed, so it's all that MapLibre should care about. locationManager.lastLocation.coordinate != userLocation.coordinates @@ -98,7 +98,7 @@ public struct NavigationMapView: View { // TODO: Make map URL configurable but gitignored let state = NavigationState.modifiedPedestrianExample(droppingNWaypoints: 4) - guard case let .navigating(snappedUserLocation: userLocation, _, _, _, _, _, _) = state.tripState else { + guard case let .navigating(_, snappedUserLocation: userLocation, _, _, _, _, _, _) = state.tripState else { return EmptyView() } diff --git a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift index 7fe65204..e85e41fc 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/LandscapeNavigationOverlayView.swift @@ -47,7 +47,7 @@ struct LandscapeNavigationOverlayView: View, CustomizableNavigatingInnerGridView var body: some View { HStack { VStack { - if case let .navigating(_, _, _, progress: progress, _, visualInstruction: visualInstruction, + if case let .navigating(_, _, _, _, progress: progress, _, visualInstruction: visualInstruction, _) = navigationState?.tripState, let visualInstruction { @@ -61,7 +61,7 @@ struct LandscapeNavigationOverlayView: View, CustomizableNavigatingInnerGridView Spacer() - if case let .navigating(_, _, _, progress: progress, _, _, _) = navigationState?.tripState { + if case let .navigating(_, _, _, _, progress: progress, _, _, _) = navigationState?.tripState { ArrivalView( progress: progress, onTapExit: onTapExit diff --git a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift index 8814af19..cfecd6f3 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/Overylays/PortraitNavigationOverlayView.swift @@ -46,7 +46,7 @@ struct PortraitNavigationOverlayView: View, CustomizableNavigatingInnerGridView var body: some View { VStack { - if case let .navigating(_, _, _, progress: progress, _, visualInstruction: visualInstruction, + if case let .navigating(_, _, _, _, progress: progress, _, visualInstruction: visualInstruction, _) = navigationState?.tripState, let visualInstruction { @@ -81,7 +81,7 @@ struct PortraitNavigationOverlayView: View, CustomizableNavigatingInnerGridView } .padding(.horizontal, 16) - if case let .navigating(_, _, _, progress: progress, _, _, _) = navigationState?.tripState { + if case let .navigating(_, _, _, _, progress: progress, _, _, _) = navigationState?.tripState { ArrivalView( progress: progress, onTapExit: onTapExit diff --git a/apple/Sources/FerrostarMapLibreUI/Views/PortraitNavigationView.swift b/apple/Sources/FerrostarMapLibreUI/Views/PortraitNavigationView.swift index a256f076..1b6a29af 100644 --- a/apple/Sources/FerrostarMapLibreUI/Views/PortraitNavigationView.swift +++ b/apple/Sources/FerrostarMapLibreUI/Views/PortraitNavigationView.swift @@ -104,7 +104,7 @@ public struct PortraitNavigationView: View, CustomizableNavigatingInnerGridView formatter.locale = Locale(identifier: "en-US") formatter.units = .imperial - guard case let .navigating(snappedUserLocation: userLocation, _, _, _, _, _, _) = state.tripState else { + guard case let .navigating(_, snappedUserLocation: userLocation, _, _, _, _, _, _) = state.tripState else { return EmptyView() } @@ -124,7 +124,7 @@ public struct PortraitNavigationView: View, CustomizableNavigatingInnerGridView formatter.locale = Locale(identifier: "en-US") formatter.units = .metric - guard case let .navigating(snappedUserLocation: userLocation, _, _, _, _, _, _) = state.tripState else { + guard case let .navigating(_, snappedUserLocation: userLocation, _, _, _, _, _, _) = state.tripState else { return EmptyView() } diff --git a/apple/Sources/UniFFI/ferrostar.swift b/apple/Sources/UniFFI/ferrostar.swift index eaef5dd1..690fb525 100644 --- a/apple/Sources/UniFFI/ferrostar.swift +++ b/apple/Sources/UniFFI/ferrostar.swift @@ -404,6 +404,19 @@ private struct FfiConverterUInt32: FfiConverterPrimitive { } } +private struct FfiConverterUInt64: FfiConverterPrimitive { + typealias FfiType = UInt64 + typealias SwiftType = UInt64 + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UInt64 { + try lift(readInt(&buf)) + } + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + private struct FfiConverterDouble: FfiConverterPrimitive { typealias FfiType = Double typealias SwiftType = Double @@ -2887,6 +2900,7 @@ extension ModelError: Foundation.LocalizedError { public enum ParsingError { case ParseError(error: String) + case InvalidStatusCode(code: String) case UnknownError } @@ -2899,9 +2913,10 @@ public struct FfiConverterTypeParsingError: FfiConverterRustBuffer { case 1: return try .ParseError( error: FfiConverterString.read(from: &buf) ) - - case 2: return .UnknownError - + case 2: return try .InvalidStatusCode( + code: FfiConverterString.read(from: &buf) + ) + case 3: return .UnknownError default: throw UniffiInternalError.unexpectedEnumCase } } @@ -2912,8 +2927,12 @@ public struct FfiConverterTypeParsingError: FfiConverterRustBuffer { writeInt(&buf, Int32(1)) FfiConverterString.write(error, into: &buf) - case .UnknownError: + case let .InvalidStatusCode(code): writeInt(&buf, Int32(2)) + FfiConverterString.write(code, into: &buf) + + case .UnknownError: + writeInt(&buf, Int32(3)) } } } @@ -3305,38 +3324,47 @@ public enum TripState { /** * The navigation controller is actively navigating a trip. */ - case navigating(snappedUserLocation: UserLocation, - /** - * The ordered list of steps that remain in the trip. - * - * The step at the front of the list is always the current step. - * We currently assume that you cannot move backward to a previous step. - */ remainingSteps: [RouteStep], - /** - * Remaining waypoints to visit on the route. - * - * The waypoint at the front of the list is always the *next* waypoint "goal." - * Unlike the current step, there is no value in tracking the "current" waypoint, - * as the main use of waypoints is recalculation when the user deviates from the route. - * (In most use cases, a route will have only two waypoints, but more complex use cases - * may have multiple intervening points that are visited along the route.) - * This list is updated as the user advances through the route. - */ remainingWaypoints: [Waypoint], - /** - * The trip progress includes information that is useful for showing the - * user's progress along the full navigation trip, the route and its components. - */ progress: TripProgress, - /** - * The route deviation status: is the user following the route or not? - */ deviation: RouteDeviation, - /** - * The visual instruction that should be displayed in the user interface. - */ visualInstruction: VisualInstruction?, - /** - * The most recent spoken instruction that should be synthesized using TTS. - * - * Note it is the responsibility of the platform layer to ensure that utterances are not synthesized multiple times. This property simply reports the current spoken instruction. - */ spokenInstruction: SpokenInstruction?) + case navigating( + /** + * The index of the closest coordinate to the user's snapped location. + * + * This index is relative to the *current* [`RouteStep`]'s geometry. + */ currentStepGeometryIndex: UInt64?, + /** + * A location on the line string that + */ snappedUserLocation: UserLocation, + /** + * The ordered list of steps that remain in the trip. + * + * The step at the front of the list is always the current step. + * We currently assume that you cannot move backward to a previous step. + */ remainingSteps: [RouteStep], + /** + * Remaining waypoints to visit on the route. + * + * The waypoint at the front of the list is always the *next* waypoint "goal." + * Unlike the current step, there is no value in tracking the "current" waypoint, + * as the main use of waypoints is recalculation when the user deviates from the route. + * (In most use cases, a route will have only two waypoints, but more complex use cases + * may have multiple intervening points that are visited along the route.) + * This list is updated as the user advances through the route. + */ remainingWaypoints: [Waypoint], + /** + * The trip progress includes information that is useful for showing the + * user's progress along the full navigation trip, the route and its components. + */ progress: TripProgress, + /** + * The route deviation status: is the user following the route or not? + */ deviation: RouteDeviation, + /** + * The visual instruction that should be displayed in the user interface. + */ visualInstruction: VisualInstruction?, + /** + * The most recent spoken instruction that should be synthesized using TTS. + * + * Note it is the responsibility of the platform layer to ensure that utterances are not synthesized multiple times. This property simply reports the current spoken instruction. + */ spokenInstruction: SpokenInstruction? + ) /** * The navigation controller has reached the end of the trip. */ @@ -3352,6 +3380,7 @@ public struct FfiConverterTypeTripState: FfiConverterRustBuffer { case 1: return .idle case 2: return try .navigating( + currentStepGeometryIndex: FfiConverterOptionUInt64.read(from: &buf), snappedUserLocation: FfiConverterTypeUserLocation.read(from: &buf), remainingSteps: FfiConverterSequenceTypeRouteStep.read(from: &buf), remainingWaypoints: FfiConverterSequenceTypeWaypoint.read(from: &buf), @@ -3373,6 +3402,7 @@ public struct FfiConverterTypeTripState: FfiConverterRustBuffer { writeInt(&buf, Int32(1)) case let .navigating( + currentStepGeometryIndex, snappedUserLocation, remainingSteps, remainingWaypoints, @@ -3382,6 +3412,7 @@ public struct FfiConverterTypeTripState: FfiConverterRustBuffer { spokenInstruction ): writeInt(&buf, Int32(2)) + FfiConverterOptionUInt64.write(currentStepGeometryIndex, into: &buf) FfiConverterTypeUserLocation.write(snappedUserLocation, into: &buf) FfiConverterSequenceTypeRouteStep.write(remainingSteps, into: &buf) FfiConverterSequenceTypeWaypoint.write(remainingWaypoints, into: &buf) @@ -3481,6 +3512,27 @@ private struct FfiConverterOptionUInt16: FfiConverterRustBuffer { } } +private struct FfiConverterOptionUInt64: FfiConverterRustBuffer { + typealias SwiftType = UInt64? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterUInt64.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterUInt64.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + private struct FfiConverterOptionDouble: FfiConverterRustBuffer { typealias SwiftType = Double? diff --git a/common/Cargo.lock b/common/Cargo.lock index 1c304bf5..a94a777e 100644 --- a/common/Cargo.lock +++ b/common/Cargo.lock @@ -328,7 +328,7 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "ferrostar" -version = "0.9.2" +version = "0.10.0" dependencies = [ "assert-json-diff", "geo", diff --git a/common/ferrostar/Cargo.toml b/common/ferrostar/Cargo.toml index ec7db0b5..ebda79eb 100644 --- a/common/ferrostar/Cargo.toml +++ b/common/ferrostar/Cargo.toml @@ -2,7 +2,7 @@ lints.workspace = true [package] name = "ferrostar" -version = "0.9.2" +version = "0.10.0" readme = "README.md" description = "The core of modern turn-by-turn navigation." keywords = ["navigation", "routing", "valhalla", "osrm"] diff --git a/common/ferrostar/src/algorithms.rs b/common/ferrostar/src/algorithms.rs index e1040892..ae35f1bb 100644 --- a/common/ferrostar/src/algorithms.rs +++ b/common/ferrostar/src/algorithms.rs @@ -1,32 +1,58 @@ //! Common spatial algorithms which are useful for navigation. +use crate::navigation_controller::models::{ + StepAdvanceMode, StepAdvanceStatus, + StepAdvanceStatus::{Advanced, EndOfRoute}, +}; use crate::{ models::{GeographicCoordinate, RouteStep, UserLocation}, navigation_controller::models::TripProgress, }; use geo::{ - Closest, ClosestPoint, EuclideanDistance, HaversineDistance, HaversineLength, LineLocatePoint, - LineString, Point, -}; - -use crate::navigation_controller::models::{ - StepAdvanceMode, StepAdvanceStatus, - StepAdvanceStatus::{Advanced, EndOfRoute}, + Closest, ClosestPoint, Coord, EuclideanDistance, HaversineDistance, HaversineLength, + LineLocatePoint, LineString, Point, }; #[cfg(test)] use { crate::navigation_controller::test_helpers::gen_dummy_route_step, geo::{coord, point}, - proptest::prelude::*, + proptest::{collection::vec, prelude::*}, }; #[cfg(all(test, feature = "std", not(feature = "web-time")))] use std::time::SystemTime; - #[cfg(all(test, feature = "web-time"))] use web_time::SystemTime; +/// Get the index of the closest *segment* to the user's location within a [`LineString`]. +/// +/// A [`LineString`] is a set of points (ex: representing the geometry of a maneuver), +/// and this function identifies which segment a point is closest to, +/// so that you can correctly match attributes along a maneuver. +/// +/// In the case of a location being exactly on the boundary +/// (unlikely in the real world, but quite possible in simulations), +/// the *first* segment of equal distance to the location will be matched. +/// +/// The maximum value returned is *one less than* the last coordinate index into `line`. +/// Returns [`None`] if `line` contains fewer than two coordinates. +pub fn index_of_closest_segment_origin(location: UserLocation, line: &LineString) -> Option { + let point = Point::from(location.coordinates); + + line.lines() + // Iterate through all segments of the line + .enumerate() + // Find the line segment closest to the user's location + .min_by(|(_, line1), (_, line2)| { + // Note: lines don't implement haversine distances + let dist1 = line1.euclidean_distance(&point); + let dist2 = line2.euclidean_distance(&point); + dist1.total_cmp(&dist2) + }) + .map(|(index, _)| index as u64) +} + /// Snaps a user location to the closest point on a route line. /// /// If the location cannot be snapped (should only be possible with an invalid coordinate or geometry), @@ -385,6 +411,37 @@ pub fn calculate_trip_progress( } } +/// Convert a vector of geographic coordinates to a [`LineString`]. +pub(crate) fn get_linestring(geometry: &[GeographicCoordinate]) -> LineString { + geometry + .iter() + .map(|coord| Coord { + x: coord.lng, + y: coord.lat, + }) + .collect() +} + +#[cfg(test)] +/// Creates a user location at the given coordinates, +/// with all other values set to defaults or (in the case of the timestamp), the current time. +fn make_user_location(lng: f64, lat: f64) -> UserLocation { + UserLocation { + coordinates: GeographicCoordinate { lng, lat }, + horizontal_accuracy: 0.0, + course_over_ground: None, + timestamp: SystemTime::now(), + speed: None, + } +} + +#[cfg(test)] +prop_compose! { + fn arb_coord()(x in -180f64..180f64, y in -90f64..90f64) -> Coord { + coord! {x: x, y: y} + } +} + #[cfg(test)] proptest! { #[test] @@ -564,6 +621,100 @@ proptest! { prop_assert_eq!(progress.distance_remaining, 0f64); prop_assert_eq!(progress.duration_remaining, 0f64); } + + #[test] + fn test_geometry_index_empty_linestring( + x: f64, y: f64, + ) { + let index = index_of_closest_segment_origin(make_user_location(x, y), &LineString::new(vec![])); + prop_assert_eq!(index, None); + } + + #[test] + fn test_geometry_index_single_coord_invalid_linestring( + x: f64, y: f64, + ) { + let index = index_of_closest_segment_origin(make_user_location(x, y), &LineString::new(vec![coord! { x: x, y: y }])); + prop_assert_eq!(index, None); + } + + #[test] + fn test_geometry_index_is_some_for_reasonable_linestrings( + x in -180f64..180f64, y in -90f64..90f64, + coords in vec(arb_coord(), 2..500) + ) { + let index = index_of_closest_segment_origin(make_user_location(x, y), &LineString::new(coords)); + + // There are at least two points, so we have a valid segment + prop_assert_ne!(index, None); + } + + #[test] + fn test_geometry_index_at_terminal_coord( + coords in vec(arb_coord(), 2..500) + ) { + let last_coord = coords.last().unwrap(); + let coord_len = coords.len(); + let user_location = make_user_location(last_coord.x, last_coord.y); + let index = index_of_closest_segment_origin(user_location, &LineString::new(coords)); + + // There are at least two points, so we have a valid segment + prop_assert_ne!(index, None); + let index = index.unwrap(); + // We should never be able to go past the origin of the final pair + prop_assert!(index < (coord_len - 1) as u64); + } +} + +#[cfg(test)] +mod geom_index_tests { + + use super::*; + + static COORDS: [Coord; 5] = [ + coord!(x: 0.0, y: 0.0), + coord!(x: 1.0, y: 1.0), + coord!(x: 2.0, y: 2.0), + coord!(x: 3.0, y: 3.0), + coord!(x: 4.0, y: 4.0), + ]; + + #[test] + fn test_geometry_index_at_point() { + let line = LineString::new(COORDS.to_vec()); + + // Exactly at a point (NB: does not advance until we move *past* the transition point + // and are closer to the next line segment!) + let index = index_of_closest_segment_origin(make_user_location(2.0, 2.0), &line); + assert_eq!(index, Some(1)); + } + + #[test] + fn test_geometry_index_near_point() { + let line = LineString::new(COORDS.to_vec()); + + // Very close to an origin point + let index = index_of_closest_segment_origin(make_user_location(1.1, 1.1), &line); + assert_eq!(index, Some(1)); + + // Very close to the next point, but not yet "passing" to the next segment! + let index = index_of_closest_segment_origin(make_user_location(1.99, 1.99), &line); + assert_eq!(index, Some(1)); + } + + #[test] + fn test_geometry_index_far_from_point() { + let line = LineString::new(COORDS.to_vec()); + + // "Before" the start + let index = index_of_closest_segment_origin(make_user_location(-1.1, -1.1), &line); + assert_eq!(index, Some(0)); + + // "Past" the end (NB: the last index in the list of coords is 4, + // but we can never advance past n-1) + let index = index_of_closest_segment_origin(make_user_location(10.0, 10.0), &line); + assert_eq!(index, Some(3)); + } } // TODO: Other unit tests diff --git a/common/ferrostar/src/models.rs b/common/ferrostar/src/models.rs index 55efb708..bb1504a4 100644 --- a/common/ferrostar/src/models.rs +++ b/common/ferrostar/src/models.rs @@ -25,6 +25,8 @@ use serde::Serialize; use uuid::Uuid; +use crate::algorithms::get_linestring; + #[derive(Debug)] #[cfg_attr(feature = "std", derive(thiserror::Error))] #[cfg_attr(feature = "uniffi", derive(uniffi::Error))] @@ -173,7 +175,7 @@ pub struct Speed { pub accuracy: Option, } -#[cfg(any(test, feature = "wasm-bindgen"))] +#[cfg(feature = "wasm-bindgen")] mod system_time_format { use serde::{self, Deserialize, Deserializer, Serializer}; @@ -250,6 +252,12 @@ pub struct Route { pub steps: Vec, } +impl Route { + pub(crate) fn get_linestring(&self) -> LineString { + get_linestring(&self.geometry) + } +} + /// Helper function for getting the route as an encoded polyline. /// /// Mostly used for debugging. @@ -290,15 +298,8 @@ pub struct RouteStep { } impl RouteStep { - // TODO: Memoize or something later pub(crate) fn get_linestring(&self) -> LineString { - self.geometry - .iter() - .map(|coord| Coord { - x: coord.lng, - y: coord.lat, - }) - .collect() + get_linestring(&self.geometry) } /// Gets the active visual instruction at a specific point along the step. diff --git a/common/ferrostar/src/navigation_controller/mod.rs b/common/ferrostar/src/navigation_controller/mod.rs index f876b231..734e37c6 100644 --- a/common/ferrostar/src/navigation_controller/mod.rs +++ b/common/ferrostar/src/navigation_controller/mod.rs @@ -7,8 +7,8 @@ pub(crate) mod test_helpers; use crate::{ algorithms::{ - advance_step, calculate_trip_progress, should_advance_to_next_step, - snap_user_location_to_line, + advance_step, calculate_trip_progress, index_of_closest_segment_origin, + should_advance_to_next_step, snap_user_location_to_line, }, models::{Route, UserLocation}, }; @@ -47,8 +47,11 @@ impl NavigationController { return TripState::Complete; }; + // TODO: We could move this to the Route struct or NavigationController directly to only calculate it once. let current_step_linestring = current_route_step.get_linestring(); let snapped_user_location = snap_user_location_to_line(location, ¤t_step_linestring); + let current_step_geometry_index = + index_of_closest_segment_origin(snapped_user_location, ¤t_step_linestring); let progress = calculate_trip_progress( &snapped_user_location.into(), ¤t_step_linestring, @@ -67,6 +70,7 @@ impl NavigationController { .cloned(); TripState::Navigating { + current_step_geometry_index, snapped_user_location, remaining_steps: remaining_steps.clone(), // Skip the first waypoint, as it is the current one @@ -131,6 +135,11 @@ impl NavigationController { &remaining_steps, ); + let current_step_geometry_index = index_of_closest_segment_origin( + *snapped_user_location, + &self.route.get_linestring(), + ); + let visual_instruction = current_step .get_active_visual_instruction(progress.distance_to_next_maneuver) .cloned(); @@ -139,6 +148,7 @@ impl NavigationController { .cloned(); TripState::Navigating { + current_step_geometry_index, snapped_user_location: *snapped_user_location, remaining_steps, remaining_waypoints, @@ -169,6 +179,7 @@ impl NavigationController { match state { TripState::Idle => TripState::Idle, TripState::Navigating { + current_step_geometry_index, ref remaining_steps, ref remaining_waypoints, deviation, @@ -188,12 +199,14 @@ impl NavigationController { let current_step_linestring = current_step.get_linestring(); let snapped_user_location = snap_user_location_to_line(location, ¤t_step_linestring); + let progress = calculate_trip_progress( &snapped_user_location.into(), ¤t_step_linestring, remaining_steps, ); let intermediate_state = TripState::Navigating { + current_step_geometry_index: *current_step_geometry_index, snapped_user_location, remaining_steps: remaining_steps.clone(), remaining_waypoints: remaining_waypoints.clone(), @@ -221,6 +234,8 @@ impl NavigationController { remaining_steps, remaining_waypoints, progress, + // Explicitly recalculated + current_step_geometry_index: _, deviation: _, visual_instruction: _, spoken_instruction: _, @@ -243,7 +258,13 @@ impl NavigationController { .get_current_spoken_instruction(progress.distance_to_next_maneuver) .cloned(); + let current_step_geometry_index = index_of_closest_segment_origin( + snapped_user_location, + ¤t_step_linestring, + ); + TripState::Navigating { + current_step_geometry_index, snapped_user_location, remaining_steps, remaining_waypoints, diff --git a/common/ferrostar/src/navigation_controller/models.rs b/common/ferrostar/src/navigation_controller/models.rs index ca427293..6679acbe 100644 --- a/common/ferrostar/src/navigation_controller/models.rs +++ b/common/ferrostar/src/navigation_controller/models.rs @@ -34,6 +34,11 @@ pub enum TripState { #[cfg_attr(feature = "wasm-bindgen", serde(rename_all = "camelCase"))] /// The navigation controller is actively navigating a trip. Navigating { + /// The index of the closest coordinate to the user's snapped location. + /// + /// This index is relative to the *current* [`RouteStep`]'s geometry. + current_step_geometry_index: Option, + /// A location on the line string that snapped_user_location: UserLocation, /// The ordered list of steps that remain in the trip. /// diff --git a/common/ferrostar/src/routing_adapters/error.rs b/common/ferrostar/src/routing_adapters/error.rs index 5266dd6a..7439ee2d 100644 --- a/common/ferrostar/src/routing_adapters/error.rs +++ b/common/ferrostar/src/routing_adapters/error.rs @@ -57,6 +57,11 @@ pub enum ParsingError { // TODO: Unable to find route and other common errors #[cfg_attr(feature = "std", error("Failed to parse route response: {error}."))] ParseError { error: String }, + #[cfg_attr( + feature = "std", + error("Routing adapter returned an unexpected status code: {code}.") + )] + InvalidStatusCode { code: String }, #[cfg_attr( feature = "std", error("An unknown error parsing a response was raised in foreign code.") diff --git a/common/ferrostar/src/routing_adapters/osrm/mod.rs b/common/ferrostar/src/routing_adapters/osrm/mod.rs index 20ab84e0..040348b8 100644 --- a/common/ferrostar/src/routing_adapters/osrm/mod.rs +++ b/common/ferrostar/src/routing_adapters/osrm/mod.rs @@ -38,10 +38,14 @@ impl RouteResponseParser for OsrmResponseParser { fn parse_response(&self, response: Vec) -> Result, ParsingError> { let res: RouteResponse = serde_json::from_slice(&response)?; - res.routes - .iter() - .map(|route| Route::from_osrm(route, &res.waypoints, self.polyline_precision)) - .collect::, _>>() + if res.code == "Ok" { + res.routes + .iter() + .map(|route| Route::from_osrm(route, &res.waypoints, self.polyline_precision)) + .collect::, _>>() + } else { + Err(ParsingError::InvalidStatusCode { code: res.code }) + } } }