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..bf97e748 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/MaxHeightScrollView.kt @@ -0,0 +1,72 @@ +/* + * 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 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, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : ScrollView(context, attrs, defStyleAttr) { + + 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() + } + } + } + + 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(maxAllowed, originalSize) + } else { + 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 a13fef69..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 @@ -66,28 +66,224 @@ 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, 9.5), + 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) ) } 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..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 @@ -20,13 +20,15 @@ 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 +import kotlin.math.round /** - * 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 +38,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, round(recRange * 0.3f)) + val rangeMax = minOf(1000.0f, round(recRange * 2.5f)) + + // 2. Base Altitude & Altitude Slider Bounds + val baseAlt = round(minAlt) + val altMin: Float + val altMax: Float + if (minAlt > 50.0) { + altMin = maxOf(0.0f, round(minAlt - 100.0).toFloat()) + altMax = round(maxAlt + 350.0).toFloat() + } else { + altMin = 0.0f + altMax = maxOf(100.0f, 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 +247,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 +290,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 +304,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 +323,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 +382,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 +406,6 @@ object PathEngine { currentLatLng: LatLng, waypointIndex: Int, altitudeMode: Int, - baseAltitude: Double, pathAltitudeOffset: Double ): List { if (path.isEmpty()) return emptyList() @@ -243,8 +417,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) @@ -255,7 +430,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 @@ -267,8 +442,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) @@ -279,11 +455,14 @@ 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 + 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/PathPlaybackController.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathPlaybackController.kt index 23df5e5e..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,12 +19,14 @@ 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. */ 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 +37,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, @@ -46,17 +48,19 @@ 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() = 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 +85,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 +106,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, @@ -182,23 +189,25 @@ 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 { 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 +218,6 @@ class PathPlaybackController( val staticVertices = PathEngine.buildStaticVertices( path = newRoute, altitudeMode = state.altitudeMode, - baseAltitude = baseAlt, pathAltitudeOffset = state.pathAltitudeOffset ) @@ -220,12 +228,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 +241,7 @@ class PathPlaybackController( cameraRange = range, groundAltitude = groundAlt, cameraTilt = tilt, + followSpeedMps = speed, currentPosition = point.latLng, currentAltitude = point.altitude, currentHeading = point.bearing, @@ -264,7 +273,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 +364,6 @@ class PathPlaybackController( currentLatLng = point.latLng, waypointIndex = point.waypointIndex, altitudeMode = state.altitudeMode, - baseAltitude = state.baseAltitude, pathAltitudeOffset = state.pathAltitudeOffset ) @@ -385,7 +395,6 @@ class PathPlaybackController( val staticVertices = PathEngine.buildStaticVertices( path = state.route, altitudeMode = state.altitudeMode, - baseAltitude = state.baseAltitude, pathAltitudeOffset = state.pathAltitudeOffset ) @@ -396,7 +405,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..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 @@ -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..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 @@ -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,31 @@ class PathPlaybackControllerTest { controller.skipRatio(-0.20f) assertEquals(totalDist * 0.40, controller.getState().elapsedDistance, 0.5) } -} \ No newline at end of file + + @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 02f98b8d..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 @@ -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,22 @@ 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 Integer lastProgressAltitudeMode; + private Boolean lastProgressDrawsOccluded; + private boolean isMapInitialized = false; 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 +126,7 @@ protected void onCreate(Bundle savedInstanceState) { bindViews(); setupCustomGestureHandling(); setupControlListeners(); - setupTouchAutoFade() ; + setupTouchAutoFade(); observeViewModel(); map3DView.onCreate(savedInstanceState); @@ -127,19 +139,17 @@ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) { googleMap3D.setOnMapReadyListener( initialTime -> { - runOnUiThread( - () -> { - lastStaticVertices = null; - lastRenderedProgressDist = -1.0; - PathPlaybackState state = viewModel.getCurrentState(); - updateStaticPolyline(state); - updateProgressPolyline(state); - updateCameraFromState(state); - renderUiControls(state); - }); + googleMap3D.setOnMapReadyListener(null); + runOnUiThread(this::initializeMap); }); } + private void initializeMap() { + if (isMapInitialized) return; + isMapInitialized = true; + resetPolylines(); + } + private void setupCustomGestureHandling() { if (gestureOverlay != null) { gestureOverlay.setOnTouchListener(new PathTouchHandler(this, viewModel)); @@ -152,6 +162,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 +185,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 +202,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 +243,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 +261,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 +271,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 +288,7 @@ public void onStopTrackingTouch(@NonNull Slider slider) { viewModel.seekToRatio(slider.getValue()); PathPlaybackState state = viewModel.getCurrentState(); updateCameraFromState(state); - updateProgressPolyline(state); + updateProgressPolyline(state, true); } }); @@ -328,25 +363,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,35 +464,86 @@ 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; + lastProgressAltitudeMode = null; + lastProgressDrawsOccluded = null; lastRenderedProgressDist = -1.0; + if (staticRoutePolyline != null) { + try { + staticRoutePolyline.remove(); + } catch (Exception ignored) { + } + staticRoutePolyline = null; + } + if (progressPolyline != null) { + try { + progressPolyline.remove(); + } catch (Exception ignored) { + } + 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); + 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()); @@ -409,12 +557,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 +587,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 +612,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())); + } } - 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 (!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())); + } } - 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 (!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())); + } + } + + 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())); + } + } + + 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 +741,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..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 @@ -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,23 @@ 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 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 // 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 @@ -127,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() } } } @@ -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,56 @@ 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 + lastProgressAltitudeMode = null + lastProgressDrawsOccluded = 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,11 +503,23 @@ 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 configChanged = progressPolyline == null || + lastProgressAltitudeMode != state.altitudeMode || + lastProgressDrawsOccluded != state.drawsOccludedSegments + + val distDelta = abs(state.elapsedDistance - lastRenderedProgressDist) + 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 @@ -418,13 +536,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 collection: ${e.message}", e) } - renderUiControls(state) - manageAnimationTicker(state.isPlaying) } } } @@ -446,7 +566,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 +579,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 +592,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()) + } } - 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 (!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()) + } } - 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 (!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()) + } + } + + 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()) + } + } + + 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 +712,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..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 @@ -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,6 +99,7 @@ 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.camera import com.google.android.gms.maps3d.model.latLngAltitude @@ -103,6 +109,8 @@ 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 +145,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 +170,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 +185,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 +205,44 @@ 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, - ) + val staticPolylineConfig = remember( + state.staticPolylineVertices, + state.altitudeMode, + 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, + ) + } } - 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, - ) + val progressPolylineConfig = remember( + state.progressPolylineVertices, + state.altitudeMode, + state.drawsOccludedSegments, + ) { + 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, + ) + } } val viewConfig = LocalViewConfiguration.current @@ -233,7 +262,7 @@ fun PathFollowingScreen( modifier = Modifier.fillMaxSize(), camera = dynamicCamera, mapMode = Map3DMode.HYBRID, - polylines = listOf(staticPolylineConfig, progressPolylineConfig), + polylines = listOfNotNull(staticPolylineConfig, progressPolylineConfig), ) // Custom Gesture Overlay replacing built-in map gestures @@ -265,12 +294,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 +329,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 +384,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 +395,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 +428,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 +460,7 @@ private fun PathFollowingControlCard( modifier: Modifier = Modifier, onTogglePlay: () -> Unit, onShowHelp: () -> Unit, + onShowAltitudeInfo: () -> Unit = {}, onSeekRatio: (Float) -> Unit, onScrubbingChange: (Boolean) -> Unit, onAltitudeModeChange: (AltitudeModeOption) -> Unit, @@ -423,14 +471,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 +503,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 +568,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 +586,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 +630,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 +662,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 +810,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 +858,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 +869,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, ) } } 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..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 @@ -129,7 +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 11539222..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 @@ -17,24 +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 /** * Internal state holder for the Maps 3D Compose library. @@ -85,7 +75,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 { @@ -110,16 +104,12 @@ class Map3DState { 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) - } + polylines[config.key] = Pair(config, newPolyline) } } 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 +119,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 +148,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 +163,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 +195,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 +210,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 {