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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +