Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Maps3DSamples/ApiDemos/common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
api(libs.androidx.lifecycle.viewmodel.ktx)
testImplementation(libs.junit)
testImplementation(libs.google.truth)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
implementation(platform(libs.androidx.compose.bom))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.example.maps3d.common

import com.google.android.gms.maps3d.model.LatLngAltitude

/**
* Shared pre-baked route datasets for ground-level path following samples.
*
* Lifting coordinates into this shared repository decouples the raw geographic
* geometry from sample application code across Java Views, Kotlin Views, and Compose.
*/
object PathData {

/**
* Urban Route: Downtown San Francisco (Market Street corridor).
*
* Features realistic per-waypoint elevation variations (1m to 10m) to demonstrate
* 3D altitude modes (Absolute, Relative to Ground, Relative to Mesh, Clamp to Ground).
*/
@JvmField
val URBAN_PATH: List<LatLngAltitude> = listOf(
LatLngAltitude(37.79323, -122.39322, 4.2),
LatLngAltitude(37.79166, -122.39519, 6.7),
LatLngAltitude(37.79124, -122.39571, 8.1),
LatLngAltitude(37.79105, -122.39599, 9.5),
LatLngAltitude(37.78893, -122.39866, 7.3),
LatLngAltitude(37.78742, -122.40060, 5.0),
LatLngAltitude(37.78686, -122.40129, 3.4),
LatLngAltitude(37.78652, -122.40171, 2.1),
LatLngAltitude(37.78632, -122.40196, 4.6),
LatLngAltitude(37.78627, -122.40207, 6.2),
LatLngAltitude(37.78453, -122.40429, 8.9),
LatLngAltitude(37.78443, -122.40434, 10.0),
LatLngAltitude(37.78155, -122.40802, 7.8),
LatLngAltitude(37.78005, -122.40990, 5.4),
LatLngAltitude(37.77856, -122.41180, 3.1),
LatLngAltitude(37.77746, -122.41318, 1.8),
LatLngAltitude(37.77624, -122.41474, 4.0),
LatLngAltitude(37.77744, -122.41623, 6.5),
LatLngAltitude(37.77749, -122.41636, 8.7),
LatLngAltitude(37.77761, -122.41654, 9.8),
LatLngAltitude(37.77769, -122.41677, 7.2),
LatLngAltitude(37.77729, -122.41981, 4.9),
LatLngAltitude(37.77523, -122.41938, 2.6),
LatLngAltitude(37.77510, -122.41934, 1.2),
LatLngAltitude(37.77442, -122.42022, 3.5),
LatLngAltitude(37.77441, -122.42033, 5.8),
LatLngAltitude(37.77348, -122.42157, 8.4),
LatLngAltitude(37.77244, -122.42289, 10.0)
)

/**
* Rural Route: Coastal highway and mountain switchbacks near Pescadero, CA.
*/
@JvmField
val RURAL_PATH: List<LatLngAltitude> = 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)
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.example.maps3d.common

import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps3d.model.AltitudeMode
import com.google.android.gms.maps3d.model.LatLngAltitude
import com.google.maps.android.SphericalUtil

/**
* Result of interpolating a position and orientation along a 3D path at a specific distance.
*
* @property latLng The interpolated 2D geographic coordinate.
* @property waypointIndex The zero-based index of the segment start waypoint.
* @property bearing The forward-looking tangent compass bearing (degrees).
* @property altitude The interpolated elevation in meters along the route segment.
*/
data class InterpolatedPathPoint(
@JvmField val latLng: LatLng,
@JvmField val waypointIndex: Int,
@JvmField val bearing: Double,
@JvmField val altitude: Double
)

