From 32e4f8ffe12e5e3f3312fdf7c5da0c56a0f19762 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:53:09 -0600 Subject: [PATCH 1/3] refactor(path-following): clean MVVM architecture, custom 3D gesture engine, and modern UI controls --- .../ApiDemos/common/build.gradle.kts | 2 + .../com/example/maps3d/common/PathData.kt | 93 ++ .../com/example/maps3d/common/PathEngine.kt | 295 +++++ .../maps3d/common/PathFollowingViewModel.kt | 138 +++ .../maps3d/common/PathPlaybackController.kt | 465 ++++++++ .../example/maps3d/common/PathTouchHandler.kt | 227 ++++ .../src/main/res/drawable/drag_handle_bar.xml | 6 + .../main/res/drawable/help_outline_24px.xml | 9 + .../res/layout/activity_path_following.xml | 191 ++- .../common/src/main/res/values/strings.xml | 9 + .../common/PathPlaybackControllerTest.kt | 224 ++++ .../pathfollowing/PathFollowingActivity.java | 1061 ++++++++--------- .../pathfollowing/PathFollowingActivity.kt | 1044 ++++++++-------- .../ComposeDemos/app/build.gradle.kts | 4 + .../pathfollowing/PathFollowingActivity.kt | 948 +++++++++------ gradle/libs.versions.toml | 1 + 16 files changed, 3200 insertions(+), 1517 deletions(-) create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathData.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathFollowingViewModel.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathPlaybackController.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathTouchHandler.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/res/drawable/drag_handle_bar.xml create mode 100644 Maps3DSamples/ApiDemos/common/src/main/res/drawable/help_outline_24px.xml create mode 100644 Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathPlaybackControllerTest.kt diff --git a/Maps3DSamples/ApiDemos/common/build.gradle.kts b/Maps3DSamples/ApiDemos/common/build.gradle.kts index 163eed9..8f4664e 100644 --- a/Maps3DSamples/ApiDemos/common/build.gradle.kts +++ b/Maps3DSamples/ApiDemos/common/build.gradle.kts @@ -61,7 +61,9 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) + api(libs.androidx.lifecycle.viewmodel.ktx) testImplementation(libs.junit) + testImplementation(libs.google.truth) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) implementation(platform(libs.androidx.compose.bom)) diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathData.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathData.kt new file mode 100644 index 0000000..a13fef6 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathData.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import com.google.android.gms.maps3d.model.LatLngAltitude + +/** + * Shared pre-baked route datasets for ground-level path following samples. + * + * Lifting coordinates into this shared repository decouples the raw geographic + * geometry from sample application code across Java Views, Kotlin Views, and Compose. + */ +object PathData { + + /** + * Urban Route: Downtown San Francisco (Market Street corridor). + * + * Features realistic per-waypoint elevation variations (1m to 10m) to demonstrate + * 3D altitude modes (Absolute, Relative to Ground, Relative to Mesh, Clamp to Ground). + */ + @JvmField + val URBAN_PATH: List = listOf( + LatLngAltitude(37.79323, -122.39322, 4.2), + LatLngAltitude(37.79166, -122.39519, 6.7), + LatLngAltitude(37.79124, -122.39571, 8.1), + LatLngAltitude(37.79105, -122.39599, 9.5), + LatLngAltitude(37.78893, -122.39866, 7.3), + LatLngAltitude(37.78742, -122.40060, 5.0), + LatLngAltitude(37.78686, -122.40129, 3.4), + LatLngAltitude(37.78652, -122.40171, 2.1), + LatLngAltitude(37.78632, -122.40196, 4.6), + LatLngAltitude(37.78627, -122.40207, 6.2), + LatLngAltitude(37.78453, -122.40429, 8.9), + LatLngAltitude(37.78443, -122.40434, 10.0), + LatLngAltitude(37.78155, -122.40802, 7.8), + LatLngAltitude(37.78005, -122.40990, 5.4), + LatLngAltitude(37.77856, -122.41180, 3.1), + LatLngAltitude(37.77746, -122.41318, 1.8), + LatLngAltitude(37.77624, -122.41474, 4.0), + LatLngAltitude(37.77744, -122.41623, 6.5), + LatLngAltitude(37.77749, -122.41636, 8.7), + LatLngAltitude(37.77761, -122.41654, 9.8), + LatLngAltitude(37.77769, -122.41677, 7.2), + LatLngAltitude(37.77729, -122.41981, 4.9), + LatLngAltitude(37.77523, -122.41938, 2.6), + LatLngAltitude(37.77510, -122.41934, 1.2), + LatLngAltitude(37.77442, -122.42022, 3.5), + LatLngAltitude(37.77441, -122.42033, 5.8), + LatLngAltitude(37.77348, -122.42157, 8.4), + LatLngAltitude(37.77244, -122.42289, 10.0) + ) + + /** + * Rural Route: Coastal highway and mountain switchbacks near Pescadero, CA. + */ + @JvmField + val RURAL_PATH: List = listOf( + LatLngAltitude(37.254529, -122.380897, 0.0), + LatLngAltitude(37.255065, -122.381627, 0.0), + LatLngAltitude(37.257540, -122.383720, 0.0), + LatLngAltitude(37.261200, -122.383950, 0.0), + LatLngAltitude(37.264780, -122.388210, 0.0), + LatLngAltitude(37.268520, -122.392450, 0.0), + LatLngAltitude(37.272110, -122.397640, 0.0), + LatLngAltitude(37.276430, -122.401120, 0.0), + LatLngAltitude(37.280850, -122.403560, 0.0), + LatLngAltitude(37.286018, -122.405072, 0.0), + LatLngAltitude(37.291040, -122.404210, 0.0), + LatLngAltitude(37.295800, -122.401980, 0.0), + LatLngAltitude(37.300120, -122.399540, 0.0), + LatLngAltitude(37.304550, -122.397210, 0.0), + LatLngAltitude(37.309200, -122.395100, 0.0), + LatLngAltitude(37.313450, -122.392840, 0.0), + LatLngAltitude(37.317200, -122.390510, 0.0), + LatLngAltitude(37.320850, -122.388740, 0.0), + LatLngAltitude(37.323540, -122.387600, 0.0), + LatLngAltitude(37.325269, -122.386728, 0.0) + ) +} diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt new file mode 100644 index 0000000..050dbe8 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt @@ -0,0 +1,295 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.LatLngAltitude +import com.google.maps.android.SphericalUtil + +/** + * Result of interpolating a position and orientation along a 3D path at a specific distance. + * + * @property latLng The interpolated 2D geographic coordinate. + * @property waypointIndex The zero-based index of the segment start waypoint. + * @property bearing The forward-looking tangent compass bearing (degrees). + * @property altitude The interpolated elevation in meters along the route segment. + */ +data class InterpolatedPathPoint( + @JvmField val latLng: LatLng, + @JvmField val waypointIndex: Int, + @JvmField val bearing: Double, + @JvmField val altitude: Double +) + +/** + * Math and geometry engine for ground-level 3D path following. + * + * Encapsulates segment search, distance accumulation, kinematic heading smoothing, + * and elevation interpolation across Kotlin Views, Java Views, and Compose. + */ +object PathEngine { + + const val STATIC_POLYLINE_ID = "path_following_static_route" + const val PROGRESS_POLYLINE_ID = "path_following_progress_route" + + /** + * Precomputes cumulative distances along a 3D path in meters. + */ + @JvmStatic + fun calculateCumulativeDistances(path: List): DoubleArray { + if (path.isEmpty()) return doubleArrayOf(0.0) + val cumulativeDistances = DoubleArray(path.size) + cumulativeDistances[0] = 0.0 + for (i in 1 until path.size) { + val pPrev = LatLng(path[i - 1].latitude, path[i - 1].longitude) + val pCurr = LatLng(path[i].latitude, path[i].longitude) + cumulativeDistances[i] = cumulativeDistances[i - 1] + SphericalUtil.computeDistanceBetween(pPrev, pCurr) + } + return cumulativeDistances + } + + /** + * Helper to interpolate 2D LatLng position at any distance along the route. + */ + @JvmStatic + fun getInterpolatedLatLng( + path: List, + cumulativeDistances: DoubleArray, + distance: Double + ): LatLng { + if (path.isEmpty()) return LatLng(0.0, 0.0) + val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0 + if (distance <= 0.0) return LatLng(path.first().latitude, path.first().longitude) + if (distance >= totalDistance) return LatLng(path.last().latitude, path.last().longitude) + + var index = 0 + while (index < cumulativeDistances.size - 1 && cumulativeDistances[index + 1] < distance) { + index++ + } + + val p1 = path[index] + val p2 = if (index < path.size - 1) path[index + 1] else p1 + val d1 = cumulativeDistances[index] + val d2 = cumulativeDistances.getOrElse(index + 1) { totalDistance } + val segLen = d2 - d1 + val fraction = if (segLen > 0) ((distance - d1) / segLen).coerceIn(0.0, 1.0) else 0.0 + + val latLng1 = LatLng(p1.latitude, p1.longitude) + val latLng2 = LatLng(p2.latitude, p2.longitude) + return SphericalUtil.interpolate(latLng1, latLng2, fraction) + } + + /** + * Finds the interpolated geographic position, smooth forward lookahead bearing, + * and elevation at a target distance. + */ + @JvmStatic + @JvmOverloads + fun interpolatePoint( + path: List, + cumulativeDistances: DoubleArray, + distance: Double, + lookaheadDistance: Double = 25.0 + ): InterpolatedPathPoint { + if (path.isEmpty()) { + return InterpolatedPathPoint( + latLng = LatLng(0.0, 0.0), + waypointIndex = 0, + bearing = 0.0, + altitude = 0.0 + ) + } + + val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0 + var index = 0 + while (index < cumulativeDistances.size - 1 && cumulativeDistances[index + 1] < distance) { + index++ + } + + val p1 = path[index] + val p2 = if (index < path.size - 1) path[index + 1] else p1 + + val segStartDist = cumulativeDistances.getOrElse(index) { 0.0 } + val segEndDist = cumulativeDistances.getOrElse(index + 1) { totalDistance } + val segLen = segEndDist - segStartDist + + val fraction = if (segLen > 0) ((distance - segStartDist) / segLen).coerceIn(0.0, 1.0) else 0.0 + val latLng1 = LatLng(p1.latitude, p1.longitude) + val latLng2 = LatLng(p2.latitude, p2.longitude) + val currentLatLng = SphericalUtil.interpolate(latLng1, latLng2, fraction) + val interpAlt = p1.altitude + fraction * (p2.altitude - p1.altitude) + + // Smooth forward lookahead tangent heading calculation + val targetLookaheadDist = (distance + lookaheadDistance).coerceAtMost(totalDistance) + val lookaheadPos = getInterpolatedLatLng(path, cumulativeDistances, targetLookaheadDist) + + val bearing = if (targetLookaheadDist > distance && currentLatLng != lookaheadPos) { + SphericalUtil.computeHeading(currentLatLng, lookaheadPos) + } else if (distance > 1.0) { + val prevPos = getInterpolatedLatLng(path, cumulativeDistances, distance - 1.0) + SphericalUtil.computeHeading(prevPos, currentLatLng) + } else if (path.size >= 2) { + SphericalUtil.computeHeading( + LatLng(path[0].latitude, path[0].longitude), + LatLng(path[1].latitude, path[1].longitude) + ) + } else { + 0.0 + } + + return InterpolatedPathPoint( + latLng = currentLatLng, + waypointIndex = index, + bearing = (bearing + 360.0) % 360.0, + altitude = interpAlt + ) + } + + /** + * Applies an Exponential Moving Average (EMA) filter to camera heading to smooth + * abrupt turns around corners during real-time playback. + */ + @JvmStatic + fun smoothHeading( + targetHeading: Double, + currentHeading: Double?, + isUserScrubbing: Boolean, + isPlaying: Boolean, + smoothingFactor: Double = 0.12 + ): Double { + val normalizedTarget = (targetHeading % 360.0 + 360.0) % 360.0 + if (currentHeading == null || isUserScrubbing || !isPlaying) { + return normalizedTarget + } + + var diff = (normalizedTarget - currentHeading) % 360.0 + if (diff > 180.0) diff -= 360.0 + if (diff < -180.0) diff += 360.0 + return (currentHeading + diff * smoothingFactor + 360.0) % 360.0 + } + + /** + * Calculates camera target altitude based on the active altitude mode and route elevation. + */ + @JvmStatic + fun calculateCameraAltitude( + altitudeMode: Int, + baseAltitude: Double, + interpolatedAltitude: Double, + groundAltitude: Double + ): Double { + return if (altitudeMode == AltitudeMode.ABSOLUTE) { + baseAltitude + interpolatedAltitude + groundAltitude + } else { + groundAltitude + } + } + + /** + * Builds static route polyline vertices with altitude mode adjustments. + */ + @JvmStatic + fun buildStaticVertices( + path: List, + altitudeMode: Int, + baseAltitude: Double, + pathAltitudeOffset: Double + ): List { + return path.map { pt -> + val vertexAltitude = when (altitudeMode) { + AltitudeMode.CLAMP_TO_GROUND -> 0.0 + AltitudeMode.ABSOLUTE -> pt.altitude + baseAltitude + pathAltitudeOffset + else -> pt.altitude + pathAltitudeOffset + } + LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude) + } + } + + /** + * Builds progress polyline vertices up to the current progress distance with +0.4m depth bias. + */ + @JvmStatic + fun buildProgressVertices( + path: List, + cumulativeDistances: DoubleArray, + elapsedDistance: Double, + currentLatLng: LatLng, + waypointIndex: Int, + altitudeMode: Int, + baseAltitude: Double, + pathAltitudeOffset: Double + ): List { + if (path.isEmpty()) return emptyList() + + val progressCoordinates = mutableListOf() + val clampedIndex = waypointIndex.coerceIn(0, path.size - 1) + + for (i in 0..clampedIndex) { + val pt = path[i] + val vertexAltitude = when (altitudeMode) { + AltitudeMode.CLAMP_TO_GROUND -> 0.0 + AltitudeMode.ABSOLUTE -> pt.altitude + baseAltitude + pathAltitudeOffset + 0.4 + else -> pt.altitude + pathAltitudeOffset + 0.4 + } + progressCoordinates.add( + LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude) + ) + } + + val lastWaypoint = path[clampedIndex] + val lastLatLng = LatLng(lastWaypoint.latitude, lastWaypoint.longitude) + val distToLast = SphericalUtil.computeDistanceBetween(lastLatLng, currentLatLng) + + if (distToLast >= 0.5) { + val p1 = path[clampedIndex] + val p2 = if (clampedIndex < path.size - 1) path[clampedIndex + 1] else p1 + val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0 + val segStartDist = cumulativeDistances.getOrElse(clampedIndex) { 0.0 } + val segEndDist = cumulativeDistances.getOrElse(clampedIndex + 1) { totalDistance } + val segLen = segEndDist - segStartDist + val fraction = if (segLen > 0) ((elapsedDistance - segStartDist) / segLen).coerceIn(0.0, 1.0) else 0.0 + val interpAlt = p1.altitude + fraction * (p2.altitude - p1.altitude) + + val progressAltitude = when (altitudeMode) { + AltitudeMode.CLAMP_TO_GROUND -> 0.0 + AltitudeMode.ABSOLUTE -> interpAlt + baseAltitude + pathAltitudeOffset + 0.4 + else -> interpAlt + pathAltitudeOffset + 0.4 + } + progressCoordinates.add( + LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, progressAltitude) + ) + } + + // Polyline requires at least 2 distinct vertices + if (progressCoordinates.size < 2 && path.size >= 2) { + val p0 = LatLng(path[0].latitude, path[0].longitude) + val p1 = LatLng(path[1].latitude, path[1].longitude) + val tinyForward = SphericalUtil.interpolate(p0, p1, 0.005) + val startAlt = when (altitudeMode) { + AltitudeMode.CLAMP_TO_GROUND -> 0.0 + AltitudeMode.ABSOLUTE -> path[0].altitude + baseAltitude + pathAltitudeOffset + 0.4 + else -> path[0].altitude + pathAltitudeOffset + 0.4 + } + progressCoordinates.add( + LatLngAltitude(tinyForward.latitude, tinyForward.longitude, startAlt) + ) + } + + return progressCoordinates + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathFollowingViewModel.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathFollowingViewModel.kt new file mode 100644 index 0000000..68d3572 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathFollowingViewModel.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import com.google.android.gms.maps3d.model.LatLngAltitude +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Shared Architecture ViewModel for 3D Path Following across Kotlin, Java, and Jetpack Compose. + * + * Bridges the UI presentation layer with the framework-independent [PathPlaybackController]. + * Exposes observable state streams via [StateFlow] and [LiveData]. + */ +class PathFollowingViewModel( + initialRoute: List = PathData.URBAN_PATH +) : ViewModel() { + + private val controller = PathPlaybackController(initialRoute) + + private val _uiState = MutableStateFlow(controller.getState()) + val uiState: StateFlow = _uiState.asStateFlow() + + val liveData: LiveData = _uiState.asLiveData() + + val currentState: PathPlaybackState + get() = _uiState.value + + fun advance(deltaTimeSeconds: Double) { + _uiState.value = controller.advance(deltaTimeSeconds) + } + + fun seekToRatio(ratio: Float) { + _uiState.value = controller.seekToRatio(ratio) + } + + fun skipDistance(deltaMeters: Double) { + _uiState.value = controller.skipDistance(deltaMeters) + } + + fun skipRatio(deltaRatio: Float) { + _uiState.value = controller.skipRatio(deltaRatio) + } + + fun seekToDistance(distanceMeters: Double) { + _uiState.value = controller.seekToDistance(distanceMeters) + } + + fun setScrubbing(isScrubbing: Boolean) { + _uiState.value = controller.setScrubbing(isScrubbing) + } + + fun setPlaying(isPlaying: Boolean) { + _uiState.value = controller.setPlaying(isPlaying) + } + + fun togglePlayPause() { + _uiState.value = controller.togglePlayPause() + } + + fun setRoute(newRoute: List, applyDefaults: Boolean = true) { + _uiState.value = controller.setRoute(newRoute, applyDefaults) + } + + fun setAltitudeMode(mode: Int) { + _uiState.value = controller.setAltitudeMode(mode) + } + + fun setDrawsOccludedSegments(drawsOccluded: Boolean) { + _uiState.value = controller.setDrawsOccludedSegments(drawsOccluded) + } + + fun setPathAltitudeOffset(offset: Double) { + _uiState.value = controller.setPathAltitudeOffset(offset) + } + + fun setCameraRange(range: Double) { + _uiState.value = controller.setCameraRange(range) + } + + fun setGroundAltitude(altitude: Double) { + _uiState.value = controller.setGroundAltitude(altitude) + } + + fun setHeadingOffset(offset: Double) { + _uiState.value = controller.setHeadingOffset(offset) + } + + fun setCameraTilt(tilt: Double) { + _uiState.value = controller.setCameraTilt(tilt) + } + + fun adjustTilt(deltaDeg: Double) { + _uiState.value = controller.adjustTilt(deltaDeg) + } + + fun adjustHeading(deltaDeg: Double) { + _uiState.value = controller.adjustHeading(deltaDeg) + } + + fun adjustRange(scaleFactor: Double) { + _uiState.value = controller.adjustRange(scaleFactor) + } + + fun setSpeedBoostMultiplier(multiplier: Double) { + _uiState.value = controller.setSpeedBoostMultiplier(multiplier) + } + + fun setSpeedBoosted(isBoosted: Boolean) { + _uiState.value = controller.setSpeedBoosted(isBoosted) + } + + fun setFollowSpeed(speedMps: Double) { + _uiState.value = controller.setFollowSpeed(speedMps) + } + + fun reset() { + _uiState.value = controller.reset() + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathPlaybackController.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathPlaybackController.kt new file mode 100644 index 0000000..941c7be --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathPlaybackController.kt @@ -0,0 +1,465 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.LatLngAltitude + +/** + * Immutable state representation of the path following engine and camera position. + */ +data class PathPlaybackState( + val route: List = PathData.URBAN_PATH, + val cumulativeDistances: DoubleArray = doubleArrayOf(0.0), + val totalDistance: Double = 0.0, + val elapsedDistance: Double = 0.0, + val progressRatio: Float = 0.0f, + val isPlaying: Boolean = false, + val isScrubbing: Boolean = false, + val speedBoostMultiplier: Double = 1.0, + val followSpeedMps: Double = 30.0, + val cameraRange: Double = 300.0, + val groundAltitude: Double = 20.0, + val headingOffset: Double = 0.0, + val cameraTilt: Double = 70.0, + val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND, + val pathAltitudeOffset: Double = 0.5, + val drawsOccludedSegments: Boolean = true, + val currentPosition: LatLng = LatLng(0.0, 0.0), + val currentAltitude: Double = 0.0, + val currentHeading: Double = 0.0, + val cameraHeading: Double = 0.0, + val staticPolylineVertices: List = emptyList(), + val progressPolylineVertices: List = emptyList() +) { + val isSpeedBoosted: Boolean get() = kotlin.math.abs(speedBoostMultiplier - 1.0) > 0.01 + + val baseAltitude: Double + get() = if (route == PathData.RURAL_PATH) 45.0 else 50.0 + + val cameraTargetAltitude: Double + get() = PathEngine.calculateCameraAltitude( + altitudeMode = altitudeMode, + baseAltitude = baseAltitude, + interpolatedAltitude = currentAltitude, + groundAltitude = groundAltitude + ) + + val effectiveHeading: Double + get() = cameraHeading + + val effectiveSpeedMps: Double + get() = followSpeedMps * speedBoostMultiplier + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PathPlaybackState) return false + + return route == other.route && + cumulativeDistances.contentEquals(other.cumulativeDistances) && + totalDistance == other.totalDistance && + elapsedDistance == other.elapsedDistance && + progressRatio == other.progressRatio && + isPlaying == other.isPlaying && + isScrubbing == other.isScrubbing && + speedBoostMultiplier == other.speedBoostMultiplier && + followSpeedMps == other.followSpeedMps && + cameraRange == other.cameraRange && + groundAltitude == other.groundAltitude && + headingOffset == other.headingOffset && + cameraTilt == other.cameraTilt && + altitudeMode == other.altitudeMode && + pathAltitudeOffset == other.pathAltitudeOffset && + drawsOccludedSegments == other.drawsOccludedSegments && + currentPosition == other.currentPosition && + currentAltitude == other.currentAltitude && + currentHeading == other.currentHeading && + cameraHeading == other.cameraHeading && + staticPolylineVertices == other.staticPolylineVertices && + progressPolylineVertices == other.progressPolylineVertices + } + + override fun hashCode(): Int { + var result = route.hashCode() + result = 31 * result + cumulativeDistances.contentHashCode() + result = 31 * result + totalDistance.hashCode() + result = 31 * result + elapsedDistance.hashCode() + result = 31 * result + progressRatio.hashCode() + result = 31 * result + isPlaying.hashCode() + result = 31 * result + isScrubbing.hashCode() + result = 31 * result + speedBoostMultiplier.hashCode() + result = 31 * result + followSpeedMps.hashCode() + result = 31 * result + cameraRange.hashCode() + result = 31 * result + groundAltitude.hashCode() + result = 31 * result + headingOffset.hashCode() + result = 31 * result + cameraTilt.hashCode() + result = 31 * result + altitudeMode.hashCode() + result = 31 * result + pathAltitudeOffset.hashCode() + result = 31 * result + drawsOccludedSegments.hashCode() + result = 31 * result + currentPosition.hashCode() + result = 31 * result + currentAltitude.hashCode() + result = 31 * result + currentHeading.hashCode() + result = 31 * result + cameraHeading.hashCode() + result = 31 * result + staticPolylineVertices.hashCode() + result = 31 * result + progressPolylineVertices.hashCode() + return result + } +} + +/** + * Framework-independent controller for 3D path following playback and camera interpolation. + * + * Encapsulates all domain math, distance tracking, kinematic heading smoothing, and polyline + * vertex generation. Has zero dependencies on Android Views, UI widgets, or GoogleMap3D rendering + * classes, making it 100% unit-testable on the JVM. + */ +class PathPlaybackController( + initialRoute: List = PathData.URBAN_PATH +) { + private var state: PathPlaybackState + + init { + val cumDist = PathEngine.calculateCumulativeDistances(initialRoute) + val totalDist = cumDist.lastOrNull() ?: 0.0 + val baseAlt = if (initialRoute == PathData.RURAL_PATH) 45.0 else 50.0 + val staticVertices = PathEngine.buildStaticVertices( + path = initialRoute, + altitudeMode = AltitudeMode.CLAMP_TO_GROUND, + baseAltitude = baseAlt, + pathAltitudeOffset = 0.5 + ) + + val point = PathEngine.interpolatePoint( + path = initialRoute, + cumulativeDistances = cumDist, + distance = 0.0 + ) + + val progressVertices = PathEngine.buildProgressVertices( + path = initialRoute, + cumulativeDistances = cumDist, + elapsedDistance = 0.0, + currentLatLng = point.latLng, + waypointIndex = point.waypointIndex, + altitudeMode = AltitudeMode.CLAMP_TO_GROUND, + baseAltitude = baseAlt, + pathAltitudeOffset = 0.5 + ) + + state = PathPlaybackState( + route = initialRoute, + cumulativeDistances = cumDist, + totalDistance = totalDist, + elapsedDistance = 0.0, + progressRatio = 0f, + isPlaying = false, + isScrubbing = false, + speedBoostMultiplier = 1.0, + currentPosition = point.latLng, + currentAltitude = point.altitude, + currentHeading = point.bearing, + cameraHeading = point.bearing, + staticPolylineVertices = staticVertices, + progressPolylineVertices = progressVertices + ) + } + + fun getState(): PathPlaybackState = state + + /** + * Advances playback by a specific time delta (in seconds). + * Takes speed boost (long-press 2x multiplier) into account. + */ + fun advance(deltaTimeSeconds: Double): PathPlaybackState { + if (!state.isPlaying || state.totalDistance <= 0.0) return state + + val stepDist = state.effectiveSpeedMps * deltaTimeSeconds + var newDist = state.elapsedDistance + stepDist + if (newDist >= state.totalDistance) { + newDist %= state.totalDistance + } else if (newDist < 0.0) { + newDist = (newDist % state.totalDistance + state.totalDistance) % state.totalDistance + } + + return updateDistanceAndRecompute(newDistance = newDist, updateProgressRatio = !state.isScrubbing) + } + + /** + * Seeks to a specific normalized progress ratio [0.0, 1.0]. + */ + fun seekToRatio(ratio: Float): PathPlaybackState { + val clampedRatio = ratio.coerceIn(0f, 1f) + val targetDist = state.totalDistance * clampedRatio.toDouble() + state = state.copy(progressRatio = clampedRatio) + return updateDistanceAndRecompute(newDistance = targetDist, updateProgressRatio = false) + } + + /** + * Seeks to a specific distance along the route in meters. + */ + fun skipDistance(deltaMeters: Double): PathPlaybackState { + if (state.totalDistance <= 0.0) return state + var newDist = state.elapsedDistance + deltaMeters + newDist = (newDist % state.totalDistance + state.totalDistance) % state.totalDistance + return updateDistanceAndRecompute(newDistance = newDist, updateProgressRatio = true) + } + + fun skipRatio(deltaRatio: Float): PathPlaybackState { + return skipDistance(state.totalDistance * deltaRatio.toDouble()) + } + + fun seekToDistance(distanceMeters: Double): PathPlaybackState { + val targetDist = distanceMeters.coerceIn(0.0, state.totalDistance) + val ratio = if (state.totalDistance > 0.0) (targetDist / state.totalDistance).toFloat().coerceIn(0f, 1f) else 0f + state = state.copy(progressRatio = ratio) + return updateDistanceAndRecompute(newDistance = targetDist, updateProgressRatio = false) + } + + fun setScrubbing(isScrubbing: Boolean): PathPlaybackState { + state = state.copy(isScrubbing = isScrubbing) + return state + } + + fun setPlaying(isPlaying: Boolean): PathPlaybackState { + state = state.copy(isPlaying = isPlaying) + return state + } + + fun togglePlayPause(): PathPlaybackState { + state = state.copy(isPlaying = !state.isPlaying) + return state + } + + fun setRoute(newRoute: List, applyDefaults: Boolean = true): PathPlaybackState { + val cumDist = PathEngine.calculateCumulativeDistances(newRoute) + val totalDist = cumDist.lastOrNull() ?: 0.0 + val isRural = newRoute == PathData.RURAL_PATH + + val range = if (applyDefaults) (if (isRural) 450.0 else 300.0) else state.cameraRange + val groundAlt = if (applyDefaults) (if (isRural) 40.0 else 20.0) else state.groundAltitude + val tilt = if (applyDefaults) (if (isRural) 75.0 else 70.0) else state.cameraTilt + val baseAlt = if (isRural) 45.0 else 50.0 + + val point = PathEngine.interpolatePoint( + path = newRoute, + cumulativeDistances = cumDist, + distance = 0.0 + ) + + val staticVertices = PathEngine.buildStaticVertices( + path = newRoute, + altitudeMode = state.altitudeMode, + baseAltitude = baseAlt, + pathAltitudeOffset = state.pathAltitudeOffset + ) + + val progressVertices = PathEngine.buildProgressVertices( + path = newRoute, + cumulativeDistances = cumDist, + elapsedDistance = 0.0, + currentLatLng = point.latLng, + waypointIndex = point.waypointIndex, + altitudeMode = state.altitudeMode, + baseAltitude = baseAlt, + pathAltitudeOffset = state.pathAltitudeOffset + ) + + state = state.copy( + route = newRoute, + cumulativeDistances = cumDist, + totalDistance = totalDist, + elapsedDistance = 0.0, + progressRatio = 0f, + isPlaying = false, + cameraRange = range, + groundAltitude = groundAlt, + cameraTilt = tilt, + currentPosition = point.latLng, + currentAltitude = point.altitude, + currentHeading = point.bearing, + cameraHeading = (point.bearing + state.headingOffset + 360.0) % 360.0, + staticPolylineVertices = staticVertices, + progressPolylineVertices = progressVertices + ) + return state + } + + fun setAltitudeMode(mode: Int): PathPlaybackState { + state = state.copy(altitudeMode = mode) + return recomputeVerticesAndAltitude() + } + + fun setDrawsOccludedSegments(drawsOccluded: Boolean): PathPlaybackState { + state = state.copy(drawsOccludedSegments = drawsOccluded) + return state + } + + fun setPathAltitudeOffset(offset: Double): PathPlaybackState { + state = state.copy(pathAltitudeOffset = offset) + return recomputeVerticesAndAltitude() + } + + fun setCameraRange(range: Double): PathPlaybackState { + state = state.copy(cameraRange = range.coerceIn(20.0, 5000.0)) + return state + } + + fun setGroundAltitude(altitude: Double): PathPlaybackState { + state = state.copy(groundAltitude = altitude.coerceIn(0.0, 500.0)) + return state + } + + fun setHeadingOffset(offset: Double): PathPlaybackState { + var normalizedOffset = offset % 360.0 + if (normalizedOffset > 180.0) normalizedOffset -= 360.0 + if (normalizedOffset < -180.0) normalizedOffset += 360.0 + + val targetCamHeading = (state.currentHeading + normalizedOffset + 360.0) % 360.0 + state = state.copy( + headingOffset = normalizedOffset, + cameraHeading = targetCamHeading + ) + return state + } + + fun setCameraTilt(tilt: Double): PathPlaybackState { + state = state.copy(cameraTilt = tilt.coerceIn(0.0, 85.0)) + return state + } + + /** + * Adjusts the camera tilt by delta degrees (e.g. from vertical gesture sweep). + * Constrained between 0° (top-down) and 85° (horizon). + */ + fun adjustTilt(deltaDeg: Double): PathPlaybackState { + return setCameraTilt(state.cameraTilt + deltaDeg) + } + + /** + * Adjusts the heading offset by delta degrees (e.g. from horizontal gesture sweep). + */ + fun adjustHeading(deltaDeg: Double): PathPlaybackState { + return setHeadingOffset(state.headingOffset + deltaDeg) + } + + /** + * Adjusts camera range via pinch scaling. + * scaleFactor > 1.0 zooms in (decreases range), scaleFactor < 1.0 zooms out (increases range). + */ + fun adjustRange(scaleFactor: Double): PathPlaybackState { + if (scaleFactor <= 0.0) return state + return setCameraRange(state.cameraRange / scaleFactor) + } + + /** + * Sets long-press speed boost (2x multiplier). + */ + fun setSpeedBoostMultiplier(multiplier: Double): PathPlaybackState { + state = state.copy(speedBoostMultiplier = multiplier) + return state + } + + fun setSpeedBoosted(isBoosted: Boolean): PathPlaybackState { + return setSpeedBoostMultiplier(if (isBoosted) 2.0 else 1.0) + } + + fun setFollowSpeed(speedMps: Double): PathPlaybackState { + state = state.copy(followSpeedMps = speedMps) + return state + } + + fun reset(): PathPlaybackState { + return seekToDistance(0.0).copy(isPlaying = false, speedBoostMultiplier = 1.0) + } + + private fun updateDistanceAndRecompute(newDistance: Double, updateProgressRatio: Boolean): PathPlaybackState { + val point = PathEngine.interpolatePoint( + path = state.route, + cumulativeDistances = state.cumulativeDistances, + distance = newDistance + ) + + val targetCameraHeading = (point.bearing + state.headingOffset + 360.0) % 360.0 + val smoothedCameraHeading = PathEngine.smoothHeading( + targetHeading = targetCameraHeading, + currentHeading = state.cameraHeading, + isUserScrubbing = state.isScrubbing, + isPlaying = state.isPlaying + ) + + val progressVertices = PathEngine.buildProgressVertices( + path = state.route, + cumulativeDistances = state.cumulativeDistances, + elapsedDistance = newDistance, + currentLatLng = point.latLng, + waypointIndex = point.waypointIndex, + altitudeMode = state.altitudeMode, + baseAltitude = state.baseAltitude, + pathAltitudeOffset = state.pathAltitudeOffset + ) + + val ratio = if (updateProgressRatio && state.totalDistance > 0.0) { + (newDistance / state.totalDistance).toFloat().coerceIn(0f, 1f) + } else { + state.progressRatio + } + + state = state.copy( + elapsedDistance = newDistance, + progressRatio = ratio, + currentPosition = point.latLng, + currentAltitude = point.altitude, + currentHeading = point.bearing, + cameraHeading = smoothedCameraHeading, + progressPolylineVertices = progressVertices + ) + return state + } + + private fun recomputeVerticesAndAltitude(): PathPlaybackState { + val point = PathEngine.interpolatePoint( + path = state.route, + cumulativeDistances = state.cumulativeDistances, + distance = state.elapsedDistance + ) + + val staticVertices = PathEngine.buildStaticVertices( + path = state.route, + altitudeMode = state.altitudeMode, + baseAltitude = state.baseAltitude, + pathAltitudeOffset = state.pathAltitudeOffset + ) + + val progressVertices = PathEngine.buildProgressVertices( + path = state.route, + cumulativeDistances = state.cumulativeDistances, + elapsedDistance = state.elapsedDistance, + currentLatLng = point.latLng, + waypointIndex = point.waypointIndex, + altitudeMode = state.altitudeMode, + baseAltitude = state.baseAltitude, + pathAltitudeOffset = state.pathAltitudeOffset + ) + + state = state.copy( + staticPolylineVertices = staticVertices, + progressPolylineVertices = progressVertices + ) + return state + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathTouchHandler.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathTouchHandler.kt new file mode 100644 index 0000000..51d54a4 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathTouchHandler.kt @@ -0,0 +1,227 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.ScaleGestureDetector +import android.view.View +import android.view.ViewConfiguration +import kotlin.math.abs + +/** + * Custom Touch Gesture Handler for 3D Path Following demo. + * + * Features: + * 1. Vertical sweep up/down (1 finger): Smoothly adjusts camera tilt [0°, 85°]. + * 2. Horizontal sweep left/right (1 finger): Smoothly adjusts camera rotation / heading offset. + * 3. Pinch gesture (2 fingers): Damped camera distance/zoom adjustment. + * 4. Tiered Long-Press & Hold: + * - Hold >= 500ms: 2x Speed Boost + * - Hold >= 2000ms: 5x Warp Speed Boost + * 5. Double-Tap & Hold (YouTube-style Shuttle): + * - Double-tap & hold Right side: +5x Fast-Forward along route + * - Double-tap & hold Left side: -5x Rewind backwards along route + * - Quick double-tap: Skips ahead/back by 10% of the route. + */ +class PathTouchHandler( + context: Context, + private val viewModel: PathFollowingViewModel +) : View.OnTouchListener { + + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + private val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong() + private val initialBoostTimeout = ViewConfiguration.getLongPressTimeout().toLong() // ~500ms + private val warpBoostTimeout = 2000L // 2.0s + private val handler = Handler(Looper.getMainLooper()) + + private val headingSensitivity = 0.08 + private val tiltSensitivity = 0.06 + private val zoomDamping = 0.65 + + private var downX = 0f + private var downY = 0f + private var lastX = 0f + private var lastY = 0f + private var isDragging = false + private var isScaling = false + + // Double-tap & hold shuttle state + private var lastTapTime = 0L + private var lastTapX = 0f + private var isDoubleTapHold = false + private var wasPlayingBeforeShuttle = false + + private val initialBoostRunnable = Runnable { + if (!isDragging && !isScaling && !isDoubleTapHold) { + viewModel.setSpeedBoostMultiplier(2.0) + } + } + + private val warpBoostRunnable = Runnable { + if (!isDragging && !isScaling && !isDoubleTapHold) { + viewModel.setSpeedBoostMultiplier(5.0) + } + } + + private val scaleDetector = ScaleGestureDetector( + context, + object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { + isScaling = true + isDragging = false + cancelBoosts() + return true + } + + override fun onScale(detector: ScaleGestureDetector): Boolean { + val rawFactor = detector.scaleFactor.toDouble() + if (rawFactor > 0.5 && rawFactor < 2.0) { + val dampedFactor = 1.0 + (rawFactor - 1.0) * zoomDamping + viewModel.adjustRange(dampedFactor) + } + return true + } + + override fun onScaleEnd(detector: ScaleGestureDetector) { + isScaling = false + } + } + ) + + override fun onTouch(v: View, event: MotionEvent): Boolean { + scaleDetector.onTouchEvent(event) + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + val now = System.currentTimeMillis() + val isDoubleTapCandidate = (now - lastTapTime < doubleTapTimeout) && + (abs(event.x - lastTapX) < touchSlop * 4) + + downX = event.x + downY = event.y + lastX = event.x + lastY = event.y + isDragging = false + isScaling = false + + if (isDoubleTapCandidate) { + isDoubleTapHold = true + wasPlayingBeforeShuttle = viewModel.currentState.isPlaying + + val isRightSide = event.x > v.width / 2f + val multiplier = if (isRightSide) 5.0 else -5.0 + viewModel.setPlaying(true) + viewModel.setSpeedBoostMultiplier(multiplier) + } else { + isDoubleTapHold = false + lastTapTime = now + lastTapX = event.x + + handler.postDelayed(initialBoostRunnable, initialBoostTimeout) + handler.postDelayed(warpBoostRunnable, warpBoostTimeout) + } + } + + MotionEvent.ACTION_POINTER_DOWN -> { + cancelBoosts() + isDragging = false + } + + MotionEvent.ACTION_POINTER_UP -> { + cancelBoosts() + val remainingIndex = if (event.actionIndex == 0) 1 else 0 + if (remainingIndex < event.pointerCount) { + lastX = event.getX(remainingIndex) + lastY = event.getY(remainingIndex) + downX = lastX + downY = lastY + } + isDragging = false + } + + MotionEvent.ACTION_MOVE -> { + if (event.pointerCount == 1 && !isScaling && !scaleDetector.isInProgress) { + val dx = event.x - lastX + val dy = event.y - lastY + + val totalMove = abs(event.x - downX) + abs(event.y - downY) + if (!isDragging && totalMove > touchSlop) { + isDragging = true + cancelBoosts() + } + + if (isDragging) { + if (abs(dx) > 0.1f) { + viewModel.adjustHeading(dx * headingSensitivity) + } + if (abs(dy) > 0.1f) { + viewModel.adjustTilt(-dy * tiltSensitivity) + } + } + + lastX = event.x + lastY = event.y + } else if (event.pointerCount > 1) { + lastX = event.x + lastY = event.y + } + } + + MotionEvent.ACTION_UP -> { + val now = System.currentTimeMillis() + if (isDoubleTapHold) { + val holdDuration = now - lastTapTime + if (holdDuration < 300L) { + // Quick double-tap: Skip +/- 10% + val isRightSide = event.x > v.width / 2f + viewModel.skipRatio(if (isRightSide) 0.10f else -0.10f) + } + // Revert playing state if it wasn't playing before + if (!wasPlayingBeforeShuttle) { + viewModel.setPlaying(false) + } + isDoubleTapHold = false + } + cancelBoosts() + isDragging = false + isScaling = false + } + + MotionEvent.ACTION_CANCEL -> { + cancelBoosts() + if (isDoubleTapHold && !wasPlayingBeforeShuttle) { + viewModel.setPlaying(false) + } + isDoubleTapHold = false + isDragging = false + isScaling = false + } + } + + return true + } + + private fun cancelBoosts() { + handler.removeCallbacks(initialBoostRunnable) + handler.removeCallbacks(warpBoostRunnable) + viewModel.setSpeedBoostMultiplier(1.0) + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/drag_handle_bar.xml b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/drag_handle_bar.xml new file mode 100644 index 0000000..f4b6103 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/drag_handle_bar.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/help_outline_24px.xml b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/help_outline_24px.xml new file mode 100644 index 0000000..dc4cbcc --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/help_outline_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_path_following.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_path_following.xml index 3d5da95..358a5f9 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_path_following.xml +++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_path_following.xml @@ -28,6 +28,15 @@ android:layout_height="match_parent" /> + + + @@ -48,16 +57,31 @@ > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + @@ -259,8 +378,8 @@ android:id="@+id/altitude_slider" android:layout_width="match_parent" android:layout_height="wrap_content" - android:valueFrom="5.0" - android:valueTo="200.0" + android:valueFrom="0.0" + android:valueTo="500.0" android:value="20.0" /> diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml index 29ec21d..3d61f5c 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml +++ b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml @@ -141,6 +141,11 @@ San Francisco Aerial Tour + Select Animation Approach: + 1. SDK Simple flyTo (Native Transition) + 2. Declarative Keyframe Queue Tour + 3. High-Rate Frame Dispatcher Loop + 4. 360-Degree Continuous Orbit Spin Press Play to begin the multi-step aerial tour. Step %1$d of %2$d: %3$s Tour complete. Press Reset to replay. @@ -182,6 +187,7 @@ Camera Tilt: 70° Follow Speed: 30 m/s Play or pause animation + Draw Occluded Segments Flood Simulation Controls @@ -206,4 +212,7 @@ 45° Standard 90° Wide 120° Ultra + 3D Path Following Controls + 🧭 Camera Gestures:\n• Vertical Sweep (1 Finger): Adjusts camera tilt (0° to 85°)\n• Horizontal Sweep (1 Finger): Rotates camera heading orbit\n• Pinch (2 Fingers): Zooms camera in / out\n\n⚡ Speed & Navigation:\n• Long Press & Hold: 2x Boost (at 0.5s) → 5x Warp Speed (at 2s)\n• Double-Tap & Hold Right: +5x Fast-Forward\n• Double-Tap & Hold Left: -5x Rewind\n• Quick Double-Tap: Skip +/- 10% along path\n• Speed Chips (0.5x - 5x): Instant preset selection\n\n🎛️ Control Panel:\n• Swipe Up / Down or Tap Header: Expand / collapse settings panel\n• Idle Auto-Fade: Panel fades to transparent after 3.5s of inactivity + Got It diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathPlaybackControllerTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathPlaybackControllerTest.kt new file mode 100644 index 0000000..1e0fe22 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathPlaybackControllerTest.kt @@ -0,0 +1,224 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.LatLngAltitude +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * JVM Unit Tests for [PathPlaybackController]. + * + * Verifies domain logic, kinematic progression, altitude computations, polyline slicing, + * custom touch gesture adjustments (tilt, heading, pinch range, speed boost), and state transitions. + */ +class PathPlaybackControllerTest { + + private lateinit var controller: PathPlaybackController + private val testPath = listOf( + LatLngAltitude(37.7749, -122.4194, 10.0), + LatLngAltitude(37.7755, -122.4180, 20.0), + LatLngAltitude(37.7760, -122.4170, 30.0) + ) + + @Before + fun setup() { + controller = PathPlaybackController(testPath) + } + + @Test + fun initialState_isConfiguredCorrectly() { + val state = controller.getState() + assertEquals(testPath, state.route) + assertEquals(0.0, state.elapsedDistance, 0.001) + assertEquals(0f, state.progressRatio, 0.001f) + assertFalse(state.isPlaying) + assertFalse(state.isScrubbing) + assertFalse(state.isSpeedBoosted) + assertEquals(testPath.first().latitude, state.currentPosition.latitude, 0.0001) + assertEquals(testPath.first().longitude, state.currentPosition.longitude, 0.0001) + assertEquals(testPath.size, state.staticPolylineVertices.size) + assertTrue(state.totalDistance > 0.0) + } + + @Test + fun togglePlayPause_updatesState() { + assertFalse(controller.getState().isPlaying) + val playingState = controller.togglePlayPause() + assertTrue(playingState.isPlaying) + val pausedState = controller.togglePlayPause() + assertFalse(pausedState.isPlaying) + } + + @Test + fun advance_progressesDistanceOnlyWhenPlaying() { + // When not playing, advance should have no effect + val initialDist = controller.getState().elapsedDistance + controller.advance(1.0) + assertEquals(initialDist, controller.getState().elapsedDistance, 0.001) + + // When playing, advance increases elapsed distance based on speed * dt + controller.setPlaying(true) + val speed = controller.getState().followSpeedMps + controller.advance(1.0) + val advancedDist = controller.getState().elapsedDistance + assertEquals(speed * 1.0, advancedDist, 0.1) + assertTrue(controller.getState().progressRatio > 0f) + } + + @Test + fun advance_loopsAroundTotalDistance() { + controller.setPlaying(true) + val totalDist = controller.getState().totalDistance + controller.seekToDistance(totalDist - 5.0) + + // Advancing beyond total distance wraps around + controller.advance(1.0) // 30m step + assertTrue(controller.getState().elapsedDistance < totalDist) + } + + @Test + fun speedBoost_supportsTieredMultipliers() { + controller.setPlaying(true) + controller.seekToDistance(0.0) + val speed = controller.getState().followSpeedMps + + // 2x Boost + controller.setSpeedBoostMultiplier(2.0) + assertTrue(controller.getState().isSpeedBoosted) + assertEquals(2.0, controller.getState().speedBoostMultiplier, 0.001) + assertEquals(speed * 2.0, controller.getState().effectiveSpeedMps, 0.001) + + // 5x Warp Boost + controller.setSpeedBoostMultiplier(5.0) + assertTrue(controller.getState().isSpeedBoosted) + assertEquals(5.0, controller.getState().speedBoostMultiplier, 0.001) + assertEquals(speed * 5.0, controller.getState().effectiveSpeedMps, 0.001) + + controller.advance(1.0) + assertEquals(speed * 5.0, controller.getState().elapsedDistance, 0.1) + + // Reset + controller.setSpeedBoostMultiplier(1.0) + assertFalse(controller.getState().isSpeedBoosted) + assertEquals(speed, controller.getState().effectiveSpeedMps, 0.001) + } + + @Test + fun adjustTilt_modifiesCameraTiltWithinBounds() { + controller.setCameraTilt(60.0) + controller.adjustTilt(10.0) + assertEquals(70.0, controller.getState().cameraTilt, 0.001) + + controller.adjustTilt(-80.0) + assertEquals(0.0, controller.getState().cameraTilt, 0.001) // Clamped to 0.0 + + controller.adjustTilt(100.0) + assertEquals(85.0, controller.getState().cameraTilt, 0.001) // Clamped to 85.0 + } + + @Test + fun adjustHeading_modifiesHeadingOffset() { + controller.setHeadingOffset(0.0) + controller.adjustHeading(45.0) + assertEquals(45.0, controller.getState().headingOffset, 0.001) + + controller.adjustHeading(150.0) // 195 -> -165 normalized + assertEquals(-165.0, controller.getState().headingOffset, 0.001) + } + + @Test + fun adjustRange_modifiesCameraRange() { + controller.setCameraRange(300.0) + controller.adjustRange(2.0) // 2x zoom in -> range 150m + assertEquals(150.0, controller.getState().cameraRange, 0.001) + + controller.adjustRange(0.5) // zoom out -> range 300m + assertEquals(300.0, controller.getState().cameraRange, 0.001) + } + + @Test + fun seekToRatio_updatesPositionAndPolylines() { + val newState = controller.seekToRatio(0.5f) + assertEquals(0.5f, newState.progressRatio, 0.01f) + assertEquals(controller.getState().totalDistance * 0.5, newState.elapsedDistance, 0.5) + assertTrue(newState.progressPolylineVertices.size >= 2) + } + + @Test + fun setAltitudeMode_recomputesPolylineAltitudes() { + controller.setAltitudeMode(AltitudeMode.CLAMP_TO_GROUND) + val clampVertices = controller.getState().staticPolylineVertices + assertTrue(clampVertices.all { it.altitude == 0.0 }) + + controller.setAltitudeMode(AltitudeMode.ABSOLUTE) + val absVertices = controller.getState().staticPolylineVertices + assertTrue(absVertices.any { it.altitude > 0.0 }) + } + + @Test + fun setRoute_resetsProgressAndAppliesDefaults() { + controller.setPlaying(true) + controller.seekToRatio(0.8f) + + val newRoute = PathData.RURAL_PATH + val state = controller.setRoute(newRoute, applyDefaults = true) + + assertEquals(newRoute, state.route) + assertEquals(0.0, state.elapsedDistance, 0.001) + assertEquals(0f, state.progressRatio, 0.001f) + assertFalse(state.isPlaying) + assertEquals(450.0, state.cameraRange, 0.001) + assertEquals(75.0, state.cameraTilt, 0.001) + } + + @Test + fun rewind_advancesBackwardsAlongPath() { + controller.setPlaying(true) + val totalDist = controller.getState().totalDistance + controller.seekToDistance(totalDist * 0.5) + val initialDist = controller.getState().elapsedDistance + + // Set negative speed multiplier (-5x rewind) + controller.setSpeedBoostMultiplier(-5.0) + val speed = controller.getState().followSpeedMps + assertEquals(-5.0 * speed, controller.getState().effectiveSpeedMps, 0.001) + + controller.advance(0.1) + val newDist = controller.getState().elapsedDistance + assertTrue(newDist < initialDist) + } + + @Test + fun skipDistance_and_skipRatio_jumpAlongPath() { + val totalDist = controller.getState().totalDistance + controller.seekToDistance(totalDist * 0.5) + + // Skip forward 10% + controller.skipRatio(0.10f) + assertEquals(totalDist * 0.60, controller.getState().elapsedDistance, 0.5) + + // Skip backward 20% + controller.skipRatio(-0.20f) + assertEquals(totalDist * 0.40, controller.getState().elapsedDistance, 0.5) + } +} \ No newline at end of file diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java index b1daec9..02f98b8 100644 --- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java +++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java @@ -16,21 +16,26 @@ package com.example.maps3djava.pathfollowing; -import static com.example.maps3d.common.UtilitiesKt.toHeading; - import android.graphics.Color; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.view.Choreographer; +import android.view.GestureDetector; import android.view.MotionEvent; +import android.view.View; import android.widget.RadioGroup; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.cardview.widget.CardView; -import androidx.core.view.WindowCompat; +import androidx.lifecycle.ViewModelProvider; +import com.example.maps3d.common.PathData; +import com.example.maps3d.common.PathEngine; +import com.example.maps3d.common.PathFollowingViewModel; +import com.example.maps3d.common.PathPlaybackState; +import com.example.maps3d.common.PathTouchHandler; import com.example.maps3dcommon.R; -import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps3d.GoogleMap3D; import com.google.android.gms.maps3d.Map3DView; import com.google.android.gms.maps3d.OnMap3DViewReadyCallback; @@ -40,625 +45,539 @@ import com.google.android.gms.maps3d.model.Polyline; import com.google.android.gms.maps3d.model.PolylineOptions; import com.google.android.material.button.MaterialButton; +import com.google.android.material.chip.ChipGroup; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.materialswitch.MaterialSwitch; import com.google.android.material.slider.Slider; -import com.google.maps.android.SphericalUtil; -import java.util.ArrayList; -import java.util.Arrays; import java.util.List; -import java.util.Locale; /** - * Advanced sample demonstrating ground-level path following in Java. + * Demonstrates 3D Path Following using an MVVM architecture with [PathFollowingViewModel]. * - * Features: - Urban vs Rural ground-level paths - Real-time camera controls via sliders: Range, - * Ground Altitude, Heading Offset, Tilt, Follow Speed - Smooth Handler-based frame animation along - * the route - Contracting and fading polyline playback visualization + * Decoupled gesture controls and dynamic progress polyline driven strictly by time and progress. */ public class PathFollowingActivity extends AppCompatActivity implements OnMap3DViewReadyCallback { - private Map3DView map3DView; - private GoogleMap3D googleMap3D; - - // Urban Path (New York City - Central Park Block Circuit) - public static final List URBAN_PATH = Arrays.asList( - new LatLng(40.7783119, -73.9627630), - new LatLng(40.7776355, -73.9611664), - new LatLng(40.7770011, -73.9616294), - new LatLng(40.7776743, -73.9632228), - new LatLng(40.7783119, -73.9627630) - ); - - // Rural Path - public static final List RURAL_PATH = Arrays.asList( - new LatLng(37.254529, -122.380897), - new LatLng(37.255065, -122.381627), - new LatLng(37.257540, -122.383720), - new LatLng(37.261200, -122.383950), - new LatLng(37.264780, -122.388210), - new LatLng(37.268520, -122.392450), - new LatLng(37.272110, -122.397640), - new LatLng(37.276430, -122.401120), - new LatLng(37.280850, -122.403560), - new LatLng(37.286018, -122.405072), - new LatLng(37.291040, -122.404210), - new LatLng(37.295800, -122.401980), - new LatLng(37.300120, -122.399540), - new LatLng(37.304550, -122.397210), - new LatLng(37.309200, -122.395100), - new LatLng(37.313450, -122.392840), - new LatLng(37.317200, -122.390510), - new LatLng(37.320850, -122.388740), - new LatLng(37.323540, -122.387600), - new LatLng(37.325269, -122.386728) - ); - - // View Bindings - private RadioGroup rgEnvironment; - private RadioGroup rgAltitudeMode; - private Slider pathAltitudeSlider; - private TextView pathAltitudeSliderLabel; - private MaterialButton btnPlayPause; - private Slider progressSlider; - private Slider rangeSlider; - private TextView rangeSliderLabel; - private Slider altitudeSlider; - private TextView altitudeSliderLabel; - private Slider headingSlider; - private TextView headingSliderLabel; - private Slider tiltSlider; - private TextView tiltSliderLabel; - private Slider speedSlider; - private TextView speedSliderLabel; - - // Control parameters - private double cameraRange = 300.0; - private double groundAltitude = 20.0; - private double headingOffset = 0.0; - private double cameraTilt = 70.0; - private double followSpeedMps = 30.0; - private @AltitudeMode int pathAltitudeMode = AltitudeMode.CLAMP_TO_GROUND; - private double pathAltitudeOffset = 0.5; - - // Path state - private List currentPath = URBAN_PATH; - private double[] cumulativeDistances = new double[0]; - private double totalDistance = 0.0; - private double elapsedDistance = 0.0; - private boolean isPlaying = false; - private boolean isUserScrubbing = false; - - // Polyline IDs - private static final String STATIC_ROUTE_POLYLINE_ID = "path_following_static_route"; - private static final String PROGRESS_POLYLINE_ID = "path_following_progress_route"; - - private Polyline staticRoutePolyline = null; - private Polyline progressPolyline = null; - private final Handler handler = new Handler(Looper.getMainLooper()); - private Runnable animationRunnable; - - private CardView controlsCard; - private android.view.View cardHeader; - private MaterialButton btnCollapse; - private boolean isCollapsed = false; - - private final Handler fadeHandler = new Handler(Looper.getMainLooper()); - private final Runnable fadeOutRunnable = () -> { - if (controlsCard != null && !isCollapsed) { - controlsCard.animate() - .alpha(0.8f) // Subtle fade when idle - .setDuration(400) - .start(); + private PathFollowingViewModel viewModel; + + // 3D Map View & Gesture Overlay + private Map3DView map3DView; + private View gestureOverlay; + private GoogleMap3D googleMap3D; + + // Polylines + private Polyline staticRoutePolyline; + private Polyline progressPolyline; + private List lastStaticVertices; + private double lastRenderedProgressDist = -1.0; + private long lastSliderUpdateMillis = 0L; + private Boolean lastIsPlaying; + + // Control panel overlay bindings + private CardView controlsCard; + private View cardHeader; + private MaterialButton btnHelp; + private MaterialButton btnCollapse; + private View controlsScroll; + private boolean isCollapsed = false; + private ChipGroup chipGroupSpeed; + + private RadioGroup rgEnvironment; + private RadioGroup rgAltitudeMode; + private MaterialSwitch switchDrawsOccludedSegments; + private Slider pathAltitudeSlider; + private TextView pathAltitudeSliderLabel; + private MaterialButton btnPlayPause; + private Slider progressSlider; + private Slider rangeSlider; + private TextView rangeSliderLabel; + private Slider altitudeSlider; + private TextView altitudeSliderLabel; + private Slider headingSlider; + private TextView headingSliderLabel; + private Slider tiltSlider; + private TextView tiltSliderLabel; + private Slider speedSlider; + private TextView speedSliderLabel; + + // Auto-fade & VSYNC Choreographer + private Choreographer.FrameCallback frameCallback; + private final Handler fadeHandler = new Handler(Looper.getMainLooper()); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_path_following); + + viewModel = new ViewModelProvider(this).get(PathFollowingViewModel.class); + + bindViews(); + setupCustomGestureHandling(); + setupControlListeners(); + setupTouchAutoFade() ; + observeViewModel(); + + map3DView.onCreate(savedInstanceState); + map3DView.getMap3DViewAsync(this); } - }; - private void collapseControls() { - if (controlsCard == null) { - return; - } - isCollapsed = true; - fadeHandler.removeCallbacks(fadeOutRunnable); - if (btnCollapse != null) { - btnCollapse.setIconResource(R.drawable.expand_less_24px); - btnCollapse.setContentDescription(getString(R.string.expand_controls)); - } - int headerHeight = (cardHeader != null && cardHeader.getHeight() > 0) - ? cardHeader.getHeight() - : (int) (48 * getResources().getDisplayMetrics().density); - float targetTranslationY = Math.max(0, controlsCard.getHeight() - headerHeight); - controlsCard.animate() - .translationY(targetTranslationY) - .alpha(0.9f) - .setDuration(300) - .start(); - } - - private void expandControls() { - if (controlsCard == null) { - return; - } - isCollapsed = false; - if (btnCollapse != null) { - btnCollapse.setIconResource(R.drawable.expand_more_24px); - btnCollapse.setContentDescription(getString(R.string.collapse_controls)); - } - controlsCard.animate() - .translationY(0f) - .alpha(1.0f) - .setDuration(250) - .start(); - fadeHandler.removeCallbacks(fadeOutRunnable); - fadeHandler.postDelayed(fadeOutRunnable, 3000L); - } - - @Override - public boolean dispatchTouchEvent(MotionEvent ev) { - if (ev.getAction() == MotionEvent.ACTION_DOWN || ev.getAction() == MotionEvent.ACTION_MOVE) { - if (controlsCard != null && !isCollapsed) { - controlsCard.animate() - .alpha(1.0f) - .setDuration(150) - .start(); - fadeHandler.removeCallbacks(fadeOutRunnable); - fadeHandler.postDelayed(fadeOutRunnable, 3000L); - } + @Override + public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) { + this.googleMap3D = googleMap3D; + + googleMap3D.setOnMapReadyListener( + initialTime -> { + runOnUiThread( + () -> { + lastStaticVertices = null; + lastRenderedProgressDist = -1.0; + PathPlaybackState state = viewModel.getCurrentState(); + updateStaticPolyline(state); + updateProgressPolyline(state); + updateCameraFromState(state); + renderUiControls(state); + }); + }); } - return super.dispatchTouchEvent(ev); - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - WindowCompat.setDecorFitsSystemWindows(getWindow(), false); - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_path_following); - - map3DView = findViewById(R.id.map3dView); - map3DView.onCreate(savedInstanceState); - map3DView.getMap3DViewAsync(this); - - initViews(); - loadPathData(URBAN_PATH); - } - - @Override - protected void onResume() { - super.onResume(); - map3DView.onResume(); - } - - @Override - protected void onPause() { - super.onPause(); - map3DView.onPause(); - pauseAnimation(); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - pauseAnimation(); - fadeHandler.removeCallbacks(fadeOutRunnable); - if (staticRoutePolyline != null) { - staticRoutePolyline.remove(); - staticRoutePolyline = null; + + private void setupCustomGestureHandling() { + if (gestureOverlay != null) { + gestureOverlay.setOnTouchListener(new PathTouchHandler(this, viewModel)); + } } - if (progressPolyline != null) { - progressPolyline.remove(); - progressPolyline = null; + + private void bindViews() { + map3DView = findViewById(R.id.map3dView); + gestureOverlay = findViewById(R.id.gesture_overlay); + controlsCard = findViewById(R.id.controls_card); + cardHeader = findViewById(R.id.card_header); + btnHelp = findViewById(R.id.btn_help); + chipGroupSpeed = findViewById(R.id.chip_group_speed); + btnCollapse = findViewById(R.id.btn_collapse); + controlsScroll = findViewById(R.id.controls_scroll); + rgEnvironment = findViewById(R.id.rg_environment); + rgAltitudeMode = findViewById(R.id.rg_altitude_mode); + switchDrawsOccludedSegments = findViewById(R.id.switch_draws_occluded_segments); + pathAltitudeSlider = findViewById(R.id.path_altitude_slider); + pathAltitudeSliderLabel = findViewById(R.id.path_altitude_slider_label); + btnPlayPause = findViewById(R.id.btn_play_pause); + progressSlider = findViewById(R.id.progress_slider); + rangeSlider = findViewById(R.id.range_slider); + rangeSliderLabel = findViewById(R.id.range_slider_label); + altitudeSlider = findViewById(R.id.altitude_slider); + altitudeSliderLabel = findViewById(R.id.altitude_slider_label); + headingSlider = findViewById(R.id.heading_slider); + headingSliderLabel = findViewById(R.id.heading_slider_label); + tiltSlider = findViewById(R.id.tilt_slider); + tiltSliderLabel = findViewById(R.id.tilt_slider_label); + speedSlider = findViewById(R.id.speed_slider); + speedSliderLabel = findViewById(R.id.speed_slider_label); } - map3DView.onDestroy(); - } - - @Override - public void onLowMemory() { - super.onLowMemory(); - map3DView.onLowMemory(); - } - - @Override - protected void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - map3DView.onSaveInstanceState(outState); - } - - private void initViews() { - controlsCard = findViewById(R.id.controls_card); - cardHeader = findViewById(R.id.card_header); - btnCollapse = findViewById(R.id.btn_collapse); - - if (btnCollapse != null) { - btnCollapse.setOnClickListener(v -> { - if (isCollapsed) { - expandControls(); - } else { - collapseControls(); + + private void setupControlListeners() { + if (btnHelp != null) { + btnHelp.setOnClickListener(v -> showHelpDialog()); } - }); - } - if (cardHeader != null) { - cardHeader.setOnClickListener(v -> { - if (isCollapsed) { - expandControls(); + btnPlayPause.setOnClickListener(v -> viewModel.togglePlayPause()); + + if (chipGroupSpeed != null) { + chipGroupSpeed.setOnCheckedStateChangeListener( + (group, checkedIds) -> { + if (checkedIds.isEmpty()) return; + int checkedId = checkedIds.get(0); + double targetSpeed = 30.0; + if (checkedId == R.id.chip_speed_05x) targetSpeed = 15.0; + else if (checkedId == R.id.chip_speed_1x) targetSpeed = 30.0; + else if (checkedId == R.id.chip_speed_2x) targetSpeed = 60.0; + else if (checkedId == R.id.chip_speed_3x) targetSpeed = 90.0; + else if (checkedId == R.id.chip_speed_5x) targetSpeed = 120.0; + + viewModel.setFollowSpeed(targetSpeed); + speedSlider.setValue((float) targetSpeed); + }); } - }); - } - fadeHandler.postDelayed(fadeOutRunnable, 3000L); - - rgEnvironment = findViewById(R.id.rg_environment); - rgAltitudeMode = findViewById(R.id.rg_altitude_mode); - pathAltitudeSlider = findViewById(R.id.path_altitude_slider); - pathAltitudeSliderLabel = findViewById(R.id.path_altitude_slider_label); - btnPlayPause = findViewById(R.id.btn_play_pause); - progressSlider = findViewById(R.id.progress_slider); - - rangeSlider = findViewById(R.id.range_slider); - rangeSliderLabel = findViewById(R.id.range_slider_label); - altitudeSlider = findViewById(R.id.altitude_slider); - altitudeSliderLabel = findViewById(R.id.altitude_slider_label); - headingSlider = findViewById(R.id.heading_slider); - headingSliderLabel = findViewById(R.id.heading_slider_label); - tiltSlider = findViewById(R.id.tilt_slider); - tiltSliderLabel = findViewById(R.id.tilt_slider_label); - speedSlider = findViewById(R.id.speed_slider); - speedSliderLabel = findViewById(R.id.speed_slider_label); - - updateControlLabels(); - - // Radio group environment selection - rgEnvironment.setOnCheckedChangeListener((group, checkedId) -> { - if (checkedId == R.id.rb_urban) { - altitudeSlider.setValueTo(200.0f); - switchEnvironment(URBAN_PATH); - } else if (checkedId == R.id.rb_rural) { - altitudeSlider.setValueTo(2000.0f); - switchEnvironment(RURAL_PATH); - } - }); - - // Radio group altitude mode selection - rgAltitudeMode.setOnCheckedChangeListener((group, checkedId) -> { - if (checkedId == R.id.rb_relative_to_ground) { - pathAltitudeMode = AltitudeMode.RELATIVE_TO_GROUND; - } else if (checkedId == R.id.rb_clamp_to_ground) { - pathAltitudeMode = AltitudeMode.CLAMP_TO_GROUND; - } else if (checkedId == R.id.rb_relative_to_mesh) { - pathAltitudeMode = AltitudeMode.RELATIVE_TO_MESH; - } else if (checkedId == R.id.rb_absolute) { - pathAltitudeMode = AltitudeMode.ABSOLUTE; - } - drawStaticRoutePolyline(); - updateCameraPositionForDistance(elapsedDistance); - }); - - // Path height slider (relative altitude) - pathAltitudeSlider.addOnChangeListener((slider, value, fromUser) -> { - pathAltitudeOffset = value; - pathAltitudeSliderLabel.setText( - getString(R.string.path_height_format, pathAltitudeOffset)); - drawStaticRoutePolyline(); - updateCameraPositionForDistance(elapsedDistance); - }); - - // Play/Pause button - btnPlayPause.setOnClickListener(v -> { - if (isPlaying) { - pauseAnimation(); - } else { - startAnimation(); - } - }); - - // Progress slider scrubbing - progressSlider.addOnChangeListener((slider, value, fromUser) -> { - if (fromUser) { - isUserScrubbing = true; - elapsedDistance = totalDistance * value; - updateCameraPositionForDistance(elapsedDistance); - } - }); - progressSlider.addOnSliderTouchListener(new Slider.OnSliderTouchListener() { - @Override - public void onStartTrackingTouch(@NonNull Slider slider) { - isUserScrubbing = true; - } - - @Override - public void onStopTrackingTouch(@NonNull Slider slider) { - isUserScrubbing = false; - } - }); - - // Sliders listeners - rangeSlider.addOnChangeListener((slider, value, fromUser) -> { - cameraRange = value; - rangeSliderLabel.setText(getString(R.string.camera_range_format, (int) cameraRange)); - updateCameraPositionForDistance(elapsedDistance); - }); - - altitudeSlider.addOnChangeListener((slider, value, fromUser) -> { - groundAltitude = value; - altitudeSliderLabel.setText(getString(R.string.ground_altitude_format, (int) groundAltitude)); - updateCameraPositionForDistance(elapsedDistance); - }); - - headingSlider.addOnChangeListener((slider, value, fromUser) -> { - headingOffset = value; - headingSliderLabel.setText(getString(R.string.heading_offset_format, (int) headingOffset)); - updateCameraPositionForDistance(elapsedDistance); - }); - - tiltSlider.addOnChangeListener((slider, value, fromUser) -> { - cameraTilt = value; - tiltSliderLabel.setText(getString(R.string.camera_tilt_format, (int) cameraTilt)); - updateCameraPositionForDistance(elapsedDistance); - }); - - speedSlider.addOnChangeListener((slider, value, fromUser) -> { - followSpeedMps = value; - speedSliderLabel.setText(getString(R.string.follow_speed_format, (int) followSpeedMps)); - }); - } - - private void updateControlLabels() { - pathAltitudeSliderLabel.setText( - getString(R.string.path_height_format, pathAltitudeOffset)); - rangeSliderLabel.setText(getString(R.string.camera_range_format, (int) cameraRange)); - altitudeSliderLabel.setText( - getString(R.string.ground_altitude_format, (int) groundAltitude)); - headingSliderLabel.setText( - getString(R.string.heading_offset_format, (int) headingOffset)); - tiltSliderLabel.setText(getString(R.string.camera_tilt_format, (int) cameraTilt)); - speedSliderLabel.setText( - getString(R.string.follow_speed_format, (int) followSpeedMps)); - } - - @Override - public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) { - this.googleMap3D = googleMap3D; - googleMap3D.setOnMapReadyListener((map) -> { - googleMap3D.setOnMapReadyListener(null); - runOnUiThread(() -> { - drawPathPolylines(); - updateCameraPositionForDistance(0.0); - startAnimation(); - }); - }); - } - - private Double currentHeading = null; - - private void switchEnvironment(List path) { - pauseAnimation(); - currentHeading = null; - elapsedDistance = 0.0; - progressSlider.setValue(0f); - - if (path.equals(RURAL_PATH)) { - cameraRange = 450.0; - groundAltitude = 40.0; - cameraTilt = 75.0; - rangeSlider.setValue(450f); - altitudeSlider.setValue(40f); - tiltSlider.setValue(75f); - rangeSliderLabel.setText(getString(R.string.camera_range_format, 450)); - altitudeSliderLabel.setText(getString(R.string.ground_altitude_format, 40)); - tiltSliderLabel.setText(getString(R.string.camera_tilt_format, 75)); - } else { - cameraRange = 300.0; - groundAltitude = 20.0; - cameraTilt = 70.0; - rangeSlider.setValue(300f); - altitudeSlider.setValue(20f); - tiltSlider.setValue(70f); - rangeSliderLabel.setText(getString(R.string.camera_range_format, 300)); - altitudeSliderLabel.setText(getString(R.string.ground_altitude_format, 20)); - tiltSliderLabel.setText(getString(R.string.camera_tilt_format, 70)); - } + if (btnCollapse != null) { + btnCollapse.setOnClickListener(v -> setPanelCollapsed(!isCollapsed)); + } - loadPathData(path); - drawPathPolylines(); - updateCameraPositionForDistance(0.0); - } - - private void loadPathData(List path) { - currentPath = path; - cumulativeDistances = new double[path.size()]; - totalDistance = 0.0; - cumulativeDistances[0] = 0.0; - for (int i = 1; i < path.size(); i++) { - double dist = SphericalUtil.computeDistanceBetween(path.get(i - 1), path.get(i)); - totalDistance += dist; - cumulativeDistances[i] = totalDistance; - } - } + if (cardHeader != null) { + cardHeader.setOnClickListener(v -> setPanelCollapsed(!isCollapsed)); + } - private void drawPathPolylines() { - drawStaticRoutePolyline(); - if (!currentPath.isEmpty()) { - updateProgressPolyline(elapsedDistance, currentPath.get(0), 0); - } - } + GestureDetector cardSwipeDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + if (e1 == null) return false; + float dy = e2.getY() - e1.getY(); + if (dy > 50 && velocityY > 100) { + setPanelCollapsed(true); + return true; + } else if (dy < -50 && velocityY < -100) { + setPanelCollapsed(false); + return true; + } + return false; + } + }); + + if (cardHeader != null) { + cardHeader.setOnTouchListener((v, event) -> cardSwipeDetector.onTouchEvent(event) || v.onTouchEvent(event)); + } - private void drawStaticRoutePolyline() { - if (googleMap3D == null || currentPath.isEmpty()) { - return; - } + if (controlsCard != null) { + controlsCard.setOnTouchListener((v, event) -> cardSwipeDetector.onTouchEvent(event)); + } - double pathAltitude = (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) - ? 0.0 - : pathAltitudeOffset; - if (pathAltitudeMode == AltitudeMode.ABSOLUTE) { - pathAltitude = (currentPath.equals(RURAL_PATH)) ? 40.0 : 15.0; + progressSlider.addOnChangeListener( + (slider, value, fromUser) -> { + if (fromUser) { + viewModel.seekToRatio(value); + PathPlaybackState state = viewModel.getCurrentState(); + updateCameraFromState(state); + updateProgressPolyline(state); + } + }); + + progressSlider.addOnSliderTouchListener( + new Slider.OnSliderTouchListener() { + @Override + public void onStartTrackingTouch(@NonNull Slider slider) { + viewModel.setScrubbing(true); + } + + @Override + public void onStopTrackingTouch(@NonNull Slider slider) { + viewModel.setScrubbing(false); + viewModel.seekToRatio(slider.getValue()); + PathPlaybackState state = viewModel.getCurrentState(); + updateCameraFromState(state); + updateProgressPolyline(state); + } + }); + + rgAltitudeMode.setOnCheckedChangeListener( + (group, checkedId) -> { + int mode = AltitudeMode.CLAMP_TO_GROUND; + if (checkedId == R.id.rb_relative_to_ground) { + mode = AltitudeMode.RELATIVE_TO_GROUND; + } else if (checkedId == R.id.rb_relative_to_mesh) { + mode = AltitudeMode.RELATIVE_TO_MESH; + } else if (checkedId == R.id.rb_absolute) { + mode = AltitudeMode.ABSOLUTE; + } + viewModel.setAltitudeMode(mode); + resetPolylines(); + }); + + switchDrawsOccludedSegments.setOnCheckedChangeListener( + (buttonView, isChecked) -> { + viewModel.setDrawsOccludedSegments(isChecked); + resetPolylines(); + }); + + pathAltitudeSlider.addOnChangeListener( + (slider, value, fromUser) -> { + if (fromUser) { + viewModel.setPathAltitudeOffset(value); + resetPolylines(); + } + pathAltitudeSliderLabel.setText(getString(R.string.path_height_format, value)); + }); + + rangeSlider.addOnChangeListener( + (slider, value, fromUser) -> { + if (fromUser) { + viewModel.setCameraRange(value); + updateCameraFromState(viewModel.getCurrentState()); + } + rangeSliderLabel.setText( + getString(R.string.camera_range_format, (int) value)); + }); + + altitudeSlider.addOnChangeListener( + (slider, value, fromUser) -> { + if (fromUser) { + viewModel.setGroundAltitude(value); + updateCameraFromState(viewModel.getCurrentState()); + } + altitudeSliderLabel.setText( + getString(R.string.ground_altitude_format, (int) value)); + }); + + headingSlider.addOnChangeListener( + (slider, value, fromUser) -> { + if (fromUser) { + viewModel.setHeadingOffset(value); + updateCameraFromState(viewModel.getCurrentState()); + } + headingSliderLabel.setText( + getString(R.string.heading_offset_format, (int) value)); + }); + + tiltSlider.addOnChangeListener( + (slider, value, fromUser) -> { + if (fromUser) { + viewModel.setCameraTilt(value); + updateCameraFromState(viewModel.getCurrentState()); + } + tiltSliderLabel.setText( + getString(R.string.camera_tilt_format, (int) value)); + }); + + speedSlider.addOnChangeListener( + (slider, value, fromUser) -> { + if (fromUser) viewModel.setFollowSpeed(value); + speedSliderLabel.setText(getString(R.string.follow_speed_format, (int) value)); + }); + + rgEnvironment.setOnCheckedChangeListener( + (group, checkedId) -> { + if (checkedId == R.id.rb_urban) { + viewModel.setRoute(PathData.URBAN_PATH, /* applyDefaults= */ true); + pathAltitudeSlider.setValueTo(20.0f); + altitudeSlider.setValueTo(500.0f); + } else if (checkedId == R.id.rb_rural) { + viewModel.setRoute(PathData.RURAL_PATH, /* applyDefaults= */ true); + pathAltitudeSlider.setValueTo(200.0f); + altitudeSlider.setValueTo(500.0f); + } + resetPolylines(); + }); } - List staticVertices = new ArrayList<>(); - for (LatLng latLng : currentPath) { - staticVertices.add(new LatLngAltitude(latLng.latitude, latLng.longitude, pathAltitude)); + private void setPanelCollapsed(boolean collapsed) { + if (isCollapsed == collapsed) return; + isCollapsed = collapsed; + if (controlsScroll != null) { + controlsScroll.setVisibility(isCollapsed ? View.GONE : View.VISIBLE); + } + if (btnCollapse != null) { + btnCollapse.setIconResource( + isCollapsed ? R.drawable.expand_less_24px : R.drawable.expand_more_24px); + } } - PolylineOptions staticOptions = new PolylineOptions(); - staticOptions.setId(STATIC_ROUTE_POLYLINE_ID); // Fixed ID prevents flickering and updates in place - staticOptions.setPath(staticVertices); - staticOptions.setStrokeColor(Color.parseColor("#4285F4")); // Route line: blue - staticOptions.setStrokeWidth(16.0); // Route line: wider - staticOptions.setZIndex(1); // Route line: lower z-index - staticOptions.setAltitudeMode(pathAltitudeMode); - - staticRoutePolyline = googleMap3D.addPolyline(staticOptions); - } - - private void updateProgressPolyline(double dist, LatLng currentLatLng, int index) { - if (googleMap3D == null || currentPath.isEmpty() || totalDistance <= 0.0) { - return; + private void showHelpDialog() { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.help_dialog_title) + .setMessage(R.string.help_dialog_message) + .setPositiveButton(R.string.help_dialog_ok, null) + .show(); } - double pathAltitude = (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) - ? 0.0 - : pathAltitudeOffset; - if (pathAltitudeMode == AltitudeMode.ABSOLUTE) { - pathAltitude = (currentPath.equals(RURAL_PATH)) ? 40.0 : 15.0; + private void resetPolylines() { + lastStaticVertices = null; + lastRenderedProgressDist = -1.0; + PathPlaybackState state = viewModel.getCurrentState(); + updateStaticPolyline(state); + updateProgressPolyline(state); + updateCameraFromState(state); } - double progressAltitude = pathAltitude + 0.2; + private void updateStaticPolyline(PathPlaybackState state) { + if (googleMap3D == null || state == null) return; + if (lastStaticVertices != null && lastStaticVertices.equals(state.getStaticPolylineVertices()) && staticRoutePolyline != null) return; + + lastStaticVertices = state.getStaticPolylineVertices(); + PolylineOptions staticOptions = new PolylineOptions(); + staticOptions.setId(PathEngine.STATIC_POLYLINE_ID); + staticOptions.setPath(state.getStaticPolylineVertices()); + staticOptions.setStrokeColor(Color.parseColor("#4285F4")); + staticOptions.setStrokeWidth(16.0); + staticOptions.setZIndex(1); + staticOptions.setAltitudeMode(state.getAltitudeMode()); + staticOptions.setDrawsOccludedSegments(state.getDrawsOccludedSegments()); + staticRoutePolyline = googleMap3D.addPolyline(staticOptions); + } - // Build traversed route from start to current position - List progressCoordinates = new ArrayList<>(); - for (int i = 0; i <= index && i < currentPath.size(); i++) { - LatLng pt = currentPath.get(i); - progressCoordinates.add(new LatLngAltitude(pt.latitude, pt.longitude, progressAltitude)); + private void updateProgressPolyline(PathPlaybackState state) { + if (googleMap3D == null || state == null || state.getProgressPolylineVertices().size() < 2) return; + + lastRenderedProgressDist = state.getElapsedDistance(); + PolylineOptions progressOptions = new PolylineOptions(); + progressOptions.setId(PathEngine.PROGRESS_POLYLINE_ID); + progressOptions.setPath(state.getProgressPolylineVertices()); + progressOptions.setStrokeColor(Color.parseColor("#9C27B0")); + progressOptions.setStrokeWidth(8.0); + progressOptions.setZIndex(2); + progressOptions.setAltitudeMode(state.getAltitudeMode()); + progressOptions.setDrawsOccludedSegments(state.getDrawsOccludedSegments()); + progressPolyline = googleMap3D.addPolyline(progressOptions); } - progressCoordinates.add( - new LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, progressAltitude)); - // Polyline requires at least 2 vertices - if (progressCoordinates.size() < 2) { - LatLng startPt = currentPath.get(0); - progressCoordinates.add(new LatLngAltitude(startPt.latitude, startPt.longitude, progressAltitude)); + private void observeViewModel() { + viewModel.getLiveData().observe(this, state -> { + updateCameraFromState(state); + if (state.isPlaying() || Math.abs(state.getElapsedDistance() - lastRenderedProgressDist) > 0.1) { + updateProgressPolyline(state); + } + renderUiControls(state); + manageAnimationTicker(state.isPlaying()); + }); } - // Dual-Polyline Rendering Technique: - // We render two layered polylines to clearly visualize completed vs. remaining route: - // 1. Base Static Polyline: A wider (#4285F4 blue, 16dp) static path at ZIndex=1. - // 2. Traversed Progress Polyline: A narrower (#9C27B0 purple, 8dp) line at ZIndex=2. - // We also apply a slight vertical altitude bias (+0.2m) to prevent 3D depth buffer z-fighting. - PolylineOptions progressOptions = new PolylineOptions(); - progressOptions.setId(PROGRESS_POLYLINE_ID); // Fixed ID upserts in place to eliminate flickering - progressOptions.setPath(progressCoordinates); - progressOptions.setStrokeColor(Color.parseColor("#9C27B0")); // Progress line: purple - progressOptions.setStrokeWidth(8.0); // Progress line: narrower - progressOptions.setZIndex(2); // Progress line: higher z-index on top of static route - progressOptions.setAltitudeMode(pathAltitudeMode); - - progressPolyline = googleMap3D.addPolyline(progressOptions); - } - - private void startAnimation() { - if (isPlaying) { - return; + private void updateCameraFromState(PathPlaybackState state) { + if (googleMap3D == null || state == null) return; + Camera newCamera = + new Camera( + /* center= */ new LatLngAltitude( + /* latitude= */ state.getCurrentPosition().latitude, + /* longitude= */ state.getCurrentPosition().longitude, + /* altitude= */ state.getCameraTargetAltitude()), + /* heading= */ state.getEffectiveHeading(), + /* tilt= */ state.getCameraTilt(), + /* roll= */ 0.0, + /* range= */ state.getCameraRange()); + googleMap3D.setCamera(newCamera); } - isPlaying = true; - btnPlayPause.setIconResource(R.drawable.pause_24px); - final long frameDurationMs = 16L; - animationRunnable = new Runnable() { - private long lastTime = System.currentTimeMillis(); + private void renderUiControls(PathPlaybackState state) { + if (state == null) return; - @Override - public void run() { - if (!isPlaying) { - return; + if (lastIsPlaying == null || lastIsPlaying != state.isPlaying()) { + lastIsPlaying = state.isPlaying(); + btnPlayPause.setIconResource( + state.isPlaying() ? R.drawable.pause_24px : R.drawable.play_arrow_24px); } - long now = System.currentTimeMillis(); - // Delta-time (dt) integration: Scale motion by actual elapsed seconds to ensure - // consistent travel speed across 60Hz, 90Hz, and 120Hz display refresh rates. - double dt = (now - lastTime) / 1000.0; - lastTime = now; + if (!state.isScrubbing()) { + long now = System.currentTimeMillis(); + if (now - lastSliderUpdateMillis >= 100L || !state.isPlaying()) { + lastSliderUpdateMillis = now; + progressSlider.setValue(state.getProgressRatio()); + } + } - double stepDistance = followSpeedMps * dt; - elapsedDistance += stepDistance; + String boostSuffix = ""; + if (state.getSpeedBoostMultiplier() >= 4.5) { + boostSuffix = " (5x Warp Speed)"; + } else if (state.getSpeedBoostMultiplier() >= 1.5) { + boostSuffix = " (2x Boost)"; + } + speedSliderLabel.setText(getString(R.string.follow_speed_format, (int) state.getFollowSpeedMps()) + boostSuffix); + + if (!isCollapsed) { + float clampedRange = Math.max(rangeSlider.getValueFrom(), Math.min(rangeSlider.getValueTo(), (float) state.getCameraRange())); + if (Math.abs(rangeSlider.getValue() - clampedRange) >= 1.0f) { + rangeSlider.setValue(clampedRange); + rangeSliderLabel.setText(getString(R.string.camera_range_format, (int) state.getCameraRange())); + } + + float clampedTilt = Math.max(tiltSlider.getValueFrom(), Math.min(tiltSlider.getValueTo(), (float) state.getCameraTilt())); + if (Math.abs(tiltSlider.getValue() - clampedTilt) >= 0.5f) { + tiltSlider.setValue(clampedTilt); + tiltSliderLabel.setText(getString(R.string.camera_tilt_format, (int) state.getCameraTilt())); + } + + float clampedHeading = Math.max(headingSlider.getValueFrom(), Math.min(headingSlider.getValueTo(), (float) state.getHeadingOffset())); + if (Math.abs(headingSlider.getValue() - clampedHeading) >= 0.5f) { + headingSlider.setValue(clampedHeading); + headingSliderLabel.setText(getString(R.string.heading_offset_format, (int) state.getHeadingOffset())); + } + } + } - if (elapsedDistance >= totalDistance) { - elapsedDistance = 0.0; + private void manageAnimationTicker(boolean isPlaying) { + if (isPlaying) { + if (frameCallback == null) { + frameCallback = + new Choreographer.FrameCallback() { + private long lastTimeNanos = 0L; + + @Override + public void doFrame(long frameTimeNanos) { + if (!viewModel.getCurrentState().isPlaying()) return; + + if (lastTimeNanos == 0L) { + lastTimeNanos = frameTimeNanos; + Choreographer.getInstance().postFrameCallback(this); + return; + } + + double dt = (frameTimeNanos - lastTimeNanos) / 1_000_000_000.0; + lastTimeNanos = frameTimeNanos; + + viewModel.advance(dt); + Choreographer.getInstance().postFrameCallback(this); + } + }; + Choreographer.getInstance().postFrameCallback(frameCallback); + } + } else { + if (frameCallback != null) { + Choreographer.getInstance().removeFrameCallback(frameCallback); + frameCallback = null; + } } + } + + private void setupTouchAutoFade() { + scheduleControlFade(); + } - if (!isUserScrubbing && totalDistance > 0) { - float progress = (float) (elapsedDistance / totalDistance); - if (progress >= 0f && progress <= 1f) { - progressSlider.setValue(progress); - } + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + if (ev.getAction() == MotionEvent.ACTION_DOWN || ev.getAction() == MotionEvent.ACTION_MOVE) { + fadeHandler.removeCallbacksAndMessages(null); + if (controlsCard != null) { + controlsCard.animate().alpha(1.0f).setDuration(150L).start(); + } + } else if (ev.getAction() == MotionEvent.ACTION_UP || ev.getAction() == MotionEvent.ACTION_CANCEL) { + scheduleControlFade(); } + return super.dispatchTouchEvent(ev); + } - updateCameraPositionForDistance(elapsedDistance); - handler.postDelayed(this, frameDurationMs); - } - }; + private void scheduleControlFade() { + fadeHandler.removeCallbacksAndMessages(null); + fadeHandler.postDelayed( + () -> { + if (controlsCard != null) { + controlsCard.animate().alpha(0.35f).setDuration(500L).start(); + } + }, + 3500L); + } - handler.post(animationRunnable); - } + @Override + protected void onResume() { + super.onResume(); + map3DView.onResume(); + } - private void pauseAnimation() { - isPlaying = false; - btnPlayPause.setIconResource(R.drawable.play_arrow_24px); - if (animationRunnable != null) { - handler.removeCallbacks(animationRunnable); - animationRunnable = null; + @Override + protected void onPause() { + super.onPause(); + viewModel.setPlaying(false); + viewModel.setSpeedBoosted(false); + map3DView.onPause(); } - } - private void updateCameraPositionForDistance(double dist) { - if (googleMap3D == null || currentPath.isEmpty()) { - return; + @Override + protected void onDestroy() { + super.onDestroy(); + viewModel.setPlaying(false); + if (frameCallback != null) { + Choreographer.getInstance().removeFrameCallback(frameCallback); + frameCallback = null; + } + fadeHandler.removeCallbacksAndMessages(null); + staticRoutePolyline = null; + progressPolyline = null; + map3DView.onDestroy(); } - int index = 0; - while (index < cumulativeDistances.length - 1 && cumulativeDistances[index + 1] < dist) { - index++; + @Override + public void onLowMemory() { + super.onLowMemory(); + map3DView.onLowMemory(); } - LatLng p1 = currentPath.get(index); - LatLng p2 = (index < currentPath.size() - 1) ? currentPath.get(index + 1) : p1; - - double segStartDist = cumulativeDistances[index]; - double segEndDist = - (index < cumulativeDistances.length - 1) ? cumulativeDistances[index + 1] : totalDistance; - double segLen = segEndDist - segStartDist; - - double fraction = - (segLen > 0) ? Math.max(0.0, Math.min(1.0, (dist - segStartDist) / segLen)) : 0.0; - LatLng currentLatLng = SphericalUtil.interpolate(p1, p2, fraction); - double bearing = SphericalUtil.computeHeading(p1, p2); - - // Kinematic Heading Smoothing (Exponential Moving Average): - // Sharp polyline bends can produce disorienting camera jerks. We compute the shortest - // angular difference wrapped to [-180, 180] and apply an exponential low-pass filter (12% lerp) - // to smoothly turn the camera around street corners and mountain switchbacks. - double targetHeadingRaw = toHeading(bearing + headingOffset); - double targetHeading; - if (currentHeading == null || isUserScrubbing || !isPlaying) { - targetHeading = targetHeadingRaw; - } else { - double diff = (targetHeadingRaw - currentHeading) % 360.0; - if (diff > 180.0) { - diff -= 360.0; - } - if (diff < -180.0) { - diff += 360.0; - } - targetHeading = toHeading(currentHeading + diff * 0.12); + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + map3DView.onSaveInstanceState(outState); } - currentHeading = targetHeading; - - Camera newCamera = new Camera( - new LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, groundAltitude), - targetHeading, - cameraTilt, - 0.0, - cameraRange - ); - - googleMap3D.setCamera(newCamera); - updateProgressPolyline(dist, currentLatLng, index); - } } diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt index 84b15f1..1cfde28 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt @@ -17,19 +17,27 @@ package com.example.maps3dkotlin.pathfollowing import android.os.Bundle -import androidx.core.graphics.toColorInt import android.os.Handler import android.os.Looper +import android.view.Choreographer +import android.view.GestureDetector import android.view.MotionEvent import android.view.View import android.widget.RadioGroup import android.widget.TextView +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.cardview.widget.CardView -import androidx.core.view.WindowCompat -import com.example.maps3d.common.toHeading +import androidx.core.graphics.toColorInt +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.example.maps3d.common.PathData +import com.example.maps3d.common.PathEngine +import com.example.maps3d.common.PathFollowingViewModel +import com.example.maps3d.common.PathPlaybackState +import com.example.maps3d.common.PathTouchHandler import com.example.maps3dcommon.R -import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps3d.GoogleMap3D import com.google.android.gms.maps3d.Map3DView import com.google.android.gms.maps3d.OnMap3DViewReadyCallback @@ -38,602 +46,534 @@ import com.google.android.gms.maps3d.model.Camera import com.google.android.gms.maps3d.model.LatLngAltitude import com.google.android.gms.maps3d.model.Polyline import com.google.android.gms.maps3d.model.PolylineOptions -import com.google.android.gms.maps3d.model.camera -import com.google.android.gms.maps3d.model.latLngAltitude import com.google.android.material.button.MaterialButton +import com.google.android.material.chip.ChipGroup +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.materialswitch.MaterialSwitch import com.google.android.material.slider.Slider -import com.google.maps.android.SphericalUtil +import kotlinx.coroutines.launch +import kotlin.math.abs /** - * Advanced sample demonstrating ground-level path following in Kotlin. + * Demonstrates 3D Path Following with decoupled gesture controls and dynamic progress polyline rendering. * - * Features: - * - Urban vs Rural ground-level paths - * - Two-polyline architecture: wide blue base route (lower z-index) + narrow purple active progress route (higher z-index) - * - In-place polyline ID updates eliminating render flickering - * - Configurable altitude modes (Clamp to Ground default, Relative to Ground, Relative to Mesh, Absolute) - * - Dynamic path elevation slider to eliminate z-fighting - * - Explicit collapse dialog button and smooth slide-down controls - * - Real-time camera controls via sliders: Range, Ground Altitude, Heading Offset, Tilt, Follow Speed + * - Progress polyline is driven strictly by time and distance along the route. + * - Touch gestures (tilt, rotation, zoom) only adjust camera pose without touching polyline pipelines. + * - Help button with instructions dialog. */ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { - private lateinit var map3DView: Map3DView - private var googleMap3D: GoogleMap3D? = null - - // View Bindings - private var controlsCard: CardView? = null - private var cardHeader: View? = null - private var btnCollapse: MaterialButton? = null - private var isCollapsed = false - - private lateinit var rgEnvironment: RadioGroup - private lateinit var rgAltitudeMode: RadioGroup - private lateinit var pathAltitudeSlider: Slider - private lateinit var pathAltitudeSliderLabel: TextView - private lateinit var btnPlayPause: MaterialButton - private lateinit var progressSlider: Slider - private lateinit var rangeSlider: Slider - private lateinit var rangeSliderLabel: TextView - private lateinit var altitudeSlider: Slider - private lateinit var altitudeSliderLabel: TextView - private lateinit var headingSlider: Slider - private lateinit var headingSliderLabel: TextView - private lateinit var tiltSlider: Slider - private lateinit var tiltSliderLabel: TextView - private lateinit var speedSlider: Slider - private lateinit var speedSliderLabel: TextView - - private val fadeHandler = Handler(Looper.getMainLooper()) - private val fadeOutRunnable = Runnable { - if (controlsCard != null && !isCollapsed) { - controlsCard?.animate() - ?.alpha(0.8f) - ?.setDuration(400) - ?.start() - } - } - - private fun collapseControls() { - val card = controlsCard ?: return - isCollapsed = true - fadeHandler.removeCallbacks(fadeOutRunnable) - btnCollapse?.setIconResource(R.drawable.expand_less_24px) - btnCollapse?.contentDescription = getString(R.string.expand_controls) - - val headerHeight = if (cardHeader != null && cardHeader!!.height > 0) { - cardHeader!!.height - } else { - (48 * resources.displayMetrics.density).toInt() - } - val targetTranslationY = (card.height - headerHeight).coerceAtLeast(0).toFloat() - card.animate() - .translationY(targetTranslationY) - .alpha(0.9f) - .setDuration(300) - .start() - } - - private fun expandControls() { - val card = controlsCard ?: return - isCollapsed = false - btnCollapse?.setIconResource(R.drawable.expand_more_24px) - btnCollapse?.contentDescription = getString(R.string.collapse_controls) - card.animate() - .translationY(0f) - .alpha(1.0f) - .setDuration(250) - .start() - fadeHandler.removeCallbacks(fadeOutRunnable) - fadeHandler.postDelayed(fadeOutRunnable, 3000L) - } - - override fun dispatchTouchEvent(ev: MotionEvent): Boolean { - if (ev.action == MotionEvent.ACTION_DOWN || ev.action == MotionEvent.ACTION_MOVE) { - if (controlsCard != null && !isCollapsed) { - controlsCard?.animate() - ?.alpha(1.0f) - ?.setDuration(150) - ?.start() - fadeHandler.removeCallbacks(fadeOutRunnable) - fadeHandler.postDelayed(fadeOutRunnable, 3000L) - } - } - return super.dispatchTouchEvent(ev) - } - - // Control parameters - private var cameraRange = 300.0 - private var groundAltitude = 20.0 - private var headingOffset = 0.0 - private var cameraTilt = 70.0 - private var followSpeedMps = 30.0 - private var pathAltitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND - private var pathAltitudeOffset: Double = 0.5 - - // Path state - private var currentPath: List = URBAN_PATH - private var cumulativeDistances: DoubleArray = doubleArrayOf() - private var totalDistance: Double = 0.0 - private var elapsedDistance: Double = 0.0 - private var isPlaying = false - private var isUserScrubbing = false - - // Polylines - private var staticRoutePolyline: Polyline? = null - private var progressPolyline: Polyline? = null - private val animationHandler = Handler(Looper.getMainLooper()) - private var animationRunnable: Runnable? = null - - override fun onCreate(savedInstanceState: Bundle?) { - WindowCompat.setDecorFitsSystemWindows(window, false) - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_path_following) - - map3DView = findViewById(R.id.map3dView) - map3DView.onCreate(savedInstanceState) - map3DView.getMap3DViewAsync(this) - - initViews() - loadPathData(URBAN_PATH) - } - - override fun onResume() { - super.onResume() - map3DView.onResume() - } - - override fun onPause() { - super.onPause() - map3DView.onPause() - pauseAnimation() - } - - override fun onDestroy() { - super.onDestroy() - pauseAnimation() - fadeHandler.removeCallbacks(fadeOutRunnable) - staticRoutePolyline?.remove() - staticRoutePolyline = null - progressPolyline?.remove() - progressPolyline = null - map3DView.onDestroy() - } - - override fun onLowMemory() { - super.onLowMemory() - map3DView.onLowMemory() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - map3DView.onSaveInstanceState(outState) - } - - override fun onMap3DViewReady(googleMap3D: GoogleMap3D) { - this.googleMap3D = googleMap3D - googleMap3D.setOnMapReadyListener { - googleMap3D.setOnMapReadyListener(null) - runOnUiThread { - drawPathPolylines() - updateCameraPositionForDistance(0.0) - startAnimation() - } - } - } - - private fun initViews() { - controlsCard = findViewById(R.id.controls_card) - cardHeader = findViewById(R.id.card_header) - btnCollapse = findViewById(R.id.btn_collapse) - - btnCollapse?.setOnClickListener { - if (isCollapsed) { - expandControls() - } else { - collapseControls() - } + private val viewModel: PathFollowingViewModel by viewModels() + + // 3D Map View & Gesture Overlay + private lateinit var map3DView: Map3DView + private lateinit var gestureOverlay: View + private var googleMap3D: GoogleMap3D? = null + + // Polylines + private var staticRoutePolyline: Polyline? = null + private var progressPolyline: Polyline? = null + private var lastStaticVertices: List? = null + private var lastRenderedProgressDist = -1.0 + private var lastSliderUpdateMillis = 0L + private var lastIsPlaying: Boolean? = null + + // Control panel overlay bindings + private var controlsCard: CardView? = null + private var cardHeader: View? = null + private var btnCollapse: MaterialButton? = null + private var btnHelp: MaterialButton? = null + private var controlsScroll: View? = null + private var isCollapsed = false + private var chipGroupSpeed: ChipGroup? = null + + private lateinit var rgEnvironment: RadioGroup + private lateinit var rgAltitudeMode: RadioGroup + private lateinit var switchDrawsOccludedSegments: MaterialSwitch + private lateinit var pathAltitudeSlider: Slider + private lateinit var pathAltitudeSliderLabel: TextView + private lateinit var btnPlayPause: MaterialButton + private lateinit var progressSlider: Slider + private lateinit var rangeSlider: Slider + private lateinit var rangeSliderLabel: TextView + private lateinit var altitudeSlider: Slider + private lateinit var altitudeSliderLabel: TextView + private lateinit var headingSlider: Slider + private lateinit var headingSliderLabel: TextView + private lateinit var tiltSlider: Slider + private lateinit var tiltSliderLabel: TextView + private lateinit var speedSlider: Slider + private lateinit var speedSliderLabel: TextView + + // Auto-fade & VSYNC Choreographer + private var frameCallback: Choreographer.FrameCallback? = null + private val fadeHandler = Handler(Looper.getMainLooper()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_path_following) + + bindViews() + setupCustomGestureHandling() + setupControlListeners() + setupTouchAutoFade() + observeViewModelState() + + map3DView.onCreate(savedInstanceState) + map3DView.getMap3DViewAsync(this) + } + + override fun onMap3DViewReady(googleMap3D: GoogleMap3D) { + this.googleMap3D = googleMap3D + + googleMap3D.setOnMapReadyListener { + runOnUiThread { + lastStaticVertices = null + lastRenderedProgressDist = -1.0 + val state = viewModel.currentState + updateStaticPolyline(state) + updateProgressPolyline(state) + updateCameraFromState(state) + renderUiControls(state) + } + } } - cardHeader?.setOnClickListener { - if (isCollapsed) { - expandControls() - } - } + private fun setupCustomGestureHandling() { + gestureOverlay.setOnTouchListener(PathTouchHandler(this, viewModel)) + } + + private fun bindViews() { + map3DView = findViewById(R.id.map3dView) + gestureOverlay = findViewById(R.id.gesture_overlay) + controlsCard = findViewById(R.id.controls_card) + cardHeader = findViewById(R.id.card_header) + btnHelp = findViewById(R.id.btn_help) + chipGroupSpeed = findViewById(R.id.chip_group_speed) + btnCollapse = findViewById(R.id.btn_collapse) + controlsScroll = findViewById(R.id.controls_scroll) + rgEnvironment = findViewById(R.id.rg_environment) + rgAltitudeMode = findViewById(R.id.rg_altitude_mode) + switchDrawsOccludedSegments = findViewById(R.id.switch_draws_occluded_segments) + pathAltitudeSlider = findViewById(R.id.path_altitude_slider) + pathAltitudeSliderLabel = findViewById(R.id.path_altitude_slider_label) + btnPlayPause = findViewById(R.id.btn_play_pause) + progressSlider = findViewById(R.id.progress_slider) + rangeSlider = findViewById(R.id.range_slider) + rangeSliderLabel = findViewById(R.id.range_slider_label) + altitudeSlider = findViewById(R.id.altitude_slider) + altitudeSliderLabel = findViewById(R.id.altitude_slider_label) + headingSlider = findViewById(R.id.heading_slider) + headingSliderLabel = findViewById(R.id.heading_slider_label) + tiltSlider = findViewById(R.id.tilt_slider) + tiltSliderLabel = findViewById(R.id.tilt_slider_label) + speedSlider = findViewById(R.id.speed_slider) + speedSliderLabel = findViewById(R.id.speed_slider_label) + } + + private fun setupControlListeners() { + btnHelp?.setOnClickListener { + showHelpDialog() + } - fadeHandler.postDelayed(fadeOutRunnable, 3000L) - - rgEnvironment = findViewById(R.id.rg_environment) - rgAltitudeMode = findViewById(R.id.rg_altitude_mode) - pathAltitudeSlider = findViewById(R.id.path_altitude_slider) - pathAltitudeSliderLabel = findViewById(R.id.path_altitude_slider_label) - btnPlayPause = findViewById(R.id.btn_play_pause) - progressSlider = findViewById(R.id.progress_slider) - - rangeSlider = findViewById(R.id.range_slider) - rangeSliderLabel = findViewById(R.id.range_slider_label) - altitudeSlider = findViewById(R.id.altitude_slider) - altitudeSliderLabel = findViewById(R.id.altitude_slider_label) - headingSlider = findViewById(R.id.heading_slider) - headingSliderLabel = findViewById(R.id.heading_slider_label) - tiltSlider = findViewById(R.id.tilt_slider) - tiltSliderLabel = findViewById(R.id.tilt_slider_label) - speedSlider = findViewById(R.id.speed_slider) - speedSliderLabel = findViewById(R.id.speed_slider_label) - - updateControlLabels() - - // Radio group environment selection - rgEnvironment.setOnCheckedChangeListener { _, checkedId -> - when (checkedId) { - R.id.rb_urban -> { - altitudeSlider.valueTo = 200.0f - switchEnvironment(URBAN_PATH) + btnPlayPause.setOnClickListener { + viewModel.togglePlayPause() } - R.id.rb_rural -> { - altitudeSlider.valueTo = 2000.0f - switchEnvironment(RURAL_PATH) + chipGroupSpeed?.setOnCheckedStateChangeListener { _, checkedIds -> + val checkedId = checkedIds.firstOrNull() ?: return@setOnCheckedStateChangeListener + val targetSpeed = when (checkedId) { + R.id.chip_speed_05x -> 15.0 + R.id.chip_speed_1x -> 30.0 + R.id.chip_speed_2x -> 60.0 + R.id.chip_speed_3x -> 90.0 + R.id.chip_speed_5x -> 120.0 + else -> 30.0 + } + viewModel.setFollowSpeed(targetSpeed) + speedSlider.value = targetSpeed.toFloat() } - } - } - // Radio group altitude mode selection - rgAltitudeMode.setOnCheckedChangeListener { _, checkedId -> - pathAltitudeMode = when (checkedId) { - R.id.rb_relative_to_ground -> AltitudeMode.RELATIVE_TO_GROUND - R.id.rb_relative_to_mesh -> AltitudeMode.RELATIVE_TO_MESH - R.id.rb_absolute -> AltitudeMode.ABSOLUTE - else -> AltitudeMode.CLAMP_TO_GROUND - } - drawStaticRoutePolyline() - updateCameraPositionForDistance(elapsedDistance) - } + fun setPanelCollapsed(collapsed: Boolean) { + if (isCollapsed == collapsed) return + isCollapsed = collapsed + controlsScroll?.visibility = if (isCollapsed) View.GONE else View.VISIBLE + btnCollapse?.setIconResource( + if (isCollapsed) R.drawable.expand_less_24px else R.drawable.expand_more_24px + ) + } - // Path height slider (relative altitude) - pathAltitudeSlider.addOnChangeListener { _, value, _ -> - pathAltitudeOffset = value.toDouble() - pathAltitudeSliderLabel.text = - getString(R.string.path_height_format, pathAltitudeOffset) - drawStaticRoutePolyline() - updateCameraPositionForDistance(elapsedDistance) - } + btnCollapse?.setOnClickListener { + setPanelCollapsed(!isCollapsed) + } - // Play/Pause button - btnPlayPause.setOnClickListener { - if (isPlaying) { - pauseAnimation() - } else { - startAnimation() - } - } + cardHeader?.setOnClickListener { + setPanelCollapsed(!isCollapsed) + } - // Progress slider scrubbing - progressSlider.addOnChangeListener { _, value, fromUser -> - if (fromUser) { - isUserScrubbing = true - elapsedDistance = totalDistance * value.toDouble() - updateCameraPositionForDistance(elapsedDistance) - } - } - progressSlider.addOnSliderTouchListener(object : Slider.OnSliderTouchListener { - override fun onStartTrackingTouch(slider: Slider) { - isUserScrubbing = true - } - - override fun onStopTrackingTouch(slider: Slider) { - isUserScrubbing = false - } - }) - - // Sliders listeners - rangeSlider.addOnChangeListener { _, value, _ -> - cameraRange = value.toDouble() - rangeSliderLabel.text = getString(R.string.camera_range_format, cameraRange.toInt()) - updateCameraPositionForDistance(elapsedDistance) - } + // Swipe Up to Expand / Swipe Down to Collapse + val cardSwipeDetector = GestureDetector(this, object : GestureDetector.SimpleOnGestureListener() { + override fun onFling( + e1: MotionEvent?, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + if (e1 == null) return false + val dy = e2.y - e1.y + if (dy > 50 && velocityY > 100) { + // Swiped Down -> Collapse + setPanelCollapsed(true) + return true + } else if (dy < -50 && velocityY < -100) { + // Swiped Up -> Expand + setPanelCollapsed(false) + return true + } + return false + } + }) + + cardHeader?.setOnTouchListener { v, event -> + if (cardSwipeDetector.onTouchEvent(event)) { + true + } else { + v.onTouchEvent(event) + } + } - altitudeSlider.addOnChangeListener { _, value, _ -> - groundAltitude = value.toDouble() - altitudeSliderLabel.text = - getString(R.string.ground_altitude_format, groundAltitude.toInt()) - updateCameraPositionForDistance(elapsedDistance) - } + controlsCard?.setOnTouchListener { _, event -> + cardSwipeDetector.onTouchEvent(event) + } - headingSlider.addOnChangeListener { _, value, _ -> - headingOffset = value.toDouble() - headingSliderLabel.text = - getString(R.string.heading_offset_format, headingOffset.toInt()) - updateCameraPositionForDistance(elapsedDistance) - } + progressSlider.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + viewModel.seekToRatio(value) + val state = viewModel.currentState + updateCameraFromState(state) + updateProgressPolyline(state) + } + } - tiltSlider.addOnChangeListener { _, value, _ -> - cameraTilt = value.toDouble() - tiltSliderLabel.text = getString(R.string.camera_tilt_format, cameraTilt.toInt()) - updateCameraPositionForDistance(elapsedDistance) - } + progressSlider.addOnSliderTouchListener(object : Slider.OnSliderTouchListener { + override fun onStartTrackingTouch(slider: Slider) { + viewModel.setScrubbing(true) + } + + override fun onStopTrackingTouch(slider: Slider) { + viewModel.setScrubbing(false) + viewModel.seekToRatio(slider.value) + val state = viewModel.currentState + updateCameraFromState(state) + updateProgressPolyline(state) + } + }) + + rgAltitudeMode.setOnCheckedChangeListener { _, checkedId -> + val mode = when (checkedId) { + R.id.rb_clamp_to_ground -> AltitudeMode.CLAMP_TO_GROUND + R.id.rb_relative_to_ground -> AltitudeMode.RELATIVE_TO_GROUND + R.id.rb_relative_to_mesh -> AltitudeMode.RELATIVE_TO_MESH + R.id.rb_absolute -> AltitudeMode.ABSOLUTE + else -> AltitudeMode.CLAMP_TO_GROUND + } + viewModel.setAltitudeMode(mode) + resetPolylines() + } - speedSlider.addOnChangeListener { _, value, _ -> - followSpeedMps = value.toDouble() - speedSliderLabel.text = - getString(R.string.follow_speed_format, followSpeedMps.toInt()) - } - } - - private fun updateControlLabels() { - pathAltitudeSliderLabel.text = - getString(R.string.path_height_format, pathAltitudeOffset) - rangeSliderLabel.text = getString(R.string.camera_range_format, cameraRange.toInt()) - altitudeSliderLabel.text = - getString(R.string.ground_altitude_format, groundAltitude.toInt()) - headingSliderLabel.text = - getString(R.string.heading_offset_format, headingOffset.toInt()) - tiltSliderLabel.text = getString(R.string.camera_tilt_format, cameraTilt.toInt()) - speedSliderLabel.text = - getString(R.string.follow_speed_format, followSpeedMps.toInt()) - } - - private var currentHeading: Double? = null - - private fun switchEnvironment(path: List) { - pauseAnimation() - currentHeading = null - elapsedDistance = 0.0 - progressSlider.value = 0f - - if (path == RURAL_PATH) { - cameraRange = 450.0 - groundAltitude = 40.0 - cameraTilt = 75.0 - rangeSlider.value = 450f - altitudeSlider.value = 40f - tiltSlider.value = 75f - rangeSliderLabel.text = getString(R.string.camera_range_format, 450) - altitudeSliderLabel.text = getString(R.string.ground_altitude_format, 40) - tiltSliderLabel.text = getString(R.string.camera_tilt_format, 75) - } else { - cameraRange = 300.0 - groundAltitude = 20.0 - cameraTilt = 70.0 - rangeSlider.value = 300f - altitudeSlider.value = 20f - tiltSlider.value = 70f - rangeSliderLabel.text = getString(R.string.camera_range_format, 300) - altitudeSliderLabel.text = getString(R.string.ground_altitude_format, 20) - tiltSliderLabel.text = getString(R.string.camera_tilt_format, 70) - } + switchDrawsOccludedSegments.setOnCheckedChangeListener { _, isChecked -> + viewModel.setDrawsOccludedSegments(isChecked) + resetPolylines() + } - loadPathData(path) - drawPathPolylines() - updateCameraPositionForDistance(0.0) - } - - private fun loadPathData(path: List) { - currentPath = path - cumulativeDistances = DoubleArray(path.size) - totalDistance = 0.0 - cumulativeDistances[0] = 0.0 - for (i in 1 until path.size) { - val dist = SphericalUtil.computeDistanceBetween(path[i - 1], path[i]) - totalDistance += dist - cumulativeDistances[i] = totalDistance - } - } + pathAltitudeSlider.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + viewModel.setPathAltitudeOffset(value.toDouble()) + resetPolylines() + } + pathAltitudeSliderLabel.text = getString(R.string.path_height_format, value) + } - private fun drawPathPolylines() { - drawStaticRoutePolyline() - if (currentPath.isNotEmpty()) { - updateProgressPolyline(elapsedDistance, currentPath[0], 0) - } - } + rangeSlider.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + viewModel.setCameraRange(value.toDouble()) + updateCameraFromState(viewModel.currentState) + } + rangeSliderLabel.text = getString(R.string.camera_range_format, value.toInt()) + } - private fun drawStaticRoutePolyline() { - val map = googleMap3D ?: return - if (currentPath.isEmpty()) return + altitudeSlider.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + viewModel.setGroundAltitude(value.toDouble()) + updateCameraFromState(viewModel.currentState) + } + altitudeSliderLabel.text = getString(R.string.ground_altitude_format, value.toInt()) + } - var pathAltitude = if (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) { - 0.0 - } else { - pathAltitudeOffset - } - if (pathAltitudeMode == AltitudeMode.ABSOLUTE) { - pathAltitude = if (currentPath == RURAL_PATH) 40.0 else 15.0 - } + headingSlider.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + viewModel.setHeadingOffset(value.toDouble()) + updateCameraFromState(viewModel.currentState) + } + headingSliderLabel.text = getString(R.string.heading_offset_format, value.toInt()) + } + + tiltSlider.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + viewModel.setCameraTilt(value.toDouble()) + updateCameraFromState(viewModel.currentState) + } + tiltSliderLabel.text = getString(R.string.camera_tilt_format, value.toInt()) + } + + speedSlider.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + viewModel.setFollowSpeed(value.toDouble()) + when (value.toInt()) { + 15 -> chipGroupSpeed?.check(R.id.chip_speed_05x) + 30 -> chipGroupSpeed?.check(R.id.chip_speed_1x) + 60 -> chipGroupSpeed?.check(R.id.chip_speed_2x) + 90 -> chipGroupSpeed?.check(R.id.chip_speed_3x) + 120 -> chipGroupSpeed?.check(R.id.chip_speed_5x) + } + } + val boostSuffix = when { + viewModel.currentState.speedBoostMultiplier >= 4.5 -> " (5x Fast-Forward)" + viewModel.currentState.speedBoostMultiplier <= -4.5 -> " (-5x Rewind)" + viewModel.currentState.speedBoostMultiplier >= 1.5 -> " (2x Boost)" + else -> "" + } + speedSliderLabel.text = getString(R.string.follow_speed_format, value.toInt()) + boostSuffix + } - val staticVertices = currentPath.map { latLng -> - LatLngAltitude(latLng.latitude, latLng.longitude, pathAltitude) + rgEnvironment.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.rb_urban -> { + viewModel.setRoute(PathData.URBAN_PATH) + pathAltitudeSlider.valueTo = 20.0f + altitudeSlider.valueTo = 500.0f + } + R.id.rb_rural -> { + viewModel.setRoute(PathData.RURAL_PATH) + pathAltitudeSlider.valueTo = 200.0f + altitudeSlider.valueTo = 500.0f + } + } + resetPolylines() + } } - val staticOptions = PolylineOptions().apply { - id = STATIC_ROUTE_POLYLINE_ID - path = staticVertices - strokeColor = "#4285F4".toColorInt() // Wide blue route - strokeWidth = 16.0 - zIndex = 1 - altitudeMode = pathAltitudeMode + private fun showHelpDialog() { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.help_dialog_title) + .setMessage(R.string.help_dialog_message) + .setPositiveButton(R.string.help_dialog_ok, null) + .show() + } + + private fun resetPolylines() { + lastStaticVertices = null + lastRenderedProgressDist = -1.0 + val state = viewModel.currentState + updateStaticPolyline(state) + updateProgressPolyline(state) + updateCameraFromState(state) + } + + private fun updateStaticPolyline(state: PathPlaybackState) { + val map = googleMap3D ?: return + if (lastStaticVertices == state.staticPolylineVertices && staticRoutePolyline != null) return + + lastStaticVertices = state.staticPolylineVertices + val staticOptions = PolylineOptions().apply { + id = PathEngine.STATIC_POLYLINE_ID + path = state.staticPolylineVertices + strokeColor = "#4285F4".toColorInt() + strokeWidth = 16.0 + zIndex = 1 + altitudeMode = state.altitudeMode + drawsOccludedSegments = state.drawsOccludedSegments + } + staticRoutePolyline = map.addPolyline(staticOptions) + } + + private fun updateProgressPolyline(state: PathPlaybackState) { + val map = googleMap3D ?: return + if (state.progressPolylineVertices.size < 2) return + + lastRenderedProgressDist = state.elapsedDistance + val progressOptions = PolylineOptions().apply { + id = PathEngine.PROGRESS_POLYLINE_ID + path = state.progressPolylineVertices + strokeColor = "#9C27B0".toColorInt() + strokeWidth = 8.0 + zIndex = 2 + altitudeMode = state.altitudeMode + drawsOccludedSegments = state.drawsOccludedSegments + } + progressPolyline = map.addPolyline(progressOptions) + } + + private fun observeViewModelState() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> + updateCameraFromState(state) + // Only update progress polyline if distance changed during playback or seek + if (state.isPlaying || abs(state.elapsedDistance - lastRenderedProgressDist) > 0.1) { + updateProgressPolyline(state) + } + renderUiControls(state) + manageAnimationTicker(state.isPlaying) + } + } + } } - staticRoutePolyline = map.addPolyline(staticOptions) - } + private fun updateCameraFromState(state: PathPlaybackState) { + val map = googleMap3D ?: return + val newCamera = Camera( + /* center = */ LatLngAltitude( + state.currentPosition.latitude, + state.currentPosition.longitude, + state.cameraTargetAltitude + ), + /* heading = */ state.effectiveHeading, + /* tilt = */ state.cameraTilt, + /* roll = */ 0.0, + /* range = */ state.cameraRange + ) + map.setCamera(newCamera) + } + + private fun renderUiControls(state: PathPlaybackState) { + if (lastIsPlaying != state.isPlaying) { + lastIsPlaying = state.isPlaying + btnPlayPause.setIconResource( + if (state.isPlaying) R.drawable.pause_24px else R.drawable.play_arrow_24px + ) + } - private fun updateProgressPolyline(dist: Double, currentLatLng: LatLng, index: Int) { - val map = googleMap3D ?: return - if (currentPath.isEmpty() || totalDistance <= 0.0) return + if (!state.isScrubbing) { + val now = System.currentTimeMillis() + if (now - lastSliderUpdateMillis >= 100L || !state.isPlaying) { + lastSliderUpdateMillis = now + progressSlider.value = state.progressRatio + } + } - var pathAltitude = if (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) { - 0.0 - } else { - pathAltitudeOffset - } - if (pathAltitudeMode == AltitudeMode.ABSOLUTE) { - pathAltitude = if (currentPath == RURAL_PATH) 40.0 else 15.0 + val boostSuffix = when { + state.speedBoostMultiplier >= 4.5 -> " (5x Warp Speed)" + state.speedBoostMultiplier >= 1.5 -> " (2x Boost)" + else -> "" + } + speedSliderLabel.text = getString(R.string.follow_speed_format, state.followSpeedMps.toInt()) + boostSuffix + + if (!isCollapsed) { + val clampedRange = state.cameraRange.toFloat().coerceIn(rangeSlider.valueFrom, rangeSlider.valueTo) + if (abs(rangeSlider.value - clampedRange) >= 1.0f) { + rangeSlider.value = clampedRange + rangeSliderLabel.text = getString(R.string.camera_range_format, state.cameraRange.toInt()) + } + + val clampedTilt = state.cameraTilt.toFloat().coerceIn(tiltSlider.valueFrom, tiltSlider.valueTo) + if (abs(tiltSlider.value - clampedTilt) >= 0.5f) { + tiltSlider.value = clampedTilt + tiltSliderLabel.text = getString(R.string.camera_tilt_format, state.cameraTilt.toInt()) + } + + val clampedHeading = state.headingOffset.toFloat().coerceIn(headingSlider.valueFrom, headingSlider.valueTo) + if (abs(headingSlider.value - clampedHeading) >= 0.5f) { + headingSlider.value = clampedHeading + headingSliderLabel.text = getString(R.string.heading_offset_format, state.headingOffset.toInt()) + } + } } - val progressAltitude = pathAltitude + 0.2 - - val progressCoordinates = ArrayList() - for (i in 0..index.coerceAtMost(currentPath.size - 1)) { - val pt = currentPath[i] - progressCoordinates.add(LatLngAltitude(pt.latitude, pt.longitude, progressAltitude)) + private fun manageAnimationTicker(isPlaying: Boolean) { + if (isPlaying) { + if (frameCallback == null) { + var lastTimeNanos = 0L + frameCallback = object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + if (!viewModel.currentState.isPlaying) return + + if (lastTimeNanos == 0L) { + lastTimeNanos = frameTimeNanos + Choreographer.getInstance().postFrameCallback(this) + return + } + + val dt = (frameTimeNanos - lastTimeNanos) / 1_000_000_000.0 + lastTimeNanos = frameTimeNanos + + viewModel.advance(dt) + Choreographer.getInstance().postFrameCallback(this) + } + } + Choreographer.getInstance().postFrameCallback(frameCallback!!) + } + } else { + frameCallback?.let { + Choreographer.getInstance().removeFrameCallback(it) + frameCallback = null + } + } } - progressCoordinates.add( - LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, progressAltitude) - ) - if (progressCoordinates.size < 2) { - val startPt = currentPath[0] - progressCoordinates.add(LatLngAltitude(startPt.latitude, startPt.longitude, progressAltitude)) + private fun setupTouchAutoFade() { + scheduleControlFade() } - // Dual-Polyline Rendering Technique: - // We render two layered polylines to clearly visualize completed vs. remaining route: - // 1. Base Static Polyline: A wider (#4285F4 blue, 16dp) static path at ZIndex=1. - // 2. Traversed Progress Polyline: A narrower (#9C27B0 purple, 8dp) line at ZIndex=2. - // We also apply a slight vertical altitude bias (+0.2m) to prevent 3D depth buffer z-fighting. - val progressOptions = PolylineOptions().apply { - id = PROGRESS_POLYLINE_ID - path = progressCoordinates - strokeColor = "#9C27B0".toColorInt() // Narrow purple progress - strokeWidth = 8.0 - zIndex = 2 - altitudeMode = pathAltitudeMode + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + if (ev.action == MotionEvent.ACTION_DOWN || ev.action == MotionEvent.ACTION_MOVE) { + fadeHandler.removeCallbacksAndMessages(null) + controlsCard?.animate()?.alpha(1.0f)?.setDuration(150L)?.start() + } else if (ev.action == MotionEvent.ACTION_UP || ev.action == MotionEvent.ACTION_CANCEL) { + scheduleControlFade() + } + return super.dispatchTouchEvent(ev) } - progressPolyline = map.addPolyline(progressOptions) - } - - private fun startAnimation() { - if (isPlaying) return - isPlaying = true - btnPlayPause.setIconResource(R.drawable.pause_24px) - - val frameDurationMs = 16L - animationRunnable = object : Runnable { - private var lastTime = System.currentTimeMillis() - - override fun run() { - if (!isPlaying) return - - val now = System.currentTimeMillis() - // Delta-time (dt) integration: Scale motion by actual elapsed seconds to ensure - // consistent travel speed across 60Hz, 90Hz, and 120Hz display refresh rates. - val dt = (now - lastTime) / 1000.0 - lastTime = now + private fun scheduleControlFade() { + fadeHandler.removeCallbacksAndMessages(null) + fadeHandler.postDelayed({ + controlsCard?.animate()?.alpha(0.35f)?.setDuration(500L)?.start() + }, 3500L) + } - val stepDistance = followSpeedMps * dt - elapsedDistance += stepDistance + override fun onResume() { + super.onResume() + map3DView.onResume() + } - if (elapsedDistance >= totalDistance) { - elapsedDistance = 0.0 - } + override fun onPause() { + super.onPause() + viewModel.setPlaying(false) + viewModel.setSpeedBoosted(false) + map3DView.onPause() + } - if (!isUserScrubbing && totalDistance > 0) { - val progress = (elapsedDistance / totalDistance).toFloat().coerceIn(0f, 1f) - progressSlider.value = progress + override fun onDestroy() { + super.onDestroy() + viewModel.setPlaying(false) + frameCallback?.let { + Choreographer.getInstance().removeFrameCallback(it) + frameCallback = null } - - updateCameraPositionForDistance(elapsedDistance) - animationHandler.postDelayed(this, frameDurationMs) - } + fadeHandler.removeCallbacksAndMessages(null) + staticRoutePolyline = null + progressPolyline = null + map3DView.onDestroy() } - animationHandler.post(animationRunnable!!) - } - - private fun pauseAnimation() { - isPlaying = false - btnPlayPause.setIconResource(R.drawable.play_arrow_24px) - animationRunnable?.let { - animationHandler.removeCallbacks(it) - animationRunnable = null - } - } - - private fun updateCameraPositionForDistance(dist: Double) { - val map = googleMap3D ?: return - if (currentPath.isEmpty()) return - var index = 0 - while (index < cumulativeDistances.size - 1 && cumulativeDistances[index + 1] < dist) { - index++ + override fun onLowMemory() { + super.onLowMemory() + map3DView.onLowMemory() } - val p1 = currentPath[index] - val p2 = if (index < currentPath.size - 1) currentPath[index + 1] else p1 - - val segStartDist = cumulativeDistances[index] - val segEndDist = - if (index < cumulativeDistances.size - 1) cumulativeDistances[index + 1] else totalDistance - val segLen = segEndDist - segStartDist - - val fraction = if (segLen > 0) ((dist - segStartDist) / segLen).coerceIn(0.0, 1.0) else 0.0 - val currentLatLng = SphericalUtil.interpolate(p1, p2, fraction) - val bearing = SphericalUtil.computeHeading(p1, p2) - - // Kinematic Heading Smoothing (Exponential Moving Average): - // Sharp polyline bends can produce disorienting camera jerks. We compute the shortest - // angular difference wrapped to [-180, 180] and apply an exponential low-pass filter (12% lerp) - // to smoothly turn the camera around street corners and mountain switchbacks. - val targetHeadingRaw = (bearing + headingOffset).toHeading() - val targetHeading = if (currentHeading == null || isUserScrubbing || !isPlaying) { - targetHeadingRaw - } else { - var diff = (targetHeadingRaw - currentHeading!!) % 360.0 - if (diff > 180.0) diff -= 360.0 - if (diff < -180.0) diff += 360.0 - (currentHeading!! + diff * 0.12).toHeading() + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + map3DView.onSaveInstanceState(outState) } - currentHeading = targetHeading - - val newCamera = camera { - center = latLngAltitude { - latitude = currentLatLng.latitude - longitude = currentLatLng.longitude - altitude = groundAltitude - } - heading = targetHeading - tilt = cameraTilt - range = cameraRange - } - - map.setCamera(newCamera) - updateProgressPolyline(dist, currentLatLng, index) - } - - companion object { - private const val STATIC_ROUTE_POLYLINE_ID = "path_following_static_route" - private const val PROGRESS_POLYLINE_ID = "path_following_progress_route" - - // Urban Path (New York City - Central Park Block Circuit) - val URBAN_PATH = listOf( - LatLng(40.7783119, -73.9627630), - LatLng(40.7776355, -73.9611664), - LatLng(40.7770011, -73.9616294), - LatLng(40.7776743, -73.9632228), - LatLng(40.7783119, -73.9627630) - ) - - // Rural Path - val RURAL_PATH = listOf( - LatLng(37.254529, -122.380897), - LatLng(37.255065, -122.381627), - LatLng(37.257540, -122.383720), - LatLng(37.261200, -122.383950), - LatLng(37.264780, -122.388210), - LatLng(37.268520, -122.392450), - LatLng(37.272110, -122.397640), - LatLng(37.276430, -122.401120), - LatLng(37.280850, -122.403560), - LatLng(37.286018, -122.405072), - LatLng(37.291040, -122.404210), - LatLng(37.295800, -122.401980), - LatLng(37.300120, -122.399540), - LatLng(37.304550, -122.397210), - LatLng(37.309200, -122.395100), - LatLng(37.313450, -122.392840), - LatLng(37.317200, -122.390510), - LatLng(37.320850, -122.388740), - LatLng(37.323540, -122.387600), - LatLng(37.325269, -122.386728) - ) - } } diff --git a/Maps3DSamples/ComposeDemos/app/build.gradle.kts b/Maps3DSamples/ComposeDemos/app/build.gradle.kts index a78fba5..0aa1605 100644 --- a/Maps3DSamples/ComposeDemos/app/build.gradle.kts +++ b/Maps3DSamples/ComposeDemos/app/build.gradle.kts @@ -59,6 +59,7 @@ android { } } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } @@ -79,7 +80,10 @@ android { } dependencies { + coreLibraryDesugaring(libs.desugar.jdk.libs) + implementation(project(":maps3d-compose")) + implementation(project(":Maps3DSamples:ApiDemos:common")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt index 1d2c09d..3b6cf0f 100644 --- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt @@ -18,195 +18,138 @@ package com.example.composedemos.pathfollowing import android.graphics.Color import android.os.Bundle -import android.view.MotionEvent import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.HelpOutline +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableDoubleStateOf -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.runtime.withFrameNanos +import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.unit.dp -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import com.google.android.gms.maps.model.LatLng +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.maps3d.common.PathData +import com.example.maps3d.common.PathEngine +import com.example.maps3d.common.PathFollowingViewModel +import com.example.maps3d.common.PathPlaybackState import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.Map3DMode import com.google.android.gms.maps3d.model.camera import com.google.android.gms.maps3d.model.latLngAltitude -import com.google.maps.android.SphericalUtil import com.google.maps.android.compose3d.GoogleMap3D import com.google.maps.android.compose3d.PolylineConfig import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.math.abs class PathFollowingActivity : ComponentActivity() { - val touchEventFlow = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 1) - - override fun dispatchTouchEvent(ev: MotionEvent): Boolean { - if (ev.action == MotionEvent.ACTION_DOWN || ev.action == MotionEvent.ACTION_MOVE) { - touchEventFlow.tryEmit(Unit) - } - return super.dispatchTouchEvent(ev) - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - - val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) - windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) - windowInsetsController.systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - setContent { MaterialTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - PathFollowingScreen() + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + color = MaterialTheme.colorScheme.background, + ) { + PathFollowingScreen() + } } } } } - - companion object { - // Urban Path (New York City - Central Park Block Circuit) - val URBAN_PATH = listOf( - LatLng(40.7783119, -73.9627630), - LatLng(40.7776355, -73.9611664), - LatLng(40.7770011, -73.9616294), - LatLng(40.7776743, -73.9632228), - LatLng(40.7783119, -73.9627630), - ) - - // Rural Path - val RURAL_PATH = listOf( - LatLng(37.254529, -122.380897), - LatLng(37.255065, -122.381627), - LatLng(37.257540, -122.383720), - LatLng(37.261200, -122.383950), - LatLng(37.264780, -122.388210), - LatLng(37.268520, -122.392450), - LatLng(37.272110, -122.397640), - LatLng(37.276430, -122.401120), - LatLng(37.280850, -122.403560), - LatLng(37.286018, -122.405072), - LatLng(37.291040, -122.404210), - LatLng(37.295800, -122.401980), - LatLng(37.300120, -122.399540), - LatLng(37.304550, -122.397210), - LatLng(37.309200, -122.395100), - LatLng(37.313450, -122.392840), - LatLng(37.317200, -122.390510), - LatLng(37.320850, -122.388740), - LatLng(37.323540, -122.387600), - LatLng(37.325269, -122.386728), - ) - } } -enum class EnvironmentType(val label: String) { - URBAN("Urban"), - RURAL("Rural"), +enum class AltitudeModeOption(val label: String, val mode: Int) { + CLAMP_TO_GROUND("Clamp to Ground", AltitudeMode.CLAMP_TO_GROUND), + RELATIVE_TO_GROUND("Rel to Ground", AltitudeMode.RELATIVE_TO_GROUND), + RELATIVE_TO_MESH("Rel to Mesh", AltitudeMode.RELATIVE_TO_MESH), + ABSOLUTE("Absolute", AltitudeMode.ABSOLUTE), } +/** + * Main Path Following demo screen orchestrating 3D map rendering and interactive controls + * driven by [PathFollowingViewModel]. + */ @Composable -fun PathFollowingScreen() { - var isMapSteady by remember { mutableStateOf(false) } - - // UX Fade logic - val activity = LocalContext.current as PathFollowingActivity - var showControls by remember { mutableStateOf(true) } - - LaunchedEffect(Unit) { - activity.touchEventFlow.collect { - showControls = true - } - } - - LaunchedEffect(showControls) { - if (showControls) { - kotlinx.coroutines.delay(3000) - showControls = false - } - } - - val controlsAlpha by animateFloatAsState( - targetValue = if (showControls) 1f else 0.2f, - animationSpec = tween(500), - ) - - // Environment Selection State - var selectedEnv by remember { mutableStateOf(EnvironmentType.URBAN) } - var currentPath by remember { mutableStateOf(PathFollowingActivity.URBAN_PATH) } - - // Path Calculations State - val pathCalculations = remember(currentPath) { - val cumulative = DoubleArray(currentPath.size) - var total = 0.0 - cumulative[0] = 0.0 - for (i in 1 until currentPath.size) { - val dist = SphericalUtil.computeDistanceBetween(currentPath[i - 1], currentPath[i]) - total += dist - cumulative[i] = total - } - Pair(cumulative, total) - } - val cumulativeDistances = pathCalculations.first - val totalDistance = pathCalculations.second - - // Animation Controls State - var isPlaying by remember { mutableStateOf(false) } - var progress by remember { mutableFloatStateOf(0.0f) } - var isUserScrubbing by remember { mutableStateOf(false) } - var elapsedDistance by remember { mutableDoubleStateOf(0.0) } +fun PathFollowingScreen( + viewModel: PathFollowingViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + var showHelpDialog by remember { mutableStateOf(false) } + var lastInteractionTime by remember { mutableStateOf(System.currentTimeMillis()) } val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP || event == Lifecycle.Event.ON_DESTROY) { - isPlaying = false + if (event == Lifecycle.Event.ON_PAUSE) { + viewModel.setPlaying(false) + viewModel.setSpeedBoosted(false) } } lifecycleOwner.lifecycle.addObserver(observer) @@ -215,291 +158,580 @@ fun PathFollowingScreen() { } } - // Camera Parameter Sliders State - var cameraRange by remember { mutableFloatStateOf(300f) } - var groundAltitude by remember { mutableFloatStateOf(20f) } - var headingOffset by remember { mutableFloatStateOf(0f) } - var cameraTilt by remember { mutableFloatStateOf(70f) } - var followSpeedMps by remember { mutableFloatStateOf(30f) } - - // Interpolated Position & Heading - var currentLatLng by remember(currentPath) { mutableStateOf(currentPath.first()) } - var currentHeading by remember { mutableStateOf(null) } - var targetHeading by remember { mutableDoubleStateOf(120.0) } - - // Helper to calculate position for given elapsed distance - fun updatePositionForDistance(dist: Double) { - if (currentPath.isEmpty()) return - var index = 0 - while (index < cumulativeDistances.size - 1 && cumulativeDistances[index + 1] < dist) { - index++ - } - val p1 = currentPath[index] - val p2 = if (index < currentPath.size - 1) currentPath[index + 1] else p1 - - val segStartDist = cumulativeDistances[index] - val segEndDist = - if (index < cumulativeDistances.size - 1) cumulativeDistances[index + 1] else totalDistance - val segLen = segEndDist - segStartDist - - val fraction = if (segLen > 0) ((dist - segStartDist) / segLen).coerceIn(0.0, 1.0) else 0.0 - currentLatLng = SphericalUtil.interpolate(p1, p2, fraction) - val bearing = SphericalUtil.computeHeading(p1, p2) - - val targetHeadingRaw = (bearing + headingOffset + 360.0) % 360.0 - val computedHeading = if (currentHeading == null || isUserScrubbing || !isPlaying) { - targetHeadingRaw - } else { - var diff = (targetHeadingRaw - currentHeading!!) % 360.0 - if (diff > 180.0) diff -= 360.0 - if (diff < -180.0) diff += 360.0 - (currentHeading!! + diff * 0.12 + 360.0) % 360.0 - } - currentHeading = computedHeading - targetHeading = computedHeading - } + // Hardware VSYNC-synced frame loop using withFrameMillis + LaunchedEffect(state.isPlaying) { + if (!state.isPlaying) return@LaunchedEffect + var lastTimeNanos = 0L + + while (isActive && state.isPlaying) { + withFrameMillis { frameTimeMillis -> + val nowNanos = frameTimeMillis * 1_000_000L + if (lastTimeNanos == 0L) { + lastTimeNanos = nowNanos + return@withFrameMillis + } + val dt = (nowNanos - lastTimeNanos) / 1_000_000_000.0 + lastTimeNanos = nowNanos - // Switch Environment logic - fun switchEnvironment(env: EnvironmentType) { - selectedEnv = env - isPlaying = false - currentHeading = null - progress = 0f - elapsedDistance = 0.0 - if (env == EnvironmentType.RURAL) { - currentPath = PathFollowingActivity.RURAL_PATH - cameraRange = 450f - groundAltitude = 40f - cameraTilt = 75f - } else { - currentPath = PathFollowingActivity.URBAN_PATH - cameraRange = 300f - groundAltitude = 20f - cameraTilt = 70f + viewModel.advance(dt) + } } - updatePositionForDistance(0.0) } - // Animation Loop synced to VSYNC - LaunchedEffect(isPlaying) { - if (!isPlaying) return@LaunchedEffect - var lastTimeNanos = withFrameNanos { it } - while (isPlaying) { - val nowNanos = withFrameNanos { it } - val dt = (nowNanos - lastTimeNanos) / 1_000_000_000.0 - lastTimeNanos = nowNanos - - val stepDistance = followSpeedMps * dt - elapsedDistance += stepDistance - - if (elapsedDistance >= totalDistance) { - elapsedDistance = 0.0 - } - - if (!isUserScrubbing && totalDistance > 0) { - progress = (elapsedDistance / totalDistance).toFloat().coerceIn(0f, 1f) + val dynamicCamera = remember(state.currentPosition, state.effectiveHeading, state.cameraTilt, state.cameraRange, state.cameraTargetAltitude) { + camera { + center = latLngAltitude { + latitude = state.currentPosition.latitude + longitude = state.currentPosition.longitude + altitude = state.cameraTargetAltitude } - updatePositionForDistance(elapsedDistance) + heading = state.effectiveHeading + tilt = state.cameraTilt + range = state.cameraRange + roll = 0.0 } } - // Dynamic Camera State - val dynamicCamera = - remember(currentLatLng, targetHeading, cameraTilt, cameraRange, groundAltitude) { - camera { - center = latLngAltitude { - latitude = currentLatLng.latitude - longitude = currentLatLng.longitude - altitude = groundAltitude.toDouble() - } - heading = targetHeading - tilt = cameraTilt.toDouble() - range = cameraRange.toDouble() - roll = 0.0 - } - } + val staticPolylineConfig = remember(state.staticPolylineVertices, state.altitudeMode, state.drawsOccludedSegments) { + PolylineConfig( + key = PathEngine.STATIC_POLYLINE_ID, + points = state.staticPolylineVertices, + width = 16f, + color = Color.parseColor("#4285F4"), + altitudeMode = state.altitudeMode, + drawsOccludedSegments = state.drawsOccludedSegments, + zIndex = 1, + ) + } - // Polyline Config - val polylineConfig = remember(currentPath) { + val progressPolylineConfig = remember(state.progressPolylineVertices, state.altitudeMode, state.drawsOccludedSegments) { PolylineConfig( - key = "path_following_polyline", - points = currentPath.map { latLng -> - latLngAltitude { - latitude = latLng.latitude - longitude = latLng.longitude - altitude = 5.0 - } - }, - color = Color.BLUE, - width = 10f, - altitudeMode = AltitudeMode.RELATIVE_TO_GROUND, + key = PathEngine.PROGRESS_POLYLINE_ID, + points = state.progressPolylineVertices, + width = 8f, + color = Color.parseColor("#9C27B0"), + altitudeMode = state.altitudeMode, + drawsOccludedSegments = state.drawsOccludedSegments, + zIndex = 2, ) } + val viewConfig = LocalViewConfiguration.current + val coroutineScope = rememberCoroutineScope() + Box( modifier = Modifier .fillMaxSize() - .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + lastInteractionTime = System.currentTimeMillis() + } + }, ) { - // 1. Full Screen 3D Map GoogleMap3D( - camera = dynamicCamera, - polylines = listOf(polylineConfig), modifier = Modifier.fillMaxSize(), - onMapSteady = { - isMapSteady = true - }, + camera = dynamicCamera, + mapMode = Map3DMode.HYBRID, + polylines = listOf(staticPolylineConfig, progressPolylineConfig), + ) + + // Custom Gesture Overlay replacing built-in map gestures + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + var lastTapTime = 0L + var lastTapX = 0f + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + lastInteractionTime = System.currentTimeMillis() + var isDragging = false + var isLongPressActive = false + var isPinching = false + + val now = System.currentTimeMillis() + val isDoubleTap = (now - lastTapTime < viewConfig.doubleTapTimeoutMillis) && + (abs(down.position.x - lastTapX) < viewConfig.touchSlop * 4) + + var isDoubleTapHold = false + var wasPlayingBeforeShuttle = false + + val longPressJob = coroutineScope.launch { + if (isDoubleTap) { + isDoubleTapHold = true + wasPlayingBeforeShuttle = viewModel.currentState.isPlaying + val isRightSide = down.position.x > 500f + viewModel.setPlaying(true) + viewModel.setSpeedBoostMultiplier(if (isRightSide) 5.0 else -5.0) + } else { + delay(viewConfig.longPressTimeoutMillis) + if (!isDragging && !isPinching) { + isLongPressActive = true + viewModel.setSpeedBoostMultiplier(2.0) + delay(1500L) + if (isLongPressActive && !isDragging && !isPinching) { + viewModel.setSpeedBoostMultiplier(5.0) + } + } + } + } + + do { + val event = awaitPointerEvent(PointerEventPass.Main) + lastInteractionTime = System.currentTimeMillis() + val pointers = event.changes.filter { it.pressed } + + if (pointers.size > 1) { + longPressJob.cancel() + if (isLongPressActive) { + viewModel.setSpeedBoosted(false) + isLongPressActive = false + } + isPinching = true + + val zoom = event.calculateZoom() + if (zoom != 1f && zoom > 0.5f && zoom < 2.0f) { + val dampedZoom = 1.0 + (zoom.toDouble() - 1.0) * 0.65 + viewModel.adjustRange(dampedZoom) + } + event.changes.forEach { it.consume() } + } else if (pointers.size == 1 && !isPinching) { + val change = pointers.first() + val pan = change.positionChange() + + if (!isDragging && ( + abs(change.position.x - down.position.x) > viewConfig.touchSlop || + abs(change.position.y - down.position.y) > viewConfig.touchSlop + ) + ) { + isDragging = true + longPressJob.cancel() + if (isLongPressActive) { + viewModel.setSpeedBoosted(false) + isLongPressActive = false + } + } + + if (isDragging && !isLongPressActive) { + if (abs(pan.x) > 0.1f) { + viewModel.adjustHeading(pan.x.toDouble() * 0.08) + } + if (abs(pan.y) > 0.1f) { + viewModel.adjustTilt(-pan.y.toDouble() * 0.06) + } + change.consume() + } + } + } while (event.changes.any { it.pressed }) + + val endNow = System.currentTimeMillis() + lastInteractionTime = endNow + if (isDoubleTapHold) { + if (endNow - lastTapTime < 300L) { + val isRightSide = down.position.x > 500f + viewModel.skipRatio(if (isRightSide) 0.10f else -0.10f) + } + if (!wasPlayingBeforeShuttle) { + viewModel.setPlaying(false) + } + } else if (!isDragging && !isPinching) { + lastTapTime = endNow + lastTapX = down.position.x + } + longPressJob.cancel() + viewModel.setSpeedBoostMultiplier(1.0) + } + }, ) - // 2. Control Panel Card at Bottom - Card( + PathFollowingControlCard( + state = state, + lastInteractionTime = lastInteractionTime, + onUserTouch = { lastInteractionTime = System.currentTimeMillis() }, modifier = Modifier .align(Alignment.BottomCenter) - .padding(16.dp) .fillMaxWidth() - .alpha(controlsAlpha), + .padding(16.dp), + onTogglePlay = { viewModel.togglePlayPause() }, + onShowHelp = { showHelpDialog = true }, + onSeekRatio = { viewModel.seekToRatio(it) }, + onScrubbingChange = { viewModel.setScrubbing(it) }, + onAltitudeModeChange = { viewModel.setAltitudeMode(it.mode) }, + onOcclusionChange = { viewModel.setDrawsOccludedSegments(it) }, + onPathAltitudeOffsetChange = { viewModel.setPathAltitudeOffset(it.toDouble()) }, + onCameraRangeChange = { viewModel.setCameraRange(it.toDouble()) }, + onGroundAltitudeChange = { viewModel.setGroundAltitude(it.toDouble()) }, + onHeadingOffsetChange = { viewModel.setHeadingOffset(it.toDouble()) }, + onCameraTiltChange = { viewModel.setCameraTilt(it.toDouble()) }, + onSpeedChange = { viewModel.setFollowSpeed(it.toDouble()) }, + onEnvironmentChange = { isUrban -> + viewModel.setRoute(if (isUrban) PathData.URBAN_PATH else PathData.RURAL_PATH) + }, + ) + + if (showHelpDialog) { + AlertDialog( + onDismissRequest = { showHelpDialog = false }, + title = { Text("3D Path Following Controls") }, + text = { + Text( + "🧭 Camera Gestures:\n" + + "• Vertical Sweep (1 Finger): Adjusts camera tilt (0° to 85°)\n" + + "• Horizontal Sweep (1 Finger): Rotates camera heading orbit\n" + + "• Pinch (2 Fingers): Zooms camera in / out\n\n" + + "⚡ Speed & Navigation:\n" + + "• Long Press & Hold: 2x Boost (at 0.5s) → 5x Warp Speed (at 2s)\n" + + "• Double-Tap & Hold Right: +5x Fast-Forward\n" + + "• Double-Tap & Hold Left: -5x Rewind\n" + + "• Quick Double-Tap: Skip +/- 10% along path\n" + + "• Speed Chips (0.5x - 5x): Instant preset selection\n\n" + + "🎛️ Control Panel:\n" + + "• Swipe Up / Down or Tap Header: Expand / collapse settings panel\n" + + "• Idle Auto-Fade: Panel fades to transparent after 3.5s of inactivity", + ) + }, + confirmButton = { + TextButton(onClick = { showHelpDialog = false }) { + Text("Got It") + } + }, + ) + } + } +} + +/** + * Collapsible floating control card exposing all path following adjustments. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PathFollowingControlCard( + state: PathPlaybackState, + lastInteractionTime: Long, + onUserTouch: () -> Unit, + modifier: Modifier = Modifier, + onTogglePlay: () -> Unit, + onShowHelp: () -> Unit, + onSeekRatio: (Float) -> Unit, + onScrubbingChange: (Boolean) -> Unit, + onAltitudeModeChange: (AltitudeModeOption) -> Unit, + onOcclusionChange: (Boolean) -> Unit, + onPathAltitudeOffsetChange: (Float) -> Unit, + onCameraRangeChange: (Float) -> Unit, + onGroundAltitudeChange: (Float) -> Unit, + onHeadingOffsetChange: (Float) -> Unit, + onCameraTiltChange: (Float) -> Unit, + onSpeedChange: (Float) -> Unit, + onEnvironmentChange: (Boolean) -> Unit, +) { + var isCollapsed by remember { mutableStateOf(false) } + var isIdle by remember { mutableStateOf(false) } + + LaunchedEffect(lastInteractionTime) { + isIdle = false + delay(3500L) + isIdle = true + } + + val alpha by animateFloatAsState( + targetValue = if (isIdle) 0.35f else 1.0f, + animationSpec = tween(durationMillis = 500), + label = "controlCardAlpha", + ) + + Card( + modifier = modifier + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + onUserTouch() + } + }, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = alpha), + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + Column( + modifier = Modifier + .padding(12.dp) + .verticalScroll(rememberScrollState()), ) { - Column( + // Drag Handle Affordance + Box( + modifier = Modifier + .width(36.dp) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) + .align(Alignment.CenterHorizontally), + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Header: Title, Help, and Collapse Toggle (Clickable row & Swipe up/down) + Row( modifier = Modifier - .padding(16.dp) - .heightIn(max = 320.dp) - .verticalScroll(rememberScrollState()), + .fillMaxWidth() + .clickable { isCollapsed = !isCollapsed } + .pointerInput(Unit) { + var totalDragY = 0f + detectVerticalDragGestures( + onDragStart = { + totalDragY = 0f + onUserTouch() + }, + onDragEnd = { + if (totalDragY > 40f && !isCollapsed) { + isCollapsed = true // Swipe down -> collapse + } else if (totalDragY < -40f && isCollapsed) { + isCollapsed = false // Swipe up -> expand + } + }, + onVerticalDrag = { _, dragAmount -> + totalDragY += dragAmount + onUserTouch() + }, + ) + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - // Environment Selector Text( - text = "Path Environment:", - style = MaterialTheme.typography.labelLarge, + text = "Path Following Controls", + style = MaterialTheme.typography.titleMedium, + ) + Row { + IconButton(onClick = onShowHelp, modifier = Modifier.size(48.dp)) { + Icon( + imageVector = Icons.AutoMirrored.Filled.HelpOutline, + contentDescription = "Help", + ) + } + IconButton(onClick = { isCollapsed = !isCollapsed }, modifier = Modifier.size(48.dp)) { + Icon( + imageVector = if (isCollapsed) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isCollapsed) "Expand" else "Collapse", + ) + } + } + } + + // Speed Preset Chips (Always visible) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + listOf(15f to "0.5x", 30f to "1x", 60f to "2x", 90f to "3x", 120f to "5x").forEach { (speed, label) -> + FilterChip( + selected = abs(state.followSpeedMps.toFloat() - speed) < 1f, + onClick = { + onSpeedChange(speed) + onUserTouch() + }, + label = { Text(label, style = MaterialTheme.typography.labelSmall) }, + ) + } + } + + // Persistent Play/Pause & Progress Slider Row (Always visible) + val progressInteractionSource = remember { MutableInteractionSource() } + val isProgressDragged by progressInteractionSource.collectIsDraggedAsState() + + LaunchedEffect(isProgressDragged) { + onScrubbingChange(isProgressDragged) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + IconButton(onClick = { + onTogglePlay() + onUserTouch() + }, modifier = Modifier.size(44.dp)) { + Icon( + imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (state.isPlaying) "Pause" else "Play", + ) + } + + Slider( + value = state.progressRatio, + onValueChange = { + onSeekRatio(it) + onUserTouch() + }, + valueRange = 0f..1f, + interactionSource = progressInteractionSource, + modifier = Modifier.weight(1f), ) + } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, + // Expandable Settings Section + AnimatedVisibility(visible = !isCollapsed) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - EnvironmentType.entries.forEach { env -> + // Environment Selection + Text("Path Environment:", style = MaterialTheme.typography.labelLarge) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .weight(1f) - .selectable( - selected = (selectedEnv == env), - onClick = { switchEnvironment(env) }, - role = Role.RadioButton, - ), + modifier = Modifier.clickable { + onEnvironmentChange(true) + onUserTouch() + }, ) { RadioButton( - selected = (selectedEnv == env), - onClick = null, + selected = state.route == PathData.URBAN_PATH, + onClick = { + onEnvironmentChange(true) + onUserTouch() + }, ) - Text( - text = env.label, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(start = 4.dp), + Text("Urban (SF)") + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + onEnvironmentChange(false) + onUserTouch() + }, + ) { + RadioButton( + selected = state.route == PathData.RURAL_PATH, + onClick = { + onEnvironmentChange(false) + onUserTouch() + }, ) + Text("Rural (Marin)") } } - } - // Play / Pause and Progress Slider Row - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - IconButton( - onClick = { isPlaying = !isPlaying }, + // Altitude Mode + Text("Altitude Mode:", style = MaterialTheme.typography.labelLarge) + Column { + AltitudeModeOption.values().forEach { modeOption -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + onAltitudeModeChange(modeOption) + onUserTouch() + }, + ) { + RadioButton( + selected = state.altitudeMode == modeOption.mode, + onClick = { + onAltitudeModeChange(modeOption) + onUserTouch() + }, + ) + Text(modeOption.label) + } + } + } + + // Occlusion Toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, - contentDescription = "Play or Pause animation", + Text("Draws Occluded Segments") + Switch( + checked = state.drawsOccludedSegments, + onCheckedChange = { + onOcclusionChange(it) + onUserTouch() + }, ) } + // Path Altitude Offset Slider + val maxPathAlt = if (state.route == PathData.URBAN_PATH) 20f else 200f + Text("Path Height Offset: ${state.pathAltitudeOffset.toInt()} m") Slider( - value = progress, - onValueChange = { newValue -> - isUserScrubbing = true - progress = newValue - elapsedDistance = totalDistance * newValue - updatePositionForDistance(elapsedDistance) + value = state.pathAltitudeOffset.toFloat().coerceIn(0f, maxPathAlt), + onValueChange = { + onPathAltitudeOffsetChange(it) + onUserTouch() }, - onValueChangeFinished = { - isUserScrubbing = false - }, - modifier = Modifier - .weight(1f) - .semantics { contentDescription = "Path Progress Slider" }, + valueRange = 0f..maxPathAlt, ) - } - // Camera Controls Sliders - Text( - text = "Camera Range: ${cameraRange.toInt()}m", - style = MaterialTheme.typography.labelMedium, - ) - Slider( - value = cameraRange, - onValueChange = { - cameraRange = it - updatePositionForDistance(elapsedDistance) - }, - valueRange = 50f..1000f, - modifier = Modifier.semantics { contentDescription = "Camera Range Slider" }, - ) + // Camera Range Slider + Text("Camera Range: ${state.cameraRange.toInt()} m") + Slider( + value = state.cameraRange.toFloat().coerceIn(50f, 1500f), + onValueChange = { + onCameraRangeChange(it) + onUserTouch() + }, + valueRange = 50f..1500f, + ) - val maxAltitude = if (selectedEnv == EnvironmentType.RURAL) 2000f else 200f - Text( - text = "Ground Altitude: ${groundAltitude.toInt()}m", - style = MaterialTheme.typography.labelMedium, - ) - Slider( - value = groundAltitude.coerceIn(2f, maxAltitude), - onValueChange = { - groundAltitude = it - updatePositionForDistance(elapsedDistance) - }, - valueRange = 2f..maxAltitude, - modifier = Modifier.semantics { contentDescription = "Ground Altitude Slider" }, - ) + // Ground Altitude Slider + Text("Ground Altitude: ${state.groundAltitude.toInt()} m") + Slider( + value = state.groundAltitude.toFloat().coerceIn(0f, 500f), + onValueChange = { + onGroundAltitudeChange(it) + onUserTouch() + }, + valueRange = 0f..500f, + ) - Text( - text = "Heading Offset: ${headingOffset.toInt()}°", - style = MaterialTheme.typography.labelMedium, - ) - Slider( - value = headingOffset, - onValueChange = { - headingOffset = it - updatePositionForDistance(elapsedDistance) - }, - valueRange = -180f..180f, - modifier = Modifier.semantics { contentDescription = "Heading Offset Slider" }, - ) + // Camera Heading Offset Slider + Text("Heading Offset: ${state.headingOffset.toInt()}°") + Slider( + value = state.headingOffset.toFloat().coerceIn(-180f, 180f), + onValueChange = { + onHeadingOffsetChange(it) + onUserTouch() + }, + valueRange = -180f..180f, + ) - Text( - text = "Camera Tilt: ${cameraTilt.toInt()}°", - style = MaterialTheme.typography.labelMedium, - ) - Slider( - value = cameraTilt, - onValueChange = { - cameraTilt = it - updatePositionForDistance(elapsedDistance) - }, - valueRange = 0f..85f, - modifier = Modifier.semantics { contentDescription = "Camera Tilt Slider" }, - ) + // Camera Tilt Slider + Text("Camera Tilt: ${state.cameraTilt.toInt()}°") + Slider( + value = state.cameraTilt.toFloat().coerceIn(0f, 85f), + onValueChange = { + onCameraTiltChange(it) + onUserTouch() + }, + valueRange = 0f..85f, + ) - Text( - text = "Follow Speed: ${followSpeedMps.toInt()} m/s", - style = MaterialTheme.typography.labelMedium, - ) - Slider( - value = followSpeedMps, - onValueChange = { followSpeedMps = it }, - valueRange = 5f..100f, - modifier = Modifier.semantics { contentDescription = "Follow Speed Slider" }, - ) + // Follow Speed Slider + val boostSuffix = when { + state.speedBoostMultiplier >= 4.5 -> " (5x Fast-Forward)" + state.speedBoostMultiplier <= -4.5 -> " (-5x Rewind)" + state.speedBoostMultiplier >= 1.5 -> " (2x Boost)" + else -> "" + } + Text("Follow Speed: ${state.followSpeedMps.toInt()} m/s$boostSuffix") + Slider( + value = state.followSpeedMps.toFloat().coerceIn(5f, 200f), + onValueChange = { + onSpeedChange(it) + onUserTouch() + }, + valueRange = 5f..200f, + ) + } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a4e94c8..14f3a9f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,6 +47,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activityCompose" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } From 3e18f897e3a2ebd695e7051302b4d88eb7b4c191 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:04:46 -0600 Subject: [PATCH 2/3] test(path-following): add unit test suites for PathEngine and PathFollowingViewModel --- .../ApiDemos/common/build.gradle.kts | 47 ++++++ .../example/maps3d/common/PathEngineTest.kt | 151 ++++++++++++++++++ .../common/PathFollowingViewModelTest.kt | 150 +++++++++++++++++ 3 files changed, 348 insertions(+) create mode 100644 Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathEngineTest.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathFollowingViewModelTest.kt diff --git a/Maps3DSamples/ApiDemos/common/build.gradle.kts b/Maps3DSamples/ApiDemos/common/build.gradle.kts index 8f4664e..b28d21d 100644 --- a/Maps3DSamples/ApiDemos/common/build.gradle.kts +++ b/Maps3DSamples/ApiDemos/common/build.gradle.kts @@ -15,6 +15,7 @@ */ plugins { + jacoco alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) @@ -27,6 +28,10 @@ android { namespace = "com.example.maps3dcommon" compileSdk = libs.versions.compileSdk.get().toInt() + testOptions { + unitTests.isReturnDefaultValues = true + } + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() @@ -64,6 +69,8 @@ dependencies { api(libs.androidx.lifecycle.viewmodel.ktx) testImplementation(libs.junit) testImplementation(libs.google.truth) + testImplementation("androidx.arch.core:core-testing:2.2.0") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0") androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) implementation(platform(libs.androidx.compose.bom)) @@ -75,3 +82,43 @@ dependencies { api(libs.play.services.maps3d) // "com.google.android.gms:play-services-maps3d:0.2.2" api(libs.maps.utils.ktx) } + +jacoco { + toolVersion = "0.8.11" +} + +tasks.register("jacocoTestReport") { + dependsOn("testDebugUnitTest") + reports { + xml.required.set(true) + html.required.set(true) + csv.required.set(false) + } + + val fileFilter = listOf( + "**/R.class", + "**/R$*.class", + "**/BuildConfig.*", + "**/Manifest*.*", + "**/*Test*.*", + "android/**/*.*" + ) + val debugTree = fileTree("${layout.buildDirectory.get()}/tmp/kotlin-classes/debug") { + exclude(fileFilter) + } + val mainSrc = "${project.projectDir}/src/main/java" + + sourceDirectories.setFrom(files(mainSrc)) + classDirectories.setFrom(files(debugTree)) + executionData.setFrom(fileTree(layout.buildDirectory) { + include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec", "jacoco/testDebugUnitTest.exec") + }) +} + +android { + buildTypes { + debug { + enableUnitTestCoverage = true + } + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathEngineTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathEngineTest.kt new file mode 100644 index 0000000..ed405d3 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathEngineTest.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.LatLngAltitude +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * JVM Unit Tests for [PathEngine]. + */ +class PathEngineTest { + + private val samplePath = listOf( + LatLngAltitude(37.7749, -122.4194, 10.0), + LatLngAltitude(37.7755, -122.4180, 20.0), + LatLngAltitude(37.7760, -122.4170, 30.0) + ) + + @Test + fun calculateCumulativeDistances_computesMonotonicallyIncreasingArray() { + val cumDist = PathEngine.calculateCumulativeDistances(samplePath) + assertEquals(3, cumDist.size) + assertEquals(0.0, cumDist[0], 0.001) + assertTrue(cumDist[1] > 0.0) + assertTrue(cumDist[2] > cumDist[1]) + } + + @Test + fun calculateCumulativeDistances_handlesEmpty() { + val empty = PathEngine.calculateCumulativeDistances(emptyList()) + assertEquals(1, empty.size) + assertEquals(0.0, empty[0], 0.001) + } + + @Test + fun interpolatePoint_atEndpointsAndMidpoints() { + val cumDist = PathEngine.calculateCumulativeDistances(samplePath) + val totalDist = cumDist.last() + + // At start (0m) + val startPt = PathEngine.interpolatePoint(samplePath, cumDist, 0.0) + assertEquals(samplePath.first().latitude, startPt.latLng.latitude, 0.0001) + assertEquals(samplePath.first().longitude, startPt.latLng.longitude, 0.0001) + assertEquals(10.0, startPt.altitude, 0.01) + + // At end (totalDist) + val endPt = PathEngine.interpolatePoint(samplePath, cumDist, totalDist) + assertEquals(samplePath.last().latitude, endPt.latLng.latitude, 0.0001) + assertEquals(samplePath.last().longitude, endPt.latLng.longitude, 0.0001) + assertEquals(30.0, endPt.altitude, 0.01) + + // At midpoint + val midPt = PathEngine.interpolatePoint(samplePath, cumDist, totalDist * 0.5) + assertTrue(midPt.latLng.latitude > samplePath.first().latitude) + assertTrue(midPt.altitude > 10.0 && midPt.altitude < 30.0) + assertTrue(midPt.bearing >= 0.0 && midPt.bearing <= 360.0) + } + + @Test + fun getInterpolatedLatLng_returnsBoundaryCoordinates() { + val cumDist = PathEngine.calculateCumulativeDistances(samplePath) + val totalDist = cumDist.last() + + val start = PathEngine.getInterpolatedLatLng(samplePath, cumDist, -10.0) + assertEquals(samplePath.first().latitude, start.latitude, 0.0001) + + val end = PathEngine.getInterpolatedLatLng(samplePath, cumDist, totalDist + 50.0) + assertEquals(samplePath.last().latitude, end.latitude, 0.0001) + } + + @Test + fun smoothHeading_appliesEmaWhenPlaying() { + // Not playing -> returns target heading immediately + val initial = PathEngine.smoothHeading(90.0, currentHeading = 0.0, isUserScrubbing = false, isPlaying = false) + assertEquals(90.0, initial, 0.001) + + // User scrubbing -> returns target heading immediately + val scrubbing = PathEngine.smoothHeading(90.0, currentHeading = 0.0, isUserScrubbing = true, isPlaying = true) + assertEquals(90.0, scrubbing, 0.001) + + // Playing -> smoothed EMA step + val smoothed = PathEngine.smoothHeading(90.0, currentHeading = 0.0, isUserScrubbing = false, isPlaying = true, smoothingFactor = 0.5) + assertEquals(45.0, smoothed, 0.01) + } + + @Test + fun calculateCameraAltitude_computesCorrectAltitudePerMode() { + // CLAMP_TO_GROUND -> returns groundAltitude + val clampAlt = PathEngine.calculateCameraAltitude( + altitudeMode = AltitudeMode.CLAMP_TO_GROUND, + baseAltitude = 50.0, + interpolatedAltitude = 15.0, + groundAltitude = 120.0 + ) + assertEquals(120.0, clampAlt, 0.001) + + // ABSOLUTE -> baseAltitude + interpolatedAltitude + groundAltitude + val absAlt = PathEngine.calculateCameraAltitude( + altitudeMode = AltitudeMode.ABSOLUTE, + baseAltitude = 50.0, + interpolatedAltitude = 15.0, + groundAltitude = 120.0 + ) + assertEquals(185.0, absAlt, 0.001) + } + + @Test + fun buildStaticVertices_and_buildProgressVertices() { + val staticVertices = PathEngine.buildStaticVertices( + path = samplePath, + altitudeMode = AltitudeMode.ABSOLUTE, + baseAltitude = 50.0, + pathAltitudeOffset = 5.0 + ) + assertEquals(3, staticVertices.size) + assertEquals(65.0, staticVertices[0].altitude, 0.01) // 10 + 50 + 5 + assertEquals(75.0, staticVertices[1].altitude, 0.01) // 20 + 50 + 5 + + val cumDist = PathEngine.calculateCumulativeDistances(samplePath) + val interp = PathEngine.interpolatePoint(samplePath, cumDist, cumDist[1]) + val progressVertices = PathEngine.buildProgressVertices( + path = samplePath, + cumulativeDistances = cumDist, + elapsedDistance = cumDist[1], + currentLatLng = interp.latLng, + waypointIndex = interp.waypointIndex, + altitudeMode = AltitudeMode.ABSOLUTE, + baseAltitude = 50.0, + pathAltitudeOffset = 5.0 + ) + assertTrue(progressVertices.size >= 2) + assertEquals(65.4, progressVertices[0].altitude, 0.01) // 65 + 0.4 depth bias + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathFollowingViewModelTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathFollowingViewModelTest.kt new file mode 100644 index 0000000..894e786 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathFollowingViewModelTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.google.android.gms.maps3d.model.AltitudeMode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +/** + * JVM Unit Tests for [PathFollowingViewModel]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PathFollowingViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + private lateinit var viewModel: PathFollowingViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + viewModel = PathFollowingViewModel() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun initialState_matchesDefaultRoute() { + val state = viewModel.currentState + assertEquals(PathData.URBAN_PATH, state.route) + assertFalse(state.isPlaying) + assertEquals(0.0, state.elapsedDistance, 0.001) + } + + @Test + fun playPauseToggle_updatesFlowAndState() { + assertFalse(viewModel.currentState.isPlaying) + viewModel.togglePlayPause() + assertTrue(viewModel.currentState.isPlaying) + viewModel.setPlaying(false) + assertFalse(viewModel.currentState.isPlaying) + } + + @Test + fun seekAndSkip_updatesProgress() { + viewModel.seekToRatio(0.5f) + assertEquals(0.5f, viewModel.currentState.progressRatio, 0.01f) + + viewModel.skipRatio(0.10f) + assertEquals(0.60f, viewModel.currentState.progressRatio, 0.02f) + + viewModel.skipDistance(50.0) + assertTrue(viewModel.currentState.elapsedDistance > 0.0) + + viewModel.seekToDistance(100.0) + assertTrue(viewModel.currentState.elapsedDistance >= 99.0) + } + + @Test + fun setters_and_gestures_updateState() { + viewModel.setAltitudeMode(AltitudeMode.RELATIVE_TO_GROUND) + assertEquals(AltitudeMode.RELATIVE_TO_GROUND, viewModel.currentState.altitudeMode) + + viewModel.setDrawsOccludedSegments(true) + assertTrue(viewModel.currentState.drawsOccludedSegments) + + viewModel.setFollowSpeed(60.0) + assertEquals(60.0, viewModel.currentState.followSpeedMps, 0.001) + + viewModel.setCameraRange(400.0) + assertEquals(400.0, viewModel.currentState.cameraRange, 0.001) + + viewModel.setCameraTilt(65.0) + assertEquals(65.0, viewModel.currentState.cameraTilt, 0.001) + + viewModel.setHeadingOffset(30.0) + assertEquals(30.0, viewModel.currentState.headingOffset, 0.001) + + viewModel.setGroundAltitude(150.0) + assertEquals(150.0, viewModel.currentState.groundAltitude, 0.001) + + viewModel.setPathAltitudeOffset(15.0) + assertEquals(15.0, viewModel.currentState.pathAltitudeOffset, 0.001) + + viewModel.setScrubbing(true) + assertTrue(viewModel.currentState.isScrubbing) + + // Gesture adjustments + viewModel.adjustTilt(10.0) + assertEquals(75.0, viewModel.currentState.cameraTilt, 0.001) + + viewModel.adjustHeading(15.0) + assertEquals(45.0, viewModel.currentState.headingOffset, 0.001) + + viewModel.adjustRange(2.0) + assertEquals(200.0, viewModel.currentState.cameraRange, 0.001) + + // Speed multipliers + viewModel.setSpeedBoostMultiplier(5.0) + assertEquals(5.0, viewModel.currentState.speedBoostMultiplier, 0.001) + assertTrue(viewModel.currentState.isSpeedBoosted) + + viewModel.setSpeedBoosted(false) + assertEquals(1.0, viewModel.currentState.speedBoostMultiplier, 0.001) + + // Switch route + viewModel.setRoute(PathData.RURAL_PATH, applyDefaults = true) + assertEquals(PathData.RURAL_PATH, viewModel.currentState.route) + + viewModel.reset() + assertEquals(0.0, viewModel.currentState.elapsedDistance, 0.001) + } + + @Test + fun advance_progressesPlayback() { + viewModel.setPlaying(true) + viewModel.advance(1.0) + assertTrue(viewModel.currentState.elapsedDistance > 0.0) + } +} From f1cd8c98cc29bf2d3797a70d648f4a435da83b6a Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:22:42 -0600 Subject: [PATCH 3/3] refactor(path-following): clean PathPlaybackState data class and remove unused strings --- .../maps3d/common/PathPlaybackController.kt | 82 +++---------------- .../common/src/main/res/values/strings.xml | 5 -- 2 files changed, 13 insertions(+), 74 deletions(-) diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathPlaybackController.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathPlaybackController.kt index 941c7be..23df5e5 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathPlaybackController.kt +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathPlaybackController.kt @@ -25,7 +25,6 @@ import com.google.android.gms.maps3d.model.LatLngAltitude */ data class PathPlaybackState( val route: List = PathData.URBAN_PATH, - val cumulativeDistances: DoubleArray = doubleArrayOf(0.0), val totalDistance: Double = 0.0, val elapsedDistance: Double = 0.0, val progressRatio: Float = 0.0f, @@ -65,60 +64,6 @@ data class PathPlaybackState( val effectiveSpeedMps: Double get() = followSpeedMps * speedBoostMultiplier - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is PathPlaybackState) return false - - return route == other.route && - cumulativeDistances.contentEquals(other.cumulativeDistances) && - totalDistance == other.totalDistance && - elapsedDistance == other.elapsedDistance && - progressRatio == other.progressRatio && - isPlaying == other.isPlaying && - isScrubbing == other.isScrubbing && - speedBoostMultiplier == other.speedBoostMultiplier && - followSpeedMps == other.followSpeedMps && - cameraRange == other.cameraRange && - groundAltitude == other.groundAltitude && - headingOffset == other.headingOffset && - cameraTilt == other.cameraTilt && - altitudeMode == other.altitudeMode && - pathAltitudeOffset == other.pathAltitudeOffset && - drawsOccludedSegments == other.drawsOccludedSegments && - currentPosition == other.currentPosition && - currentAltitude == other.currentAltitude && - currentHeading == other.currentHeading && - cameraHeading == other.cameraHeading && - staticPolylineVertices == other.staticPolylineVertices && - progressPolylineVertices == other.progressPolylineVertices - } - - override fun hashCode(): Int { - var result = route.hashCode() - result = 31 * result + cumulativeDistances.contentHashCode() - result = 31 * result + totalDistance.hashCode() - result = 31 * result + elapsedDistance.hashCode() - result = 31 * result + progressRatio.hashCode() - result = 31 * result + isPlaying.hashCode() - result = 31 * result + isScrubbing.hashCode() - result = 31 * result + speedBoostMultiplier.hashCode() - result = 31 * result + followSpeedMps.hashCode() - result = 31 * result + cameraRange.hashCode() - result = 31 * result + groundAltitude.hashCode() - result = 31 * result + headingOffset.hashCode() - result = 31 * result + cameraTilt.hashCode() - result = 31 * result + altitudeMode.hashCode() - result = 31 * result + pathAltitudeOffset.hashCode() - result = 31 * result + drawsOccludedSegments.hashCode() - result = 31 * result + currentPosition.hashCode() - result = 31 * result + currentAltitude.hashCode() - result = 31 * result + currentHeading.hashCode() - result = 31 * result + cameraHeading.hashCode() - result = 31 * result + staticPolylineVertices.hashCode() - result = 31 * result + progressPolylineVertices.hashCode() - return result - } } /** @@ -131,11 +76,12 @@ data class PathPlaybackState( class PathPlaybackController( initialRoute: List = PathData.URBAN_PATH ) { + private var cumulativeDistances: DoubleArray private var state: PathPlaybackState init { - val cumDist = PathEngine.calculateCumulativeDistances(initialRoute) - val totalDist = cumDist.lastOrNull() ?: 0.0 + cumulativeDistances = PathEngine.calculateCumulativeDistances(initialRoute) + val totalDist = cumulativeDistances.lastOrNull() ?: 0.0 val baseAlt = if (initialRoute == PathData.RURAL_PATH) 45.0 else 50.0 val staticVertices = PathEngine.buildStaticVertices( path = initialRoute, @@ -146,13 +92,13 @@ class PathPlaybackController( val point = PathEngine.interpolatePoint( path = initialRoute, - cumulativeDistances = cumDist, + cumulativeDistances = cumulativeDistances, distance = 0.0 ) val progressVertices = PathEngine.buildProgressVertices( path = initialRoute, - cumulativeDistances = cumDist, + cumulativeDistances = cumulativeDistances, elapsedDistance = 0.0, currentLatLng = point.latLng, waypointIndex = point.waypointIndex, @@ -163,7 +109,6 @@ class PathPlaybackController( state = PathPlaybackState( route = initialRoute, - cumulativeDistances = cumDist, totalDistance = totalDist, elapsedDistance = 0.0, progressRatio = 0f, @@ -246,8 +191,8 @@ class PathPlaybackController( } fun setRoute(newRoute: List, applyDefaults: Boolean = true): PathPlaybackState { - val cumDist = PathEngine.calculateCumulativeDistances(newRoute) - val totalDist = cumDist.lastOrNull() ?: 0.0 + cumulativeDistances = PathEngine.calculateCumulativeDistances(newRoute) + val totalDist = cumulativeDistances.lastOrNull() ?: 0.0 val isRural = newRoute == PathData.RURAL_PATH val range = if (applyDefaults) (if (isRural) 450.0 else 300.0) else state.cameraRange @@ -257,7 +202,7 @@ class PathPlaybackController( val point = PathEngine.interpolatePoint( path = newRoute, - cumulativeDistances = cumDist, + cumulativeDistances = cumulativeDistances, distance = 0.0 ) @@ -270,7 +215,7 @@ class PathPlaybackController( val progressVertices = PathEngine.buildProgressVertices( path = newRoute, - cumulativeDistances = cumDist, + cumulativeDistances = cumulativeDistances, elapsedDistance = 0.0, currentLatLng = point.latLng, waypointIndex = point.waypointIndex, @@ -281,7 +226,6 @@ class PathPlaybackController( state = state.copy( route = newRoute, - cumulativeDistances = cumDist, totalDistance = totalDist, elapsedDistance = 0.0, progressRatio = 0f, @@ -390,7 +334,7 @@ class PathPlaybackController( private fun updateDistanceAndRecompute(newDistance: Double, updateProgressRatio: Boolean): PathPlaybackState { val point = PathEngine.interpolatePoint( path = state.route, - cumulativeDistances = state.cumulativeDistances, + cumulativeDistances = cumulativeDistances, distance = newDistance ) @@ -404,7 +348,7 @@ class PathPlaybackController( val progressVertices = PathEngine.buildProgressVertices( path = state.route, - cumulativeDistances = state.cumulativeDistances, + cumulativeDistances = cumulativeDistances, elapsedDistance = newDistance, currentLatLng = point.latLng, waypointIndex = point.waypointIndex, @@ -434,7 +378,7 @@ class PathPlaybackController( private fun recomputeVerticesAndAltitude(): PathPlaybackState { val point = PathEngine.interpolatePoint( path = state.route, - cumulativeDistances = state.cumulativeDistances, + cumulativeDistances = cumulativeDistances, distance = state.elapsedDistance ) @@ -447,7 +391,7 @@ class PathPlaybackController( val progressVertices = PathEngine.buildProgressVertices( path = state.route, - cumulativeDistances = state.cumulativeDistances, + cumulativeDistances = cumulativeDistances, elapsedDistance = state.elapsedDistance, currentLatLng = point.latLng, waypointIndex = point.waypointIndex, diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml index 3d61f5c..34768b3 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml +++ b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml @@ -141,11 +141,6 @@ San Francisco Aerial Tour - Select Animation Approach: - 1. SDK Simple flyTo (Native Transition) - 2. Declarative Keyframe Queue Tour - 3. High-Rate Frame Dispatcher Loop - 4. 360-Degree Continuous Orbit Spin Press Play to begin the multi-step aerial tour. Step %1$d of %2$d: %3$s Tour complete. Press Reset to replay.