diff --git a/.gitignore b/.gitignore
index c32cfa86..2b8fb646 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# Gradle files
.gradle/
build/
+gradle/gradle-daemon-jvm.properties
# Local configuration file (sdk path, etc)
local.properties
@@ -40,3 +41,5 @@ google-services.json
.vscode/
snippets/docs/
.kotlin/
+.android/
+scratch/
diff --git a/Maps3DSamples/ApiDemos/common/build.gradle.kts b/Maps3DSamples/ApiDemos/common/build.gradle.kts
index 163eed90..76a30dc8 100644
--- a/Maps3DSamples/ApiDemos/common/build.gradle.kts
+++ b/Maps3DSamples/ApiDemos/common/build.gradle.kts
@@ -15,6 +15,7 @@
*/
plugins {
+ jacoco
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
@@ -27,6 +28,10 @@ android {
namespace = "com.example.maps3dcommon"
compileSdk = libs.versions.compileSdk.get().toInt()
+ testOptions {
+ unitTests.isReturnDefaultValues = true
+ }
+
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
@@ -61,7 +66,11 @@ 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)
+ testImplementation(libs.androidx.arch.core.testing)
+ testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
implementation(platform(libs.androidx.compose.bom))
@@ -73,3 +82,43 @@ dependencies {
api(libs.play.services.maps3d) // "com.google.android.gms:play-services-maps3d:0.2.2"
api(libs.maps.utils.ktx)
}
+
+jacoco {
+ toolVersion = "0.8.11"
+}
+
+tasks.register("jacocoTestReport") {
+ dependsOn("testDebugUnitTest")
+ reports {
+ xml.required.set(true)
+ html.required.set(true)
+ csv.required.set(false)
+ }
+
+ val fileFilter = listOf(
+ "**/R.class",
+ "**/R$*.class",
+ "**/BuildConfig.*",
+ "**/Manifest*.*",
+ "**/*Test*.*",
+ "android/**/*.*"
+ )
+ val debugTree = fileTree("${layout.buildDirectory.get()}/tmp/kotlin-classes/debug") {
+ exclude(fileFilter)
+ }
+ val mainSrc = "${project.projectDir}/src/main/java"
+
+ sourceDirectories.setFrom(files(mainSrc))
+ classDirectories.setFrom(files(debugTree))
+ executionData.setFrom(fileTree(layout.buildDirectory) {
+ include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec", "jacoco/testDebugUnitTest.exec")
+ })
+}
+
+android {
+ buildTypes {
+ debug {
+ enableUnitTestCoverage = true
+ }
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/AdvancedCameraAnimationViewModel.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/AdvancedCameraAnimationViewModel.kt
new file mode 100644
index 00000000..374e80fa
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/AdvancedCameraAnimationViewModel.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3d.common
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.asLiveData
+import com.google.android.gms.maps.model.LatLng
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Shared Architecture ViewModel for 3D Advanced Camera Animation across Kotlin, Java, and Compose.
+ *
+ * Bridges presentation layers with [WorldController] and the reactive [WorldState] pipeline.
+ */
+class AdvancedCameraAnimationViewModel(
+ flightPath: List = TourData.AIRPLANE_FLIGHT_PATH,
+ keyframes: List = TourData.SAN_FRANCISCO_TOUR
+) : ViewModel() {
+
+ private val controller = WorldController(flightPath, keyframes)
+
+ private val _worldState = MutableStateFlow(controller.getState())
+ val worldState: StateFlow = _worldState.asStateFlow()
+
+ // Backward compatibility alias for UI consumers
+ val uiState: StateFlow = _worldState.asStateFlow()
+
+ val liveData: LiveData = _worldState.asLiveData()
+
+ val currentState: WorldState
+ get() = _worldState.value
+
+ fun setApproach(approach: AnimationApproach) {
+ _worldState.value = controller.setApproach(approach)
+ }
+
+ fun setSimpleFlyToMode(mode: SimpleFlyToMode) {
+ _worldState.value = controller.setSimpleFlyToMode(mode)
+ }
+
+ fun play() {
+ _worldState.value = controller.play()
+ }
+
+ fun pause() {
+ _worldState.value = controller.pause()
+ }
+
+ fun setPlaying(isPlaying: Boolean) {
+ if (isPlaying) play() else pause()
+ }
+
+ fun togglePlayPause() {
+ _worldState.value = controller.togglePlayPause()
+ }
+
+ fun resetTour() {
+ _worldState.value = controller.reset()
+ }
+
+ fun onNativeCameraAnimationFinished() {
+ _worldState.value = controller.onNativeCameraAnimationFinished()
+ }
+
+ fun tick(deltaTimeSeconds: Double) {
+ _worldState.value = controller.tick(deltaTimeSeconds)
+ }
+
+ fun updateAirplanePose(pose: EntityPose) {
+ _worldState.value = controller.updateAirplanePose(pose)
+ }
+
+ fun setKeyframeStep(index: Int) {
+ _worldState.value = controller.setKeyframeStep(index)
+ }
+
+
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/EntityAnimator.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/EntityAnimator.kt
new file mode 100644
index 00000000..ae9dda32
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/EntityAnimator.kt
@@ -0,0 +1,178 @@
+/*
+ * 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.LatLngAltitude
+import com.google.maps.android.SphericalUtil
+
+/**
+ * Interface for pluggable entity animators that update an entity pose over time.
+ */
+interface EntityAnimator {
+ /**
+ * Calculates the entity pose for the given elapsed and total duration.
+ */
+ fun update(elapsedMs: Long, totalDurationMs: Long): EntityPose
+
+ /**
+ * Returns whether the animation has completed its duration.
+ */
+ fun isFinished(elapsedMs: Long, totalDurationMs: Long): Boolean
+
+ /**
+ * Resets internal state if necessary.
+ */
+ fun reset()
+}
+
+/**
+ * Discrete action animator that holds the entity at [startPose] until exactly half the duration,
+ * then instantly transitions to [endPose].
+ *
+ * Exhibits near-zero CPU overhead because intermediate geometry calculations are bypassed.
+ */
+class MidpointJumpAnimator(
+ val startPose: EntityPose,
+ val endPose: EntityPose
+) : EntityAnimator {
+
+ override fun update(elapsedMs: Long, totalDurationMs: Long): EntityPose {
+ val midpoint = totalDurationMs / 2
+ return if (elapsedMs < midpoint) startPose else endPose
+ }
+
+ override fun isFinished(elapsedMs: Long, totalDurationMs: Long): Boolean =
+ elapsedMs >= totalDurationMs
+
+ override fun reset() {}
+}
+
+/**
+ * Continuous trajectory animator that interpolates the entity pose along a multi-waypoint path
+ * based on elapsed time.
+ *
+ * Exhibits higher CPU load due to spherical trigonometry calculations executed on each frame tick.
+ */
+class TrajectoryFlightAnimator(
+ val waypoints: List,
+ val altitude: Double = 250.0,
+ val scale: Double = 0.08
+) : EntityAnimator {
+
+ private val cumulativeDistances: DoubleArray = calculateCumulativeDistances(waypoints)
+ val totalDistance: Double = cumulativeDistances.lastOrNull() ?: 0.0
+
+ override fun update(elapsedMs: Long, totalDurationMs: Long): EntityPose {
+ if (totalDurationMs <= 0L || waypoints.isEmpty()) {
+ val defaultLoc = waypoints.firstOrNull() ?: LatLng(0.0, 0.0)
+ return EntityPose(LatLngAltitude(defaultLoc.latitude, defaultLoc.longitude, altitude), 0.0, -90.0, 0.0, scale)
+ }
+
+ val fraction = (elapsedMs.toDouble() / totalDurationMs).coerceIn(0.0, 1.0)
+ val targetDist = fraction * totalDistance
+ val point = interpolateFlightPoint(waypoints, cumulativeDistances, targetDist)
+
+ return EntityPose(
+ position = LatLngAltitude(point.position.latitude, point.position.longitude, altitude),
+ heading = normalizeHeading(point.bearing + 180.0), // Airplane glTF asset 180° mesh alignment offset
+ pitch = -90.0,
+ roll = 0.0,
+ scale = scale
+ )
+ }
+
+ override fun isFinished(elapsedMs: Long, totalDurationMs: Long): Boolean =
+ elapsedMs >= totalDurationMs
+
+ override fun reset() {}
+
+ companion object {
+ fun normalizeHeading(headingDeg: Double): Double {
+ val normalized = headingDeg % 360.0
+ return if (normalized < 0.0) normalized + 360.0 else normalized
+ }
+
+ fun calculateCumulativeDistances(path: List): DoubleArray {
+ if (path.isEmpty()) return doubleArrayOf(0.0)
+ val distances = DoubleArray(path.size)
+ distances[0] = 0.0
+ for (i in 1 until path.size) {
+ distances[i] = distances[i - 1] + SphericalUtil.computeDistanceBetween(path[i - 1], path[i])
+ }
+ return distances
+ }
+
+ data class InterpolatedFlightPoint(
+ val position: LatLng,
+ val bearing: Double,
+ val waypointIndex: Int
+ )
+
+ fun interpolateFlightPoint(
+ path: List,
+ cumulativeDistances: DoubleArray,
+ distance: Double
+ ): InterpolatedFlightPoint {
+ if (path.isEmpty()) return InterpolatedFlightPoint(LatLng(0.0, 0.0), 0.0, 0)
+ val totalDist = 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 d1 = cumulativeDistances.getOrElse(index) { 0.0 }
+ val d2 = cumulativeDistances.getOrElse(index + 1) { totalDist }
+ val segLen = d2 - d1
+ val fraction = if (segLen > 0) ((distance - d1) / segLen).coerceIn(0.0, 1.0) else 0.0
+
+ val currentLatLng = SphericalUtil.interpolate(p1, p2, fraction)
+ val bearing = if (p1 != p2) SphericalUtil.computeHeading(p1, p2) else 105.0
+
+ return InterpolatedFlightPoint(
+ position = currentLatLng,
+ bearing = normalizeHeading(bearing),
+ waypointIndex = index
+ )
+ }
+ }
+}
+
+/**
+ * Continuous 360° orbital spin animator rotating around a central landmark.
+ */
+class ContinuousOrbitAnimator(
+ val center: LatLng,
+ initialHeading: Double = 105.0,
+ val altitude: Double = 250.0,
+ val speedDegPerSec: Double = 25.0
+) {
+ private var currentHeading: Double = initialHeading
+
+ fun tick(deltaTimeSeconds: Double): Double {
+ currentHeading = (currentHeading + speedDegPerSec * deltaTimeSeconds) % 360.0
+ return currentHeading
+ }
+
+ fun getHeading(): Double = currentHeading
+
+ fun reset(initialHeading: Double = 105.0) {
+ currentHeading = initialHeading
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/HtmlUtils.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/HtmlUtils.kt
new file mode 100644
index 00000000..3cabd51d
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/HtmlUtils.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.text.Spanned
+import androidx.annotation.RawRes
+import androidx.core.text.HtmlCompat
+
+/**
+ * Shared utility for loading and parsing raw HTML resources across Kotlin, Java, and Compose.
+ */
+object HtmlUtils {
+
+ /**
+ * Reads an HTML file from [res/raw] and parses it into an Android [Spanned] instance.
+ */
+ @JvmStatic
+ fun loadRawHtml(context: Context, @RawRes resId: Int): Spanned {
+ val htmlText = context.resources.openRawResource(resId).bufferedReader().use { it.readText() }
+ return HtmlCompat.fromHtml(htmlText, HtmlCompat.FROM_HTML_MODE_COMPACT)
+ }
+
+ /**
+ * Reads an HTML file from [res/raw] as a raw string.
+ */
+ @JvmStatic
+ fun loadRawHtmlString(context: Context, @RawRes resId: Int): String {
+ return context.resources.openRawResource(resId).bufferedReader().use { it.readText() }
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DModelEntity.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DModelEntity.kt
new file mode 100644
index 00000000..9f57f363
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DModelEntity.kt
@@ -0,0 +1,71 @@
+/*
+ * 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.GoogleMap3D
+import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.Model
+import com.google.android.gms.maps3d.model.ModelOptions
+import com.google.android.gms.maps3d.model.Orientation
+import com.google.android.gms.maps3d.model.Vector3D
+
+/**
+ * Self-managing lifecycle wrapper for 3D model entities on Google Maps 3D.
+ *
+ * Encapsulates model creation, pose updates, and safe detachment to prevent memory leaks
+ * during Activity recreations and configuration changes.
+ */
+class Map3DModelEntity(
+ val id: String,
+ val assetUrl: String,
+ val altitudeMode: Int = AltitudeMode.ABSOLUTE
+) {
+ private var nativeModel: Model? = null
+
+ /**
+ * Attaches and renders the model onto the given [map].
+ */
+ fun attach(map: GoogleMap3D, pose: EntityPose): Model? {
+ detach()
+ val options = ModelOptions().apply {
+ id = this@Map3DModelEntity.id
+ position = pose.position
+ url = assetUrl
+ altitudeMode = this@Map3DModelEntity.altitudeMode
+ scale = Vector3D(pose.scale, pose.scale, pose.scale)
+ orientation = Orientation(pose.heading, pose.pitch, pose.roll)
+ }
+ nativeModel = map.addModel(options)
+ return nativeModel
+ }
+
+ /**
+ * Updates the position and orientation of the entity.
+ * In the current 3D Maps SDK, re-adding or updating the model is encapsulated here.
+ */
+ fun applyPose(pose: EntityPose, map: GoogleMap3D?) {
+ if (map == null) return
+ attach(map, pose)
+ }
+
+ /**
+ * Safely detaches the entity and releases references to prevent leaking the map context.
+ */
+ fun detach() {
+ nativeModel = null
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathData.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathData.kt
new file mode 100644
index 00000000..a13fef69
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathData.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3d.common
+
+import com.google.android.gms.maps3d.model.LatLngAltitude
+
+/**
+ * Shared pre-baked route datasets for ground-level path following samples.
+ *
+ * Lifting coordinates into this shared repository decouples the raw geographic
+ * geometry from sample application code across Java Views, Kotlin Views, and Compose.
+ */
+object PathData {
+
+ /**
+ * Urban Route: Downtown San Francisco (Market Street corridor).
+ *
+ * Features realistic per-waypoint elevation variations (1m to 10m) to demonstrate
+ * 3D altitude modes (Absolute, Relative to Ground, Relative to Mesh, Clamp to Ground).
+ */
+ @JvmField
+ val URBAN_PATH: List = listOf(
+ LatLngAltitude(37.79323, -122.39322, 4.2),
+ LatLngAltitude(37.79166, -122.39519, 6.7),
+ LatLngAltitude(37.79124, -122.39571, 8.1),
+ LatLngAltitude(37.79105, -122.39599, 9.5),
+ LatLngAltitude(37.78893, -122.39866, 7.3),
+ LatLngAltitude(37.78742, -122.40060, 5.0),
+ LatLngAltitude(37.78686, -122.40129, 3.4),
+ LatLngAltitude(37.78652, -122.40171, 2.1),
+ LatLngAltitude(37.78632, -122.40196, 4.6),
+ LatLngAltitude(37.78627, -122.40207, 6.2),
+ LatLngAltitude(37.78453, -122.40429, 8.9),
+ LatLngAltitude(37.78443, -122.40434, 10.0),
+ LatLngAltitude(37.78155, -122.40802, 7.8),
+ LatLngAltitude(37.78005, -122.40990, 5.4),
+ LatLngAltitude(37.77856, -122.41180, 3.1),
+ LatLngAltitude(37.77746, -122.41318, 1.8),
+ LatLngAltitude(37.77624, -122.41474, 4.0),
+ LatLngAltitude(37.77744, -122.41623, 6.5),
+ LatLngAltitude(37.77749, -122.41636, 8.7),
+ LatLngAltitude(37.77761, -122.41654, 9.8),
+ LatLngAltitude(37.77769, -122.41677, 7.2),
+ LatLngAltitude(37.77729, -122.41981, 4.9),
+ LatLngAltitude(37.77523, -122.41938, 2.6),
+ LatLngAltitude(37.77510, -122.41934, 1.2),
+ LatLngAltitude(37.77442, -122.42022, 3.5),
+ LatLngAltitude(37.77441, -122.42033, 5.8),
+ LatLngAltitude(37.77348, -122.42157, 8.4),
+ LatLngAltitude(37.77244, -122.42289, 10.0)
+ )
+
+ /**
+ * Rural Route: Coastal highway and mountain switchbacks near Pescadero, CA.
+ */
+ @JvmField
+ val RURAL_PATH: List = listOf(
+ LatLngAltitude(37.254529, -122.380897, 0.0),
+ LatLngAltitude(37.255065, -122.381627, 0.0),
+ LatLngAltitude(37.257540, -122.383720, 0.0),
+ LatLngAltitude(37.261200, -122.383950, 0.0),
+ LatLngAltitude(37.264780, -122.388210, 0.0),
+ LatLngAltitude(37.268520, -122.392450, 0.0),
+ LatLngAltitude(37.272110, -122.397640, 0.0),
+ LatLngAltitude(37.276430, -122.401120, 0.0),
+ LatLngAltitude(37.280850, -122.403560, 0.0),
+ LatLngAltitude(37.286018, -122.405072, 0.0),
+ LatLngAltitude(37.291040, -122.404210, 0.0),
+ LatLngAltitude(37.295800, -122.401980, 0.0),
+ LatLngAltitude(37.300120, -122.399540, 0.0),
+ LatLngAltitude(37.304550, -122.397210, 0.0),
+ LatLngAltitude(37.309200, -122.395100, 0.0),
+ LatLngAltitude(37.313450, -122.392840, 0.0),
+ LatLngAltitude(37.317200, -122.390510, 0.0),
+ LatLngAltitude(37.320850, -122.388740, 0.0),
+ LatLngAltitude(37.323540, -122.387600, 0.0),
+ LatLngAltitude(37.325269, -122.386728, 0.0)
+ )
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt
new file mode 100644
index 00000000..050dbe8f
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt
@@ -0,0 +1,295 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3d.common
+
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.LatLngAltitude
+import com.google.maps.android.SphericalUtil
+
+/**
+ * Result of interpolating a position and orientation along a 3D path at a specific distance.
+ *
+ * @property latLng The interpolated 2D geographic coordinate.
+ * @property waypointIndex The zero-based index of the segment start waypoint.
+ * @property bearing The forward-looking tangent compass bearing (degrees).
+ * @property altitude The interpolated elevation in meters along the route segment.
+ */
+data class InterpolatedPathPoint(
+ @JvmField val latLng: LatLng,
+ @JvmField val waypointIndex: Int,
+ @JvmField val bearing: Double,
+ @JvmField val altitude: Double
+)
+
+/**
+ * Math and geometry engine for ground-level 3D path following.
+ *
+ * Encapsulates segment search, distance accumulation, kinematic heading smoothing,
+ * and elevation interpolation across Kotlin Views, Java Views, and Compose.
+ */
+object PathEngine {
+
+ const val STATIC_POLYLINE_ID = "path_following_static_route"
+ const val PROGRESS_POLYLINE_ID = "path_following_progress_route"
+
+ /**
+ * Precomputes cumulative distances along a 3D path in meters.
+ */
+ @JvmStatic
+ fun calculateCumulativeDistances(path: List): DoubleArray {
+ if (path.isEmpty()) return doubleArrayOf(0.0)
+ val cumulativeDistances = DoubleArray(path.size)
+ cumulativeDistances[0] = 0.0
+ for (i in 1 until path.size) {
+ val pPrev = LatLng(path[i - 1].latitude, path[i - 1].longitude)
+ val pCurr = LatLng(path[i].latitude, path[i].longitude)
+ cumulativeDistances[i] = cumulativeDistances[i - 1] + SphericalUtil.computeDistanceBetween(pPrev, pCurr)
+ }
+ return cumulativeDistances
+ }
+
+ /**
+ * Helper to interpolate 2D LatLng position at any distance along the route.
+ */
+ @JvmStatic
+ fun getInterpolatedLatLng(
+ path: List,
+ cumulativeDistances: DoubleArray,
+ distance: Double
+ ): LatLng {
+ if (path.isEmpty()) return LatLng(0.0, 0.0)
+ val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0
+ if (distance <= 0.0) return LatLng(path.first().latitude, path.first().longitude)
+ if (distance >= totalDistance) return LatLng(path.last().latitude, path.last().longitude)
+
+ var index = 0
+ while (index < cumulativeDistances.size - 1 && cumulativeDistances[index + 1] < distance) {
+ index++
+ }
+
+ val p1 = path[index]
+ val p2 = if (index < path.size - 1) path[index + 1] else p1
+ val d1 = cumulativeDistances[index]
+ val d2 = cumulativeDistances.getOrElse(index + 1) { totalDistance }
+ val segLen = d2 - d1
+ val fraction = if (segLen > 0) ((distance - d1) / segLen).coerceIn(0.0, 1.0) else 0.0
+
+ val latLng1 = LatLng(p1.latitude, p1.longitude)
+ val latLng2 = LatLng(p2.latitude, p2.longitude)
+ return SphericalUtil.interpolate(latLng1, latLng2, fraction)
+ }
+
+ /**
+ * Finds the interpolated geographic position, smooth forward lookahead bearing,
+ * and elevation at a target distance.
+ */
+ @JvmStatic
+ @JvmOverloads
+ fun interpolatePoint(
+ path: List,
+ cumulativeDistances: DoubleArray,
+ distance: Double,
+ lookaheadDistance: Double = 25.0
+ ): InterpolatedPathPoint {
+ if (path.isEmpty()) {
+ return InterpolatedPathPoint(
+ latLng = LatLng(0.0, 0.0),
+ waypointIndex = 0,
+ bearing = 0.0,
+ altitude = 0.0
+ )
+ }
+
+ val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0
+ var index = 0
+ while (index < cumulativeDistances.size - 1 && cumulativeDistances[index + 1] < distance) {
+ index++
+ }
+
+ val p1 = path[index]
+ val p2 = if (index < path.size - 1) path[index + 1] else p1
+
+ val segStartDist = cumulativeDistances.getOrElse(index) { 0.0 }
+ val segEndDist = cumulativeDistances.getOrElse(index + 1) { totalDistance }
+ val segLen = segEndDist - segStartDist
+
+ val fraction = if (segLen > 0) ((distance - segStartDist) / segLen).coerceIn(0.0, 1.0) else 0.0
+ val latLng1 = LatLng(p1.latitude, p1.longitude)
+ val latLng2 = LatLng(p2.latitude, p2.longitude)
+ val currentLatLng = SphericalUtil.interpolate(latLng1, latLng2, fraction)
+ val interpAlt = p1.altitude + fraction * (p2.altitude - p1.altitude)
+
+ // Smooth forward lookahead tangent heading calculation
+ val targetLookaheadDist = (distance + lookaheadDistance).coerceAtMost(totalDistance)
+ val lookaheadPos = getInterpolatedLatLng(path, cumulativeDistances, targetLookaheadDist)
+
+ val bearing = if (targetLookaheadDist > distance && currentLatLng != lookaheadPos) {
+ SphericalUtil.computeHeading(currentLatLng, lookaheadPos)
+ } else if (distance > 1.0) {
+ val prevPos = getInterpolatedLatLng(path, cumulativeDistances, distance - 1.0)
+ SphericalUtil.computeHeading(prevPos, currentLatLng)
+ } else if (path.size >= 2) {
+ SphericalUtil.computeHeading(
+ LatLng(path[0].latitude, path[0].longitude),
+ LatLng(path[1].latitude, path[1].longitude)
+ )
+ } else {
+ 0.0
+ }
+
+ return InterpolatedPathPoint(
+ latLng = currentLatLng,
+ waypointIndex = index,
+ bearing = (bearing + 360.0) % 360.0,
+ altitude = interpAlt
+ )
+ }
+
+ /**
+ * Applies an Exponential Moving Average (EMA) filter to camera heading to smooth
+ * abrupt turns around corners during real-time playback.
+ */
+ @JvmStatic
+ fun smoothHeading(
+ targetHeading: Double,
+ currentHeading: Double?,
+ isUserScrubbing: Boolean,
+ isPlaying: Boolean,
+ smoothingFactor: Double = 0.12
+ ): Double {
+ val normalizedTarget = (targetHeading % 360.0 + 360.0) % 360.0
+ if (currentHeading == null || isUserScrubbing || !isPlaying) {
+ return normalizedTarget
+ }
+
+ var diff = (normalizedTarget - currentHeading) % 360.0
+ if (diff > 180.0) diff -= 360.0
+ if (diff < -180.0) diff += 360.0
+ return (currentHeading + diff * smoothingFactor + 360.0) % 360.0
+ }
+
+ /**
+ * Calculates camera target altitude based on the active altitude mode and route elevation.
+ */
+ @JvmStatic
+ fun calculateCameraAltitude(
+ altitudeMode: Int,
+ baseAltitude: Double,
+ interpolatedAltitude: Double,
+ groundAltitude: Double
+ ): Double {
+ return if (altitudeMode == AltitudeMode.ABSOLUTE) {
+ baseAltitude + interpolatedAltitude + groundAltitude
+ } else {
+ groundAltitude
+ }
+ }
+
+ /**
+ * Builds static route polyline vertices with altitude mode adjustments.
+ */
+ @JvmStatic
+ fun buildStaticVertices(
+ path: List,
+ altitudeMode: Int,
+ baseAltitude: Double,
+ pathAltitudeOffset: Double
+ ): List {
+ return path.map { pt ->
+ val vertexAltitude = when (altitudeMode) {
+ AltitudeMode.CLAMP_TO_GROUND -> 0.0
+ AltitudeMode.ABSOLUTE -> pt.altitude + baseAltitude + pathAltitudeOffset
+ else -> pt.altitude + pathAltitudeOffset
+ }
+ LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude)
+ }
+ }
+
+ /**
+ * Builds progress polyline vertices up to the current progress distance with +0.4m depth bias.
+ */
+ @JvmStatic
+ fun buildProgressVertices(
+ path: List,
+ cumulativeDistances: DoubleArray,
+ elapsedDistance: Double,
+ currentLatLng: LatLng,
+ waypointIndex: Int,
+ altitudeMode: Int,
+ baseAltitude: Double,
+ pathAltitudeOffset: Double
+ ): List {
+ if (path.isEmpty()) return emptyList()
+
+ val progressCoordinates = mutableListOf()
+ val clampedIndex = waypointIndex.coerceIn(0, path.size - 1)
+
+ for (i in 0..clampedIndex) {
+ val pt = path[i]
+ val vertexAltitude = when (altitudeMode) {
+ AltitudeMode.CLAMP_TO_GROUND -> 0.0
+ AltitudeMode.ABSOLUTE -> pt.altitude + baseAltitude + pathAltitudeOffset + 0.4
+ else -> pt.altitude + pathAltitudeOffset + 0.4
+ }
+ progressCoordinates.add(
+ LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude)
+ )
+ }
+
+ val lastWaypoint = path[clampedIndex]
+ val lastLatLng = LatLng(lastWaypoint.latitude, lastWaypoint.longitude)
+ val distToLast = SphericalUtil.computeDistanceBetween(lastLatLng, currentLatLng)
+
+ if (distToLast >= 0.5) {
+ val p1 = path[clampedIndex]
+ val p2 = if (clampedIndex < path.size - 1) path[clampedIndex + 1] else p1
+ val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0
+ val segStartDist = cumulativeDistances.getOrElse(clampedIndex) { 0.0 }
+ val segEndDist = cumulativeDistances.getOrElse(clampedIndex + 1) { totalDistance }
+ val segLen = segEndDist - segStartDist
+ val fraction = if (segLen > 0) ((elapsedDistance - segStartDist) / segLen).coerceIn(0.0, 1.0) else 0.0
+ val interpAlt = p1.altitude + fraction * (p2.altitude - p1.altitude)
+
+ val progressAltitude = when (altitudeMode) {
+ AltitudeMode.CLAMP_TO_GROUND -> 0.0
+ AltitudeMode.ABSOLUTE -> interpAlt + baseAltitude + pathAltitudeOffset + 0.4
+ else -> interpAlt + pathAltitudeOffset + 0.4
+ }
+ progressCoordinates.add(
+ LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, progressAltitude)
+ )
+ }
+
+ // Polyline requires at least 2 distinct vertices
+ if (progressCoordinates.size < 2 && path.size >= 2) {
+ val p0 = LatLng(path[0].latitude, path[0].longitude)
+ val p1 = LatLng(path[1].latitude, path[1].longitude)
+ val tinyForward = SphericalUtil.interpolate(p0, p1, 0.005)
+ val startAlt = when (altitudeMode) {
+ AltitudeMode.CLAMP_TO_GROUND -> 0.0
+ AltitudeMode.ABSOLUTE -> path[0].altitude + baseAltitude + pathAltitudeOffset + 0.4
+ else -> path[0].altitude + pathAltitudeOffset + 0.4
+ }
+ progressCoordinates.add(
+ LatLngAltitude(tinyForward.latitude, tinyForward.longitude, startAlt)
+ )
+ }
+
+ return progressCoordinates
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathFollowingViewModel.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathFollowingViewModel.kt
new file mode 100644
index 00000000..68d35723
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathFollowingViewModel.kt
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3d.common
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.asLiveData
+import com.google.android.gms.maps3d.model.LatLngAltitude
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Shared Architecture ViewModel for 3D Path Following across Kotlin, Java, and Jetpack Compose.
+ *
+ * Bridges the UI presentation layer with the framework-independent [PathPlaybackController].
+ * Exposes observable state streams via [StateFlow] and [LiveData].
+ */
+class PathFollowingViewModel(
+ initialRoute: List = PathData.URBAN_PATH
+) : ViewModel() {
+
+ private val controller = PathPlaybackController(initialRoute)
+
+ private val _uiState = MutableStateFlow(controller.getState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ val liveData: LiveData = _uiState.asLiveData()
+
+ val currentState: PathPlaybackState
+ get() = _uiState.value
+
+ fun advance(deltaTimeSeconds: Double) {
+ _uiState.value = controller.advance(deltaTimeSeconds)
+ }
+
+ fun seekToRatio(ratio: Float) {
+ _uiState.value = controller.seekToRatio(ratio)
+ }
+
+ fun skipDistance(deltaMeters: Double) {
+ _uiState.value = controller.skipDistance(deltaMeters)
+ }
+
+ fun skipRatio(deltaRatio: Float) {
+ _uiState.value = controller.skipRatio(deltaRatio)
+ }
+
+ fun seekToDistance(distanceMeters: Double) {
+ _uiState.value = controller.seekToDistance(distanceMeters)
+ }
+
+ fun setScrubbing(isScrubbing: Boolean) {
+ _uiState.value = controller.setScrubbing(isScrubbing)
+ }
+
+ fun setPlaying(isPlaying: Boolean) {
+ _uiState.value = controller.setPlaying(isPlaying)
+ }
+
+ fun togglePlayPause() {
+ _uiState.value = controller.togglePlayPause()
+ }
+
+ fun setRoute(newRoute: List, applyDefaults: Boolean = true) {
+ _uiState.value = controller.setRoute(newRoute, applyDefaults)
+ }
+
+ fun setAltitudeMode(mode: Int) {
+ _uiState.value = controller.setAltitudeMode(mode)
+ }
+
+ fun setDrawsOccludedSegments(drawsOccluded: Boolean) {
+ _uiState.value = controller.setDrawsOccludedSegments(drawsOccluded)
+ }
+
+ fun setPathAltitudeOffset(offset: Double) {
+ _uiState.value = controller.setPathAltitudeOffset(offset)
+ }
+
+ fun setCameraRange(range: Double) {
+ _uiState.value = controller.setCameraRange(range)
+ }
+
+ fun setGroundAltitude(altitude: Double) {
+ _uiState.value = controller.setGroundAltitude(altitude)
+ }
+
+ fun setHeadingOffset(offset: Double) {
+ _uiState.value = controller.setHeadingOffset(offset)
+ }
+
+ fun setCameraTilt(tilt: Double) {
+ _uiState.value = controller.setCameraTilt(tilt)
+ }
+
+ fun adjustTilt(deltaDeg: Double) {
+ _uiState.value = controller.adjustTilt(deltaDeg)
+ }
+
+ fun adjustHeading(deltaDeg: Double) {
+ _uiState.value = controller.adjustHeading(deltaDeg)
+ }
+
+ fun adjustRange(scaleFactor: Double) {
+ _uiState.value = controller.adjustRange(scaleFactor)
+ }
+
+ fun setSpeedBoostMultiplier(multiplier: Double) {
+ _uiState.value = controller.setSpeedBoostMultiplier(multiplier)
+ }
+
+ fun setSpeedBoosted(isBoosted: Boolean) {
+ _uiState.value = controller.setSpeedBoosted(isBoosted)
+ }
+
+ fun setFollowSpeed(speedMps: Double) {
+ _uiState.value = controller.setFollowSpeed(speedMps)
+ }
+
+ fun reset() {
+ _uiState.value = controller.reset()
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathPlaybackController.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathPlaybackController.kt
new file mode 100644
index 00000000..23df5e5e
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathPlaybackController.kt
@@ -0,0 +1,409 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3d.common
+
+import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.LatLngAltitude
+
+/**
+ * Immutable state representation of the path following engine and camera position.
+ */
+data class PathPlaybackState(
+ val route: List = PathData.URBAN_PATH,
+ val totalDistance: Double = 0.0,
+ val elapsedDistance: Double = 0.0,
+ val progressRatio: Float = 0.0f,
+ val isPlaying: Boolean = false,
+ val isScrubbing: Boolean = false,
+ val speedBoostMultiplier: Double = 1.0,
+ val followSpeedMps: Double = 30.0,
+ val cameraRange: Double = 300.0,
+ val groundAltitude: Double = 20.0,
+ val headingOffset: Double = 0.0,
+ val cameraTilt: Double = 70.0,
+ val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND,
+ val pathAltitudeOffset: Double = 0.5,
+ val drawsOccludedSegments: Boolean = true,
+ val currentPosition: LatLng = LatLng(0.0, 0.0),
+ val currentAltitude: Double = 0.0,
+ val currentHeading: Double = 0.0,
+ val cameraHeading: Double = 0.0,
+ val staticPolylineVertices: List = emptyList(),
+ val progressPolylineVertices: List = emptyList()
+) {
+ val isSpeedBoosted: Boolean get() = kotlin.math.abs(speedBoostMultiplier - 1.0) > 0.01
+
+ val baseAltitude: Double
+ get() = if (route == PathData.RURAL_PATH) 45.0 else 50.0
+
+ val cameraTargetAltitude: Double
+ get() = PathEngine.calculateCameraAltitude(
+ altitudeMode = altitudeMode,
+ baseAltitude = baseAltitude,
+ interpolatedAltitude = currentAltitude,
+ groundAltitude = groundAltitude
+ )
+
+ val effectiveHeading: Double
+ get() = cameraHeading
+
+ val effectiveSpeedMps: Double
+ get() = followSpeedMps * speedBoostMultiplier
+}
+
+/**
+ * Framework-independent controller for 3D path following playback and camera interpolation.
+ *
+ * Encapsulates all domain math, distance tracking, kinematic heading smoothing, and polyline
+ * vertex generation. Has zero dependencies on Android Views, UI widgets, or GoogleMap3D rendering
+ * classes, making it 100% unit-testable on the JVM.
+ */
+class PathPlaybackController(
+ initialRoute: List = PathData.URBAN_PATH
+) {
+ private var cumulativeDistances: DoubleArray
+ private var state: PathPlaybackState
+
+ init {
+ cumulativeDistances = PathEngine.calculateCumulativeDistances(initialRoute)
+ val totalDist = cumulativeDistances.lastOrNull() ?: 0.0
+ val baseAlt = if (initialRoute == PathData.RURAL_PATH) 45.0 else 50.0
+ val staticVertices = PathEngine.buildStaticVertices(
+ path = initialRoute,
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND,
+ baseAltitude = baseAlt,
+ pathAltitudeOffset = 0.5
+ )
+
+ val point = PathEngine.interpolatePoint(
+ path = initialRoute,
+ cumulativeDistances = cumulativeDistances,
+ distance = 0.0
+ )
+
+ val progressVertices = PathEngine.buildProgressVertices(
+ path = initialRoute,
+ cumulativeDistances = cumulativeDistances,
+ elapsedDistance = 0.0,
+ currentLatLng = point.latLng,
+ waypointIndex = point.waypointIndex,
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND,
+ baseAltitude = baseAlt,
+ pathAltitudeOffset = 0.5
+ )
+
+ state = PathPlaybackState(
+ route = initialRoute,
+ totalDistance = totalDist,
+ elapsedDistance = 0.0,
+ progressRatio = 0f,
+ isPlaying = false,
+ isScrubbing = false,
+ speedBoostMultiplier = 1.0,
+ currentPosition = point.latLng,
+ currentAltitude = point.altitude,
+ currentHeading = point.bearing,
+ cameraHeading = point.bearing,
+ staticPolylineVertices = staticVertices,
+ progressPolylineVertices = progressVertices
+ )
+ }
+
+ fun getState(): PathPlaybackState = state
+
+ /**
+ * Advances playback by a specific time delta (in seconds).
+ * Takes speed boost (long-press 2x multiplier) into account.
+ */
+ fun advance(deltaTimeSeconds: Double): PathPlaybackState {
+ if (!state.isPlaying || state.totalDistance <= 0.0) return state
+
+ val stepDist = state.effectiveSpeedMps * deltaTimeSeconds
+ var newDist = state.elapsedDistance + stepDist
+ if (newDist >= state.totalDistance) {
+ newDist %= state.totalDistance
+ } else if (newDist < 0.0) {
+ newDist = (newDist % state.totalDistance + state.totalDistance) % state.totalDistance
+ }
+
+ return updateDistanceAndRecompute(newDistance = newDist, updateProgressRatio = !state.isScrubbing)
+ }
+
+ /**
+ * Seeks to a specific normalized progress ratio [0.0, 1.0].
+ */
+ fun seekToRatio(ratio: Float): PathPlaybackState {
+ val clampedRatio = ratio.coerceIn(0f, 1f)
+ val targetDist = state.totalDistance * clampedRatio.toDouble()
+ state = state.copy(progressRatio = clampedRatio)
+ return updateDistanceAndRecompute(newDistance = targetDist, updateProgressRatio = false)
+ }
+
+ /**
+ * Seeks to a specific distance along the route in meters.
+ */
+ fun skipDistance(deltaMeters: Double): PathPlaybackState {
+ if (state.totalDistance <= 0.0) return state
+ var newDist = state.elapsedDistance + deltaMeters
+ newDist = (newDist % state.totalDistance + state.totalDistance) % state.totalDistance
+ return updateDistanceAndRecompute(newDistance = newDist, updateProgressRatio = true)
+ }
+
+ fun skipRatio(deltaRatio: Float): PathPlaybackState {
+ return skipDistance(state.totalDistance * deltaRatio.toDouble())
+ }
+
+ fun seekToDistance(distanceMeters: Double): PathPlaybackState {
+ val targetDist = distanceMeters.coerceIn(0.0, state.totalDistance)
+ val ratio = if (state.totalDistance > 0.0) (targetDist / state.totalDistance).toFloat().coerceIn(0f, 1f) else 0f
+ state = state.copy(progressRatio = ratio)
+ return updateDistanceAndRecompute(newDistance = targetDist, updateProgressRatio = false)
+ }
+
+ fun setScrubbing(isScrubbing: Boolean): PathPlaybackState {
+ state = state.copy(isScrubbing = isScrubbing)
+ return state
+ }
+
+ fun setPlaying(isPlaying: Boolean): PathPlaybackState {
+ state = state.copy(isPlaying = isPlaying)
+ return state
+ }
+
+ fun togglePlayPause(): PathPlaybackState {
+ state = state.copy(isPlaying = !state.isPlaying)
+ return state
+ }
+
+ fun setRoute(newRoute: List, applyDefaults: Boolean = true): PathPlaybackState {
+ cumulativeDistances = PathEngine.calculateCumulativeDistances(newRoute)
+ val totalDist = cumulativeDistances.lastOrNull() ?: 0.0
+ val isRural = newRoute == PathData.RURAL_PATH
+
+ val range = if (applyDefaults) (if (isRural) 450.0 else 300.0) else state.cameraRange
+ val groundAlt = if (applyDefaults) (if (isRural) 40.0 else 20.0) else state.groundAltitude
+ val tilt = if (applyDefaults) (if (isRural) 75.0 else 70.0) else state.cameraTilt
+ val baseAlt = if (isRural) 45.0 else 50.0
+
+ val point = PathEngine.interpolatePoint(
+ path = newRoute,
+ cumulativeDistances = cumulativeDistances,
+ distance = 0.0
+ )
+
+ val staticVertices = PathEngine.buildStaticVertices(
+ path = newRoute,
+ altitudeMode = state.altitudeMode,
+ baseAltitude = baseAlt,
+ pathAltitudeOffset = state.pathAltitudeOffset
+ )
+
+ val progressVertices = PathEngine.buildProgressVertices(
+ path = newRoute,
+ cumulativeDistances = cumulativeDistances,
+ elapsedDistance = 0.0,
+ currentLatLng = point.latLng,
+ waypointIndex = point.waypointIndex,
+ altitudeMode = state.altitudeMode,
+ baseAltitude = baseAlt,
+ pathAltitudeOffset = state.pathAltitudeOffset
+ )
+
+ state = state.copy(
+ route = newRoute,
+ totalDistance = totalDist,
+ elapsedDistance = 0.0,
+ progressRatio = 0f,
+ isPlaying = false,
+ cameraRange = range,
+ groundAltitude = groundAlt,
+ cameraTilt = tilt,
+ currentPosition = point.latLng,
+ currentAltitude = point.altitude,
+ currentHeading = point.bearing,
+ cameraHeading = (point.bearing + state.headingOffset + 360.0) % 360.0,
+ staticPolylineVertices = staticVertices,
+ progressPolylineVertices = progressVertices
+ )
+ return state
+ }
+
+ fun setAltitudeMode(mode: Int): PathPlaybackState {
+ state = state.copy(altitudeMode = mode)
+ return recomputeVerticesAndAltitude()
+ }
+
+ fun setDrawsOccludedSegments(drawsOccluded: Boolean): PathPlaybackState {
+ state = state.copy(drawsOccludedSegments = drawsOccluded)
+ return state
+ }
+
+ fun setPathAltitudeOffset(offset: Double): PathPlaybackState {
+ state = state.copy(pathAltitudeOffset = offset)
+ return recomputeVerticesAndAltitude()
+ }
+
+ fun setCameraRange(range: Double): PathPlaybackState {
+ state = state.copy(cameraRange = range.coerceIn(20.0, 5000.0))
+ return state
+ }
+
+ fun setGroundAltitude(altitude: Double): PathPlaybackState {
+ state = state.copy(groundAltitude = altitude.coerceIn(0.0, 500.0))
+ return state
+ }
+
+ fun setHeadingOffset(offset: Double): PathPlaybackState {
+ var normalizedOffset = offset % 360.0
+ if (normalizedOffset > 180.0) normalizedOffset -= 360.0
+ if (normalizedOffset < -180.0) normalizedOffset += 360.0
+
+ val targetCamHeading = (state.currentHeading + normalizedOffset + 360.0) % 360.0
+ state = state.copy(
+ headingOffset = normalizedOffset,
+ cameraHeading = targetCamHeading
+ )
+ return state
+ }
+
+ fun setCameraTilt(tilt: Double): PathPlaybackState {
+ state = state.copy(cameraTilt = tilt.coerceIn(0.0, 85.0))
+ return state
+ }
+
+ /**
+ * Adjusts the camera tilt by delta degrees (e.g. from vertical gesture sweep).
+ * Constrained between 0° (top-down) and 85° (horizon).
+ */
+ fun adjustTilt(deltaDeg: Double): PathPlaybackState {
+ return setCameraTilt(state.cameraTilt + deltaDeg)
+ }
+
+ /**
+ * Adjusts the heading offset by delta degrees (e.g. from horizontal gesture sweep).
+ */
+ fun adjustHeading(deltaDeg: Double): PathPlaybackState {
+ return setHeadingOffset(state.headingOffset + deltaDeg)
+ }
+
+ /**
+ * Adjusts camera range via pinch scaling.
+ * scaleFactor > 1.0 zooms in (decreases range), scaleFactor < 1.0 zooms out (increases range).
+ */
+ fun adjustRange(scaleFactor: Double): PathPlaybackState {
+ if (scaleFactor <= 0.0) return state
+ return setCameraRange(state.cameraRange / scaleFactor)
+ }
+
+ /**
+ * Sets long-press speed boost (2x multiplier).
+ */
+ fun setSpeedBoostMultiplier(multiplier: Double): PathPlaybackState {
+ state = state.copy(speedBoostMultiplier = multiplier)
+ return state
+ }
+
+ fun setSpeedBoosted(isBoosted: Boolean): PathPlaybackState {
+ return setSpeedBoostMultiplier(if (isBoosted) 2.0 else 1.0)
+ }
+
+ fun setFollowSpeed(speedMps: Double): PathPlaybackState {
+ state = state.copy(followSpeedMps = speedMps)
+ return state
+ }
+
+ fun reset(): PathPlaybackState {
+ return seekToDistance(0.0).copy(isPlaying = false, speedBoostMultiplier = 1.0)
+ }
+
+ private fun updateDistanceAndRecompute(newDistance: Double, updateProgressRatio: Boolean): PathPlaybackState {
+ val point = PathEngine.interpolatePoint(
+ path = state.route,
+ cumulativeDistances = cumulativeDistances,
+ distance = newDistance
+ )
+
+ val targetCameraHeading = (point.bearing + state.headingOffset + 360.0) % 360.0
+ val smoothedCameraHeading = PathEngine.smoothHeading(
+ targetHeading = targetCameraHeading,
+ currentHeading = state.cameraHeading,
+ isUserScrubbing = state.isScrubbing,
+ isPlaying = state.isPlaying
+ )
+
+ val progressVertices = PathEngine.buildProgressVertices(
+ path = state.route,
+ cumulativeDistances = cumulativeDistances,
+ elapsedDistance = newDistance,
+ currentLatLng = point.latLng,
+ waypointIndex = point.waypointIndex,
+ altitudeMode = state.altitudeMode,
+ baseAltitude = state.baseAltitude,
+ pathAltitudeOffset = state.pathAltitudeOffset
+ )
+
+ val ratio = if (updateProgressRatio && state.totalDistance > 0.0) {
+ (newDistance / state.totalDistance).toFloat().coerceIn(0f, 1f)
+ } else {
+ state.progressRatio
+ }
+
+ state = state.copy(
+ elapsedDistance = newDistance,
+ progressRatio = ratio,
+ currentPosition = point.latLng,
+ currentAltitude = point.altitude,
+ currentHeading = point.bearing,
+ cameraHeading = smoothedCameraHeading,
+ progressPolylineVertices = progressVertices
+ )
+ return state
+ }
+
+ private fun recomputeVerticesAndAltitude(): PathPlaybackState {
+ val point = PathEngine.interpolatePoint(
+ path = state.route,
+ cumulativeDistances = cumulativeDistances,
+ distance = state.elapsedDistance
+ )
+
+ val staticVertices = PathEngine.buildStaticVertices(
+ path = state.route,
+ altitudeMode = state.altitudeMode,
+ baseAltitude = state.baseAltitude,
+ pathAltitudeOffset = state.pathAltitudeOffset
+ )
+
+ val progressVertices = PathEngine.buildProgressVertices(
+ path = state.route,
+ cumulativeDistances = cumulativeDistances,
+ elapsedDistance = state.elapsedDistance,
+ currentLatLng = point.latLng,
+ waypointIndex = point.waypointIndex,
+ altitudeMode = state.altitudeMode,
+ baseAltitude = state.baseAltitude,
+ pathAltitudeOffset = state.pathAltitudeOffset
+ )
+
+ state = state.copy(
+ staticPolylineVertices = staticVertices,
+ progressPolylineVertices = progressVertices
+ )
+ return state
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathTouchHandler.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathTouchHandler.kt
new file mode 100644
index 00000000..a4467114
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathTouchHandler.kt
@@ -0,0 +1,229 @@
+/*
+ * 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.annotation.SuppressLint
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.view.GestureDetector
+import android.view.MotionEvent
+import android.view.ScaleGestureDetector
+import android.view.View
+import android.view.ViewConfiguration
+import kotlin.math.abs
+
+/**
+ * Custom Touch Gesture Handler for 3D Path Following demo.
+ *
+ * Features:
+ * 1. Vertical sweep up/down (1 finger): Smoothly adjusts camera tilt [0°, 85°].
+ * 2. Horizontal sweep left/right (1 finger): Smoothly adjusts camera rotation / heading offset.
+ * 3. Pinch gesture (2 fingers): Damped camera distance/zoom adjustment.
+ * 4. Tiered Long-Press & Hold:
+ * - Hold >= 500ms: 2x Speed Boost
+ * - Hold >= 2000ms: 5x Warp Speed Boost
+ * 5. Double-Tap & Hold (YouTube-style Shuttle):
+ * - Double-tap & hold Right side: +5x Fast-Forward along route
+ * - Double-tap & hold Left side: -5x Rewind backwards along route
+ * - Quick double-tap: Skips ahead/back by 10% of the route.
+ */
+class PathTouchHandler(
+ context: Context,
+ private val viewModel: PathFollowingViewModel
+) : View.OnTouchListener {
+
+ private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
+ private val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong()
+ private val initialBoostTimeout = ViewConfiguration.getLongPressTimeout().toLong() // ~500ms
+ private val warpBoostTimeout = 2000L // 2.0s
+ private val handler = Handler(Looper.getMainLooper())
+
+ private val headingSensitivity = 0.08
+ private val tiltSensitivity = 0.06
+ private val zoomDamping = 0.65
+
+ private var downX = 0f
+ private var downY = 0f
+ private var lastX = 0f
+ private var lastY = 0f
+ private var isDragging = false
+ private var isScaling = false
+
+ // Double-tap & hold shuttle state
+ private var lastTapTime = 0L
+ private var lastTapX = 0f
+ private var isDoubleTapHold = false
+ private var wasPlayingBeforeShuttle = false
+
+ private val initialBoostRunnable = Runnable {
+ if (!isDragging && !isScaling && !isDoubleTapHold) {
+ viewModel.setSpeedBoostMultiplier(2.0)
+ }
+ }
+
+ private val warpBoostRunnable = Runnable {
+ if (!isDragging && !isScaling && !isDoubleTapHold) {
+ viewModel.setSpeedBoostMultiplier(5.0)
+ }
+ }
+
+ private val scaleDetector = ScaleGestureDetector(
+ context,
+ object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
+ override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
+ isScaling = true
+ isDragging = false
+ cancelBoosts()
+ return true
+ }
+
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
+ val rawFactor = detector.scaleFactor.toDouble()
+ if (rawFactor > 0.5 && rawFactor < 2.0) {
+ val dampedFactor = 1.0 + (rawFactor - 1.0) * zoomDamping
+ viewModel.adjustRange(dampedFactor)
+ }
+ return true
+ }
+
+ override fun onScaleEnd(detector: ScaleGestureDetector) {
+ isScaling = false
+ }
+ }
+ )
+
+ @SuppressLint("ClickableViewAccessibility")
+ override fun onTouch(v: View, event: MotionEvent): Boolean {
+ scaleDetector.onTouchEvent(event)
+
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ val now = System.currentTimeMillis()
+ val isDoubleTapCandidate = (now - lastTapTime < doubleTapTimeout) &&
+ (abs(event.x - lastTapX) < touchSlop * 4)
+
+ downX = event.x
+ downY = event.y
+ lastX = event.x
+ lastY = event.y
+ isDragging = false
+ isScaling = false
+
+ if (isDoubleTapCandidate) {
+ isDoubleTapHold = true
+ wasPlayingBeforeShuttle = viewModel.currentState.isPlaying
+
+ val isRightSide = event.x > v.width / 2f
+ val multiplier = if (isRightSide) 5.0 else -5.0
+ viewModel.setPlaying(true)
+ viewModel.setSpeedBoostMultiplier(multiplier)
+ } else {
+ isDoubleTapHold = false
+ lastTapTime = now
+ lastTapX = event.x
+
+ handler.postDelayed(initialBoostRunnable, initialBoostTimeout)
+ handler.postDelayed(warpBoostRunnable, warpBoostTimeout)
+ }
+ }
+
+ MotionEvent.ACTION_POINTER_DOWN -> {
+ cancelBoosts()
+ isDragging = false
+ }
+
+ MotionEvent.ACTION_POINTER_UP -> {
+ cancelBoosts()
+ val remainingIndex = if (event.actionIndex == 0) 1 else 0
+ if (remainingIndex < event.pointerCount) {
+ lastX = event.getX(remainingIndex)
+ lastY = event.getY(remainingIndex)
+ downX = lastX
+ downY = lastY
+ }
+ isDragging = false
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ if (event.pointerCount == 1 && !isScaling && !scaleDetector.isInProgress) {
+ val dx = event.x - lastX
+ val dy = event.y - lastY
+
+ val totalMove = abs(event.x - downX) + abs(event.y - downY)
+ if (!isDragging && totalMove > touchSlop) {
+ isDragging = true
+ cancelBoosts()
+ }
+
+ if (isDragging) {
+ if (abs(dx) > 0.1f) {
+ viewModel.adjustHeading(dx * headingSensitivity)
+ }
+ if (abs(dy) > 0.1f) {
+ viewModel.adjustTilt(-dy * tiltSensitivity)
+ }
+ }
+
+ lastX = event.x
+ lastY = event.y
+ } else if (event.pointerCount > 1) {
+ lastX = event.x
+ lastY = event.y
+ }
+ }
+
+ MotionEvent.ACTION_UP -> {
+ val now = System.currentTimeMillis()
+ if (isDoubleTapHold) {
+ val holdDuration = now - lastTapTime
+ if (holdDuration < 300L) {
+ // Quick double-tap: Skip +/- 10%
+ val isRightSide = event.x > v.width / 2f
+ viewModel.skipRatio(if (isRightSide) 0.10f else -0.10f)
+ }
+ // Revert playing state if it wasn't playing before
+ if (!wasPlayingBeforeShuttle) {
+ viewModel.setPlaying(false)
+ }
+ isDoubleTapHold = false
+ }
+ cancelBoosts()
+ isDragging = false
+ isScaling = false
+ }
+
+ MotionEvent.ACTION_CANCEL -> {
+ cancelBoosts()
+ if (isDoubleTapHold && !wasPlayingBeforeShuttle) {
+ viewModel.setPlaying(false)
+ }
+ isDoubleTapHold = false
+ isDragging = false
+ isScaling = false
+ }
+ }
+
+ return true
+ }
+
+ private fun cancelBoosts() {
+ handler.removeCallbacks(initialBoostRunnable)
+ handler.removeCallbacks(warpBoostRunnable)
+ viewModel.setSpeedBoostMultiplier(1.0)
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/StationaryCameraTracker.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/StationaryCameraTracker.kt
new file mode 100644
index 00000000..765bd6be
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/StationaryCameraTracker.kt
@@ -0,0 +1,210 @@
+/*
+ * 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.Camera
+import com.google.android.gms.maps3d.model.LatLngAltitude
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.maps.android.SphericalUtil
+import kotlin.math.PI
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+/**
+ * Represents a 3D Cartesian coordinate in a local East-North-Up (ENU) metric frame.
+ *
+ * @property east East displacement in meters (+East / -West).
+ * @property north North displacement in meters (+North / -South).
+ * @property up Altitude in meters above sea level (+Up / -Down).
+ */
+data class Cartesian3D(
+ val east: Double,
+ val north: Double,
+ val up: Double
+) {
+ /** Computes Euclidean distance to another point. */
+ fun distanceTo(other: Cartesian3D): Double {
+ val dx = east - other.east
+ val dy = north - other.north
+ val dz = up - other.up
+ return sqrt(dx * dx + dy * dy + dz * dz)
+ }
+}
+
+/**
+ * Mathematical controller that locks the physical camera eye at a stationary 3D spatial position
+ * while continuously tracking a moving entity (such as an airplane flying across San Francisco Bay).
+ * ### Mathematical Mechanics & Literate Formulation
+ *
+ * The Google Maps 3D SDK defines camera poses from the perspective of their focal center:
+ * `Camera(center = target, heading = H, tilt = θ, range = R)`
+ *
+ * When the focal target moves from an initial location P₀ to a destination P(t), maintaining
+ * a fixed physical observation vantage point requires computing the inverse spherical parameters:
+ *
+ * 1. **Initial Vantage Point Derivation**:
+ * From the initial observation camera parameters (P₀, H₀, θ₀, R₀), the physical eye is
+ * located behind the target in direction (H₀ + 180°):
+ * - Horizontal Ground Distance: `D₀ = R₀ * sin(θ₀)`
+ * - Vertical Height Above Target: `ΔZ₀ = R₀ * cos(θ₀)`
+ * - Fixed 3D Eye Coordinate in Local ENU Space:
+ * - `E_eye = -D₀ * sin(H₀)`
+ * - `N_eye = -D₀ * cos(H₀)`
+ * - `U_eye = P₀.altitude + ΔZ₀`
+ *
+ * 2. **Target Tracking & Inverse Projection**:
+ * As the target moves to P(t), its ENU position (E_t, N_t, U_t) relative to P₀ is computed
+ * via spherical trigonometry. The line-of-sight vector from the fixed eye to the target is:
+ * `V = (E_t - E_eye, N_t - N_eye, U_t - U_eye)`
+ * - **Range**: `R(t) = ||V|| = sqrt(ΔE² + ΔN² + ΔU²)`
+ * - **Heading**: `H(t) = atan2(ΔE, ΔN) mod 360°`
+ * - **Tilt**: `θ(t) = atan2(sqrt(ΔE² + ΔN²), U_eye - U_t)`
+ */
+class StationaryCameraTracker(
+ val referenceCenter: LatLngAltitude,
+ val initialHeading: Double,
+ val initialTilt: Double,
+ val initialRange: Double
+) {
+ /** The permanently fixed 3D Cartesian position of the camera eye in ENU space. */
+ val fixedEyePosition: Cartesian3D = computeInitialEyePosition()
+
+ private fun computeInitialEyePosition(): Cartesian3D {
+ val tiltRad = initialTilt * (PI / 180.0)
+ val headingRad = initialHeading * (PI / 180.0)
+ val horizDist = initialRange * sin(tiltRad)
+ val vertOffset = initialRange * cos(tiltRad)
+
+ val eastOffset = -horizDist * sin(headingRad)
+ val northOffset = -horizDist * cos(headingRad)
+ val upAltitude = referenceCenter.altitude + vertOffset
+
+ return Cartesian3D(east = eastOffset, north = northOffset, up = upAltitude)
+ }
+
+ /**
+ * Calculates the dynamic [Camera] pose required to keep the physical camera eye stationary
+ * while focusing on the moving target at [targetLocation].
+ *
+ * @param targetLocation The dynamic 3D location of the target entity.
+ * @return A [Camera] configuration centered on [targetLocation] with inverse-projected orientation.
+ */
+ fun computeTrackingCamera(targetLocation: LatLngAltitude): Camera {
+ // Geodesic offset from reference origin to current target
+ val targetLatLng = LatLng(targetLocation.latitude, targetLocation.longitude)
+ val refLatLng = LatLng(referenceCenter.latitude, referenceCenter.longitude)
+
+ val dist = SphericalUtil.computeDistanceBetween(refLatLng, targetLatLng)
+ val bearingRad = if (dist > 0.001) {
+ SphericalUtil.computeHeading(refLatLng, targetLatLng) * (PI / 180.0)
+ } else {
+ 0.0
+ }
+
+ val targetEast = dist * sin(bearingRad)
+ val targetNorth = dist * cos(bearingRad)
+ val targetUp = targetLocation.altitude
+
+ // Line of sight vector from fixed eye to target
+ val deltaEast = targetEast - fixedEyePosition.east
+ val deltaNorth = targetNorth - fixedEyePosition.north
+ val deltaUp = targetUp - fixedEyePosition.up
+
+ val horizDist = sqrt(deltaEast * deltaEast + deltaNorth * deltaNorth)
+ val range = sqrt(deltaEast * deltaEast + deltaNorth * deltaNorth + deltaUp * deltaUp)
+
+ // Heading: direction from eye to target in degrees [0, 360)
+ val headingDeg = (atan2(deltaEast, deltaNorth) * (180.0 / PI) + 360.0) % 360.0
+
+ // Tilt: angle with vertical nadir in degrees [0, 90]
+ val verticalDrop = fixedEyePosition.up - targetUp
+ val tiltDeg = if (verticalDrop > 0.0) {
+ (atan2(horizDist, verticalDrop) * (180.0 / PI)).coerceIn(0.0, 89.9)
+ } else {
+ 89.9
+ }
+
+ return camera {
+ center = latLngAltitude {
+ latitude = targetLocation.latitude
+ longitude = targetLocation.longitude
+ altitude = targetLocation.altitude
+ }
+ heading = headingDeg
+ tilt = tiltDeg
+ this.range = range
+ }
+ }
+
+ /**
+ * Convenience method to compute the tracking camera for an [EntityPose].
+ */
+ fun computeTrackingCamera(pose: EntityPose): Camera =
+ computeTrackingCamera(pose.position)
+
+ /**
+ * Verifies the invariant: reconstructs the physical eye position from any [Camera] object
+ * relative to [referenceCenter].
+ */
+ fun reconstructEyePosition(camera: Camera): Cartesian3D {
+ val targetLatLng = LatLng(camera.center.latitude, camera.center.longitude)
+ val refLatLng = LatLng(referenceCenter.latitude, referenceCenter.longitude)
+
+ val dist = SphericalUtil.computeDistanceBetween(refLatLng, targetLatLng)
+ val bearingRad = if (dist > 0.001) {
+ SphericalUtil.computeHeading(refLatLng, targetLatLng) * (PI / 180.0)
+ } else {
+ 0.0
+ }
+
+ val targetEast = dist * sin(bearingRad)
+ val targetNorth = dist * cos(bearingRad)
+ val targetUp = camera.center.altitude
+
+ val tilt = camera.tilt ?: 0.0
+ val heading = camera.heading ?: 0.0
+ val range = camera.range ?: 1000.0
+
+ val tiltRad = tilt * (PI / 180.0)
+ val headingRad = heading * (PI / 180.0)
+ val horizDist = range * sin(tiltRad)
+ val vertOffset = range * cos(tiltRad)
+
+ val eyeEast = targetEast - horizDist * sin(headingRad)
+ val eyeNorth = targetNorth - horizDist * cos(headingRad)
+ val eyeUp = targetUp + vertOffset
+
+ return Cartesian3D(east = eyeEast, north = eyeNorth, up = eyeUp)
+ }
+
+ companion object {
+ /**
+ * Factory method to construct a tracker from an initial [Camera].
+ */
+ fun fromInitialCamera(camera: Camera): StationaryCameraTracker =
+ StationaryCameraTracker(
+ referenceCenter = camera.center,
+ initialHeading = camera.heading ?: 0.0,
+ initialTilt = camera.tilt ?: 0.0,
+ initialRange = camera.range ?: 1000.0
+ )
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/TourData.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/TourData.kt
new file mode 100644
index 00000000..7e7594db
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/TourData.kt
@@ -0,0 +1,187 @@
+/*
+ * 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.Camera
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+
+/**
+ * Supported camera animation paradigms showcased in the demo.
+ */
+enum class AnimationApproach(val title: String) {
+ SIMPLE_FLY_TO("1. SDK Simple flyTo (Native Transition)"),
+ KEYFRAME_TOUR("2. Declarative Keyframe Queue Tour"),
+ DISPATCHER_FRAME_LOOP("3. High-Rate Frame Dispatcher Loop"),
+ ORBIT_360_SPIN("4. 360-Degree Continuous Orbit Spin")
+}
+
+/**
+ * Represents a single declarative step in a multi-step camera flight tour.
+ */
+sealed interface CameraKeyframe {
+ val stepTitle: String
+ val stepDescription: String
+ val durationMs: Long
+
+ data class FlyTo(
+ override val stepTitle: String,
+ override val stepDescription: String,
+ val targetCamera: Camera,
+ override val durationMs: Long = 3500L
+ ) : CameraKeyframe
+
+ data class DwellPause(
+ override val stepTitle: String,
+ override val stepDescription: String,
+ override val durationMs: Long = 2000L
+ ) : CameraKeyframe
+
+ data class StationaryTrackingFlight(
+ override val stepTitle: String = "Step 4 of 4: Stationary Vantage Tracking Flight",
+ override val stepDescription: String = "Camera remains stationary at high vantage point while tracking the plane flying to Coit Tower.",
+ val observationCamera: Camera = TourData.OVERVIEW_CAMERA,
+ val flightPath: List = TourData.AIRPLANE_FLIGHT_PATH,
+ override val durationMs: Long = 6000L
+ ) : CameraKeyframe
+
+ data class FlyAround(
+ override val stepTitle: String,
+ override val stepDescription: String,
+ val centerCamera: Camera,
+ val rounds: Double = 1.0,
+ override val durationMs: Long = 6000L
+ ) : CameraKeyframe
+}
+
+/**
+ * Shared geographical coordinates and tour definitions for Advanced Camera Animation.
+ */
+object TourData {
+
+ const val AIRPLANE_MODEL_ID = "airplane_model"
+ const val AIRPLANE_MODEL_URL = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/Airplane.glb"
+
+ val GOLDEN_GATE_BRIDGE = LatLng(37.8199, -122.4783)
+ val COIT_TOWER = LatLng(37.8024, -122.4058)
+
+ /**
+ * High-altitude overview camera used at the start of the Keyframe Tour.
+ */
+ @JvmField
+ val OVERVIEW_CAMERA: Camera = camera {
+ center = latLngAltitude {
+ latitude = 37.8199
+ longitude = -122.4783
+ altitude = 250.0
+ }
+ heading = 106.2
+ tilt = 35.0
+ range = 2800.0
+ }
+
+ /**
+ * Close-range flight camera behind the airplane over Golden Gate Bridge.
+ */
+ @JvmField
+ val CLOSE_INSPECTION_CAMERA: Camera = camera {
+ center = latLngAltitude {
+ latitude = 37.8199
+ longitude = -122.4783
+ altitude = 250.0
+ }
+ heading = 106.2
+ tilt = 65.0
+ range = 600.0
+ }
+
+ /**
+ * Close inspection camera at Coit Tower on Telegraph Hill looking back west over San Francisco.
+ */
+ @JvmField
+ val COIT_TOWER_INSPECTION_CAMERA: Camera = camera {
+ center = latLngAltitude {
+ latitude = 37.8024
+ longitude = -122.4058
+ altitude = 250.0
+ }
+ heading = 286.2 // Pointing back west in the direction the plane is coming from
+ tilt = 65.0
+ range = 600.0
+ }
+
+ /**
+ * 15 fine-grained waypoints along the direct aerial corridor from Golden Gate Bridge to Coit Tower.
+ */
+ @JvmField
+ val AIRPLANE_FLIGHT_PATH: List = listOf(
+ LatLng(37.8199, -122.4783), // 1. Golden Gate Bridge (Source)
+ LatLng(37.8188, -122.4735), // 2. Fort Point / Presidio Overlook
+ LatLng(37.8175, -122.4685), // 3. Crissy Field West
+ LatLng(37.8160, -122.4635), // 4. Crissy Field East
+ LatLng(37.8145, -122.4585), // 5. Marina Green West
+ LatLng(37.8130, -122.4530), // 6. Marina District Center
+ LatLng(37.8115, -122.4475), // 7. Fort Mason West
+ LatLng(37.8100, -122.4420), // 8. Fort Mason Heights
+ LatLng(37.8085, -122.4365), // 9. Aquatic Park Cove
+ LatLng(37.8070, -122.4310), // 10. Fisherman's Wharf West
+ LatLng(37.8058, -122.4250), // 11. Fisherman's Wharf Center
+ LatLng(37.8048, -122.4195), // 12. Pier 39 Promenade
+ LatLng(37.8038, -122.4140), // 13. Embarcadero North
+ LatLng(37.8030, -122.4090), // 14. Telegraph Hill Slopes
+ LatLng(37.8024, -122.4058) // 15. Coit Tower (Destination)
+ )
+
+ /**
+ * Standard 4-step San Francisco aerial tour keyframe sequence.
+ */
+ @JvmField
+ val SAN_FRANCISCO_TOUR: List = listOf(
+ CameraKeyframe.FlyTo(
+ stepTitle = "Step 1 of 5: High-Altitude Swoop In",
+ stepDescription = "Swooping down from 1200m high-altitude overview into close flight alignment (250m) behind the airplane.",
+ targetCamera = CLOSE_INSPECTION_CAMERA,
+ durationMs = 3500L
+ ),
+ CameraKeyframe.DwellPause(
+ stepTitle = "Step 2 of 5: Mid-Air Inspection Pause",
+ stepDescription = "Dwell pause (2.0s) holding camera lock to inspect the 3D airplane model above the Golden Gate Bridge.",
+ durationMs = 2000L
+ ),
+ CameraKeyframe.FlyAround(
+ stepTitle = "Step 3 of 5: 360° Orbital Revolution",
+ stepDescription = "Smooth 360° hardware-accelerated orbit around the airplane over Golden Gate Bridge using SDK flyCameraAround.",
+ centerCamera = CLOSE_INSPECTION_CAMERA,
+ rounds = 1.0,
+ durationMs = 6000L
+ ),
+ CameraKeyframe.StationaryTrackingFlight(
+ stepTitle = "Step 4 of 5: Stationary Vantage Tracking Flight",
+ stepDescription = "Camera returns to high vantage point, remaining stationary while tracking the airplane flying to Coit Tower.",
+ observationCamera = OVERVIEW_CAMERA,
+ flightPath = AIRPLANE_FLIGHT_PATH,
+ durationMs = 6000L
+ ),
+ CameraKeyframe.FlyTo(
+ stepTitle = "Step 5 of 5: Native Transit Flight to Coit Tower",
+ stepDescription = "Native SDK flyCameraTo transition flying directly to Coit Tower on Telegraph Hill, inspecting the destination.",
+ targetCamera = COIT_TOWER_INSPECTION_CAMERA,
+ durationMs = 4000L
+ )
+ )
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/TourPlaybackController.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/TourPlaybackController.kt
new file mode 100644
index 00000000..43becb3f
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/TourPlaybackController.kt
@@ -0,0 +1,283 @@
+/*
+ * 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.Camera
+import com.google.android.gms.maps3d.model.LatLngAltitude
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.maps.android.SphericalUtil
+
+/**
+ * Immutable state representing the advanced camera animation and airplane position.
+ */
+data class TourPlaybackState(
+ val selectedApproach: AnimationApproach = AnimationApproach.DISPATCHER_FRAME_LOOP,
+ val isPlaying: Boolean = false,
+ val isFinished: Boolean = false,
+ val currentStepIndex: Int = 0,
+ val totalSteps: Int = TourData.SAN_FRANCISCO_TOUR.size,
+ val statusText: String = "Press Play to start the aerial tour.",
+ val currentStepTitle: String = "",
+ val currentStepDescription: String = "",
+ val airplanePosition: LatLng = TourData.AIRPLANE_FLIGHT_PATH.first(),
+ val airplaneAltitude: Double = 250.0,
+ val airplaneHeading: Double = 286.2, // Face eastward toward Coit Tower
+ val cameraCenter: LatLng = TourData.AIRPLANE_FLIGHT_PATH.first(),
+ val cameraAltitude: Double = 250.0,
+ val cameraHeading: Double = 106.2,
+ val cameraTilt: Double = 65.0,
+ val cameraRange: Double = 600.0,
+ val cameraRoll: Double = 0.0,
+ val elapsedDistance: Double = 0.0,
+ val totalDistance: Double = 0.0,
+ val progressRatio: Float = 0.0f
+) {
+ val currentCamera: Camera
+ get() = camera {
+ center = latLngAltitude {
+ latitude = cameraCenter.latitude
+ longitude = cameraCenter.longitude
+ altitude = cameraAltitude
+ }
+ heading = cameraHeading
+ tilt = cameraTilt
+ range = cameraRange
+ roll = cameraRoll
+ }
+
+ val currentAirplanePositionWithAltitude: LatLngAltitude
+ get() = LatLngAltitude(airplanePosition.latitude, airplanePosition.longitude, airplaneAltitude)
+}
+
+/**
+ * Framework-independent domain state machine and kinematics controller for 3D Camera Tours.
+ *
+ * Encapsulates multi-step keyframe tours, high-rate VSYNC frame dispatching, and 360° orbital
+ * camera calculations with zero Android View/UI dependencies.
+ */
+class TourPlaybackController(
+ val flightPath: List = TourData.AIRPLANE_FLIGHT_PATH,
+ val keyframes: List = TourData.SAN_FRANCISCO_TOUR
+) {
+ private val cumulativeDistances: DoubleArray = calculateCumulativeDistances(flightPath)
+ val totalFlightDistance: Double = cumulativeDistances.lastOrNull() ?: 0.0
+ private var state: TourPlaybackState
+
+ init {
+ val startLoc = flightPath.firstOrNull() ?: LatLng(0.0, 0.0)
+ val initialBearing = if (flightPath.size >= 2) {
+ SphericalUtil.computeHeading(flightPath[0], flightPath[1])
+ } else {
+ 105.0
+ }
+
+ state = TourPlaybackState(
+ selectedApproach = AnimationApproach.DISPATCHER_FRAME_LOOP,
+ isPlaying = false,
+ isFinished = false,
+ currentStepIndex = 0,
+ totalSteps = keyframes.size,
+ statusText = "Press Play to start the aerial tour.",
+ airplanePosition = startLoc,
+ airplaneAltitude = 200.0,
+ airplaneHeading = normalizeHeading(initialBearing + 180.0),
+ cameraCenter = startLoc,
+ cameraAltitude = 200.0,
+ cameraHeading = normalizeHeading(initialBearing),
+ cameraTilt = 65.0,
+ cameraRange = 600.0,
+ elapsedDistance = 0.0,
+ totalDistance = totalFlightDistance,
+ progressRatio = 0f
+ )
+ }
+
+ fun getState(): TourPlaybackState = state
+
+ fun setApproach(approach: AnimationApproach): TourPlaybackState {
+ state = state.copy(
+ selectedApproach = approach,
+ isPlaying = false,
+ isFinished = false,
+ currentStepIndex = 0,
+ elapsedDistance = 0.0,
+ progressRatio = 0f,
+ statusText = when (approach) {
+ AnimationApproach.SIMPLE_FLY_TO -> "Native SDK flyTo transition across landmarks."
+ AnimationApproach.KEYFRAME_TOUR -> "Declarative keyframe sequence (FlyTo → Dwell → Orbit → FlyTo)."
+ AnimationApproach.DISPATCHER_FRAME_LOOP -> "High-rate 400 m/s flight frame dispatcher."
+ AnimationApproach.ORBIT_360_SPIN -> "Continuous 360° orbital camera spin."
+ }
+ )
+ return reset()
+ }
+
+ fun setPlaying(isPlaying: Boolean): TourPlaybackState {
+ state = state.copy(isPlaying = isPlaying)
+ return state
+ }
+
+ fun togglePlayPause(): TourPlaybackState {
+ state = state.copy(isPlaying = !state.isPlaying)
+ return state
+ }
+
+ fun setKeyframeStep(index: Int): TourPlaybackState {
+ if (index !in keyframes.indices) return state
+ val step = keyframes[index]
+ state = state.copy(
+ currentStepIndex = index,
+ currentStepTitle = step.stepTitle,
+ currentStepDescription = step.stepDescription,
+ statusText = "Step ${index + 1} of ${keyframes.size}: ${step.stepTitle}"
+ )
+ return state
+ }
+
+
+
+ fun advanceFrameDispatcher(deltaTimeSeconds: Double, speedMps: Double = 400.0): TourPlaybackState {
+ if (!state.isPlaying || totalFlightDistance <= 0.0) return state
+
+ val stepDist = speedMps * deltaTimeSeconds
+ val newDist = (state.elapsedDistance + stepDist).coerceAtMost(totalFlightDistance)
+ val ratio = (newDist / totalFlightDistance).toFloat().coerceIn(0f, 1f)
+
+ val point = interpolateFlightPoint(flightPath, cumulativeDistances, newDist)
+ val isAtEnd = newDist >= totalFlightDistance
+
+ state = state.copy(
+ elapsedDistance = newDist,
+ progressRatio = ratio,
+ airplanePosition = point.position,
+ airplaneAltitude = 200.0,
+ airplaneHeading = normalizeHeading(point.bearing + 180.0),
+ cameraCenter = point.position,
+ cameraAltitude = 200.0,
+ cameraHeading = normalizeHeading(point.bearing),
+ isFinished = isAtEnd,
+ isPlaying = if (isAtEnd) false else state.isPlaying,
+ statusText = if (isAtEnd) "Flight complete: Arrived at Coit Tower." else "Flying at 400 m/s: ${(ratio * 100).toInt()}% complete"
+ )
+ return state
+ }
+
+ fun advanceContinuousOrbit(deltaTimeSeconds: Double, speedDegPerSec: Double = 25.0): TourPlaybackState {
+ if (!state.isPlaying) return state
+
+ val newHeading = (state.cameraHeading + speedDegPerSec * deltaTimeSeconds) % 360.0
+ val targetCenter = flightPath.firstOrNull() ?: TourData.GOLDEN_GATE_BRIDGE
+
+ state = state.copy(
+ cameraCenter = targetCenter,
+ cameraAltitude = 200.0,
+ cameraHeading = normalizeHeading(newHeading),
+ cameraTilt = 65.0,
+ cameraRange = 600.0,
+ statusText = "360° Orbit Spin: ${newHeading.toInt()}°"
+ )
+ return state
+ }
+
+ fun reset(): TourPlaybackState {
+ val startLoc = flightPath.firstOrNull() ?: LatLng(0.0, 0.0)
+ val initialBearing = if (flightPath.size >= 2) {
+ SphericalUtil.computeHeading(flightPath[0], flightPath[1])
+ } else {
+ 105.0
+ }
+
+ state = state.copy(
+ isPlaying = false,
+ isFinished = false,
+ currentStepIndex = 0,
+ elapsedDistance = 0.0,
+ progressRatio = 0f,
+ airplanePosition = startLoc,
+ airplaneAltitude = 200.0,
+ airplaneHeading = normalizeHeading(initialBearing + 180.0),
+ cameraCenter = startLoc,
+ cameraAltitude = 200.0,
+ cameraHeading = normalizeHeading(initialBearing),
+ cameraTilt = 65.0,
+ cameraRange = 600.0,
+ statusText = "Tour reset. Press Play to begin."
+ )
+ return state
+ }
+
+ companion object {
+ fun normalizeHeading(headingDeg: Double): Double {
+ val normalized = headingDeg % 360.0
+ return if (normalized < 0.0) normalized + 360.0 else normalized
+ }
+
+ fun interpolateAngle(start: Double, end: Double, fraction: Double): Double {
+ var diff = (end - start) % 360.0
+ if (diff > 180.0) diff -= 360.0
+ if (diff < -180.0) diff += 360.0
+ return (start + diff * fraction + 360.0) % 360.0
+ }
+
+ fun calculateCumulativeDistances(path: List): DoubleArray {
+ if (path.isEmpty()) return doubleArrayOf(0.0)
+ val distances = DoubleArray(path.size)
+ distances[0] = 0.0
+ for (i in 1 until path.size) {
+ distances[i] = distances[i - 1] + SphericalUtil.computeDistanceBetween(path[i - 1], path[i])
+ }
+ return distances
+ }
+
+ data class InterpolatedFlightPoint(
+ val position: LatLng,
+ val bearing: Double,
+ val waypointIndex: Int
+ )
+
+ fun interpolateFlightPoint(
+ path: List,
+ cumulativeDistances: DoubleArray,
+ distance: Double
+ ): InterpolatedFlightPoint {
+ if (path.isEmpty()) return InterpolatedFlightPoint(LatLng(0.0, 0.0), 0.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 d1 = cumulativeDistances.getOrElse(index) { 0.0 }
+ 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 currentLatLng = SphericalUtil.interpolate(p1, p2, fraction)
+ val bearing = if (p1 != p2) SphericalUtil.computeHeading(p1, p2) else 105.0
+
+ return InterpolatedFlightPoint(
+ position = currentLatLng,
+ bearing = normalizeHeading(bearing),
+ waypointIndex = index
+ )
+ }
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/WorldController.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/WorldController.kt
new file mode 100644
index 00000000..e6b0d566
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/WorldController.kt
@@ -0,0 +1,327 @@
+/*
+ * 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.Camera
+import com.google.android.gms.maps3d.model.LatLngAltitude
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+import com.google.maps.android.SphericalUtil
+
+/**
+ * Pure domain state machine and flight simulation engine managing the 3D World.
+ *
+ * Decoupled from Android UI, Views, and GoogleMap3D rendering handles.
+ */
+class WorldController(
+ val flightPath: List = TourData.AIRPLANE_FLIGHT_PATH,
+ val keyframes: List = TourData.SAN_FRANCISCO_TOUR,
+ val planeEntityId: String = TourData.AIRPLANE_MODEL_ID
+) {
+
+ private val startLoc: LatLng = flightPath.firstOrNull() ?: LatLng(37.8199, -122.4783)
+ private val endLoc: LatLng = flightPath.lastOrNull() ?: LatLng(37.8024, -122.4058)
+ private val initialHeading: Double = if (flightPath.size >= 2) {
+ TrajectoryFlightAnimator.normalizeHeading(SphericalUtil.computeHeading(flightPath[0], flightPath[1]))
+ } else {
+ 105.0
+ }
+
+ private val initialPlanePose = EntityPose(
+ position = LatLngAltitude(startLoc.latitude, startLoc.longitude, 250.0),
+ heading = TrajectoryFlightAnimator.normalizeHeading(initialHeading + 180.0),
+ pitch = -90.0,
+ roll = 0.0,
+ scale = 0.08
+ )
+
+ private val finalPlanePose = EntityPose(
+ position = LatLngAltitude(endLoc.latitude, endLoc.longitude, 250.0),
+ heading = TrajectoryFlightAnimator.normalizeHeading(initialHeading + 180.0),
+ pitch = -90.0,
+ roll = 0.0,
+ scale = 0.08
+ )
+
+ private var simpleFlyToMode: SimpleFlyToMode = SimpleFlyToMode.SYNCHRONIZED_FLIGHT
+ private var selectedApproach: AnimationApproach = AnimationApproach.SIMPLE_FLY_TO
+ private var executionState: AnimationExecutionState = AnimationExecutionState.IDLE
+ private var currentStepIndex: Int = 0
+ private var elapsedTimeMs: Long = 0L
+ private val totalFlyToDurationMs: Long = 5000L
+
+ // Active Entity Animators
+ private var planeTrajectoryAnimator: TrajectoryFlightAnimator = TrajectoryFlightAnimator(flightPath)
+ private var planeMidpointAnimator: MidpointJumpAnimator = MidpointJumpAnimator(initialPlanePose, finalPlanePose)
+ private var orbitAnimator: ContinuousOrbitAnimator = ContinuousOrbitAnimator(startLoc, initialHeading)
+
+ private var state: WorldState
+
+ init {
+ state = buildInitialState()
+ }
+
+ private fun buildInitialState(): WorldState {
+ val initialCam = if (selectedApproach == AnimationApproach.KEYFRAME_TOUR) {
+ TourData.OVERVIEW_CAMERA
+ } else {
+ camera {
+ center = latLngAltitude {
+ latitude = startLoc.latitude
+ longitude = startLoc.longitude
+ altitude = 250.0
+ }
+ heading = initialHeading
+ tilt = 65.0
+ range = 600.0
+ }
+ }
+
+ val firstStep = keyframes.firstOrNull()
+ return WorldState(
+ entities = mapOf(planeEntityId to initialPlanePose),
+ camera = initialCam,
+ executionState = AnimationExecutionState.IDLE,
+ selectedApproach = selectedApproach,
+ simpleFlyToMode = simpleFlyToMode,
+ currentStepIndex = 0,
+ totalSteps = keyframes.size,
+ stepTitle = firstStep?.stepTitle ?: "",
+ stepDescription = firstStep?.stepDescription ?: "",
+ statusText = "Press Play to start the aerial tour.",
+ elapsedTimeMs = 0L,
+ totalDurationMs = totalFlyToDurationMs,
+ progressRatio = 0f,
+ pendingCameraCommand = null
+ )
+ }
+
+ fun getState(): WorldState = state
+
+ fun setApproach(approach: AnimationApproach): WorldState {
+ selectedApproach = approach
+ executionState = AnimationExecutionState.IDLE
+ elapsedTimeMs = 0L
+ currentStepIndex = 0
+ val firstStep = keyframes.firstOrNull()
+ state = buildInitialState().copy(
+ selectedApproach = approach,
+ stepTitle = if (approach == AnimationApproach.KEYFRAME_TOUR) (firstStep?.stepTitle ?: "") else "",
+ stepDescription = if (approach == AnimationApproach.KEYFRAME_TOUR) (firstStep?.stepDescription ?: "") else "",
+ statusText = when (approach) {
+ AnimationApproach.SIMPLE_FLY_TO -> "1. Native SDK flyTo with ${simpleFlyToMode.label}"
+ AnimationApproach.KEYFRAME_TOUR -> firstStep?.stepTitle ?: "Declarative Keyframe Tour"
+ AnimationApproach.DISPATCHER_FRAME_LOOP -> "3. High-rate 400 m/s flight frame loop"
+ AnimationApproach.ORBIT_360_SPIN -> "4. 360-degree continuous orbital camera spin"
+ }
+ )
+ return state
+ }
+
+ fun setSimpleFlyToMode(mode: SimpleFlyToMode): WorldState {
+ simpleFlyToMode = mode
+ state = state.copy(
+ simpleFlyToMode = mode,
+ statusText = "Selected: ${mode.label}"
+ )
+ return state
+ }
+
+ fun play(): WorldState {
+ executionState = AnimationExecutionState.RUNNING
+
+ val command = when (selectedApproach) {
+ AnimationApproach.SIMPLE_FLY_TO -> {
+ val targetCam = camera {
+ center = latLngAltitude {
+ latitude = endLoc.latitude
+ longitude = endLoc.longitude
+ altitude = 250.0
+ }
+ heading = 285.0 // Look back West-Northwest toward Golden Gate Bridge to see the plane fly in
+ tilt = 65.0
+ range = 600.0
+ }
+ CameraAnimationCommand.NativeFlyTo(targetCam, totalFlyToDurationMs)
+ }
+ else -> null
+ }
+
+ state = state.copy(
+ executionState = AnimationExecutionState.RUNNING,
+ pendingCameraCommand = command,
+ statusText = when (selectedApproach) {
+ AnimationApproach.SIMPLE_FLY_TO -> "Flying to Coit Tower (${simpleFlyToMode.label})"
+ AnimationApproach.KEYFRAME_TOUR -> "Running keyframe tour: Step ${currentStepIndex + 1} of ${keyframes.size}"
+ AnimationApproach.DISPATCHER_FRAME_LOOP -> "Flying at 400 m/s along flight path"
+ AnimationApproach.ORBIT_360_SPIN -> "Continuous 360° orbital spin active"
+ }
+ )
+ return state
+ }
+
+ fun pause(): WorldState {
+ executionState = AnimationExecutionState.PAUSED
+ state = state.copy(
+ executionState = AnimationExecutionState.PAUSED,
+ pendingCameraCommand = CameraAnimationCommand.StopCameraAnimation,
+ statusText = "Tour paused."
+ )
+ return state
+ }
+
+ fun togglePlayPause(): WorldState {
+ return if (executionState == AnimationExecutionState.RUNNING) pause() else play()
+ }
+
+ fun reset(): WorldState {
+ executionState = AnimationExecutionState.IDLE
+ elapsedTimeMs = 0L
+ currentStepIndex = 0
+ planeTrajectoryAnimator.reset()
+ planeMidpointAnimator.reset()
+ orbitAnimator.reset(initialHeading)
+ state = buildInitialState().copy(
+ selectedApproach = selectedApproach,
+ simpleFlyToMode = simpleFlyToMode,
+ pendingCameraCommand = CameraAnimationCommand.SetCameraDirect(buildInitialState().camera),
+ statusText = "Tour reset. Press Play to begin."
+ )
+ return state
+ }
+
+ fun onNativeCameraAnimationFinished(): WorldState {
+ executionState = AnimationExecutionState.FINISHED
+ state = state.copy(
+ executionState = AnimationExecutionState.FINISHED,
+ progressRatio = 1.0f,
+ statusText = "Flight complete: Arrived at Coit Tower.",
+ entities = mapOf(planeEntityId to finalPlanePose)
+ )
+ return state
+ }
+
+ /**
+ * Advances the world model by [deltaTimeSeconds].
+ * Synchronizes plane entity pose and camera position atomically.
+ */
+ fun tick(deltaTimeSeconds: Double): WorldState {
+ if (executionState != AnimationExecutionState.RUNNING) return state
+
+ val deltaMs = (deltaTimeSeconds * 1000.0).toLong()
+ elapsedTimeMs += deltaMs
+
+ when (selectedApproach) {
+ AnimationApproach.SIMPLE_FLY_TO -> {
+ val planePose = if (simpleFlyToMode == SimpleFlyToMode.MIDPOINT_JUMP) {
+ planeMidpointAnimator.update(elapsedTimeMs, totalFlyToDurationMs)
+ } else {
+ planeTrajectoryAnimator.update(elapsedTimeMs, totalFlyToDurationMs)
+ }
+
+ val ratio = (elapsedTimeMs.toFloat() / totalFlyToDurationMs).coerceIn(0f, 1f)
+ val isDone = elapsedTimeMs >= totalFlyToDurationMs
+
+ state = state.copy(
+ entities = mapOf(planeEntityId to planePose),
+ elapsedTimeMs = elapsedTimeMs,
+ progressRatio = ratio,
+ executionState = if (isDone) AnimationExecutionState.FINISHED else AnimationExecutionState.RUNNING,
+ statusText = if (isDone) "Flight complete: Arrived at Coit Tower." else "Flying to Coit Tower: ${(ratio * 100).toInt()}%"
+ )
+ }
+
+ AnimationApproach.DISPATCHER_FRAME_LOOP -> {
+ val totalDist = planeTrajectoryAnimator.totalDistance
+ val speedMps = 400.0
+ val totalDurationMs = if (speedMps > 0) ((totalDist / speedMps) * 1000.0).toLong() else 5000L
+ val planePose = planeTrajectoryAnimator.update(elapsedTimeMs, totalDurationMs)
+ val ratio = (elapsedTimeMs.toFloat() / totalDurationMs).coerceIn(0f, 1f)
+ val isDone = elapsedTimeMs >= totalDurationMs
+
+ val cam = camera {
+ center = latLngAltitude {
+ latitude = planePose.position.latitude
+ longitude = planePose.position.longitude
+ altitude = 250.0
+ }
+ heading = TrajectoryFlightAnimator.normalizeHeading(planePose.heading + 180.0)
+ tilt = 65.0
+ range = 600.0
+ }
+
+ state = state.copy(
+ entities = mapOf(planeEntityId to planePose),
+ camera = cam,
+ elapsedTimeMs = elapsedTimeMs,
+ progressRatio = ratio,
+ executionState = if (isDone) AnimationExecutionState.FINISHED else AnimationExecutionState.RUNNING,
+ statusText = if (isDone) "Flight complete: Arrived at Coit Tower." else "Flying at 400 m/s: ${(ratio * 100).toInt()}%"
+ )
+ }
+
+ AnimationApproach.ORBIT_360_SPIN -> {
+ val newHeading = orbitAnimator.tick(deltaTimeSeconds)
+ val cam = camera {
+ center = latLngAltitude {
+ latitude = startLoc.latitude
+ longitude = startLoc.longitude
+ altitude = 250.0
+ }
+ heading = newHeading
+ tilt = 65.0
+ range = 600.0
+ }
+
+ state = state.copy(
+ camera = cam,
+ entities = mapOf(planeEntityId to initialPlanePose),
+ statusText = "360° Orbit Spin: ${newHeading.toInt()}°"
+ )
+ }
+
+ AnimationApproach.KEYFRAME_TOUR -> {
+ // Keyframe tour advances via explicit step commands
+ }
+ }
+
+ return state
+ }
+
+ fun updateAirplanePose(pose: EntityPose): WorldState {
+ state = state.copy(
+ entities = mapOf(planeEntityId to pose)
+ )
+ return state
+ }
+
+ fun setKeyframeStep(index: Int): WorldState {
+ if (index !in keyframes.indices) return state
+ currentStepIndex = index
+ val step = keyframes[index]
+ state = state.copy(
+ currentStepIndex = index,
+ stepTitle = step.stepTitle,
+ stepDescription = step.stepDescription,
+ statusText = "${step.stepTitle}: ${step.stepDescription}"
+ )
+ return state
+ }
+
+
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/WorldModel.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/WorldModel.kt
new file mode 100644
index 00000000..6e6442f6
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/WorldModel.kt
@@ -0,0 +1,101 @@
+/*
+ * 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.Camera
+import com.google.android.gms.maps3d.model.LatLngAltitude
+import com.google.android.gms.maps3d.model.camera
+import com.google.android.gms.maps3d.model.latLngAltitude
+
+/**
+ * Represents the 3D spatial transformation (position, orientation, scale) of an entity in the scene.
+ */
+data class EntityPose(
+ val position: LatLngAltitude,
+ val heading: Double, // degrees [0, 360)
+ val pitch: Double = -90.0,
+ val roll: Double = 0.0,
+ val scale: Double = 0.08
+)
+
+/**
+ * Execution mode for "1. SDK Simple flyTo" demonstrating CPU performance trade-offs.
+ */
+enum class SimpleFlyToMode(val label: String, val description: String) {
+ MIDPOINT_JUMP(
+ "Midpoint Reposition (Low CPU)",
+ "Schedules a single discrete action at t = T/2 to move the plane to destination."
+ ),
+ SYNCHRONIZED_FLIGHT(
+ "Synchronized Animation (High CPU)",
+ "Animates the plane per-frame along trajectory for the full duration of flyTo."
+ )
+}
+
+/**
+ * State of animation lifecycle in the world model.
+ */
+enum class AnimationExecutionState {
+ IDLE,
+ RUNNING,
+ PAUSED,
+ FINISHED
+}
+
+/**
+ * Sealed class representing camera animation commands emitted from the controller/ViewModel to the UI.
+ */
+sealed interface CameraAnimationCommand {
+ data class NativeFlyTo(
+ val targetCamera: Camera,
+ val durationMs: Long
+ ) : CameraAnimationCommand
+
+ data class SetCameraDirect(
+ val camera: Camera
+ ) : CameraAnimationCommand
+
+ data object StopCameraAnimation : CameraAnimationCommand
+}
+
+/**
+ * Immutable snapshot of the entire 3D simulation scene at a specific point in time.
+ */
+data class WorldState(
+ val entities: Map = emptyMap(),
+ val camera: Camera,
+ val executionState: AnimationExecutionState = AnimationExecutionState.IDLE,
+ val selectedApproach: AnimationApproach = AnimationApproach.SIMPLE_FLY_TO,
+ val simpleFlyToMode: SimpleFlyToMode = SimpleFlyToMode.SYNCHRONIZED_FLIGHT,
+ val currentStepIndex: Int = 0,
+ val totalSteps: Int = TourData.SAN_FRANCISCO_TOUR.size,
+ val stepTitle: String = "",
+ val stepDescription: String = "",
+ val statusText: String = "Press Play to start the aerial tour.",
+ val elapsedTimeMs: Long = 0L,
+ val totalDurationMs: Long = 5000L,
+ val progressRatio: Float = 0.0f,
+ val pendingCameraCommand: CameraAnimationCommand? = null
+) {
+ val isPlaying: Boolean
+ get() = executionState == AnimationExecutionState.RUNNING
+
+ val isFinished: Boolean
+ get() = executionState == AnimationExecutionState.FINISHED
+
+ fun getEntityPose(id: String): EntityPose? = entities[id]
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/arrow_drop_down_24px.xml b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/arrow_drop_down_24px.xml
new file mode 100644
index 00000000..17a416cd
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/arrow_drop_down_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/drag_handle_bar.xml b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/drag_handle_bar.xml
new file mode 100644
index 00000000..f4b6103a
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/drag_handle_bar.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_less_24px.xml b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_less_24px.xml
new file mode 100644
index 00000000..df9af507
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_less_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_more_24px.xml b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_more_24px.xml
new file mode 100644
index 00000000..391e875f
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_more_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/help_outline_24px.xml b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/help_outline_24px.xml
new file mode 100644
index 00000000..dc4cbccf
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/help_outline_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_path_following.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_path_following.xml
new file mode 100644
index 00000000..e8e557f3
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_path_following.xml
@@ -0,0 +1,440 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_advanced_animation.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_advanced_animation.xml
new file mode 100644
index 00000000..72cbce82
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_advanced_animation.xml
@@ -0,0 +1,283 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_data_visualization.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_data_visualization.xml
new file mode 100644
index 00000000..43767b1b
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_data_visualization.xml
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_field_of_view.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_field_of_view.xml
new file mode 100644
index 00000000..4db8a07c
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_field_of_view.xml
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_roadmap_mode.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_roadmap_mode.xml
new file mode 100644
index 00000000..059caa53
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_roadmap_mode.xml
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_routes.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_routes.xml
similarity index 70%
rename from Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_routes.xml
rename to Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_routes.xml
index 014aa8b2..583a8265 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_routes.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_routes.xml
@@ -14,61 +14,71 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -99,7 +109,6 @@
android:value="0.0"
app:labelBehavior="gone"
/>
-
@@ -163,6 +172,5 @@
/>
-
-
-
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/dialog_help_advanced_animation.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/dialog_help_advanced_animation.xml
new file mode 100644
index 00000000..1e8b1dbf
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/dialog_help_advanced_animation.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/raw/help_advanced_animation.html b/Maps3DSamples/ApiDemos/common/src/main/res/raw/help_advanced_animation.html
new file mode 100644
index 00000000..5eec7916
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/raw/help_advanced_animation.html
@@ -0,0 +1,19 @@
+
✈️ Camera Animation Paradigms
+
+ • 1. SDK Simple flyTo: Native asynchronous GPU camera flight transition directly to Coit Tower with automatic altitude and tilt easing.
+ • 2. Keyframe Queue: Declarative chained sequence (FlyTo → Dwell Pause → 360° Orbit → Final FlyTo).
+ • 3. Frame Dispatcher: Continuous 400 m/s flight synced to hardware VSYNC display frames via Choreographer.
+ • 4. 360° Continuous Orbit: Constant angular camera rotation around landmark.
+
+
+
🎮 3D Model Synchronization
+
+ • Synchronized Flight (High CPU): Calculates continuous spherical waypoints and model heading on every frame in lockstep with the camera.
+ • Midpoint Jump (Low CPU): Leaves model at origin until t = T/2, then repositions to destination with near-zero CPU overhead.
+
+
+
🎛️ Panel & Gestures
+
+ • Swipe Up / Down or Tap Header: Collapse or expand the controls panel.
+ • Idle Auto-Fade: Panel smoothly dims after 3.5s of inactivity to provide an unobstructed full-screen map view.
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
index c8c730dc..78936c32 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
@@ -74,7 +74,6 @@
SnapshotStop camera animations
-
+ Route Simulation Controls
+ Route is still loading…Play or pause route animationCamera Altitude: %1$dmVehicle Speed: %1$dm/sCamera Yaw Offset: %1$d°Offline: Using local Oahu fallback route
+ Play
+ Pause
+ Reset
+
+
+ 3D Map Mode Controls
+ Map Mode
+ Roadmap
+ Hybrid
+ Satellite
+
+
+ San Francisco Aerial Tour
+ Kotlin Views
+ Java Views
+ Jetpack Compose
+ Select Animation Approach:
+ 1. SDK Simple flyTo
+ 2. Keyframe Queue Tour
+ 3. Frame Dispatcher (400 m/s)
+ 4. 360° Continuous Orbit
+ 3D Camera Animation Paradigms
+ ✈️ Animation Approaches:
+
1. SDK Simple flyTo: Native asynchronous camera flight transition directly to Coit Tower with automatic altitude and tilt easing.
3. High-Rate Frame Dispatcher: Continuous 400 m/s flight synced to hardware VSYNC display frames via Choreographer.
+
4. 360° Continuous Orbit: Constant angular camera rotation around the Golden Gate Bridge.
+
+
🎛️ Control Panel:
+
• Dropdown Menu: Tap to select animation approach
+
• Header / Drag Handle: Tap or swipe to expand/collapse panel
+
• Idle Auto-Fade: Panel dims after 3.5s of inactivity
+]]>
+ Press Play to begin the multi-step aerial tour.
+ Step %1$d of %2$d: %3$s
+ Tour complete. Press Reset to replay.
+ 1. Swoop to Golden Gate
+ Descend from high-altitude SF panorama to Golden Gate Bridge
+ 2. Mid-Air Observation
+ Dwell pause observing 3D airplane over Golden Gate
+ 3. Golden Gate 360° Orbit
+ 360° orbital camera spin around airplane
+ 4. Transit to Coit Tower
+ Airplane flight across San Francisco to Coit Tower
+ Continuous 360° Orbit
+ Continuous orbital camera spin around Golden Gate Bridge
+ Dwell Pause
+ Observing current location
+
+
+ Path Controls
+ Collapse Controls
+ Expand Controls
+ Path Environment:
+ Urban
+ Rural
+ Altitude Mode:
+ Relative to Ground
+ Clamp to Ground
+ Relative to Mesh
+ Absolute
+ 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
+ Path Height: 0.5m
+ Camera Range: 300m
+ Ground Altitude: 20m
+ Heading Offset: 0°
+ Camera Tilt: 70°
+ Follow Speed: 30 m/s
+ Help
+ 0.5x
+ 1x
+ 2x
+ 3x
+ 5x
+ Play or pause animation
+ Draw Occluded Segments
+
+
+ Flood Simulation Controls
+ Flood Elevation: +%1$.1f m (%2$.1f ft)
+ Flood Elevation: +10.0 m (32.8 ft)
+ 🌊 Baseline Tide
+ ⚠️ Minor Inundation
+ 🌊 Moderate Flooding
+ 🚨 Storm Surge (Cat 3)
+ ⛔ Extreme Inundation
+ Continuous Tide Simulation:
+ ▶ Start Simulation
+ ⏹ Stop Simulation
+ San Francisco Waterfront - Water Level: +%1$.1f m
+
+
+ Field of View Controls
+ Field of View: %1$d°
+ Field of View: 45°
+ FOV Presets:
+ 20° Tele
+ 45° Standard
+ 90° Wide
+ 120° Ultra
+ 3D Path Following Controls
+ 🧭 Camera Gestures:\n• Vertical Sweep (1 Finger): Adjusts camera tilt (0° to 85°)\n• Horizontal Sweep (1 Finger): Rotates camera heading orbit\n• Pinch (2 Fingers): Zooms camera in / out\n\n⚡ Speed & Navigation:\n• Long Press & Hold: 2x Boost (at 0.5s) → 5x Warp Speed (at 2s)\n• Double-Tap & Hold Right: +5x Fast-Forward\n• Double-Tap & Hold Left: -5x Rewind\n• Quick Double-Tap: Skip +/- 10% along path\n• Speed Chips (0.5x - 5x): Instant preset selection\n\n🎛️ Control Panel:\n• Swipe Up / Down or Tap Header: Expand / collapse settings panel\n• Idle Auto-Fade: Panel fades to transparent after 3.5s of inactivity
+ Got It
+
+ 3D Model Animation Mode:
+ Synchronized (High CPU)
+ Midpoint Jump (Low CPU)
diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/AdvancedCameraAnimationViewModelTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/AdvancedCameraAnimationViewModelTest.kt
new file mode 100644
index 00000000..3db3b482
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/AdvancedCameraAnimationViewModelTest.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3d.common
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+/**
+ * JVM Unit Tests for [AdvancedCameraAnimationViewModel] using Google Truth.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class AdvancedCameraAnimationViewModelTest {
+
+ @get:Rule
+ val instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ private val testDispatcher = StandardTestDispatcher()
+ private lateinit var viewModel: AdvancedCameraAnimationViewModel
+
+ @Before
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+ viewModel = AdvancedCameraAnimationViewModel()
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun initialViewModelState_isCorrect() {
+ val state = viewModel.currentState
+ assertThat(state.selectedApproach).isEqualTo(AnimationApproach.SIMPLE_FLY_TO)
+ assertThat(state.isPlaying).isFalse()
+ assertThat(state.entities).containsKey(TourData.AIRPLANE_MODEL_ID)
+ }
+
+ @Test
+ fun actions_updateStateFlowAndLiveData() {
+ viewModel.setApproach(AnimationApproach.SIMPLE_FLY_TO)
+ assertThat(viewModel.currentState.selectedApproach).isEqualTo(AnimationApproach.SIMPLE_FLY_TO)
+
+ viewModel.setSimpleFlyToMode(SimpleFlyToMode.MIDPOINT_JUMP)
+ assertThat(viewModel.currentState.simpleFlyToMode).isEqualTo(SimpleFlyToMode.MIDPOINT_JUMP)
+
+ viewModel.play()
+ assertThat(viewModel.currentState.isPlaying).isTrue()
+
+ viewModel.pause()
+ assertThat(viewModel.currentState.isPlaying).isFalse()
+
+ viewModel.togglePlayPause()
+ assertThat(viewModel.currentState.isPlaying).isTrue()
+
+ viewModel.resetTour()
+ assertThat(viewModel.currentState.isPlaying).isFalse()
+ assertThat(viewModel.currentState.progressRatio).isEqualTo(0f)
+ }
+
+ @Test
+ fun tick_advancesSimulationState() {
+ viewModel.setApproach(AnimationApproach.SIMPLE_FLY_TO)
+ viewModel.setSimpleFlyToMode(SimpleFlyToMode.SYNCHRONIZED_FLIGHT)
+ viewModel.play()
+
+ viewModel.tick(1.0)
+ assertThat(viewModel.currentState.elapsedTimeMs).isEqualTo(1000L)
+ assertThat(viewModel.currentState.progressRatio).isGreaterThan(0f)
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/EntityAnimatorTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/EntityAnimatorTest.kt
new file mode 100644
index 00000000..1abc7dbb
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/EntityAnimatorTest.kt
@@ -0,0 +1,115 @@
+/*
+ * 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.LatLngAltitude
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+/**
+ * JVM Unit Tests for [EntityAnimator] implementations using Google Truth.
+ */
+class EntityAnimatorTest {
+
+ private val startPose = EntityPose(
+ position = LatLngAltitude(37.8199, -122.4783, 200.0),
+ heading = 285.0
+ )
+
+ private val endPose = EntityPose(
+ position = LatLngAltitude(37.8024, -122.4058, 200.0),
+ heading = 285.0
+ )
+
+ @Test
+ fun midpointJumpAnimator_holdsStartPoseBeforeMidpoint_andJumpsAtMidpoint() {
+ val totalDurationMs = 5000L
+ val animator = MidpointJumpAnimator(startPose, endPose)
+
+ // At t = 0ms -> Start Pose
+ val poseAt0 = animator.update(0L, totalDurationMs)
+ assertThat(poseAt0.position.latitude).isEqualTo(startPose.position.latitude)
+ assertThat(poseAt0.position.longitude).isEqualTo(startPose.position.longitude)
+ assertThat(animator.isFinished(0L, totalDurationMs)).isFalse()
+
+ // At t = 2499ms (just before 2500ms midpoint) -> Still Start Pose
+ val poseBeforeMid = animator.update(2499L, totalDurationMs)
+ assertThat(poseBeforeMid.position.latitude).isEqualTo(startPose.position.latitude)
+ assertThat(poseBeforeMid.position.longitude).isEqualTo(startPose.position.longitude)
+
+ // At t = 2500ms (exact midpoint) -> Jumps to End Pose
+ val poseAtMid = animator.update(2500L, totalDurationMs)
+ assertThat(poseAtMid.position.latitude).isEqualTo(endPose.position.latitude)
+ assertThat(poseAtMid.position.longitude).isEqualTo(endPose.position.longitude)
+
+ // At t = 5000ms -> End Pose and Finished
+ val poseAtEnd = animator.update(5000L, totalDurationMs)
+ assertThat(poseAtEnd.position.latitude).isEqualTo(endPose.position.latitude)
+ assertThat(animator.isFinished(5000L, totalDurationMs)).isTrue()
+ }
+
+ @Test
+ fun trajectoryFlightAnimator_interpolatesContinuouslyAlongPath() {
+ val waypoints = listOf(
+ LatLng(37.8199, -122.4783),
+ LatLng(37.8115, -122.4475),
+ LatLng(37.8024, -122.4058)
+ )
+ val totalDurationMs = 4000L
+ val animator = TrajectoryFlightAnimator(waypoints, altitude = 200.0)
+
+ assertThat(animator.totalDistance).isGreaterThan(5000.0)
+
+ // At t = 0ms -> Start of path
+ val pose0 = animator.update(0L, totalDurationMs)
+ assertThat(pose0.position.latitude).isWithin(0.0001).of(waypoints.first().latitude)
+ assertThat(pose0.position.longitude).isWithin(0.0001).of(waypoints.first().longitude)
+
+ // At t = 2000ms (50%) -> Mid-flight point
+ val poseMid = animator.update(2000L, totalDurationMs)
+ assertThat(poseMid.position.longitude).isGreaterThan(waypoints.first().longitude)
+ assertThat(poseMid.position.longitude).isLessThan(waypoints.last().longitude)
+
+ // At t = 4000ms (100%) -> End of path
+ val poseEnd = animator.update(4000L, totalDurationMs)
+ assertThat(poseEnd.position.latitude).isWithin(0.0001).of(waypoints.last().latitude)
+ assertThat(poseEnd.position.longitude).isWithin(0.0001).of(waypoints.last().longitude)
+ assertThat(animator.isFinished(4000L, totalDurationMs)).isTrue()
+ }
+
+ @Test
+ fun continuousOrbitAnimator_advancesHeadingLinearlyAndWraps() {
+ val animator = ContinuousOrbitAnimator(
+ center = LatLng(37.8199, -122.4783),
+ speedDegPerSec = 30.0
+ )
+ animator.reset(initialHeading = 350.0)
+
+ // Advance 1 second at 30 deg/sec: (350 + 30) % 360 = 20 deg
+ val heading1 = animator.tick(1.0)
+ assertThat(heading1).isWithin(0.01).of(20.0)
+
+ // Advance another 2 seconds: (20 + 60) = 80 deg
+ val heading2 = animator.tick(2.0)
+ assertThat(heading2).isWithin(0.01).of(80.0)
+
+ // Reset
+ animator.reset(105.0)
+ assertThat(animator.getHeading()).isEqualTo(105.0)
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathEngineTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathEngineTest.kt
new file mode 100644
index 00000000..ed405d39
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathEngineTest.kt
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3d.common
+
+import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.LatLngAltitude
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * JVM Unit Tests for [PathEngine].
+ */
+class PathEngineTest {
+
+ private val samplePath = listOf(
+ LatLngAltitude(37.7749, -122.4194, 10.0),
+ LatLngAltitude(37.7755, -122.4180, 20.0),
+ LatLngAltitude(37.7760, -122.4170, 30.0)
+ )
+
+ @Test
+ fun calculateCumulativeDistances_computesMonotonicallyIncreasingArray() {
+ val cumDist = PathEngine.calculateCumulativeDistances(samplePath)
+ assertEquals(3, cumDist.size)
+ assertEquals(0.0, cumDist[0], 0.001)
+ assertTrue(cumDist[1] > 0.0)
+ assertTrue(cumDist[2] > cumDist[1])
+ }
+
+ @Test
+ fun calculateCumulativeDistances_handlesEmpty() {
+ val empty = PathEngine.calculateCumulativeDistances(emptyList())
+ assertEquals(1, empty.size)
+ assertEquals(0.0, empty[0], 0.001)
+ }
+
+ @Test
+ fun interpolatePoint_atEndpointsAndMidpoints() {
+ val cumDist = PathEngine.calculateCumulativeDistances(samplePath)
+ val totalDist = cumDist.last()
+
+ // At start (0m)
+ val startPt = PathEngine.interpolatePoint(samplePath, cumDist, 0.0)
+ assertEquals(samplePath.first().latitude, startPt.latLng.latitude, 0.0001)
+ assertEquals(samplePath.first().longitude, startPt.latLng.longitude, 0.0001)
+ assertEquals(10.0, startPt.altitude, 0.01)
+
+ // At end (totalDist)
+ val endPt = PathEngine.interpolatePoint(samplePath, cumDist, totalDist)
+ assertEquals(samplePath.last().latitude, endPt.latLng.latitude, 0.0001)
+ assertEquals(samplePath.last().longitude, endPt.latLng.longitude, 0.0001)
+ assertEquals(30.0, endPt.altitude, 0.01)
+
+ // At midpoint
+ val midPt = PathEngine.interpolatePoint(samplePath, cumDist, totalDist * 0.5)
+ assertTrue(midPt.latLng.latitude > samplePath.first().latitude)
+ assertTrue(midPt.altitude > 10.0 && midPt.altitude < 30.0)
+ assertTrue(midPt.bearing >= 0.0 && midPt.bearing <= 360.0)
+ }
+
+ @Test
+ fun getInterpolatedLatLng_returnsBoundaryCoordinates() {
+ val cumDist = PathEngine.calculateCumulativeDistances(samplePath)
+ val totalDist = cumDist.last()
+
+ val start = PathEngine.getInterpolatedLatLng(samplePath, cumDist, -10.0)
+ assertEquals(samplePath.first().latitude, start.latitude, 0.0001)
+
+ val end = PathEngine.getInterpolatedLatLng(samplePath, cumDist, totalDist + 50.0)
+ assertEquals(samplePath.last().latitude, end.latitude, 0.0001)
+ }
+
+ @Test
+ fun smoothHeading_appliesEmaWhenPlaying() {
+ // Not playing -> returns target heading immediately
+ val initial = PathEngine.smoothHeading(90.0, currentHeading = 0.0, isUserScrubbing = false, isPlaying = false)
+ assertEquals(90.0, initial, 0.001)
+
+ // User scrubbing -> returns target heading immediately
+ val scrubbing = PathEngine.smoothHeading(90.0, currentHeading = 0.0, isUserScrubbing = true, isPlaying = true)
+ assertEquals(90.0, scrubbing, 0.001)
+
+ // Playing -> smoothed EMA step
+ val smoothed = PathEngine.smoothHeading(90.0, currentHeading = 0.0, isUserScrubbing = false, isPlaying = true, smoothingFactor = 0.5)
+ assertEquals(45.0, smoothed, 0.01)
+ }
+
+ @Test
+ fun calculateCameraAltitude_computesCorrectAltitudePerMode() {
+ // CLAMP_TO_GROUND -> returns groundAltitude
+ val clampAlt = PathEngine.calculateCameraAltitude(
+ altitudeMode = AltitudeMode.CLAMP_TO_GROUND,
+ baseAltitude = 50.0,
+ interpolatedAltitude = 15.0,
+ groundAltitude = 120.0
+ )
+ assertEquals(120.0, clampAlt, 0.001)
+
+ // ABSOLUTE -> baseAltitude + interpolatedAltitude + groundAltitude
+ val absAlt = PathEngine.calculateCameraAltitude(
+ altitudeMode = AltitudeMode.ABSOLUTE,
+ baseAltitude = 50.0,
+ interpolatedAltitude = 15.0,
+ groundAltitude = 120.0
+ )
+ assertEquals(185.0, absAlt, 0.001)
+ }
+
+ @Test
+ fun buildStaticVertices_and_buildProgressVertices() {
+ val staticVertices = PathEngine.buildStaticVertices(
+ path = samplePath,
+ altitudeMode = AltitudeMode.ABSOLUTE,
+ baseAltitude = 50.0,
+ pathAltitudeOffset = 5.0
+ )
+ assertEquals(3, staticVertices.size)
+ assertEquals(65.0, staticVertices[0].altitude, 0.01) // 10 + 50 + 5
+ assertEquals(75.0, staticVertices[1].altitude, 0.01) // 20 + 50 + 5
+
+ val cumDist = PathEngine.calculateCumulativeDistances(samplePath)
+ val interp = PathEngine.interpolatePoint(samplePath, cumDist, cumDist[1])
+ val progressVertices = PathEngine.buildProgressVertices(
+ path = samplePath,
+ cumulativeDistances = cumDist,
+ elapsedDistance = cumDist[1],
+ currentLatLng = interp.latLng,
+ waypointIndex = interp.waypointIndex,
+ altitudeMode = AltitudeMode.ABSOLUTE,
+ baseAltitude = 50.0,
+ pathAltitudeOffset = 5.0
+ )
+ assertTrue(progressVertices.size >= 2)
+ assertEquals(65.4, progressVertices[0].altitude, 0.01) // 65 + 0.4 depth bias
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathFollowingViewModelTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathFollowingViewModelTest.kt
new file mode 100644
index 00000000..894e786b
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathFollowingViewModelTest.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3d.common
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import com.google.android.gms.maps3d.model.AltitudeMode
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+/**
+ * JVM Unit Tests for [PathFollowingViewModel].
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class PathFollowingViewModelTest {
+
+ @get:Rule
+ val instantTaskExecutorRule = InstantTaskExecutorRule()
+
+ private val testDispatcher = StandardTestDispatcher()
+ private lateinit var viewModel: PathFollowingViewModel
+
+ @Before
+ fun setup() {
+ Dispatchers.setMain(testDispatcher)
+ viewModel = PathFollowingViewModel()
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun initialState_matchesDefaultRoute() {
+ val state = viewModel.currentState
+ assertEquals(PathData.URBAN_PATH, state.route)
+ assertFalse(state.isPlaying)
+ assertEquals(0.0, state.elapsedDistance, 0.001)
+ }
+
+ @Test
+ fun playPauseToggle_updatesFlowAndState() {
+ assertFalse(viewModel.currentState.isPlaying)
+ viewModel.togglePlayPause()
+ assertTrue(viewModel.currentState.isPlaying)
+ viewModel.setPlaying(false)
+ assertFalse(viewModel.currentState.isPlaying)
+ }
+
+ @Test
+ fun seekAndSkip_updatesProgress() {
+ viewModel.seekToRatio(0.5f)
+ assertEquals(0.5f, viewModel.currentState.progressRatio, 0.01f)
+
+ viewModel.skipRatio(0.10f)
+ assertEquals(0.60f, viewModel.currentState.progressRatio, 0.02f)
+
+ viewModel.skipDistance(50.0)
+ assertTrue(viewModel.currentState.elapsedDistance > 0.0)
+
+ viewModel.seekToDistance(100.0)
+ assertTrue(viewModel.currentState.elapsedDistance >= 99.0)
+ }
+
+ @Test
+ fun setters_and_gestures_updateState() {
+ viewModel.setAltitudeMode(AltitudeMode.RELATIVE_TO_GROUND)
+ assertEquals(AltitudeMode.RELATIVE_TO_GROUND, viewModel.currentState.altitudeMode)
+
+ viewModel.setDrawsOccludedSegments(true)
+ assertTrue(viewModel.currentState.drawsOccludedSegments)
+
+ viewModel.setFollowSpeed(60.0)
+ assertEquals(60.0, viewModel.currentState.followSpeedMps, 0.001)
+
+ viewModel.setCameraRange(400.0)
+ assertEquals(400.0, viewModel.currentState.cameraRange, 0.001)
+
+ viewModel.setCameraTilt(65.0)
+ assertEquals(65.0, viewModel.currentState.cameraTilt, 0.001)
+
+ viewModel.setHeadingOffset(30.0)
+ assertEquals(30.0, viewModel.currentState.headingOffset, 0.001)
+
+ viewModel.setGroundAltitude(150.0)
+ assertEquals(150.0, viewModel.currentState.groundAltitude, 0.001)
+
+ viewModel.setPathAltitudeOffset(15.0)
+ assertEquals(15.0, viewModel.currentState.pathAltitudeOffset, 0.001)
+
+ viewModel.setScrubbing(true)
+ assertTrue(viewModel.currentState.isScrubbing)
+
+ // Gesture adjustments
+ viewModel.adjustTilt(10.0)
+ assertEquals(75.0, viewModel.currentState.cameraTilt, 0.001)
+
+ viewModel.adjustHeading(15.0)
+ assertEquals(45.0, viewModel.currentState.headingOffset, 0.001)
+
+ viewModel.adjustRange(2.0)
+ assertEquals(200.0, viewModel.currentState.cameraRange, 0.001)
+
+ // Speed multipliers
+ viewModel.setSpeedBoostMultiplier(5.0)
+ assertEquals(5.0, viewModel.currentState.speedBoostMultiplier, 0.001)
+ assertTrue(viewModel.currentState.isSpeedBoosted)
+
+ viewModel.setSpeedBoosted(false)
+ assertEquals(1.0, viewModel.currentState.speedBoostMultiplier, 0.001)
+
+ // Switch route
+ viewModel.setRoute(PathData.RURAL_PATH, applyDefaults = true)
+ assertEquals(PathData.RURAL_PATH, viewModel.currentState.route)
+
+ viewModel.reset()
+ assertEquals(0.0, viewModel.currentState.elapsedDistance, 0.001)
+ }
+
+ @Test
+ fun advance_progressesPlayback() {
+ viewModel.setPlaying(true)
+ viewModel.advance(1.0)
+ assertTrue(viewModel.currentState.elapsedDistance > 0.0)
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathPlaybackControllerTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathPlaybackControllerTest.kt
new file mode 100644
index 00000000..1e0fe223
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathPlaybackControllerTest.kt
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.maps3d.common
+
+import com.google.android.gms.maps3d.model.AltitudeMode
+import com.google.android.gms.maps3d.model.LatLngAltitude
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * JVM Unit Tests for [PathPlaybackController].
+ *
+ * Verifies domain logic, kinematic progression, altitude computations, polyline slicing,
+ * custom touch gesture adjustments (tilt, heading, pinch range, speed boost), and state transitions.
+ */
+class PathPlaybackControllerTest {
+
+ private lateinit var controller: PathPlaybackController
+ private val testPath = listOf(
+ LatLngAltitude(37.7749, -122.4194, 10.0),
+ LatLngAltitude(37.7755, -122.4180, 20.0),
+ LatLngAltitude(37.7760, -122.4170, 30.0)
+ )
+
+ @Before
+ fun setup() {
+ controller = PathPlaybackController(testPath)
+ }
+
+ @Test
+ fun initialState_isConfiguredCorrectly() {
+ val state = controller.getState()
+ assertEquals(testPath, state.route)
+ assertEquals(0.0, state.elapsedDistance, 0.001)
+ assertEquals(0f, state.progressRatio, 0.001f)
+ assertFalse(state.isPlaying)
+ assertFalse(state.isScrubbing)
+ assertFalse(state.isSpeedBoosted)
+ assertEquals(testPath.first().latitude, state.currentPosition.latitude, 0.0001)
+ assertEquals(testPath.first().longitude, state.currentPosition.longitude, 0.0001)
+ assertEquals(testPath.size, state.staticPolylineVertices.size)
+ assertTrue(state.totalDistance > 0.0)
+ }
+
+ @Test
+ fun togglePlayPause_updatesState() {
+ assertFalse(controller.getState().isPlaying)
+ val playingState = controller.togglePlayPause()
+ assertTrue(playingState.isPlaying)
+ val pausedState = controller.togglePlayPause()
+ assertFalse(pausedState.isPlaying)
+ }
+
+ @Test
+ fun advance_progressesDistanceOnlyWhenPlaying() {
+ // When not playing, advance should have no effect
+ val initialDist = controller.getState().elapsedDistance
+ controller.advance(1.0)
+ assertEquals(initialDist, controller.getState().elapsedDistance, 0.001)
+
+ // When playing, advance increases elapsed distance based on speed * dt
+ controller.setPlaying(true)
+ val speed = controller.getState().followSpeedMps
+ controller.advance(1.0)
+ val advancedDist = controller.getState().elapsedDistance
+ assertEquals(speed * 1.0, advancedDist, 0.1)
+ assertTrue(controller.getState().progressRatio > 0f)
+ }
+
+ @Test
+ fun advance_loopsAroundTotalDistance() {
+ controller.setPlaying(true)
+ val totalDist = controller.getState().totalDistance
+ controller.seekToDistance(totalDist - 5.0)
+
+ // Advancing beyond total distance wraps around
+ controller.advance(1.0) // 30m step
+ assertTrue(controller.getState().elapsedDistance < totalDist)
+ }
+
+ @Test
+ fun speedBoost_supportsTieredMultipliers() {
+ controller.setPlaying(true)
+ controller.seekToDistance(0.0)
+ val speed = controller.getState().followSpeedMps
+
+ // 2x Boost
+ controller.setSpeedBoostMultiplier(2.0)
+ assertTrue(controller.getState().isSpeedBoosted)
+ assertEquals(2.0, controller.getState().speedBoostMultiplier, 0.001)
+ assertEquals(speed * 2.0, controller.getState().effectiveSpeedMps, 0.001)
+
+ // 5x Warp Boost
+ controller.setSpeedBoostMultiplier(5.0)
+ assertTrue(controller.getState().isSpeedBoosted)
+ assertEquals(5.0, controller.getState().speedBoostMultiplier, 0.001)
+ assertEquals(speed * 5.0, controller.getState().effectiveSpeedMps, 0.001)
+
+ controller.advance(1.0)
+ assertEquals(speed * 5.0, controller.getState().elapsedDistance, 0.1)
+
+ // Reset
+ controller.setSpeedBoostMultiplier(1.0)
+ assertFalse(controller.getState().isSpeedBoosted)
+ assertEquals(speed, controller.getState().effectiveSpeedMps, 0.001)
+ }
+
+ @Test
+ fun adjustTilt_modifiesCameraTiltWithinBounds() {
+ controller.setCameraTilt(60.0)
+ controller.adjustTilt(10.0)
+ assertEquals(70.0, controller.getState().cameraTilt, 0.001)
+
+ controller.adjustTilt(-80.0)
+ assertEquals(0.0, controller.getState().cameraTilt, 0.001) // Clamped to 0.0
+
+ controller.adjustTilt(100.0)
+ assertEquals(85.0, controller.getState().cameraTilt, 0.001) // Clamped to 85.0
+ }
+
+ @Test
+ fun adjustHeading_modifiesHeadingOffset() {
+ controller.setHeadingOffset(0.0)
+ controller.adjustHeading(45.0)
+ assertEquals(45.0, controller.getState().headingOffset, 0.001)
+
+ controller.adjustHeading(150.0) // 195 -> -165 normalized
+ assertEquals(-165.0, controller.getState().headingOffset, 0.001)
+ }
+
+ @Test
+ fun adjustRange_modifiesCameraRange() {
+ controller.setCameraRange(300.0)
+ controller.adjustRange(2.0) // 2x zoom in -> range 150m
+ assertEquals(150.0, controller.getState().cameraRange, 0.001)
+
+ controller.adjustRange(0.5) // zoom out -> range 300m
+ assertEquals(300.0, controller.getState().cameraRange, 0.001)
+ }
+
+ @Test
+ fun seekToRatio_updatesPositionAndPolylines() {
+ val newState = controller.seekToRatio(0.5f)
+ assertEquals(0.5f, newState.progressRatio, 0.01f)
+ assertEquals(controller.getState().totalDistance * 0.5, newState.elapsedDistance, 0.5)
+ assertTrue(newState.progressPolylineVertices.size >= 2)
+ }
+
+ @Test
+ fun setAltitudeMode_recomputesPolylineAltitudes() {
+ controller.setAltitudeMode(AltitudeMode.CLAMP_TO_GROUND)
+ val clampVertices = controller.getState().staticPolylineVertices
+ assertTrue(clampVertices.all { it.altitude == 0.0 })
+
+ controller.setAltitudeMode(AltitudeMode.ABSOLUTE)
+ val absVertices = controller.getState().staticPolylineVertices
+ assertTrue(absVertices.any { it.altitude > 0.0 })
+ }
+
+ @Test
+ fun setRoute_resetsProgressAndAppliesDefaults() {
+ controller.setPlaying(true)
+ controller.seekToRatio(0.8f)
+
+ val newRoute = PathData.RURAL_PATH
+ val state = controller.setRoute(newRoute, applyDefaults = true)
+
+ assertEquals(newRoute, state.route)
+ assertEquals(0.0, state.elapsedDistance, 0.001)
+ assertEquals(0f, state.progressRatio, 0.001f)
+ assertFalse(state.isPlaying)
+ assertEquals(450.0, state.cameraRange, 0.001)
+ assertEquals(75.0, state.cameraTilt, 0.001)
+ }
+
+ @Test
+ fun rewind_advancesBackwardsAlongPath() {
+ controller.setPlaying(true)
+ val totalDist = controller.getState().totalDistance
+ controller.seekToDistance(totalDist * 0.5)
+ val initialDist = controller.getState().elapsedDistance
+
+ // Set negative speed multiplier (-5x rewind)
+ controller.setSpeedBoostMultiplier(-5.0)
+ val speed = controller.getState().followSpeedMps
+ assertEquals(-5.0 * speed, controller.getState().effectiveSpeedMps, 0.001)
+
+ controller.advance(0.1)
+ val newDist = controller.getState().elapsedDistance
+ assertTrue(newDist < initialDist)
+ }
+
+ @Test
+ fun skipDistance_and_skipRatio_jumpAlongPath() {
+ val totalDist = controller.getState().totalDistance
+ controller.seekToDistance(totalDist * 0.5)
+
+ // Skip forward 10%
+ controller.skipRatio(0.10f)
+ assertEquals(totalDist * 0.60, controller.getState().elapsedDistance, 0.5)
+
+ // Skip backward 20%
+ controller.skipRatio(-0.20f)
+ assertEquals(totalDist * 0.40, controller.getState().elapsedDistance, 0.5)
+ }
+}
\ No newline at end of file
diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/StationaryCameraTrackerTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/StationaryCameraTrackerTest.kt
new file mode 100644
index 00000000..bf5cb1ae
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/StationaryCameraTrackerTest.kt
@@ -0,0 +1,132 @@
+/*
+ * 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.LatLngAltitude
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * Unit test suite verifying [StationaryCameraTracker] mathematical precision and eye spatial invariance.
+ */
+class StationaryCameraTrackerTest {
+
+ private lateinit var tracker: StationaryCameraTracker
+ private val startLocation = LatLngAltitude(37.8199, -122.4783, 250.0) // Golden Gate Bridge
+ private val initialHeading = 106.2
+ private val initialTilt = 35.0
+ private val initialRange = 2800.0
+
+ @Before
+ fun setUp() {
+ tracker = StationaryCameraTracker(
+ referenceCenter = startLocation,
+ initialHeading = initialHeading,
+ initialTilt = initialTilt,
+ initialRange = initialRange
+ )
+ }
+
+ @Test
+ fun initialPose_matchesInitialCameraParametersExactly() {
+ val initialCam = tracker.computeTrackingCamera(startLocation)
+
+ assertThat(initialCam.heading).isWithin(0.001).of(initialHeading)
+ assertThat(initialCam.tilt).isWithin(0.001).of(initialTilt)
+ assertThat(initialCam.range).isWithin(0.001).of(initialRange)
+ assertThat(initialCam.center.latitude).isWithin(1e-6).of(startLocation.latitude)
+ assertThat(initialCam.center.longitude).isWithin(1e-6).of(startLocation.longitude)
+ assertThat(initialCam.center.altitude).isWithin(0.001).of(startLocation.altitude)
+ }
+
+ @Test
+ fun eyePosition_remainsInvariantAlongEntireFlightPath() {
+ val expectedEye = tracker.fixedEyePosition
+
+ for (waypoint in TourData.AIRPLANE_FLIGHT_PATH) {
+ val target = LatLngAltitude(waypoint.latitude, waypoint.longitude, 250.0)
+ val trackingCam = tracker.computeTrackingCamera(target)
+
+ // Reconstruct the physical eye position from the newly generated camera
+ val reconstructedEye = tracker.reconstructEyePosition(trackingCam)
+
+ // Assert that the physical camera eye has not moved in 3D space
+ val eyeDrift = reconstructedEye.distanceTo(expectedEye)
+ assertThat(eyeDrift).isLessThan(0.05) // Drift less than 5cm across multiple kilometers
+ }
+ }
+
+ @Test
+ fun range_monotonicallyIncreasesAsTargetFliesAway() {
+ var lastRange = 0.0
+
+ for (waypoint in TourData.AIRPLANE_FLIGHT_PATH) {
+ val target = LatLngAltitude(waypoint.latitude, waypoint.longitude, 250.0)
+ val trackingCam = tracker.computeTrackingCamera(target)
+
+ assertThat(trackingCam.range).isAtLeast(lastRange)
+ lastRange = trackingCam.range ?: 0.0
+ }
+
+ // Final range at Coit Tower should be substantially larger than the initial 2800m
+ val finalTarget = LatLngAltitude(TourData.COIT_TOWER.latitude, TourData.COIT_TOWER.longitude, 250.0)
+ val finalCam = tracker.computeTrackingCamera(finalTarget)
+ assertThat(finalCam.range).isGreaterThan(7000.0)
+ }
+
+ @Test
+ fun tilt_remainsWithinValidBounds() {
+ for (waypoint in TourData.AIRPLANE_FLIGHT_PATH) {
+ val target = LatLngAltitude(waypoint.latitude, waypoint.longitude, 250.0)
+ val trackingCam = tracker.computeTrackingCamera(target)
+
+ assertThat(trackingCam.tilt).isAtLeast(0.0)
+ assertThat(trackingCam.tilt).isAtMost(90.0)
+ }
+ }
+
+ @Test
+ fun altitudeVariations_correctlyAdjustRangeAndTilt() {
+ val expectedEye = tracker.fixedEyePosition
+
+ val testAltitudes = listOf(50.0, 150.0, 250.0, 500.0, 1000.0, 1500.0)
+ for (alt in testAltitudes) {
+ val target = LatLngAltitude(TourData.AIRPLANE_FLIGHT_PATH[5].latitude, TourData.AIRPLANE_FLIGHT_PATH[5].longitude, alt)
+ val trackingCam = tracker.computeTrackingCamera(target)
+
+ val reconstructedEye = tracker.reconstructEyePosition(trackingCam)
+ val eyeDrift = reconstructedEye.distanceTo(expectedEye)
+ assertThat(eyeDrift).isLessThan(0.05)
+ }
+ }
+
+ @Test
+ fun factoryMethod_createsEquivalentTracker() {
+ val initialCam = tracker.computeTrackingCamera(startLocation)
+ val fromFactory = StationaryCameraTracker.fromInitialCamera(initialCam)
+
+ val target = LatLngAltitude(TourData.COIT_TOWER.latitude, TourData.COIT_TOWER.longitude, 250.0)
+ val cam1 = tracker.computeTrackingCamera(target)
+ val cam2 = fromFactory.computeTrackingCamera(target)
+
+ assertThat(cam1.heading ?: 0.0).isWithin(0.001).of(cam2.heading ?: 0.0)
+ assertThat(cam1.tilt ?: 0.0).isWithin(0.001).of(cam2.tilt ?: 0.0)
+ assertThat(cam1.range ?: 0.0).isWithin(0.001).of(cam2.range ?: 0.0)
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/TourPlaybackControllerTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/TourPlaybackControllerTest.kt
new file mode 100644
index 00000000..36a509b5
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/TourPlaybackControllerTest.kt
@@ -0,0 +1,141 @@
+/*
+ * 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 org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * JVM Unit Tests for [TourPlaybackController].
+ */
+class TourPlaybackControllerTest {
+
+ private lateinit var controller: TourPlaybackController
+
+ @Before
+ fun setup() {
+ controller = TourPlaybackController()
+ }
+
+ @Test
+ fun initialState_isConfiguredCorrectly() {
+ val state = controller.getState()
+ assertEquals(AnimationApproach.DISPATCHER_FRAME_LOOP, state.selectedApproach)
+ assertFalse(state.isPlaying)
+ assertFalse(state.isFinished)
+ assertEquals(0.0, state.elapsedDistance, 0.001)
+ assertEquals(0f, state.progressRatio, 0.001f)
+ assertEquals(TourData.AIRPLANE_FLIGHT_PATH.first(), state.airplanePosition)
+ assertEquals(TourData.AIRPLANE_FLIGHT_PATH.first(), state.cameraCenter)
+ assertEquals(200.0, state.airplaneAltitude, 0.001)
+ assertEquals(200.0, state.cameraAltitude, 0.001)
+ assertEquals(65.0, state.cameraTilt, 0.001)
+ assertEquals(600.0, state.cameraRange, 0.001)
+ }
+
+ @Test
+ fun setApproach_updatesStateAndResets() {
+ controller.setApproach(AnimationApproach.KEYFRAME_TOUR)
+ var state = controller.getState()
+ assertEquals(AnimationApproach.KEYFRAME_TOUR, state.selectedApproach)
+ assertFalse(state.isPlaying)
+
+ controller.setApproach(AnimationApproach.ORBIT_360_SPIN)
+ state = controller.getState()
+ assertEquals(AnimationApproach.ORBIT_360_SPIN, state.selectedApproach)
+ }
+
+ @Test
+ fun playPause_togglesCorrectly() {
+ assertFalse(controller.getState().isPlaying)
+ controller.togglePlayPause()
+ assertTrue(controller.getState().isPlaying)
+ controller.setPlaying(false)
+ assertFalse(controller.getState().isPlaying)
+ }
+
+ @Test
+ fun frameDispatcher_advancesAlongPath() {
+ controller.setPlaying(true)
+ val initialDist = controller.getState().elapsedDistance
+
+ // Advance 1 second at 400 m/s
+ controller.advanceFrameDispatcher(1.0, speedMps = 400.0)
+ val state = controller.getState()
+
+ assertEquals(initialDist + 400.0, state.elapsedDistance, 0.5)
+ assertTrue(state.progressRatio > 0f)
+ assertTrue(state.airplanePosition.longitude > TourData.AIRPLANE_FLIGHT_PATH.first().longitude)
+ }
+
+ @Test
+ fun frameDispatcher_completesAtDestination() {
+ controller.setPlaying(true)
+ val totalDist = controller.totalFlightDistance
+
+ // Advance 100 seconds to exceed total distance
+ controller.advanceFrameDispatcher(100.0, speedMps = 400.0)
+ val state = controller.getState()
+
+ assertEquals(totalDist, state.elapsedDistance, 0.001)
+ assertEquals(1.0f, state.progressRatio, 0.001f)
+ assertTrue(state.isFinished)
+ assertFalse(state.isPlaying)
+ }
+
+ @Test
+ fun continuousOrbit_advancesHeading() {
+ controller.setPlaying(true)
+ val initialHeading = controller.getState().cameraHeading
+
+ // Advance 2 seconds at 30 deg/sec = +60 deg
+ controller.advanceContinuousOrbit(2.0, speedDegPerSec = 30.0)
+ val newHeading = controller.getState().cameraHeading
+
+ val expected = (initialHeading + 60.0) % 360.0
+ assertEquals(expected, newHeading, 0.01)
+ }
+
+ @Test
+ fun keyframeFlyAround_containsExpectedProperties() {
+ val flyAroundStep = CameraKeyframe.FlyAround(
+ stepTitle = "Orbit",
+ stepDescription = "Desc",
+ centerCamera = TourData.CLOSE_INSPECTION_CAMERA,
+ rounds = 1.0,
+ durationMs = 6000L
+ )
+
+ assertEquals("Orbit", flyAroundStep.stepTitle)
+ assertEquals("Desc", flyAroundStep.stepDescription)
+ assertEquals(1.0, flyAroundStep.rounds, 0.001)
+ assertEquals(6000L, flyAroundStep.durationMs)
+ }
+
+ @Test
+ fun mathUtilities_workAsExpected() {
+ assertEquals(45.0, TourPlaybackController.normalizeHeading(405.0), 0.001)
+ assertEquals(315.0, TourPlaybackController.normalizeHeading(-45.0), 0.001)
+
+ val angle = TourPlaybackController.interpolateAngle(350.0, 10.0, 0.5)
+ assertEquals(0.0, angle, 0.001)
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/WorldControllerTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/WorldControllerTest.kt
new file mode 100644
index 00000000..43108fba
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/WorldControllerTest.kt
@@ -0,0 +1,134 @@
+/*
+ * 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.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * JVM Unit Tests for [WorldController] using Google Truth.
+ */
+class WorldControllerTest {
+
+ private lateinit var controller: WorldController
+
+ @Before
+ fun setup() {
+ controller = WorldController()
+ }
+
+ @Test
+ fun initialState_isConfiguredCorrectly() {
+ val state = controller.getState()
+ assertThat(state.executionState).isEqualTo(AnimationExecutionState.IDLE)
+ assertThat(state.isPlaying).isFalse()
+ assertThat(state.isFinished).isFalse()
+ assertThat(state.entities).containsKey(TourData.AIRPLANE_MODEL_ID)
+
+ val planePose = state.getEntityPose(TourData.AIRPLANE_MODEL_ID)
+ assertThat(planePose).isNotNull()
+ assertThat(planePose!!.position.latitude).isWithin(0.001).of(TourData.AIRPLANE_FLIGHT_PATH.first().latitude)
+ }
+
+ @Test
+ fun simpleFlyTo_withMidpointJump_teleportsPlaneAtHalfDuration() {
+ controller.setApproach(AnimationApproach.SIMPLE_FLY_TO)
+ controller.setSimpleFlyToMode(SimpleFlyToMode.MIDPOINT_JUMP)
+ val playState = controller.play()
+
+ assertThat(playState.executionState).isEqualTo(AnimationExecutionState.RUNNING)
+ assertThat(playState.pendingCameraCommand).isInstanceOf(CameraAnimationCommand.NativeFlyTo::class.java)
+
+ // Advance 1 second (1000ms < 2500ms midpoint) -> Plane stays at start
+ val state1s = controller.tick(1.0)
+ val pose1s = state1s.getEntityPose(TourData.AIRPLANE_MODEL_ID)!!
+ assertThat(pose1s.position.latitude).isWithin(0.001).of(TourData.AIRPLANE_FLIGHT_PATH.first().latitude)
+
+ // Advance another 2 seconds (total 3000ms > 2500ms midpoint) -> Plane teleports to destination
+ val state3s = controller.tick(2.0)
+ val pose3s = state3s.getEntityPose(TourData.AIRPLANE_MODEL_ID)!!
+ assertThat(pose3s.position.latitude).isWithin(0.001).of(TourData.AIRPLANE_FLIGHT_PATH.last().latitude)
+ }
+
+ @Test
+ fun simpleFlyTo_withSynchronizedFlight_animatesPlaneContinuously() {
+ controller.setApproach(AnimationApproach.SIMPLE_FLY_TO)
+ controller.setSimpleFlyToMode(SimpleFlyToMode.SYNCHRONIZED_FLIGHT)
+ controller.play()
+
+ // Advance 2.5 seconds (50% of 5.0s)
+ val state2_5s = controller.tick(2.5)
+ val pose2_5s = state2_5s.getEntityPose(TourData.AIRPLANE_MODEL_ID)!!
+
+ // Plane should be in intermediate position between source and destination
+ assertThat(pose2_5s.position.longitude).isGreaterThan(TourData.AIRPLANE_FLIGHT_PATH.first().longitude)
+ assertThat(pose2_5s.position.longitude).isLessThan(TourData.AIRPLANE_FLIGHT_PATH.last().longitude)
+ assertThat(state2_5s.progressRatio).isWithin(0.05f).of(0.5f)
+
+ // Advance to 5.0s total -> Completes
+ val state5s = controller.tick(2.5)
+ assertThat(state5s.isFinished).isTrue()
+ assertThat(state5s.progressRatio).isEqualTo(1.0f)
+ }
+
+ @Test
+ fun dispatcherFrameLoop_updatesBothCameraAndPlaneEntities() {
+ controller.setApproach(AnimationApproach.DISPATCHER_FRAME_LOOP)
+ controller.play()
+
+ val state1s = controller.tick(1.0)
+ val planePose = state1s.getEntityPose(TourData.AIRPLANE_MODEL_ID)!!
+ val cameraCenter = state1s.camera.center
+
+ // In dispatcher loop, camera is locked to plane position
+ assertThat(cameraCenter.latitude).isWithin(0.0001).of(planePose.position.latitude)
+ assertThat(cameraCenter.longitude).isWithin(0.0001).of(planePose.position.longitude)
+ }
+
+ @Test
+ fun continuousOrbit_advancesCameraHeading() {
+ controller.setApproach(AnimationApproach.ORBIT_360_SPIN)
+ controller.play()
+
+ val initialHeading = controller.getState().camera.heading ?: 105.0
+ val state2s = controller.tick(2.0)
+ val newHeading = state2s.camera.heading ?: 0.0
+
+ // 2s * 25 deg/s = +50 deg
+ val expectedHeading = (initialHeading + 50.0) % 360.0
+ assertThat(newHeading).isWithin(0.1).of(expectedHeading)
+ }
+
+ @Test
+ fun pauseAndReset_resetsStateCleanly() {
+ controller.setApproach(AnimationApproach.DISPATCHER_FRAME_LOOP)
+ controller.play()
+ controller.tick(2.0)
+
+ controller.pause()
+ assertThat(controller.getState().executionState).isEqualTo(AnimationExecutionState.PAUSED)
+ assertThat(controller.getState().pendingCameraCommand).isEqualTo(CameraAnimationCommand.StopCameraAnimation)
+
+ controller.reset()
+ val resetState = controller.getState()
+ assertThat(resetState.executionState).isEqualTo(AnimationExecutionState.IDLE)
+ assertThat(resetState.elapsedTimeMs).isEqualTo(0L)
+ assertThat(resetState.progressRatio).isEqualTo(0f)
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/README.md b/Maps3DSamples/ApiDemos/java-app/README.md
index 3c58d619..83e01eb7 100644
--- a/Maps3DSamples/ApiDemos/java-app/README.md
+++ b/Maps3DSamples/ApiDemos/java-app/README.md
@@ -17,17 +17,17 @@ This directory contains the Java samples using traditional Android Views for the
| **Camera Restrictions** | 🚧 Skeleton | [CameraRestrictionsActivity.java](src/main/java/com/example/maps3djava/camerarestrictions/CameraRestrictionsActivity.java) | |
| **Flight Simulator** | 🚧 Skeleton | [FlightSimulatorActivity.java](src/main/java/com/example/maps3djava/flightsimulator/FlightSimulatorActivity.java) | |
| **Routes API** | ✅ Done | [RoutesActivity.java](src/main/java/com/example/maps3djava/routes/RoutesActivity.java) | |
-| **Path Following** | 🚧 Skeleton | [PathFollowingActivity.java](src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java) | |
+| **Path Following** | ✅ Done | [PathFollowingActivity.java](src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java) | |
| **Path Styling** | 🚧 Skeleton | [PathStylingActivity.java](src/main/java/com/example/maps3djava/pathstyling/PathStylingActivity.java) | |
| **Animating Models** | 🚧 Skeleton | [AnimatingModelsActivity.java](src/main/java/com/example/maps3djava/animatingmodels/AnimatingModelsActivity.java) | |
| **Place Search** | 🚧 Skeleton | [PlaceSearchActivity.java](src/main/java/com/example/maps3djava/placesearch/PlaceSearchActivity.java) | |
| **Place Autocomplete** | 🚧 Skeleton | [PlaceAutocompleteActivity.java](src/main/java/com/example/maps3djava/placeautocomplete/PlaceAutocompleteActivity.java) | |
| **Place Details** | 🚧 Skeleton | [PlaceDetailsActivity.java](src/main/java/com/example/maps3djava/placedetails/PlaceDetailsActivity.java) | |
-| **Advanced Camera Animation** | 🚧 Skeleton | [AdvancedCameraAnimationActivity.java](src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java) | |
-| **Data Visualization** | 🚧 Skeleton | [DataVisualizationActivity.java](src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java) | |
-| **Cloud Map Styling** | 🚧 Skeleton | [CloudStylingActivity.java](src/main/java/com/example/maps3djava/cloudstyling/CloudStylingActivity.java) | |
-| **Roadmap Mode** | 🚧 Skeleton | [RoadmapModeActivity.java](src/main/java/com/example/maps3djava/roadmapmode/RoadmapModeActivity.java) | |
-| **Field Of View** | 🚧 Skeleton | [FieldOfViewActivity.java](src/main/java/com/example/maps3djava/fieldofview/FieldOfViewActivity.java) | |
+| **Advanced Camera Animation** | ✅ Done | [AdvancedCameraAnimationActivity.java](src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java) | |
+| **Data Visualization** | ✅ Done | [DataVisualizationActivity.java](src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java) | |
+| **Cloud Map Styling** | ✅ Done | [CloudStylingActivity.java](src/main/java/com/example/maps3djava/cloudstyling/CloudStylingActivity.java) | |
+| **Roadmap Mode** | ✅ Done | [RoadmapModeActivity.java](src/main/java/com/example/maps3djava/roadmapmode/RoadmapModeActivity.java) | |
+| **Field Of View** | ✅ Done | [FieldOfViewActivity.java](src/main/java/com/example/maps3djava/fieldofview/FieldOfViewActivity.java) | |
---
> [!NOTE]
diff --git a/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/AdvancedCameraAnimationVisualTest.java b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/AdvancedCameraAnimationVisualTest.java
new file mode 100644
index 00000000..25b5c70c
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/AdvancedCameraAnimationVisualTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.maps3djava;
+
+import static org.junit.Assert.assertTrue;
+
+import android.content.Intent;
+import android.graphics.Bitmap;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.uiautomator.By;
+import androidx.test.uiautomator.Until;
+
+import com.example.maps3djava.advancedcameraanimation.AdvancedCameraAnimationActivity;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * A premium visual regression test for the View-based Java Advanced Camera Animation sample.
+ *
+ * Demonstrates robust programmatic testing of 3D camera animations and glTF models by launching the Java-based
+ * [AdvancedCameraAnimationActivity], waiting for 3D map tiles, the 3D airplane glTF model, and camera tour approaches
+ * to render, capturing a screenshot of the active map scene, and verifying visual correctness using the Gemini API.
+ */
+@RunWith(AndroidJUnit4.class)
+public class AdvancedCameraAnimationVisualTest extends BaseVisualTest {
+
+ @Test
+ public void verifyAdvancedCameraAnimationRenders() {
+ // Launch AdvancedCameraAnimationActivity
+ Intent intent = new Intent(context, AdvancedCameraAnimationActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(intent);
+
+ // Wait for the activity to be displayed in the foreground
+ uiDevice.wait(Until.hasObject(By.pkg(context.getPackageName()).depth(0)), 10000);
+
+ // Wait for map tiles to load, 3D airplane model to stream, and camera flight animation to settle
+ waitForMapRendering(15);
+
+ // Capture high-resolution screenshot of the active 3D map scene
+ Bitmap screenshotBitmap = captureScreenshot("advanced_camera_animation_screenshot.png");
+
+ // Define the verification prompt for the visual testing agent
+ String prompt = "Please act as a UI tester and analyze this screenshot.\n" +
+ "1. Confirm that a 3D map view is visible over San Francisco (Golden Gate Bridge / Bay area).\n" +
+ "2. Confirm that a 3D AIRPLANE MODEL or aerial flight path object is visible in 3D space.\n" +
+ "3. Confirm that the animation approach control card (with radio options for Simple flyTo, Keyframe Tour, Frame Dispatcher, 360 Orbit Spin, and Play/Reset buttons) is visible at the bottom of the screen.\n" +
+ "\n" +
+ "If and ONLY IF you can clearly see the 3D map scene, 3D airplane flight tour, and bottom animation approach selector card, reply with \"PASSED\".\n" +
+ "If you cannot see the 3D map scene or control card, reply with \"FAILED: 3D map scene or control card not visible\".\n" +
+ "Report what you see in detail.";
+
+ // Analyze the image using Gemini (using blocking wrapper)
+ String geminiResponse = helper.analyzeImageBlocking(screenshotBitmap, prompt, geminiApiKey);
+ System.out.println("Gemini's analysis: " + geminiResponse);
+
+ // Assert on Gemini's response
+ assertTrue(
+ "Visual verification failed. Gemini response: " + geminiResponse,
+ geminiResponse != null && geminiResponse.toUpperCase().contains("PASSED")
+ );
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/BaseVisualTest.java b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/BaseVisualTest.java
index 60f54cce..16757a45 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/BaseVisualTest.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/BaseVisualTest.java
@@ -93,12 +93,10 @@ protected Bitmap captureScreenshot() {
* @param timeoutSeconds The maximum time to wait in seconds.
*/
protected void waitForMapRendering(long timeoutSeconds) {
- // Fallback to sleep since View-based samples do not set "MapSteady" content description.
- System.out.println("Sleeping for " + timeoutSeconds + " seconds to allow map to render...");
- try {
- Thread.sleep(timeoutSeconds * 1000);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
+ android.util.Log.i("BaseVisualTest", "Waiting up to " + timeoutSeconds + "s for map to become steady...");
+ boolean steady = uiDevice.wait(Until.hasObject(By.desc("MapSteady")), timeoutSeconds * 1000);
+ if (!steady) {
+ android.util.Log.w("BaseVisualTest", "Map did not report MapSteady within " + timeoutSeconds + "s; proceeding with capture.");
}
}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/DataVisualizationVisualTest.java b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/DataVisualizationVisualTest.java
new file mode 100644
index 00000000..a2f00a72
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/DataVisualizationVisualTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.maps3djava;
+
+import static org.junit.Assert.assertTrue;
+
+import android.content.Intent;
+import android.graphics.Bitmap;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.uiautomator.By;
+import androidx.test.uiautomator.Until;
+
+import com.example.maps3djava.datavisualization.DataVisualizationActivity;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * A premium visual regression test for the View-based Java 3D Data Visualization sample.
+ *
+ * Demonstrates robust programmatic testing of dynamic 3D extruded volume polygons by launching the Java-based
+ * [DataVisualizationActivity], waiting for 3D map tiles, the extruded flood volume polygon over the San Francisco
+ * waterfront, and control panel widgets to render, capturing a screenshot of the active map scene, and verifying visual
+ * correctness using the Gemini API.
+ */
+@RunWith(AndroidJUnit4.class)
+public class DataVisualizationVisualTest extends BaseVisualTest {
+
+ @Test
+ public void verifyDataVisualizationRenders() {
+ // Launch DataVisualizationActivity
+ Intent intent = new Intent(context, DataVisualizationActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(intent);
+
+ // Wait for the activity to be displayed in the foreground
+ uiDevice.wait(Until.hasObject(By.pkg(context.getPackageName()).depth(0)), 10000);
+
+ // Wait for map tiles to load, 3D flood polygon to extrude, and UI controls to settle
+ waitForMapRendering(15);
+
+ // Capture high-resolution screenshot of the active 3D map scene
+ Bitmap screenshotBitmap = captureScreenshot("data_visualization_screenshot.png");
+
+ // Define the verification prompt for the visual testing agent
+ String prompt = "Please act as a UI tester and analyze this screenshot.\n" +
+ "1. Confirm that a 3D map view is visible over the San Francisco waterfront.\n" +
+ "2. Confirm that an EXTRUDED 3D POLYGON or volumetric polygon shape (red tinted flood water zone) is clearly visible on top of the 3D terrain/map.\n" +
+ "3. Confirm that the Data Visualization control card (showing Flood Elevation label, Risk Badge status, elevation slider, and Start/Stop Simulation button) is visible at the bottom of the screen.\n" +
+ "\n" +
+ "If and ONLY IF you can clearly see the 3D map view with the extruded red flood polygon and bottom simulation control panel, reply with \"PASSED\".\n" +
+ "If you cannot see the extruded flood polygon or control panel, reply with \"FAILED: Extruded flood polygon or control panel not visible\".\n" +
+ "Report what you see in detail.";
+
+ // Analyze the image using Gemini (using blocking wrapper)
+ String geminiResponse = helper.analyzeImageBlocking(screenshotBitmap, prompt, geminiApiKey);
+ System.out.println("Gemini's analysis: " + geminiResponse);
+
+ // Assert on Gemini's response
+ assertTrue(
+ "Visual verification failed. Gemini response: " + geminiResponse,
+ geminiResponse != null && geminiResponse.toUpperCase().contains("PASSED")
+ );
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/FieldOfViewVisualTest.java b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/FieldOfViewVisualTest.java
new file mode 100644
index 00000000..77f4d16c
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/FieldOfViewVisualTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.maps3djava;
+
+import static org.junit.Assert.assertTrue;
+
+import android.content.Intent;
+import android.graphics.Bitmap;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.uiautomator.By;
+import androidx.test.uiautomator.Until;
+
+import com.example.maps3djava.fieldofview.FieldOfViewActivity;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * A premium visual regression test for the View-based Java Field of View (FOV) sample.
+ *
+ * Demonstrates robust programmatic testing of optical dolly-zoom camera controls by launching the Java-based
+ * [FieldOfViewActivity], waiting for 3D map tiles over the San Francisco Financial District, perspective dolly-zoom
+ * camera controls, and quick FOV preset buttons to load, capturing a screenshot of the active map scene, and verifying
+ * visual correctness using the Gemini API.
+ */
+@RunWith(AndroidJUnit4.class)
+public class FieldOfViewVisualTest extends BaseVisualTest {
+
+ @Test
+ public void verifyFieldOfViewRenders() {
+ // Launch FieldOfViewActivity
+ Intent intent = new Intent(context, FieldOfViewActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(intent);
+
+ // Wait for the activity to be displayed in the foreground
+ uiDevice.wait(Until.hasObject(By.pkg(context.getPackageName()).depth(0)), 10000);
+
+ // Wait for map tiles to render and optical dolly zoom view to settle
+ waitForMapRendering(15);
+
+ // Capture high-resolution screenshot of the active 3D map scene
+ Bitmap screenshotBitmap = captureScreenshot("field_of_view_screenshot.png");
+
+ // Define the verification prompt for the visual testing agent
+ String prompt = "Please act as a UI tester and analyze this screenshot.\n" +
+ "1. Confirm that a 3D map view is visible over the San Francisco Financial District area.\n" +
+ "2. Confirm that the Field of View perspective control card (with FOV angle slider and instant preset buttons: 20° Telephoto, 45° Standard, 90° Wide, 120° Ultra-Wide) is visible at the bottom of the screen.\n" +
+ "3. Confirm that the visual text label showing the current FOV angle (e.g. \"Field of View: 45°\") is visible on the card.\n" +
+ "\n" +
+ "If and ONLY IF you can clearly see the 3D map view and bottom Field of View control card with presets and slider, reply with \"PASSED\".\n" +
+ "If you cannot see the 3D map scene or FOV control card, reply with \"FAILED: 3D map scene or FOV controls not visible\".\n" +
+ "Report what you see in detail.";
+
+ // Analyze the image using Gemini (using blocking wrapper)
+ String geminiResponse = helper.analyzeImageBlocking(screenshotBitmap, prompt, geminiApiKey);
+ System.out.println("Gemini's analysis: " + geminiResponse);
+
+ // Assert on Gemini's response
+ assertTrue(
+ "Visual verification failed. Gemini response: " + geminiResponse,
+ geminiResponse != null && geminiResponse.toUpperCase().contains("PASSED")
+ );
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/PathFollowingVisualTest.java b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/PathFollowingVisualTest.java
new file mode 100644
index 00000000..2fabd18b
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/PathFollowingVisualTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.maps3djava;
+
+import static org.junit.Assert.assertTrue;
+
+import android.content.Intent;
+import android.graphics.Bitmap;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.uiautomator.By;
+import androidx.test.uiautomator.Until;
+
+import com.example.maps3djava.pathfollowing.PathFollowingActivity;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * A premium visual regression test for the View-based Java Path Following sample.
+ * Demonstrates robust programmatic testing of ground-level route navigation by launching the Java-based
+ * [PathFollowingActivity], waiting for 3D map tiles and path coordinates to render, allowing the Handler-driven
+ * play loop to animate the camera along the route, capturing a screenshot of the active map scene, and verifying
+ * visual correctness using the Gemini API.
+ */
+@RunWith(AndroidJUnit4.class)
+public class PathFollowingVisualTest extends BaseVisualTest {
+
+ @Test
+ public void verifyPathFollowingRenders() {
+ // Launch PathFollowingActivity
+ Intent intent = new Intent(context, PathFollowingActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(intent);
+
+ // Wait for the activity to be displayed in the foreground
+ uiDevice.wait(Until.hasObject(By.pkg(context.getPackageName()).depth(0)), 10000);
+
+ // Wait for map tiles to load, ground polyline to render, and initial animation to settle
+ waitForMapRendering(15);
+
+ // Capture high-resolution screenshot of the active 3D map scene
+ Bitmap screenshotBitmap = captureScreenshot("path_following_screenshot.png");
+
+ // Define the verification prompt for the visual testing agent
+ String prompt = "Please act as a UI tester and analyze this screenshot.\n" +
+ "1. Confirm that a 3D map view is visible.\n" +
+ "2. Confirm that a BLUE POLYLINE (line) is clearly visible on the map, representing the route path.\n" +
+ "3. Confirm that interactive camera control sliders (Range, Altitude, Heading, Tilt, Speed) and an Urban/Rural selector panel are visible at the bottom of the screen.\n" +
+ "\n" +
+ "If and ONLY IF you can clearly see the 3D map view with the blue route polyline and bottom control card, reply with \"PASSED\".\n" +
+ "If you cannot see the blue route polyline or control panel, reply with \"FAILED: Blue path polyline or UI controls not visible\".\n" +
+ "Report what you see in detail.";
+
+ // Analyze the image using Gemini (using blocking wrapper)
+ String geminiResponse = helper.analyzeImageBlocking(screenshotBitmap, prompt, geminiApiKey);
+ System.out.println("Gemini's analysis: " + geminiResponse);
+
+ // Assert on Gemini's response
+ assertTrue(
+ "Visual verification failed. Gemini response: " + geminiResponse,
+ geminiResponse != null && geminiResponse.toUpperCase().contains("PASSED")
+ );
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/RoadmapModeVisualTest.java b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/RoadmapModeVisualTest.java
new file mode 100644
index 00000000..a42602ef
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/RoadmapModeVisualTest.java
@@ -0,0 +1,84 @@
+/*
+ * 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.maps3djava;
+
+import static org.junit.Assert.assertTrue;
+
+import android.content.Intent;
+import android.graphics.Bitmap;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.uiautomator.By;
+import androidx.test.uiautomator.Until;
+
+import com.example.maps3djava.roadmapmode.RoadmapModeActivity;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * A premium visual regression test for the View-based Java 3D Roadmap Mode sample.
+
+ * Demonstrates robust programmatic testing of 3D vector map modes by launching the Java-based
+ * [RoadmapModeActivity], waiting for 3D vector roadmap tiles and street networks to initialize,
+ * capturing a screenshot of the active map scene, and verifying visual correctness using the Gemini
+ * API.
+ */
+@RunWith(AndroidJUnit4.class)
+public class RoadmapModeVisualTest extends BaseVisualTest {
+
+ @Test
+ public void verifyRoadmapModeRenders() {
+ // Launch RoadmapModeActivity
+ Intent intent = new Intent(context, RoadmapModeActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(intent);
+
+ // Wait for the activity to be displayed in the foreground
+ uiDevice.wait(Until.hasObject(By.pkg(context.getPackageName()).depth(0)), 10000);
+
+ // Wait for map tiles to render and 3D vector buildings/streets to settle
+ waitForMapRendering(15);
+
+ // Capture high-resolution screenshot of the active 3D map scene
+ Bitmap screenshotBitmap = captureScreenshot("roadmap_mode_screenshot.png");
+
+ // Define the verification prompt for the visual testing agent
+ String prompt = "Please act as a UI tester and analyze this screenshot.\n" +
+ "1. Confirm that a 3D map view is visible over San Francisco.\n" +
+ "2. Confirm that the map is rendered in 3D ROADMAP / VECTOR mode (showing street network layouts, road labels, and/or vector 3D building blocks rather than raw satellite imagery only).\n"
+ +
+ "3. Confirm that the Map Mode radio selection card (with Roadmap, Hybrid, and Satellite options) is visible at the bottom of the screen.\n"
+ +
+ "\n" +
+ "If and ONLY IF you can clearly see the 3D Roadmap map scene and bottom Map Mode selection card, reply with \"PASSED\".\n"
+ +
+ "If you cannot see the 3D map scene or control card, reply with \"FAILED: 3D Roadmap scene or Map Mode controls not visible\".\n"
+ +
+ "Report what you see in detail.";
+
+ // Analyze the image using Gemini (using blocking wrapper)
+ String geminiResponse = helper.analyzeImageBlocking(screenshotBitmap, prompt, geminiApiKey);
+ System.out.println("Gemini's analysis: " + geminiResponse);
+
+ // Assert on Gemini's response
+ assertTrue(
+ "Visual verification failed. Gemini response: " + geminiResponse,
+ geminiResponse != null && geminiResponse.toUpperCase().contains("PASSED")
+ );
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/RoutesVisualTest.java b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/RoutesVisualTest.java
index 77922fdc..4614d0aa 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/RoutesVisualTest.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/RoutesVisualTest.java
@@ -51,13 +51,8 @@ public void verifyRoutesRenders() {
// Wait for the activity to be displayed in the foreground
uiDevice.wait(Until.hasObject(By.pkg(context.getPackageName()).depth(0)), 10000);
- // Wait 15 seconds for map tiles to load, route coordinates to fetch, and the vehicle model to start animating
- System.out.println("Waiting 15 seconds for map rendering and vehicle animation...");
- try {
- Thread.sleep(15000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
+ // Wait for map tiles to load, route coordinates to fetch, and the vehicle model to start animating
+ waitForMapRendering(15);
// Capture high-resolution screenshot of the active 3D map scene
Bitmap screenshotBitmap = captureScreenshot("routes_screenshot.png");
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java
index fa422f98..fbd71379 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java
@@ -16,15 +16,578 @@
package com.example.maps3djava.advancedcameraanimation;
-import com.example.maps3dcommon.R;
-import com.example.maps3djava.common.BaseSkeletonActivity;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Choreographer;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.cardview.widget.CardView;
+import androidx.lifecycle.ViewModelProvider;
+import com.example.maps3d.common.AdvancedCameraAnimationViewModel;
+import com.example.maps3d.common.AnimationApproach;
+import com.example.maps3d.common.CameraKeyframe;
+import com.example.maps3d.common.EntityPose;
+import com.example.maps3d.common.HtmlUtils;
+import com.example.maps3d.common.Map3DModelEntity;
+import com.example.maps3d.common.SimpleFlyToMode;
+import com.example.maps3d.common.TourData;
+import com.example.maps3d.common.StationaryCameraTracker;
+import com.example.maps3d.common.TrajectoryFlightAnimator;
+import com.example.maps3d.common.EntityPose;
+import com.example.maps3d.common.WorldState;
+
+import com.example.maps3djava.R;
+import com.example.maps3djava.sampleactivity.SampleBaseActivity;
+import com.google.android.gms.maps.model.LatLng;
+import com.google.android.gms.maps3d.GoogleMap3D;
+import com.google.android.gms.maps3d.model.AltitudeMode;
+import com.google.android.gms.maps3d.model.Camera;
+import com.google.android.gms.maps3d.model.FlyToOptions;
+import com.google.android.gms.maps3d.model.FlyAroundOptions;
+import com.google.android.gms.maps3d.model.LatLngAltitude;
+import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.button.MaterialButton;
+import com.google.android.material.chip.ChipGroup;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import java.util.List;
/**
- * Skeleton activity for AdvancedCameraAnimationActivity.
+ * Java implementation of Advanced Camera Animation demo in Google Maps 3D.
*/
-public class AdvancedCameraAnimationActivity extends BaseSkeletonActivity {
+public class AdvancedCameraAnimationActivity extends SampleBaseActivity {
+
+ @NonNull
+ @Override
+ public String getTAG() {
+ return "AdvancedCameraAnimationActivity";
+ }
+
+
+ private AdvancedCameraAnimationViewModel viewModel;
+ private final Map3DModelEntity airplaneEntity =
+ new Map3DModelEntity(TourData.AIRPLANE_MODEL_ID, TourData.AIRPLANE_MODEL_URL, AltitudeMode.ABSOLUTE);
+
+ private CardView controlsCard;
+ private MaterialButton btnPlayPause;
+ private MaterialButton btnReset;
+ private MaterialButton btnCollapseToggle;
+ private TextView tvTourStatus;
+ private LinearLayout collapsibleContent;
+ private MaterialButton btnSelectApproach;
+ private com.google.android.material.card.MaterialCardView cardKeyframeTourStep;
+ private TextView tvKeyframeStepBadge;
+ private TextView tvKeyframeStepDesc;
+ private com.google.android.material.progressindicator.LinearProgressIndicator progressKeyframeStep;
+ private TextView tvStepDetail;
+ private LinearLayout layoutSimpleFlyToOptions;
+ private ChipGroup chipGroupSimpleFlyToMode;
+
+ private boolean isControlsCollapsed = false;
+ private final Handler autoFadeHandler = new Handler(Looper.getMainLooper());
+ private final Runnable autoFadeRunnable = () ->
+ controlsCard.animate().alpha(0.35f).setDuration(400L).start();
+
+ private Choreographer.FrameCallback frameCallback;
+ private boolean isTourRunning = false;
+ private int currentKeyframeIndex = 0;
+
+ @NonNull
+ @Override
+ public Camera getInitialCamera() {
+ LatLng start = TourData.AIRPLANE_FLIGHT_PATH.get(0);
+ return new Camera(
+ new LatLngAltitude(start.latitude, start.longitude, 250.0),
+ 105.0,
+ 65.0,
+ 0.0,
+ 600.0
+ );
+ }
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (snapshotButton != null) snapshotButton.setVisibility(View.GONE);
+ if (recenterButton != null) recenterButton.setVisibility(View.GONE);
+
+ MaterialToolbar topBar = findViewById(com.example.maps3dcommon.R.id.top_bar);
+ if (topBar != null) {
+ topBar.setTitle(com.example.maps3dcommon.R.string.aerial_tour_title);
+ topBar.setSubtitle(com.example.maps3dcommon.R.string.framework_java_views);
+ }
+
+ viewModel = new ViewModelProvider(this).get(AdvancedCameraAnimationViewModel.class);
+
+ setupCustomControls();
+ observeViewModel();
+ resetAutoFadeTimer();
+ }
+
+ private void setupCustomControls() {
+ ViewGroup rootLayout = findViewById(com.example.maps3dcommon.R.id.map_container);
+ View customView = getLayoutInflater().inflate(
+ com.example.maps3dcommon.R.layout.control_panel_advanced_animation,
+ rootLayout,
+ false
+ );
+ rootLayout.addView(customView);
+
+ controlsCard = customView.findViewById(com.example.maps3dcommon.R.id.control_panel);
+ TextView tvFrameworkSubtitle = customView.findViewById(com.example.maps3dcommon.R.id.tv_framework_subtitle);
+ if (tvFrameworkSubtitle != null) {
+ tvFrameworkSubtitle.setText("Java Views");
+ tvFrameworkSubtitle.setVisibility(View.VISIBLE);
+ }
+ LinearLayout headerTitleBar = customView.findViewById(com.example.maps3dcommon.R.id.header_title_bar);
+ MaterialButton btnHelp = customView.findViewById(com.example.maps3dcommon.R.id.btn_help);
+ btnCollapseToggle = customView.findViewById(com.example.maps3dcommon.R.id.btn_collapse_toggle);
+ btnPlayPause = customView.findViewById(com.example.maps3dcommon.R.id.btn_play_pause);
+ btnReset = customView.findViewById(com.example.maps3dcommon.R.id.btn_reset);
+ tvTourStatus = customView.findViewById(com.example.maps3dcommon.R.id.tv_tour_status);
+ collapsibleContent = customView.findViewById(com.example.maps3dcommon.R.id.collapsible_content);
+ btnSelectApproach = customView.findViewById(com.example.maps3dcommon.R.id.btn_select_approach);
+ tvStepDetail = customView.findViewById(com.example.maps3dcommon.R.id.tv_step_detail);
+ layoutSimpleFlyToOptions = customView.findViewById(com.example.maps3dcommon.R.id.layout_simple_fly_to_options);
+ cardKeyframeTourStep = customView.findViewById(com.example.maps3dcommon.R.id.card_keyframe_tour_step);
+ tvKeyframeStepBadge = customView.findViewById(com.example.maps3dcommon.R.id.tv_keyframe_step_badge);
+ tvKeyframeStepDesc = customView.findViewById(com.example.maps3dcommon.R.id.tv_keyframe_step_description);
+ progressKeyframeStep = customView.findViewById(com.example.maps3dcommon.R.id.progress_keyframe_step);
+ chipGroupSimpleFlyToMode = customView.findViewById(com.example.maps3dcommon.R.id.chip_group_simple_fly_to_mode);
+
+ headerTitleBar.setOnClickListener(v -> {
+ toggleControlsCollapse();
+ resetAutoFadeTimer();
+ });
+
+ btnCollapseToggle.setOnClickListener(v -> {
+ toggleControlsCollapse();
+ resetAutoFadeTimer();
+ });
+
+ btnPlayPause.setOnClickListener(v -> {
+ resetAutoFadeTimer();
+ if (viewModel.getCurrentState().isPlaying()) {
+ stopAnimationLoops();
+ viewModel.pause();
+ } else {
+ startSelectedApproach();
+ }
+ });
+
+ btnReset.setOnClickListener(v -> {
+ resetAutoFadeTimer();
+ stopAnimationLoops();
+ viewModel.resetTour();
+ if (googleMap3D != null) {
+ Camera targetCam = (viewModel.getCurrentState().getSelectedApproach() == AnimationApproach.KEYFRAME_TOUR) ? TourData.OVERVIEW_CAMERA : getInitialCamera();
+ googleMap3D.setCamera(targetCam);
+ }
+ });
+
+ btnHelp.setOnClickListener(v -> {
+ showHelpDialog();
+ resetAutoFadeTimer();
+ });
+
+ btnSelectApproach.setOnClickListener(v -> {
+ resetAutoFadeTimer();
+ showApproachMenu(v);
+ });
+
+ chipGroupSimpleFlyToMode.setOnCheckedStateChangeListener((group, checkedIds) -> {
+ if (checkedIds.isEmpty()) return;
+ int id = checkedIds.get(0);
+ SimpleFlyToMode mode = (id == com.example.maps3dcommon.R.id.chip_fly_to_midpoint)
+ ? SimpleFlyToMode.MIDPOINT_JUMP
+ : SimpleFlyToMode.SYNCHRONIZED_FLIGHT;
+ viewModel.setSimpleFlyToMode(mode);
+ resetAutoFadeTimer();
+ });
+
+ GestureDetector gestureDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() {
+ @Override
+ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
+ if (e1 == null) return false;
+ float deltaY = e2.getY() - e1.getY();
+ if (Math.abs(deltaY) > 50 && Math.abs(velocityY) > 100) {
+ if (deltaY > 0 && !isControlsCollapsed) {
+ toggleControlsCollapse();
+ } else if (deltaY < 0 && isControlsCollapsed) {
+ toggleControlsCollapse();
+ }
+ return true;
+ }
+ return false;
+ }
+ });
+
+ controlsCard.setOnTouchListener((v, event) -> {
+ resetAutoFadeTimer();
+ gestureDetector.onTouchEvent(event);
+ return false;
+ });
+ }
+
+ private void observeViewModel() {
+ viewModel.getLiveData().observe(this, state -> {
+ if (state == null) return;
+ tvTourStatus.setText(state.getStatusText());
+ btnPlayPause.setIconResource(
+ state.isPlaying()
+ ? com.example.maps3dcommon.R.drawable.pause_24px
+ : com.example.maps3dcommon.R.drawable.play_arrow_24px
+ );
+
+ // Synchronize approach button label & UI
+ btnSelectApproach.setText(state.getSelectedApproach().getTitle());
+ layoutSimpleFlyToOptions.setVisibility(
+ state.getSelectedApproach() == AnimationApproach.SIMPLE_FLY_TO ? View.VISIBLE : View.GONE
+ );
+
+ boolean isKeyframeTour = state.getSelectedApproach() == AnimationApproach.KEYFRAME_TOUR;
+ cardKeyframeTourStep.setVisibility(isKeyframeTour ? View.VISIBLE : View.GONE);
+ if (isKeyframeTour) {
+ tvKeyframeStepBadge.setText(!state.getStepTitle().isEmpty() ? state.getStepTitle() : "Step " + (state.getCurrentStepIndex() + 1) + " of " + state.getTotalSteps());
+ tvKeyframeStepDesc.setText(state.getStepDescription());
+ progressKeyframeStep.setMax(state.getTotalSteps());
+ progressKeyframeStep.setProgress(state.getCurrentStepIndex() + 1);
+ }
+
+ // Update detail explanation text
+ updateApproachUI(state.getSelectedApproach());
+
+ // Synchronize sub-mode chip selection
+ int targetSubModeChipId = (state.getSimpleFlyToMode() == SimpleFlyToMode.MIDPOINT_JUMP)
+ ? com.example.maps3dcommon.R.id.chip_fly_to_midpoint
+ : com.example.maps3dcommon.R.id.chip_fly_to_synchronized;
+ if (chipGroupSimpleFlyToMode.getCheckedChipId() != targetSubModeChipId) {
+ chipGroupSimpleFlyToMode.check(targetSubModeChipId);
+ }
+
+ if (googleMap3D != null) {
+ if (state.getSelectedApproach() == AnimationApproach.DISPATCHER_FRAME_LOOP ||
+ state.getSelectedApproach() == AnimationApproach.ORBIT_360_SPIN) {
+ googleMap3D.setCamera(state.getCamera());
+ }
+ }
+
+ EntityPose pose = state.getEntityPose(TourData.AIRPLANE_MODEL_ID);
+ if (pose != null) {
+ airplaneEntity.applyPose(pose, googleMap3D);
+ }
+ });
+ }
+
+ @Override
+ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
+ super.onMap3DViewReady(googleMap3D);
+ // Workaround: A short delay ensures the native map viewport and surface have fully initialized
+ // before setting the initial tour camera and binding the 3D Airplane glTF entity to the scene.
+ new Handler(Looper.getMainLooper()).postDelayed(() -> {
+ if (!isDestroyed() && !isFinishing() && this.googleMap3D != null) {
+ Camera targetCam = (viewModel.getCurrentState().getSelectedApproach() == AnimationApproach.KEYFRAME_TOUR)
+ ? TourData.OVERVIEW_CAMERA
+ : getInitialCamera();
+ this.googleMap3D.setCamera(targetCam);
+ EntityPose initialPose = viewModel.getCurrentState().getEntityPose(TourData.AIRPLANE_MODEL_ID);
+ if (initialPose != null) {
+ airplaneEntity.attach(this.googleMap3D, initialPose);
+ }
+ }
+ }, 350L);
+ }
+
+ private void resetAndRestartTour() {
+ if (googleMap3D != null) {
+ Camera targetCam = (viewModel.getCurrentState().getSelectedApproach() == AnimationApproach.KEYFRAME_TOUR) ? TourData.OVERVIEW_CAMERA : getInitialCamera();
+ googleMap3D.setCamera(targetCam);
+ }
+ new Handler(Looper.getMainLooper()).postDelayed(() -> {
+ if (!isDestroyed() && !isFinishing()) {
+ startSelectedApproach();
+ }
+ }, 400L);
+ }
+
+ private void startSelectedApproach() {
+ if (googleMap3D == null) return;
+ stopAnimationLoops();
+ viewModel.play();
+
+ AnimationApproach approach = viewModel.getCurrentState().getSelectedApproach();
+ if (approach == AnimationApproach.SIMPLE_FLY_TO) {
+ runSimpleFlyTo(googleMap3D);
+ } else if (approach == AnimationApproach.KEYFRAME_TOUR) {
+ runKeyframeTour(googleMap3D);
+ } else if (approach == AnimationApproach.DISPATCHER_FRAME_LOOP) {
+ runFrameDispatcherLoop();
+ } else if (approach == AnimationApproach.ORBIT_360_SPIN) {
+ runContinuousOrbitLoop();
+ }
+ }
+
+ private void runSimpleFlyTo(GoogleMap3D map) {
+ isTourRunning = true;
+ LatLng target = TourData.AIRPLANE_FLIGHT_PATH.get(TourData.AIRPLANE_FLIGHT_PATH.size() - 1);
+ Camera targetCam = new Camera(
+ new LatLngAltitude(target.latitude, target.longitude, 250.0),
+ 285.0, // Facing back toward Golden Gate Bridge to watch the plane fly in
+ 65.0,
+ 0.0,
+ 600.0
+ );
+
+ FlyToOptions options = new FlyToOptions(targetCam, 5000);
+
+ frameCallback = new Choreographer.FrameCallback() {
+ private long lastNanos = 0L;
+ @Override
+ public void doFrame(long frameTimeNanos) {
+ if (lastNanos > 0L) {
+ double dt = (frameTimeNanos - lastNanos) / 1_000_000_000.0;
+ viewModel.tick(Math.max(0.001, Math.min(0.1, dt)));
+ }
+ lastNanos = frameTimeNanos;
+ if (viewModel.getCurrentState().isPlaying() && isTourRunning) {
+ Choreographer.getInstance().postFrameCallback(this);
+ }
+ }
+ };
+ Choreographer.getInstance().postFrameCallback(frameCallback);
+
+ map.setCameraAnimationEndListener(() -> {
+ map.setCameraAnimationEndListener(null);
+ stopAnimationLoops();
+ viewModel.onNativeCameraAnimationFinished();
+ });
+ map.flyCameraTo(options);
+ }
+
+ private void runKeyframeTour(GoogleMap3D map) {
+ isTourRunning = true;
+ currentKeyframeIndex = 0;
+ executeNextKeyframeStep(map);
+ }
+
+ private void executeNextKeyframeStep(GoogleMap3D map) {
+ List tour = TourData.SAN_FRANCISCO_TOUR;
+ if (!isTourRunning || currentKeyframeIndex >= tour.size() || !viewModel.getCurrentState().isPlaying()) {
+ stopAnimationLoops();
+ viewModel.onNativeCameraAnimationFinished();
+ return;
+ }
+
+ viewModel.setKeyframeStep(currentKeyframeIndex);
+ CameraKeyframe step = tour.get(currentKeyframeIndex);
+
+ if (step instanceof CameraKeyframe.FlyTo) {
+ CameraKeyframe.FlyTo flyStep = (CameraKeyframe.FlyTo) step;
+ FlyToOptions options = new FlyToOptions(flyStep.getTargetCamera(), (int) flyStep.getDurationMs());
+ map.setCameraAnimationEndListener(() -> {
+ map.setCameraAnimationEndListener(null);
+ currentKeyframeIndex++;
+ executeNextKeyframeStep(map);
+ });
+ map.flyCameraTo(options);
+ } else if (step instanceof CameraKeyframe.DwellPause) {
+ CameraKeyframe.DwellPause dwellStep = (CameraKeyframe.DwellPause) step;
+ new Handler(Looper.getMainLooper()).postDelayed(() -> {
+ currentKeyframeIndex++;
+ executeNextKeyframeStep(map);
+ }, dwellStep.getDurationMs());
+ } else if (step instanceof CameraKeyframe.FlyAround) {
+ CameraKeyframe.FlyAround flyAround = (CameraKeyframe.FlyAround) step;
+ FlyAroundOptions options = new FlyAroundOptions(flyAround.getCenterCamera(), (int) flyAround.getDurationMs(), (float) flyAround.getRounds());
+ map.setCameraAnimationEndListener(() -> {
+ map.setCameraAnimationEndListener(null);
+ currentKeyframeIndex++;
+ executeNextKeyframeStep(map);
+ });
+ map.flyCameraAround(options);
+ } else if (step instanceof CameraKeyframe.StationaryTrackingFlight) {
+ CameraKeyframe.StationaryTrackingFlight trackingStep = (CameraKeyframe.StationaryTrackingFlight) step;
+ FlyToOptions toVantage = new FlyToOptions(trackingStep.getObservationCamera(), 2000);
+ map.setCameraAnimationEndListener(() -> {
+ map.setCameraAnimationEndListener(null);
+ if (!isTourRunning || !viewModel.getCurrentState().isPlaying()) return;
+ StationaryCameraTracker tracker = StationaryCameraTracker.Companion.fromInitialCamera(trackingStep.getObservationCamera());
+ TrajectoryFlightAnimator flightAnimator = new TrajectoryFlightAnimator(trackingStep.getFlightPath(), 250.0, 0.08);
+ long startTime = System.currentTimeMillis();
+ Handler trackHandler = new Handler(Looper.getMainLooper());
+ Runnable trackRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (!isTourRunning || !viewModel.getCurrentState().isPlaying()) return;
+ long elapsed = System.currentTimeMillis() - startTime;
+ EntityPose targetPose = flightAnimator.update(elapsed, trackingStep.getDurationMs());
+ Camera trackingCam = tracker.computeTrackingCamera(targetPose);
+
+ viewModel.updateAirplanePose(targetPose);
+ airplaneEntity.applyPose(targetPose, map);
+ map.setCamera(trackingCam);
+
+ if (!flightAnimator.isFinished(elapsed, trackingStep.getDurationMs())) {
+ trackHandler.postDelayed(this, 16L);
+ } else {
+ EntityPose finalPose = flightAnimator.update(trackingStep.getDurationMs(), trackingStep.getDurationMs());
+ viewModel.updateAirplanePose(finalPose);
+ airplaneEntity.applyPose(finalPose, map);
+
+ currentKeyframeIndex++;
+ executeNextKeyframeStep(map);
+ }
+ }
+ };
+ trackHandler.post(trackRunnable);
+ });
+ map.flyCameraTo(toVantage);
+ }
+ }
+
+ private void runFrameDispatcherLoop() {
+ isTourRunning = true;
+ frameCallback = new Choreographer.FrameCallback() {
+ private long lastNanos = 0L;
+ @Override
+ public void doFrame(long frameTimeNanos) {
+ if (lastNanos > 0L) {
+ double dt = (frameTimeNanos - lastNanos) / 1_000_000_000.0;
+ viewModel.tick(Math.max(0.001, Math.min(0.1, dt)));
+ }
+ lastNanos = frameTimeNanos;
+ if (viewModel.getCurrentState().isPlaying() && isTourRunning) {
+ Choreographer.getInstance().postFrameCallback(this);
+ }
+ }
+ };
+ Choreographer.getInstance().postFrameCallback(frameCallback);
+ }
+
+ private void runContinuousOrbitLoop() {
+ isTourRunning = true;
+ frameCallback = new Choreographer.FrameCallback() {
+ private long lastNanos = 0L;
+ @Override
+ public void doFrame(long frameTimeNanos) {
+ if (lastNanos > 0L) {
+ double dt = (frameTimeNanos - lastNanos) / 1_000_000_000.0;
+ viewModel.tick(Math.max(0.001, Math.min(0.1, dt)));
+ }
+ lastNanos = frameTimeNanos;
+ if (viewModel.getCurrentState().isPlaying() && isTourRunning) {
+ Choreographer.getInstance().postFrameCallback(this);
+ }
+ }
+ };
+ Choreographer.getInstance().postFrameCallback(frameCallback);
+ }
+
+ private void stopAnimationLoops() {
+ isTourRunning = false;
+ if (frameCallback != null) {
+ Choreographer.getInstance().removeFrameCallback(frameCallback);
+ frameCallback = null;
+ }
+ if (googleMap3D != null) {
+ googleMap3D.setCameraAnimationEndListener(null);
+ googleMap3D.stopCameraAnimation();
+ }
+ }
+
+ private void toggleControlsCollapse() {
+ isControlsCollapsed = !isControlsCollapsed;
+ collapsibleContent.setVisibility(isControlsCollapsed ? View.GONE : View.VISIBLE);
+ btnCollapseToggle.setIconResource(
+ isControlsCollapsed
+ ? com.example.maps3dcommon.R.drawable.expand_less_24px
+ : com.example.maps3dcommon.R.drawable.expand_more_24px
+ );
+ }
+
+ private void updateApproachUI(AnimationApproach approach) {
+ layoutSimpleFlyToOptions.setVisibility(
+ approach == AnimationApproach.SIMPLE_FLY_TO ? View.VISIBLE : View.GONE
+ );
+ if (approach == AnimationApproach.SIMPLE_FLY_TO) {
+ tvStepDetail.setText("Native asynchronous SDK flight transition directly to Coit Tower.");
+ } else if (approach == AnimationApproach.KEYFRAME_TOUR) {
+ tvStepDetail.setText("Declarative 5-step sequence: Swoop FlyTo → Dwell Pause → 360° Orbit → Stationary Tracking Flight → Final FlyTo.");
+ } else if (approach == AnimationApproach.DISPATCHER_FRAME_LOOP) {
+ tvStepDetail.setText("Continuous 400 m/s flight synced to hardware VSYNC display frames.");
+ } else if (approach == AnimationApproach.ORBIT_360_SPIN) {
+ tvStepDetail.setText("Continuous 360° orbital camera rotation around Golden Gate Bridge.");
+ }
+ }
+
+ private void resetAutoFadeTimer() {
+ controlsCard.animate().alpha(1.0f).setDuration(150L).start();
+ autoFadeHandler.removeCallbacks(autoFadeRunnable);
+ autoFadeHandler.postDelayed(autoFadeRunnable, 3500L);
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ resetAutoFadeTimer();
+ return super.dispatchTouchEvent(ev);
+ }
+
+ private void showApproachMenu(View anchor) {
+ androidx.appcompat.widget.PopupMenu popup = new androidx.appcompat.widget.PopupMenu(this, anchor);
+ AnimationApproach[] approaches = AnimationApproach.values();
+ for (int i = 0; i < approaches.length; i++) {
+ popup.getMenu().add(0, i, i, approaches[i].getTitle());
+ }
+ popup.setOnMenuItemClickListener(item -> {
+ resetAutoFadeTimer();
+ int index = item.getItemId();
+ if (index >= 0 && index < approaches.length) {
+ stopAnimationLoops();
+ viewModel.setApproach(approaches[index]);
+ viewModel.resetTour();
+ if (googleMap3D != null) {
+ Camera targetCam = (viewModel.getCurrentState().getSelectedApproach() == AnimationApproach.KEYFRAME_TOUR) ? TourData.OVERVIEW_CAMERA : getInitialCamera();
+ googleMap3D.setCamera(targetCam);
+ }
+ return true;
+ }
+ return false;
+ });
+ popup.show();
+ }
+
+ private void showHelpDialog() {
+ View dialogView = getLayoutInflater().inflate(com.example.maps3dcommon.R.layout.dialog_help_advanced_animation, null);
+ TextView tvContent = dialogView.findViewById(com.example.maps3dcommon.R.id.tv_help_html_content);
+ if (tvContent != null) {
+ tvContent.setText(HtmlUtils.loadRawHtml(this, com.example.maps3dcommon.R.raw.help_advanced_animation));
+ }
+
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(com.example.maps3dcommon.R.string.help_dialog_advanced_animation_title)
+ .setView(dialogView)
+ .setPositiveButton(com.example.maps3dcommon.R.string.help_dialog_ok, (dialog, which) -> dialog.dismiss())
+ .show();
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ stopAnimationLoops();
+ viewModel.pause();
+ autoFadeHandler.removeCallbacks(autoFadeRunnable);
+ }
+
@Override
- protected int getTitleResId() {
- return R.string.feature_title_advanced_camera_animation;
+ protected void onDestroy() {
+ super.onDestroy();
+ airplaneEntity.detach();
}
}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java
index 08e793cc..242e9b4b 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java
@@ -16,15 +16,471 @@
package com.example.maps3djava.datavisualization;
+import static com.example.maps3d.common.UtilitiesKt.toValidCamera;
+
+import android.graphics.Color;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.transition.TransitionManager;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.cardview.widget.CardView;
+
import com.example.maps3dcommon.R;
-import com.example.maps3djava.common.BaseSkeletonActivity;
+import com.example.maps3djava.sampleactivity.SampleBaseActivity;
+import com.google.android.gms.maps.model.LatLng;
+import com.google.android.gms.maps3d.GoogleMap3D;
+import com.google.android.gms.maps3d.model.AltitudeMode;
+import com.google.android.gms.maps3d.model.Camera;
+import com.google.android.gms.maps3d.model.FlyToOptions;
+import com.google.android.gms.maps3d.model.LatLngAltitude;
+import com.google.android.gms.maps3d.model.Map3DMode;
+import com.google.android.gms.maps3d.model.Polygon;
+import com.google.android.gms.maps3d.model.PolygonOptions;
+import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.button.MaterialButton;
+import com.google.android.material.slider.Slider;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
/**
- * Skeleton activity for DataVisualizationActivity.
+ * =================================================================================================
+ * Data Visualization: 3D Extruded Flood Simulation (Java)
+ * =================================================================================================
+ *
+ * This sample demonstrates how to render and dynamically animate volumetric 3D extruded polygons
+ * using the Google Maps 3D SDK.
+ *
+ * Key Concepts Demonstrated:
+ * 1. 3D Volumetric Polygon Extrusion:
+ * - Uses {@link PolygonOptions#setExtruded(boolean)} to generate 3D vertical walls extending
+ * from ground level up to an absolute altitude ceiling.
+ * - Configures {@link AltitudeMode#ABSOLUTE} so the polygon elevation represents true mean sea
+ * level (MSL) altitude rather than terrain-relative offsets.
+ *
+ * 2. Real-Time Elevation Updates & Animation:
+ * - Updates the polygon altitude in real-time in response to slider gestures or an automated
+ * continuous tide simulation loop.
+ * - Re-uses a static {@link PolygonOptions#setId(String)} to upsert the polygon in place within
+ * the Maps 3D rendering engine.
+ *
+ * 3. Modern Material UI & Collapse Affordances:
+ * - Provides a bottom overlay card with dynamic flood elevation readouts and risk badges.
+ * - Features a header toggle with {@link MaterialButton} to expand/collapse controls for
+ * unobstructed 3D scene inspection.
*/
-public class DataVisualizationActivity extends BaseSkeletonActivity {
- @Override
- protected int getTitleResId() {
- return R.string.feature_title_data_visualization;
+public class DataVisualizationActivity extends SampleBaseActivity {
+
+ // --- Constants & Geographical Bounds ---
+
+ /** Focal viewpoint centered on the San Francisco Embarcadero waterfront. */
+ public static final LatLng SF_FLOOD_CENTER = new LatLng(37.8025, -122.4030);
+
+ /** Stable identifier for upserting the flood polygon in the 3D map engine. */
+ private static final String POLYGON_ID = "flood_zone_polygon";
+
+ /** Boundary coordinates outlining the San Francisco waterfront flood study area. */
+ public static final List floodZoneCoords = Arrays.asList(
+ new double[]{37.805156, -122.403256},
+ new double[]{37.803370, -122.401287},
+ new double[]{37.799222, -122.405080},
+ new double[]{37.797500, -122.408000},
+ new double[]{37.801000, -122.411000},
+ new double[]{37.805156, -122.403256}
+ );
+
+ /** Translucent water body fill color. */
+ private final int waterFillColor = Color.argb(140, 230, 40, 40);
+
+ /** Opaque perimeter boundary stroke color. */
+ private final int waterStrokeColor = Color.argb(255, 180, 0, 0);
+
+ /** Width of the polygon boundary line in screen pixels. */
+ private final double waterStrokeWidth = 2.5;
+
+ // --- UI Elements ---
+
+ private CardView controlsCard;
+ private View cardHeader;
+ private View cardContent;
+ private MaterialButton btnCollapse;
+ private TextView floodDepthLabel;
+ private TextView floodRiskBadge;
+ private Slider floodSlider;
+ private MaterialButton btnAnimateFlood;
+
+ // --- State Variables ---
+
+ private Polygon floodPolygon = null;
+ private double currentFloodElevation = 10.0;
+ private boolean isSimulating = false;
+ private boolean isCollapsed = false;
+
+ // --- Handlers & Runnables ---
+
+ private final Handler simulationHandler = new Handler(Looper.getMainLooper());
+ private Runnable simulationRunnable;
+
+ private final Handler fadeHandler = new Handler(Looper.getMainLooper());
+ private final Runnable fadeOutRunnable = () -> {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(0.85f)
+ .setDuration(400)
+ .start();
+ }
+ };
+
+ // --- Base Activity Overrides ---
+
+ @NonNull
+ @Override
+ public String getTAG() {
+ return "DataVisualizationActivity";
+ }
+
+ @NonNull
+ @Override
+ public Camera getInitialCamera() {
+ return toValidCamera(new Camera(
+ new LatLngAltitude(SF_FLOOD_CENTER.latitude, SF_FLOOD_CENTER.longitude, 120.0),
+ 35.0,
+ 64.0,
+ 0.0,
+ 1200.0
+ ));
+ }
+
+ // --- Lifecycle & Initialization ---
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Hide base pill scroll view as this activity manages its own overlay card
+ View baseScrollView = findViewById(R.id.control_scroll_view);
+ if (baseScrollView != null) {
+ baseScrollView.setVisibility(View.GONE);
+ }
+
+ ViewGroup container = findViewById(R.id.map_container);
+ if (container != null) {
+ getLayoutInflater().inflate(R.layout.control_panel_data_visualization, container, true);
+ }
+
+ MaterialToolbar topBar = findViewById(R.id.top_bar);
+ if (topBar != null) {
+ topBar.setTitle(R.string.feature_title_data_visualization);
+ topBar.setNavigationOnClickListener(v -> finish());
+ }
+
+ initViews();
+ updateControlLabels(currentFloodElevation);
+ }
+
+ /**
+ * Initializes view references and wires up touch and click listeners.
+ */
+ private void initViews() {
+ controlsCard = findViewById(R.id.control_panel);
+ cardHeader = findViewById(R.id.card_header);
+ cardContent = findViewById(R.id.card_content);
+ btnCollapse = findViewById(R.id.btn_collapse);
+
+ floodDepthLabel = findViewById(R.id.tv_flood_depth_label);
+ floodRiskBadge = findViewById(R.id.tv_flood_risk_badge);
+ floodSlider = findViewById(R.id.flood_slider);
+ btnAnimateFlood = findViewById(R.id.btn_animate_flood);
+
+ if (btnCollapse != null) {
+ btnCollapse.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
+ if (cardHeader != null) {
+ cardHeader.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
+ if (floodSlider != null) {
+ floodSlider.addOnChangeListener((slider, value, fromUser) -> {
+ if (fromUser) {
+ stopSimulation();
+ }
+ updateFloodElevation(value);
+ });
+ }
+
+ if (btnAnimateFlood != null) {
+ btnAnimateFlood.setOnClickListener(v -> {
+ if (isSimulating) {
+ stopSimulation();
+ } else {
+ startSimulation();
+ }
+ });
}
+
+ // Schedule subtle initial auto-fade for unobstructed viewing
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ // --- UI Collapse / Expand Mechanics ---
+
+ /**
+ * Collapses the control card downward, leaving only the title header visible.
+ */
+ private void collapseControls() {
+ if (controlsCard == null || cardContent == null) {
+ return;
+ }
+ if (isCollapsed) {
+ return;
+ }
+ isCollapsed = true;
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_less_24px);
+ btnCollapse.setContentDescription(getString(R.string.expand_controls));
+ }
+ TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.GONE);
+ }
+
+ /**
+ * Expands the control card back to its full height.
+ */
+ private void expandControls() {
+ if (controlsCard == null || cardContent == null) {
+ return;
+ }
+ if (!isCollapsed) {
+ return;
+ }
+ isCollapsed = false;
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_more_24px);
+ btnCollapse.setContentDescription(getString(R.string.collapse_controls));
+ }
+ TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.VISIBLE);
+
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ if (ev.getAction() == MotionEvent.ACTION_DOWN || ev.getAction() == MotionEvent.ACTION_MOVE) {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(1.0f)
+ .setDuration(150)
+ .start();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+ }
+ return super.dispatchTouchEvent(ev);
+ }
+
+ // --- 3D Map Setup & Extrusion Engine ---
+
+ @Override
+ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
+ super.onMap3DViewReady(googleMap3D);
+
+ googleMap3D.setMapMode(Map3DMode.HYBRID);
+
+ googleMap3D.setOnMapReadyListener(sceneReadiness -> {
+ googleMap3D.setOnMapReadyListener(null);
+ googleMap3D.flyCameraTo(new FlyToOptions(getInitialCamera(), 1200));
+ runOnUiThread(() -> updateFloodElevation(currentFloodElevation));
+ });
+ }
+
+ /**
+ * Updates the 3D polygon height and refreshes formatted textual status indicators.
+ *
+ * @param currentFloodHeightMeters Target sea level elevation in meters.
+ */
+ public void updateFloodElevation(double currentFloodHeightMeters) {
+ currentFloodElevation = currentFloodHeightMeters;
+
+ runOnUiThread(() -> {
+ updateControlLabels(currentFloodHeightMeters);
+
+ if (googleMap3D == null) {
+ return;
+ }
+
+ // Build 3D path vertices at the specified absolute altitude
+ List path = new ArrayList<>();
+ for (double[] coord : floodZoneCoords) {
+ path.add(new LatLngAltitude(coord[0], coord[1], currentFloodHeightMeters));
+ }
+
+ // Volumetric 3D Polygon Extrusion Technique:
+ // 1. AltitudeMode.ABSOLUTE: Water elevation represents true Mean Sea Level (MSL).
+ // Unlike RELATIVE_TO_GROUND, ABSOLUTE ensures a flat, uniform horizontal water plane.
+ // 2. setExtruded(true): Instructs the 3D rendering engine to drop vertical skirt walls
+ // from the polygon vertices down to the ground terrain mesh, forming a 3D volumetric water body.
+ // 3. setId(POLYGON_ID): Re-using a stable ID upserts the existing polygon in place,
+ // eliminating render flickering during rapid slider or animation updates.
+ PolygonOptions options = new PolygonOptions();
+ options.setId(POLYGON_ID);
+ options.setPath(path);
+ options.setFillColor(waterFillColor);
+ options.setStrokeColor(waterStrokeColor);
+ options.setStrokeWidth(waterStrokeWidth);
+ options.setAltitudeMode(AltitudeMode.ABSOLUTE);
+ options.setExtruded(true);
+ options.setDrawsOccludedSegments(true);
+ options.setGeodesic(false);
+
+ floodPolygon = googleMap3D.addPolygon(options);
+ if (floodPolygon != null) {
+ floodPolygon.setClickListener(() -> runOnUiThread(() -> Toast.makeText(
+ DataVisualizationActivity.this,
+ getString(R.string.flood_toast_format, currentFloodElevation),
+ Toast.LENGTH_SHORT
+ ).show()));
+ }
+ });
+ }
+
+ /**
+ * Refreshes textual status labels and risk severity badges based on water height.
+ */
+ private void updateControlLabels(double currentFloodHeightMeters) {
+ double feet = currentFloodHeightMeters * 3.28084;
+ if (floodDepthLabel != null) {
+ floodDepthLabel.setText(
+ getString(R.string.flood_elevation_format, currentFloodHeightMeters, feet));
+ }
+
+ if (floodRiskBadge != null) {
+ if (currentFloodHeightMeters <= 2.0) {
+ floodRiskBadge.setText(R.string.flood_risk_baseline);
+ floodRiskBadge.setTextColor(Color.parseColor("#008800"));
+ floodRiskBadge.setBackgroundColor(Color.parseColor("#2000AA00"));
+ } else if (currentFloodHeightMeters <= 8.0) {
+ floodRiskBadge.setText(R.string.flood_risk_minor);
+ floodRiskBadge.setTextColor(Color.parseColor("#BB7700"));
+ floodRiskBadge.setBackgroundColor(Color.parseColor("#20FFAA00"));
+ } else if (currentFloodHeightMeters <= 20.0) {
+ floodRiskBadge.setText(R.string.flood_risk_moderate);
+ floodRiskBadge.setTextColor(Color.parseColor("#0077CC"));
+ floodRiskBadge.setBackgroundColor(Color.parseColor("#200088FF"));
+ } else if (currentFloodHeightMeters <= 35.0) {
+ floodRiskBadge.setText(R.string.flood_risk_storm_surge);
+ floodRiskBadge.setTextColor(Color.parseColor("#DD4400"));
+ floodRiskBadge.setBackgroundColor(Color.parseColor("#25FF5500"));
+ } else {
+ floodRiskBadge.setText(R.string.flood_risk_extreme);
+ floodRiskBadge.setTextColor(Color.parseColor("#CC0000"));
+ floodRiskBadge.setBackgroundColor(Color.parseColor("#25FF0000"));
+ }
+ }
+ }
+
+ // --- Automated Continuous Simulation Loop ---
+
+ /**
+ * Starts continuous incremental sea level rise simulation.
+ */
+ private void startSimulation() {
+ double maxVal = floodSlider != null ? floodSlider.getValueTo() : 100.0;
+ double minVal = floodSlider != null ? floodSlider.getValueFrom() : 0.0;
+ if (currentFloodElevation >= maxVal) {
+ if (floodSlider != null) {
+ floodSlider.setValue((float) minVal);
+ }
+ updateFloodElevation(minVal);
+ }
+
+ isSimulating = true;
+ if (btnAnimateFlood != null) {
+ btnAnimateFlood.setText(R.string.stop_simulation);
+ }
+
+ simulationRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (!isSimulating) {
+ return;
+ }
+
+ double currentMax = floodSlider != null ? floodSlider.getValueTo() : 100.0;
+ double newElevation = currentFloodElevation + 0.2;
+ newElevation = Math.round(newElevation * 10.0) / 10.0;
+ if (floodSlider != null) {
+ floodSlider.setValue((float) newElevation);
+ } else {
+ updateFloodElevation(newElevation);
+ }
+ if (newElevation >= currentMax) {
+ stopSimulation();
+ return;
+ }
+
+ simulationHandler.postDelayed(this, 20);
+ }
+ };
+ simulationHandler.post(simulationRunnable);
+ }
+
+ /**
+ * Stops the ongoing sea level rise simulation loop.
+ */
+ private void stopSimulation() {
+ isSimulating = false;
+ if (simulationRunnable != null) {
+ simulationHandler.removeCallbacks(simulationRunnable);
+ simulationRunnable = null;
+ }
+ if (btnAnimateFlood != null) {
+ btnAnimateFlood.setText(R.string.start_simulation);
+ }
+ }
+
+ // --- Lifecycle Teardown ---
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ stopSimulation();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ }
+
+ @Override
+ protected void onDestroy() {
+ stopSimulation();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (floodPolygon != null) {
+ floodPolygon.remove();
+ floodPolygon = null;
+ }
+ super.onDestroy();
+ }
}
+
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/fieldofview/FieldOfViewActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/fieldofview/FieldOfViewActivity.java
index 47496a2a..f2585ec2 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/fieldofview/FieldOfViewActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/fieldofview/FieldOfViewActivity.java
@@ -16,15 +16,333 @@
package com.example.maps3djava.fieldofview;
+import static com.example.maps3d.common.UtilitiesKt.toValidCamera;
+
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.cardview.widget.CardView;
+
import com.example.maps3dcommon.R;
-import com.example.maps3djava.common.BaseSkeletonActivity;
+import com.example.maps3djava.sampleactivity.SampleBaseActivity;
+import com.google.android.gms.maps.model.LatLng;
+import com.google.android.gms.maps3d.GoogleMap3D;
+import com.google.android.gms.maps3d.model.Camera;
+import com.google.android.gms.maps3d.model.LatLngAltitude;
+import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.button.MaterialButton;
+import com.google.android.material.slider.Slider;
/**
- * Skeleton activity for FieldOfViewActivity.
+ * =================================================================================================
+ * Field of View (FOV) Perspective Scaling (Java)
+ * =================================================================================================
+ *
+ * This sample demonstrates perspective Field of View (FOV) scaling and optical dolly-zoom simulation
+ * using the Google Maps 3D SDK.
+ *
+ * Key Concepts Demonstrated:
+ * 1. Perspective Field of View (FOV) Adjustments:
+ * - Adjusts the camera distance (range) proportionally using trigonometric perspective projection
+ * math to simulate optical lens focal length changes (Telephoto, Standard, Wide, Ultra-Wide).
+ * - Formula: range = baseRange * (tan(baseFov / 2) / tan(targetFov / 2)).
+ *
+ * 2. Seamless Camera State Preservation:
+ * - Inspects the active live camera before adjusting perspective, ensuring custom panning,
+ * heading rotations, and tilt angles applied by user gestures are smoothly retained.
+ *
+ * 3. Modern Material UI & Collapse Affordances:
+ * - Features a bottom control card with dynamic FOV angle readout and quick preset buttons.
+ * - Supports expanding and collapsing the card header to maximize the visible 3D map scene.
+ * - Implements subtle UI idle auto-fade with touch-to-wake responsiveness.
*/
-public class FieldOfViewActivity extends BaseSkeletonActivity {
- @Override
- protected int getTitleResId() {
- return R.string.feature_title_field_of_view;
+public class FieldOfViewActivity extends SampleBaseActivity {
+
+ // --- Constants & Perspective Geometry ---
+
+ /** Focal landmark centered near the San Francisco Transamerica Pyramid & Financial District. */
+ public static final LatLng SF_FINANCIAL_DISTRICT = new LatLng(37.7952, -122.4028);
+
+ /** Baseline field of view angle in degrees (human eye / standard focal length). */
+ private static final double BASE_FOV_DEGREES = 45.0;
+
+ /** Baseline camera range in meters corresponding to the standard 45° FOV baseline. */
+ private static final double BASE_RANGE_METERS = 800.0;
+
+ /** Minimum optical range boundary in meters. */
+ private static final double MIN_RANGE_METERS = 150.0;
+
+ /** Maximum optical range boundary in meters. */
+ private static final double MAX_RANGE_METERS = 3000.0;
+
+ // --- UI Elements ---
+
+ private CardView controlsCard;
+ private View cardHeader;
+ private View cardContent;
+ private MaterialButton btnCollapse;
+ private TextView fovSliderLabel;
+ private Slider fovSlider;
+
+ // --- State Variables ---
+
+ private double currentFov = 45.0;
+ private boolean isCollapsed = false;
+
+ // --- Handlers & Runnables ---
+
+ private final Handler fadeHandler = new Handler(Looper.getMainLooper());
+ private final Runnable fadeOutRunnable = () -> {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(0.85f)
+ .setDuration(400)
+ .start();
+ }
+ };
+
+ // --- Base Activity Overrides ---
+
+ @NonNull
+ @Override
+ public String getTAG() {
+ return "FieldOfViewActivity";
+ }
+
+ @NonNull
+ @Override
+ public Camera getInitialCamera() {
+ return toValidCamera(new Camera(
+ new LatLngAltitude(SF_FINANCIAL_DISTRICT.latitude, SF_FINANCIAL_DISTRICT.longitude, 150.0),
+ 45.0,
+ 65.0,
+ 0.0,
+ BASE_RANGE_METERS
+ ));
+ }
+
+ // --- Lifecycle & Initialization ---
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Hide base pill scroll view as this activity manages its own overlay card
+ View baseScrollView = findViewById(R.id.control_scroll_view);
+ if (baseScrollView != null) {
+ baseScrollView.setVisibility(View.GONE);
+ }
+
+ ViewGroup container = findViewById(R.id.map_container);
+ if (container != null) {
+ getLayoutInflater().inflate(R.layout.control_panel_field_of_view, container, true);
+ }
+
+ MaterialToolbar topBar = findViewById(R.id.top_bar);
+ if (topBar != null) {
+ topBar.setTitle(R.string.feature_title_field_of_view);
+ topBar.setNavigationOnClickListener(v -> finish());
+ }
+
+ initViews();
+ }
+
+ /**
+ * Initializes view references and wires up touch and click listeners.
+ */
+ private void initViews() {
+ controlsCard = findViewById(R.id.control_panel);
+ cardHeader = findViewById(R.id.card_header);
+ cardContent = findViewById(R.id.card_content);
+ btnCollapse = findViewById(R.id.btn_collapse);
+
+ fovSliderLabel = findViewById(R.id.fov_slider_label);
+ fovSlider = findViewById(R.id.fov_slider);
+
+ if (btnCollapse != null) {
+ btnCollapse.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
+ if (cardHeader != null) {
+ cardHeader.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
+ if (fovSlider != null) {
+ fovSlider.addOnChangeListener((slider, value, fromUser) -> updateFov(value));
+ }
+
+ Button btnTele = findViewById(R.id.btn_fov_telephoto);
+ if (btnTele != null) {
+ btnTele.setOnClickListener(v -> {
+ if (fovSlider != null) {
+ fovSlider.setValue(20.0f);
+ }
+ });
+ }
+
+ Button btnStd = findViewById(R.id.btn_fov_standard);
+ if (btnStd != null) {
+ btnStd.setOnClickListener(v -> {
+ if (fovSlider != null) {
+ fovSlider.setValue(45.0f);
+ }
+ });
+ }
+
+ Button btnWide = findViewById(R.id.btn_fov_wide);
+ if (btnWide != null) {
+ btnWide.setOnClickListener(v -> {
+ if (fovSlider != null) {
+ fovSlider.setValue(90.0f);
+ }
+ });
+ }
+
+ Button btnUltra = findViewById(R.id.btn_fov_ultrawide);
+ if (btnUltra != null) {
+ btnUltra.setOnClickListener(v -> {
+ if (fovSlider != null) {
+ fovSlider.setValue(120.0f);
+ }
+ });
+ }
+
+ // Schedule subtle initial auto-fade for unobstructed viewing
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ // --- UI Collapse / Expand Mechanics ---
+
+ /**
+ * Collapses the control card downward, leaving only the title header visible.
+ */
+ private void collapseControls() {
+ if (controlsCard == null || cardContent == null) {
+ return;
}
+ isCollapsed = true;
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_less_24px);
+ btnCollapse.setContentDescription(getString(R.string.expand_controls));
+ }
+ android.transition.TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.GONE);
+ }
+
+ /**
+ * Expands the control card back to its full height.
+ */
+ private void expandControls() {
+ if (controlsCard == null || cardContent == null) {
+ return;
+ }
+ isCollapsed = false;
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_more_24px);
+ btnCollapse.setContentDescription(getString(R.string.collapse_controls));
+ }
+ android.transition.TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.VISIBLE);
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ if (ev.getAction() == MotionEvent.ACTION_DOWN || ev.getAction() == MotionEvent.ACTION_MOVE) {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(1.0f)
+ .setDuration(150)
+ .start();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+ }
+ return super.dispatchTouchEvent(ev);
+ }
+
+ // --- 3D Map Setup & Perspective Scaling Engine ---
+
+ @Override
+ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
+ super.onMap3DViewReady(googleMap3D);
+ googleMap3D.setOnMapReadyListener(sceneReadiness -> {
+ googleMap3D.setOnMapReadyListener(null);
+ runOnUiThread(() -> updateFov((float) currentFov));
+ });
+ }
+
+ /**
+ * Calculates and applies the optical dolly-zoom perspective range for the specified FOV angle.
+ *
+ * @param fovAngle Perspective field of view angle in degrees (e.g. 15° to 120°).
+ */
+ private void updateFov(float fovAngle) {
+ currentFov = fovAngle;
+ runOnUiThread(() -> {
+ if (fovSliderLabel != null) {
+ fovSliderLabel.setText(getString(R.string.field_of_view_format, (int) fovAngle));
+ }
+ });
+
+ if (googleMap3D != null) {
+ Camera liveCam = googleMap3D.getCamera() != null ? toValidCamera(googleMap3D.getCamera()) : null;
+ Camera currCam = (liveCam != null && (Math.abs(liveCam.getCenter().getLatitude()) > 0.001
+ || Math.abs(liveCam.getCenter().getLongitude()) > 0.001))
+ ? liveCam
+ : getInitialCamera();
+
+ // Optical perspective transformation: range = baseRange * tan(baseFov/2) / tan(targetFov/2)
+ double baseFovRad = Math.toRadians(BASE_FOV_DEGREES / 2.0);
+ double targetFovRad = Math.toRadians(fovAngle / 2.0);
+
+ double targetRange = BASE_RANGE_METERS * Math.tan(baseFovRad) / Math.tan(targetFovRad);
+ if (targetRange < MIN_RANGE_METERS) {
+ targetRange = MIN_RANGE_METERS;
+ }
+ if (targetRange > MAX_RANGE_METERS) {
+ targetRange = MAX_RANGE_METERS;
+ }
+
+ Camera updatedCam = toValidCamera(new Camera(
+ currCam.getCenter(),
+ currCam.getHeading(),
+ currCam.getTilt(),
+ currCam.getRoll(),
+ targetRange
+ ));
+ googleMap3D.setCamera(updatedCam);
+ }
+ }
+
+ // --- Teardown ---
+
+ @Override
+ protected void onDestroy() {
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ super.onDestroy();
+ }
}
+
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 51fe5942..02f98b8d 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,15 +16,568 @@
package com.example.maps3djava.pathfollowing;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Choreographer;
+import android.view.GestureDetector;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.RadioGroup;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.cardview.widget.CardView;
+import androidx.lifecycle.ViewModelProvider;
+import com.example.maps3d.common.PathData;
+import com.example.maps3d.common.PathEngine;
+import com.example.maps3d.common.PathFollowingViewModel;
+import com.example.maps3d.common.PathPlaybackState;
+import com.example.maps3d.common.PathTouchHandler;
import com.example.maps3dcommon.R;
-import com.example.maps3djava.common.BaseSkeletonActivity;
+import com.google.android.gms.maps3d.GoogleMap3D;
+import com.google.android.gms.maps3d.Map3DView;
+import com.google.android.gms.maps3d.OnMap3DViewReadyCallback;
+import com.google.android.gms.maps3d.model.AltitudeMode;
+import com.google.android.gms.maps3d.model.Camera;
+import com.google.android.gms.maps3d.model.LatLngAltitude;
+import com.google.android.gms.maps3d.model.Polyline;
+import com.google.android.gms.maps3d.model.PolylineOptions;
+import com.google.android.material.button.MaterialButton;
+import com.google.android.material.chip.ChipGroup;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.google.android.material.materialswitch.MaterialSwitch;
+import com.google.android.material.slider.Slider;
+import java.util.List;
/**
- * Skeleton activity for PathFollowingActivity.
+ * 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 BaseSkeletonActivity {
+public class PathFollowingActivity extends AppCompatActivity implements OnMap3DViewReadyCallback {
+
+ private PathFollowingViewModel viewModel;
+
+ // 3D Map View & Gesture Overlay
+ private Map3DView map3DView;
+ private View gestureOverlay;
+ private GoogleMap3D googleMap3D;
+
+ // Polylines
+ private Polyline staticRoutePolyline;
+ private Polyline progressPolyline;
+ private List lastStaticVertices;
+ private double lastRenderedProgressDist = -1.0;
+ private long lastSliderUpdateMillis = 0L;
+ private Boolean lastIsPlaying;
+
+ // Control panel overlay bindings
+ private CardView controlsCard;
+ private View cardHeader;
+ private MaterialButton btnHelp;
+ private MaterialButton btnCollapse;
+ private View controlsScroll;
+ private boolean isCollapsed = false;
+ private ChipGroup chipGroupSpeed;
+
+ private RadioGroup rgEnvironment;
+ private RadioGroup rgAltitudeMode;
+ private MaterialSwitch switchDrawsOccludedSegments;
+ private Slider pathAltitudeSlider;
+ private TextView pathAltitudeSliderLabel;
+ private MaterialButton btnPlayPause;
+ private Slider progressSlider;
+ private Slider rangeSlider;
+ private TextView rangeSliderLabel;
+ private Slider altitudeSlider;
+ private TextView altitudeSliderLabel;
+ private Slider headingSlider;
+ private TextView headingSliderLabel;
+ private Slider tiltSlider;
+ private TextView tiltSliderLabel;
+ private Slider speedSlider;
+ private TextView speedSliderLabel;
+
+ // Auto-fade & VSYNC Choreographer
+ private Choreographer.FrameCallback frameCallback;
+ private final Handler fadeHandler = new Handler(Looper.getMainLooper());
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_path_following);
+
+ viewModel = new ViewModelProvider(this).get(PathFollowingViewModel.class);
+
+ bindViews();
+ setupCustomGestureHandling();
+ setupControlListeners();
+ setupTouchAutoFade() ;
+ observeViewModel();
+
+ map3DView.onCreate(savedInstanceState);
+ map3DView.getMap3DViewAsync(this);
+ }
+
+ @Override
+ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
+ this.googleMap3D = googleMap3D;
+
+ googleMap3D.setOnMapReadyListener(
+ initialTime -> {
+ runOnUiThread(
+ () -> {
+ lastStaticVertices = null;
+ lastRenderedProgressDist = -1.0;
+ PathPlaybackState state = viewModel.getCurrentState();
+ updateStaticPolyline(state);
+ updateProgressPolyline(state);
+ updateCameraFromState(state);
+ renderUiControls(state);
+ });
+ });
+ }
+
+ private void setupCustomGestureHandling() {
+ if (gestureOverlay != null) {
+ gestureOverlay.setOnTouchListener(new PathTouchHandler(this, viewModel));
+ }
+ }
+
+ private void bindViews() {
+ map3DView = findViewById(R.id.map3dView);
+ gestureOverlay = findViewById(R.id.gesture_overlay);
+ controlsCard = findViewById(R.id.controls_card);
+ cardHeader = findViewById(R.id.card_header);
+ btnHelp = findViewById(R.id.btn_help);
+ chipGroupSpeed = findViewById(R.id.chip_group_speed);
+ btnCollapse = findViewById(R.id.btn_collapse);
+ controlsScroll = findViewById(R.id.controls_scroll);
+ rgEnvironment = findViewById(R.id.rg_environment);
+ rgAltitudeMode = findViewById(R.id.rg_altitude_mode);
+ switchDrawsOccludedSegments = findViewById(R.id.switch_draws_occluded_segments);
+ pathAltitudeSlider = findViewById(R.id.path_altitude_slider);
+ pathAltitudeSliderLabel = findViewById(R.id.path_altitude_slider_label);
+ btnPlayPause = findViewById(R.id.btn_play_pause);
+ progressSlider = findViewById(R.id.progress_slider);
+ rangeSlider = findViewById(R.id.range_slider);
+ rangeSliderLabel = findViewById(R.id.range_slider_label);
+ altitudeSlider = findViewById(R.id.altitude_slider);
+ altitudeSliderLabel = findViewById(R.id.altitude_slider_label);
+ headingSlider = findViewById(R.id.heading_slider);
+ headingSliderLabel = findViewById(R.id.heading_slider_label);
+ tiltSlider = findViewById(R.id.tilt_slider);
+ tiltSliderLabel = findViewById(R.id.tilt_slider_label);
+ speedSlider = findViewById(R.id.speed_slider);
+ speedSliderLabel = findViewById(R.id.speed_slider_label);
+ }
+
+ private void setupControlListeners() {
+ if (btnHelp != null) {
+ btnHelp.setOnClickListener(v -> showHelpDialog());
+ }
+
+ btnPlayPause.setOnClickListener(v -> viewModel.togglePlayPause());
+
+ if (chipGroupSpeed != null) {
+ chipGroupSpeed.setOnCheckedStateChangeListener(
+ (group, checkedIds) -> {
+ if (checkedIds.isEmpty()) return;
+ int checkedId = checkedIds.get(0);
+ double targetSpeed = 30.0;
+ if (checkedId == R.id.chip_speed_05x) targetSpeed = 15.0;
+ else if (checkedId == R.id.chip_speed_1x) targetSpeed = 30.0;
+ else if (checkedId == R.id.chip_speed_2x) targetSpeed = 60.0;
+ else if (checkedId == R.id.chip_speed_3x) targetSpeed = 90.0;
+ else if (checkedId == R.id.chip_speed_5x) targetSpeed = 120.0;
+
+ viewModel.setFollowSpeed(targetSpeed);
+ speedSlider.setValue((float) targetSpeed);
+ });
+ }
+
+ if (btnCollapse != null) {
+ btnCollapse.setOnClickListener(v -> setPanelCollapsed(!isCollapsed));
+ }
+
+ if (cardHeader != null) {
+ cardHeader.setOnClickListener(v -> setPanelCollapsed(!isCollapsed));
+ }
+
+ GestureDetector cardSwipeDetector = new GestureDetector(this, new GestureDetector.SimpleOnGestureListener() {
+ @Override
+ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
+ if (e1 == null) return false;
+ float dy = e2.getY() - e1.getY();
+ if (dy > 50 && velocityY > 100) {
+ setPanelCollapsed(true);
+ return true;
+ } else if (dy < -50 && velocityY < -100) {
+ setPanelCollapsed(false);
+ return true;
+ }
+ return false;
+ }
+ });
+
+ if (cardHeader != null) {
+ cardHeader.setOnTouchListener((v, event) -> cardSwipeDetector.onTouchEvent(event) || v.onTouchEvent(event));
+ }
+
+ if (controlsCard != null) {
+ controlsCard.setOnTouchListener((v, event) -> cardSwipeDetector.onTouchEvent(event));
+ }
+
+ progressSlider.addOnChangeListener(
+ (slider, value, fromUser) -> {
+ if (fromUser) {
+ viewModel.seekToRatio(value);
+ PathPlaybackState state = viewModel.getCurrentState();
+ updateCameraFromState(state);
+ updateProgressPolyline(state);
+ }
+ });
+
+ progressSlider.addOnSliderTouchListener(
+ new Slider.OnSliderTouchListener() {
+ @Override
+ public void onStartTrackingTouch(@NonNull Slider slider) {
+ viewModel.setScrubbing(true);
+ }
+
+ @Override
+ public void onStopTrackingTouch(@NonNull Slider slider) {
+ viewModel.setScrubbing(false);
+ viewModel.seekToRatio(slider.getValue());
+ PathPlaybackState state = viewModel.getCurrentState();
+ updateCameraFromState(state);
+ updateProgressPolyline(state);
+ }
+ });
+
+ rgAltitudeMode.setOnCheckedChangeListener(
+ (group, checkedId) -> {
+ int mode = AltitudeMode.CLAMP_TO_GROUND;
+ if (checkedId == R.id.rb_relative_to_ground) {
+ mode = AltitudeMode.RELATIVE_TO_GROUND;
+ } else if (checkedId == R.id.rb_relative_to_mesh) {
+ mode = AltitudeMode.RELATIVE_TO_MESH;
+ } else if (checkedId == R.id.rb_absolute) {
+ mode = AltitudeMode.ABSOLUTE;
+ }
+ viewModel.setAltitudeMode(mode);
+ resetPolylines();
+ });
+
+ switchDrawsOccludedSegments.setOnCheckedChangeListener(
+ (buttonView, isChecked) -> {
+ viewModel.setDrawsOccludedSegments(isChecked);
+ resetPolylines();
+ });
+
+ pathAltitudeSlider.addOnChangeListener(
+ (slider, value, fromUser) -> {
+ if (fromUser) {
+ viewModel.setPathAltitudeOffset(value);
+ resetPolylines();
+ }
+ pathAltitudeSliderLabel.setText(getString(R.string.path_height_format, value));
+ });
+
+ rangeSlider.addOnChangeListener(
+ (slider, value, fromUser) -> {
+ if (fromUser) {
+ viewModel.setCameraRange(value);
+ updateCameraFromState(viewModel.getCurrentState());
+ }
+ rangeSliderLabel.setText(
+ getString(R.string.camera_range_format, (int) value));
+ });
+
+ altitudeSlider.addOnChangeListener(
+ (slider, value, fromUser) -> {
+ if (fromUser) {
+ viewModel.setGroundAltitude(value);
+ updateCameraFromState(viewModel.getCurrentState());
+ }
+ altitudeSliderLabel.setText(
+ getString(R.string.ground_altitude_format, (int) value));
+ });
+
+ headingSlider.addOnChangeListener(
+ (slider, value, fromUser) -> {
+ if (fromUser) {
+ viewModel.setHeadingOffset(value);
+ updateCameraFromState(viewModel.getCurrentState());
+ }
+ headingSliderLabel.setText(
+ getString(R.string.heading_offset_format, (int) value));
+ });
+
+ tiltSlider.addOnChangeListener(
+ (slider, value, fromUser) -> {
+ if (fromUser) {
+ viewModel.setCameraTilt(value);
+ updateCameraFromState(viewModel.getCurrentState());
+ }
+ tiltSliderLabel.setText(
+ getString(R.string.camera_tilt_format, (int) value));
+ });
+
+ speedSlider.addOnChangeListener(
+ (slider, value, fromUser) -> {
+ if (fromUser) viewModel.setFollowSpeed(value);
+ speedSliderLabel.setText(getString(R.string.follow_speed_format, (int) value));
+ });
+
+ rgEnvironment.setOnCheckedChangeListener(
+ (group, checkedId) -> {
+ if (checkedId == R.id.rb_urban) {
+ viewModel.setRoute(PathData.URBAN_PATH, /* applyDefaults= */ true);
+ pathAltitudeSlider.setValueTo(20.0f);
+ altitudeSlider.setValueTo(500.0f);
+ } else if (checkedId == R.id.rb_rural) {
+ viewModel.setRoute(PathData.RURAL_PATH, /* applyDefaults= */ true);
+ pathAltitudeSlider.setValueTo(200.0f);
+ altitudeSlider.setValueTo(500.0f);
+ }
+ resetPolylines();
+ });
+ }
+
+ private void setPanelCollapsed(boolean collapsed) {
+ if (isCollapsed == collapsed) return;
+ isCollapsed = collapsed;
+ if (controlsScroll != null) {
+ controlsScroll.setVisibility(isCollapsed ? View.GONE : View.VISIBLE);
+ }
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(
+ isCollapsed ? R.drawable.expand_less_24px : R.drawable.expand_more_24px);
+ }
+ }
+
+ private void showHelpDialog() {
+ new MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.help_dialog_title)
+ .setMessage(R.string.help_dialog_message)
+ .setPositiveButton(R.string.help_dialog_ok, null)
+ .show();
+ }
+
+ private void resetPolylines() {
+ lastStaticVertices = null;
+ lastRenderedProgressDist = -1.0;
+ PathPlaybackState state = viewModel.getCurrentState();
+ updateStaticPolyline(state);
+ updateProgressPolyline(state);
+ updateCameraFromState(state);
+ }
+
+ private void updateStaticPolyline(PathPlaybackState state) {
+ if (googleMap3D == null || state == null) return;
+ if (lastStaticVertices != null && lastStaticVertices.equals(state.getStaticPolylineVertices()) && staticRoutePolyline != null) return;
+
+ lastStaticVertices = state.getStaticPolylineVertices();
+ PolylineOptions staticOptions = new PolylineOptions();
+ staticOptions.setId(PathEngine.STATIC_POLYLINE_ID);
+ staticOptions.setPath(state.getStaticPolylineVertices());
+ staticOptions.setStrokeColor(Color.parseColor("#4285F4"));
+ staticOptions.setStrokeWidth(16.0);
+ staticOptions.setZIndex(1);
+ staticOptions.setAltitudeMode(state.getAltitudeMode());
+ staticOptions.setDrawsOccludedSegments(state.getDrawsOccludedSegments());
+ staticRoutePolyline = googleMap3D.addPolyline(staticOptions);
+ }
+
+ private void updateProgressPolyline(PathPlaybackState state) {
+ if (googleMap3D == null || state == null || state.getProgressPolylineVertices().size() < 2) return;
+
+ lastRenderedProgressDist = state.getElapsedDistance();
+ PolylineOptions progressOptions = new PolylineOptions();
+ progressOptions.setId(PathEngine.PROGRESS_POLYLINE_ID);
+ progressOptions.setPath(state.getProgressPolylineVertices());
+ progressOptions.setStrokeColor(Color.parseColor("#9C27B0"));
+ progressOptions.setStrokeWidth(8.0);
+ progressOptions.setZIndex(2);
+ progressOptions.setAltitudeMode(state.getAltitudeMode());
+ progressOptions.setDrawsOccludedSegments(state.getDrawsOccludedSegments());
+ progressPolyline = googleMap3D.addPolyline(progressOptions);
+ }
+
+ private void observeViewModel() {
+ viewModel.getLiveData().observe(this, state -> {
+ updateCameraFromState(state);
+ if (state.isPlaying() || Math.abs(state.getElapsedDistance() - lastRenderedProgressDist) > 0.1) {
+ updateProgressPolyline(state);
+ }
+ renderUiControls(state);
+ manageAnimationTicker(state.isPlaying());
+ });
+ }
+
+ private void updateCameraFromState(PathPlaybackState state) {
+ if (googleMap3D == null || state == null) return;
+ Camera newCamera =
+ new Camera(
+ /* center= */ new LatLngAltitude(
+ /* latitude= */ state.getCurrentPosition().latitude,
+ /* longitude= */ state.getCurrentPosition().longitude,
+ /* altitude= */ state.getCameraTargetAltitude()),
+ /* heading= */ state.getEffectiveHeading(),
+ /* tilt= */ state.getCameraTilt(),
+ /* roll= */ 0.0,
+ /* range= */ state.getCameraRange());
+ googleMap3D.setCamera(newCamera);
+ }
+
+ private void renderUiControls(PathPlaybackState state) {
+ if (state == null) return;
+
+ 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()) {
+ long now = System.currentTimeMillis();
+ if (now - lastSliderUpdateMillis >= 100L || !state.isPlaying()) {
+ lastSliderUpdateMillis = now;
+ progressSlider.setValue(state.getProgressRatio());
+ }
+ }
+
+ String boostSuffix = "";
+ if (state.getSpeedBoostMultiplier() >= 4.5) {
+ boostSuffix = " (5x Warp Speed)";
+ } else if (state.getSpeedBoostMultiplier() >= 1.5) {
+ boostSuffix = " (2x Boost)";
+ }
+ speedSliderLabel.setText(getString(R.string.follow_speed_format, (int) state.getFollowSpeedMps()) + boostSuffix);
+
+ if (!isCollapsed) {
+ float clampedRange = Math.max(rangeSlider.getValueFrom(), Math.min(rangeSlider.getValueTo(), (float) state.getCameraRange()));
+ if (Math.abs(rangeSlider.getValue() - clampedRange) >= 1.0f) {
+ rangeSlider.setValue(clampedRange);
+ rangeSliderLabel.setText(getString(R.string.camera_range_format, (int) state.getCameraRange()));
+ }
+
+ float clampedTilt = Math.max(tiltSlider.getValueFrom(), Math.min(tiltSlider.getValueTo(), (float) state.getCameraTilt()));
+ if (Math.abs(tiltSlider.getValue() - clampedTilt) >= 0.5f) {
+ tiltSlider.setValue(clampedTilt);
+ tiltSliderLabel.setText(getString(R.string.camera_tilt_format, (int) state.getCameraTilt()));
+ }
+
+ float clampedHeading = Math.max(headingSlider.getValueFrom(), Math.min(headingSlider.getValueTo(), (float) state.getHeadingOffset()));
+ if (Math.abs(headingSlider.getValue() - clampedHeading) >= 0.5f) {
+ headingSlider.setValue(clampedHeading);
+ headingSliderLabel.setText(getString(R.string.heading_offset_format, (int) state.getHeadingOffset()));
+ }
+ }
+ }
+
+ private void manageAnimationTicker(boolean isPlaying) {
+ if (isPlaying) {
+ if (frameCallback == null) {
+ frameCallback =
+ new Choreographer.FrameCallback() {
+ private long lastTimeNanos = 0L;
+
+ @Override
+ public void doFrame(long frameTimeNanos) {
+ if (!viewModel.getCurrentState().isPlaying()) return;
+
+ if (lastTimeNanos == 0L) {
+ lastTimeNanos = frameTimeNanos;
+ Choreographer.getInstance().postFrameCallback(this);
+ return;
+ }
+
+ double dt = (frameTimeNanos - lastTimeNanos) / 1_000_000_000.0;
+ lastTimeNanos = frameTimeNanos;
+
+ viewModel.advance(dt);
+ Choreographer.getInstance().postFrameCallback(this);
+ }
+ };
+ Choreographer.getInstance().postFrameCallback(frameCallback);
+ }
+ } else {
+ if (frameCallback != null) {
+ Choreographer.getInstance().removeFrameCallback(frameCallback);
+ frameCallback = null;
+ }
+ }
+ }
+
+ private void setupTouchAutoFade() {
+ scheduleControlFade();
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ if (ev.getAction() == MotionEvent.ACTION_DOWN || ev.getAction() == MotionEvent.ACTION_MOVE) {
+ fadeHandler.removeCallbacksAndMessages(null);
+ if (controlsCard != null) {
+ controlsCard.animate().alpha(1.0f).setDuration(150L).start();
+ }
+ } else if (ev.getAction() == MotionEvent.ACTION_UP || ev.getAction() == MotionEvent.ACTION_CANCEL) {
+ scheduleControlFade();
+ }
+ return super.dispatchTouchEvent(ev);
+ }
+
+ private void scheduleControlFade() {
+ fadeHandler.removeCallbacksAndMessages(null);
+ fadeHandler.postDelayed(
+ () -> {
+ if (controlsCard != null) {
+ controlsCard.animate().alpha(0.35f).setDuration(500L).start();
+ }
+ },
+ 3500L);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ map3DView.onResume();
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ viewModel.setPlaying(false);
+ viewModel.setSpeedBoosted(false);
+ map3DView.onPause();
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ viewModel.setPlaying(false);
+ if (frameCallback != null) {
+ Choreographer.getInstance().removeFrameCallback(frameCallback);
+ frameCallback = null;
+ }
+ fadeHandler.removeCallbacksAndMessages(null);
+ staticRoutePolyline = null;
+ progressPolyline = null;
+ map3DView.onDestroy();
+ }
+
+ @Override
+ public void onLowMemory() {
+ super.onLowMemory();
+ map3DView.onLowMemory();
+ }
+
@Override
- protected int getTitleResId() {
- return R.string.feature_title_path_following;
+ protected void onSaveInstanceState(@NonNull Bundle outState) {
+ super.onSaveInstanceState(outState);
+ map3DView.onSaveInstanceState(outState);
}
}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/roadmapmode/RoadmapModeActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/roadmapmode/RoadmapModeActivity.java
index aba4d625..e50eebf0 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/roadmapmode/RoadmapModeActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/roadmapmode/RoadmapModeActivity.java
@@ -16,15 +16,253 @@
package com.example.maps3djava.roadmapmode;
+import static com.example.maps3d.common.UtilitiesKt.toValidCamera;
+
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.transition.TransitionManager;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.RadioGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.cardview.widget.CardView;
+
import com.example.maps3dcommon.R;
-import com.example.maps3djava.common.BaseSkeletonActivity;
+import com.example.maps3djava.sampleactivity.SampleBaseActivity;
+import com.google.android.gms.maps.model.LatLng;
+import com.google.android.gms.maps3d.GoogleMap3D;
+import com.google.android.gms.maps3d.model.Camera;
+import com.google.android.gms.maps3d.model.LatLngAltitude;
+import com.google.android.gms.maps3d.model.Map3DMode;
+import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.button.MaterialButton;
/**
- * Skeleton activity for RoadmapModeActivity.
+ * =================================================================================================
+ * 3D Roadmap Mode & Render Style Switching (Java)
+ * =================================================================================================
+ *
+ * This sample demonstrates switching between distinct visual rendering modes provided by the
+ * Google Maps 3D SDK:
+ *
+ * Key Concepts Demonstrated:
+ * 1. 3D Map Rendering Modes ({@link Map3DMode}):
+ * - {@link Map3DMode#ROADMAP}: High-contrast 3D vector street network with clean white building
+ * massings, road labels, and stylized transit geometry.
+ * - {@link Map3DMode#HYBRID}: High-resolution 3D photorealistic mesh overlaid with prominent
+ * vector road networks, street names, and point-of-interest labels.
+ * - {@link Map3DMode#SATELLITE}: Pure photorealistic 3D mesh rendering without overlay labels
+ * or vector lines, ideal for cinematic aerial exploration.
+ *
+ * 2. Camera Stability & Safe Angle Validation:
+ * - Configures a dramatic 3D perspective centered on the San Francisco Financial District with
+ * {@link com.example.maps3d.common.UtilitiesKt#toValidCamera(Camera)}.
+ *
+ * 3. Modern Material UI & Collapse Affordances:
+ * - Bottom control card with quick radio button switching between map rendering styles.
+ * - Expandable / collapsible header bar for unobstructed 3D scene inspection.
+ * - Subtle UI idle auto-fade with touch-to-wake responsiveness.
*/
-public class RoadmapModeActivity extends BaseSkeletonActivity {
- @Override
- protected int getTitleResId() {
- return R.string.feature_title_roadmap_mode;
+public class RoadmapModeActivity extends SampleBaseActivity {
+
+ // --- Constants & Geographical Bounds ---
+
+ /** Focal landmark centered on the San Francisco Financial District. */
+ public static final LatLng SF_LOCATION = new LatLng(37.7915, -122.4010);
+
+ // --- UI Elements ---
+
+ private CardView controlsCard;
+ private View cardHeader;
+ private View cardContent;
+ private MaterialButton btnCollapse;
+ private RadioGroup rgMapMode;
+
+ // --- State Variables ---
+
+ private boolean isCollapsed = false;
+
+ // --- Handlers & Runnables ---
+
+ private final Handler fadeHandler = new Handler(Looper.getMainLooper());
+ private final Runnable fadeOutRunnable = () -> {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(0.85f)
+ .setDuration(400)
+ .start();
+ }
+ };
+
+ // --- Base Activity Overrides ---
+
+ @NonNull
+ @Override
+ public String getTAG() {
+ return "RoadmapModeActivity";
+ }
+
+ @NonNull
+ @Override
+ public Camera getInitialCamera() {
+ return toValidCamera(new Camera(
+ new LatLngAltitude(SF_LOCATION.latitude, SF_LOCATION.longitude, 250.0),
+ 45.0,
+ 65.0,
+ 0.0,
+ 800.0
+ ));
+ }
+
+ // --- Lifecycle & Initialization ---
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Hide base pill scroll view as this activity manages its own overlay card
+ View baseScrollView = findViewById(R.id.control_scroll_view);
+ if (baseScrollView != null) {
+ baseScrollView.setVisibility(View.GONE);
}
+
+ ViewGroup container = findViewById(R.id.map_container);
+ if (container != null) {
+ getLayoutInflater().inflate(R.layout.control_panel_roadmap_mode, container, true);
+ }
+
+ MaterialToolbar topBar = findViewById(R.id.top_bar);
+ if (topBar != null) {
+ topBar.setTitle(R.string.feature_title_roadmap_mode);
+ topBar.setNavigationOnClickListener(v -> finish());
+ }
+
+ initViews();
+ }
+
+ /**
+ * Initializes view references and wires up touch and click listeners.
+ */
+ private void initViews() {
+ controlsCard = findViewById(R.id.control_panel);
+ cardHeader = findViewById(R.id.card_header);
+ rgMapMode = findViewById(R.id.rg_map_mode);
+ cardContent = rgMapMode;
+ btnCollapse = findViewById(R.id.btn_collapse);
+
+ if (btnCollapse != null) {
+ btnCollapse.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
+ if (cardHeader != null) {
+ cardHeader.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
+ if (rgMapMode != null) {
+ rgMapMode.setOnCheckedChangeListener((group, checkedId) -> {
+ if (googleMap3D == null) {
+ return;
+ }
+ if (checkedId == R.id.rb_roadmap) {
+ googleMap3D.setMapMode(Map3DMode.ROADMAP);
+ } else if (checkedId == R.id.rb_hybrid) {
+ googleMap3D.setMapMode(Map3DMode.HYBRID);
+ } else if (checkedId == R.id.rb_satellite) {
+ googleMap3D.setMapMode(Map3DMode.SATELLITE);
+ }
+ });
+ }
+
+ // Schedule subtle initial auto-fade for unobstructed viewing
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ // --- UI Collapse / Expand Mechanics ---
+
+ /**
+ * Collapses the control card downward, leaving only the title header visible.
+ */
+ private void collapseControls() {
+ if (controlsCard == null || cardContent == null) {
+ return;
+ }
+ isCollapsed = true;
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_less_24px);
+ btnCollapse.setContentDescription(getString(R.string.expand_controls));
+ }
+ TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.GONE);
+ }
+
+ /**
+ * Expands the control card back to its full height.
+ */
+ private void expandControls() {
+ if (controlsCard == null || cardContent == null) {
+ return;
+ }
+ isCollapsed = false;
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_more_24px);
+ btnCollapse.setContentDescription(getString(R.string.collapse_controls));
+ }
+ TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.VISIBLE);
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ if (ev.getAction() == MotionEvent.ACTION_DOWN || ev.getAction() == MotionEvent.ACTION_MOVE) {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(1.0f)
+ .setDuration(150)
+ .start();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+ }
+ return super.dispatchTouchEvent(ev);
+ }
+
+ // --- 3D Map Setup ---
+
+ @Override
+ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
+ super.onMap3DViewReady(googleMap3D);
+ googleMap3D.setOnMapReadyListener(sceneReadiness -> {
+ googleMap3D.setOnMapReadyListener(null);
+ googleMap3D.setMapMode(Map3DMode.ROADMAP);
+ googleMap3D.setCamera(getInitialCamera());
+ });
+ }
+
+ // --- Teardown ---
+
+ @Override
+ protected void onDestroy() {
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ super.onDestroy();
+ }
}
+
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/routes/RoutesActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/routes/RoutesActivity.java
index e8b7d7c4..bf694e77 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/routes/RoutesActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/routes/RoutesActivity.java
@@ -17,22 +17,28 @@
package com.example.maps3djava.routes;
import static com.example.maps3d.common.UtilitiesKt.toHeading;
+import static com.example.maps3d.common.UtilitiesKt.toValidCamera;
+import android.annotation.SuppressLint;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
+import android.transition.TransitionManager;
import android.util.Log;
+import android.view.MotionEvent;
import android.view.View;
+import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
-import androidx.core.view.WindowCompat;
+import androidx.annotation.Nullable;
+import androidx.cardview.widget.CardView;
+import com.example.maps3d.common.OahuRouteData;
import com.example.maps3d.common.PositionAndHeading;
import com.example.maps3d.common.RouteEngine;
-import com.example.maps3d.common.OahuRouteData;
import com.example.maps3dcommon.R;
import com.example.maps3djava.BuildConfig;
import com.example.maps3djava.sampleactivity.SampleBaseActivity;
@@ -41,6 +47,7 @@
import com.google.android.gms.maps3d.OnMap3DViewReadyCallback;
import com.google.android.gms.maps3d.model.AltitudeMode;
import com.google.android.gms.maps3d.model.Camera;
+import com.google.android.gms.maps3d.model.LatLngAltitude;
import com.google.android.gms.maps3d.model.Map3DMode;
import com.google.android.gms.maps3d.model.Model;
import com.google.android.gms.maps3d.model.ModelOptions;
@@ -48,7 +55,6 @@
import com.google.android.gms.maps3d.model.Polyline;
import com.google.android.gms.maps3d.model.PolylineOptions;
import com.google.android.gms.maps3d.model.Vector3D;
-import com.google.android.gms.maps3d.model.LatLngAltitude;
import com.google.android.material.appbar.MaterialToolbar;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.slider.Slider;
@@ -58,210 +64,374 @@
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
/**
- * A premium View-based sample activity demonstrating cross-product integration with the Routes API in Java.
+ * Demonstrates cross-product integration between Google Maps Platform Routes API and GoogleMap3D.
*
- * This sample executes background threads to fetch driving routes in Honolulu, parses the encoded polyline,
- * renders the route line on [GoogleMap3D], loads a 3D model (.glb), and implements an android.os.Handler
- * framework to animate the car smoothly along the path with real-time camera tracking.
+ *
Key Architecture Highlights:
+ *
+ *
Async Route Calculation: Dispatches background tasks via {@link RouteRepository} to
+ * fetch driving directions in Honolulu, falling back gracefully to bundled Oahu coordinates
+ * when offline or unauthenticated.
+ *
3D Polyline Visualization: Renders clamped-to-ground 3D route paths that conform
+ * seamlessly to elevation and terrain variations.
+ *
3D glTF Model Anchoring: Loads a 3D vehicle model and updates its geographic
+ * coordinates and heading on each tick along the path.
+ *
Dynamic Camera Tracking: Synchronizes the 3D camera to follow behind the vehicle
+ * with configurable altitude, speed, and yaw offsets.
+ *
Ergonomic Collapsible UI: Employs an animated Material3 floating card with
+ * touch-aware auto-fade after 3 seconds of inactivity.