/**
* Math and geometry engine for ground-level 3D path following.
*
* Encapsulates segment search, distance accumulation, kinematic heading smoothing,
* and elevation interpolation across Kotlin Views, Java Views, and Compose.
*/
object PathEngine {

const val STATIC_POLYLINE_ID = "path_following_static_route"
const val PROGRESS_POLYLINE_ID = "path_following_progress_route"

/**
* Precomputes cumulative distances along a 3D path in meters.
*/
@JvmStatic
fun calculateCumulativeDistances(path: List<LatLngAltitude>): DoubleArray {
if (path.isEmpty()) return doubleArrayOf(0.0)
val cumulativeDistances = DoubleArray(path.size)
cumulativeDistances[0] = 0.0
for (i in 1 until path.size) {
val pPrev = LatLng(path[i - 1].latitude, path[i - 1].longitude)
val pCurr = LatLng(path[i].latitude, path[i].longitude)
cumulativeDistances[i] = cumulativeDistances[i - 1] + SphericalUtil.computeDistanceBetween(pPrev, pCurr)
}
return cumulativeDistances
}

/**
* Helper to interpolate 2D LatLng position at any distance along the route.
*/
@JvmStatic
fun getInterpolatedLatLng(
path: List<LatLngAltitude>,
cumulativeDistances: DoubleArray,
distance: Double
): LatLng {
if (path.isEmpty()) return LatLng(0.0, 0.0)
val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0
if (distance <= 0.0) return LatLng(path.first().latitude, path.first().longitude)
if (distance >= totalDistance) return LatLng(path.last().latitude, path.last().longitude)

var index = 0
while (index < cumulativeDistances.size - 1 && cumulativeDistances[index + 1] < distance) {
index++
}

val p1 = path[index]
val p2 = if (index < path.size - 1) path[index + 1] else p1
val d1 = cumulativeDistances[index]
val d2 = cumulativeDistances.getOrElse(index + 1) { totalDistance }
val segLen = d2 - d1
val fraction = if (segLen > 0) ((distance - d1) / segLen).coerceIn(0.0, 1.0) else 0.0

val latLng1 = LatLng(p1.latitude, p1.longitude)
val latLng2 = LatLng(p2.latitude, p2.longitude)
return SphericalUtil.interpolate(latLng1, latLng2, fraction)
}

/**
* Finds the interpolated geographic position, smooth forward lookahead bearing,
* and elevation at a target distance.
*/
@JvmStatic
@JvmOverloads
fun interpolatePoint(
path: List<LatLngAltitude>,
cumulativeDistances: DoubleArray,
distance: Double,
lookaheadDistance: Double = 25.0
): InterpolatedPathPoint {
if (path.isEmpty()) {
return InterpolatedPathPoint(
latLng = LatLng(0.0, 0.0),
waypointIndex = 0,
bearing = 0.0,
altitude = 0.0
)
}

val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0
var index = 0
while (index < cumulativeDistances.size - 1 && cumulativeDistances[index + 1] < distance) {
index++
}

val p1 = path[index]
val p2 = if (index < path.size - 1) path[index + 1] else p1

val segStartDist = cumulativeDistances.getOrElse(index) { 0.0 }
val segEndDist = cumulativeDistances.getOrElse(index + 1) { totalDistance }
val segLen = segEndDist - segStartDist

val fraction = if (segLen > 0) ((distance - segStartDist) / segLen).coerceIn(0.0, 1.0) else 0.0
val latLng1 = LatLng(p1.latitude, p1.longitude)
val latLng2 = LatLng(p2.latitude, p2.longitude)
val currentLatLng = SphericalUtil.interpolate(latLng1, latLng2, fraction)
val interpAlt = p1.altitude + fraction * (p2.altitude - p1.altitude)

// Smooth forward lookahead tangent heading calculation
val targetLookaheadDist = (distance + lookaheadDistance).coerceAtMost(totalDistance)
val lookaheadPos = getInterpolatedLatLng(path, cumulativeDistances, targetLookaheadDist)

val bearing = if (targetLookaheadDist > distance && currentLatLng != lookaheadPos) {
SphericalUtil.computeHeading(currentLatLng, lookaheadPos)
} else if (distance > 1.0) {
val prevPos = getInterpolatedLatLng(path, cumulativeDistances, distance - 1.0)
SphericalUtil.computeHeading(prevPos, currentLatLng)
} else if (path.size >= 2) {
SphericalUtil.computeHeading(
LatLng(path[0].latitude, path[0].longitude),
LatLng(path[1].latitude, path[1].longitude)
)
} else {
0.0
}

return InterpolatedPathPoint(
latLng = currentLatLng,
waypointIndex = index,
bearing = (bearing + 360.0) % 360.0,
altitude = interpAlt
)
}

/**
* Applies an Exponential Moving Average (EMA) filter to camera heading to smooth
* abrupt turns around corners during real-time playback.
*/
@JvmStatic
fun smoothHeading(
targetHeading: Double,
currentHeading: Double?,
isUserScrubbing: Boolean,
isPlaying: Boolean,
smoothingFactor: Double = 0.12
): Double {
val normalizedTarget = (targetHeading % 360.0 + 360.0) % 360.0
if (currentHeading == null || isUserScrubbing || !isPlaying) {
return normalizedTarget
}

var diff = (normalizedTarget - currentHeading) % 360.0
if (diff > 180.0) diff -= 360.0
if (diff < -180.0) diff += 360.0
return (currentHeading + diff * smoothingFactor + 360.0) % 360.0
}

/**
* Calculates camera target altitude based on the active altitude mode and route elevation.
*/
@JvmStatic
fun calculateCameraAltitude(
altitudeMode: Int,
baseAltitude: Double,
interpolatedAltitude: Double,
groundAltitude: Double
): Double {
return if (altitudeMode == AltitudeMode.ABSOLUTE) {
baseAltitude + interpolatedAltitude + groundAltitude
} else {
groundAltitude
}
}

/**
* Builds static route polyline vertices with altitude mode adjustments.
*/
@JvmStatic
fun buildStaticVertices(
path: List<LatLngAltitude>,
altitudeMode: Int,
baseAltitude: Double,
pathAltitudeOffset: Double
): List<LatLngAltitude> {
return path.map { pt ->
val vertexAltitude = when (altitudeMode) {
AltitudeMode.CLAMP_TO_GROUND -> 0.0
AltitudeMode.ABSOLUTE -> pt.altitude + baseAltitude + pathAltitudeOffset
else -> pt.altitude + pathAltitudeOffset
}
LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude)
}
}

/**
* Builds progress polyline vertices up to the current progress distance with +0.4m depth bias.
*/
@JvmStatic
fun buildProgressVertices(
path: List<LatLngAltitude>,
cumulativeDistances: DoubleArray,
elapsedDistance: Double,
currentLatLng: LatLng,
waypointIndex: Int,
altitudeMode: Int,
baseAltitude: Double,
pathAltitudeOffset: Double
): List<LatLngAltitude> {
if (path.isEmpty()) return emptyList()

val progressCoordinates = mutableListOf<LatLngAltitude>()
val clampedIndex = waypointIndex.coerceIn(0, path.size - 1)

for (i in 0..clampedIndex) {
val pt = path[i]
val vertexAltitude = when (altitudeMode) {
AltitudeMode.CLAMP_TO_GROUND -> 0.0
AltitudeMode.ABSOLUTE -> pt.altitude + baseAltitude + pathAltitudeOffset + 0.4
else -> pt.altitude + pathAltitudeOffset + 0.4
}
progressCoordinates.add(
LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude)
)
}

val lastWaypoint = path[clampedIndex]
val lastLatLng = LatLng(lastWaypoint.latitude, lastWaypoint.longitude)
val distToLast = SphericalUtil.computeDistanceBetween(lastLatLng, currentLatLng)

if (distToLast >= 0.5) {
val p1 = path[clampedIndex]
val p2 = if (clampedIndex < path.size - 1) path[clampedIndex + 1] else p1
val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0
val segStartDist = cumulativeDistances.getOrElse(clampedIndex) { 0.0 }
val segEndDist = cumulativeDistances.getOrElse(clampedIndex + 1) { totalDistance }
val segLen = segEndDist - segStartDist
val fraction = if (segLen > 0) ((elapsedDistance - segStartDist) / segLen).coerceIn(0.0, 1.0) else 0.0
val interpAlt = p1.altitude + fraction * (p2.altitude - p1.altitude)

val progressAltitude = when (altitudeMode) {
AltitudeMode.CLAMP_TO_GROUND -> 0.0
AltitudeMode.ABSOLUTE -> interpAlt + baseAltitude + pathAltitudeOffset + 0.4
else -> interpAlt + pathAltitudeOffset + 0.4
}
progressCoordinates.add(
LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, progressAltitude)
)
}

// Polyline requires at least 2 distinct vertices
if (progressCoordinates.size < 2 && path.size >= 2) {
val p0 = LatLng(path[0].latitude, path[0].longitude)
val p1 = LatLng(path[1].latitude, path[1].longitude)
val tinyForward = SphericalUtil.interpolate(p0, p1, 0.005)
val startAlt = when (altitudeMode) {
AltitudeMode.CLAMP_TO_GROUND -> 0.0
AltitudeMode.ABSOLUTE -> path[0].altitude + baseAltitude + pathAltitudeOffset + 0.4
else -> path[0].altitude + pathAltitudeOffset + 0.4
}
progressCoordinates.add(
LatLngAltitude(tinyForward.latitude, tinyForward.longitude, startAlt)
)
}

return progressCoordinates
}
}
Loading