From ccd141b219d67783344ae48b1c9b43abc26de022 Mon Sep 17 00:00:00 2001 From: Ashik Abbas Date: Mon, 7 Sep 2026 17:25:05 +0530 Subject: [PATCH 1/4] feat(path-following): dynamic route calibration, authentic uphill hiking trail, and altitude controls UX --- .../maps3d/common/MaxHeightScrollView.kt | 56 +++ .../com/example/maps3d/common/PathData.kt | 274 +++++++++++++- .../com/example/maps3d/common/PathEngine.kt | 238 ++++++++++-- .../maps3d/common/PathFollowingViewModel.kt | 84 +++++ .../maps3d/common/PathPlaybackController.kt | 43 ++- .../src/main/res/drawable/info_24px.xml | 9 + .../res/layout/activity_path_following.xml | 86 +++-- .../common/src/main/res/values/strings.xml | 9 +- .../example/maps3d/common/PathEngineTest.kt | 106 +++++- .../common/PathFollowingViewModelTest.kt | 11 + .../common/PathPlaybackControllerTest.kt | 23 +- .../pathfollowing/PathFollowingActivity.java | 276 +++++++++++--- .../pathfollowing/PathFollowingActivity.kt | 243 ++++++++++--- .../pathfollowing/PathFollowingActivity.kt | 341 ++++++++++++++---- 14 files changed, 1500 insertions(+), 299 deletions(-) create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/MaxHeightScrollView.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/res/drawable/info_24px.xml diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/MaxHeightScrollView.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/MaxHeightScrollView.kt new file mode 100644 index 00000000..6a5820e1 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/MaxHeightScrollView.kt @@ -0,0 +1,56 @@ +/* + * 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.util.AttributeSet +import android.widget.ScrollView + +/** + * A [ScrollView] that dynamically constrains its maximum measured height so that + * collapsible control panels never cover the entire display across varied screen sizes + * and orientations. + */ +class MaxHeightScrollView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : ScrollView(context, attrs, defStyleAttr) { + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val displayMetrics = context.resources.displayMetrics + val density = displayMetrics.density + val screenHeight = displayMetrics.heightPixels + + // Constrain height to at most 260dp or 60% of total screen height + val maxDpHeight = (480 * density).toInt() + val maxScreenShare = (screenHeight * 0.60f).toInt() + val effectiveMax = minOf(maxDpHeight, maxScreenShare) + + val originalSize = MeasureSpec.getSize(heightMeasureSpec) + val originalMode = MeasureSpec.getMode(heightMeasureSpec) + + val targetHeight = if (originalMode != MeasureSpec.UNSPECIFIED && originalSize > 0) { + minOf(effectiveMax, originalSize) + } else { + effectiveMax + } + + val constrainedHeightSpec = MeasureSpec.makeMeasureSpec(targetHeight, MeasureSpec.AT_MOST) + super.onMeasure(widthMeasureSpec, constrainedHeightSpec) + } +} 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 index a13fef69..bf30a220 100644 --- 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 @@ -66,28 +66,262 @@ object PathData { /** * Rural Route: Coastal highway and mountain switchbacks near Pescadero, CA. + * Snapped to real-time road curvature from Google Routes API (112 waypoints). */ @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) + LatLngAltitude(37.254420, -122.381400, 10.1), + LatLngAltitude(37.255070, -122.381630, 9.9), + LatLngAltitude(37.255070, -122.381920, 9.7), + LatLngAltitude(37.255150, -122.382530, 9.8), + LatLngAltitude(37.255240, -122.383230, 10.9), + LatLngAltitude(37.253400, -122.383160, 10.4), + LatLngAltitude(37.252680, -122.383100, 10.5), + LatLngAltitude(37.252000, -122.382970, 10.2), + LatLngAltitude(37.251880, -122.382970, 10.1), + LatLngAltitude(37.251660, -122.382920, 9.8), + LatLngAltitude(37.251410, -122.384860, 8.5), + LatLngAltitude(37.250140, -122.395120, 4.4), + LatLngAltitude(37.249730, -122.398740, 4.8), + LatLngAltitude(37.249720, -122.399170, 4.9), + LatLngAltitude(37.249760, -122.399590, 5.0), + LatLngAltitude(37.249860, -122.400070, 5.0), + LatLngAltitude(37.250060, -122.400580, 4.9), + LatLngAltitude(37.250230, -122.400910, 5.0), + LatLngAltitude(37.250530, -122.401310, 4.9), + LatLngAltitude(37.250700, -122.401480, 4.7), + LatLngAltitude(37.251970, -122.402620, 4.9), + LatLngAltitude(37.252200, -122.402910, 4.8), + LatLngAltitude(37.252430, -122.403320, 4.8), + LatLngAltitude(37.252580, -122.403730, 4.9), + LatLngAltitude(37.253270, -122.405810, 4.7), + LatLngAltitude(37.253440, -122.406190, 4.8), + LatLngAltitude(37.253720, -122.406620, 4.5), + LatLngAltitude(37.253990, -122.406920, 4.3), + LatLngAltitude(37.254320, -122.407180, 4.3), + LatLngAltitude(37.255380, -122.407760, 6.7), + LatLngAltitude(37.258040, -122.409140, 13.0), + LatLngAltitude(37.258370, -122.409360, 13.8), + LatLngAltitude(37.258730, -122.409720, 14.8), + LatLngAltitude(37.259040, -122.410150, 15.8), + LatLngAltitude(37.259250, -122.410530, 16.6), + LatLngAltitude(37.259420, -122.410980, 17.3), + LatLngAltitude(37.259540, -122.411490, 17.4), + LatLngAltitude(37.259590, -122.411820, 17.2), + LatLngAltitude(37.259700, -122.413170, 15.6), + LatLngAltitude(37.261630, -122.412750, 15.0), + LatLngAltitude(37.263680, -122.412250, 14.3), + LatLngAltitude(37.264060, -122.412160, 14.2), + LatLngAltitude(37.264190, -122.412210, 14.2), + LatLngAltitude(37.264310, -122.412160, 14.0), + LatLngAltitude(37.265160, -122.411950, 13.1), + LatLngAltitude(37.265870, -122.411680, 9.4), + LatLngAltitude(37.266480, -122.411390, 1.6), + LatLngAltitude(37.267140, -122.411000, 9.6), + LatLngAltitude(37.268110, -122.410400, 7.6), + LatLngAltitude(37.268560, -122.410170, 6.6), + LatLngAltitude(37.269450, -122.409840, 5.1), + LatLngAltitude(37.270150, -122.409630, 4.5), + LatLngAltitude(37.275560, -122.408270, 19.7), + LatLngAltitude(37.276080, -122.408190, 23.3), + LatLngAltitude(37.276680, -122.408140, 27.5), + LatLngAltitude(37.277630, -122.408170, 34.0), + LatLngAltitude(37.279400, -122.408230, 46.2), + LatLngAltitude(37.280030, -122.408190, 50.1), + LatLngAltitude(37.280740, -122.408060, 52.9), + LatLngAltitude(37.281360, -122.407880, 53.9), + LatLngAltitude(37.282140, -122.407570, 53.4), + LatLngAltitude(37.282800, -122.407210, 51.1), + LatLngAltitude(37.283590, -122.406680, 46.3), + LatLngAltitude(37.285420, -122.405390, 40.8), + LatLngAltitude(37.285900, -122.405120, 40.4), + LatLngAltitude(37.286410, -122.404900, 40.1), + LatLngAltitude(37.286990, -122.404730, 39.6), + LatLngAltitude(37.287600, -122.404640, 39.3), + LatLngAltitude(37.288070, -122.404640, 38.9), + LatLngAltitude(37.288580, -122.404680, 38.3), + LatLngAltitude(37.289170, -122.404810, 37.7), + LatLngAltitude(37.290740, -122.405390, 35.8), + LatLngAltitude(37.291860, -122.405830, 37.8), + LatLngAltitude(37.293070, -122.406250, 44.3), + LatLngAltitude(37.293360, -122.406320, 44.9), + LatLngAltitude(37.293810, -122.406370, 45.2), + LatLngAltitude(37.294420, -122.406370, 44.4), + LatLngAltitude(37.295040, -122.406270, 41.7), + LatLngAltitude(37.295560, -122.406110, 38.2), + LatLngAltitude(37.296070, -122.405880, 34.0), + LatLngAltitude(37.297320, -122.405160, 23.2), + LatLngAltitude(37.297900, -122.404910, 18.3), + LatLngAltitude(37.298420, -122.404760, 14.1), + LatLngAltitude(37.298830, -122.404690, 11.0), + LatLngAltitude(37.299490, -122.404660, 8.0), + LatLngAltitude(37.305570, -122.404640, 48.9), + LatLngAltitude(37.306650, -122.404590, 52.4), + LatLngAltitude(37.307900, -122.404460, 50.9), + LatLngAltitude(37.316640, -122.403260, 49.3), + LatLngAltitude(37.317660, -122.403050, 41.3), + LatLngAltitude(37.318760, -122.402760, 32.5), + LatLngAltitude(37.319880, -122.402380, 23.4), + LatLngAltitude(37.320920, -122.401940, 3.2), + LatLngAltitude(37.321750, -122.401540, 10.6), + LatLngAltitude(37.322640, -122.401060, 9.7), + LatLngAltitude(37.323740, -122.400420, 14.8), + LatLngAltitude(37.324560, -122.399860, 21.2), + LatLngAltitude(37.324480, -122.399680, 20.3), + LatLngAltitude(37.324700, -122.392770, 20.4), + LatLngAltitude(37.324730, -122.392370, 20.6), + LatLngAltitude(37.324880, -122.391740, 20.2), + LatLngAltitude(37.325000, -122.391400, 19.9), + LatLngAltitude(37.325330, -122.390750, 19.0), + LatLngAltitude(37.326050, -122.389410, 18.9), + LatLngAltitude(37.326210, -122.389040, 19.0), + LatLngAltitude(37.326360, -122.388570, 19.2), + LatLngAltitude(37.326860, -122.386510, 19.7), + LatLngAltitude(37.326270, -122.386430, 14.6), + LatLngAltitude(37.325810, -122.386420, 5.6), + LatLngAltitude(37.325670, -122.386450, 12.4), + LatLngAltitude(37.325460, -122.386550, 11.3), + LatLngAltitude(37.325280, -122.386740, 10.6) ) + + /** + * Mountain hiking trail in Griffith Park ascending to Mount Hollywood Summit. + * Snapped to real-time trail curvature from Google Routes API with monotonic terrain elevations (94 waypoints). + * Continuous uphill climb starting at the base trailhead (343.8m) and finishing right on the summit peak (457.0m). + */ + @JvmField + val MOUNTAIN_PATH: List = listOf( + LatLngAltitude(34.120800, -118.300500, 343.8), + LatLngAltitude(34.120830, -118.300510, 343.8), + LatLngAltitude(34.120880, -118.300290, 343.9), + LatLngAltitude(34.121000, -118.300420, 344.2), + LatLngAltitude(34.121170, -118.300510, 344.7), + LatLngAltitude(34.121390, -118.300540, 345.9), + LatLngAltitude(34.121630, -118.300550, 348.6), + LatLngAltitude(34.121800, -118.300610, 350.0), + LatLngAltitude(34.122030, -118.300800, 353.7), + LatLngAltitude(34.122200, -118.300860, 356.8), + LatLngAltitude(34.122630, -118.300920, 362.0), + LatLngAltitude(34.122920, -118.301000, 363.0), + LatLngAltitude(34.123010, -118.301100, 363.1), + LatLngAltitude(34.123080, -118.301100, 363.2), + LatLngAltitude(34.123430, -118.300850, 363.3), + LatLngAltitude(34.123530, -118.300780, 363.4), + LatLngAltitude(34.123610, -118.300780, 363.5), + LatLngAltitude(34.123870, -118.300740, 363.6), + LatLngAltitude(34.123940, -118.300770, 363.7), + LatLngAltitude(34.123980, -118.300800, 363.8), + LatLngAltitude(34.124120, -118.301120, 363.9), + LatLngAltitude(34.124200, -118.301170, 364.0), + LatLngAltitude(34.124300, -118.301200, 364.1), + LatLngAltitude(34.124330, -118.301300, 364.2), + LatLngAltitude(34.124350, -118.301700, 364.3), + LatLngAltitude(34.124430, -118.301840, 365.0), + LatLngAltitude(34.124560, -118.301950, 365.7), + LatLngAltitude(34.124610, -118.301980, 366.1), + LatLngAltitude(34.124700, -118.301960, 366.6), + LatLngAltitude(34.125090, -118.301730, 369.2), + LatLngAltitude(34.125160, -118.301730, 369.7), + LatLngAltitude(34.125400, -118.301850, 371.0), + LatLngAltitude(34.125520, -118.302010, 372.5), + LatLngAltitude(34.125690, -118.302220, 373.8), + LatLngAltitude(34.125860, -118.302370, 374.7), + LatLngAltitude(34.126030, -118.302410, 375.4), + LatLngAltitude(34.126240, -118.302620, 376.8), + LatLngAltitude(34.126280, -118.302730, 377.9), + LatLngAltitude(34.126330, -118.302770, 377.9), + LatLngAltitude(34.126460, -118.302820, 377.9), + LatLngAltitude(34.126490, -118.302870, 377.9), + LatLngAltitude(34.126520, -118.302980, 378.4), + LatLngAltitude(34.126590, -118.303120, 379.3), + LatLngAltitude(34.126580, -118.303220, 379.3), + LatLngAltitude(34.126460, -118.303760, 379.6), + LatLngAltitude(34.126350, -118.303900, 379.6), + LatLngAltitude(34.126310, -118.304000, 379.6), + LatLngAltitude(34.126350, -118.304210, 380.0), + LatLngAltitude(34.126300, -118.304370, 380.6), + LatLngAltitude(34.126210, -118.304530, 380.9), + LatLngAltitude(34.126190, -118.304650, 381.0), + LatLngAltitude(34.126250, -118.304840, 381.3), + LatLngAltitude(34.126240, -118.304960, 381.5), + LatLngAltitude(34.126130, -118.305430, 382.7), + LatLngAltitude(34.126160, -118.305500, 383.0), + LatLngAltitude(34.126220, -118.305530, 383.5), + LatLngAltitude(34.126270, -118.305510, 383.8), + LatLngAltitude(34.126440, -118.304870, 392.3), + LatLngAltitude(34.126550, -118.304640, 396.3), + LatLngAltitude(34.126570, -118.304420, 398.3), + LatLngAltitude(34.126620, -118.304320, 398.9), + LatLngAltitude(34.126680, -118.304160, 399.7), + LatLngAltitude(34.127060, -118.303460, 404.6), + LatLngAltitude(34.127100, -118.303370, 405.2), + LatLngAltitude(34.127140, -118.303020, 408.1), + LatLngAltitude(34.127140, -118.302790, 409.9), + LatLngAltitude(34.127270, -118.302450, 413.4), + LatLngAltitude(34.127260, -118.302260, 415.3), + LatLngAltitude(34.127330, -118.302000, 417.7), + LatLngAltitude(34.127300, -118.301880, 419.2), + LatLngAltitude(34.127240, -118.301740, 420.9), + LatLngAltitude(34.127210, -118.301510, 424.0), + LatLngAltitude(34.127140, -118.301450, 425.2), + LatLngAltitude(34.126930, -118.301410, 426.5), + LatLngAltitude(34.126840, -118.301350, 427.5), + LatLngAltitude(34.126720, -118.301180, 429.9), + LatLngAltitude(34.126700, -118.300700, 436.0), + LatLngAltitude(34.126940, -118.300540, 436.4), + LatLngAltitude(34.127010, -118.300430, 436.8), + LatLngAltitude(34.127080, -118.300230, 437.2), + LatLngAltitude(34.127100, -118.299990, 437.5), + LatLngAltitude(34.127140, -118.299920, 437.9), + LatLngAltitude(34.127240, -118.299920, 438.3), + LatLngAltitude(34.128080, -118.300240, 438.7), + LatLngAltitude(34.128190, -118.300260, 440.2), + LatLngAltitude(34.128270, -118.300220, 440.8), + LatLngAltitude(34.128400, -118.300070, 442.0), + LatLngAltitude(34.128730, -118.299970, 444.8), + LatLngAltitude(34.128930, -118.299860, 448.0), + LatLngAltitude(34.129010, -118.299730, 449.4), + LatLngAltitude(34.129060, -118.299530, 451.5), + LatLngAltitude(34.129090, -118.299420, 452.8), + LatLngAltitude(34.129210, -118.299270, 455.0), + LatLngAltitude(34.129340, -118.298900, 457.0) + ) + + /** + * Decodes an encoded polyline string from the Google Routes API into a list of [LatLngAltitude]. + */ + @JvmStatic + fun decodePolyline(encoded: String, altitude: Double = 0.0): List { + val poly = mutableListOf() + var index = 0 + val len = encoded.length + var lat = 0 + var lng = 0 + + while (index < len) { + var b: Int + var shift = 0 + var result = 0 + do { + b = encoded[index++].code - 63 + result = result or (b and 0x1f shl shift) + shift += 5 + } while (b >= 0x20) + val dlat = if (result and 1 != 0) (result shr 1).inv() else result shr 1 + lat += dlat + + shift = 0 + result = 0 + do { + b = encoded[index++].code - 63 + result = result or (b and 0x1f shl shift) + shift += 5 + } while (b >= 0x20) + val dlng = if (result and 1 != 0) (result shr 1).inv() else result shr 1 + lng += dlng + + poly.add(LatLngAltitude(lat / 1E5, lng / 1E5, altitude)) + } + return poly + } } 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 index 050dbe8f..63b6c5ef 100644 --- 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 @@ -20,13 +20,14 @@ 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 +import kotlin.math.abs /** - * Result of interpolating a position and orientation along a 3D path at a specific distance. + * Result of path interpolation containing LatLng, segment index, lookahead bearing, and altitude. * - * @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 latLng The interpolated 2D geographic coordinates. + * @property waypointIndex The route vertex index immediately preceding or at the current position. + * @property bearing The forward camera heading in degrees [0.0, 360.0). * @property altitude The interpolated elevation in meters along the route segment. */ data class InterpolatedPathPoint( @@ -36,17 +37,167 @@ data class InterpolatedPathPoint( @JvmField val altitude: Double ) +/** + * Metrics and dynamic camera calibration parameters computed from route geometry. + * + * Automatically calibrated upon route loading to configure optimal camera framing, + * tilt angles, follow speed, and responsive slider boundaries without hardcoded values. + * + * @property totalDistance Total cumulative length of the route in meters. + * @property avgSegmentLength Average distance between consecutive route vertices. + * @property minAltitude Minimum elevation along the route in meters. + * @property maxAltitude Maximum elevation along the route in meters. + * @property baseAltitude Baseline reference elevation (minimum route altitude). + * @property topographicGrade Overall vertical climb relative to total distance (Δalt / L_total). + * @property recommendedRange Dynamically calibrated camera distance from target. + * @property recommendedTilt Dynamically calibrated camera pitch angle. + * @property recommendedSpeed Follow speed scaled to traverse the route in ~75 seconds. + * @property altitudeSliderMin Lower bound for the altitude trim slider. + * @property altitudeSliderMax Upper bound for the altitude trim slider. + * @property rangeSliderMin Lower bound for the camera range slider. + * @property rangeSliderMax Upper bound for the camera range slider. + * @property speedSliderMin Minimum speed slider value. + * @property speedSliderMax Maximum speed slider value. + */ +data class RouteProfile( + @JvmField val totalDistance: Double, + @JvmField val avgSegmentLength: Double, + @JvmField val minAltitude: Double, + @JvmField val maxAltitude: Double, + @JvmField val baseAltitude: Double, + @JvmField val topographicGrade: Double, + @JvmField val recommendedRange: Float, + @JvmField val recommendedTilt: Float, + @JvmField val recommendedSpeed: Float, + @JvmField val altitudeSliderMin: Float, + @JvmField val altitudeSliderMax: Float, + @JvmField val rangeSliderMin: Float, + @JvmField val rangeSliderMax: Float, + @JvmField val speedSliderMin: Float = 5.0f, + @JvmField val speedSliderMax: Float = 150.0f +) + /** * 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. + * dynamic route profiling, 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" + /** + * Extracts geographic and elevation metrics from an arbitrary path in O(N) time + * to compute optimal camera framing and UI slider configurations. + */ + @JvmStatic + fun profileRoute(path: List): RouteProfile { + if (path.isEmpty()) { + return RouteProfile( + totalDistance = 0.0, + avgSegmentLength = 50.0, + minAltitude = 0.0, + maxAltitude = 0.0, + baseAltitude = 0.0, + topographicGrade = 0.0, + recommendedRange = 100.0f, + recommendedTilt = 55.0f, + recommendedSpeed = 30.0f, + altitudeSliderMin = 0.0f, + altitudeSliderMax = 100.0f, + rangeSliderMin = 20.0f, + rangeSliderMax = 500.0f, + speedSliderMin = 5.0f, + speedSliderMax = 150.0f + ) + } + + var totalDist = 0.0 + var minAlt = path.first().altitude + var maxAlt = minAlt + + 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) + totalDist += SphericalUtil.computeDistanceBetween(pPrev, pCurr) + minAlt = minOf(minAlt, path[i].altitude) + maxAlt = maxOf(maxAlt, path[i].altitude) + } + + val avgSegLen = if (path.size > 1 && totalDist > 0.0) totalDist / (path.size - 1) else 50.0 + val altSpan = maxAlt - minAlt + val topoGrade = if (totalDist > 0.0) altSpan / totalDist else 0.0 + + // 1. Camera Range: R = clamp(2.5 * avgSegLen, 65m, 250m) + val recRange = (avgSegLen * 2.5).toFloat().coerceIn(65.0f, 250.0f) + val rangeMin = maxOf(20.0f, kotlin.math.round(recRange * 0.3f)) + val rangeMax = minOf(1000.0f, kotlin.math.round(recRange * 2.5f)) + + // 2. Base Altitude & Altitude Slider Bounds + val baseAlt = kotlin.math.round(minAlt) + val altMin: Float + val altMax: Float + if (minAlt > 50.0) { + altMin = maxOf(0.0f, kotlin.math.round(minAlt - 100.0).toFloat()) + altMax = kotlin.math.round(maxAlt + 350.0).toFloat() + } else { + altMin = 0.0f + altMax = maxOf(100.0f, kotlin.math.round(maxAlt + 50.0).toFloat()) + } + + // 3. Camera Tilt: 48° for steep grades / mountain switchbacks, 55° for urban/open highways + val isSteepOrSwitchback = topoGrade > 0.08 || avgSegLen < 35.0 + val recTilt = if (isSteepOrSwitchback) 48.0f else 55.0f + + // 4. Follow Speed: Scaled to complete route in ~75s + val recSpeed = if (totalDist > 0.0) { + (totalDist / 75.0).toFloat().coerceIn(10.0f, 40.0f) + } else { + 30.0f + } + val speedMax = recSpeed * 5.0f + + return RouteProfile( + totalDistance = totalDist, + avgSegmentLength = avgSegLen, + minAltitude = minAlt, + maxAltitude = maxAlt, + baseAltitude = baseAlt, + topographicGrade = topoGrade, + recommendedRange = recRange, + recommendedTilt = recTilt, + recommendedSpeed = recSpeed, + altitudeSliderMin = altMin, + altitudeSliderMax = altMax, + rangeSliderMin = rangeMin, + rangeSliderMax = rangeMax, + speedSliderMin = 5.0f, + speedSliderMax = speedMax + ) + } + + /** + * Calculates the shortest angular difference between two compass bearings (-180° to +180°). + */ + @JvmStatic + fun angularDifference(angleA: Double, angleB: Double): Double { + var diff = (angleA - angleB) % 360.0 + if (diff > 180.0) diff -= 360.0 + if (diff < -180.0) diff += 360.0 + return diff + } + + /** + * Performs spherical interpolation between two angles along the shortest arc. + */ + @JvmStatic + fun interpolateAngle(fromAngle: Double, toAngle: Double, fraction: Double): Double { + val diff = angularDifference(toAngle, fromAngle) + return (fromAngle + diff * fraction.coerceIn(0.0, 1.0) + 360.0) % 360.0 + } + /** * Precomputes cumulative distances along a 3D path in meters. */ @@ -95,8 +246,8 @@ object PathEngine { } /** - * Finds the interpolated geographic position, smooth forward lookahead bearing, - * and elevation at a target distance. + * Finds the interpolated geographic position, smooth forward lookahead bearing with + * corner-transition blending, and elevation at a target distance. */ @JvmStatic @JvmOverloads @@ -138,7 +289,7 @@ object PathEngine { val targetLookaheadDist = (distance + lookaheadDistance).coerceAtMost(totalDistance) val lookaheadPos = getInterpolatedLatLng(path, cumulativeDistances, targetLookaheadDist) - val bearing = if (targetLookaheadDist > distance && currentLatLng != lookaheadPos) { + var bearing = if (targetLookaheadDist > distance && currentLatLng != lookaheadPos) { SphericalUtil.computeHeading(currentLatLng, lookaheadPos) } else if (distance > 1.0) { val prevPos = getInterpolatedLatLng(path, cumulativeDistances, distance - 1.0) @@ -152,6 +303,16 @@ object PathEngine { 0.0 } + // Slerp corner blending if approaching the end of current segment (within 8 meters) + val distToEndOfSeg = segEndDist - distance + if (distToEndOfSeg in 0.0..8.0 && index < path.size - 2) { + val nextP1 = LatLng(path[index + 1].latitude, path[index + 1].longitude) + val nextP2 = LatLng(path[index + 2].latitude, path[index + 2].longitude) + val nextBearing = SphericalUtil.computeHeading(nextP1, nextP2) + val blendFactor = ((8.0 - distToEndOfSeg) / 8.0).coerceIn(0.0, 1.0) * 0.45 + bearing = interpolateAngle(bearing, nextBearing, blendFactor) + } + return InterpolatedPathPoint( latLng = currentLatLng, waypointIndex = index, @@ -161,42 +322,55 @@ object PathEngine { } /** - * Applies an Exponential Moving Average (EMA) filter to camera heading to smooth - * abrupt turns around corners during real-time playback. + * Applies an adaptive Exponential Moving Average (EMA) filter to camera heading to smooth + * abrupt turns around corners without lag during real-time playback. */ @JvmStatic + @JvmOverloads fun smoothHeading( targetHeading: Double, currentHeading: Double?, isUserScrubbing: Boolean, isPlaying: Boolean, - smoothingFactor: Double = 0.12 + smoothingFactor: Double? = null ): 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 + val diff = angularDifference(normalizedTarget, currentHeading) + + val alpha = smoothingFactor ?: when { + abs(diff) > 45.0 -> 0.40 // Fast turn tracking + abs(diff) > 20.0 -> 0.28 // Moderate curve tracking + else -> 0.16 // Smooth straightaway tracking + } + + return (currentHeading + diff * alpha + 360.0) % 360.0 } /** - * Calculates camera target altitude based on the active altitude mode and route elevation. + * Calculates camera target altitude based on the active altitude mode, route elevation, + * ground elevation trim, and an eye-level bias to prevent terrain clipping. */ @JvmStatic + @JvmOverloads fun calculateCameraAltitude( altitudeMode: Int, baseAltitude: Double, interpolatedAltitude: Double, - groundAltitude: Double + groundAltitude: Double, + cameraRange: Double = 100.0, + pathAltitudeOffset: Double = 0.0 ): Double { - return if (altitudeMode == AltitudeMode.ABSOLUTE) { - baseAltitude + interpolatedAltitude + groundAltitude - } else { - groundAltitude + val eyeLevelBias = (cameraRange * 0.035).coerceIn(2.0, 8.0) + val groundTrim = groundAltitude - baseAltitude + val surfaceElevation = interpolatedAltitude + groundTrim + + return when (altitudeMode) { + AltitudeMode.CLAMP_TO_GROUND -> surfaceElevation + eyeLevelBias + else -> surfaceElevation + pathAltitudeOffset + eyeLevelBias } } @@ -207,14 +381,14 @@ object PathEngine { 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 + AltitudeMode.ABSOLUTE -> pt.altitude + pathAltitudeOffset + AltitudeMode.RELATIVE_TO_GROUND, AltitudeMode.RELATIVE_TO_MESH -> pathAltitudeOffset + else -> pathAltitudeOffset } LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude) } @@ -231,7 +405,6 @@ object PathEngine { currentLatLng: LatLng, waypointIndex: Int, altitudeMode: Int, - baseAltitude: Double, pathAltitudeOffset: Double ): List { if (path.isEmpty()) return emptyList() @@ -243,8 +416,9 @@ object PathEngine { 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 + AltitudeMode.ABSOLUTE -> pt.altitude + pathAltitudeOffset + 0.4 + AltitudeMode.RELATIVE_TO_GROUND, AltitudeMode.RELATIVE_TO_MESH -> pathAltitudeOffset + 0.4 + else -> pathAltitudeOffset + 0.4 } progressCoordinates.add( LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude) @@ -267,8 +441,9 @@ object PathEngine { val progressAltitude = when (altitudeMode) { AltitudeMode.CLAMP_TO_GROUND -> 0.0 - AltitudeMode.ABSOLUTE -> interpAlt + baseAltitude + pathAltitudeOffset + 0.4 - else -> interpAlt + pathAltitudeOffset + 0.4 + AltitudeMode.ABSOLUTE -> interpAlt + pathAltitudeOffset + 0.4 + AltitudeMode.RELATIVE_TO_GROUND, AltitudeMode.RELATIVE_TO_MESH -> pathAltitudeOffset + 0.4 + else -> pathAltitudeOffset + 0.4 } progressCoordinates.add( LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, progressAltitude) @@ -282,8 +457,9 @@ object PathEngine { 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 + AltitudeMode.ABSOLUTE -> path[0].altitude + pathAltitudeOffset + 0.4 + AltitudeMode.RELATIVE_TO_GROUND, AltitudeMode.RELATIVE_TO_MESH -> pathAltitudeOffset + 0.4 + else -> pathAltitudeOffset + 0.4 } progressCoordinates.add( LatLngAltitude(tinyForward.latitude, tinyForward.longitude, startAlt) 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 index 68d35723..5956cd4f 100644 --- 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 @@ -20,9 +20,15 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import com.google.android.gms.maps3d.model.LatLngAltitude +import java.net.HttpURLConnection +import java.net.URL +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import org.json.JSONObject /** * Shared Architecture ViewModel for 3D Path Following across Kotlin, Java, and Jetpack Compose. @@ -80,6 +86,84 @@ class PathFollowingViewModel( _uiState.value = controller.setRoute(newRoute, applyDefaults) } + /** + * Fetches a real-time road route from the Google Routes API, decodes it, and sets it + * as the active route. + * + * @return true if route was successfully fetched and applied, false otherwise. + */ + @Suppress("unused") + suspend fun fetchAndSetRoute( + apiKey: String, + originLat: Double, + originLng: Double, + destLat: Double, + destLng: Double, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + ): Boolean = withContext(ioDispatcher) { + try { + val url = URL("https://routes.googleapis.com/directions/v2:computeRoutes") + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("X-Goog-Api-Key", apiKey) + connection.setRequestProperty("X-Goog-FieldMask", "routes.polyline.encodedPolyline") + connection.doOutput = true + + val requestJson = JSONObject().apply { + put( + "origin", + JSONObject().put( + "location", + JSONObject().put( + "latLng", + JSONObject().apply { + put("latitude", originLat) + put("longitude", originLng) + }, + ), + ), + ) + put( + "destination", + JSONObject().put( + "location", + JSONObject().put( + "latLng", + JSONObject().apply { + put("latitude", destLat) + put("longitude", destLng) + }, + ), + ), + ) + put("travelMode", "DRIVE") + } + + connection.outputStream.use { os -> + os.write(requestJson.toString().toByteArray(Charsets.UTF_8)) + } + + val response = connection.inputStream.bufferedReader().use { it.readText() } + val jsonResponse = JSONObject(response) + val routes = jsonResponse.optJSONArray("routes") + if (routes != null && routes.length() > 0) { + val encoded = routes.getJSONObject(0) + .getJSONObject("polyline") + .getString("encodedPolyline") + val decoded = PathData.decodePolyline(encoded) + withContext(Dispatchers.Main) { + setRoute(decoded, applyDefaults = true) + } + true + } else { + false + } + } catch (_: Exception) { + false + } + } + fun setAltitudeMode(mode: Int) { _uiState.value = controller.setAltitudeMode(mode) } 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 23df5e5e..cd5c02d3 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,6 +25,7 @@ import com.google.android.gms.maps3d.model.LatLngAltitude */ data class PathPlaybackState( val route: List = PathData.URBAN_PATH, + val routeProfile: RouteProfile = PathEngine.profileRoute(route), val totalDistance: Double = 0.0, val elapsedDistance: Double = 0.0, val progressRatio: Float = 0.0f, @@ -35,7 +36,7 @@ data class PathPlaybackState( val cameraRange: Double = 300.0, val groundAltitude: Double = 20.0, val headingOffset: Double = 0.0, - val cameraTilt: Double = 70.0, + val cameraTilt: Double = 55.0, val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND, val pathAltitudeOffset: Double = 0.5, val drawsOccludedSegments: Boolean = true, @@ -49,14 +50,16 @@ data class PathPlaybackState( 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 + get() = routeProfile.baseAltitude val cameraTargetAltitude: Double get() = PathEngine.calculateCameraAltitude( altitudeMode = altitudeMode, baseAltitude = baseAltitude, interpolatedAltitude = currentAltitude, - groundAltitude = groundAltitude + groundAltitude = groundAltitude, + cameraRange = cameraRange, + pathAltitudeOffset = pathAltitudeOffset ) val effectiveHeading: Double @@ -81,12 +84,11 @@ class PathPlaybackController( init { cumulativeDistances = PathEngine.calculateCumulativeDistances(initialRoute) - val totalDist = cumulativeDistances.lastOrNull() ?: 0.0 - val baseAlt = if (initialRoute == PathData.RURAL_PATH) 45.0 else 50.0 + val profile = PathEngine.profileRoute(initialRoute) + val totalDist = profile.totalDistance val staticVertices = PathEngine.buildStaticVertices( path = initialRoute, altitudeMode = AltitudeMode.CLAMP_TO_GROUND, - baseAltitude = baseAlt, pathAltitudeOffset = 0.5 ) @@ -103,18 +105,22 @@ class PathPlaybackController( currentLatLng = point.latLng, waypointIndex = point.waypointIndex, altitudeMode = AltitudeMode.CLAMP_TO_GROUND, - baseAltitude = baseAlt, pathAltitudeOffset = 0.5 ) state = PathPlaybackState( route = initialRoute, + routeProfile = profile, totalDistance = totalDist, elapsedDistance = 0.0, progressRatio = 0f, isPlaying = false, isScrubbing = false, speedBoostMultiplier = 1.0, + followSpeedMps = profile.recommendedSpeed.toDouble(), + cameraRange = profile.recommendedRange.toDouble(), + groundAltitude = profile.baseAltitude, + cameraTilt = profile.recommendedTilt.toDouble(), currentPosition = point.latLng, currentAltitude = point.altitude, currentHeading = point.bearing, @@ -192,13 +198,13 @@ class PathPlaybackController( fun setRoute(newRoute: List, applyDefaults: Boolean = true): PathPlaybackState { cumulativeDistances = PathEngine.calculateCumulativeDistances(newRoute) - val totalDist = cumulativeDistances.lastOrNull() ?: 0.0 - val isRural = newRoute == PathData.RURAL_PATH + val profile = PathEngine.profileRoute(newRoute) + val totalDist = profile.totalDistance - 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 range = if (applyDefaults) profile.recommendedRange.toDouble() else state.cameraRange + val groundAlt = if (applyDefaults) profile.baseAltitude else state.groundAltitude + val tilt = if (applyDefaults) profile.recommendedTilt.toDouble() else state.cameraTilt + val speed = if (applyDefaults) profile.recommendedSpeed.toDouble() else state.followSpeedMps val point = PathEngine.interpolatePoint( path = newRoute, @@ -209,7 +215,6 @@ class PathPlaybackController( val staticVertices = PathEngine.buildStaticVertices( path = newRoute, altitudeMode = state.altitudeMode, - baseAltitude = baseAlt, pathAltitudeOffset = state.pathAltitudeOffset ) @@ -220,12 +225,12 @@ class PathPlaybackController( currentLatLng = point.latLng, waypointIndex = point.waypointIndex, altitudeMode = state.altitudeMode, - baseAltitude = baseAlt, pathAltitudeOffset = state.pathAltitudeOffset ) state = state.copy( route = newRoute, + routeProfile = profile, totalDistance = totalDist, elapsedDistance = 0.0, progressRatio = 0f, @@ -233,6 +238,7 @@ class PathPlaybackController( cameraRange = range, groundAltitude = groundAlt, cameraTilt = tilt, + followSpeedMps = speed, currentPosition = point.latLng, currentAltitude = point.altitude, currentHeading = point.bearing, @@ -264,7 +270,9 @@ class PathPlaybackController( } fun setGroundAltitude(altitude: Double): PathPlaybackState { - state = state.copy(groundAltitude = altitude.coerceIn(0.0, 500.0)) + val minBound = minOf(0.0, state.routeProfile.altitudeSliderMin.toDouble()) + val maxBound = maxOf(500.0, state.routeProfile.altitudeSliderMax.toDouble()) + state = state.copy(groundAltitude = altitude.coerceIn(minBound, maxBound)) return state } @@ -353,7 +361,6 @@ class PathPlaybackController( currentLatLng = point.latLng, waypointIndex = point.waypointIndex, altitudeMode = state.altitudeMode, - baseAltitude = state.baseAltitude, pathAltitudeOffset = state.pathAltitudeOffset ) @@ -385,7 +392,6 @@ class PathPlaybackController( val staticVertices = PathEngine.buildStaticVertices( path = state.route, altitudeMode = state.altitudeMode, - baseAltitude = state.baseAltitude, pathAltitudeOffset = state.pathAltitudeOffset ) @@ -396,7 +402,6 @@ class PathPlaybackController( currentLatLng = point.latLng, waypointIndex = point.waypointIndex, altitudeMode = state.altitudeMode, - baseAltitude = state.baseAltitude, pathAltitudeOffset = state.pathAltitudeOffset ) diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/info_24px.xml b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/info_24px.xml new file mode 100644 index 00000000..ea00b41d --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/info_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 e8e557f3..d9cb6157 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 @@ -54,20 +54,31 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" + android:animateLayoutChanges="true" > - + + + + - + + - + android:gravity="center_vertical" + android:orientation="horizontal" + > + + + + + - - - + diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml index 78936c32..138b4de3 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml +++ b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml @@ -13,7 +13,7 @@ See the License for the specific language governing permissions and limitations under the License. --> - + Maps 3D Samples MainActivity @@ -185,17 +185,22 @@ Path Environment: Urban Rural + Mountain Altitude Mode: Relative to Ground Clamp to Ground Relative to Mesh Absolute - Path Height: %1$.1fm + Altitude Modes Explained + Altitude Modes Info + 🌐 CLAMP TO GROUND\nAltitude values are ignored. The path is draped directly onto the surface terrain mesh.\n• Difference: Follows ground contours tightly with 0 elevation clearance.\n\n⛰️ RELATIVE TO GROUND\nAltitude is measured as an offset above the terrain surface at each coordinate.\n• Difference: Floats at a constant height above natural ground elevation.\n\n🏢 RELATIVE TO MESH\nAltitude is measured above the highest 3D mesh surface, including 3D buildings, bridges, and trees.\n• Difference: Rises over rooftops to prevent clipping through urban structures.\n\n🛰️ ABSOLUTE\nAltitude is measured from sea level (WGS84 ellipsoid datum), completely independent of terrain or buildings beneath.\n• Difference: Fixed global altitude, ideal for flight paths and aviation. + Path Height: %1$.1fm Camera Range: %1$dm Ground Altitude: %1$dm Heading Offset: %1$d° Camera Tilt: %1$d° Follow Speed: %1$d m/s + Follow Speed: %1$d m/s%2$s Path Height: 0.5m Camera Range: 300m Ground Altitude: 20m 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 index ed405d39..d2e6c001 100644 --- 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 @@ -100,38 +100,119 @@ class PathEngineTest { assertEquals(45.0, smoothed, 0.01) } + @Test + fun smoothHeading_appliesAdaptiveEmaBasedOnDelta() { + // Delta <= 20 -> alpha = 0.16 + val straight = PathEngine.smoothHeading(10.0, currentHeading = 0.0, isUserScrubbing = false, isPlaying = true) + assertEquals(1.6, straight, 0.01) + + // Delta in (20, 45] -> alpha = 0.28 (diff = 30 -> 30 * 0.28 = 8.4) + val curve = PathEngine.smoothHeading(30.0, currentHeading = 0.0, isUserScrubbing = false, isPlaying = true) + assertEquals(8.4, curve, 0.01) + + // Delta > 45 -> alpha = 0.40 (diff = 90 -> 90 * 0.40 = 36.0) + val sharp = PathEngine.smoothHeading(90.0, currentHeading = 0.0, isUserScrubbing = false, isPlaying = true) + assertEquals(36.0, sharp, 0.01) + } + + @Test + fun angularDifference_and_interpolateAngle_handlesWrapAround() { + val diff1 = PathEngine.angularDifference(10.0, 350.0) + assertEquals(20.0, diff1, 0.001) + + val diff2 = PathEngine.angularDifference(350.0, 10.0) + assertEquals(-20.0, diff2, 0.001) + + val interp = PathEngine.interpolateAngle(350.0, 10.0, 0.5) + assertEquals(0.0, interp, 0.001) + } + + @Test + fun profileRoute_calculatesAccurateBoundsAndCalibration() { + val urbanProfile = PathEngine.profileRoute(PathData.URBAN_PATH) + assertTrue(urbanProfile.totalDistance > 1000.0) + assertEquals(1.2, urbanProfile.minAltitude, 0.01) + assertEquals(10.0, urbanProfile.maxAltitude, 0.01) + assertEquals(1.0, urbanProfile.baseAltitude, 0.01) + assertTrue(urbanProfile.recommendedRange in 65.0f..250.0f) + assertEquals(55.0f, urbanProfile.recommendedTilt, 0.01f) + assertEquals(urbanProfile.recommendedSpeed * 5.0f, urbanProfile.speedSliderMax, 0.01f) + + val mountainProfile = PathEngine.profileRoute(PathData.MOUNTAIN_PATH) + assertTrue(mountainProfile.totalDistance > 1950.0) + assertEquals(343.8, mountainProfile.minAltitude, 0.01) + assertEquals(457.0, mountainProfile.maxAltitude, 0.01) + assertEquals(344.0, mountainProfile.baseAltitude, 0.01) + assertEquals(48.0f, mountainProfile.recommendedTilt, 0.01f) + assertTrue(mountainProfile.altitudeSliderMax > 457.0f) + assertEquals(mountainProfile.recommendedSpeed * 5.0f, mountainProfile.speedSliderMax, 0.01f) + } + @Test fun calculateCameraAltitude_computesCorrectAltitudePerMode() { - // CLAMP_TO_GROUND -> returns groundAltitude + // CLAMP_TO_GROUND -> returns surfaceElevation (15.0 + (120 - 50)) + eyeLevelBias (3.5m) = 88.5m val clampAlt = PathEngine.calculateCameraAltitude( altitudeMode = AltitudeMode.CLAMP_TO_GROUND, baseAltitude = 50.0, interpolatedAltitude = 15.0, - groundAltitude = 120.0 + groundAltitude = 120.0, + cameraRange = 100.0 ) - assertEquals(120.0, clampAlt, 0.001) + assertEquals(88.5, clampAlt, 0.001) - // ABSOLUTE -> baseAltitude + interpolatedAltitude + groundAltitude + // ABSOLUTE -> surfaceElevation + pathAltitudeOffset + eyeLevelBias val absAlt = PathEngine.calculateCameraAltitude( altitudeMode = AltitudeMode.ABSOLUTE, baseAltitude = 50.0, interpolatedAltitude = 15.0, - groundAltitude = 120.0 + groundAltitude = 120.0, + cameraRange = 100.0, + pathAltitudeOffset = 5.0 + ) + assertEquals(93.5, absAlt, 0.001) + + // RELATIVE_TO_GROUND -> surfaceElevation + pathAltitudeOffset + eyeLevelBias + val relAlt = PathEngine.calculateCameraAltitude( + altitudeMode = AltitudeMode.RELATIVE_TO_GROUND, + baseAltitude = 50.0, + interpolatedAltitude = 15.0, + groundAltitude = 120.0, + cameraRange = 100.0, + pathAltitudeOffset = 5.0 ) - assertEquals(185.0, absAlt, 0.001) + assertEquals(93.5, relAlt, 0.001) } @Test fun buildStaticVertices_and_buildProgressVertices() { - val staticVertices = PathEngine.buildStaticVertices( + // ABSOLUTE mode uses pt.altitude + pathAltitudeOffset + val staticVerticesAbs = 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 + assertEquals(3, staticVerticesAbs.size) + assertEquals(15.0, staticVerticesAbs[0].altitude, 0.01) // 10 + 5 + assertEquals(25.0, staticVerticesAbs[1].altitude, 0.01) // 20 + 5 + + // RELATIVE_TO_GROUND uses pathAltitudeOffset directly as clearance + val staticVerticesRel = PathEngine.buildStaticVertices( + path = samplePath, + altitudeMode = AltitudeMode.RELATIVE_TO_GROUND, + pathAltitudeOffset = 5.0 + ) + assertEquals(3, staticVerticesRel.size) + assertEquals(5.0, staticVerticesRel[0].altitude, 0.01) + assertEquals(5.0, staticVerticesRel[1].altitude, 0.01) + + // CLAMP_TO_GROUND uses 0.0 + val staticVerticesClamp = PathEngine.buildStaticVertices( + path = samplePath, + altitudeMode = AltitudeMode.CLAMP_TO_GROUND, + pathAltitudeOffset = 5.0 + ) + assertEquals(3, staticVerticesClamp.size) + assertEquals(0.0, staticVerticesClamp[0].altitude, 0.01) val cumDist = PathEngine.calculateCumulativeDistances(samplePath) val interp = PathEngine.interpolatePoint(samplePath, cumDist, cumDist[1]) @@ -142,10 +223,9 @@ class PathEngineTest { 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 + assertEquals(15.4, progressVertices[0].altitude, 0.01) // 15 + 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 index 894e786b..450422c9 100644 --- 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 @@ -141,6 +141,17 @@ class PathFollowingViewModelTest { assertEquals(0.0, viewModel.currentState.elapsedDistance, 0.001) } + @Test + fun setRoute_mountainPath_calibratesProfile() { + viewModel.setRoute(PathData.MOUNTAIN_PATH, applyDefaults = true) + val state = viewModel.currentState + assertEquals(PathData.MOUNTAIN_PATH, state.route) + assertEquals(48.0, state.cameraTilt, 0.001) + assertEquals(344.0, state.groundAltitude, 0.001) + assertEquals(344.0, state.routeProfile.baseAltitude, 0.001) + assertEquals(48.0f, state.routeProfile.recommendedTilt, 0.001f) + } + @Test fun advance_progressesPlayback() { viewModel.setPlaying(true) 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 index 1e0fe223..dfaaad63 100644 --- 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 @@ -20,7 +20,6 @@ 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 @@ -173,6 +172,10 @@ class PathPlaybackControllerTest { controller.setAltitudeMode(AltitudeMode.ABSOLUTE) val absVertices = controller.getState().staticPolylineVertices assertTrue(absVertices.any { it.altitude > 0.0 }) + + controller.setAltitudeMode(AltitudeMode.RELATIVE_TO_GROUND) + val relVertices = controller.getState().staticPolylineVertices + assertTrue(relVertices.all { it.altitude == controller.getState().pathAltitudeOffset }) } @Test @@ -187,8 +190,20 @@ class PathPlaybackControllerTest { 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) + val expectedProfile = PathEngine.profileRoute(newRoute) + assertEquals(expectedProfile.recommendedRange.toDouble(), state.cameraRange, 0.001) + assertEquals(expectedProfile.recommendedTilt.toDouble(), state.cameraTilt, 0.001) + assertEquals(expectedProfile.baseAltitude, state.groundAltitude, 0.001) + } + + @Test + fun setRoute_mountainPath_calibratesProfile() { + val state = controller.setRoute(PathData.MOUNTAIN_PATH, applyDefaults = true) + assertEquals(PathData.MOUNTAIN_PATH, state.route) + assertEquals(48.0, state.cameraTilt, 0.001) + assertEquals(344.0, state.groundAltitude, 0.001) + assertEquals(344.0, state.routeProfile.baseAltitude, 0.001) + assertEquals(48.0f, state.routeProfile.recommendedTilt, 0.001f) } @Test @@ -221,4 +236,4 @@ class PathPlaybackControllerTest { 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 02f98b8d..86110630 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,10 +16,12 @@ package com.example.maps3djava.pathfollowing; +import android.annotation.SuppressLint; import android.graphics.Color; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.util.Log; import android.view.Choreographer; import android.view.GestureDetector; import android.view.MotionEvent; @@ -35,6 +37,7 @@ import com.example.maps3d.common.PathFollowingViewModel; import com.example.maps3d.common.PathPlaybackState; import com.example.maps3d.common.PathTouchHandler; +import com.example.maps3d.common.RouteProfile; import com.example.maps3dcommon.R; import com.google.android.gms.maps3d.GoogleMap3D; import com.google.android.gms.maps3d.Map3DView; @@ -53,11 +56,12 @@ /** * Demonstrates 3D Path Following using an MVVM architecture with [PathFollowingViewModel]. - * + * Decoupled gesture controls and dynamic progress polyline driven strictly by time and progress. */ public class PathFollowingActivity extends AppCompatActivity implements OnMap3DViewReadyCallback { + private static final String TAG = "PathFollowingActivity"; private PathFollowingViewModel viewModel; // 3D Map View & Gesture Overlay @@ -69,14 +73,19 @@ public class PathFollowingActivity extends AppCompatActivity implements OnMap3DV private Polyline staticRoutePolyline; private Polyline progressPolyline; private List lastStaticVertices; + private Integer lastStaticAltitudeMode; + private Boolean lastStaticDrawsOccluded; + private Double lastStaticAltitudeOffset; private double lastRenderedProgressDist = -1.0; private long lastSliderUpdateMillis = 0L; private Boolean lastIsPlaying; + private List lastRoute; // Control panel overlay bindings private CardView controlsCard; private View cardHeader; private MaterialButton btnHelp; + private MaterialButton btnAltitudeModeInfo; private MaterialButton btnCollapse; private View controlsScroll; private boolean isCollapsed = false; @@ -114,7 +123,7 @@ protected void onCreate(Bundle savedInstanceState) { bindViews(); setupCustomGestureHandling(); setupControlListeners(); - setupTouchAutoFade() ; + setupTouchAutoFade(); observeViewModel(); map3DView.onCreate(savedInstanceState); @@ -126,18 +135,7 @@ 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); - }); - }); + initialTime -> runOnUiThread(this::resetPolylines)); } private void setupCustomGestureHandling() { @@ -152,6 +150,7 @@ private void bindViews() { controlsCard = findViewById(R.id.controls_card); cardHeader = findViewById(R.id.card_header); btnHelp = findViewById(R.id.btn_help); + btnAltitudeModeInfo = findViewById(R.id.btn_altitude_mode_info); chipGroupSpeed = findViewById(R.id.chip_group_speed); btnCollapse = findViewById(R.id.btn_collapse); controlsScroll = findViewById(R.id.controls_scroll); @@ -174,11 +173,16 @@ private void bindViews() { speedSliderLabel = findViewById(R.id.speed_slider_label); } + @SuppressLint({"StringFormatInvalid", "ClickableViewAccessibility"}) private void setupControlListeners() { if (btnHelp != null) { btnHelp.setOnClickListener(v -> showHelpDialog()); } + if (btnAltitudeModeInfo != null) { + btnAltitudeModeInfo.setOnClickListener(v -> showAltitudeModeInfoDialog()); + } + btnPlayPause.setOnClickListener(v -> viewModel.togglePlayPause()); if (chipGroupSpeed != null) { @@ -186,18 +190,37 @@ private void setupControlListeners() { (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); + double multiplier; + if (checkedId == R.id.chip_speed_05x) { + multiplier = 0.5; + } else if (checkedId == R.id.chip_speed_2x) { + multiplier = 2.0; + } else if (checkedId == R.id.chip_speed_3x) { + multiplier = 3.0; + } else if (checkedId == R.id.chip_speed_5x) { + multiplier = 5.0; + } else { + multiplier = 1.0; + } + + double baseSpeed = viewModel.getCurrentState().getRouteProfile().recommendedSpeed; + float targetSpeed = (float) (baseSpeed * multiplier); + float clampedSpeed = Math.max(speedSlider.getValueFrom(), Math.min(speedSlider.getValueTo(), targetSpeed)); + + viewModel.setFollowSpeed(clampedSpeed); + speedSlider.setValue(clampedSpeed); }); } + View layoutDragHandle = findViewById(R.id.layout_drag_handle); + if (layoutDragHandle != null) { + layoutDragHandle.setOnClickListener(v -> setPanelCollapsed(!isCollapsed)); + } + View dragHandle = findViewById(R.id.drag_handle); + if (dragHandle != null) { + dragHandle.setOnClickListener(v -> setPanelCollapsed(!isCollapsed)); + } + if (btnCollapse != null) { btnCollapse.setOnClickListener(v -> setPanelCollapsed(!isCollapsed)); } @@ -208,7 +231,7 @@ private void setupControlListeners() { GestureDetector cardSwipeDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() { @Override - public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + public boolean onFling(MotionEvent e1, @NonNull MotionEvent e2, float velocityX, float velocityY) { if (e1 == null) return false; float dy = e2.getY() - e1.getY(); if (dy > 50 && velocityY > 100) { @@ -226,8 +249,8 @@ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float ve cardHeader.setOnTouchListener((v, event) -> cardSwipeDetector.onTouchEvent(event) || v.onTouchEvent(event)); } - if (controlsCard != null) { - controlsCard.setOnTouchListener((v, event) -> cardSwipeDetector.onTouchEvent(event)); + if (layoutDragHandle != null) { + layoutDragHandle.setOnTouchListener((v, event) -> cardSwipeDetector.onTouchEvent(event) || v.onTouchEvent(event)); } progressSlider.addOnChangeListener( @@ -236,7 +259,7 @@ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float ve viewModel.seekToRatio(value); PathPlaybackState state = viewModel.getCurrentState(); updateCameraFromState(state); - updateProgressPolyline(state); + updateProgressPolyline(state, true); } }); @@ -253,7 +276,7 @@ public void onStopTrackingTouch(@NonNull Slider slider) { viewModel.seekToRatio(slider.getValue()); PathPlaybackState state = viewModel.getCurrentState(); updateCameraFromState(state); - updateProgressPolyline(state); + updateProgressPolyline(state, true); } }); @@ -328,25 +351,87 @@ public void onStopTrackingTouch(@NonNull Slider slider) { speedSlider.addOnChangeListener( (slider, value, fromUser) -> { - if (fromUser) viewModel.setFollowSpeed(value); - speedSliderLabel.setText(getString(R.string.follow_speed_format, (int) value)); + if (fromUser) { + viewModel.setFollowSpeed(value); + if (chipGroupSpeed != null) { + float baseSpeed = viewModel.getCurrentState().getRouteProfile().recommendedSpeed; + if (baseSpeed > 0.0f) { + float mult = value / baseSpeed; + if (Math.abs(mult - 0.5f) < 0.15f) { + chipGroupSpeed.check(R.id.chip_speed_05x); + } else if (Math.abs(mult - 1.0f) < 0.15f) { + chipGroupSpeed.check(R.id.chip_speed_1x); + } else if (Math.abs(mult - 2.0f) < 0.15f) { + chipGroupSpeed.check(R.id.chip_speed_2x); + } else if (Math.abs(mult - 3.0f) < 0.15f) { + chipGroupSpeed.check(R.id.chip_speed_3x); + } else if (Math.abs(mult - 5.0f) < 0.15f) { + chipGroupSpeed.check(R.id.chip_speed_5x); + } + } + } + } + String boostSuffix = ""; + if (viewModel.getCurrentState().getSpeedBoostMultiplier() >= 4.5) { + boostSuffix = " (5x Fast-Forward)"; + } else if (viewModel.getCurrentState().getSpeedBoostMultiplier() <= -4.5) { + boostSuffix = " (-5x Rewind)"; + } else if (viewModel.getCurrentState().getSpeedBoostMultiplier() >= 1.5) { + boostSuffix = " (2x Boost)"; + } + speedSliderLabel.setText(getString(R.string.follow_speed_with_suffix_format, (int) value, boostSuffix)); }); 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); + configureSlider(pathAltitudeSlider, 0.0f, 20.0f, pathAltitudeSlider.getValue()); } else if (checkedId == R.id.rb_rural) { viewModel.setRoute(PathData.RURAL_PATH, /* applyDefaults= */ true); - pathAltitudeSlider.setValueTo(200.0f); - altitudeSlider.setValueTo(500.0f); + configureSlider(pathAltitudeSlider, 0.0f, 200.0f, pathAltitudeSlider.getValue()); + } else if (checkedId == R.id.rb_mountain) { + viewModel.setRoute(PathData.MOUNTAIN_PATH, /* applyDefaults= */ true); + configureSlider(pathAltitudeSlider, 0.0f, 200.0f, pathAltitudeSlider.getValue()); } resetPolylines(); }); } + private void configureSlider(Slider slider, float min, float max, float targetVal) { + try { + float safeMin = Math.min(min, max - 1f); + float safeMax = Math.max(max, safeMin + 1f); + float clampedTarget = Math.max(safeMin, Math.min(safeMax, targetVal)); + // 4-step bound adjustment ensures valueFrom <= value <= valueTo invariant at every step + slider.setValueFrom(Math.min(slider.getValueFrom(), safeMin)); + slider.setValueTo(Math.max(slider.getValueTo(), safeMax)); + slider.setValue(clampedTarget); + slider.setValueFrom(safeMin); + slider.setValueTo(safeMax); + } catch (Exception e) { + Log.e(TAG, "Error configuring slider: " + e.getMessage(), e); + } + } + + private void applyRouteProfile(RouteProfile profile) { + configureSlider(rangeSlider, profile.rangeSliderMin, profile.rangeSliderMax, profile.recommendedRange); + rangeSliderLabel.setText(getString(R.string.camera_range_format, (int) profile.recommendedRange)); + + configureSlider(altitudeSlider, profile.altitudeSliderMin, profile.altitudeSliderMax, (float) profile.baseAltitude); + altitudeSliderLabel.setText(getString(R.string.ground_altitude_format, (int) profile.baseAltitude)); + + configureSlider(speedSlider, profile.speedSliderMin, profile.speedSliderMax, profile.recommendedSpeed); + speedSliderLabel.setText(getString(R.string.follow_speed_format, (int) profile.recommendedSpeed)); + if (chipGroupSpeed != null) { + chipGroupSpeed.check(R.id.chip_speed_1x); + } + + float clampedTilt = Math.max(tiltSlider.getValueFrom(), Math.min(tiltSlider.getValueTo(), profile.recommendedTilt)); + tiltSlider.setValue(clampedTilt); + tiltSliderLabel.setText(getString(R.string.camera_tilt_format, (int) clampedTilt)); + } + private void setPanelCollapsed(boolean collapsed) { if (isCollapsed == collapsed) return; isCollapsed = collapsed; @@ -367,34 +452,70 @@ private void showHelpDialog() { .show(); } + private void showAltitudeModeInfoDialog() { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.altitude_mode_info_title) + .setMessage(R.string.altitude_mode_info_message) + .setPositiveButton(R.string.help_dialog_ok, null) + .show(); + } + private void resetPolylines() { lastStaticVertices = null; + lastStaticAltitudeMode = null; + lastStaticDrawsOccluded = null; + lastStaticAltitudeOffset = null; lastRenderedProgressDist = -1.0; + if (staticRoutePolyline != null) { + staticRoutePolyline.remove(); + staticRoutePolyline = null; + } + if (progressPolyline != null) { + progressPolyline.remove(); + progressPolyline = null; + } PathPlaybackState state = viewModel.getCurrentState(); updateStaticPolyline(state); - updateProgressPolyline(state); + updateProgressPolyline(state, true); updateCameraFromState(state); + renderUiControls(state); } private void updateStaticPolyline(PathPlaybackState state) { - if (googleMap3D == null || state == null) return; - if (lastStaticVertices != null && lastStaticVertices.equals(state.getStaticPolylineVertices()) && staticRoutePolyline != null) return; + if (googleMap3D == null || state == null || state.getStaticPolylineVertices().size() < 2) return; + + if (lastStaticVertices != null && lastStaticVertices.equals(state.getStaticPolylineVertices()) + && lastStaticAltitudeMode != null && lastStaticAltitudeMode == state.getAltitudeMode() + && lastStaticDrawsOccluded != null && lastStaticDrawsOccluded == state.getDrawsOccludedSegments() + && lastStaticAltitudeOffset != null && lastStaticAltitudeOffset.equals(state.getPathAltitudeOffset()) + && staticRoutePolyline != null) { + return; + } lastStaticVertices = state.getStaticPolylineVertices(); + lastStaticAltitudeMode = state.getAltitudeMode(); + lastStaticDrawsOccluded = state.getDrawsOccludedSegments(); + lastStaticAltitudeOffset = state.getPathAltitudeOffset(); + PolylineOptions staticOptions = new PolylineOptions(); staticOptions.setId(PathEngine.STATIC_POLYLINE_ID); staticOptions.setPath(state.getStaticPolylineVertices()); staticOptions.setStrokeColor(Color.parseColor("#4285F4")); - staticOptions.setStrokeWidth(16.0); + staticOptions.setStrokeWidth(10.0); staticOptions.setZIndex(1); staticOptions.setAltitudeMode(state.getAltitudeMode()); staticOptions.setDrawsOccludedSegments(state.getDrawsOccludedSegments()); staticRoutePolyline = googleMap3D.addPolyline(staticOptions); } - private void updateProgressPolyline(PathPlaybackState state) { + private void updateProgressPolyline(PathPlaybackState state, boolean force) { if (googleMap3D == null || state == null || state.getProgressPolylineVertices().size() < 2) return; + double distDelta = Math.abs(state.getElapsedDistance() - lastRenderedProgressDist); + if (!force && state.isPlaying() && distDelta < 15.0) { + return; + } + lastRenderedProgressDist = state.getElapsedDistance(); PolylineOptions progressOptions = new PolylineOptions(); progressOptions.setId(PathEngine.PROGRESS_POLYLINE_ID); @@ -409,12 +530,15 @@ private void updateProgressPolyline(PathPlaybackState state) { private void observeViewModel() { viewModel.getLiveData().observe(this, state -> { - updateCameraFromState(state); - if (state.isPlaying() || Math.abs(state.getElapsedDistance() - lastRenderedProgressDist) > 0.1) { - updateProgressPolyline(state); + try { + updateCameraFromState(state); + updateStaticPolyline(state); + updateProgressPolyline(state, false); + renderUiControls(state); + manageAnimationTicker(state.isPlaying()); + } catch (Exception e) { + Log.e(TAG, "Error in UI state update: " + e.getMessage(), e); } - renderUiControls(state); - manageAnimationTicker(state.isPlaying()); }); } @@ -436,13 +560,18 @@ private void updateCameraFromState(PathPlaybackState state) { private void renderUiControls(PathPlaybackState state) { if (state == null) return; + if (lastRoute == null || !lastRoute.equals(state.getRoute())) { + lastRoute = state.getRoute(); + applyRouteProfile(state.getRouteProfile()); + } + if (lastIsPlaying == null || lastIsPlaying != state.isPlaying()) { lastIsPlaying = state.isPlaying(); btnPlayPause.setIconResource( state.isPlaying() ? R.drawable.pause_24px : R.drawable.play_arrow_24px); } - if (!state.isScrubbing()) { + if (!state.isScrubbing() && !progressSlider.isPressed()) { long now = System.currentTimeMillis(); if (now - lastSliderUpdateMillis >= 100L || !state.isPlaying()) { lastSliderUpdateMillis = now; @@ -456,25 +585,46 @@ private void renderUiControls(PathPlaybackState state) { } else if (state.getSpeedBoostMultiplier() >= 1.5) { boostSuffix = " (2x Boost)"; } - speedSliderLabel.setText(getString(R.string.follow_speed_format, (int) state.getFollowSpeedMps()) + boostSuffix); + speedSliderLabel.setText(getString(R.string.follow_speed_with_suffix_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())); + if (!rangeSlider.isPressed()) { + 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())); + } + } + + if (!tiltSlider.isPressed()) { + 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())); + } + } + + if (!headingSlider.isPressed()) { + 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())); + } } - 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())); + if (!altitudeSlider.isPressed()) { + float clampedAltitude = Math.max(altitudeSlider.getValueFrom(), Math.min(altitudeSlider.getValueTo(), (float) state.getGroundAltitude())); + if (Math.abs(altitudeSlider.getValue() - clampedAltitude) >= 0.5f) { + altitudeSlider.setValue(clampedAltitude); + altitudeSliderLabel.setText(getString(R.string.ground_altitude_format, (int) state.getGroundAltitude())); + } } - 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 (!speedSlider.isPressed()) { + float clampedSpeed = Math.max(speedSlider.getValueFrom(), Math.min(speedSlider.getValueTo(), (float) state.getFollowSpeedMps())); + if (Math.abs(speedSlider.getValue() - clampedSpeed) >= 0.5f) { + speedSlider.setValue(clampedSpeed); + } } } } @@ -564,8 +714,14 @@ protected void onDestroy() { frameCallback = null; } fadeHandler.removeCallbacksAndMessages(null); - staticRoutePolyline = null; - progressPolyline = null; + if (staticRoutePolyline != null) { + staticRoutePolyline.remove(); + staticRoutePolyline = null; + } + if (progressPolyline != null) { + progressPolyline.remove(); + progressPolyline = null; + } map3DView.onDestroy(); } 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 1cfde289..6dd44194 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 @@ -16,9 +16,11 @@ package com.example.maps3dkotlin.pathfollowing +import android.annotation.SuppressLint import android.os.Bundle import android.os.Handler import android.os.Looper +import android.util.Log import android.view.Choreographer import android.view.GestureDetector import android.view.MotionEvent @@ -37,6 +39,7 @@ 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.maps3d.common.RouteProfile import com.example.maps3dcommon.R import com.google.android.gms.maps3d.GoogleMap3D import com.google.android.gms.maps3d.Map3DView @@ -63,6 +66,7 @@ import kotlin.math.abs */ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { + private val TAG = "PathFollowingActivity" private val viewModel: PathFollowingViewModel by viewModels() // 3D Map View & Gesture Overlay @@ -74,15 +78,20 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { private var staticRoutePolyline: Polyline? = null private var progressPolyline: Polyline? = null private var lastStaticVertices: List? = null + private var lastStaticAltitudeMode: Int? = null + private var lastStaticDrawsOccluded: Boolean? = null + private var lastStaticAltitudeOffset: Double? = null private var lastRenderedProgressDist = -1.0 private var lastSliderUpdateMillis = 0L private var lastIsPlaying: Boolean? = null + private var lastRoute: List? = 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 btnAltitudeModeInfo: MaterialButton? = null private var controlsScroll: View? = null private var isCollapsed = false private var chipGroupSpeed: ChipGroup? = null @@ -149,6 +158,7 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { controlsCard = findViewById(R.id.controls_card) cardHeader = findViewById(R.id.card_header) btnHelp = findViewById(R.id.btn_help) + btnAltitudeModeInfo = findViewById(R.id.btn_altitude_mode_info) chipGroupSpeed = findViewById(R.id.chip_group_speed) btnCollapse = findViewById(R.id.btn_collapse) controlsScroll = findViewById(R.id.controls_scroll) @@ -171,27 +181,34 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { speedSliderLabel = findViewById(R.id.speed_slider_label) } + @SuppressLint("StringFormatInvalid", "ClickableViewAccessibility") private fun setupControlListeners() { btnHelp?.setOnClickListener { showHelpDialog() } + btnAltitudeModeInfo?.setOnClickListener { + showAltitudeModeInfoDialog() + } + btnPlayPause.setOnClickListener { viewModel.togglePlayPause() } 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 + val multiplier = when (checkedId) { + R.id.chip_speed_05x -> 0.5 + R.id.chip_speed_1x -> 1.0 + R.id.chip_speed_2x -> 2.0 + R.id.chip_speed_3x -> 3.0 + R.id.chip_speed_5x -> 5.0 + else -> 1.0 } - viewModel.setFollowSpeed(targetSpeed) - speedSlider.value = targetSpeed.toFloat() + val baseSpeed = viewModel.currentState.routeProfile.recommendedSpeed.toDouble() + val targetSpeed = (baseSpeed * multiplier).toFloat().coerceIn(speedSlider.valueFrom, speedSlider.valueTo) + viewModel.setFollowSpeed(targetSpeed.toDouble()) + speedSlider.value = targetSpeed } fun setPanelCollapsed(collapsed: Boolean) { @@ -203,6 +220,13 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { ) } + findViewById(R.id.layout_drag_handle)?.setOnClickListener { + setPanelCollapsed(!isCollapsed) + } + findViewById(R.id.drag_handle)?.setOnClickListener { + setPanelCollapsed(!isCollapsed) + } + btnCollapse?.setOnClickListener { setPanelCollapsed(!isCollapsed) } @@ -242,8 +266,12 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { } } - controlsCard?.setOnTouchListener { _, event -> - cardSwipeDetector.onTouchEvent(event) + findViewById(R.id.layout_drag_handle)?.setOnTouchListener { v, event -> + if (cardSwipeDetector.onTouchEvent(event)) { + true + } else { + v.onTouchEvent(event) + } } progressSlider.addOnChangeListener { _, value, fromUser -> @@ -251,7 +279,7 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { viewModel.seekToRatio(value) val state = viewModel.currentState updateCameraFromState(state) - updateProgressPolyline(state) + updateProgressPolyline(state, force = true) } } @@ -265,7 +293,7 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { viewModel.seekToRatio(slider.value) val state = viewModel.currentState updateCameraFromState(state) - updateProgressPolyline(state) + updateProgressPolyline(state, force = true) } }) @@ -329,12 +357,16 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { 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 baseSpeed = viewModel.currentState.routeProfile.recommendedSpeed + if (baseSpeed > 0.0f) { + val mult = value / baseSpeed + when { + abs(mult - 0.5f) < 0.15f -> chipGroupSpeed?.check(R.id.chip_speed_05x) + abs(mult - 1.0f) < 0.15f -> chipGroupSpeed?.check(R.id.chip_speed_1x) + abs(mult - 2.0f) < 0.15f -> chipGroupSpeed?.check(R.id.chip_speed_2x) + abs(mult - 3.0f) < 0.15f -> chipGroupSpeed?.check(R.id.chip_speed_3x) + abs(mult - 5.0f) < 0.15f -> chipGroupSpeed?.check(R.id.chip_speed_5x) + } } } val boostSuffix = when { @@ -343,26 +375,69 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { viewModel.currentState.speedBoostMultiplier >= 1.5 -> " (2x Boost)" else -> "" } - speedSliderLabel.text = getString(R.string.follow_speed_format, value.toInt()) + boostSuffix + speedSliderLabel.text = getString( + R.string.follow_speed_with_suffix_format, + value.toInt(), + boostSuffix, + ) } rgEnvironment.setOnCheckedChangeListener { _, checkedId -> when (checkedId) { R.id.rb_urban -> { - viewModel.setRoute(PathData.URBAN_PATH) - pathAltitudeSlider.valueTo = 20.0f - altitudeSlider.valueTo = 500.0f + viewModel.setRoute(PathData.URBAN_PATH, applyDefaults = true) + configureSlider(pathAltitudeSlider, 0.0f, 20.0f, pathAltitudeSlider.value) } R.id.rb_rural -> { - viewModel.setRoute(PathData.RURAL_PATH) - pathAltitudeSlider.valueTo = 200.0f - altitudeSlider.valueTo = 500.0f + viewModel.setRoute(PathData.RURAL_PATH, applyDefaults = true) + configureSlider(pathAltitudeSlider, 0.0f, 200.0f, pathAltitudeSlider.value) + } + R.id.rb_mountain -> { + viewModel.setRoute(PathData.MOUNTAIN_PATH, applyDefaults = true) + configureSlider(pathAltitudeSlider, 0.0f, 200.0f, pathAltitudeSlider.value) } } resetPolylines() } } + private fun configureSlider(slider: Slider, min: Float, max: Float, targetVal: Float) { + try { + val safeMin = minOf(min, max - 1f) + val safeMax = maxOf(max, safeMin + 1f) + val clampedTarget = targetVal.coerceIn(safeMin, safeMax) + // 4-step bound adjustment ensures valueFrom <= value <= valueTo invariant at every step + slider.valueFrom = minOf(slider.valueFrom, safeMin) + slider.valueTo = maxOf(slider.valueTo, safeMax) + slider.value = clampedTarget + slider.valueFrom = safeMin + slider.valueTo = safeMax + } catch (e: Exception) { + Log.e(TAG, "Error configuring slider: ${e.message}", e) + } + } + + private fun applyRouteProfile(profile: RouteProfile) { + configureSlider(rangeSlider, profile.rangeSliderMin, profile.rangeSliderMax, profile.recommendedRange) + rangeSliderLabel.text = getString(R.string.camera_range_format, profile.recommendedRange.toInt()) + + configureSlider( + altitudeSlider, + profile.altitudeSliderMin, + profile.altitudeSliderMax, + profile.baseAltitude.toFloat() + ) + altitudeSliderLabel.text = getString(R.string.ground_altitude_format, profile.baseAltitude.toInt()) + + configureSlider(speedSlider, profile.speedSliderMin, profile.speedSliderMax, profile.recommendedSpeed) + speedSliderLabel.text = getString(R.string.follow_speed_format, profile.recommendedSpeed.toInt()) + chipGroupSpeed?.check(R.id.chip_speed_1x) + + val clampedTilt = profile.recommendedTilt.coerceIn(tiltSlider.valueFrom, tiltSlider.valueTo) + tiltSlider.value = clampedTilt + tiltSliderLabel.text = getString(R.string.camera_tilt_format, clampedTilt.toInt()) + } + private fun showHelpDialog() { MaterialAlertDialogBuilder(this) .setTitle(R.string.help_dialog_title) @@ -371,25 +446,54 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { .show() } + private fun showAltitudeModeInfoDialog() { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.altitude_mode_info_title) + .setMessage(R.string.altitude_mode_info_message) + .setPositiveButton(R.string.help_dialog_ok, null) + .show() + } + private fun resetPolylines() { lastStaticVertices = null + lastStaticAltitudeMode = null + lastStaticDrawsOccluded = null + lastStaticAltitudeOffset = null lastRenderedProgressDist = -1.0 + staticRoutePolyline?.remove() + progressPolyline?.remove() + staticRoutePolyline = null + progressPolyline = null val state = viewModel.currentState updateStaticPolyline(state) - updateProgressPolyline(state) + updateProgressPolyline(state, force = true) updateCameraFromState(state) + renderUiControls(state) } private fun updateStaticPolyline(state: PathPlaybackState) { val map = googleMap3D ?: return - if (lastStaticVertices == state.staticPolylineVertices && staticRoutePolyline != null) return + if (state.staticPolylineVertices.size < 2) return + + if (lastStaticVertices == state.staticPolylineVertices && + lastStaticAltitudeMode == state.altitudeMode && + lastStaticDrawsOccluded == state.drawsOccludedSegments && + lastStaticAltitudeOffset == state.pathAltitudeOffset && + staticRoutePolyline != null + ) { + return + } lastStaticVertices = state.staticPolylineVertices + lastStaticAltitudeMode = state.altitudeMode + lastStaticDrawsOccluded = state.drawsOccludedSegments + lastStaticAltitudeOffset = state.pathAltitudeOffset + val staticOptions = PolylineOptions().apply { id = PathEngine.STATIC_POLYLINE_ID path = state.staticPolylineVertices strokeColor = "#4285F4".toColorInt() - strokeWidth = 16.0 + strokeWidth = 10.0 zIndex = 1 altitudeMode = state.altitudeMode drawsOccludedSegments = state.drawsOccludedSegments @@ -397,10 +501,15 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { staticRoutePolyline = map.addPolyline(staticOptions) } - private fun updateProgressPolyline(state: PathPlaybackState) { + private fun updateProgressPolyline(state: PathPlaybackState, force: Boolean = false) { val map = googleMap3D ?: return if (state.progressPolylineVertices.size < 2) return + val distDelta = abs(state.elapsedDistance - lastRenderedProgressDist) + if (!force && state.isPlaying && distDelta < 15.0) { + return + } + lastRenderedProgressDist = state.elapsedDistance val progressOptions = PolylineOptions().apply { id = PathEngine.PROGRESS_POLYLINE_ID @@ -418,13 +527,15 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { 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) { + try { + updateCameraFromState(state) + updateStaticPolyline(state) updateProgressPolyline(state) + renderUiControls(state) + manageAnimationTicker(state.isPlaying) + } catch (e: Exception) { + Log.e(TAG, "Error in UI state update: ${e.message}", e) } - renderUiControls(state) - manageAnimationTicker(state.isPlaying) } } } @@ -446,7 +557,12 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { map.setCamera(newCamera) } - private fun renderUiControls(state: PathPlaybackState) { + private fun renderUiControls(state: PathPlaybackState) { + if (lastRoute != state.route) { + lastRoute = state.route + applyRouteProfile(state.routeProfile) + } + if (lastIsPlaying != state.isPlaying) { lastIsPlaying = state.isPlaying btnPlayPause.setIconResource( @@ -454,7 +570,7 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { ) } - if (!state.isScrubbing) { + if (!state.isScrubbing && !progressSlider.isPressed) { val now = System.currentTimeMillis() if (now - lastSliderUpdateMillis >= 100L || !state.isPlaying) { lastSliderUpdateMillis = now @@ -467,25 +583,50 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { state.speedBoostMultiplier >= 1.5 -> " (2x Boost)" else -> "" } - speedSliderLabel.text = getString(R.string.follow_speed_format, state.followSpeedMps.toInt()) + boostSuffix + speedSliderLabel.text = getString( + R.string.follow_speed_with_suffix_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()) + if (!rangeSlider.isPressed) { + 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()) + } + } + + if (!tiltSlider.isPressed) { + 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()) + } + } + + if (!headingSlider.isPressed) { + 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 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()) + if (!altitudeSlider.isPressed) { + val clampedAltitude = state.groundAltitude.toFloat().coerceIn(altitudeSlider.valueFrom, altitudeSlider.valueTo) + if (abs(altitudeSlider.value - clampedAltitude) >= 0.5f) { + altitudeSlider.value = clampedAltitude + altitudeSliderLabel.text = getString(R.string.ground_altitude_format, state.groundAltitude.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()) + if (!speedSlider.isPressed) { + val clampedSpeed = state.followSpeedMps.toFloat().coerceIn(speedSlider.valueFrom, speedSlider.valueTo) + if (abs(speedSlider.value - clampedSpeed) >= 0.5f) { + speedSlider.value = clampedSpeed + } } } } @@ -562,6 +703,8 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { frameCallback = null } fadeHandler.removeCallbacksAndMessages(null) + staticRoutePolyline?.remove() + progressPolyline?.remove() staticRoutePolyline = null progressPolyline = null map3DView.onDestroy() 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 3b6cf0fa..3579d0f7 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 @@ -16,7 +16,6 @@ package com.example.composedemos.pathfollowing -import android.graphics.Color import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -40,6 +39,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -50,6 +50,7 @@ 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.Info import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.AlertDialog @@ -82,8 +83,12 @@ 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.LocalDensity import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.core.graphics.toColorInt import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -94,15 +99,19 @@ 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.LatLngAltitude import com.google.android.gms.maps3d.model.Map3DMode +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.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 +import kotlin.time.Duration.Companion.milliseconds +import com.example.maps3dcommon.R as CommonR class PathFollowingActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -137,11 +146,10 @@ enum class AltitudeModeOption(val label: String, val mode: Int) { * driven by [PathFollowingViewModel]. */ @Composable -fun PathFollowingScreen( - viewModel: PathFollowingViewModel = viewModel(), -) { +fun PathFollowingScreen(viewModel: PathFollowingViewModel = viewModel()) { val state by viewModel.uiState.collectAsStateWithLifecycle() var showHelpDialog by remember { mutableStateOf(false) } + var showAltitudeInfoDialog by remember { mutableStateOf(false) } var lastInteractionTime by remember { mutableStateOf(System.currentTimeMillis()) } val lifecycleOwner = LocalLifecycleOwner.current @@ -163,7 +171,7 @@ fun PathFollowingScreen( if (!state.isPlaying) return@LaunchedEffect var lastTimeNanos = 0L - while (isActive && state.isPlaying) { + while (isActive) { withFrameMillis { frameTimeMillis -> val nowNanos = frameTimeMillis * 1_000_000L if (lastTimeNanos == 0L) { @@ -178,7 +186,13 @@ fun PathFollowingScreen( } } - val dynamicCamera = remember(state.currentPosition, state.effectiveHeading, state.cameraTilt, state.cameraRange, state.cameraTargetAltitude) { + val dynamicCamera = remember( + state.currentPosition, + state.effectiveHeading, + state.cameraTilt, + state.cameraRange, + state.cameraTargetAltitude, + ) { camera { center = latLngAltitude { latitude = state.currentPosition.latitude @@ -192,28 +206,81 @@ fun PathFollowingScreen( } } - 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, - ) + var googleMap3DInstance by remember { + mutableStateOf(null) + } + var staticPolyline by remember { mutableStateOf(null) } + var progressPolyline by remember { mutableStateOf(null) } + var lastRenderedProgressDist by remember { mutableStateOf(-1.0) } + + // Clear polylines when switching routes + LaunchedEffect(state.route) { + staticPolyline?.remove() + progressPolyline?.remove() + staticPolyline = null + progressPolyline = null + lastRenderedProgressDist = -1.0 } - val progressPolylineConfig = remember(state.progressPolylineVertices, state.altitudeMode, state.drawsOccludedSegments) { - PolylineConfig( - key = PathEngine.PROGRESS_POLYLINE_ID, - points = state.progressPolylineVertices, - width = 8f, - color = Color.parseColor("#9C27B0"), - altitudeMode = state.altitudeMode, - drawsOccludedSegments = state.drawsOccludedSegments, - zIndex = 2, - ) + // Static route polyline: rendered once upon route/altitude mode change + LaunchedEffect( + googleMap3DInstance, + state.staticPolylineVertices, + state.altitudeMode, + state.drawsOccludedSegments, + state.pathAltitudeOffset, + ) { + val map = googleMap3DInstance ?: return@LaunchedEffect + if (state.staticPolylineVertices.size < 2) return@LaunchedEffect + + val staticOptions = PolylineOptions().apply { + id = PathEngine.STATIC_POLYLINE_ID + path = state.staticPolylineVertices + strokeColor = "#4285F4".toColorInt() + strokeWidth = 10.0 + zIndex = 1 + altitudeMode = state.altitudeMode + drawsOccludedSegments = state.drawsOccludedSegments + } + staticPolyline = map.addPolyline(staticOptions) + } + + // Progress polyline: throttled during playback to prevent GPU thrashing and flickering + LaunchedEffect( + googleMap3DInstance, + state.progressPolylineVertices, + state.altitudeMode, + state.drawsOccludedSegments, + state.isPlaying, + ) { + val map = googleMap3DInstance ?: return@LaunchedEffect + if (state.progressPolylineVertices.size < 2) return@LaunchedEffect + + val distDelta = abs(state.elapsedDistance - lastRenderedProgressDist) + if (state.isPlaying && distDelta < 15.0) { + return@LaunchedEffect + } + + 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) + } + + DisposableEffect(Unit) { + onDispose { + staticPolyline?.remove() + progressPolyline?.remove() + staticPolyline = null + progressPolyline = null + } } val viewConfig = LocalViewConfiguration.current @@ -233,7 +300,7 @@ fun PathFollowingScreen( modifier = Modifier.fillMaxSize(), camera = dynamicCamera, mapMode = Map3DMode.HYBRID, - polylines = listOf(staticPolylineConfig, progressPolylineConfig), + onMapReady = { googleMap3DInstance = it }, ) // Custom Gesture Overlay replacing built-in map gestures @@ -265,12 +332,12 @@ fun PathFollowingScreen( viewModel.setPlaying(true) viewModel.setSpeedBoostMultiplier(if (isRightSide) 5.0 else -5.0) } else { - delay(viewConfig.longPressTimeoutMillis) + delay(viewConfig.longPressTimeoutMillis.milliseconds) if (!isDragging && !isPinching) { isLongPressActive = true viewModel.setSpeedBoostMultiplier(2.0) - delay(1500L) - if (isLongPressActive && !isDragging && !isPinching) { + delay(1500.milliseconds) + if (isLongPressActive) { viewModel.setSpeedBoostMultiplier(5.0) } } @@ -300,11 +367,11 @@ fun PathFollowingScreen( 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 - ) - ) { + val dx = abs(change.position.x - down.position.x) + val dy = abs(change.position.y - down.position.y) + val exceedsSlop = dx > viewConfig.touchSlop || + dy > viewConfig.touchSlop + if (!isDragging && exceedsSlop) { isDragging = true longPressJob.cancel() if (isLongPressActive) { @@ -355,6 +422,7 @@ fun PathFollowingScreen( .padding(16.dp), onTogglePlay = { viewModel.togglePlayPause() }, onShowHelp = { showHelpDialog = true }, + onShowAltitudeInfo = { showAltitudeInfoDialog = true }, onSeekRatio = { viewModel.seekToRatio(it) }, onScrubbingChange = { viewModel.setScrubbing(it) }, onAltitudeModeChange = { viewModel.setAltitudeMode(it.mode) }, @@ -365,8 +433,8 @@ fun PathFollowingScreen( 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) + onEnvironmentChange = { route -> + viewModel.setRoute(route, applyDefaults = true) }, ) @@ -398,6 +466,23 @@ fun PathFollowingScreen( }, ) } + + if (showAltitudeInfoDialog) { + AlertDialog( + onDismissRequest = { showAltitudeInfoDialog = false }, + title = { Text(stringResource(CommonR.string.altitude_mode_info_title)) }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Text(stringResource(CommonR.string.altitude_mode_info_message)) + } + }, + confirmButton = { + TextButton(onClick = { showAltitudeInfoDialog = false }) { + Text(stringResource(CommonR.string.help_dialog_ok)) + } + }, + ) + } } } @@ -413,6 +498,7 @@ private fun PathFollowingControlCard( modifier: Modifier = Modifier, onTogglePlay: () -> Unit, onShowHelp: () -> Unit, + onShowAltitudeInfo: () -> Unit = {}, onSeekRatio: (Float) -> Unit, onScrubbingChange: (Boolean) -> Unit, onAltitudeModeChange: (AltitudeModeOption) -> Unit, @@ -423,14 +509,14 @@ private fun PathFollowingControlCard( onHeadingOffsetChange: (Float) -> Unit, onCameraTiltChange: (Float) -> Unit, onSpeedChange: (Float) -> Unit, - onEnvironmentChange: (Boolean) -> Unit, + onEnvironmentChange: (List) -> Unit, ) { var isCollapsed by remember { mutableStateOf(false) } var isIdle by remember { mutableStateOf(false) } LaunchedEffect(lastInteractionTime) { isIdle = false - delay(3500L) + delay(3500.milliseconds) isIdle = true } @@ -455,19 +541,29 @@ private fun PathFollowingControlCard( elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), ) { Column( - modifier = Modifier - .padding(12.dp) - .verticalScroll(rememberScrollState()), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), ) { // 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), - ) + .fillMaxWidth() + .clickable { + isCollapsed = !isCollapsed + onUserTouch() + } + .padding(vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .width(36.dp) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background( + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ), + ) + } Spacer(modifier = Modifier.height(4.dp)) @@ -510,9 +606,17 @@ private fun PathFollowingControlCard( contentDescription = "Help", ) } - IconButton(onClick = { isCollapsed = !isCollapsed }, modifier = Modifier.size(48.dp)) { + IconButton( + onClick = { isCollapsed = !isCollapsed }, + modifier = Modifier.size(48.dp), + ) { + val expandIcon = if (isCollapsed) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + } Icon( - imageVector = if (isCollapsed) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + imageVector = expandIcon, contentDescription = if (isCollapsed) "Expand" else "Collapse", ) } @@ -520,16 +624,30 @@ private fun PathFollowingControlCard( } // Speed Preset Chips (Always visible) + val baseSpeed = state.routeProfile.recommendedSpeed + val speedPresets = listOf( + (baseSpeed * 0.5f) to "0.5x", + (baseSpeed * 1.0f) to "1x", + (baseSpeed * 2.0f) to "2x", + (baseSpeed * 3.0f) to "3x", + (baseSpeed * 5.0f) to "5x", + ) Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + 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) -> + speedPresets.forEach { (speed, label) -> + val safeSpeed = speed.coerceIn( + state.routeProfile.speedSliderMin, + state.routeProfile.speedSliderMax, + ) FilterChip( - selected = abs(state.followSpeedMps.toFloat() - speed) < 1f, + selected = abs(state.followSpeedMps.toFloat() - safeSpeed) < 1f, onClick = { - onSpeedChange(speed) + onSpeedChange(safeSpeed) onUserTouch() }, label = { Text(label, style = MaterialTheme.typography.labelSmall) }, @@ -550,12 +668,20 @@ private fun PathFollowingControlCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - IconButton(onClick = { - onTogglePlay() - onUserTouch() - }, modifier = Modifier.size(44.dp)) { + IconButton( + onClick = { + onTogglePlay() + onUserTouch() + }, + modifier = Modifier.size(44.dp), + ) { + val playPauseIcon = if (state.isPlaying) { + Icons.Default.Pause + } else { + Icons.Default.PlayArrow + } Icon( - imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + imageVector = playPauseIcon, contentDescription = if (state.isPlaying) "Pause" else "Play", ) } @@ -574,54 +700,105 @@ private fun PathFollowingControlCard( // Expandable Settings Section AnimatedVisibility(visible = !isCollapsed) { + val windowInfo = LocalWindowInfo.current + val density = LocalDensity.current + val maxSettingsHeight = minOf( + 260.dp, + with(density) { (windowInfo.containerSize.height * 0.38f).toDp() }, + ) + Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .heightIn(max = maxSettingsHeight) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), ) { // Environment Selection Text("Path Environment:", style = MaterialTheme.typography.labelLarge) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { - onEnvironmentChange(true) + onEnvironmentChange(PathData.URBAN_PATH) onUserTouch() }, ) { RadioButton( selected = state.route == PathData.URBAN_PATH, onClick = { - onEnvironmentChange(true) + onEnvironmentChange(PathData.URBAN_PATH) onUserTouch() }, ) - Text("Urban (SF)") + Text("Urban") } Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { - onEnvironmentChange(false) + onEnvironmentChange(PathData.RURAL_PATH) onUserTouch() }, ) { RadioButton( selected = state.route == PathData.RURAL_PATH, onClick = { - onEnvironmentChange(false) + onEnvironmentChange(PathData.RURAL_PATH) onUserTouch() }, ) - Text("Rural (Marin)") + Text("Rural") + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + onEnvironmentChange(PathData.MOUNTAIN_PATH) + onUserTouch() + }, + ) { + RadioButton( + selected = state.route == PathData.MOUNTAIN_PATH, + onClick = { + onEnvironmentChange(PathData.MOUNTAIN_PATH) + onUserTouch() + }, + ) + Text("Mountain") } } // Altitude Mode - Text("Altitude Mode:", style = MaterialTheme.typography.labelLarge) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Altitude Mode:", + style = MaterialTheme.typography.labelLarge, + ) + IconButton( + onClick = { + onShowAltitudeInfo() + onUserTouch() + }, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = stringResource( + CommonR.string.altitude_mode_info_description, + ), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } Column { - AltitudeModeOption.values().forEach { modeOption -> + AltitudeModeOption.entries.forEach { modeOption -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -671,26 +848,30 @@ private fun PathFollowingControlCard( valueRange = 0f..maxPathAlt, ) - // Camera Range Slider + // Camera Range Slider (Dynamically calibrated) + val rangeMin = state.routeProfile.rangeSliderMin + val rangeMax = state.routeProfile.rangeSliderMax Text("Camera Range: ${state.cameraRange.toInt()} m") Slider( - value = state.cameraRange.toFloat().coerceIn(50f, 1500f), + value = state.cameraRange.toFloat().coerceIn(rangeMin, rangeMax), onValueChange = { onCameraRangeChange(it) onUserTouch() }, - valueRange = 50f..1500f, + valueRange = rangeMin..rangeMax, ) - // Ground Altitude Slider + // Ground Altitude Slider (Dynamically calibrated) + val altMin = state.routeProfile.altitudeSliderMin + val altMax = state.routeProfile.altitudeSliderMax Text("Ground Altitude: ${state.groundAltitude.toInt()} m") Slider( - value = state.groundAltitude.toFloat().coerceIn(0f, 500f), + value = state.groundAltitude.toFloat().coerceIn(altMin, altMax), onValueChange = { onGroundAltitudeChange(it) onUserTouch() }, - valueRange = 0f..500f, + valueRange = altMin..altMax, ) // Camera Heading Offset Slider @@ -715,7 +896,9 @@ private fun PathFollowingControlCard( valueRange = 0f..85f, ) - // Follow Speed Slider + // Follow Speed Slider (Dynamically calibrated) + val speedMin = state.routeProfile.speedSliderMin + val speedMax = state.routeProfile.speedSliderMax val boostSuffix = when { state.speedBoostMultiplier >= 4.5 -> " (5x Fast-Forward)" state.speedBoostMultiplier <= -4.5 -> " (-5x Rewind)" @@ -724,12 +907,12 @@ private fun PathFollowingControlCard( } Text("Follow Speed: ${state.followSpeedMps.toInt()} m/s$boostSuffix") Slider( - value = state.followSpeedMps.toFloat().coerceIn(5f, 200f), + value = state.followSpeedMps.toFloat().coerceIn(speedMin, speedMax), onValueChange = { onSpeedChange(it) onUserTouch() }, - valueRange = 5f..200f, + valueRange = speedMin..speedMax, ) } } From 54b692c1cd58e02e04de126014747a66c5dee99c Mon Sep 17 00:00:00 2001 From: Ashik Abbas Date: Tue, 8 Sep 2026 11:23:06 +0530 Subject: [PATCH 2/4] feat(path-following) : Fixed all PR comments and remove unused code --- .../maps3d/common/MaxHeightScrollView.kt | 42 ++++++--- .../com/example/maps3d/common/PathData.kt | 40 +------- .../maps3d/common/PathFollowingViewModel.kt | 84 ----------------- .../res/layout/activity_path_following.xml | 1 + .../pathfollowing/PathFollowingActivity.java | 14 +-- .../pathfollowing/PathFollowingActivity.kt | 14 +-- .../pathfollowing/PathFollowingActivity.kt | 94 ++++++------------- .../maps/android/compose3d/GoogleMap3D.kt | 8 +- .../maps/android/compose3d/Map3DState.kt | 71 +++++++------- 9 files changed, 114 insertions(+), 254 deletions(-) diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/MaxHeightScrollView.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/MaxHeightScrollView.kt index 6a5820e1..bf97e748 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/MaxHeightScrollView.kt +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/MaxHeightScrollView.kt @@ -21,9 +21,11 @@ import android.util.AttributeSet import android.widget.ScrollView /** - * A [ScrollView] that dynamically constrains its maximum measured height so that - * collapsible control panels never cover the entire display across varied screen sizes - * and orientations. + * A [ScrollView] that constrains its maximum measured height so that + * collapsible control panels never cover the entire display across varied screen sizes, + * orientations, and multi-window configurations. + * + * Supports specifying `android:maxHeight` in XML or defaults to 480dp. */ class MaxHeightScrollView @JvmOverloads constructor( context: Context, @@ -31,26 +33,40 @@ class MaxHeightScrollView @JvmOverloads constructor( defStyleAttr: Int = 0, ) : ScrollView(context, attrs, defStyleAttr) { - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val displayMetrics = context.resources.displayMetrics - val density = displayMetrics.density - val screenHeight = displayMetrics.heightPixels + private var maxHeightPx: Int = -1 + + init { + if (attrs != null) { + val typedArray = context.obtainStyledAttributes(attrs, intArrayOf(android.R.attr.maxHeight)) + try { + maxHeightPx = typedArray.getDimensionPixelSize(0, -1) + } finally { + typedArray.recycle() + } + } + } - // Constrain height to at most 260dp or 60% of total screen height - val maxDpHeight = (480 * density).toInt() - val maxScreenShare = (screenHeight * 0.60f).toInt() - val effectiveMax = minOf(maxDpHeight, maxScreenShare) + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val maxAllowed = if (maxHeightPx > 0) { + maxHeightPx + } else { + (DEFAULT_MAX_HEIGHT_DP * context.resources.displayMetrics.density).toInt() + } val originalSize = MeasureSpec.getSize(heightMeasureSpec) val originalMode = MeasureSpec.getMode(heightMeasureSpec) val targetHeight = if (originalMode != MeasureSpec.UNSPECIFIED && originalSize > 0) { - minOf(effectiveMax, originalSize) + minOf(maxAllowed, originalSize) } else { - effectiveMax + maxAllowed } val constrainedHeightSpec = MeasureSpec.makeMeasureSpec(targetHeight, MeasureSpec.AT_MOST) super.onMeasure(widthMeasureSpec, constrainedHeightSpec) } + + companion object { + private const val DEFAULT_MAX_HEIGHT_DP = 480 + } } 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 index bf30a220..0b2b5646 100644 --- 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 @@ -116,7 +116,7 @@ object PathData { LatLngAltitude(37.264310, -122.412160, 14.0), LatLngAltitude(37.265160, -122.411950, 13.1), LatLngAltitude(37.265870, -122.411680, 9.4), - LatLngAltitude(37.266480, -122.411390, 1.6), + LatLngAltitude(37.266480, -122.411390, 9.5), LatLngAltitude(37.267140, -122.411000, 9.6), LatLngAltitude(37.268110, -122.410400, 7.6), LatLngAltitude(37.268560, -122.410170, 6.6), @@ -286,42 +286,4 @@ object PathData { LatLngAltitude(34.129210, -118.299270, 455.0), LatLngAltitude(34.129340, -118.298900, 457.0) ) - - /** - * Decodes an encoded polyline string from the Google Routes API into a list of [LatLngAltitude]. - */ - @JvmStatic - fun decodePolyline(encoded: String, altitude: Double = 0.0): List { - val poly = mutableListOf() - var index = 0 - val len = encoded.length - var lat = 0 - var lng = 0 - - while (index < len) { - var b: Int - var shift = 0 - var result = 0 - do { - b = encoded[index++].code - 63 - result = result or (b and 0x1f shl shift) - shift += 5 - } while (b >= 0x20) - val dlat = if (result and 1 != 0) (result shr 1).inv() else result shr 1 - lat += dlat - - shift = 0 - result = 0 - do { - b = encoded[index++].code - 63 - result = result or (b and 0x1f shl shift) - shift += 5 - } while (b >= 0x20) - val dlng = if (result and 1 != 0) (result shr 1).inv() else result shr 1 - lng += dlng - - poly.add(LatLngAltitude(lat / 1E5, lng / 1E5, altitude)) - } - return poly - } } 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 index 5956cd4f..68d35723 100644 --- 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 @@ -20,15 +20,9 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import com.google.android.gms.maps3d.model.LatLngAltitude -import java.net.HttpURLConnection -import java.net.URL -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.withContext -import org.json.JSONObject /** * Shared Architecture ViewModel for 3D Path Following across Kotlin, Java, and Jetpack Compose. @@ -86,84 +80,6 @@ class PathFollowingViewModel( _uiState.value = controller.setRoute(newRoute, applyDefaults) } - /** - * Fetches a real-time road route from the Google Routes API, decodes it, and sets it - * as the active route. - * - * @return true if route was successfully fetched and applied, false otherwise. - */ - @Suppress("unused") - suspend fun fetchAndSetRoute( - apiKey: String, - originLat: Double, - originLng: Double, - destLat: Double, - destLng: Double, - ioDispatcher: CoroutineDispatcher = Dispatchers.IO, - ): Boolean = withContext(ioDispatcher) { - try { - val url = URL("https://routes.googleapis.com/directions/v2:computeRoutes") - val connection = url.openConnection() as HttpURLConnection - connection.requestMethod = "POST" - connection.setRequestProperty("Content-Type", "application/json") - connection.setRequestProperty("X-Goog-Api-Key", apiKey) - connection.setRequestProperty("X-Goog-FieldMask", "routes.polyline.encodedPolyline") - connection.doOutput = true - - val requestJson = JSONObject().apply { - put( - "origin", - JSONObject().put( - "location", - JSONObject().put( - "latLng", - JSONObject().apply { - put("latitude", originLat) - put("longitude", originLng) - }, - ), - ), - ) - put( - "destination", - JSONObject().put( - "location", - JSONObject().put( - "latLng", - JSONObject().apply { - put("latitude", destLat) - put("longitude", destLng) - }, - ), - ), - ) - put("travelMode", "DRIVE") - } - - connection.outputStream.use { os -> - os.write(requestJson.toString().toByteArray(Charsets.UTF_8)) - } - - val response = connection.inputStream.bufferedReader().use { it.readText() } - val jsonResponse = JSONObject(response) - val routes = jsonResponse.optJSONArray("routes") - if (routes != null && routes.length() > 0) { - val encoded = routes.getJSONObject(0) - .getJSONObject("polyline") - .getString("encodedPolyline") - val decoded = PathData.decodePolyline(encoded) - withContext(Dispatchers.Main) { - setRoute(decoded, applyDefaults = true) - } - true - } else { - false - } - } catch (_: Exception) { - false - } - } - fun setAltitudeMode(mode: Int) { _uiState.value = controller.setAltitudeMode(mode) } 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 d9cb6157..3ae39c80 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 @@ -229,6 +229,7 @@ android:id="@+id/controls_scroll" android:layout_width="match_parent" android:layout_height="wrap_content" + android:maxHeight="480dp" android:scrollbars="vertical" > 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 86110630..0a1df2f5 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 @@ -530,15 +530,11 @@ private void updateProgressPolyline(PathPlaybackState state, boolean force) { private void observeViewModel() { viewModel.getLiveData().observe(this, state -> { - try { - updateCameraFromState(state); - updateStaticPolyline(state); - updateProgressPolyline(state, false); - renderUiControls(state); - manageAnimationTicker(state.isPlaying()); - } catch (Exception e) { - Log.e(TAG, "Error in UI state update: " + e.getMessage(), e); - } + updateCameraFromState(state); + updateStaticPolyline(state); + updateProgressPolyline(state, false); + renderUiControls(state); + manageAnimationTicker(state.isPlaying()); }); } 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 6dd44194..6a4609c0 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 @@ -527,15 +527,11 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state -> - try { - updateCameraFromState(state) - updateStaticPolyline(state) - updateProgressPolyline(state) - renderUiControls(state) - manageAnimationTicker(state.isPlaying) - } catch (e: Exception) { - Log.e(TAG, "Error in UI state update: ${e.message}", e) - } + updateCameraFromState(state) + updateStaticPolyline(state) + updateProgressPolyline(state) + renderUiControls(state) + manageAnimationTicker(state.isPlaying) } } } 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 3579d0f7..b249887d 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 @@ -101,11 +101,10 @@ import com.example.maps3d.common.PathPlaybackState import com.google.android.gms.maps3d.model.AltitudeMode import com.google.android.gms.maps3d.model.LatLngAltitude import com.google.android.gms.maps3d.model.Map3DMode -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.maps.android.compose3d.GoogleMap3D +import com.google.maps.android.compose3d.PolylineConfig import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -206,80 +205,43 @@ fun PathFollowingScreen(viewModel: PathFollowingViewModel = viewModel()) { } } - var googleMap3DInstance by remember { - mutableStateOf(null) - } - var staticPolyline by remember { mutableStateOf(null) } - var progressPolyline by remember { mutableStateOf(null) } - var lastRenderedProgressDist by remember { mutableStateOf(-1.0) } - - // Clear polylines when switching routes - LaunchedEffect(state.route) { - staticPolyline?.remove() - progressPolyline?.remove() - staticPolyline = null - progressPolyline = null - lastRenderedProgressDist = -1.0 - } - - // Static route polyline: rendered once upon route/altitude mode change - LaunchedEffect( - googleMap3DInstance, + val staticPolylineConfig = remember( state.staticPolylineVertices, state.altitudeMode, state.drawsOccludedSegments, - state.pathAltitudeOffset, ) { - val map = googleMap3DInstance ?: return@LaunchedEffect - if (state.staticPolylineVertices.size < 2) return@LaunchedEffect - - val staticOptions = PolylineOptions().apply { - id = PathEngine.STATIC_POLYLINE_ID - path = state.staticPolylineVertices - strokeColor = "#4285F4".toColorInt() - strokeWidth = 10.0 - zIndex = 1 - altitudeMode = state.altitudeMode - drawsOccludedSegments = state.drawsOccludedSegments + if (state.staticPolylineVertices.size < 2) { + null + } else { + PolylineConfig( + key = PathEngine.STATIC_POLYLINE_ID, + points = state.staticPolylineVertices, + color = "#4285F4".toColorInt(), + width = 10f, + altitudeMode = state.altitudeMode, + drawsOccludedSegments = state.drawsOccludedSegments, + zIndex = 1, + ) } - staticPolyline = map.addPolyline(staticOptions) } - // Progress polyline: throttled during playback to prevent GPU thrashing and flickering - LaunchedEffect( - googleMap3DInstance, + val progressPolylineConfig = remember( state.progressPolylineVertices, state.altitudeMode, state.drawsOccludedSegments, - state.isPlaying, ) { - val map = googleMap3DInstance ?: return@LaunchedEffect - if (state.progressPolylineVertices.size < 2) return@LaunchedEffect - - val distDelta = abs(state.elapsedDistance - lastRenderedProgressDist) - if (state.isPlaying && distDelta < 15.0) { - return@LaunchedEffect - } - - 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) - } - - DisposableEffect(Unit) { - onDispose { - staticPolyline?.remove() - progressPolyline?.remove() - staticPolyline = null - progressPolyline = null + if (state.progressPolylineVertices.size < 2) { + null + } else { + PolylineConfig( + key = PathEngine.PROGRESS_POLYLINE_ID, + points = state.progressPolylineVertices, + color = "#9C27B0".toColorInt(), + width = 8f, + altitudeMode = state.altitudeMode, + drawsOccludedSegments = state.drawsOccludedSegments, + zIndex = 2, + ) } } @@ -300,7 +262,7 @@ fun PathFollowingScreen(viewModel: PathFollowingViewModel = viewModel()) { modifier = Modifier.fillMaxSize(), camera = dynamicCamera, mapMode = Map3DMode.HYBRID, - onMapReady = { googleMap3DInstance = it }, + polylines = listOfNotNull(staticPolylineConfig, progressPolylineConfig), ) // Custom Gesture Overlay replacing built-in map gestures diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt index bd14fbdc..ba499475 100644 --- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt @@ -82,7 +82,8 @@ fun GoogleMap3D( minTilt = 0.0, maxTilt = 90.0, bounds = null, - mapMode = Map3DMode.SATELLITE, // using the class from com.google.android.gms.maps3d.model.Map3DMode + // using the class from com.google.android.gms.maps3d.model.Map3DMode + mapMode = Map3DMode.SATELLITE, mapId = null, minAltitude = 0.0, maxAltitude = 1000000.0, @@ -129,7 +130,10 @@ fun GoogleMap3D( if (currentOnMapClick != null || currentOnPlaceClick != null) { googleMap3D.setMap3DClickListener { location, placeId -> - android.util.Log.d("GoogleMap3D", "Map clicked at $location, placeId: $placeId") + android.util.Log.d( + "GoogleMap3D", + "Map clicked at $location, placeId: $placeId", + ) if (placeId != null) { currentOnPlaceClick?.invoke(placeId) } else { diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt index 11539222..474b1fdb 100644 --- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt @@ -17,23 +17,14 @@ package com.google.maps.android.compose3d import android.content.Context -import android.graphics.Color import androidx.compose.ui.platform.ComposeView import com.google.android.gms.maps3d.GoogleMap3D import com.google.android.gms.maps3d.Popover -import com.google.android.gms.maps3d.model.Glyph -import com.google.android.gms.maps3d.model.Hole import com.google.android.gms.maps3d.model.Marker import com.google.android.gms.maps3d.model.Model -import com.google.android.gms.maps3d.model.Orientation -import com.google.android.gms.maps3d.model.PinConfiguration import com.google.android.gms.maps3d.model.Polygon import com.google.android.gms.maps3d.model.Polyline -import com.google.android.gms.maps3d.model.markerOptions -import com.google.android.gms.maps3d.model.orientation -import com.google.android.gms.maps3d.model.polygonOptions import com.google.android.gms.maps3d.model.popoverOptions -import com.google.android.gms.maps3d.model.vector3D import com.google.maps.android.compose3d.utils.toValidLocation /** @@ -85,7 +76,11 @@ class Map3DState { } } - private fun createMarker(map: GoogleMap3D, config: MarkerConfig, overrideId: String? = null): Marker? { + private fun createMarker( + map: GoogleMap3D, + config: MarkerConfig, + overrideId: String? = null, + ): Marker? { val marker = map.addMarker(config.toMarkerOptions(overrideId)) config.onClick?.let { callback -> marker?.setClickListener { @@ -108,18 +103,26 @@ class Map3DState { if (existing != null) { val (oldConfig, polyline) = existing if (oldConfig != config) { - // Config changed, update by adding again with same ID! - val newPolyline = createPolyline(map, config, polyline.id) - if (newPolyline != null) { - polylines[config.key] = Pair(config, newPolyline) + // Update existing polyline in-place to prevent flickering and unnecessary recreations + polyline.path = config.points.map { it.toValidLocation() } + polyline.strokeColor = config.color + polyline.strokeWidth = config.width.toDouble() + polyline.altitudeMode = config.altitudeMode + polyline.zIndex = config.zIndex + polyline.outerColor = config.outerColor + polyline.outerWidth = config.outerWidth.toDouble() + polyline.drawsOccludedSegments = config.drawsOccludedSegments + config.onClick?.let { callback -> + polyline.setClickListener { + callback(polyline) + } } + polylines[config.key] = Pair(config, polyline) } } else { // New polyline val newPolyline = createPolyline(map, config) - if (newPolyline != null) { - polylines[config.key] = Pair(config, newPolyline) - } + polylines[config.key] = Pair(config, newPolyline) } } @@ -129,7 +132,11 @@ class Map3DState { } } - private fun createPolyline(map: GoogleMap3D, config: PolylineConfig, overrideId: String? = null): Polyline { + private fun createPolyline( + map: GoogleMap3D, + config: PolylineConfig, + overrideId: String? = null, + ): Polyline { val polyline = map.addPolyline(config.toPolylineOptions(overrideId)) config.onClick?.let { callback -> polyline.setClickListener { @@ -154,16 +161,12 @@ class Map3DState { if (oldConfig != config) { // Config changed, update by adding again with same ID! val newPolygon = createPolygon(map, config, polygon.id) - if (newPolygon != null) { - polygons[config.key] = Pair(config, newPolygon) - } + polygons[config.key] = Pair(config, newPolygon) } } else { // New polygon val newPolygon = createPolygon(map, config) - if (newPolygon != null) { - polygons[config.key] = Pair(config, newPolygon) - } + polygons[config.key] = Pair(config, newPolygon) } } @@ -173,7 +176,11 @@ class Map3DState { } } - private fun createPolygon(map: GoogleMap3D, config: PolygonConfig, overrideId: String? = null): Polygon { + private fun createPolygon( + map: GoogleMap3D, + config: PolygonConfig, + overrideId: String? = null, + ): Polygon { val polygon = map.addPolygon(config.toPolygonOptions(overrideId)) config.onClick?.let { callback -> polygon.setClickListener { @@ -201,16 +208,12 @@ class Map3DState { // with the same underlying ID (`model.id`). Under the hood, this acts as a safe, // efficient upsert operation to the renderer. val newModel = createModel(map, config, model.id) - if (newModel != null) { - models[config.key] = Pair(config, newModel) - } + models[config.key] = Pair(config, newModel) } } else { // New model val newModel = createModel(map, config) - if (newModel != null) { - models[config.key] = Pair(config, newModel) - } + models[config.key] = Pair(config, newModel) } } @@ -220,7 +223,11 @@ class Map3DState { } } - private fun createModel(map: GoogleMap3D, config: ModelConfig, overrideId: String? = null): Model { + private fun createModel( + map: GoogleMap3D, + config: ModelConfig, + overrideId: String? = null, + ): Model { val model = map.addModel(config.toModelOptions(overrideId)) config.onClick?.let { callback -> model.setClickListener { From c0ad1d57143b106123b7a306e9dd98f37efba686 Mon Sep 17 00:00:00 2001 From: Ashik Abbas Date: Tue, 8 Sep 2026 13:59:06 +0530 Subject: [PATCH 3/4] feat(path-following) : enable purple line progress polyline to animate smoothly in real-time lockstep with the camera across Kotlin, Java, and Compose. --- .../com/example/maps3d/common/PathEngine.kt | 6 ++- .../maps3d/common/PathPlaybackController.kt | 6 ++- .../common/PathPlaybackControllerTest.kt | 27 ++++++++++ .../pathfollowing/PathFollowingActivity.java | 49 +++++++++++++++---- .../pathfollowing/PathFollowingActivity.kt | 39 ++++++++++----- .../maps/android/compose3d/GoogleMap3D.kt | 9 ++-- .../maps/android/compose3d/Map3DState.kt | 19 ++----- 7 files changed, 107 insertions(+), 48 deletions(-) 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 index 63b6c5ef..0a18cad8 100644 --- 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 @@ -429,7 +429,7 @@ object PathEngine { val lastLatLng = LatLng(lastWaypoint.latitude, lastWaypoint.longitude) val distToLast = SphericalUtil.computeDistanceBetween(lastLatLng, currentLatLng) - if (distToLast >= 0.5) { + if (distToLast >= 0.05) { val p1 = path[clampedIndex] val p2 = if (clampedIndex < path.size - 1) path[clampedIndex + 1] else p1 val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0 @@ -454,7 +454,9 @@ object PathEngine { 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 segDist = SphericalUtil.computeDistanceBetween(p0, p1) + val tinyFraction = if (segDist > 0.0) (0.05 / segDist).coerceIn(0.0001, 0.1) else 0.001 + val tinyForward = SphericalUtil.interpolate(p0, p1, tinyFraction) val startAlt = when (altitudeMode) { AltitudeMode.CLAMP_TO_GROUND -> 0.0 AltitudeMode.ABSOLUTE -> path[0].altitude + pathAltitudeOffset + 0.4 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 cd5c02d3..59c2cbcf 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 @@ -188,12 +188,14 @@ class PathPlaybackController( fun setPlaying(isPlaying: Boolean): PathPlaybackState { state = state.copy(isPlaying = isPlaying) + if (!isPlaying) { + return updateDistanceAndRecompute(state.elapsedDistance, updateProgressRatio = false) + } return state } fun togglePlayPause(): PathPlaybackState { - state = state.copy(isPlaying = !state.isPlaying) - return state + return setPlaying(!state.isPlaying) } fun setRoute(newRoute: List, applyDefaults: Boolean = true): PathPlaybackState { 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 index dfaaad63..735ad347 100644 --- 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 @@ -236,4 +236,31 @@ class PathPlaybackControllerTest { controller.skipRatio(-0.20f) assertEquals(totalDist * 0.40, controller.getState().elapsedDistance, 0.5) } + + @Test + fun advance_updatesProgressVerticesSmoothlyOnSmallSteps() { + controller.setPlaying(true) + val initialVertices = controller.getState().progressPolylineVertices + assertTrue(initialVertices.size >= 2) + + // Advance by 1 frame (16.6ms at default 30 m/s = ~0.5m) + controller.advance(0.0166) + val state1 = controller.getState() + assertTrue(state1.elapsedDistance > 0.0) + val vertices1 = state1.progressPolylineVertices + assertTrue(vertices1.size >= 2) + val tip1 = vertices1.last() + assertEquals(state1.currentPosition.latitude, tip1.latitude, 0.0001) + assertEquals(state1.currentPosition.longitude, tip1.longitude, 0.0001) + + // Advance by another frame + controller.advance(0.0166) + val state2 = controller.getState() + assertTrue(state2.elapsedDistance > state1.elapsedDistance) + val vertices2 = state2.progressPolylineVertices + val tip2 = vertices2.last() + assertEquals(state2.currentPosition.latitude, tip2.latitude, 0.0001) + assertEquals(state2.currentPosition.longitude, tip2.longitude, 0.0001) + assertTrue(tip2.latitude != tip1.latitude || tip2.longitude != tip1.longitude) + } } 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 0a1df2f5..b83f9985 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 @@ -77,6 +77,9 @@ public class PathFollowingActivity extends AppCompatActivity implements OnMap3DV private Boolean lastStaticDrawsOccluded; private Double lastStaticAltitudeOffset; private double lastRenderedProgressDist = -1.0; + private Integer lastProgressAltitudeMode; + private Boolean lastProgressDrawsOccluded; + private boolean isMapInitialized = false; private long lastSliderUpdateMillis = 0L; private Boolean lastIsPlaying; private List lastRoute; @@ -135,7 +138,16 @@ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) { this.googleMap3D = googleMap3D; googleMap3D.setOnMapReadyListener( - initialTime -> runOnUiThread(this::resetPolylines)); + initialTime -> { + googleMap3D.setOnMapReadyListener(null); + runOnUiThread(this::initializeMap); + }); + } + + private void initializeMap() { + if (isMapInitialized) return; + isMapInitialized = true; + resetPolylines(); } private void setupCustomGestureHandling() { @@ -465,13 +477,21 @@ private void resetPolylines() { lastStaticAltitudeMode = null; lastStaticDrawsOccluded = null; lastStaticAltitudeOffset = null; + lastProgressAltitudeMode = null; + lastProgressDrawsOccluded = null; lastRenderedProgressDist = -1.0; if (staticRoutePolyline != null) { - staticRoutePolyline.remove(); + try { + staticRoutePolyline.remove(); + } catch (Exception ignored) { + } staticRoutePolyline = null; } if (progressPolyline != null) { - progressPolyline.remove(); + try { + progressPolyline.remove(); + } catch (Exception ignored) { + } progressPolyline = null; } PathPlaybackState state = viewModel.getCurrentState(); @@ -512,11 +532,18 @@ private void updateProgressPolyline(PathPlaybackState state, boolean force) { if (googleMap3D == null || state == null || state.getProgressPolylineVertices().size() < 2) return; double distDelta = Math.abs(state.getElapsedDistance() - lastRenderedProgressDist); - if (!force && state.isPlaying() && distDelta < 15.0) { + boolean configChanged = progressPolyline == null + || lastProgressAltitudeMode == null || lastProgressAltitudeMode != state.getAltitudeMode() + || lastProgressDrawsOccluded == null || lastProgressDrawsOccluded != state.getDrawsOccludedSegments(); + + if (!force && !configChanged && distDelta <= 0.0) { return; } lastRenderedProgressDist = state.getElapsedDistance(); + lastProgressAltitudeMode = state.getAltitudeMode(); + lastProgressDrawsOccluded = state.getDrawsOccludedSegments(); + PolylineOptions progressOptions = new PolylineOptions(); progressOptions.setId(PathEngine.PROGRESS_POLYLINE_ID); progressOptions.setPath(state.getProgressPolylineVertices()); @@ -530,11 +557,15 @@ private void updateProgressPolyline(PathPlaybackState state, boolean force) { private void observeViewModel() { viewModel.getLiveData().observe(this, state -> { - updateCameraFromState(state); - updateStaticPolyline(state); - updateProgressPolyline(state, false); - renderUiControls(state); - manageAnimationTicker(state.isPlaying()); + try { + updateCameraFromState(state); + updateStaticPolyline(state); + updateProgressPolyline(state, false); + renderUiControls(state); + manageAnimationTicker(state.isPlaying()); + } catch (Exception e) { + Log.e(TAG, "Error in UI state update: " + e.getMessage(), e); + } }); } 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 6a4609c0..f7d8c13e 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 @@ -82,6 +82,9 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { private var lastStaticDrawsOccluded: Boolean? = null private var lastStaticAltitudeOffset: Double? = null private var lastRenderedProgressDist = -1.0 + private var lastProgressAltitudeMode: Int? = null + private var lastProgressDrawsOccluded: Boolean? = null + private var isMapInitialized = false private var lastSliderUpdateMillis = 0L private var lastIsPlaying: Boolean? = null private var lastRoute: List? = null @@ -136,14 +139,11 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { this.googleMap3D = googleMap3D googleMap3D.setOnMapReadyListener { + googleMap3D.setOnMapReadyListener(null) runOnUiThread { - lastStaticVertices = null - lastRenderedProgressDist = -1.0 - val state = viewModel.currentState - updateStaticPolyline(state) - updateProgressPolyline(state) - updateCameraFromState(state) - renderUiControls(state) + if (isMapInitialized) return@runOnUiThread + isMapInitialized = true + resetPolylines() } } } @@ -459,6 +459,8 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { lastStaticAltitudeMode = null lastStaticDrawsOccluded = null lastStaticAltitudeOffset = null + lastProgressAltitudeMode = null + lastProgressDrawsOccluded = null lastRenderedProgressDist = -1.0 staticRoutePolyline?.remove() progressPolyline?.remove() @@ -505,12 +507,19 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { val map = googleMap3D ?: return if (state.progressPolylineVertices.size < 2) return + val configChanged = progressPolyline == null || + lastProgressAltitudeMode != state.altitudeMode || + lastProgressDrawsOccluded != state.drawsOccludedSegments + val distDelta = abs(state.elapsedDistance - lastRenderedProgressDist) - if (!force && state.isPlaying && distDelta < 15.0) { + if (!force && !configChanged && distDelta <= 0.0) { return } lastRenderedProgressDist = state.elapsedDistance + lastProgressAltitudeMode = state.altitudeMode + lastProgressDrawsOccluded = state.drawsOccludedSegments + val progressOptions = PolylineOptions().apply { id = PathEngine.PROGRESS_POLYLINE_ID path = state.progressPolylineVertices @@ -527,11 +536,15 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state -> - updateCameraFromState(state) - updateStaticPolyline(state) - updateProgressPolyline(state) - renderUiControls(state) - manageAnimationTicker(state.isPlaying) + try { + updateCameraFromState(state) + updateStaticPolyline(state) + updateProgressPolyline(state) + renderUiControls(state) + manageAnimationTicker(state.isPlaying) + } catch (e: Exception) { + Log.e(TAG, "Error in UI state collection: ${e.message}", e) + } } } } diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt index ba499475..4ec383c0 100644 --- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt @@ -16,6 +16,7 @@ package com.google.maps.android.compose3d +import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -82,8 +83,7 @@ fun GoogleMap3D( minTilt = 0.0, maxTilt = 90.0, bounds = null, - // using the class from com.google.android.gms.maps3d.model.Map3DMode - mapMode = Map3DMode.SATELLITE, + mapMode = Map3DMode.SATELLITE, // using the class from com.google.android.gms.maps3d.model.Map3DMode mapId = null, minAltitude = 0.0, maxAltitude = 1000000.0, @@ -130,10 +130,7 @@ fun GoogleMap3D( if (currentOnMapClick != null || currentOnPlaceClick != null) { googleMap3D.setMap3DClickListener { location, placeId -> - android.util.Log.d( - "GoogleMap3D", - "Map clicked at $location, placeId: $placeId", - ) + Log.d("GoogleMap3D", "Map clicked at $location, placeId: $placeId") if (placeId != null) { currentOnPlaceClick?.invoke(placeId) } else { diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt index 474b1fdb..ce9c2c39 100644 --- a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt @@ -25,7 +25,6 @@ import com.google.android.gms.maps3d.model.Model import com.google.android.gms.maps3d.model.Polygon import com.google.android.gms.maps3d.model.Polyline import com.google.android.gms.maps3d.model.popoverOptions -import com.google.maps.android.compose3d.utils.toValidLocation /** * Internal state holder for the Maps 3D Compose library. @@ -103,21 +102,9 @@ class Map3DState { if (existing != null) { val (oldConfig, polyline) = existing if (oldConfig != config) { - // Update existing polyline in-place to prevent flickering and unnecessary recreations - polyline.path = config.points.map { it.toValidLocation() } - polyline.strokeColor = config.color - polyline.strokeWidth = config.width.toDouble() - polyline.altitudeMode = config.altitudeMode - polyline.zIndex = config.zIndex - polyline.outerColor = config.outerColor - polyline.outerWidth = config.outerWidth.toDouble() - polyline.drawsOccludedSegments = config.drawsOccludedSegments - config.onClick?.let { callback -> - polyline.setClickListener { - callback(polyline) - } - } - polylines[config.key] = Pair(config, polyline) + // Config changed, update by adding again with same ID! + val newPolyline = createPolyline(map, config, polyline.id) + polylines[config.key] = Pair(config, newPolyline) } } else { // New polyline From f17e455d91881c4b8401367d985f13ce3e7c8b8f Mon Sep 17 00:00:00 2001 From: Ashik Abbas Date: Tue, 8 Sep 2026 14:20:08 +0530 Subject: [PATCH 4/4] feat(path-following) : remove Fully Qualified Name --- .../java/com/example/maps3d/common/PathEngine.kt | 13 +++++++------ .../example/maps3d/common/PathPlaybackController.kt | 3 ++- 2 files changed, 9 insertions(+), 7 deletions(-) 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 index 0a18cad8..45c22775 100644 --- 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 @@ -21,6 +21,7 @@ import com.google.android.gms.maps3d.model.AltitudeMode import com.google.android.gms.maps3d.model.LatLngAltitude import com.google.maps.android.SphericalUtil import kotlin.math.abs +import kotlin.math.round /** * Result of path interpolation containing LatLng, segment index, lookahead bearing, and altitude. @@ -132,19 +133,19 @@ object PathEngine { // 1. Camera Range: R = clamp(2.5 * avgSegLen, 65m, 250m) val recRange = (avgSegLen * 2.5).toFloat().coerceIn(65.0f, 250.0f) - val rangeMin = maxOf(20.0f, kotlin.math.round(recRange * 0.3f)) - val rangeMax = minOf(1000.0f, kotlin.math.round(recRange * 2.5f)) + val rangeMin = maxOf(20.0f, round(recRange * 0.3f)) + val rangeMax = minOf(1000.0f, round(recRange * 2.5f)) // 2. Base Altitude & Altitude Slider Bounds - val baseAlt = kotlin.math.round(minAlt) + val baseAlt = round(minAlt) val altMin: Float val altMax: Float if (minAlt > 50.0) { - altMin = maxOf(0.0f, kotlin.math.round(minAlt - 100.0).toFloat()) - altMax = kotlin.math.round(maxAlt + 350.0).toFloat() + altMin = maxOf(0.0f, round(minAlt - 100.0).toFloat()) + altMax = round(maxAlt + 350.0).toFloat() } else { altMin = 0.0f - altMax = maxOf(100.0f, kotlin.math.round(maxAlt + 50.0).toFloat()) + altMax = maxOf(100.0f, round(maxAlt + 50.0).toFloat()) } // 3. Camera Tilt: 48° for steep grades / mountain switchbacks, 55° for urban/open highways 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 59c2cbcf..cf4589f7 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 @@ -19,6 +19,7 @@ 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 kotlin.math.abs /** * Immutable state representation of the path following engine and camera position. @@ -47,7 +48,7 @@ data class PathPlaybackState( val staticPolylineVertices: List = emptyList(), val progressPolylineVertices: List = emptyList() ) { - val isSpeedBoosted: Boolean get() = kotlin.math.abs(speedBoostMultiplier - 1.0) > 0.01 + val isSpeedBoosted: Boolean get() = abs(speedBoostMultiplier - 1.0) > 0.01 val baseAltitude: Double get() = routeProfile.baseAltitude