diff --git a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts index 648ead02..19f427fb 100644 --- a/Maps3DSamples/ApiDemos/java-app/build.gradle.kts +++ b/Maps3DSamples/ApiDemos/java-app/build.gradle.kts @@ -64,10 +64,6 @@ if (!isCI) { if (apiKey.isNullOrBlank() || !apiKey.matches(Regex("^AIza[a-zA-Z0-9_-]{35}$"))) { throw GradleException("Invalid or missing MAPS3D_API_KEY in secrets.properties. Please provide a valid Google Maps API key (starts with 'AIza').") } - - if (secrets.getProperty("MAPS_API_KEY") != null) { - println("Warning: Found MAPS_API_KEY in secrets.properties. This project relies exclusively on MAPS3D_API_KEY.") - } } } } @@ -138,7 +134,7 @@ dependencies { testImplementation(libs.json) // "org.json:json:20251224" testImplementation(libs.robolectric) // "org.robolectric:robolectric:4.16.1" testImplementation(libs.androidx.core) // "androidx.test:core:1.7.0" - testImplementation(libs.truth) // "com.google.truth:truth:1.4.5" + testImplementation(libs.google.truth) // "com.google.truth:truth:1.4.5" androidTestImplementation(libs.androidx.junit) androidTestImplementation(project(":Maps3DSamples:ApiDemos:common")) diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/MarkersActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/MarkersActivity.java index d93ab3bc..6419ead5 100644 --- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/MarkersActivity.java +++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/markers/MarkersActivity.java @@ -495,7 +495,7 @@ private void advanceTour(GoogleMap3D map) { .thenComposeAsync(isSteady -> { // If the user tapped "Stop" while we were waiting, gracefully exit the chain. if (!isTourActive) - return CompletableFuture.completedFuture((Void) null); + return CompletableFuture.completedFuture(null); FlyAroundOptions orbitOptions = new FlyAroundOptions(camera, 5000L, 1.0); return com.example.maps3djava.common.MapUtils.awaitCameraAnimation(map, orbitOptions); diff --git a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts index e50557d5..08387045 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts +++ b/Maps3DSamples/ApiDemos/kotlin-app/build.gradle.kts @@ -65,10 +65,6 @@ if (!isCI) { if (apiKey.isNullOrBlank() || !apiKey.matches(Regex("^AIza[a-zA-Z0-9_-]{35}$"))) { throw GradleException("Invalid or missing MAPS3D_API_KEY in secrets.properties. Please provide a valid Google Maps API key (starts with 'AIza').") } - - if (secrets.getProperty("MAPS_API_KEY") != null) { - println("Warning: Found MAPS_API_KEY in secrets.properties. This project relies exclusively on MAPS3D_API_KEY.") - } } } } @@ -144,7 +140,7 @@ dependencies { testImplementation(libs.json) // "org.json:json:20251224" testImplementation(libs.robolectric) // "org.robolectric:robolectric:4.16.1" testImplementation(libs.androidx.core) // "androidx.test:core:1.7.0" - testImplementation(libs.truth) // "com.google.truth:truth:1.4.5" + testImplementation(libs.google.truth) // "com.google.truth:truth:1.4.5" androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.uiautomator) androidTestImplementation(libs.androidx.espresso.core) diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/models/ModelsActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/models/ModelsActivity.kt index 47c5f6cd..e5d034c9 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/models/ModelsActivity.kt +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/models/ModelsActivity.kt @@ -20,7 +20,6 @@ import android.widget.Toast import androidx.lifecycle.lifecycleScope import com.example.maps3d.common.awaitCameraUpdate import com.example.maps3d.common.toCameraUpdate -import com.example.maps3d.common.toValidCamera import com.example.maps3dcommon.R import com.example.maps3dkotlin.sampleactivity.SampleBaseActivity import com.google.android.gms.maps3d.GoogleMap3D @@ -35,10 +34,9 @@ import com.google.android.gms.maps3d.model.orientation import com.google.android.gms.maps3d.model.vector3D import com.google.android.material.button.MaterialButton import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/polygons/PolygonsActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/polygons/PolygonsActivity.kt index 0998df3e..f3430946 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/polygons/PolygonsActivity.kt +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/polygons/PolygonsActivity.kt @@ -15,7 +15,6 @@ package com.example.maps3dkotlin.polygons import android.graphics.Color -import android.os.Bundle import android.util.Log import android.widget.Toast import androidx.lifecycle.lifecycleScope @@ -36,8 +35,6 @@ import com.google.android.gms.maps3d.model.latLngAltitude import com.google.android.gms.maps3d.model.polygonOptions import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds /** * This activity demonstrates how to create and display polygons on a 3D map. It showcases @@ -163,7 +160,6 @@ class PolygonsActivity : SampleBaseActivity() { } companion object { - private val TAG = PolygonsActivity::class.java.simpleName private const val DENVER_LATITUDE = 39.748477 private const val DENVER_LONGITUDE = -104.947575 diff --git a/Maps3DSamples/advanced/app/build.gradle.kts b/Maps3DSamples/advanced/app/build.gradle.kts index ce0330ad..93761b73 100644 --- a/Maps3DSamples/advanced/app/build.gradle.kts +++ b/Maps3DSamples/advanced/app/build.gradle.kts @@ -64,10 +64,6 @@ if (!isCI) { if (apiKey.isNullOrBlank() || !apiKey.matches(Regex("^AIza[a-zA-Z0-9_-]{35}$"))) { throw GradleException("Invalid or missing MAPS3D_API_KEY in secrets.properties. Please provide a valid Google Maps API key (starts with 'AIza').") } - - if (secrets.getProperty("MAPS_API_KEY") != null) { - println("Warning: Found MAPS_API_KEY in secrets.properties. This project relies exclusively on MAPS3D_API_KEY.") - } } } } diff --git a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ThreeDMap.kt b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ThreeDMap.kt index f51aacd3..2a53a7db 100644 --- a/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ThreeDMap.kt +++ b/Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/scenarios/ThreeDMap.kt @@ -15,6 +15,8 @@ package com.example.advancedmaps3dsamples.scenarios import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView import com.google.android.gms.maps3d.GoogleMap3D @@ -28,20 +30,23 @@ internal fun ThreeDMap( viewModel: ScenariosViewModel, modifier: Modifier = Modifier, ) { + // Use rememberUpdatedState to avoid capturing stale callbacks if they change + val currentOnMapSteadyChange by rememberUpdatedState { isSteady: Boolean -> + viewModel.onMapSteadyChange(isSteady) + } + AndroidView( modifier = modifier, factory = { context -> val map3dView = Map3DView(context = context, options = options) map3dView.onCreate(null) - map3dView - }, - update = { map3dView -> + map3dView.getMap3DViewAsync( object : OnMap3DViewReadyCallback { override fun onMap3DViewReady(googleMap3D: GoogleMap3D) { viewModel.setGoogleMap3D(googleMap3D) googleMap3D.setOnMapSteadyListener { isSceneSteady -> - viewModel.onMapSteadyChange(isSceneSteady) + currentOnMapSteadyChange(isSceneSteady) } } @@ -50,6 +55,11 @@ internal fun ThreeDMap( } } ) + + map3dView + }, + update = { _ -> + // No-op, updates are handled via viewModel and imperative calls on the stored instance }, onRelease = { _ -> // Clean up resources if needed diff --git a/README.md b/README.md index 4c80b808..849eaede 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,16 @@ The [Maps3DSamples/ApiDemos/common](Maps3DSamples/ApiDemos/common) module contai It also has [Map3dViewModel](Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/common/Map3dViewModel.kt) which demonstrates how to create an abstract base class for view models needing to interact with the Maps3DView via a GoogleMap3D. +## Experimental Compose Wrapper + +In addition to the advanced sample, this repository contains an experimental, declarative Compose wrapper for the Maps 3D SDK: + +* **[maps3d-compose](maps3d-compose)**: Provides the `GoogleMap3D` composable and supports declarative state management for markers, polylines, polygons, models, and popovers. +* **[maps3d-compose-demo](maps3d-compose-demo)**: Contains sample activities demonstrating how to use the wrapper. + +> [!WARNING] +> These modules are a **Work In Progress (WIP) experiment** and serve as a **reference implementation**. They are not intended for production use. + ## Requirements To run the samples, you will need: diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..793378a9 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + alias(libs.plugins.spotless) apply false +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 64e6441b..dc0c8af9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,16 +22,17 @@ material = "1.13.0" playServicesBase = "18.10.0" playServicesMaps3d = "0.2.0" -places = "5.1.1" +places = "5.2.0" secretsGradlePlugin = "2.0.1" truth = "1.4.5" uiautomator = "2.3.0" +spotless = "8.4.0" kotlinxDatetime = "0.7.1" -kotlinxSerialization = "1.10.0" -ktor = "3.4.1" +kotlinxSerialization = "1.11.0" +ktor = "3.4.2" hilt = "2.59.2" -ksp = "2.3.2" +ksp = "2.3.6" mapsUtilsKtx = "6.0.1" androidx-core-ktx = "1.8.9" @@ -63,7 +64,6 @@ play-services-base = { module = "com.google.android.gms:play-services-base", ver play-services-maps3d = { module = "com.google.android.gms:play-services-maps3d", version.ref = "playServicesMaps3d" } places = { module = "com.google.android.libraries.places:places", version.ref = "places" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } -truth = { module = "com.google.truth:truth", version.ref = "truth" } google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "uiautomator" } @@ -89,3 +89,4 @@ hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } jetbrains-kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/maps3d-compose-demo/README.md b/maps3d-compose-demo/README.md new file mode 100644 index 00000000..8e125460 --- /dev/null +++ b/maps3d-compose-demo/README.md @@ -0,0 +1,22 @@ +# Maps 3D Compose Demo + +This module is a sample application that demonstrates how to use the `maps3d-compose` wrapper library. + +> [!WARNING] +> **Status**: This application serves as a **reference implementation** for using the experimental Compose wrapper. It is a WIP experiment. + +## What's Inside + +You can find several sample activities demonstrating different features of the 3D Map in Compose: +- **Basic Map**: A simple map with camera controls. +- **Map Options**: Demonstrates changing map modes (Satellite/Hybrid) and applying camera restrictions. +- **Models**: Shows how to add and interact with 3D models (glTF) on the map, including click listeners. +- **Popovers**: Demonstrates the declarative Popover API, rendering Compose content inside native map popovers. + +## How to Run + +You can install and run this demo on a connected device or emulator using: +```bash +./gradlew :maps3d-compose-demo:installDebug +``` +Then launch any of the activities via the launcher or ADB. diff --git a/maps3d-compose-demo/build.gradle.kts b/maps3d-compose-demo/build.gradle.kts new file mode 100644 index 00000000..28cbd953 --- /dev/null +++ b/maps3d-compose-demo/build.gradle.kts @@ -0,0 +1,117 @@ +/* + * 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. + */ + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.secrets.gradle.plugin) + alias(libs.plugins.spotless) +} + +configure { + kotlin { + target("**/*.kt") + ktlint().editorConfigOverride(mapOf("indent_size" to "4", "ktlint_function_naming_ignore_when_annotated_with" to "Composable")) + trimTrailingWhitespace() + endWithNewline() + } +} + +android { + namespace = "com.example.maps3dcomposedemo" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + applicationId = "com.example.maps3dcomposedemo" + minSdk = libs.versions.minSdk.get().toInt() + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } + buildFeatures { + compose = true + buildConfig = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation(project(":maps3d-compose")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + + // Maps 3D SDK + implementation(libs.play.services.maps3d) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + androidTestImplementation(libs.androidx.uiautomator) + androidTestImplementation(libs.kotlinx.serialization.json) + androidTestImplementation(project(":visual-testing")) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} + +secrets { + propertiesFileName = "secrets.properties" +} + +tasks.register("installAndLaunch") { + description = "Installs and launches the demo app." + group = "install" + dependsOn("installDebug") + commandLine("adb", "shell", "am", "start", "-n", "com.example.maps3dcomposedemo/.MainActivity") +} diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/BaseVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/BaseVisualTest.kt new file mode 100644 index 00000000..4d4614b8 --- /dev/null +++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/BaseVisualTest.kt @@ -0,0 +1,70 @@ +/* + * 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.maps3dcomposedemo + +import android.app.Instrumentation +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import com.google.maps.android.visualtesting.GeminiVisualTestHelper +import org.junit.Assert.assertTrue +import java.io.File + +abstract class BaseVisualTest { + + protected val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation() + protected val uiDevice = UiDevice.getInstance(instrumentation) + protected val context: Context = instrumentation.targetContext + protected val helper = GeminiVisualTestHelper() + + protected val geminiApiKey: String by lazy { + val key = BuildConfig.GEMINI_API_KEY + assertTrue( + "GEMINI_API_KEY is not set in secrets.properties. Please add GEMINI_API_KEY=YOUR_API_KEY to your secrets.properties file.", + key != "YOUR_GEMINI_API_KEY", + ) + key + } + + protected fun captureScreenshot(filename: String = "screenshot_${System.currentTimeMillis()}.png"): Bitmap { + val screenshotFile = File(context.getExternalFilesDir(null), filename) + val screenshotTaken = uiDevice.takeScreenshot(screenshotFile) + assertTrue("Failed to take screenshot: $filename", screenshotTaken) + + val bitmap = BitmapFactory.decodeFile(screenshotFile.absolutePath) + assertTrue("Failed to decode screenshot file: $filename", bitmap != null) + + println("Screenshot saved to device: ${screenshotFile.absolutePath}") + println("To pull: adb pull ${screenshotFile.absolutePath}") + + return bitmap + } + + /** + * Waits for the map to render. + * Since MapView content (tiles, markers) is rendered on a GL surface and not exposed as + * accessibility nodes, we cannot rely on UiAutomator looking for text/markers. + * We use a stable delay to ensure rendering is complete. + */ + protected fun waitForMapRendering(timeoutSeconds: Long = 30) { + val found = uiDevice.wait(Until.hasObject(By.desc("MapSteady")), timeoutSeconds * 1000) + assertTrue("Map did not become steady within $timeoutSeconds seconds", found) + } +} diff --git a/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt new file mode 100644 index 00000000..86eae31d --- /dev/null +++ b/maps3d-compose-demo/src/androidTest/java/com/example/maps3dcomposedemo/Maps3DVisualTest.kt @@ -0,0 +1,500 @@ +/* + * 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.maps3dcomposedemo + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class Maps3DVisualTest : BaseVisualTest() { + + @Before + fun setup() { + // Launch the app + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) + context.startActivity(intent) + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + } + + @Test + fun verifyCatalogListRenders() = runBlocking { + // Wait for the list to appear + val found = uiDevice.wait(Until.hasObject(By.text("Maps 3D Compose Samples")), 5000) + assertTrue("Catalog list title not found", found) + + // Capture a screenshot + val screenshotBitmap = captureScreenshot("catalog_list_screenshot.png") + + // Define the verification prompt for Gemini + val prompt = """ + Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly. + Check the image against the following criteria: + 1. Confirm that the title 'Maps 3D Compose Samples' is visible. + 2. Confirm that there are multiple list items visible (e.g., "Basic Map with Marker & Polyline", "Hello Map", etc.). + + If all elements are present and look reasonable for a list of samples, reply with "PASSED". + If any element is missing or incorrect, please detail the discrepancy. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + + println("Gemini's analysis: $geminiResponse") + + // Assert on Gemini's response + assertTrue( + "Visual verification failed. Gemini response: $geminiResponse", + geminiResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } + + @Test + fun verifyHelloMapRenders() = runBlocking { + // Launch HelloMapActivity + val context = InstrumentationRegistry.getInstrumentation().targetContext + val intent = Intent(context, HelloMapActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load (up to 120 seconds for 3D SDK) + waitForMapRendering(120) + + // Capture a screenshot + val screenshotBitmap = captureScreenshot("hello_map_screenshot.png") + + // Define the verification prompt for Gemini + val prompt = """ + Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly. + Check the image against the following criteria: + 1. Confirm that a 3D map view is visible. + 2. Confirm that the Delicate Arch itself is clearly visible and a prominent part of the scene (it should look like a large freestanding rock arch). + + If all elements are present and the Delicate Arch is clearly visible, reply with "PASSED". + If any element is missing or incorrect, please detail the discrepancy. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + + println("Gemini's analysis: $geminiResponse") + + // Assert on Gemini's response + assertTrue( + "Visual verification failed. Gemini response: $geminiResponse", + geminiResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } + + @Test + fun verifyCameraControlsRenders() = runBlocking { + // Launch CameraControlsActivity directly + val intent = Intent(context, CameraControlsActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + // Capture a screenshot + val screenshotBitmap = captureScreenshot("camera_controls_screenshot.png") + + // Define the verification prompt for Gemini + val prompt = """ + Please act as a UI tester and analyze this screenshot to verify the application is rendering correctly. + Check the image against the following criteria: + 1. Confirm that a 3D map view is visible. + 2. Confirm that the Space Needle or surrounding Seattle urban area (like stadiums/arenas) is visible and prominent. + + If all elements are present and look reasonable for a 3D map of Seattle, reply with "PASSED". + If any element is missing or incorrect, please detail the discrepancy. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + + println("Gemini's analysis: $geminiResponse") + + // Assert on Gemini's response + assertTrue( + "Visual verification failed. Gemini response: $geminiResponse", + geminiResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } + + @Test + fun verifyMapInteractionsRenders() { + runBlocking { + // Launch MapInteractionsActivity directly + val intent = Intent(context, MapInteractionsActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + // Wait a bit more to ensure map is interactive + android.os.SystemClock.sleep(2000) + + // Strategy 1: Click the center of the screen and surrounding points + val screenWidth = uiDevice.displayWidth + val screenHeight = uiDevice.displayHeight + + val centerX = screenWidth / 2 + val centerY = screenHeight / 2 + + // Click center and a few points around it to increase chances + uiDevice.click(centerX, centerY) + android.os.SystemClock.sleep(500) + uiDevice.click(centerX + 50, centerY + 50) + android.os.SystemClock.sleep(500) + uiDevice.click(centerX - 50, centerY - 50) + android.os.SystemClock.sleep(500) + uiDevice.click(centerX + 50, centerY - 50) + android.os.SystemClock.sleep(500) + uiDevice.click(centerX - 50, centerY + 50) + + // Wait for the click info card to update with text containing "Clicked" + val textUpdated = uiDevice.wait( + Until.hasObject(By.descContains("Clicked")), + 10000, + ) + assertTrue("Card text did not update after click", textUpdated) + + // Verify that the text contains coordinates or place ID + val cardObject = uiDevice.findObject(By.descContains("Clicked")) + val description = cardObject.contentDescription + println("Card text: $description") + + assertTrue( + "Card text should contain 'Location' or 'Place ID'", + description.contains("Location") || description.contains("Place ID"), + ) + + // Capture a screenshot for visual confirmation + captureScreenshot("map_interactions_success.png") + } + } + + @Test + fun verifyMarkersRenders() { + runBlocking { + // Launch MarkersActivity directly + val intent = Intent(context, MarkersActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + // Capture a screenshot + val screenshotBitmap = captureScreenshot("markers_devils_tower.png") + + // Define the verification prompt for Gemini + val prompt = """ + Please act as a UI tester and analyze this screenshot. + 1. Confirm that a 3D map view is visible. + 2. Confirm that an alien icon or marker is visible on top of the prominent rock formation (Devils Tower). + + If the map is visible and the alien marker is seen on the tower, reply with "PASSED". + Otherwise, report what you see. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + println("Gemini's analysis: $geminiResponse") + + // Assert on Gemini's response + assertTrue( + "Visual verification failed. Gemini response: $geminiResponse", + geminiResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } + } + + @Test + fun verifyPolylinesRenders() { + // TODO: Add test for polyline click listener when we can reliably click on polylines. + runBlocking { + // Launch PolylinesActivity directly + val intent = Intent(context, PolylinesActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + // Capture a screenshot + val screenshotBitmap = captureScreenshot("polylines_sanitas.png") + + // Define the verification prompt for Gemini + val prompt = """ + Please act as a UI tester and analyze this screenshot. + 1. Confirm that a 3D map view is visible. + 2. Confirm that a red polyline (line) is visible on the map, representing a trail. + + If the map is visible and the red polyline is seen, reply with "PASSED". + Otherwise, report what you see. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + println("Gemini's analysis: $geminiResponse") + + // Assert on Gemini's response + assertTrue( + "Visual verification failed. Gemini response: $geminiResponse", + geminiResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } + } + + @Test + fun verifyPolygonsRenders() { + runBlocking { + // Launch PolygonsActivity directly + val intent = Intent(context, PolygonsActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + // Capture a screenshot + val screenshotBitmap = captureScreenshot("polygons_denver_zoo.png") + + // Define the verification prompt for Gemini + val prompt = """ + Please act as a UI tester and analyze this screenshot. + 1. Confirm that a 3D map view is visible. + 2. Confirm that a yellow translucent polygon with a green border is visible on the map. + + If the map is visible and the yellow polygon is seen, reply with "PASSED". + Otherwise, report what you see. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + println("Gemini's analysis: $geminiResponse") + + // Assert on Gemini's response + assertTrue( + "Visual verification failed. Gemini response: $geminiResponse", + geminiResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } + } + + @Test + fun verifyCustomMarkersRenders() { + runBlocking { + // Launch CustomMarkersActivity directly + val intent = Intent(context, CustomMarkersActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + // Capture a screenshot + val screenshotBitmap = captureScreenshot("custom_markers_screenshot.png") + + // Define the verification prompt for Gemini + val prompt = """ + Please act as a UI tester and analyze this screenshot. + 1. Confirm that a 3D map view is visible. + 2. Confirm that multiple markers with different styles are visible around the rock formation (Devils Tower). + 3. Specifically look for: + - A red pin. + - A marker with text "DT". + - A green pin with a blue circle. + - A marker with an alien icon. + + If the map is visible and these custom styled markers are seen, reply with "PASSED". + Otherwise, report what you see. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + println("Gemini's analysis: $geminiResponse") + + // Assert on Gemini's response + assertTrue( + "Visual verification failed. Gemini response: $geminiResponse", + geminiResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } + } + + @Test + fun verifyPlaceClick() { + runBlocking { + // Launch PlaceClickActivity directly + val intent = Intent(context, PlaceClickActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + // Capture a screenshot to find the place mark + val screenshotBitmap = captureScreenshot("place_click_search.png") + + // Define the prompt for Gemini to find coordinates + val promptFind = """ + Analyze this screenshot of a 3D map. + You should see a landmark called "Devils Tower" near the center. + Find the label or marker for "Devils Tower". + Return its center coordinates as a JSON object: {"x": , "y": } where x and y are normalized coordinates between 0.0 and 1.0 (0.0 is top/left, 1.0 is bottom/right). + Return ONLY the JSON object, nothing else. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(screenshotBitmap, promptFind, geminiApiKey) + println("Gemini's coordinate response: $geminiResponse") + + // Parse JSON and click + try { + val jsonStr = geminiResponse?.substringAfter("{")?.substringBeforeLast("}")?.let { "{$it}" } ?: "" + val json = org.json.JSONObject(jsonStr) + val x = json.getDouble("x") + val y = json.getDouble("y") + + val clickX = (x * uiDevice.displayWidth).toInt() + val clickY = (y * uiDevice.displayHeight).toInt() + + uiDevice.click(clickX, clickY) + } catch (e: Exception) { + org.junit.Assert.fail("Failed to parse coordinates from Gemini response: $geminiResponse. Error: ${e.message}") + } + + // Wait for UI to update + kotlinx.coroutines.delay(2000) + + // Capture another screenshot to verify the click + val verifyBitmap = captureScreenshot("place_click_verification.png") + + // Define the verification prompt + val promptVerify = """ + Please act as a UI tester and analyze this screenshot. + Confirm that the text "Clicked Place:" followed by an ID is visible at the bottom of the screen. + + If the text is seen, reply with "PASSED". + Otherwise, report what you see. + """.trimIndent() + + val verifyResponse = helper.analyzeImage(verifyBitmap, promptVerify, geminiApiKey) + println("Gemini's verification response: $verifyResponse") + + // Assert on Gemini's response + assertTrue( + "Visual verification failed. Gemini response: $verifyResponse", + verifyResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } + } + + @Test + fun verifyRotationMaintainsMap() = runBlocking { + // Launch PolylinesActivity directly + val intent = Intent(context, PolylinesActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + + // Wait for the activity to be displayed + uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000) + + // Wait for the map to render and tiles to load + waitForMapRendering(60) + + try { + // Rotate device to landscape + uiDevice.setOrientationLeft() + + // Wait for rotation to complete and map to render again + kotlinx.coroutines.delay(5000) // Wait for rotation animation + waitForMapRendering(60) + + // Capture a screenshot in landscape + val screenshotBitmap = captureScreenshot("polylines_landscape.png") + + // Define the verification prompt for Gemini + val prompt = """ + Please act as a UI tester and analyze this screenshot of a 3D map in landscape mode. + 1. Confirm that a 3D map view is visible. + 2. Confirm that a red polyline is still visible on the map. + + If the map is visible and the red polyline is seen, reply with "PASSED". + Otherwise, report what you see. + """.trimIndent() + + // Analyze the image using Gemini + val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey) + println("Gemini's analysis (Landscape): $geminiResponse") + + // Assert on Gemini's response + assertTrue( + "Visual verification failed in landscape. Gemini response: $geminiResponse", + geminiResponse?.contains("PASSED", ignoreCase = true) == true, + ) + } finally { + // Restore orientation + uiDevice.setOrientationNatural() + kotlinx.coroutines.delay(2000) // Wait for it to settle + } + } +} diff --git a/maps3d-compose-demo/src/main/AndroidManifest.xml b/maps3d-compose-demo/src/main/AndroidManifest.xml new file mode 100644 index 00000000..c49875d0 --- /dev/null +++ b/maps3d-compose-demo/src/main/AndroidManifest.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/BasicMapActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/BasicMapActivity.kt new file mode 100644 index 00000000..9e486af3 --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/BasicMapActivity.kt @@ -0,0 +1,192 @@ +/* + * 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.maps3dcomposedemo + +import android.graphics.Color +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.CollisionBehavior +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D +import com.google.maps.android.compose3d.MarkerConfig +import com.google.maps.android.compose3d.PolylineConfig + +/** + * Activity that demonstrates a basic 3D map with a marker and a polyline using Compose. + */ +class BasicMapActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + BasicMapSample() + } + } + } + } +} + +@Composable +fun BasicMapSample() { + // Hoist the camera state + var cameraState by remember { + mutableStateOf( + camera { + center = latLngAltitude { + latitude = 37.8199 + longitude = -122.4783 + altitude = 0.0 + } + heading = 0.0 + tilt = 60.0 + range = 3000.0 + roll = 0.0 + }, + ) + } + + var mapMode by remember { mutableIntStateOf(Map3DMode.SATELLITE) } + var isMapSteady by remember { mutableStateOf(false) } + + // Sample markers + val markers = remember { + listOf( + MarkerConfig( + key = "golden_gate", + position = latLngAltitude { + latitude = 37.8199 + longitude = -122.4783 + altitude = 1.0 + }, + label = "Golden Gate Bridge", + altitudeMode = AltitudeMode.RELATIVE_TO_MESH, + isDrawnWhenOccluded = true, + collisionBehavior = CollisionBehavior.REQUIRED_AND_HIDES_OPTIONAL, + ), + ) + } + + // Sample polyline + val polylines = remember { + listOf( + PolylineConfig( + key = "sample_line", + points = listOf( + latLngAltitude { + latitude = 37.8199 + longitude = -122.4783 + altitude = 0.0 + }, + latLngAltitude { + latitude = 37.8299 + longitude = -122.4883 + altitude = 0.0 + }, + ), + color = Color.RED, + width = 10f, + altitudeMode = AltitudeMode.CLAMP_TO_GROUND, + ), + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + GoogleMap3D( + camera = cameraState, + markers = markers, + polylines = polylines, + mapMode = mapMode, + modifier = Modifier.fillMaxSize(), + onMapReady = { + // Map is ready, we could perform additional setup here if needed + }, + onMapSteady = { + isMapSteady = true + }, + ) + + // UI Controls + FloatingActionButton( + onClick = { + mapMode = if (mapMode == Map3DMode.SATELLITE) { + Map3DMode.HYBRID + } else { + Map3DMode.SATELLITE + } + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + ) { + Text(text = if (mapMode == Map3DMode.SATELLITE) "Hybrid" else "Satellite") + } + + FloatingActionButton( + onClick = { + // Reset camera + cameraState = camera { + center = latLngAltitude { + latitude = 37.8199 + longitude = -122.4783 + altitude = 0.0 + } + heading = 0.0 + tilt = 60.0 + range = 2000.0 + roll = 0.0 + } + }, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(16.dp), + ) { + Text(text = "Reset") + } + } +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraAnimationsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraAnimationsActivity.kt new file mode 100644 index 00000000..64a53fe0 --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraAnimationsActivity.kt @@ -0,0 +1,190 @@ +/* + * 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.maps3dcomposedemo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.flyAroundOptions +import com.google.android.gms.maps3d.model.flyToOptions +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +class CameraAnimationsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + CameraAnimationsScreen() + } + } + } + } +} + +@Composable +fun CameraAnimationsScreen() { + var mapInstance by remember { mutableStateOf(null) } + var statusText by remember { mutableStateOf("Idle") } + val coroutineScope = rememberCoroutineScope() + + val newYork = latLngAltitude { + latitude = 40.7128 + longitude = -74.0060 + altitude = 0.0 + } + val sf = latLngAltitude { + latitude = 37.7749 + longitude = -122.4194 + altitude = 0.0 + } + + Box(modifier = Modifier.fillMaxSize()) { + GoogleMap3D( + camera = camera { + center = newYork + range = 10000.0 + tilt = 45.0 + }, + modifier = Modifier.fillMaxSize(), + onMapReady = { googleMap3D -> + mapInstance = googleMap3D + + googleMap3D.setOnMapSteadyListener { isSteady -> + if (isSteady) { + statusText = "Steady" + } + } + }, + ) + + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(16.dp) + .background(Color.Black.copy(alpha = 0.7f), RoundedCornerShape(8.dp)) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = "Status: $statusText", color = Color.White) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button(onClick = { + mapInstance?.let { map -> + statusText = "Animating to SF" + map.flyCameraTo( + flyToOptions { + endCamera = camera { + center = sf + range = 5000.0 + tilt = 60.0 + } + durationInMillis = 5000 + }, + ) + coroutineScope.launch { + map.awaitCameraAnimation() + statusText = "Animation Ended (SF)" + } + } + }) { + Text("Fly to SF") + } + + Button(onClick = { + mapInstance?.let { map -> + statusText = "Orbiting" + map.flyCameraAround( + flyAroundOptions { + rounds = 1.0 + durationInMillis = 10000 + }, + ) + coroutineScope.launch { + map.awaitCameraAnimation() + statusText = "Orbit Ended" + } + } + }) { + Text("Orbit") + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Button(onClick = { + mapInstance?.let { map -> + map.stopCameraAnimation() + statusText = "Animation Stopped" + } + }) { + Text("Stop") + } + } + } + } +} + +// Helper extensions +suspend fun com.google.android.gms.maps3d.GoogleMap3D.awaitCameraAnimation() = suspendCancellableCoroutine { continuation -> + setCameraAnimationEndListener { + setCameraAnimationEndListener(null) // Cleanup + if (continuation.isActive) { + continuation.resume(Unit) + } + } + + continuation.invokeOnCancellation { + setCameraAnimationEndListener(null) + } +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraChangedActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraChangedActivity.kt new file mode 100644 index 00000000..256eb929 --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraChangedActivity.kt @@ -0,0 +1,124 @@ +/* + * 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.maps3dcomposedemo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.maps3dcomposedemo.widgets.RangeScale +import com.example.maps3dcomposedemo.widgets.TiltScale +import com.example.maps3dcomposedemo.widgets.WhiskeyCompass +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D +import kotlinx.coroutines.flow.MutableStateFlow + +class CameraChangedActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + CameraChangedScreen() + } + } + } + } +} + +@Composable +fun CameraChangedScreen() { + // Camera centered on San Francisco + val initialCamera = remember { + camera { + center = latLngAltitude { + latitude = 37.7749 + longitude = -122.4194 + altitude = 0.0 + } + heading = 0.0 + tilt = 45.0 + range = 2000.0 + roll = 0.0 + } + } + + // Use a flow to hold the camera state, as requested + val cameraFlow = remember { MutableStateFlow(initialCamera) } + val currentCamera by cameraFlow.collectAsState() + + Box(modifier = Modifier.fillMaxSize()) { + GoogleMap3D( + camera = initialCamera, + mapMode = Map3DMode.HYBRID, + modifier = Modifier.fillMaxSize(), + onCameraChanged = { camera -> + cameraFlow.value = camera + }, + ) + + val heading = currentCamera.heading?.toFloat() ?: 0f + val tilt = currentCamera.tilt?.toFloat() ?: 0f + val range = currentCamera.range?.toFloat() ?: 0f + + // Overlay the Whiskey Compass at the top + WhiskeyCompass( + heading = heading, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + // Padding for edge-to-edge only at top + .padding(top = 48.dp), + backgroundColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f), + ) + + // Overlay Tilt Scale on the right + TiltScale( + tilt = tilt, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 16.dp), + ) + + // Overlay Range Scale at the bottom + RangeScale( + range = range, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 32.dp), + ) + } +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraControlsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraControlsActivity.kt new file mode 100644 index 00000000..f64f348d --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraControlsActivity.kt @@ -0,0 +1,88 @@ +/* + * 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.maps3dcomposedemo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D + +class CameraControlsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + CameraControlsScreen() + } + } + } + } +} + +@Composable +fun CameraControlsScreen() { + var isMapSteady by remember { mutableStateOf(false) } + + // Calibrated camera centered around Seattle (Space Needle area) + val seattleCamera = remember { + camera { + center = latLngAltitude { + latitude = 47.620527586075134 + longitude = -122.34935779313246 + altitude = 155.2680153221576 + } + heading = 153.21621713443722 + tilt = 79.73103098583165 + range = 584.2747848654835 + roll = 0.0 + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + GoogleMap3D( + camera = seattleCamera, + modifier = Modifier.fillMaxSize(), + onMapSteady = { + isMapSteady = true + }, + ) + } +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CustomMarkersActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CustomMarkersActivity.kt new file mode 100644 index 00000000..afb01da7 --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CustomMarkersActivity.kt @@ -0,0 +1,158 @@ +/* + * 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.maps3dcomposedemo + +import android.graphics.Color +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GlyphConfig +import com.google.maps.android.compose3d.GoogleMap3D +import com.google.maps.android.compose3d.MarkerConfig +import com.google.maps.android.compose3d.PinConfig + +class CustomMarkersActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + CustomMarkersScreen() + } + } + } + } +} + +@Composable +fun CustomMarkersScreen() { + var isMapSteady by remember { mutableStateOf(false) } + + // Camera centered on Devils Tower + val devilsTowerCamera = remember { + camera { + center = latLngAltitude { + latitude = 44.589994 + longitude = -104.715326 + altitude = 1508.9 + } + heading = 1.0 + tilt = 60.0 + range = 5000.0 + roll = 0.0 + } + } + + val markers = remember { + listOf( + MarkerConfig( + key = "red_pin", + position = latLngAltitude { + latitude = 44.5930 + longitude = -104.7180 + altitude = 10.0 + }, + altitudeMode = AltitudeMode.RELATIVE_TO_MESH, + label = "Red Pin", + pinConfig = PinConfig( + scale = 1.5f, + backgroundColor = Color.RED, + borderColor = Color.WHITE, + ), + ), + MarkerConfig( + key = "text_glyph", + position = latLngAltitude { + latitude = 44.5870 + longitude = -104.7120 + altitude = 10.0 + }, + altitudeMode = AltitudeMode.RELATIVE_TO_MESH, + label = "Text Glyph", + pinConfig = PinConfig( + glyph = GlyphConfig.Text("DT", color = Color.BLACK), + backgroundColor = Color.YELLOW, + ), + ), + MarkerConfig( + key = "circle_glyph", + position = latLngAltitude { + latitude = 44.5900 + longitude = -104.7100 + altitude = 10.0 + }, + altitudeMode = AltitudeMode.RELATIVE_TO_MESH, + label = "Circle Glyph", + pinConfig = PinConfig( + glyph = GlyphConfig.Circle(color = Color.BLUE), + backgroundColor = Color.GREEN, + ), + ), + MarkerConfig( + key = "image_glyph", + position = latLngAltitude { + latitude = 44.5960 + longitude = -104.7250 + altitude = 10.0 + }, + altitudeMode = AltitudeMode.RELATIVE_TO_MESH, + label = "Image Glyph", + pinConfig = PinConfig( + glyph = GlyphConfig.Image(R.drawable.alien, color = Color.WHITE), + backgroundColor = Color.CYAN, + ), + ), + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + GoogleMap3D( + camera = devilsTowerCamera, + mapMode = Map3DMode.HYBRID, + markers = markers, + modifier = Modifier.fillMaxSize(), + onMapSteady = { + isMapSteady = true + }, + ) + } +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/Data.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/Data.kt new file mode 100644 index 00000000..7a0f8468 --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/Data.kt @@ -0,0 +1,1554 @@ +// Copyright 2025 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.maps3dcomposedemo + +/** + * A trail up Mount Sanitas in Boulder, Colorado. + */ +internal val sanitasLoop = """ + 40.0201040, -105.2976640 + 40.0201080, -105.2976450 + 40.0201640, -105.2975120 + 40.0202200, -105.2973740 + 40.0202500, -105.2972760 + 40.0202960, -105.2971410 + 40.0203080, -105.2970990 + 40.0203320, -105.2970070 + 40.0203640, -105.2969400 + 40.0203710, -105.2969250 + 40.0203770, -105.2969220 + 40.0203910, -105.2969130 + 40.0203940, -105.2969120 + 40.0204200, -105.2969130 + 40.0204630, -105.2968910 + 40.0205270, -105.2968280 + 40.0206030, -105.2967570 + 40.0206590, -105.2966100 + 40.0206990, -105.2964870 + 40.0207290, -105.2963090 + 40.0207300, -105.2963070 + 40.0207490, -105.2963130 + 40.0208050, -105.2963460 + 40.0208120, -105.2963490 + 40.0209090, -105.2963280 + 40.0209720, -105.2963220 + 40.0209820, -105.2963320 + 40.0209940, -105.2963650 + 40.0210100, -105.2963890 + 40.0210470, -105.2964310 + 40.0210580, -105.2964600 + 40.0210620, -105.2964670 + 40.0210700, -105.2964730 + 40.0210930, -105.2964810 + 40.0211260, -105.2964920 + 40.0211330, -105.2964940 + 40.0211460, -105.2964970 + 40.0211790, -105.2964880 + 40.0211850, -105.2964900 + 40.0211940, -105.2964990 + 40.0212070, -105.2965170 + 40.0212440, -105.2965230 + 40.0212690, -105.2965050 + 40.0212820, -105.2964860 + 40.0213030, -105.2964820 + 40.0213910, -105.2965070 + 40.0214170, -105.2965640 + 40.0214310, -105.2966060 + 40.0214510, -105.2966250 + 40.0214610, -105.2966250 + 40.0214900, -105.2966160 + 40.0215010, -105.2966140 + 40.0215420, -105.2966170 + 40.0215490, -105.2966240 + 40.0215680, -105.2966660 + 40.0215850, -105.2966580 + 40.0215670, -105.2967210 + 40.0215720, -105.2967320 + 40.0216080, -105.2967810 + 40.0216000, -105.2967920 + 40.0215890, -105.2968390 + 40.0215740, -105.2968660 + 40.0215710, -105.2968720 + 40.0215470, -105.2969270 + 40.0215430, -105.2969400 + 40.0215280, -105.2969570 + 40.0214980, -105.2970100 + 40.0214950, -105.2970270 + 40.0214980, -105.2970750 + 40.0215000, -105.2971370 + 40.0215080, -105.2971650 + 40.0215100, -105.2971650 + 40.0215240, -105.2971730 + 40.0215250, -105.2971720 + 40.0215290, -105.2971720 + 40.0215290, -105.2971710 + 40.0215290, -105.2971630 + 40.0215270, -105.2971670 + 40.0215180, -105.2972120 + 40.0215070, -105.2972370 + 40.0214640, -105.2973310 + 40.0214550, -105.2973440 + 40.0214360, -105.2973800 + 40.0214170, -105.2974290 + 40.0213830, -105.2974730 + 40.0213650, -105.2974980 + 40.0213680, -105.2975510 + 40.0213580, -105.2976040 + 40.0213680, -105.2976650 + 40.0213750, -105.2976830 + 40.0213900, -105.2976930 + 40.0213970, -105.2977160 + 40.0213920, -105.2977290 + 40.0213900, -105.2977480 + 40.0213930, -105.2977630 + 40.0214020, -105.2977960 + 40.0214160, -105.2978200 + 40.0214300, -105.2978340 + 40.0214340, -105.2978420 + 40.0214310, -105.2978830 + 40.0214340, -105.2979180 + 40.0214370, -105.2979360 + 40.0214660, -105.2979600 + 40.0214740, -105.2979790 + 40.0214800, -105.2979830 + 40.0215000, -105.2979910 + 40.0215080, -105.2980210 + 40.0215110, -105.2980290 + 40.0215200, -105.2980420 + 40.0215480, -105.2980680 + 40.0215530, -105.2980690 + 40.0215690, -105.2980540 + 40.0215930, -105.2980560 + 40.0215980, -105.2980760 + 40.0216060, -105.2980770 + 40.0216220, -105.2980850 + 40.0216290, -105.2980880 + 40.0216460, -105.2981000 + 40.0216670, -105.2981260 + 40.0216740, -105.2981480 + 40.0216770, -105.2981580 + 40.0216810, -105.2981680 + 40.0216980, -105.2981840 + 40.0217130, -105.2982030 + 40.0217270, -105.2982190 + 40.0217490, -105.2982340 + 40.0217780, -105.2982490 + 40.0217890, -105.2982530 + 40.0218190, -105.2982880 + 40.0218230, -105.2982990 + 40.0218330, -105.2983010 + 40.0218490, -105.2983030 + 40.0218840, -105.2982950 + 40.0218970, -105.2982890 + 40.0219190, -105.2982790 + 40.0219350, -105.2982620 + 40.0219670, -105.2982660 + 40.0219710, -105.2982700 + 40.0219930, -105.2983070 + 40.0220040, -105.2983340 + 40.0220350, -105.2983630 + 40.0220870, -105.2983850 + 40.0220870, -105.2983820 + 40.0221100, -105.2983540 + 40.0221360, -105.2983430 + 40.0221470, -105.2983430 + 40.0221620, -105.2983330 + 40.0221870, -105.2983190 + 40.0222350, -105.2983130 + 40.0222710, -105.2982950 + 40.0222850, -105.2982950 + 40.0223160, -105.2982870 + 40.0223250, -105.2982920 + 40.0223410, -105.2983040 + 40.0223890, -105.2983220 + 40.0224060, -105.2983260 + 40.0224260, -105.2983290 + 40.0224320, -105.2983270 + 40.0224440, -105.2983310 + 40.0224840, -105.2983290 + 40.0224890, -105.2983310 + 40.0224900, -105.2983330 + 40.0225380, -105.2983600 + 40.0225490, -105.2983650 + 40.0225830, -105.2983730 + 40.0225900, -105.2983740 + 40.0226190, -105.2983930 + 40.0226310, -105.2984710 + 40.0226420, -105.2984710 + 40.0226520, -105.2984700 + 40.0226630, -105.2984700 + 40.0226790, -105.2984670 + 40.0227100, -105.2984830 + 40.0227230, -105.2984930 + 40.0227370, -105.2984810 + 40.0227400, -105.2984820 + 40.0227550, -105.2984930 + 40.0227960, -105.2985050 + 40.0228030, -105.2985160 + 40.0228200, -105.2985270 + 40.0228320, -105.2985310 + 40.0228380, -105.2985310 + 40.0228600, -105.2985560 + 40.0228660, -105.2985600 + 40.0228770, -105.2985560 + 40.0228830, -105.2985570 + 40.0228900, -105.2985590 + 40.0229210, -105.2985790 + 40.0229390, -105.2985990 + 40.0229510, -105.2986240 + 40.0230030, -105.2986460 + 40.0230410, -105.2986660 + 40.0230680, -105.2986750 + 40.0231020, -105.2986900 + 40.0231320, -105.2986980 + 40.0231910, -105.2987450 + 40.0232120, -105.2987550 + 40.0232230, -105.2987590 + 40.0232660, -105.2987540 + 40.0232920, -105.2987570 + 40.0233090, -105.2987630 + 40.0233560, -105.2987770 + 40.0234730, -105.2987670 + 40.0235930, -105.2987510 + 40.0236340, -105.2987430 + 40.0236560, -105.2987430 + 40.0236810, -105.2987430 + 40.0237340, -105.2987310 + 40.0238230, -105.2987030 + 40.0238450, -105.2986990 + 40.0238800, -105.2987020 + 40.0239000, -105.2987190 + 40.0239100, -105.2987370 + 40.0239260, -105.2987690 + 40.0239460, -105.2988040 + 40.0239990, -105.2988670 + 40.0240350, -105.2989020 + 40.0240550, -105.2989150 + 40.0240600, -105.2989170 + 40.0240680, -105.2989230 + 40.0240750, -105.2989280 + 40.0240780, -105.2989360 + 40.0241120, -105.2989990 + 40.0242240, -105.2990910 + 40.0242650, -105.2990930 + 40.0242920, -105.2991000 + 40.0243420, -105.2991160 + 40.0244060, -105.2991360 + 40.0244160, -105.2991380 + 40.0244660, -105.2991390 + 40.0244770, -105.2991420 + 40.0244970, -105.2991480 + 40.0245320, -105.2991610 + 40.0245770, -105.2991810 + 40.0246660, -105.2991740 + 40.0246750, -105.2991730 + 40.0247120, -105.2991710 + 40.0247460, -105.2991620 + 40.0247690, -105.2991650 + 40.0248050, -105.2991710 + 40.0248510, -105.2991710 + 40.0248910, -105.2992000 + 40.0248980, -105.2992110 + 40.0249060, -105.2992220 + 40.0249230, -105.2992250 + 40.0249390, -105.2992230 + 40.0249620, -105.2992380 + 40.0249830, -105.2992570 + 40.0250010, -105.2992730 + 40.0250150, -105.2992790 + 40.0250530, -105.2992880 + 40.0250590, -105.2992950 + 40.0250680, -105.2993240 + 40.0250890, -105.2993520 + 40.0251040, -105.2993710 + 40.0251680, -105.2994320 + 40.0252780, -105.2995230 + 40.0253010, -105.2995500 + 40.0253350, -105.2995950 + 40.0253500, -105.2996020 + 40.0253880, -105.2996130 + 40.0254040, -105.2996150 + 40.0254370, -105.2996420 + 40.0254490, -105.2996530 + 40.0254680, -105.2996690 + 40.0255100, -105.2996920 + 40.0255470, -105.2997460 + 40.0255530, -105.2997600 + 40.0255700, -105.2997800 + 40.0256120, -105.2998030 + 40.0256440, -105.2998160 + 40.0256990, -105.2998290 + 40.0257590, -105.2998430 + 40.0257870, -105.2998510 + 40.0258060, -105.2998610 + 40.0258890, -105.2998780 + 40.0258940, -105.2998790 + 40.0259070, -105.2998880 + 40.0259410, -105.2998980 + 40.0260250, -105.2999160 + 40.0260350, -105.2999140 + 40.0260440, -105.2999140 + 40.0261120, -105.2999520 + 40.0261450, -105.2999620 + 40.0261780, -105.2999810 + 40.0262350, -105.3000140 + 40.0262880, -105.3000300 + 40.0263100, -105.3000360 + 40.0263190, -105.3000370 + 40.0263460, -105.3000490 + 40.0263640, -105.3000570 + 40.0264090, -105.3000870 + 40.0264460, -105.3001080 + 40.0264740, -105.3001430 + 40.0264780, -105.3001480 + 40.0265140, -105.3002100 + 40.0265290, -105.3002120 + 40.0265690, -105.3002070 + 40.0265760, -105.3002040 + 40.0266160, -105.3002220 + 40.0266560, -105.3002270 + 40.0266800, -105.3002210 + 40.0267120, -105.3001900 + 40.0267680, -105.3002060 + 40.0267830, -105.3002270 + 40.0268000, -105.3002380 + 40.0268170, -105.3002460 + 40.0268440, -105.3002490 + 40.0268590, -105.3002560 + 40.0268790, -105.3002710 + 40.0268880, -105.3002940 + 40.0269060, -105.3003340 + 40.0269160, -105.3003550 + 40.0269280, -105.3003730 + 40.0269340, -105.3003790 + 40.0269530, -105.3003920 + 40.0269680, -105.3004060 + 40.0269820, -105.3004160 + 40.0270260, -105.3004380 + 40.0270430, -105.3004500 + 40.0270900, -105.3005030 + 40.0271200, -105.3005240 + 40.0271280, -105.3005340 + 40.0271480, -105.3005630 + 40.0271760, -105.3005730 + 40.0271910, -105.3005830 + 40.0272370, -105.3006310 + 40.0272990, -105.3007520 + 40.0273310, -105.3007940 + 40.0273510, -105.3008320 + 40.0273780, -105.3008640 + 40.0274180, -105.3008810 + 40.0274410, -105.3008940 + 40.0275000, -105.3009660 + 40.0275080, -105.3009790 + 40.0275240, -105.3010140 + 40.0275310, -105.3010200 + 40.0275390, -105.3010270 + 40.0275460, -105.3010300 + 40.0275560, -105.3010330 + 40.0275630, -105.3010370 + 40.0275870, -105.3010660 + 40.0275950, -105.3010740 + 40.0276070, -105.3010950 + 40.0276220, -105.3011170 + 40.0276220, -105.3011250 + 40.0276330, -105.3011680 + 40.0276430, -105.3011910 + 40.0276640, -105.3012190 + 40.0276880, -105.3012260 + 40.0276970, -105.3012270 + 40.0277470, -105.3012490 + 40.0277520, -105.3012480 + 40.0278080, -105.3012660 + 40.0278980, -105.3013300 + 40.0279030, -105.3013400 + 40.0279340, -105.3013870 + 40.0279390, -105.3013920 + 40.0279540, -105.3014140 + 40.0279600, -105.3014230 + 40.0279700, -105.3014390 + 40.0279840, -105.3014580 + 40.0279860, -105.3014860 + 40.0279880, -105.3014980 + 40.0279890, -105.3015140 + 40.0279950, -105.3015160 + 40.0280040, -105.3015440 + 40.0280110, -105.3015530 + 40.0280130, -105.3015660 + 40.0280200, -105.3015770 + 40.0280360, -105.3015860 + 40.0280420, -105.3016010 + 40.0280690, -105.3016490 + 40.0281510, -105.3017030 + 40.0281560, -105.3017130 + 40.0281890, -105.3017560 + 40.0282470, -105.3017780 + 40.0282820, -105.3018160 + 40.0282880, -105.3018230 + 40.0283250, -105.3018550 + 40.0283430, -105.3018660 + 40.0283800, -105.3018950 + 40.0283930, -105.3019020 + 40.0284060, -105.3019120 + 40.0284130, -105.3019290 + 40.0284140, -105.3019380 + 40.0284200, -105.3019530 + 40.0284190, -105.3019610 + 40.0284230, -105.3019790 + 40.0284440, -105.3019960 + 40.0284470, -105.3020090 + 40.0284490, -105.3020250 + 40.0284780, -105.3020400 + 40.0284860, -105.3020410 + 40.0285160, -105.3020710 + 40.0285330, -105.3020870 + 40.0285850, -105.3020990 + 40.0286070, -105.3021040 + 40.0286350, -105.3021120 + 40.0286710, -105.3021290 + 40.0286790, -105.3021410 + 40.0286870, -105.3021450 + 40.0286940, -105.3021480 + 40.0287080, -105.3021580 + 40.0287170, -105.3021700 + 40.0287210, -105.3021760 + 40.0287280, -105.3021790 + 40.0287470, -105.3021870 + 40.0287510, -105.3021880 + 40.0287670, -105.3022000 + 40.0288070, -105.3022200 + 40.0288160, -105.3022240 + 40.0288210, -105.3022260 + 40.0288440, -105.3022450 + 40.0288470, -105.3022430 + 40.0288470, -105.3022430 + 40.0288470, -105.3022430 + 40.0288480, -105.3022430 + 40.0288490, -105.3022430 + 40.0288490, -105.3022430 + 40.0288490, -105.3022430 + 40.0288490, -105.3022430 + 40.0288490, -105.3022440 + 40.0288480, -105.3022440 + 40.0288480, -105.3022450 + 40.0288470, -105.3022460 + 40.0288470, -105.3022470 + 40.0288440, -105.3022490 + 40.0288410, -105.3022500 + 40.0288370, -105.3022510 + 40.0288370, -105.3022510 + 40.0288370, -105.3022510 + 40.0288370, -105.3022500 + 40.0288380, -105.3022480 + 40.0288790, -105.3022840 + 40.0288880, -105.3022960 + 40.0289260, -105.3023360 + 40.0289430, -105.3023540 + 40.0289500, -105.3023620 + 40.0289540, -105.3023670 + 40.0289670, -105.3023870 + 40.0289750, -105.3024050 + 40.0289880, -105.3024370 + 40.0290040, -105.3024630 + 40.0290070, -105.3024680 + 40.0290170, -105.3024840 + 40.0290190, -105.3024890 + 40.0290300, -105.3024980 + 40.0290350, -105.3025000 + 40.0290400, -105.3025030 + 40.0290410, -105.3025100 + 40.0290480, -105.3025270 + 40.0290650, -105.3025330 + 40.0290770, -105.3025430 + 40.0290980, -105.3025610 + 40.0291040, -105.3025960 + 40.0291180, -105.3026160 + 40.0291260, -105.3026180 + 40.0291380, -105.3026220 + 40.0291480, -105.3026290 + 40.0291580, -105.3026370 + 40.0291610, -105.3026440 + 40.0291680, -105.3026550 + 40.0291910, -105.3026680 + 40.0291980, -105.3026730 + 40.0292330, -105.3026690 + 40.0292520, -105.3026760 + 40.0293140, -105.3026920 + 40.0293330, -105.3026960 + 40.0293560, -105.3026990 + 40.0293910, -105.3027160 + 40.0294060, -105.3027310 + 40.0294250, -105.3027450 + 40.0294290, -105.3027490 + 40.0294330, -105.3027520 + 40.0294480, -105.3027580 + 40.0294710, -105.3027650 + 40.0294770, -105.3027690 + 40.0294910, -105.3027780 + 40.0295140, -105.3028000 + 40.0295260, -105.3028150 + 40.0295560, -105.3028290 + 40.0295620, -105.3028330 + 40.0295960, -105.3028470 + 40.0296070, -105.3028410 + 40.0296080, -105.3028370 + 40.0296070, -105.3028370 + 40.0296260, -105.3028580 + 40.0296530, -105.3028790 + 40.0297020, -105.3029200 + 40.0297270, -105.3029350 + 40.0297360, -105.3029350 + 40.0297710, -105.3029490 + 40.0297800, -105.3029530 + 40.0298070, -105.3029600 + 40.0298260, -105.3029870 + 40.0298310, -105.3029950 + 40.0298440, -105.3030660 + 40.0298400, -105.3030800 + 40.0298420, -105.3030950 + 40.0298490, -105.3031100 + 40.0299040, -105.3032390 + 40.0299150, -105.3032920 + 40.0299350, -105.3033310 + 40.0299430, -105.3033400 + 40.0299540, -105.3033470 + 40.0299780, -105.3033560 + 40.0300220, -105.3033750 + 40.0300390, -105.3033890 + 40.0300450, -105.3033930 + 40.0300670, -105.3034060 + 40.0300970, -105.3034200 + 40.0301070, -105.3034250 + 40.0301630, -105.3034100 + 40.0301980, -105.3034020 + 40.0302560, -105.3033930 + 40.0302950, -105.3033910 + 40.0304050, -105.3034310 + 40.0304170, -105.3034360 + 40.0304280, -105.3034400 + 40.0304520, -105.3034510 + 40.0305200, -105.3034770 + 40.0305290, -105.3034830 + 40.0305580, -105.3034980 + 40.0305960, -105.3035100 + 40.0306810, -105.3035960 + 40.0306940, -105.3036230 + 40.0307100, -105.3036550 + 40.0307220, -105.3036720 + 40.0307350, -105.3036940 + 40.0307400, -105.3037010 + 40.0307860, -105.3037390 + 40.0308050, -105.3037570 + 40.0308080, -105.3037660 + 40.0308220, -105.3038180 + 40.0308330, -105.3038520 + 40.0308350, -105.3038650 + 40.0308580, -105.3038900 + 40.0308650, -105.3039130 + 40.0308830, -105.3039360 + 40.0309350, -105.3039550 + 40.0309490, -105.3039580 + 40.0309850, -105.3039820 + 40.0310340, -105.3040170 + 40.0310490, -105.3040240 + 40.0310780, -105.3040320 + 40.0311160, -105.3040480 + 40.0311260, -105.3040560 + 40.0311850, -105.3040800 + 40.0311990, -105.3040850 + 40.0312340, -105.3041020 + 40.0312950, -105.3041110 + 40.0313040, -105.3041200 + 40.0313250, -105.3041390 + 40.0313580, -105.3041730 + 40.0314180, -105.3042230 + 40.0314310, -105.3042280 + 40.0314600, -105.3042380 + 40.0314920, -105.3042540 + 40.0315050, -105.3042580 + 40.0315270, -105.3042740 + 40.0315520, -105.3042890 + 40.0316390, -105.3043180 + 40.0317520, -105.3043350 + 40.0318500, -105.3043680 + 40.0318820, -105.3043760 + 40.0319260, -105.3043830 + 40.0319470, -105.3043890 + 40.0319950, -105.3044190 + 40.0320180, -105.3044350 + 40.0320730, -105.3044390 + 40.0320930, -105.3044460 + 40.0321170, -105.3044510 + 40.0321290, -105.3044550 + 40.0321710, -105.3044470 + 40.0322100, -105.3044430 + 40.0322180, -105.3044490 + 40.0322230, -105.3044590 + 40.0322690, -105.3044700 + 40.0322740, -105.3044580 + 40.0322800, -105.3044490 + 40.0323020, -105.3044360 + 40.0323260, -105.3044360 + 40.0323630, -105.3044390 + 40.0323690, -105.3044460 + 40.0323760, -105.3044550 + 40.0323890, -105.3044680 + 40.0324090, -105.3044750 + 40.0324300, -105.3044940 + 40.0324390, -105.3045030 + 40.0324510, -105.3045340 + 40.0324610, -105.3045350 + 40.0324860, -105.3045440 + 40.0325080, -105.3045620 + 40.0325220, -105.3045720 + 40.0325380, -105.3045800 + 40.0325470, -105.3045840 + 40.0325660, -105.3046050 + 40.0325930, -105.3046130 + 40.0326270, -105.3046180 + 40.0326560, -105.3046370 + 40.0326620, -105.3046470 + 40.0326650, -105.3046700 + 40.0326780, -105.3047240 + 40.0326870, -105.3047350 + 40.0327330, -105.3047650 + 40.0327540, -105.3047820 + 40.0327650, -105.3047880 + 40.0327760, -105.3047960 + 40.0327840, -105.3048020 + 40.0327970, -105.3048370 + 40.0328020, -105.3048600 + 40.0328110, -105.3048740 + 40.0328350, -105.3048980 + 40.0328450, -105.3049050 + 40.0328570, -105.3049110 + 40.0328700, -105.3049140 + 40.0329060, -105.3049140 + 40.0329620, -105.3049470 + 40.0329700, -105.3049560 + 40.0330310, -105.3049990 + 40.0330370, -105.3050040 + 40.0330600, -105.3050220 + 40.0330750, -105.3050360 + 40.0331270, -105.3050700 + 40.0332040, -105.3051260 + 40.0332150, -105.3051390 + 40.0332260, -105.3051530 + 40.0332340, -105.3051720 + 40.0332340, -105.3051900 + 40.0332500, -105.3052230 + 40.0332560, -105.3052280 + 40.0332870, -105.3052500 + 40.0332920, -105.3052730 + 40.0333040, -105.3052830 + 40.0333390, -105.3053040 + 40.0333500, -105.3053140 + 40.0333890, -105.3053370 + 40.0333960, -105.3053520 + 40.0334030, -105.3053620 + 40.0334310, -105.3053720 + 40.0334550, -105.3053800 + 40.0334740, -105.3053890 + 40.0335150, -105.3054040 + 40.0335210, -105.3054230 + 40.0335310, -105.3054340 + 40.0335490, -105.3054590 + 40.0335660, -105.3054740 + 40.0335780, -105.3054790 + 40.0336220, -105.3054800 + 40.0336310, -105.3054800 + 40.0336740, -105.3054740 + 40.0336850, -105.3054730 + 40.0337070, -105.3055020 + 40.0337200, -105.3054980 + 40.0337320, -105.3054990 + 40.0337490, -105.3055230 + 40.0337610, -105.3055690 + 40.0337600, -105.3055900 + 40.0337630, -105.3056010 + 40.0337950, -105.3056330 + 40.0338110, -105.3056400 + 40.0338290, -105.3056460 + 40.0338470, -105.3056840 + 40.0338730, -105.3057140 + 40.0339050, -105.3057320 + 40.0339410, -105.3057380 + 40.0339490, -105.3057360 + 40.0339640, -105.3057420 + 40.0339750, -105.3057520 + 40.0339870, -105.3057600 + 40.0340080, -105.3057550 + 40.0340230, -105.3057140 + 40.0340290, -105.3056880 + 40.0340500, -105.3056630 + 40.0340780, -105.3056370 + 40.0340860, -105.3056210 + 40.0340860, -105.3056130 + 40.0340870, -105.3056060 + 40.0341010, -105.3055920 + 40.0341100, -105.3055800 + 40.0341090, -105.3055690 + 40.0341170, -105.3055490 + 40.0341470, -105.3055410 + 40.0341500, -105.3055360 + 40.0341760, -105.3055150 + 40.0341820, -105.3055070 + 40.0341960, -105.3055060 + 40.0342170, -105.3054940 + 40.0342290, -105.3054910 + 40.0342340, -105.3054910 + 40.0342670, -105.3054760 + 40.0342910, -105.3054790 + 40.0342990, -105.3054790 + 40.0343220, -105.3054470 + 40.0343310, -105.3054030 + 40.0343320, -105.3053870 + 40.0343370, -105.3053570 + 40.0343520, -105.3053580 + 40.0343770, -105.3053620 + 40.0344000, -105.3053570 + 40.0344070, -105.3053290 + 40.0344080, -105.3053290 + 40.0344070, -105.3053210 + 40.0344070, -105.3053210 + 40.0344060, -105.3053210 + 40.0344050, -105.3053200 + 40.0344050, -105.3053200 + 40.0344040, -105.3053190 + 40.0344040, -105.3053190 + 40.0344040, -105.3053180 + 40.0344040, -105.3053140 + 40.0344030, -105.3053140 + 40.0344030, -105.3053050 + 40.0344070, -105.3053010 + 40.0344070, -105.3053010 + 40.0344050, -105.3053000 + 40.0344040, -105.3053000 + 40.0344030, -105.3052960 + 40.0344030, -105.3052950 + 40.0344030, -105.3052950 + 40.0344030, -105.3052950 + 40.0344030, -105.3052950 + 40.0344040, -105.3052950 + 40.0344040, -105.3052950 + 40.0344040, -105.3052950 + 40.0344040, -105.3052950 + 40.0344050, -105.3052950 + 40.0344020, -105.3052960 + 40.0344020, -105.3052950 + 40.0344010, -105.3052940 + 40.0343990, -105.3052880 + 40.0343990, -105.3052880 + 40.0343990, -105.3052880 + 40.0344000, -105.3052880 + 40.0344000, -105.3052880 + 40.0344000, -105.3052880 + 40.0344000, -105.3052880 + 40.0344000, -105.3052880 + 40.0344000, -105.3052880 + 40.0344000, -105.3052870 + 40.0344000, -105.3052870 + 40.0344050, -105.3052780 + 40.0344260, -105.3052210 + 40.0344320, -105.3051550 + 40.0344390, -105.3051470 + 40.0344460, -105.3051390 + 40.0344530, -105.3051310 + 40.0344600, -105.3051230 + 40.0344670, -105.3051160 + 40.0344740, -105.3051080 + 40.0344810, -105.3051000 + 40.0344880, -105.3050920 + 40.0344950, -105.3050840 + 40.0345020, -105.3050760 + 40.0345090, -105.3050680 + 40.0345170, -105.3050600 + 40.0345240, -105.3050520 + 40.0345310, -105.3050440 + 40.0345380, -105.3050370 + 40.0345450, -105.3050290 + 40.0345520, -105.3050210 + 40.0345590, -105.3050130 + 40.0345660, -105.3050050 + 40.0345730, -105.3049970 + 40.0345800, -105.3049890 + 40.0345870, -105.3049810 + 40.0345940, -105.3049730 + 40.0346010, -105.3049650 + 40.0346080, -105.3049570 + 40.0346150, -105.3049500 + 40.0346220, -105.3049420 + 40.0346290, -105.3049340 + 40.0346360, -105.3049260 + 40.0346430, -105.3049180 + 40.0346500, -105.3049100 + 40.0346570, -105.3049020 + 40.0346640, -105.3048940 + 40.0346710, -105.3048860 + 40.0346780, -105.3048780 + 40.0346860, -105.3048710 + 40.0346930, -105.3048630 + 40.0347000, -105.3048550 + 40.0347070, -105.3048470 + 40.0347140, -105.3048390 + 40.0347210, -105.3048310 + 40.0347280, -105.3048230 + 40.0347350, -105.3048150 + 40.0347420, -105.3048070 + 40.0347490, -105.3047990 + 40.0347560, -105.3047910 + 40.0347630, -105.3047840 + 40.0347700, -105.3047760 + 40.0347770, -105.3047680 + 40.0347840, -105.3047600 + 40.0347910, -105.3047520 + 40.0347980, -105.3047440 + 40.0348050, -105.3047360 + 40.0348120, -105.3047280 + 40.0348190, -105.3047200 + 40.0348260, -105.3047120 + 40.0348330, -105.3047040 + 40.0348400, -105.3046970 + 40.0348470, -105.3046890 + 40.0348540, -105.3046810 + 40.0348620, -105.3046730 + 40.0348690, -105.3046650 + 40.0348760, -105.3046570 + 40.0348830, -105.3046490 + 40.0348900, -105.3046410 + 40.0348970, -105.3046330 + 40.0349040, -105.3046250 + 40.0349110, -105.3046180 + 40.0349180, -105.3046100 + 40.0349250, -105.3046020 + 40.0349320, -105.3045940 + 40.0349390, -105.3045860 + 40.0349460, -105.3045780 + 40.0349690, -105.3045820 + 40.0350130, -105.3045850 + 40.0350690, -105.3045930 + 40.0351040, -105.3046080 + 40.0351160, -105.3046070 + 40.0351320, -105.3045960 + 40.0351360, -105.3045390 + 40.0351300, -105.3045160 + 40.0351160, -105.3044850 + 40.0350970, -105.3044110 + 40.0350990, -105.3044000 + 40.0350960, -105.3043820 + 40.0350910, -105.3043720 + 40.0350400, -105.3043410 + 40.0350290, -105.3043310 + 40.0350120, -105.3043120 + 40.0349950, -105.3042880 + 40.0349800, -105.3042380 + 40.0349770, -105.3042160 + 40.0349660, -105.3041800 + 40.0349280, -105.3041110 + 40.0348900, -105.3040480 + 40.0348720, -105.3040370 + 40.0348330, -105.3040260 + 40.0348040, -105.3040150 + 40.0347580, -105.3039720 + 40.0347430, -105.3039610 + 40.0347140, -105.3039560 + 40.0346960, -105.3039340 + 40.0346870, -105.3039260 + 40.0346840, -105.3039150 + 40.0346310, -105.3038530 + 40.0345880, -105.3038520 + 40.0345760, -105.3038380 + 40.0345580, -105.3038190 + 40.0345310, -105.3038000 + 40.0345240, -105.3037890 + 40.0345100, -105.3037740 + 40.0344840, -105.3037530 + 40.0344790, -105.3037480 + 40.0344750, -105.3037460 + 40.0344720, -105.3037150 + 40.0344660, -105.3037000 + 40.0344600, -105.3036940 + 40.0344560, -105.3036890 + 40.0344550, -105.3036990 + 40.0344500, -105.3036770 + 40.0344530, -105.3036650 + 40.0344530, -105.3036650 + 40.0344530, -105.3036650 + 40.0344530, -105.3036650 + 40.0344530, -105.3036650 + 40.0344520, -105.3036630 + 40.0344510, -105.3036520 + 40.0344500, -105.3036450 + 40.0344450, -105.3036320 + 40.0344240, -105.3036160 + 40.0344300, -105.3035900 + 40.0344430, -105.3035590 + 40.0344500, -105.3035400 + 40.0344660, -105.3034550 + 40.0344690, -105.3034320 + 40.0344720, -105.3034110 + 40.0344940, -105.3034170 + 40.0345100, -105.3033910 + 40.0345100, -105.3033640 + 40.0345080, -105.3033490 + 40.0345070, -105.3033390 + 40.0345060, -105.3033330 + 40.0345030, -105.3033250 + 40.0345050, -105.3033150 + 40.0344830, -105.3032790 + 40.0344630, -105.3032580 + 40.0344450, -105.3032560 + 40.0344330, -105.3032320 + 40.0344290, -105.3031960 + 40.0344120, -105.3031550 + 40.0344030, -105.3031340 + 40.0344130, -105.3031340 + 40.0344590, -105.3031170 + 40.0344710, -105.3030840 + 40.0344770, -105.3030880 + 40.0344990, -105.3030880 + 40.0345150, -105.3030930 + 40.0345230, -105.3030940 + 40.0345320, -105.3031040 + 40.0345520, -105.3030910 + 40.0345430, -105.3030200 + 40.0345340, -105.3029230 + 40.0345280, -105.3028920 + 40.0344980, -105.3028620 + 40.0344810, -105.3028480 + 40.0344410, -105.3028210 + 40.0344250, -105.3028040 + 40.0344250, -105.3027930 + 40.0344290, -105.3027870 + 40.0344380, -105.3027690 + 40.0344570, -105.3027320 + 40.0344600, -105.3027170 + 40.0344710, -105.3026780 + 40.0344690, -105.3026660 + 40.0344680, -105.3026500 + 40.0344720, -105.3026240 + 40.0345040, -105.3025570 + 40.0345090, -105.3025410 + 40.0344910, -105.3024820 + 40.0344850, -105.3024620 + 40.0344840, -105.3024450 + 40.0344880, -105.3024280 + 40.0345190, -105.3023920 + 40.0345170, -105.3023730 + 40.0345250, -105.3023480 + 40.0345320, -105.3023110 + 40.0345250, -105.3022970 + 40.0345190, -105.3022840 + 40.0345310, -105.3022500 + 40.0345230, -105.3022010 + 40.0345130, -105.3021900 + 40.0344950, -105.3021690 + 40.0344790, -105.3021330 + 40.0344760, -105.3021110 + 40.0344810, -105.3020910 + 40.0344850, -105.3020640 + 40.0344780, -105.3020400 + 40.0344700, -105.3020130 + 40.0344820, -105.3019800 + 40.0344810, -105.3019510 + 40.0344850, -105.3019470 + 40.0345000, -105.3019310 + 40.0345030, -105.3019200 + 40.0345090, -105.3019130 + 40.0345150, -105.3019090 + 40.0345260, -105.3019050 + 40.0345410, -105.3019010 + 40.0345510, -105.3018980 + 40.0345680, -105.3018930 + 40.0345750, -105.3018900 + 40.0345840, -105.3018880 + 40.0346090, -105.3018610 + 40.0346070, -105.3018460 + 40.0346010, -105.3018270 + 40.0345890, -105.3018070 + 40.0345810, -105.3017890 + 40.0345700, -105.3017720 + 40.0345580, -105.3017380 + 40.0345450, -105.3017180 + 40.0345370, -105.3017100 + 40.0345060, -105.3016930 + 40.0344860, -105.3016770 + 40.0344840, -105.3016680 + 40.0344670, -105.3016340 + 40.0344540, -105.3016270 + 40.0344380, -105.3016040 + 40.0344140, -105.3015920 + 40.0343910, -105.3015830 + 40.0343700, -105.3015790 + 40.0343660, -105.3015770 + 40.0343520, -105.3015690 + 40.0343330, -105.3015560 + 40.0342880, -105.3015610 + 40.0342770, -105.3015600 + 40.0342580, -105.3015550 + 40.0342550, -105.3015470 + 40.0342570, -105.3015290 + 40.0342730, -105.3014840 + 40.0342870, -105.3014700 + 40.0342960, -105.3014630 + 40.0343140, -105.3014440 + 40.0343320, -105.3014270 + 40.0343530, -105.3014000 + 40.0343630, -105.3013870 + 40.0343830, -105.3013760 + 40.0344030, -105.3013610 + 40.0343980, -105.3013290 + 40.0343890, -105.3013150 + 40.0343630, -105.3013030 + 40.0343610, -105.3013000 + 40.0343870, -105.3012650 + 40.0343870, -105.3012590 + 40.0343830, -105.3012410 + 40.0343860, -105.3012300 + 40.0344030, -105.3012050 + 40.0344130, -105.3011920 + 40.0344330, -105.3011770 + 40.0344470, -105.3011650 + 40.0344580, -105.3011480 + 40.0344700, -105.3011390 + 40.0345070, -105.3011280 + 40.0345780, -105.3011060 + 40.0346210, -105.3011210 + 40.0346840, -105.3011290 + 40.0346690, -105.3010910 + 40.0346000, -105.3010300 + 40.0345560, -105.3010070 + 40.0345330, -105.3010040 + 40.0344580, -105.3009900 + 40.0344270, -105.3009860 + 40.0343370, -105.3009550 + 40.0343170, -105.3009870 + 40.0343040, -105.3009930 + 40.0342820, -105.3009920 + 40.0342610, -105.3009850 + 40.0342420, -105.3009910 + 40.0342340, -105.3009860 + 40.0342290, -105.3009710 + 40.0342370, -105.3009650 + 40.0342470, -105.3009590 + 40.0342900, -105.3009150 + 40.0343320, -105.3008830 + 40.0343490, -105.3008850 + 40.0343600, -105.3008820 + 40.0343850, -105.3008670 + 40.0343900, -105.3008600 + 40.0344020, -105.3008460 + 40.0344130, -105.3008310 + 40.0344260, -105.3007970 + 40.0344350, -105.3007810 + 40.0344730, -105.3007500 + 40.0345350, -105.3007350 + 40.0345500, -105.3007340 + 40.0346700, -105.3007530 + 40.0346820, -105.3007550 + 40.0347320, -105.3007820 + 40.0347880, -105.3007640 + 40.0347940, -105.3007510 + 40.0347960, -105.3007040 + 40.0347840, -105.3006740 + 40.0347580, -105.3006500 + 40.0347350, -105.3006270 + 40.0347280, -105.3006170 + 40.0346850, -105.3005680 + 40.0346610, -105.3005550 + 40.0346380, -105.3005320 + 40.0346120, -105.3005080 + 40.0345950, -105.3005040 + 40.0345810, -105.3005020 + 40.0345710, -105.3004940 + 40.0345490, -105.3004710 + 40.0345340, -105.3004620 + 40.0344880, -105.3004230 + 40.0344600, -105.3004030 + 40.0344340, -105.3003780 + 40.0344000, -105.3003520 + 40.0343720, -105.3003320 + 40.0343600, -105.3003160 + 40.0343380, -105.3002880 + 40.0343060, -105.3002800 + 40.0342900, -105.3002780 + 40.0342660, -105.3002620 + 40.0342630, -105.3002470 + 40.0342630, -105.3002320 + 40.0342720, -105.3002200 + 40.0342850, -105.3002040 + 40.0342990, -105.3001690 + 40.0343230, -105.3000990 + 40.0343360, -105.3000850 + 40.0343510, -105.3000810 + 40.0343970, -105.3000740 + 40.0344270, -105.3000630 + 40.0344570, -105.3000650 + 40.0345060, -105.3000550 + 40.0345410, -105.3000650 + 40.0345760, -105.3000650 + 40.0345950, -105.3000700 + 40.0346820, -105.3001080 + 40.0347480, -105.3000830 + 40.0347930, -105.3000780 + 40.0348360, -105.3000710 + 40.0348950, -105.3000540 + 40.0349310, -105.3000420 + 40.0349690, -105.3000360 + 40.0349920, -105.3000380 + 40.0350400, -105.3000500 + 40.0350770, -105.3000520 + 40.0351010, -105.3000550 + 40.0351340, -105.3000560 + 40.0351940, -105.3000680 + 40.0352310, -105.3000800 + 40.0352740, -105.3000780 + 40.0353610, -105.3000980 + 40.0354130, -105.3001010 + 40.0354740, -105.3000870 + 40.0355250, -105.3000620 + 40.0355750, -105.3000320 + 40.0355870, -105.3000220 + 40.0356210, -105.3000110 + 40.0356760, -105.2999770 + 40.0356910, -105.2999640 + 40.0357260, -105.2999400 + 40.0357330, -105.2999240 + 40.0357430, -105.2999040 + 40.0357670, -105.2998310 + 40.0357590, -105.2997830 + 40.0357530, -105.2997600 + 40.0357500, -105.2996880 + 40.0357600, -105.2996070 + 40.0357700, -105.2995770 + 40.0357840, -105.2995280 + 40.0358190, -105.2994390 + 40.0358500, -105.2993650 + 40.0358600, -105.2993400 + 40.0358630, -105.2992790 + 40.0358560, -105.2991780 + 40.0358540, -105.2990970 + 40.0358660, -105.2990400 + 40.0358750, -105.2990080 + 40.0358950, -105.2989530 + 40.0359020, -105.2989260 + 40.0359100, -105.2989010 + 40.0359160, -105.2988490 + 40.0359260, -105.2987620 + 40.0359300, -105.2987450 + 40.0359330, -105.2986890 + 40.0359340, -105.2985940 + 40.0359240, -105.2985090 + 40.0359120, -105.2984090 + 40.0358960, -105.2983000 + 40.0358790, -105.2982370 + 40.0358460, -105.2981180 + 40.0357880, -105.2980190 + 40.0357310, -105.2979480 + 40.0356840, -105.2979010 + 40.0356590, -105.2978730 + 40.0355460, -105.2977650 + 40.0354820, -105.2976970 + 40.0354390, -105.2976530 + 40.0354160, -105.2976320 + 40.0353140, -105.2975640 + 40.0352370, -105.2975200 + 40.0351720, -105.2974730 + 40.0351050, -105.2973880 + 40.0350680, -105.2973370 + 40.0350420, -105.2972930 + 40.0350050, -105.2972350 + 40.0349820, -105.2971930 + 40.0349470, -105.2971640 + 40.0348160, -105.2970830 + 40.0348030, -105.2970650 + 40.0347400, -105.2969750 + 40.0346940, -105.2969270 + 40.0346660, -105.2969030 + 40.0345570, -105.2968410 + 40.0345360, -105.2968370 + 40.0344320, -105.2968230 + 40.0343920, -105.2968140 + 40.0342360, -105.2967800 + 40.0340430, -105.2967070 + 40.0339020, -105.2966860 + 40.0338840, -105.2966810 + 40.0337840, -105.2966790 + 40.0337440, -105.2966780 + 40.0336550, -105.2966540 + 40.0335000, -105.2966370 + 40.0333980, -105.2966340 + 40.0333530, -105.2966190 + 40.0333150, -105.2965980 + 40.0332740, -105.2965830 + 40.0332290, -105.2965810 + 40.0331610, -105.2965700 + 40.0331200, -105.2965610 + 40.0330010, -105.2965110 + 40.0329780, -105.2965080 + 40.0328490, -105.2964770 + 40.0327910, -105.2964570 + 40.0326720, -105.2964400 + 40.0326540, -105.2964350 + 40.0325260, -105.2964060 + 40.0324480, -105.2964020 + 40.0323720, -105.2964050 + 40.0323210, -105.2963920 + 40.0321960, -105.2963850 + 40.0321540, -105.2963650 + 40.0321130, -105.2963570 + 40.0319710, -105.2963080 + 40.0319510, -105.2963000 + 40.0319310, -105.2962960 + 40.0318730, -105.2963040 + 40.0318340, -105.2962960 + 40.0317320, -105.2962710 + 40.0316930, -105.2962620 + 40.0316750, -105.2962650 + 40.0316320, -105.2962640 + 40.0316150, -105.2962650 + 40.0315120, -105.2962540 + 40.0314360, -105.2962390 + 40.0313820, -105.2962220 + 40.0313210, -105.2961890 + 40.0312850, -105.2961910 + 40.0312660, -105.2961880 + 40.0311810, -105.2961060 + 40.0310430, -105.2960390 + 40.0309710, -105.2960530 + 40.0309220, -105.2960430 + 40.0308970, -105.2960370 + 40.0307340, -105.2960110 + 40.0306940, -105.2960180 + 40.0306110, -105.2960530 + 40.0305640, -105.2960810 + 40.0305250, -105.2961100 + 40.0304390, -105.2961230 + 40.0304000, -105.2961330 + 40.0303370, -105.2961480 + 40.0302260, -105.2961670 + 40.0301300, -105.2961680 + 40.0300600, -105.2961190 + 40.0299570, -105.2961260 + 40.0299400, -105.2961420 + 40.0299100, -105.2961670 + 40.0298900, -105.2961710 + 40.0298260, -105.2961540 + 40.0298050, -105.2961490 + 40.0296840, -105.2960950 + 40.0296050, -105.2960690 + 40.0295240, -105.2960900 + 40.0294530, -105.2960970 + 40.0294110, -105.2960900 + 40.0292820, -105.2960810 + 40.0291940, -105.2960940 + 40.0291720, -105.2961040 + 40.0290570, -105.2961100 + 40.0290200, -105.2961020 + 40.0289470, -105.2960900 + 40.0289260, -105.2960960 + 40.0288870, -105.2961110 + 40.0288270, -105.2961050 + 40.0287980, -105.2961020 + 40.0286990, -105.2960880 + 40.0285460, -105.2960930 + 40.0285070, -105.2961010 + 40.0284440, -105.2961240 + 40.0283810, -105.2961380 + 40.0283670, -105.2961440 + 40.0282890, -105.2961640 + 40.0282350, -105.2961460 + 40.0282140, -105.2961350 + 40.0282020, -105.2961350 + 40.0281210, -105.2961120 + 40.0281070, -105.2960970 + 40.0280130, -105.2960340 + 40.0279960, -105.2960300 + 40.0279620, -105.2960340 + 40.0279240, -105.2960350 + 40.0278970, -105.2960400 + 40.0278940, -105.2960400 + 40.0278810, -105.2960300 + 40.0278570, -105.2960280 + 40.0278410, -105.2960260 + 40.0277940, -105.2959990 + 40.0277810, -105.2960060 + 40.0277630, -105.2960150 + 40.0277320, -105.2960430 + 40.0277230, -105.2960550 + 40.0276630, -105.2960980 + 40.0276190, -105.2960930 + 40.0275430, -105.2960670 + 40.0274950, -105.2960480 + 40.0274120, -105.2960170 + 40.0273360, -105.2959930 + 40.0272950, -105.2959930 + 40.0272710, -105.2959890 + 40.0271740, -105.2959660 + 40.0271370, -105.2959730 + 40.0271110, -105.2959670 + 40.0270980, -105.2959590 + 40.0270750, -105.2959600 + 40.0270290, -105.2959910 + 40.0270040, -105.2959950 + 40.0269630, -105.2959980 + 40.0269390, -105.2959940 + 40.0269030, -105.2959800 + 40.0268810, -105.2959760 + 40.0268120, -105.2959750 + 40.0267780, -105.2959680 + 40.0267160, -105.2959540 + 40.0266220, -105.2959860 + 40.0266040, -105.2959930 + 40.0265570, -105.2960470 + 40.0265360, -105.2960640 + 40.0265190, -105.2960710 + 40.0264970, -105.2960750 + 40.0264730, -105.2960800 + 40.0264620, -105.2960850 + 40.0264470, -105.2960850 + 40.0263900, -105.2960860 + 40.0263570, -105.2960810 + 40.0263250, -105.2960830 + 40.0263100, -105.2960820 + 40.0262520, -105.2960890 + 40.0262120, -105.2960950 + 40.0261190, -105.2961230 + 40.0260970, -105.2961220 + 40.0260760, -105.2961250 + 40.0259890, -105.2960960 + 40.0259780, -105.2960840 + 40.0259340, -105.2960810 + 40.0258340, -105.2960780 + 40.0258220, -105.2960790 + 40.0257550, -105.2960960 + 40.0257090, -105.2960680 + 40.0256680, -105.2960620 + 40.0256520, -105.2960480 + 40.0256390, -105.2960310 + 40.0256190, -105.2959970 + 40.0255740, -105.2959480 + 40.0255050, -105.2958840 + 40.0254730, -105.2958780 + 40.0254440, -105.2958870 + 40.0254150, -105.2959120 + 40.0254020, -105.2959160 + 40.0253730, -105.2959250 + 40.0253300, -105.2959360 + 40.0253050, -105.2959380 + 40.0252940, -105.2959370 + 40.0252840, -105.2959410 + 40.0252580, -105.2959480 + 40.0252260, -105.2959730 + 40.0252140, -105.2959810 + 40.0252050, -105.2959870 + 40.0251790, -105.2959900 + 40.0251520, -105.2959920 + 40.0251060, -105.2960140 + 40.0250920, -105.2960220 + 40.0250700, -105.2960310 + 40.0250480, -105.2960320 + 40.0249980, -105.2960380 + 40.0249620, -105.2960420 + 40.0249450, -105.2960390 + 40.0249040, -105.2960160 + 40.0248480, -105.2959830 + 40.0248280, -105.2959690 + 40.0247580, -105.2959590 + 40.0247170, -105.2959450 + 40.0246170, -105.2958810 + 40.0245990, -105.2958730 + 40.0245500, -105.2958450 + 40.0244740, -105.2958090 + 40.0244400, -105.2957950 + 40.0244000, -105.2957820 + 40.0242900, -105.2956960 + 40.0242720, -105.2956800 + 40.0242330, -105.2956590 + 40.0242180, -105.2956540 + 40.0241400, -105.2956220 + 40.0239730, -105.2956060 + 40.0239590, -105.2956030 + 40.0238420, -105.2955900 + 40.0238250, -105.2955850 + 40.0237110, -105.2955380 + 40.0237020, -105.2955280 + 40.0236250, -105.2954360 + 40.0235470, -105.2953140 + 40.0235330, -105.2952880 + 40.0234340, -105.2951490 + 40.0233580, -105.2950810 + 40.0233420, -105.2950790 + 40.0233310, -105.2950920 + 40.0233270, -105.2951070 + 40.0233260, -105.2951350 + 40.0233320, -105.2952590 + 40.0233720, -105.2953700 + 40.0234080, -105.2954670 + 40.0234230, -105.2955140 + 40.0234720, -105.2956300 + 40.0234900, -105.2956400 + 40.0235720, -105.2957480 + 40.0235880, -105.2957660 + 40.0236920, -105.2958760 + 40.0237080, -105.2958840 + 40.0237520, -105.2959150 + 40.0237610, -105.2959330 + 40.0237670, -105.2959570 + 40.0237670, -105.2960260 + 40.0237630, -105.2960570 + 40.0237230, -105.2961180 + 40.0236820, -105.2961250 + 40.0236580, -105.2961280 + 40.0236500, -105.2961400 + 40.0236280, -105.2961570 + 40.0236090, -105.2961600 + 40.0236060, -105.2961730 + 40.0236020, -105.2961790 + 40.0235470, -105.2961500 + 40.0235300, -105.2961440 + 40.0234280, -105.2961060 + 40.0233660, -105.2960700 + 40.0233360, -105.2960520 + 40.0232150, -105.2960090 + 40.0231960, -105.2960020 + 40.0230930, -105.2960610 + 40.0230680, -105.2960680 + 40.0229940, -105.2960820 + 40.0228820, -105.2960840 + 40.0227630, -105.2960860 + 40.0227290, -105.2960950 + 40.0227120, -105.2960990 + 40.0226960, -105.2961090 + 40.0226670, -105.2961250 + 40.0226510, -105.2961300 + 40.0226110, -105.2961420 + 40.0225940, -105.2961510 + 40.0225340, -105.2961560 + 40.0224130, -105.2962030 + 40.0223910, -105.2962050 + 40.0222950, -105.2962240 + 40.0222760, -105.2962310 + 40.0221810, -105.2962610 + 40.0220920, -105.2963130 + 40.0220660, -105.2963330 + 40.0220310, -105.2963790 + 40.0219240, -105.2964790 + 40.0218820, -105.2965520 + 40.0218770, -105.2965630 + 40.0218100, -105.2966340 + 40.0217380, -105.2966670 + 40.0217160, -105.2966900 + 40.0216770, -105.2967490 + 40.0216600, -105.2967830 + 40.0216550, -105.2968000 + 40.0216510, -105.2968200 + 40.0216400, -105.2968400 + 40.0215920, -105.2969220 + 40.0215720, -105.2969480 + 40.0215440, -105.2970120 + 40.0215380, -105.2970470 + 40.0215380, -105.2971040 + 40.0215290, -105.2971160 + 40.0215270, -105.2971190 + 40.0215320, -105.2971200 + 40.0215410, -105.2971100 + 40.0215440, -105.2971000 + 40.0215670, -105.2970410 + 40.0215740, -105.2970240 + 40.0216070, -105.2969910 + 40.0216350, -105.2969600 + 40.0216400, -105.2969440 + 40.0216540, -105.2969090 + 40.0216780, -105.2968650 + 40.0217040, -105.2968140 + 40.0217040, -105.2968000 + 40.0217050, -105.2967790 + 40.0217000, -105.2967360 + 40.0216850, -105.2967180 + 40.0216750, -105.2967110 + 40.0216530, -105.2966920 + 40.0216310, -105.2966500 + 40.0216320, -105.2966410 + 40.0216270, -105.2966050 + 40.0216100, -105.2965880 + 40.0216050, -105.2965610 + 40.0216020, -105.2965430 + 40.0215680, -105.2964980 + 40.0215550, -105.2964940 + 40.0215290, -105.2964860 + 40.0215080, -105.2964620 + 40.0215010, -105.2964440 + 40.0214880, -105.2964180 + 40.0214820, -105.2964100 + 40.0214350, -105.2963820 + 40.0213820, -105.2963700 + 40.0213570, -105.2963720 + 40.0213080, -105.2964050 + 40.0212920, -105.2964100 + 40.0212760, -105.2964020 + 40.0212630, -105.2963850 + 40.0212380, -105.2963640 + 40.0211680, -105.2963830 + 40.0211520, -105.2963820 + 40.0211380, -105.2963790 + 40.0211010, -105.2963510 + 40.0210890, -105.2963300 + 40.0210830, -105.2963200 + 40.0210730, -105.2963030 + 40.0210640, -105.2962860 + 40.0210550, -105.2962770 + 40.0210380, -105.2962560 + 40.0210170, -105.2962200 + 40.0209940, -105.2961930 + 40.0209820, -105.2961920 + 40.0209640, -105.2961920 + 40.0209500, -105.2962040 + 40.0208990, -105.2962220 + 40.0208880, -105.2962210 + 40.0208580, -105.2962220 + 40.0208460, -105.2962270 + 40.0207540, -105.2962410 + 40.0207370, -105.2962310 + 40.0207090, -105.2962250 + 40.0207080, -105.2962210 + 40.0206950, -105.2962290 + 40.0206880, -105.2962310 + 40.0206600, -105.2962330 + 40.0206430, -105.2962690 + 40.0206410, -105.2963240 + 40.0206190, -105.2964510 + 40.0206050, -105.2965010 + 40.0205950, -105.2965470 + 40.0205660, -105.2966680 + 40.0205570, -105.2966910 + 40.0205160, -105.2967500 + 40.0204830, -105.2967840 + 40.0204450, -105.2968090 + 40.0204260, -105.2968160 + 40.0203550, -105.2968520 + 40.0203440, -105.2968680 + 40.0203000, -105.2969500 + 40.0202780, -105.2970310 + 40.0202670, -105.2970960 + 40.0202650, -105.2971150 + 40.0202410, -105.2972730 + 40.0202330, -105.2972980 + 40.0201860, -105.2974740 + 40.0202110, -105.2976040 + 40.0202120, -105.2976260 + 40.0201700, -105.2977960 + 40.0201510, -105.2978440 +""".trimIndent() diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt new file mode 100644 index 00000000..89232a6d --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/HelloMapActivity.kt @@ -0,0 +1,87 @@ +/* + * 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.maps3dcomposedemo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D + +class HelloMapActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + HelloMapScreen() + } + } + } + } +} + +@Composable +fun HelloMapScreen() { + var isMapSteady by remember { mutableStateOf(false) } + + val flatironsCamera = remember { + camera { + center = latLngAltitude { + latitude = 38.74349839523953 + longitude = -109.49930710001824 + altitude = 1467.1204001315878 + } + heading = 151.8340412978984 + tilt = 68.31411315130784 + range = 250.56850704659155 + roll = 0.0 + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + GoogleMap3D( + camera = flatironsCamera, + modifier = Modifier.fillMaxSize(), + onMapSteady = { + isMapSteady = true + }, + ) + } +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt new file mode 100644 index 00000000..e73e370f --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MainActivity.kt @@ -0,0 +1,158 @@ +/* + * 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.maps3dcomposedemo + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + CatalogScreen() + } + } + } + } +} + +@Composable +fun CatalogScreen() { + val context = LocalContext.current + LazyColumn(modifier = Modifier.fillMaxSize().safeDrawingPadding()) { + item { + Text( + text = "Maps 3D Compose Samples", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(16.dp), + ) + } + item { + SampleItem("Basic Map with Marker & Polyline") { + context.startActivity(Intent(context, BasicMapActivity::class.java)) + } + } + item { + SampleItem("Hello Map") { + context.startActivity(Intent(context, HelloMapActivity::class.java)) + } + } + item { + SampleItem("Camera Controls") { + context.startActivity(Intent(context, CameraControlsActivity::class.java)) + } + } + item { + SampleItem("Map Interactions") { + context.startActivity(Intent(context, MapInteractionsActivity::class.java)) + } + } + item { + SampleItem("Markers") { + context.startActivity(Intent(context, MarkersActivity::class.java)) + } + } + item { + SampleItem("Custom Markers (PinConfig)") { + context.startActivity(Intent(context, CustomMarkersActivity::class.java)) + } + } + item { + SampleItem("Place Clicks") { + context.startActivity(Intent(context, PlaceClickActivity::class.java)) + } + } + item { + SampleItem("Models") { + context.startActivity(Intent(context, ModelsActivity::class.java)) + } + } + item { + SampleItem("Polygons") { + context.startActivity(Intent(context, PolygonsActivity::class.java)) + } + } + item { + SampleItem("Polylines") { + context.startActivity(Intent(context, PolylinesActivity::class.java)) + } + } + item { + SampleItem("Popovers") { + context.startActivity(Intent(context, PopoversActivity::class.java)) + } + } + item { + SampleItem("Map Options") { + context.startActivity(Intent(context, MapOptionsActivity::class.java)) + } + } + item { + SampleItem("Camera Animations") { + context.startActivity(Intent(context, CameraAnimationsActivity::class.java)) + } + } + item { + SampleItem("Camera Changed Listener (Whiskey Compass)") { + context.startActivity(Intent(context, CameraChangedActivity::class.java)) + } + } + } +} + +@Composable +fun SampleItem(title: String, onClick: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .clickable { onClick() }, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Text( + text = title, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyLarge, + ) + } +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt new file mode 100644 index 00000000..e47f8341 --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapInteractionsActivity.kt @@ -0,0 +1,128 @@ +/* + * 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.maps3dcomposedemo + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D + +class MapInteractionsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + MapInteractionsScreen() + } + } + } + } +} + +@Composable +fun MapInteractionsScreen() { + var isMapSteady by remember { mutableStateOf(false) } + var clickedInfo by remember { mutableStateOf("Click on the map to see details") } + var map3dInstance by remember { mutableStateOf(null) } + + // Calibrated camera centered around Colorado State Capitol + val calibratedCamera = remember { + camera { + center = latLngAltitude { + latitude = 39.73924812963158 + longitude = -104.98498430890453 + altitude = 1640.448817525612 + } + heading = 93.65938810195067 + tilt = 61.598097303273555 + range = 494.79807973388233 + roll = 0.0 + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + GoogleMap3D( + camera = calibratedCamera, + mapMode = Map3DMode.HYBRID, + modifier = Modifier.fillMaxSize(), + onMapReady = { instance -> + map3dInstance = instance + // Set up click listener directly on the instance + instance.setMap3DClickListener { location, placeId -> + Log.d("MapInteractionsActivity", "Map clicked at ${location.latitude}, ${location.longitude}") + clickedInfo = if (placeId != null) { + "Clicked Place ID: $placeId\nAt: ${location.latitude}, ${location.longitude}" + } else { + "Clicked Location: ${location.latitude}, ${location.longitude}" + } + } + }, + onMapSteady = { + isMapSteady = true + }, + ) + + // Click Info Card + Card( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + // Extra padding to avoid system bars + .padding(bottom = 32.dp), + ) { + Text( + text = clickedInfo, + modifier = Modifier + .padding(16.dp) + .semantics { contentDescription = clickedInfo }, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapOptionsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapOptionsActivity.kt new file mode 100644 index 00000000..ac99040d --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapOptionsActivity.kt @@ -0,0 +1,155 @@ +/* + * 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.maps3dcomposedemo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.cameraRestriction +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.android.gms.maps3d.model.latLngBounds +import com.google.maps.android.compose3d.GoogleMap3D + +class MapOptionsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + MapOptionsScreen() + } + } + } + } +} + +@Composable +fun MapOptionsScreen() { + var mapMode by remember { mutableStateOf(Map3DMode.SATELLITE) } + var isRestricted by remember { mutableStateOf(false) } + + // Camera centered on Devils Tower + val devilsTowerCamera = remember { + camera { + center = latLngAltitude { + latitude = 44.589994 + longitude = -104.715326 + altitude = 1508.9 + } + heading = 1.0 + tilt = 75.0 + range = 1635.0 + roll = 0.0 + } + } + + // Define a restriction area around Devils Tower + val restriction = remember { + cameraRestriction { + bounds = latLngBounds { + northEastLat = 44.595 + northEastLng = -104.710 + southWestLat = 44.585 + southWestLng = -104.720 + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + GoogleMap3D( + camera = devilsTowerCamera, + mapMode = mapMode, + cameraRestriction = if (isRestricted) restriction else null, + modifier = Modifier.fillMaxSize(), + ) + + // Control Panel + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + .background( + color = Color.Black.copy(alpha = 0.6f), + shape = RoundedCornerShape(16.dp), + ) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Map Options", + color = Color.White, + style = MaterialTheme.typography.titleMedium, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { mapMode = Map3DMode.SATELLITE }, + modifier = Modifier.weight(1f), + enabled = mapMode != Map3DMode.SATELLITE, + ) { + Text("Satellite") + } + Button( + onClick = { mapMode = Map3DMode.HYBRID }, + modifier = Modifier.weight(1f), + enabled = mapMode != Map3DMode.HYBRID, + ) { + Text("Hybrid") + } + } + + Button( + onClick = { isRestricted = !isRestricted }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (isRestricted) "Clear Restriction" else "Restrict to Area") + } + } + } +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt new file mode 100644 index 00000000..83f058a6 --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt @@ -0,0 +1,145 @@ +/* + * 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.maps3dcomposedemo + +import android.graphics.Color +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import com.google.android.gms.maps3d.Popover +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.ImageView +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.android.gms.maps3d.model.popoverOptions +import com.google.maps.android.compose3d.GoogleMap3D +import com.google.maps.android.compose3d.MarkerConfig +import com.google.android.gms.maps3d.GoogleMap3D as NativeGoogleMap3D + +class MarkersActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + MarkersScreen() + } + } + } + } +} + +@Composable +fun MarkersScreen() { + var isMapSteady by remember { mutableStateOf(false) } + + // Camera centered on Devils Tower + val devilsTowerCamera = remember { + camera { + center = latLngAltitude { + latitude = 44.589994 + longitude = -104.715326 + altitude = 1508.9 + } + heading = 1.0 + tilt = 75.0 + range = 1635.0 + roll = 0.0 + } + } + + val context = LocalContext.current + var activePopover by remember { mutableStateOf(null) } + var googleMap3DInstance by remember { mutableStateOf(null) } + + val alienMarker = remember { + MarkerConfig( + key = "alien", + position = latLngAltitude { + latitude = 44.59054845363309 + longitude = -104.715177415273 + altitude = 10.0 + }, + altitudeMode = AltitudeMode.RELATIVE_TO_MESH, + styleView = ImageView(R.drawable.alien), + label = "Devil's Tower Alien", + isExtruded = true, + isDrawnWhenOccluded = true, + onClick = { marker -> + googleMap3DInstance?.let { map -> + val textView = android.widget.TextView(context).apply { + text = "They didn't just come to sculpt mashed potatoes." + setPadding(32, 16, 32, 16) + setTextColor(Color.BLACK) + setBackgroundColor(Color.WHITE) + } + val newPopover = map.addPopover( + popoverOptions { + positionAnchor = marker + altitudeMode = AltitudeMode.ABSOLUTE + content = textView + autoCloseEnabled = true + autoPanEnabled = false + }, + ) + + activePopover?.remove() + activePopover = newPopover + activePopover?.show() + } + }, + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + GoogleMap3D( + camera = devilsTowerCamera, + mapMode = Map3DMode.HYBRID, + markers = listOf(alienMarker), + modifier = Modifier.fillMaxSize(), + onMapSteady = { + isMapSteady = true + }, + onMapReady = { instance -> + googleMap3DInstance = instance + }, + ) + } +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt new file mode 100644 index 00000000..12e35d33 --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt @@ -0,0 +1,193 @@ +/* + * 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.maps3dcomposedemo + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.flyAroundOptions +import com.google.android.gms.maps3d.model.flyToOptions +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D +import com.google.maps.android.compose3d.ModelConfig +import com.google.maps.android.compose3d.ModelScale +import com.google.maps.android.compose3d.utils.awaitCameraAnimation +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class ModelsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + ModelsScreen() + } + } +} + +@Composable +private fun ModelsScreen() { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + val initialCamera = camera { + center = latLngAltitude { + latitude = 47.133971 + longitude = 11.333161 + altitude = 2200.0 + } + heading = 221.0 + tilt = 25.0 + range = 30000.0 + } + + var cameraState by remember { mutableStateOf(initialCamera) } + var mapInstance by remember { mutableStateOf(null) } + var animationJob by remember { mutableStateOf(null) } + + val models = remember { + listOf( + ModelConfig( + key = "plane_model", + position = latLngAltitude { + latitude = 47.133971 + longitude = 11.333161 + altitude = 2200.0 + }, + url = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/Airplane.glb", + altitudeMode = AltitudeMode.ABSOLUTE, + scale = ModelScale.Uniform(0.05f), + heading = 41.5, + tilt = -90.0, + roll = 0.0, + onClick = { + Toast.makeText(context, "Clicked on Airplane!", Toast.LENGTH_SHORT).show() + }, + ), + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + GoogleMap3D( + camera = cameraState, + models = models, + mapMode = Map3DMode.SATELLITE, + modifier = Modifier.fillMaxSize(), + onMapReady = { googleMap3D -> + mapInstance = googleMap3D + + // Start animation sequence when map is ready + animationJob = coroutineScope.launch { + runAnimationSequence(googleMap3D) + } + }, + ) + + // UI Controls + FloatingActionButton( + onClick = { + animationJob?.cancel() + animationJob = coroutineScope.launch { + mapInstance?.let { map -> + // Fly back to initial camera + map.flyCameraTo( + flyToOptions { + endCamera = initialCamera + durationInMillis = 2000 + }, + ) + map.awaitCameraAnimation() + runAnimationSequence(map) + } + } + }, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(16.dp), + ) { + Text(text = "Reset") + } + + FloatingActionButton( + onClick = { + animationJob?.cancel() + animationJob = null + Toast.makeText(context, "Animation Stopped", Toast.LENGTH_SHORT).show() + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + ) { + Text(text = "Stop") + } + } +} + +private suspend fun runAnimationSequence(googleMap3D: com.google.android.gms.maps3d.GoogleMap3D) { + delay(1500) + + val camera = camera { + center = latLngAltitude { + latitude = 47.133971 + longitude = 11.333161 + altitude = 2200.0 + } + heading = 221.4 + tilt = 75.0 + range = 700.0 + } + + // Fly to the plane model + googleMap3D.flyCameraTo( + flyToOptions { + endCamera = camera + durationInMillis = 3500 + }, + ) + googleMap3D.awaitCameraAnimation() + + delay(500) + + // Fly around the plane model + googleMap3D.flyCameraAround( + flyAroundOptions { + center = camera + durationInMillis = 3500 + rounds = 0.5 + }, + ) + googleMap3D.awaitCameraAnimation() +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PlaceClickActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PlaceClickActivity.kt new file mode 100644 index 00000000..eabbc0a5 --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PlaceClickActivity.kt @@ -0,0 +1,100 @@ +/* + * 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.maps3dcomposedemo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D + +class PlaceClickActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + var clickedPlaceId by remember { mutableStateOf("") } + PlaceClickScreen(clickedPlaceId) { placeId -> + clickedPlaceId = placeId + } + } + } +} + +@Composable +fun PlaceClickScreen(clickedPlaceId: String, onPlaceClick: (String) -> Unit) { + var isMapSteady by remember { mutableStateOf(false) } + + val empireStateCamera = remember { + camera { + center = latLngAltitude { + latitude = 44.589994 + longitude = -104.715326 + altitude = 1508.9 + } + tilt = 60.0 + heading = 1.0 + range = 5000.0 + roll = 0.0 + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + GoogleMap3D( + camera = empireStateCamera, + mapMode = Map3DMode.HYBRID, + modifier = Modifier.fillMaxSize(), + onMapSteady = { + isMapSteady = true + }, + onPlaceClick = onPlaceClick, + ) + + if (clickedPlaceId.isNotEmpty()) { + Text( + text = "Clicked Place: $clickedPlaceId", + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + .background(Color.White) + .padding(8.dp) + .semantics { contentDescription = "ClickedPlaceText" }, + ) + } + } +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt new file mode 100644 index 00000000..7d4f99ed --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt @@ -0,0 +1,270 @@ +// 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.maps3dcomposedemo + +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.LatLngAltitude +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D +import com.google.maps.android.compose3d.PolygonConfig +import kotlinx.coroutines.launch + +/** + * Activity that demonstrates the use of polygons on a 3D map using Compose. + * + * This activity displays a polygon representing the Denver Zoo area. + * It uses the `PolygonConfig` to draw a filled shape with a border and a hole. + */ +class PolygonsActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + PolygonsScreen() + } + } + } + } +} + +@Composable +fun PolygonsScreen() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var isMapSteady by remember { mutableStateOf(false) } + + // Define the camera position centered around Denver Zoo + val denverCamera = remember { + camera { + center = latLngAltitude { + latitude = 39.748477 + longitude = -104.947575 + altitude = 1609.34 // Denver is a mile high! + } + heading = -68.0 + tilt = 47.0 + roll = 0.0 + range = 2251.0 + } + } + + // Define the outline for the zoo + val zooOutline = remember { + """ + 39.7508987, -104.9565381 + 39.7502883, -104.9565489 + 39.7501976, -104.9563557 + 39.7501481, -104.955594 + 39.7499171, -104.9553043 + 39.7495872, -104.9551648 + 39.7492407, -104.954961 + 39.7489685, -104.9548859 + 39.7484488, -104.9548966 + 39.7481189, -104.9548859 + 39.7479539, -104.9547679 + 39.7479209, -104.9544567 + 39.7476487, -104.9535341 + 39.7475085, -104.9525792 + 39.7474095, -104.9519247 + 39.747525, -104.9513776 + 39.7476734, -104.9511844 + 39.7478137, -104.9506265 + 39.7477559, -104.9496395 + 39.7477477, -104.9486203 + 39.7478467, -104.9475796 + 39.7482344, -104.9465818 + 39.7486138, -104.9457878 + 39.7491005, -104.9454874 + 39.7495789, -104.945938 + 39.7500491, -104.9466998 + 39.7503213, -104.9474615 + 39.7505358, -104.9486954 + 39.7505111, -104.950648 + 39.7511215, -104.9506587 + 39.7511173, -104.9527187 + 39.7511091, -104.9546445 + 39.7508987, -104.9565381 + """.trimIndent() + .lines() + .map { line -> line.split(",").map { it.trim().toDouble() } } + .map { (lat, lng) -> + latLngAltitude { + latitude = lat + longitude = lng + altitude = 0.0 + } + } + } + + // Define a hole inside the zoo area + val zooHole = remember { + """ + 39.7498, -104.9535 + 39.7498, -104.9525 + 39.7488, -104.9525 + 39.7488, -104.9535 + 39.7498, -104.9535 + """.trimIndent() + .lines() + .map { line -> line.split(",").map { it.trim().toDouble() } } + .map { (lat, lng) -> + latLngAltitude { + latitude = lat + longitude = lng + altitude = 0.0 + } + } + } + + // Create a polygon config + val polygonConfig = remember { + PolygonConfig( + key = "denver_zoo", + path = zooOutline, + innerPaths = listOf(zooHole), + // Translucent yellow + fillColor = Color.argb(70, 255, 255, 0), + strokeColor = Color.GREEN, + strokeWidth = 3f, + altitudeMode = AltitudeMode.CLAMP_TO_GROUND, + onClick = { polygon -> + Log.d("PolygonsActivity", "Polygon clicked: $polygon") + scope.launch { + Toast.makeText(context, "Zoo time!", Toast.LENGTH_SHORT).show() + } + }, + ) + } + + // Define the base face for the museum + val museumBaseFace = remember { + """ + 39.74812392425406, -104.94414971628434 + 39.7465307929639, -104.94370889409778 + 39.747031745033794, -104.9415078562927 + 39.74837320615968, -104.94194414397013 + 39.74812392425406, -104.94414971628434 + """.trimIndent() + .lines() + .map { line -> line.split(",").map { it.trim().toDouble() } } + .map { (lat, lng) -> + latLngAltitude { + latitude = lat + longitude = lng + altitude = 1609.34 // Denver is a mile high! + } + } + } + + // Create extruded polygons for the museum + val museumPolygons = remember { + extrudePolygon(museumBaseFace, 50.0).mapIndexed { index, outline -> + PolygonConfig( + key = "museum_face_$index", + path = outline, + // Semi-transparent magenta + fillColor = Color.argb(70, 255, 0, 255), + strokeColor = Color.MAGENTA, + strokeWidth = 3f, + altitudeMode = AltitudeMode.ABSOLUTE, + onClick = { polygon -> + Log.d("PolygonsActivity", "Museum face clicked: $polygon") + scope.launch { + Toast.makeText(context, "Museum time!", Toast.LENGTH_SHORT).show() + } + }, + ) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + GoogleMap3D( + camera = denverCamera, + mapMode = Map3DMode.HYBRID, + polygons = listOf(polygonConfig) + museumPolygons, + modifier = Modifier.fillMaxSize(), + onMapSteady = { + isMapSteady = true + }, + ) + } +} + +/** + * Extrudes a flat polygon (defined by basePoints, all at the same altitude) + * upwards by a given extrusionHeight to form a 3D prism. + */ +fun extrudePolygon( + basePoints: List, + extrusionHeight: Double, +): List> { + if (basePoints.size < 3) return emptyList() + if (extrusionHeight <= 0) return emptyList() + + val baseAltitude = basePoints.first().altitude + val topPoints = basePoints.map { basePoint -> + latLngAltitude { + latitude = basePoint.latitude + longitude = basePoint.longitude + altitude = baseAltitude + extrusionHeight + } + } + + val faces = mutableListOf>() + faces.add(basePoints.toList()) + faces.add(topPoints.toList().reversed()) + + for (i in basePoints.indices) { + val p1Base = basePoints[i] + val p2Base = basePoints[(i + 1) % basePoints.size] + val p1Top = topPoints[i] + val p2Top = topPoints[(i + 1) % basePoints.size] + faces.add(listOf(p1Base, p2Base, p2Top, p1Top)) + } + + return faces +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt new file mode 100644 index 00000000..499f9f7a --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt @@ -0,0 +1,140 @@ +// 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.maps3dcomposedemo + +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D +import com.google.maps.android.compose3d.PolylineConfig +import kotlinx.coroutines.launch + +/** + * Activity that demonstrates the use of polylines on a 3D map using Compose. + * + * This activity displays a polyline representing a portion of the Sanitas Loop trail + * in Boulder, Colorado. It uses the `PolylineConfig` to create a stroked effect + * with a red inner line and a translucent black outer line. + */ +class PolylinesActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + PolylinesScreen() + } + } + } + } +} + +@Composable +fun PolylinesScreen() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var isMapSteady by remember { mutableStateOf(false) } + + // Define the camera position centered around Mount Sanitas trailhead + val boulderCamera = remember { + camera { + center = latLngAltitude { + latitude = 40.029349 + longitude = -105.300354 + altitude = 1833.9 + } + heading = 326.0 + tilt = 75.0 + roll = 0.0 + range = 3757.0 + } + } + + // Parse the full trail data from sanitasLoop + val trailPoints = remember { + sanitasLoop.lines() + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { + val parts = it.split(",") + latLngAltitude { + latitude = parts[0].trim().toDouble() + longitude = parts[1].trim().toDouble() + altitude = 0.5 + } + } + } + + // Create a polyline config + val polylineConfig = remember { + PolylineConfig( + key = "sanitas_loop", + points = trailPoints, + color = Color.RED, + width = 10f, + altitudeMode = AltitudeMode.RELATIVE_TO_GROUND, + zIndex = 10, + drawsOccludedSegments = true, + onClick = { polyline -> + Log.d("PolylinesActivity", "Polyline clicked: $polyline") + scope.launch { + Toast.makeText(context, "Hiking time!", Toast.LENGTH_SHORT).show() + } + }, + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, + ) { + GoogleMap3D( + camera = boulderCamera, + mapMode = Map3DMode.HYBRID, + polylines = listOf(polylineConfig), + modifier = Modifier.fillMaxSize(), + onMapSteady = { + isMapSteady = true + }, + ) + } +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt new file mode 100644 index 00000000..0cc416a2 --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt @@ -0,0 +1,137 @@ +/* + * 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.maps3dcomposedemo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.compose3d.GoogleMap3D +import com.google.maps.android.compose3d.MarkerConfig +import com.google.maps.android.compose3d.PopoverConfig + +class PopoversActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + PopoversScreen() + } + } + } + } +} + +@Composable +fun PopoversScreen() { + var popovers by remember { mutableStateOf(emptyList()) } + var isMapSteady by remember { mutableStateOf(false) } + + // Camera centered on Devils Tower + val devilsTowerCamera = remember { + camera { + center = latLngAltitude { + latitude = 44.589994 + longitude = -104.715326 + altitude = 1508.9 + } + heading = 1.0 + tilt = 75.0 + range = 1635.0 + roll = 0.0 + } + } + + // Sample marker that will trigger the popover + val marker = remember { + MarkerConfig( + key = "popover_marker", + position = latLngAltitude { + latitude = 44.59054845363309 + longitude = -104.715177415273 + altitude = 10.0 + }, + altitudeMode = AltitudeMode.RELATIVE_TO_MESH, + label = "Click me for Popover", + isExtruded = true, + isDrawnWhenOccluded = true, + onClick = { + popovers = listOf( + PopoverConfig( + key = "popover_1", + positionAnchorKey = "popover_marker", + autoPanEnabled = false, + autoCloseEnabled = false, + content = { + Surface( + color = Color.White, + shape = RoundedCornerShape(8.dp), + modifier = Modifier.padding(8.dp), + ) { + Text( + text = "This is a Popover anchored to a marker!", + modifier = Modifier.padding(16.dp), + color = Color.Black, + ) + } + }, + ), + ) + }, + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + GoogleMap3D( + camera = devilsTowerCamera, + markers = listOf(marker), + popovers = popovers, + mapMode = Map3DMode.HYBRID, + modifier = Modifier.fillMaxSize(), + onMapSteady = { + isMapSteady = true + }, + onMapClick = { + popovers = emptyList() + }, + ) + } +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/RangeScale.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/RangeScale.kt new file mode 100644 index 00000000..3c1c7652 --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/RangeScale.kt @@ -0,0 +1,26 @@ +package com.example.maps3dcomposedemo.widgets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt + +@Composable +fun RangeScale(range: Float, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)) + .padding(8.dp), + ) { + Text( + text = "Range: ${range.roundToInt()} m", + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.bodyMedium, + ) + } +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/TiltScale.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/TiltScale.kt new file mode 100644 index 00000000..358e5344 --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/TiltScale.kt @@ -0,0 +1,81 @@ +package com.example.maps3dcomposedemo.widgets + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun TiltScale(tilt: Float, modifier: Modifier = Modifier) { + // User requested: 90 = straight down, 0 = horizontal + // SDK: 0 = straight down, 90 = horizontal + // So displayedTilt = 90 - sdkTilt + val displayedTilt = 90f - tilt + val textMeasurer = rememberTextMeasurer() + val onPrimaryColor = MaterialTheme.colorScheme.onPrimaryContainer + + Box( + modifier = modifier + .height(300.dp) + .width(80.dp) + .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)) + .clipToBounds(), + contentAlignment = Alignment.Center, + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val centerLineY = size.height / 2f + val centerLineX = size.width / 2f + + // Pixels per degree + val pixelsPerDegree = 5f + + // Draw a fixed center line (pointer) + drawLine( + color = Color.Red, + start = Offset(0f, centerLineY), + end = Offset(size.width, centerLineY), + strokeWidth = 2f, + ) + + // Draw scrolling ticks + translate(top = centerLineY + (displayedTilt * pixelsPerDegree)) { + for (i in 0..90 step 5) { + val yPos = -i * pixelsPerDegree + + val isMajor = i % 15 == 0 + val tickLength = if (isMajor) 15f else 8f + + drawLine( + color = onPrimaryColor, + start = Offset(centerLineX - tickLength, yPos), + end = Offset(centerLineX + tickLength, yPos), + strokeWidth = if (isMajor) 2f else 1f, + ) + + if (isMajor) { + val measuredText = textMeasurer.measure(i.toString(), style = TextStyle(color = onPrimaryColor, fontSize = 12.sp)) + drawText( + textLayoutResult = measuredText, + topLeft = Offset(centerLineX + 20f, yPos - measuredText.size.height / 2f), + ) + } + } + } + } + } +} diff --git a/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/WhiskeyCompass.kt b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/WhiskeyCompass.kt new file mode 100644 index 00000000..ef3ed41e --- /dev/null +++ b/maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/widgets/WhiskeyCompass.kt @@ -0,0 +1,326 @@ +package com.example.maps3dcomposedemo.widgets + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.roundToInt + +/** + * A composable that displays a customizable aviation-style "whiskey" compass. + * This version renders a flat, scrollable strip with ticks, degree labels, and cardinal directions. + * + * @param heading The current heading in degrees (0-360). + * @param modifier The modifier to be applied to the compass. + * @param stripHeight The total visual height of the compass strip. + * @param backgroundColor The background color of the compass strip. + * @param tickColor The color of the tick marks on the rotating dial. + * @param lubberLineColor The color of the central indicator line. + * @param pixelsPerDegree Controls the horizontal spacing between degree markers on the dial. + * + * @param showDegreeLabels Whether to display numeric degree labels on the strip. + * @param degreeLabelInterval Interval for numeric degree labels (e.g., every 15 degrees). + * @param degreeLabelTextStyle TextStyle for the numeric degree labels on the strip. + * @param degreeLabelVerticalOffset Vertical offset of degree labels from the bottom of the ticks. + * + * @param showCardinalLabels Whether to display cardinal direction labels (N, NE, E, etc.) on the strip. + * @param cardinalLabelTextStyle TextStyle for the cardinal direction labels on the strip. + * @param cardinalLabelVerticalOffset Vertical offset of cardinal labels from the top of the ticks. + * + * @param majorTickHeight Height of the major tick marks. + * @param minorTickHeight Height of the minor tick marks. + * @param majorTickStrokeWidth Stroke width for major ticks. + * @param minorTickStrokeWidth Stroke width for minor ticks. + * @param lubberLineStrokeWidth Stroke width for the lubber line. + */ +@Composable +fun WhiskeyCompass( + heading: Float, + modifier: Modifier = Modifier, + stripHeight: Dp = 80.dp, + backgroundColor: Color = MaterialTheme.colorScheme.primaryContainer, + tickColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, + lubberLineColor: Color = Color.Red, + pixelsPerDegree: Float = 10f, + + showDegreeLabels: Boolean = true, + degreeLabelInterval: Int = 15, + degreeLabelTextStyle: TextStyle = MaterialTheme.typography.labelSmall.copy(textAlign = TextAlign.Center), + degreeLabelVerticalOffset: Dp = 4.dp, + + showCardinalLabels: Boolean = true, + // Added for clarity + cardinalLabelInterval: Int = 45, + cardinalLabelTextStyle: TextStyle = MaterialTheme.typography.labelMedium.copy( + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ), + cardinalLabelVerticalOffset: Dp = 4.dp, + + majorTickHeight: Dp = 25.dp, + minorTickHeight: Dp = 15.dp, + majorTickStrokeWidth: Dp = 2.dp, + minorTickStrokeWidth: Dp = 1.dp, + lubberLineStrokeWidth: Dp = 2.dp, +) { + val textMeasurer = rememberTextMeasurer() + val density = LocalDensity.current + + // Normalize heading to the 0-360 range to prevent offset issues + val normalizedHeading = (heading % 360f + 360f) % 360f + + // Memoize measured cardinal labels for performance + val measuredCardinalLabels = remember(cardinalLabelTextStyle, density) { + with(density) { + mapOf( + 0 to textMeasurer.measure("N", style = cardinalLabelTextStyle), + 45 to textMeasurer.measure("NE", style = cardinalLabelTextStyle), + 90 to textMeasurer.measure("E", style = cardinalLabelTextStyle), + 135 to textMeasurer.measure("SE", style = cardinalLabelTextStyle), + 180 to textMeasurer.measure("S", style = cardinalLabelTextStyle), + 225 to textMeasurer.measure("SW", style = cardinalLabelTextStyle), + 270 to textMeasurer.measure("W", style = cardinalLabelTextStyle), + 315 to textMeasurer.measure("NW", style = cardinalLabelTextStyle), + ) + } + } + + Box( + modifier = modifier + .height(stripHeight) + .background(backgroundColor) + .clipToBounds(), + contentAlignment = Alignment.Center, + ) { + // Canvas 1: Scrolling Compass Strip (ticks, degree labels, cardinal labels) + Canvas(modifier = Modifier.matchParentSize()) { + val majorTickHeightPx = majorTickHeight.toPx() + val minorTickHeightPx = minorTickHeight.toPx() + val majorTickStrokeWidthPx = majorTickStrokeWidth.toPx() + val minorTickStrokeWidthPx = minorTickStrokeWidth.toPx() + val degreeLabelVerticalOffsetPx = degreeLabelVerticalOffset.toPx() + val cardinalLabelVerticalOffsetPx = cardinalLabelVerticalOffset.toPx() + + val canvasWidth = size.width + val canvasCenterY = center.y + + val xOffset = center.x - (normalizedHeading * pixelsPerDegree) + val tickCenterY = canvasCenterY + + translate(left = xOffset) { + for (repetition in -1..1) { + val repetitionBaseDegree = repetition * 360 + for (degreeInRepetition in 0 until 360) { + val absoluteDegree = repetitionBaseDegree + degreeInRepetition + val xPos = absoluteDegree * pixelsPerDegree + + if (xPos < -xOffset + canvasWidth + canvasWidth && xPos > -xOffset - canvasWidth) { + val isMajorTickEquivalent = degreeInRepetition % 10 == 0 + val isMinorTickEquivalent = + degreeInRepetition % 5 == 0 && !isMajorTickEquivalent + + if (isMajorTickEquivalent) { + val tickTopY = tickCenterY - majorTickHeightPx / 2f + val tickBottomY = tickCenterY + majorTickHeightPx / 2f + drawLine( + color = tickColor, + start = Offset(x = xPos, y = tickTopY), + end = Offset(x = xPos, y = tickBottomY), + strokeWidth = majorTickStrokeWidthPx, + ) + + if (showCardinalLabels && measuredCardinalLabels.containsKey( + degreeInRepetition + ) + ) { + val measuredText = + measuredCardinalLabels.getValue(degreeInRepetition) + drawText( + textLayoutResult = measuredText, + topLeft = Offset( + x = xPos - measuredText.size.width / 2f, + y = tickTopY - measuredText.size.height - cardinalLabelVerticalOffsetPx, + ), + ) + } + } else if (isMinorTickEquivalent) { + val tickTopY = tickCenterY - minorTickHeightPx / 2f + val tickBottomY = tickCenterY + minorTickHeightPx / 2f + drawLine( + color = tickColor, + start = Offset(x = xPos, y = tickTopY), + end = Offset(x = xPos, y = tickBottomY), + strokeWidth = minorTickStrokeWidthPx, + ) + } + + if (showDegreeLabels && degreeInRepetition % degreeLabelInterval == 0) { + val tickBottomY = tickCenterY + (if (isMajorTickEquivalent) { + majorTickHeightPx + } else if (isMinorTickEquivalent) { + minorTickHeightPx + } else { + 0f + }) / 2f + val labelText = degreeInRepetition.toString() + val measuredText = + textMeasurer.measure(labelText, style = degreeLabelTextStyle) + drawText( + textLayoutResult = measuredText, + topLeft = Offset( + x = xPos - measuredText.size.width / 2f, + y = tickBottomY + degreeLabelVerticalOffsetPx, + ), + ) + } + } + } + } + } + } + + // Canvas 2: Fixed Lubber Line + Canvas(modifier = Modifier.matchParentSize()) { + val lubberLineVisualPadding = stripHeight.toPx() * 0.05f + val currentLubberLineStrokeWidthPx = lubberLineStrokeWidth.toPx() + drawLine( + color = lubberLineColor, + start = Offset(x = center.x, y = lubberLineVisualPadding), + end = Offset(x = center.x, y = size.height - lubberLineVisualPadding), + strokeWidth = currentLubberLineStrokeWidthPx, + ) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF222222) +@Composable +private fun FlatWhiskeyCompassPreview() { + val exampleHeadings = listOf( + 0f, + 10f, + 22.5f, + 45f, + 168f, + 270f, + 358f, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color.DarkGray) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + "Default Flat Compass Strip", + color = Color.White, + style = MaterialTheme.typography.titleMedium + ) + WhiskeyCompass( + heading = 45f, + modifier = Modifier.fillMaxWidth(), + stripHeight = 100.dp, + pixelsPerDegree = 8f, + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text( + "Customized Labels & Ticks", + color = Color.White, + style = MaterialTheme.typography.titleMedium + ) + WhiskeyCompass( + heading = 123f, + modifier = Modifier.fillMaxWidth(), + stripHeight = 120.dp, + backgroundColor = Color(0xFF1A237E), + tickColor = Color(0xFFB0BEC5), + lubberLineColor = Color(0xFFFFD600), + pixelsPerDegree = 12f, + degreeLabelInterval = 10, + degreeLabelTextStyle = MaterialTheme.typography.bodySmall.copy(color = Color(0xFF81D4FA)), + cardinalLabelTextStyle = MaterialTheme.typography.labelLarge.copy( + color = Color.White, + fontWeight = FontWeight.Bold + ), + majorTickHeight = 30.dp, + minorTickHeight = 18.dp, + degreeLabelVerticalOffset = 6.dp, + cardinalLabelVerticalOffset = 6.dp, + majorTickStrokeWidth = 2.5.dp, + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text( + "No Cardinal Labels", + color = Color.White, + style = MaterialTheme.typography.titleMedium + ) + WhiskeyCompass( + heading = 210f, + modifier = Modifier.fillMaxWidth(), + stripHeight = 70.dp, + showCardinalLabels = false, + pixelsPerDegree = 6f, + ) + + Spacer(modifier = Modifier.height(16.dp)) + Text("No Degree Labels", color = Color.White, style = MaterialTheme.typography.titleMedium) + WhiskeyCompass( + heading = 300f, + modifier = Modifier.fillMaxWidth(), + stripHeight = 70.dp, + showDegreeLabels = false, + pixelsPerDegree = 6f, + ) + + exampleHeadings.forEach { currentHeading -> + Spacer(Modifier.height(12.dp)) + Text( + text = "Test Heading: ${currentHeading.roundToInt()}°", + color = Color.LightGray, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + ) + WhiskeyCompass( + heading = currentHeading, + modifier = Modifier.fillMaxWidth(), + stripHeight = 90.dp, + pixelsPerDegree = 7f, + degreeLabelInterval = 30, + ) + } + } +} diff --git a/maps3d-compose-demo/src/main/res/drawable/alien.png b/maps3d-compose-demo/src/main/res/drawable/alien.png new file mode 100644 index 00000000..2c38fc9e Binary files /dev/null and b/maps3d-compose-demo/src/main/res/drawable/alien.png differ diff --git a/maps3d-compose-demo/src/main/res/values/strings.xml b/maps3d-compose-demo/src/main/res/values/strings.xml new file mode 100644 index 00000000..62ed188a --- /dev/null +++ b/maps3d-compose-demo/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + + + Maps 3D Compose Demo + diff --git a/maps3d-compose/README.md b/maps3d-compose/README.md new file mode 100644 index 00000000..51445948 --- /dev/null +++ b/maps3d-compose/README.md @@ -0,0 +1,17 @@ +# Maps 3D Compose Wrapper (Experimental) + +This library provides a Jetpack Compose wrapper for the Google Maps 3D SDK for Android. + +> [!WARNING] +> **Status**: This implementation is a **Work In Progress (WIP) experiment** and serves as a **reference implementation**. It is not intended for production use. APIs may change significantly in the future. + +## What's Inside + +This module contains the source code for the Compose wrapper: +- **`GoogleMap3D`**: The main Composable function to display a 3D map. +- **Declarative State**: Support for adding Markers, Polylines, Polygons, 3D Models, and Popovers via standard Compose state lists. +- **Camera Hoisting**: Ability to hoist the camera state for animations and position tracking. + +## Current Coverage + +For a detailed list of supported features and missing APIs, see [compose_api_coverage.md](./compose_api_coverage.md). diff --git a/maps3d-compose/build.gradle.kts b/maps3d-compose/build.gradle.kts new file mode 100644 index 00000000..27cc602d --- /dev/null +++ b/maps3d-compose/build.gradle.kts @@ -0,0 +1,75 @@ +/* + * 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. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.spotless) +} + +configure { + kotlin { + target("**/*.kt") + ktlint().editorConfigOverride(mapOf("indent_size" to "4", "ktlint_function_naming_ignore_when_annotated_with" to "Composable")) + trimTrailingWhitespace() + endWithNewline() + } +} + +android { + namespace = "com.google.maps.android.compose3d" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + + // Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + + // Maps 3D SDK + implementation(libs.play.services.maps3d) + implementation(libs.maps.utils.ktx) + + testImplementation(libs.junit) + testImplementation(libs.robolectric) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/maps3d-compose/compose_api_coverage.md b/maps3d-compose/compose_api_coverage.md new file mode 100644 index 00000000..b07fa580 --- /dev/null +++ b/maps3d-compose/compose_api_coverage.md @@ -0,0 +1,83 @@ +# Compose API Coverage Checklist + +This document tracks the coverage of the Maps 3D SDK APIs in the experimental Compose wrapper. + +> [!WARNING] +> This implementation is a **Work In Progress (WIP) experiment** and serves as a **reference implementation**. It is not intended for production use. + +## Coverage Status + +| Feature / Class | Status | Reference / Notes | +| :--- | :--- | :--- | +| **Map3DOptions** | Supported | Passed to `GoogleMap3D` in [`GoogleMap3D.kt:L69`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L69). | +| **OnCameraAnimationEndListener** | Supported via Native | Used in sample extensions, not exposed as parameter. | +| **OnCameraChangedListener** | Supported | Exposed as `onCameraChanged` in `GoogleMap3D` in [`GoogleMap3D.kt:L73`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L73). | +| **OnFirstSceneListener** | Not Supported | Not yet exposed. | +| **OnMap3DClickListener** | Supported | Exposed as `onMapClick` in `GoogleMap3D`. | +| **OnMap3DViewReadyCallback** | Handled Internally | Used in [`GoogleMap3D.kt:L84`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L84) to initialize. | +| **OnMarkerClickListener** | Handled Internally | Exposed as `onClick` in `MarkerConfig`. | +| **OnModelClickListener** | Handled Internally | Exposed as `onClick` in `ModelConfig`. | +| **OnPlaceClickListener** | Supported | Exposed as `onPlaceClick` in `GoogleMap3D`. | +| **OnPolygonClickListener** | Handled Internally | Exposed as `onClick` in `PolygonConfig`. | +| **OnPolylineClickListener** | Handled Internally | Exposed as `onClick` in `PolylineConfig`. | +| **AltitudeMode** | Supported | Defined in [`DataModels.kt`](src/main/java/com/google/maps/android/compose3d/DataModels.kt). | +| **Camera** | Supported | Hoisted in `GoogleMap3D` [`GoogleMap3D.kt:L60`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L60). | +| **CameraRestriction** | Supported | Passed to `GoogleMap3D` [`GoogleMap3D.kt:L67`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L67). | +| **CollisionBehavior** | Supported | Used in `MarkerConfig` in [`DataModels.kt`](src/main/java/com/google/maps/android/compose3d/DataModels.kt). | +| **FlyAround / FlyTo** | Supported via Native | Used in samples via native instance, not declarative. | +| **Glyph** | Supported | Via `GlyphConfig` in [`DataModels.kt`](src/main/java/com/google/maps/android/compose3d/DataModels.kt). | +| **Hole** | Supported | Used in `PolygonConfig` in `Map3DState.kt`. | +| **ImageView** | Handled Internally | Used for Popover content rendering. | +| **Marker / MarkerOptions** | Supported | Via `MarkerConfig` in [`DataModels.kt:L35`](src/main/java/com/google/maps/android/compose3d/DataModels.kt#L35). | +| **Model / ModelOptions** | Supported | Via `ModelConfig` in [`DataModels.kt:L93`](src/main/java/com/google/maps/android/compose3d/DataModels.kt#L93). | +| **Orientation** | Supported | Used in `ModelConfig` in [`DataModels.kt`](src/main/java/com/google/maps/android/compose3d/DataModels.kt). | +| **PinConfiguration** | Supported | Via `PinConfig` in [`DataModels.kt`](src/main/java/com/google/maps/android/compose3d/DataModels.kt). | +| **Polygon / PolygonOptions** | Supported | Via `PolygonConfig` in [`DataModels.kt:L70`](src/main/java/com/google/maps/android/compose3d/DataModels.kt#L70). | +| **Polyline / PolylineOptions** | Supported | Via `PolylineConfig` in [`DataModels.kt:L52`](src/main/java/com/google/maps/android/compose3d/DataModels.kt#L52). | +| **OnMapReady** | Supported | Callback in `GoogleMap3D` [`GoogleMap3D.kt:L70`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L70). | +| **OnMapSteady** | Supported | Callback in `GoogleMap3D` [`GoogleMap3D.kt:L71`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L71). | +| **Popover / PopoverContentsView...**| Supported | Via `PopoverConfig` in [`DataModels.kt:L109`](src/main/java/com/google/maps/android/compose3d/DataModels.kt#L109). | +| **Vector3D** | Supported | Used for scale in [`Map3DState.kt`](src/main/java/com/google/maps/android/compose3d/Map3DState.kt). | +| **GoogleMap3D Composable...** | Supported | Lifecycle handling implemented in [`GoogleMap3D.kt`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt). | +| **Core state management...** | Partial | Map properties supported, gestures might need `Map3DViewUiController`. | +| **Camera animation...** | Supported via Native | `flyCameraTo` and `flyCameraAround` used in samples. | + +## Not Yet Exposed Functionality + +The following features and listeners from the Maps 3D SDK are not yet supported or exposed in this experimental Compose wrapper: + +| Feature / Class | Status | Reference / Notes | +| :--- | :--- | :--- | +| **Map3DViewUiController** | Not Supported | Gestures and UI settings controller. | +| **Anchorable** | Not Supported | Interface for anchorable objects. | +| **BoundingBox** | Not Supported | Spatial bounding box. | +| **DrawingState** | Not Supported | State of drawing operations. | +| **MarkerView / MarkerViewOptions** | Not Supported | View-based markers. | +| **PinView** | Not Supported | Custom pin views. | +| **VisibilityState** | Not Supported | Visibility state tracking. | + +## References + +### Implementation in `maps3d-compose` + +- **Core Composable**: [`GoogleMap3D.kt:L59`](src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt#L59) +- **State Management (`Map3DState.kt`)**: + - `syncMarkers`: [`Map3DState.kt:L51`](src/main/java/com/google/maps/android/compose3d/Map3DState.kt#L51) + - `syncPolylines`: [`Map3DState.kt:L109`](src/main/java/com/google/maps/android/compose3d/Map3DState.kt#L109) + - `syncPolygons`: [`Map3DState.kt:L154`](src/main/java/com/google/maps/android/compose3d/Map3DState.kt#L154) + - `syncModels`: [`Map3DState.kt:L208`](src/main/java/com/google/maps/android/compose3d/Map3DState.kt#L208) + - `syncPopovers`: [`Map3DState.kt:L253`](src/main/java/com/google/maps/android/compose3d/Map3DState.kt#L253) + +### Demonstrations in `maps3d-compose-demo` + +- **Basic Map & Camera**: [`BasicMapActivity.kt:L140`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/BasicMapActivity.kt#L140) +- **Map Options & Restrictions**: [`MapOptionsActivity.kt:L102`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MapOptionsActivity.kt#L102) +- **Camera Animations**: [`CameraAnimationsActivity.kt:L88`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraAnimationsActivity.kt#L88) +- **Markers**: [`MarkersActivity.kt:L89`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/MarkersActivity.kt#L89) +- **Custom Markers**: [`CustomMarkersActivity.kt`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CustomMarkersActivity.kt) +- **Polylines**: [`PolylinesActivity.kt:L108`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolylinesActivity.kt#L108) +- **Polygons**: [`PolygonsActivity.kt:L161`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PolygonsActivity.kt#L161) +- **3D Models**: [`ModelsActivity.kt:L83`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/ModelsActivity.kt#L83) +- **Popovers**: [`PopoversActivity.kt:L102`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PopoversActivity.kt#L102) +- **Camera Changed Listener**: [`CameraChangedActivity.kt`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/CameraChangedActivity.kt) +- **Place Clicks**: [`PlaceClickActivity.kt`](../maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/PlaceClickActivity.kt) diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.kt new file mode 100644 index 00000000..39ad04d3 --- /dev/null +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/DataModels.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.google.maps.android.compose3d + +import androidx.annotation.WorkerThread +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.CollisionBehavior +import com.google.android.gms.maps3d.model.ImageView +import com.google.android.gms.maps3d.model.LatLngAltitude +import com.google.android.gms.maps3d.model.Marker +import com.google.android.gms.maps3d.model.Model +import com.google.android.gms.maps3d.model.Polygon +import com.google.android.gms.maps3d.model.Polyline + +/** + * Sealed class representing the glyph (icon/text) inside a pin marker. + */ +sealed class GlyphConfig { + data class Color(val color: Int) : GlyphConfig() + data class Text(val text: String, val color: Int? = null) : GlyphConfig() + data class Circle(val color: Int? = null) : GlyphConfig() + data class Image(val imageResId: Int, val color: Int? = null) : GlyphConfig() +} + +/** + * Data class representing the configuration of a pin marker. + */ +@Immutable +data class PinConfig( + val scale: Float? = null, + val backgroundColor: Int? = null, + val borderColor: Int? = null, + val glyph: GlyphConfig? = null, +) + +/** + * Data class representing a Marker to be added to the 3D map. + */ +@Immutable +data class MarkerConfig( + val key: String, + val position: LatLngAltitude, + val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND, + val styleView: ImageView? = null, + val label: String = "", + val zIndex: Int = 0, + val isExtruded: Boolean = false, + val isDrawnWhenOccluded: Boolean = false, + val collisionBehavior: Int = CollisionBehavior.REQUIRED, + val pinConfig: PinConfig? = null, + val onClick: ((Marker) -> Unit)? = null, +) + +/** + * Data class representing a Polyline to be added to the 3D map. + */ +@Immutable +data class PolylineConfig( + val key: String, + val points: List, + val color: Int, + val width: Float, + val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND, + val zIndex: Int = 0, + val outerColor: Int = 0, + val outerWidth: Float = 0f, + val drawsOccludedSegments: Boolean = false, + @get:WorkerThread + val onClick: ((Polyline) -> Unit)? = null, +) + +/** + * Data class representing a Polygon to be added to the 3D map. + */ +@Immutable +data class PolygonConfig( + val key: String, + val path: List, + val innerPaths: List> = emptyList(), + val fillColor: Int, + val strokeColor: Int, + val strokeWidth: Float, + val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND, + val onClick: ((Polygon) -> Unit)? = null, +) + +/** + * Sealed class representing the scale of a 3D model. + */ +sealed class ModelScale { + data class Uniform(val value: Float) : ModelScale() + data class PerAxis(val x: Float, val y: Float, val z: Float) : ModelScale() +} + +/** + * Data class representing a 3D Model to be added to the 3D map. + */ +@Immutable +data class ModelConfig( + val key: String, + val position: LatLngAltitude, + val url: String, + val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND, + val scale: ModelScale = ModelScale.Uniform(1.0f), + val heading: Double = 0.0, + val tilt: Double = 0.0, + val roll: Double = 0.0, + val onClick: ((Model) -> Unit)? = null, +) + +/** + * Data class representing a Popover to be added to the 3D map. + */ +@Immutable +data class PopoverConfig( + val key: String, + val positionAnchorKey: String, + val content: @Composable () -> Unit, + val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND, + val autoCloseEnabled: Boolean = true, + val autoPanEnabled: Boolean = true, +) diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt new file mode 100644 index 00000000..84387ae5 --- /dev/null +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/GoogleMap3D.kt @@ -0,0 +1,168 @@ +/* + * 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.google.maps.android.compose3d + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.Map3DOptions +import com.google.android.gms.maps3d.Map3DView +import com.google.android.gms.maps3d.OnMap3DViewReadyCallback +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.CameraRestriction +import com.google.android.gms.maps3d.model.Map3DMode +import com.google.maps.android.compose3d.utils.toValidCamera +import com.google.maps.android.compose3d.utils.toValidCameraRestriction + +/** + * A declarative Compose wrapper for the Google Maps 3D SDK [Map3DView]. + * + * This composable allows you to display a 3D map and control it using standard Compose state. + * It handles the underlying view lifecycle and synchronizes state objects (markers, polylines, + * polygons, and models) with the imperative SDK instance. + * + * Literate Programming Note: Initialization in the Maps 3D SDK is tricky. We cannot rely solely + * on `getMap3DViewAsync`. We must wait for `setOnMapReadyListener` to fire before adding any + * content, otherwise additions might be ignored. Furthermore, this listener only fires ONCE + * in the lifetime of the application. To handle this, we track readiness globally in + * [Map3DRegistry] and defer all state updates until we are certain the map is ready. + * + * @param camera The hoisted camera state to apply to the map. + * @param markers The list of markers to display on the map. + * @param polylines The list of polylines to display on the map. + * @param polygons The list of polygons to display on the map. + * @param models The list of 3D models to display on the map. + * @param cameraRestriction The camera restriction to apply to the map. + * @param mapMode The map mode (e.g., SATELLITE, HYBRID). + * @param modifier The modifier to apply to the layout. + * @param options The options to initialize the [Map3DView] with. + * @param onMapReady Optional callback invoked when the [GoogleMap3D] instance is ready. + */ +@Composable +fun GoogleMap3D( + camera: Camera, + modifier: Modifier = Modifier, + markers: List = emptyList(), + polylines: List = emptyList(), + polygons: List = emptyList(), + models: List = emptyList(), + popovers: List = emptyList(), + cameraRestriction: CameraRestriction? = null, + @Map3DMode mapMode: Int = Map3DMode.SATELLITE, + options: Map3DOptions = Map3DOptions(), + onMapReady: (GoogleMap3D) -> Unit = {}, + onMapSteady: () -> Unit = {}, + onMapClick: (() -> Unit)? = null, + onPlaceClick: ((String) -> Unit)? = null, + onCameraChanged: (Camera) -> Unit = {}, +) { + val state = remember { Map3DState() } + val hasCalledOnMapReady = remember { mutableStateOf(false) } + val googleMap3DState = remember { mutableStateOf(null) } + + // Use rememberUpdatedState to avoid capturing stale lambdas in the async callback + val currentOnMapSteady by rememberUpdatedState(onMapSteady) + val currentOnCameraChanged by rememberUpdatedState(onCameraChanged) + val currentOnMapReady by rememberUpdatedState(onMapReady) + val currentOnMapClick by rememberUpdatedState(onMapClick) + val currentOnPlaceClick by rememberUpdatedState(onPlaceClick) + + AndroidView( + modifier = modifier, + factory = { context -> + val map3dView = Map3DView(context, options) + map3dView.onCreate(null) + + map3dView.getMap3DViewAsync(object : OnMap3DViewReadyCallback { + override fun onMap3DViewReady(googleMap3D: GoogleMap3D) { + googleMap3DState.value = googleMap3D + Map3DRegistry.setInstance(googleMap3D) + + googleMap3D.setOnMapSteadyListener { isSteady -> + if (isSteady) { + currentOnMapSteady() + } + } + + googleMap3D.setCameraChangedListener { camera -> + currentOnCameraChanged(camera) + } + + if (currentOnMapClick != null || currentOnPlaceClick != null) { + googleMap3D.setMap3DClickListener { _, placeId -> + if (placeId != null) { + currentOnPlaceClick?.invoke(placeId) + } else { + currentOnMapClick?.invoke() + } + } + } + } + + override fun onError(error: Exception) { + throw error + } + }) + + map3dView + }, + update = { map3dView -> + val googleMap3D = googleMap3DState.value + if (googleMap3D != null) { + fun applyUpdates() { + if (!hasCalledOnMapReady.value) { + currentOnMapReady(googleMap3D) + hasCalledOnMapReady.value = true + } + + // Sync hoisted state with the imperative map instance + googleMap3D.setCamera(camera.toValidCamera()) + googleMap3D.setCameraRestriction(cameraRestriction.toValidCameraRestriction()) + googleMap3D.setMapMode(mapMode) + + state.syncMarkers(googleMap3D, markers) + state.syncPolylines(googleMap3D, polylines) + state.syncPolygons(googleMap3D, polygons) + state.syncModels(googleMap3D, models) + state.syncPopovers(map3dView.context, googleMap3D, popovers) + } + + if (Map3DRegistry.isMapReady) { + // Map was already ready (e.g. reused instance), apply updates immediately + applyUpdates() + } else { + // First time initialization, must wait for listener + googleMap3D.setOnMapReadyListener { + googleMap3D.setOnMapReadyListener(null) // Clear it immediately + Map3DRegistry.markReady() + applyUpdates() + } + } + } + }, + onRelease = { map3dView -> + state.clear() + Map3DRegistry.clearInstance() + map3dView.onDestroy() + }, + ) +} diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DRegistry.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DRegistry.kt new file mode 100644 index 00000000..c1a2b697 --- /dev/null +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DRegistry.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.google.maps.android.compose3d + +import com.google.android.gms.maps3d.GoogleMap3D + +/** + * A global registry to manage the single instance of [GoogleMap3D] provided by the SDK. + * + * The Maps 3D SDK typically creates and reuses a single [GoogleMap3D] instance across + * multiple [com.google.android.gms.maps3d.Map3DView] instances. To adhere to Compose + * design principles and avoid passing this imperative object through ViewModels, this + * registry holds the instance and provides access to it for state synchronization. + * + * We also track whether the map has ever been "ready" via `OnMapReadyListener`, + * as the SDK only triggers this once per application lifetime. + */ +object Map3DRegistry { + private var mapInstance: GoogleMap3D? = null + + /** + * Tracks whether the map has been initialized and is ready for content. + * The SDK only calls `OnMapReadyListener` once. + */ + var isMapReady: Boolean = false + internal set + + /** + * Sets the [GoogleMap3D] instance. This should be called when the map is ready + * in the [GoogleMap3D] composable. + */ + fun setInstance(map: GoogleMap3D) { + mapInstance = map + } + + /** + * Retrieves the current [GoogleMap3D] instance, if available. + */ + fun getInstance(): GoogleMap3D? = mapInstance + + /** + * Clears the instance. This should be called when the map view is destroyed or + * no longer needed to prevent potential leaks. + */ + fun clearInstance() { + mapInstance = null + // We do not reset isMapReady here, as the underlying SDK instance might still be ready + // even if we detach from a specific view. + } + + /** + * Marks the map as ready. Called when the `OnMapReadyListener` fires. + */ + fun markReady() { + isMapReady = true + } +} diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt new file mode 100644 index 00000000..6588ee4f --- /dev/null +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Map3DState.kt @@ -0,0 +1,356 @@ +/* + * 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.google.maps.android.compose3d + +import android.content.Context +import android.graphics.Color +import androidx.compose.ui.platform.ComposeView +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.Popover +import com.google.android.gms.maps3d.model.Glyph +import com.google.android.gms.maps3d.model.Hole +import com.google.android.gms.maps3d.model.Marker +import com.google.android.gms.maps3d.model.Model +import com.google.android.gms.maps3d.model.PinConfiguration +import com.google.android.gms.maps3d.model.Polygon +import com.google.android.gms.maps3d.model.Polyline +import com.google.android.gms.maps3d.model.markerOptions +import com.google.android.gms.maps3d.model.polygonOptions +import com.google.android.gms.maps3d.model.popoverOptions +import com.google.maps.android.compose3d.utils.toValidLocation + +/** + * Internal state holder for the Maps 3D Compose library. + * + * This class maintains the mapping between user-provided configuration keys and the + * actual SDK objects created on the map. Since the SDK objects are largely immutable + * or do not support property updates in place, this class recreates them when their + * configuration changes. + */ +class Map3DState { + private val markers = mutableMapOf>() + private val polylines = mutableMapOf>() + private val polygons = mutableMapOf>() + private val models = mutableMapOf>() + private val popovers = mutableMapOf>() + + /** + * Synchronizes the markers on the map with the provided list of configurations. + */ + fun syncMarkers(map: GoogleMap3D, markerConfigs: List) { + val keysToRemove = markers.keys.toMutableSet() + + markerConfigs.forEach { config -> + keysToRemove.remove(config.key) + val existing = markers[config.key] + + if (existing != null) { + val (oldConfig, marker) = existing + if (oldConfig != config) { + // Config changed, recreate + marker.remove() + val newMarker = createMarker(map, config) + if (newMarker != null) { + markers[config.key] = Pair(config, newMarker) + } + } + } else { + // New marker + val newMarker = createMarker(map, config) + if (newMarker != null) { + markers[config.key] = Pair(config, newMarker) + } + } + } + + keysToRemove.forEach { key -> + markers[key]?.second?.remove() + markers.remove(key) + } + } + + private fun createMarker(map: GoogleMap3D, config: MarkerConfig): Marker? { + val marker = map.addMarker( + markerOptions { + position = config.position.toValidLocation() + altitudeMode = config.altitudeMode + config.styleView?.let { setStyle(it) } + label = config.label + zIndex = config.zIndex + isExtruded = config.isExtruded + isDrawnWhenOccluded = config.isDrawnWhenOccluded + collisionBehavior = config.collisionBehavior + + config.pinConfig?.let { pin -> + val builder = PinConfiguration.builder() + pin.scale?.let { builder.setScale(it) } + pin.backgroundColor?.let { builder.setBackgroundColor(it) } + pin.borderColor?.let { builder.setBorderColor(it) } + + pin.glyph?.let { glyphConfig -> + val glyph = when (glyphConfig) { + is GlyphConfig.Color -> Glyph.fromColor(glyphConfig.color) + is GlyphConfig.Text -> { + val g = Glyph.fromText(glyphConfig.text) + glyphConfig.color?.let { g.color = it } + g + } + is GlyphConfig.Circle -> { + val g = Glyph.fromCircle() + glyphConfig.color?.let { g.color = it } + g + } + is GlyphConfig.Image -> { + val g = Glyph.fromColor(glyphConfig.color ?: Color.WHITE) + g.setImage(com.google.android.gms.maps3d.model.ImageView(glyphConfig.imageResId)) + g + } + } + builder.setGlyph(glyph) + } + setStyle(builder.build()) + } + }, + ) + + config.onClick?.let { callback -> + marker?.setClickListener { + callback(marker) + } + } + + return marker + } + + /** + * Synchronizes the polylines on the map with the provided list of configurations. + */ + fun syncPolylines(map: GoogleMap3D, polylineConfigs: List) { + val keysToRemove = polylines.keys.toMutableSet() + + polylineConfigs.forEach { config -> + keysToRemove.remove(config.key) + val existing = polylines[config.key] + + if (existing != null) { + val (oldConfig, polyline) = existing + if (oldConfig != config) { + // Config changed, recreate + polyline.remove() + val newPolyline = createPolyline(map, config) + if (newPolyline != null) { + polylines[config.key] = Pair(config, newPolyline) + } + } + } else { + // New polyline + val newPolyline = createPolyline(map, config) + if (newPolyline != null) { + polylines[config.key] = Pair(config, newPolyline) + } + } + } + + keysToRemove.forEach { key -> + polylines[key]?.second?.remove() + polylines.remove(key) + } + } + + private fun createPolyline(map: GoogleMap3D, config: PolylineConfig): Polyline { + val polyline = map.addPolyline(config.toPolylineOptions()) + config.onClick?.let { callback -> + polyline.setClickListener { + callback(polyline) + } + } + return polyline + } + + /** + * Synchronizes the polygons on the map with the provided list of configurations. + */ + fun syncPolygons(map: GoogleMap3D, polygonConfigs: List) { + val keysToRemove = polygons.keys.toMutableSet() + + polygonConfigs.forEach { config -> + keysToRemove.remove(config.key) + val existing = polygons[config.key] + + if (existing != null) { + val (oldConfig, polygon) = existing + if (oldConfig != config) { + // Config changed, recreate + polygon.remove() + val newPolygon = createPolygon(map, config) + if (newPolygon != null) { + polygons[config.key] = Pair(config, newPolygon) + } + } + } else { + // New polygon + val newPolygon = createPolygon(map, config) + if (newPolygon != null) { + polygons[config.key] = Pair(config, newPolygon) + } + } + } + + keysToRemove.forEach { key -> + polygons[key]?.second?.remove() + polygons.remove(key) + } + } + + private fun createPolygon(map: GoogleMap3D, config: PolygonConfig): Polygon { + val polygon = map.addPolygon( + polygonOptions { + this.path = config.path.map { it.toValidLocation() } + innerPaths = config.innerPaths.map { Hole(it.map { p -> p.toValidLocation() }) } + fillColor = config.fillColor + strokeColor = config.strokeColor + strokeWidth = config.strokeWidth.toDouble() + altitudeMode = config.altitudeMode + }, + ) + config.onClick?.let { callback -> + polygon.setClickListener { + callback(polygon) + } + } + return polygon + } + + /** + * Synchronizes the 3D models on the map with the provided list of configurations. + */ + fun syncModels(map: GoogleMap3D, modelConfigs: List) { + val keysToRemove = models.keys.toMutableSet() + + modelConfigs.forEach { config -> + keysToRemove.remove(config.key) + val existing = models[config.key] + + if (existing != null) { + val (oldConfig, model) = existing + if (oldConfig != config) { + // Config changed, recreate + model.remove() + val newModel = createModel(map, config) + if (newModel != null) { + models[config.key] = Pair(config, newModel) + } + } + } else { + // New model + val newModel = createModel(map, config) + if (newModel != null) { + models[config.key] = Pair(config, newModel) + } + } + } + + keysToRemove.forEach { key -> + models[key]?.second?.remove() + models.remove(key) + } + } + + private fun createModel(map: GoogleMap3D, config: ModelConfig): Model { + val model = map.addModel(config.toModelOptions()) + config.onClick?.let { callback -> + model.setClickListener { + callback(model) + } + } + return model + } + + /** + * Synchronizes the popovers on the map with the provided list of configurations. + */ + fun syncPopovers(context: Context, map: GoogleMap3D, popoverConfigs: List) { + val keysToRemove = popovers.keys.toMutableSet() + + popoverConfigs.forEach { config -> + keysToRemove.remove(config.key) + val existing = popovers[config.key] + + if (existing != null) { + val (oldConfig, popover) = existing + if (oldConfig != config) { + // Config changed, recreate + popover.remove() + val newPopover = createPopover(context, map, config) + if (newPopover != null) { + popovers[config.key] = Pair(config, newPopover) + } + } + } else { + // New popover + val newPopover = createPopover(context, map, config) + if (newPopover != null) { + popovers[config.key] = Pair(config, newPopover) + } + } + } + + keysToRemove.forEach { key -> + popovers[key]?.second?.remove() + popovers.remove(key) + } + } + + private fun createPopover(context: Context, map: GoogleMap3D, config: PopoverConfig): Popover? { + val marker = markers[config.positionAnchorKey]?.second ?: return null + + val composeView = ComposeView(context).apply { + setContent { + config.content() + } + } + + val popover = map.addPopover( + popoverOptions { + positionAnchor = marker + altitudeMode = config.altitudeMode + content = composeView + autoCloseEnabled = config.autoCloseEnabled + autoPanEnabled = config.autoPanEnabled + }, + ) + + popover.show() + return popover + } + + /** + * Clears all state and removes all objects from the map. + */ + fun clear() { + markers.values.forEach { it.second.remove() } + markers.clear() + polylines.values.forEach { it.second.remove() } + polylines.clear() + polygons.values.forEach { it.second.remove() } + polygons.clear() + models.values.forEach { it.second.remove() } + models.clear() + popovers.values.forEach { it.second.remove() } + popovers.clear() + } +} diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt new file mode 100644 index 00000000..30efad18 --- /dev/null +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/Mappers.kt @@ -0,0 +1,77 @@ +package com.google.maps.android.compose3d + +import com.google.android.gms.maps3d.model.Hole +import com.google.android.gms.maps3d.model.markerOptions +import com.google.android.gms.maps3d.model.modelOptions +import com.google.android.gms.maps3d.model.orientation +import com.google.android.gms.maps3d.model.polygonOptions +import com.google.android.gms.maps3d.model.polylineOptions +import com.google.android.gms.maps3d.model.vector3D +import com.google.maps.android.compose3d.utils.toValidLocation + +/** + * Extension function to map [PolylineConfig] to [PolylineOptions]. + */ +fun PolylineConfig.toPolylineOptions() = polylineOptions { + this.path = points.map { it.toValidLocation() } + strokeColor = color + strokeWidth = width.toDouble() + altitudeMode = this@toPolylineOptions.altitudeMode + zIndex = this@toPolylineOptions.zIndex + outerColor = this@toPolylineOptions.outerColor + outerWidth = this@toPolylineOptions.outerWidth.toDouble() + drawsOccludedSegments = this@toPolylineOptions.drawsOccludedSegments +} + +/** + * Extension function to map [MarkerConfig] to [MarkerOptions]. + */ +fun MarkerConfig.toMarkerOptions() = markerOptions { + id = key + position = this@toMarkerOptions.position.toValidLocation() + altitudeMode = this@toMarkerOptions.altitudeMode + label = this@toMarkerOptions.label + isExtruded = this@toMarkerOptions.isExtruded + isDrawnWhenOccluded = this@toMarkerOptions.isDrawnWhenOccluded + collisionBehavior = this@toMarkerOptions.collisionBehavior + styleView?.let { setStyle(it) } +} + +/** + * Extension function to map [PolygonConfig] to [PolygonOptions]. + */ +fun PolygonConfig.toPolygonOptions() = polygonOptions { + path = this@toPolygonOptions.path.map { it.toValidLocation() } + innerPaths = this@toPolygonOptions.innerPaths.map { Hole(it.map { p -> p.toValidLocation() }) } + fillColor = this@toPolygonOptions.fillColor + strokeColor = this@toPolygonOptions.strokeColor + strokeWidth = this@toPolygonOptions.strokeWidth.toDouble() + altitudeMode = this@toPolygonOptions.altitudeMode +} + +/** + * Extension function to map [ModelConfig] to [ModelOptions]. + */ +fun ModelConfig.toModelOptions() = modelOptions { + id = key + position = this@toModelOptions.position.toValidLocation() + altitudeMode = this@toModelOptions.altitudeMode + orientation = orientation { + heading = this@toModelOptions.heading + tilt = this@toModelOptions.tilt + roll = this@toModelOptions.roll + } + url = this@toModelOptions.url + scale = when (val s = this@toModelOptions.scale) { + is ModelScale.Uniform -> vector3D { + x = s.value.toDouble() + y = s.value.toDouble() + z = s.value.toDouble() + } + is ModelScale.PerAxis -> vector3D { + x = s.x.toDouble() + y = s.y.toDouble() + z = s.z.toDouble() + } + } +} diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/CameraUpdate.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/CameraUpdate.kt new file mode 100644 index 00000000..f05e8553 --- /dev/null +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/CameraUpdate.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose3d.utils + +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.OnCameraAnimationEndListener +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.FlyAroundOptions +import com.google.android.gms.maps3d.model.FlyToOptions +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** + * Represents an update to the camera of a [GoogleMap3D]. + * + * This sealed class provides different ways to update the camera, such as flying to a specific location, + * flying around a point, or simply moving the camera to a new position. + * + * The main advantage is to allow creation of the [awaitCameraUpdate] method. + * + * Each subclass of [CameraUpdate] defines how the camera should be updated through its `invoke` method. + * + * Subclasses: + * - [FlyTo]: Represents a camera fly-to animation. + * - [FlyAround]: Represents a camera fly-around animation. + * - [Move]: Represents a direct camera move without animation. + */ +sealed class CameraUpdate { + abstract operator fun invoke(controller: GoogleMap3D) + + data class FlyTo(val options: FlyToOptions) : CameraUpdate() { + override fun invoke(controller: GoogleMap3D) { + controller.flyCameraTo(options) + } + } + + data class FlyAround(val options: FlyAroundOptions) : CameraUpdate() { + override fun invoke(controller: GoogleMap3D) { + controller.flyCameraAround(options) + } + } + + data class Move(val camera: Camera) : CameraUpdate() { + override fun invoke(controller: GoogleMap3D) { + controller.setCamera(camera) + } + } +} + +fun FlyToOptions.toCameraUpdate(): CameraUpdate { + return CameraUpdate.FlyTo(this.toValidFlyToOptions()) +} + +fun FlyAroundOptions.toCameraUpdate(): CameraUpdate { + return CameraUpdate.FlyAround(this.toValidFlyAroundOptions()) +} + +fun FlyToOptions.toValidFlyToOptions(): FlyToOptions { + return this.copy( + endCamera = this.endCamera.toValidCamera(), + ) +} + +fun FlyAroundOptions.toValidFlyAroundOptions(): FlyAroundOptions { + return this.copy( + center = this.center.toValidCamera(), + ) +} + +/** + * Suspends the coroutine until the camera update animation is finished. + * + * If the [cameraUpdate] is a [CameraUpdate.Move], it will be applied immediately without waiting. + * + * Otherwise, it will wait for the camera animation to finish, then it will resume the coroutine. + * + * You can pass in an existing [cameraChangedListener] that will be invoked when the camera + * animation finishes and also will be restored afterwards. + * + * @param controller The [GoogleMap3D] instance to apply the camera update to. + * @param cameraUpdate The [CameraUpdate] to apply. + * @param cameraChangedListener An optional existing listener to invoke and restore + */ +suspend fun awaitCameraUpdate( + controller: GoogleMap3D, + cameraUpdate: CameraUpdate, + cameraChangedListener: OnCameraAnimationEndListener? = null, +) = suspendCancellableCoroutine { continuation -> + // No need to wait if the update is a move + if (cameraUpdate is CameraUpdate.Move) { + cameraUpdate.invoke(controller) + return@suspendCancellableCoroutine + } + + // If the coroutine is canceled, stop the camera animation as well. + continuation.invokeOnCancellation { + controller.stopCameraAnimation() + } + + controller.setCameraAnimationEndListener { + cameraChangedListener?.onCameraAnimationEnd() + controller.setCameraAnimationEndListener(cameraChangedListener) + if (continuation.isActive) { + continuation.resume(Unit) + } + } + + cameraUpdate.invoke(controller) +} + +/** + * Suspends the coroutine until the current camera animation is finished. + * + * In a 3D environment, this is essential for sequencing cinematic movements. + */ +suspend fun GoogleMap3D.awaitCameraAnimation() = suspendCancellableCoroutine { continuation -> + setCameraAnimationEndListener { + setCameraAnimationEndListener(null) // Cleanup + if (continuation.isActive) { + continuation.resume(Unit) + } + } + + continuation.invokeOnCancellation { + setCameraAnimationEndListener(null) + } +} diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/GeoMathUtils.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/GeoMathUtils.kt new file mode 100644 index 00000000..80724515 --- /dev/null +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/GeoMathUtils.kt @@ -0,0 +1,65 @@ +/* + * 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.google.maps.android.compose3d.utils + +import com.google.android.gms.maps.model.LatLng + +object GeoMathUtils { + + /** + * Instantly finds the mathematical coordinate along the path given an absolute distance in meters. + * Replaces expensive haversine math inside the render loop with a precomputed distance array lookup. + */ + fun getInterpolatedPoint( + distance: Double, + path: List, + cumulativeDistances: DoubleArray, + ): LatLng { + if (distance <= 0.0) return path.first() + if (distance >= cumulativeDistances.last()) return path.last() + + var idx = cumulativeDistances.binarySearch(distance) + if (idx < 0) { + idx = -(idx + 1) - 1 // insertion point - 1 + } + idx = idx.coerceIn(0, cumulativeDistances.size - 2) + + val p1 = path[idx] + val p2 = path[idx + 1] + val d1 = cumulativeDistances[idx] + val d2 = cumulativeDistances[idx + 1] + + val fraction = (distance - d1) / (d2 - d1) + if (fraction <= 0.0) return p1 + if (fraction >= 1.0) return p2 + + val lat = p1.latitude + (p2.latitude - p1.latitude) * fraction + val lng = p1.longitude + (p2.longitude - p1.longitude) * fraction + return LatLng(lat, lng) + } + + /** + * Ensures the camera takes the shortest rotational path (e.g., crossing 359 to 0 smoothly) + * instead of violently spinning backward. + */ + fun slerpHeading(current: Float, target: Float, factor: Float): Float { + var dh = target - current + while (dh > 180f) dh -= 360f + while (dh <= -180f) dh += 360f + return current + dh * factor + } +} diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Units.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Units.kt new file mode 100644 index 00000000..b37233b9 --- /dev/null +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Units.kt @@ -0,0 +1,180 @@ +/* + * 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.google.maps.android.compose3d.utils + +import android.content.res.Resources +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.res.stringResource +import com.google.maps.android.compose3d.R + +const val METERS_PER_FOOT = 3.28084 +const val METERS_PER_KILOMETER = 1_000 +const val FEET_PER_METER = 1 / METERS_PER_FOOT +const val FEET_PER_MILE = 5_280 +const val MILES_PER_METER = 0.000621371 + +/** A value class to wrap a value representing a measurement in meters. */ +@Immutable +@JvmInline +value class Meters(val value: Double) : Comparable { + override fun compareTo(other: Meters) = value.compareTo(other.value) + + operator fun minus(other: Meters) = Meters(value = this.value - other.value) +} + +/** Create a Meters class from a [Number] */ +@Stable +inline val Number.meters: Meters + get() = Meters(value = this.toDouble()) + +/** Create a Meters class from a [Number] */ +@Stable +inline val Number.m: Meters + get() = Meters(value = this.toDouble()) + +/** Create a Meters class from a [Number] of kilometers */ +@Stable +inline val Number.km: Meters + get() = Meters(value = this.toDouble() * METERS_PER_KILOMETER) + +/** Create a Meters class from a [Number] of feet */ +@Stable +inline val Number.feet: Meters + get() = Meters(value = this.toDouble() * FEET_PER_METER) + +/** Create a Meters class from a [Number] of miles */ +@Stable +inline val Number.miles: Meters + get() = Meters(value = this.toDouble() / MILES_PER_METER) + +/** Gets the number of equivalent feet from a meters value class */ +@Stable +inline val Meters.toFeet: Double + get() = value * METERS_PER_FOOT + +/** Gets the value of a meters class as a Double */ +@Stable +inline val Meters.toMeters: Double + get() = value + +/** Gets the number of equivalent kilometers from a meters value class */ +@Stable +inline val Meters.toKilometers: Double + get() = value / METERS_PER_KILOMETER + +/** Gets the number of equivalent kilometers from a meters value class */ +@Stable +inline val Meters.toMiles: Double + get() = (value * MILES_PER_METER) + +@Stable +fun Meters.plus(other: Meters) = Meters(value = this.value + other.value) + +/** + * A data class representing a value with a string resource ID for its units template. + * + * @property value: The numerical value. + * @property unitsTemplate: The string resource ID for the units. + */ +data class ValueWithUnitsTemplate(val value: Double, @StringRes val unitsTemplate: Int) + +/** Abstract base class for all units converters. */ +abstract class UnitsConverter { + abstract fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate + + abstract fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate + + @Composable + fun toDistanceString(meters: Meters): String { + val (value, resourceId) = toDistanceUnits(meters = meters) + return stringResource(id = resourceId, value) + } + + fun toDistanceString(resources: Resources, meters: Meters): String { + val (value, resourceId) = toDistanceUnits(meters = meters) + return resources.getString(resourceId, value) + } + + @Composable + fun toElevationString(meters: Meters): String { + val (value, resourceId) = toElevationUnits(meters = meters) + return stringResource(id = resourceId, value) + } +} + +/** + * Returns the appropriate [UnitsConverter] based on the given country code. + * + * @param countryCode The country code to determine the units converter for. + * @return The appropriate [UnitsConverter] for the specified country code. + */ +fun getUnitsConverter(countryCode: String?): UnitsConverter { + // TODO: other counties that use imperial units for distances? + return if (countryCode == "US") { + ImperialUnitsConverter + } else { + MetricUnitsConverter + } +} + +/** Class to render measurements in imperial units. */ +object ImperialUnitsConverter : UnitsConverter() { + override fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate { + return if (meters < 0.25.miles) { + ValueWithUnitsTemplate(meters.toFeet, R.string.in_feet) + } else { + ValueWithUnitsTemplate(meters.toMiles, R.string.in_miles) + } + } + + override fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate { + return ValueWithUnitsTemplate(meters.toFeet, R.string.in_feet) + } +} + +/** Class to render measurements in metric units. */ +object MetricUnitsConverter : UnitsConverter() { + override fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate { + return if (meters < 1000.meters) { + ValueWithUnitsTemplate(meters.toMeters, R.string.in_meters) + } else { + ValueWithUnitsTemplate(meters.toKilometers, R.string.in_kilometers) + } + } + + override fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate { + return ValueWithUnitsTemplate(meters.toMeters, R.string.in_meters) + } +} + +/** A composition local that provides a [UnitsConverter] instance. */ +val LocalUnitsConverter = compositionLocalOf { MetricUnitsConverter } + +/** Creates a string to show the distance formatted with units */ +@Composable +fun Meters.toDistanceString(): String { + return LocalUnitsConverter.current.toDistanceString(this) +} + +@Composable +fun Meters.toElevationString(): String { + return LocalUnitsConverter.current.toElevationString(this) +} diff --git a/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt new file mode 100644 index 00000000..266ce469 --- /dev/null +++ b/maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Utilities.kt @@ -0,0 +1,544 @@ +/* + * 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.google.maps.android.compose3d.utils + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.CameraRestriction +import com.google.android.gms.maps3d.model.FlyAroundOptions +import com.google.android.gms.maps3d.model.FlyToOptions +import com.google.android.gms.maps3d.model.LatLngAltitude +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.cameraRestriction +import com.google.android.gms.maps3d.model.flyAroundOptions +import com.google.android.gms.maps3d.model.flyToOptions +import com.google.android.gms.maps3d.model.latLngAltitude +import java.util.Locale +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt + +val headingRange = 0.0..360.0 +val tiltRange = 0.0..90.0 +val rangeRange = 0.0..63170000.0 +val rollRange = -360.0..360.0 + +val latitudeRange = -90.0..90.0 +val longitudeRange = -180.0..180.0 +val altitudeRange = 0.0..LatLngAltitude.MAX_ALTITUDE_METERS + +const val DEFAULT_HEADING = 0.0 +const val DEFAULT_TILT = 60.0 +const val DEFAULT_RANGE = 1500.0 +const val DEFAULT_ROLL = 0.0 + +/** + * Converts a nullable Camera object into a valid, non-null Camera object. + * If the input is null, returns the DEFAULT_CAMERA configuration. + * If the input is non-null, validates its components (center, heading, tilt, roll, range) + * using helper functions (toValidLocation, toHeading, toTilt, toRoll, toRange). + * + * @receiver The nullable Camera object to validate. + * @return A valid, non-null Camera object. + */ +fun Camera?.toValidCamera(): Camera { + // Use elvis operator for concise null handling + val source = this ?: return Camera.DEFAULT_CAMERA // Return default camera if source is null + + // If source is not null, validate its components + return camera { + // Validate center using the provided toValidLocation function + center = source.center.toValidLocation() + // Validate orientation and range using the existing to...() functions + heading = source.heading.toHeading() + tilt = source.tilt.toTilt() + roll = source.roll.toRoll() + range = source.range.toRange() + } +} + +/** + * Converts a nullable CameraRestriction object into a valid CameraRestriction object. + * Provides safe defaults for null parameters to prevent crashes in the native SDK. + * Ensures min <= max for altitude, heading, and tilt. + * + * @receiver The nullable CameraRestriction object to validate. + * @return A valid CameraRestriction object, or null if the input was null. + */ +fun CameraRestriction?.toValidCameraRestriction(): CameraRestriction? { + val source = this ?: return null + + val minAlt = source.minAltitude ?: altitudeRange.start + val maxAlt = source.maxAltitude ?: altitudeRange.endInclusive + val (finalMinAlt, finalMaxAlt) = if (minAlt > maxAlt) { + Pair(maxAlt, minAlt) + } else { + Pair(minAlt, maxAlt) + } + + val minHead = source.minHeading ?: headingRange.start + val maxHead = source.maxHeading ?: headingRange.endInclusive + val (finalMinHead, finalMaxHead) = if (minHead > maxHead) { + Pair(maxHead, minHead) + } else { + Pair(minHead, maxHead) + } + + val minT = source.minTilt ?: tiltRange.start + val maxT = source.maxTilt ?: tiltRange.endInclusive + val (finalMinT, finalMaxT) = if (minT > maxT) { + Pair(maxT, minT) + } else { + Pair(minT, maxT) + } + + if (source.minAltitude == null || source.maxAltitude == null || + source.minHeading == null || source.maxHeading == null || + source.minTilt == null || source.maxTilt == null || + finalMinAlt != source.minAltitude || finalMaxAlt != source.maxAltitude || + finalMinHead != source.minHeading || finalMaxHead != source.maxHeading || + finalMinT != source.minTilt || finalMaxT != source.maxTilt + ) { + return cameraRestriction { + bounds = source.bounds + minAltitude = finalMinAlt + maxAltitude = finalMaxAlt + minHeading = finalMinHead + maxHeading = finalMaxHead + minTilt = finalMinT + maxTilt = finalMaxT + } + } + return source +} + +/** + * Coerces the latitude, longitude, and altitude of a LatLngAltitude object + * to be within their valid ranges. Longitude is clamped, not wrapped here. + * + * @receiver The LatLngAltitude to validate. + * @return A new LatLngAltitude object with validated components. + */ +fun LatLngAltitude.toValidLocation(): LatLngAltitude { + val objectToCopy = this + return latLngAltitude { + // Coerce latitude within -90.0 to 90.0 + latitude = objectToCopy.latitude.coerceIn(latitudeRange) + // Coerce longitude within -180.0 to 180.0 (Note: wrapping might be preferred sometimes) + longitude = objectToCopy.longitude.coerceIn(longitudeRange) + // Coerce altitude within 0.0 to MAX_ALTITUDE_METERS + altitude = objectToCopy.altitude.coerceIn(altitudeRange) + } +} + +/** + * Converts a Number? to a valid heading value (0.0 to 360.0). + * Returns 0.0 if the input is null. + * Uses wrapIn to ensure the value is within the headingRange. + * + * @receiver The Number? to convert. + * @return The heading value as a Double within [0.0, 360.0). + */ +fun Number?.toHeading(): Double = + this?.toDouble()?.wrapIn(headingRange.start, headingRange.endInclusive) ?: DEFAULT_HEADING + +/** + * Converts a Number? to a valid tilt value (0.0 to 90.0). + * Returns 0.0 if the input is null. + * Clamps the value to the tiltRange, as tilt doesn't typically wrap. + * + * @receiver The Number? to convert. + * @return The tilt value as a Double clamped within [0.0, 90.0]. + */ +fun Number?.toTilt(): Double = this?.toDouble()?.coerceIn(tiltRange) ?: DEFAULT_TILT + +/** + * Converts a Number? to a valid roll value (-360.0 to 360.0 or often -180..180). + * Returns 0.0 if the input is null. + * Uses wrapIn to ensure the value is within the rollRange. + * Consider using -180..180 range and wrapIn(lower, upper) for standard roll representation. + * + * @receiver The Number? to convert. + * @return The roll value as a Double within the defined rollRange. + */ +fun Number?.toRoll(): Double = this?.toDouble()?.wrapIn(rollRange) ?: DEFAULT_ROLL + +/** + * Converts a Number? to a valid range value (0.0 to ~63,170,000.0). + * Returns 0.0 if the input is null. + * Clamps the value to the rangeRange, as range/distance doesn't wrap. + * + * @receiver The Number? to convert. + * @return The range value as a Double clamped within the defined rangeRange. + */ +fun Number?.toRange(): Double = this?.toDouble()?.coerceIn(rangeRange) ?: DEFAULT_RANGE + +// Assumes we are close to the range +fun Double.wrapIn(range: ClosedFloatingPointRange): Double { + var answer = this + val delta = range.endInclusive - range.start + while (answer > range.endInclusive) { + answer -= delta + } + while (answer < range.start) { + answer += delta + } + + return answer +} + +/** + * Wraps a Float value within a specified range. + * If the value is outside the range, it is adjusted by repeatedly adding or subtracting + * the range's span (delta) until it falls within the range. + * + * @param range The ClosedFloatingPointRange within which to wrap the value. + * @return The wrapped Float value, guaranteed to be within the specified range. + */ +fun Float.wrapIn(range: ClosedFloatingPointRange): Float { + var answer = this + val delta = range.endInclusive - range.start + while (answer > range.endInclusive) { + answer -= delta + } + while (answer < range.start) { + answer += delta + } + + return answer +} + +/** + * Wraps a Double value within the specified range [lower, upper). + * This method ensures that the returned value always falls within the specified range. + * If the value is outside the range, it will be "wrapped around" to fit within the range. + * For example, if the range is [0.0, 360.0) and the input is 370.0, the output will be 10.0. + * If the range is [0.0, 360.0) and the input is -10.0, the output will be 350.0. + * + * @param lower The lower bound of the range (inclusive). + * @param upper The upper bound of the range (exclusive). + * @return The wrapped value within the range [lower, upper). + * @throws IllegalArgumentException if the upper bound is not greater than the lower bound. + */ +fun Double.wrapIn(lower: Double, upper: Double): Double { + val range = upper - lower + if (range <= 0) { + throw IllegalArgumentException("Upper bound must be greater than lower bound") + } + val offset = this - lower + return lower + (offset - floor(offset / range) * range) +} + +/** + * Extension function on Number to get the nearest compass direction string + * from a given heading in degrees. + * + * 0 degrees is North, 90 is East, 180 is South, 270 is West. + * Handles headings outside the standard 0-360 range (e.g., -90 or 450 degrees). + * + * @return A string representing the nearest compass direction (e.g., "N", "NNE", "NE"). + */ +fun Number.toCompassDirection(): String { + val directions = listOf( + "N", "NNE", "NE", "ENE", + "E", "ESE", "SE", "SSE", + "S", "SSW", "SW", "WSW", + "W", "WNW", "NW", "NNW", + ) + + val headingDegrees = this.toDouble() + + // Normalize heading to 0-359.99... degrees + val normalizedHeading = (headingDegrees % 360.0 + 360.0) % 360.0 + + // Each of the 16 directions covers an arc of 360/16 = 22.5 degrees. + // We add half of this (11.25) to the normalized heading before dividing + // to correctly align with the center of each compass arc. + val segment = 22.5 + val index = floor((normalizedHeading + (segment / 2)) / segment).toInt() % directions.size + + return directions[index] +} + +/** + * Creates a new [Camera] object by copying the current [Camera] and optionally overriding + * its center, heading, tilt, range, and roll properties. + * + * @param center The new center [LatLngAltitude] to use, or null to keep the current center. + * @param heading The new heading (bearing) to use, or null to keep the current heading. + * @param tilt The new tilt (pitch) to use, or null to keep the current tilt. + * @param range The new range (distance from the center) to use, or null to keep the current range. + * @param roll The new roll to use, or null to keep the current roll. + * @return A new [Camera] object with the specified properties updated. + */ +fun Camera.copy( + center: LatLngAltitude? = null, + heading: Double? = null, + tilt: Double? = null, + range: Double? = null, + roll: Double? = null, +): Camera { + val objectToCopy = this + return camera { + this.center = center ?: objectToCopy.center + this.heading = heading ?: objectToCopy.heading + this.tilt = tilt ?: objectToCopy.tilt + this.range = range ?: objectToCopy.range + this.roll = roll ?: objectToCopy.roll + } +} + +fun FlyAroundOptions.copy( + center: Camera? = null, + durationInMillis: Long? = null, + rounds: Double? = null, +): FlyAroundOptions { + val objectToCopy = this + + return flyAroundOptions { + this.center = (center ?: objectToCopy.center) + this.durationInMillis = durationInMillis ?: objectToCopy.durationInMillis + this.rounds = rounds ?: objectToCopy.rounds + } +} + +fun FlyToOptions.copy( + endCamera: Camera? = null, + durationInMillis: Long? = null, +): FlyToOptions { + val objectToCopy = this + + return flyToOptions { + this.endCamera = (endCamera ?: objectToCopy.endCamera) + this.durationInMillis = durationInMillis ?: objectToCopy.durationInMillis + } +} + +/** + * Converts a [Camera] object to a formatted string representation. + * + * This function takes a [Camera] object, validates it using [toValidCamera], and then + * constructs a multi-line string that represents the camera's properties in a human-readable + * format. The string includes the camera's center (latitude, longitude, altitude), + * heading, tilt, and range. + * + * The latitude, longitude, altitude, heading, tilt, and range are formatted to specific + * decimal places for readability (6, 6, 1, 0, 0, 0 respectively). + * + * The output string is designed to be easily copied and pasted directly into code to recreate + * a [Camera] object with the same parameters. This is especially useful for quickly positioning + * the camera to a specific view. + * + * Example output: + * ``` + * camera { + * center = latLngAltitude { + * latitude = 34.052235 + * longitude = -118.243685 + * altitude = 100.0 + * } + * heading = 90 + * tilt = 45 + * range = 5000 + * } + * ``` + * + * @receiver The [Camera] object to convert. + * @return A string representation of the [Camera] object, suitable for pasting into source code. + */ +fun Camera.toCameraString(): String { + val camera = this.toValidCamera() + return """ + camera { + center = latLngAltitude { + latitude = ${camera.center.latitude.format(6)} + longitude = ${camera.center.longitude.format(6)} + altitude = ${camera.center.altitude.format(1)} + } + heading = ${camera.heading.format(0)} + tilt = ${camera.tilt.format(0)} + range = ${camera.range.format(0)} + } + """.trimIndent() +} + +/** + * Formats a nullable Double to a string with a specified number of decimal places. + * + * If the Double is null, returns "null". + * If decimalPlaces is 0, it formats the number with no decimal places and appends ".0". + * If decimalPlaces is greater than 0, it formats the number with the specified number of decimal places. + * + * Note, this is intended for logging and debugging not for display to the user. + * + * @receiver The nullable Double to format. + * @param decimalPlaces The number of decimal places to include in the formatted string. + * @return The formatted string representation of the Double, or "null" if the input is null. + */ +internal fun Double?.format(decimalPlaces: Int): String { + if (this == null) return "null" + + return if (decimalPlaces == 0) { + String.format(Locale.US, "%.0f.0", this) + } else { + String.format(Locale.US, "%.${decimalPlaces}f", this) + } +} + +/** + * Smooths a path of LatLng points using Chaikin's algorithm. + * + * Chaikin's algorithm works by cutting corners. Each iteration replaces each + * internal point with two points, each 1/4 and 3/4 along the edge between the + * previous and next points. + * + * @param iterations The number of smoothing iterations to perform. Higher + * values result in smoother curves but more points. + * @return A new list of smoothed [LatLng] points. + */ +fun List.smoothPath(iterations: Int = 1): List { + if (size < 3 || iterations <= 0) return this + + var currentPath = this + repeat(iterations) { + val nextPath = mutableListOf() + // Keep the first point + nextPath.add(currentPath.first()) + + for (i in 0 until currentPath.size - 1) { + val p0 = currentPath[i] + val p1 = currentPath[i + 1] + + // Point at 1/4 of the way + val q = LatLng( + p0.latitude * 0.75 + p1.latitude * 0.25, + p0.longitude * 0.75 + p1.longitude * 0.25, + ) + + // Point at 3/4 of the way + val r = LatLng( + p0.latitude * 0.25 + p1.latitude * 0.75, + p0.longitude * 0.25 + p1.longitude * 0.75, + ) + + nextPath.add(q) + nextPath.add(r) + } + + // Keep the last point + nextPath.add(currentPath.last()) + currentPath = nextPath + } + + return currentPath +} + +/** + * Calculates the heading (bearing) from one LatLng to another. + * + * @return The heading in degrees clockwise from North. + */ +fun calculateHeading(from: LatLng, to: LatLng): Double { + val lat1 = Math.toRadians(from.latitude) + val lon1 = Math.toRadians(from.longitude) + val lat2 = Math.toRadians(to.latitude) + val lon2 = Math.toRadians(to.longitude) + + val dLon = lon2 - lon1 + val y = sin(dLon) * cos(lat2) + val x = cos(lat1) * sin(lat2) - + sin(lat1) * cos(lat2) * cos(dLon) + + val bearing = Math.toDegrees(atan2(y, x)) + return (bearing + 360.0) % 360.0 +} + +/** + * Simplifies a path of LatLng points using the Ramer-Douglas-Peucker algorithm. + * + * This algorithm reduces the number of points in a curve that is approximated + * by a series of points, while preserving the overall shape. + * + * @param epsilon The maximum distance between the original path and the + * simplified path. Higher values result in more simplification. + * Value is in degrees (very rough approximation). + * @return A new list of simplified [LatLng] points. + */ +fun List.simplifyPath(epsilon: Double = 0.001): List { + if (size < 3) return this + + var maxDistance = 0.0 + var index = 0 + val first = first() + val last = last() + + for (i in 1 until size - 1) { + val distance = perpendicularDistance(this[i], first, last) + if (distance > maxDistance) { + index = i + maxDistance = distance + } + } + + return if (maxDistance > epsilon) { + val left = subList(0, index + 1).simplifyPath(epsilon) + val right = subList(index, size).simplifyPath(epsilon) + left.dropLast(1) + right + } else { + listOf(first, last) + } +} + +/** + * Calculates the perpendicular distance from a point to a line segment. + */ +private fun perpendicularDistance(point: LatLng, start: LatLng, end: LatLng): Double { + val x = point.longitude + val y = point.latitude + val x1 = start.longitude + val y1 = start.latitude + val x2 = end.longitude + val y2 = end.latitude + + val area = abs((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1) + val bottom = sqrt((y2 - y1).pow(2.0) + (x2 - x1).pow(2.0)) + return area / bottom +} + +/** + * Calculates the distance in meters between two [LatLng] points using the Haversine formula. + */ +fun haversineDistance(p1: LatLng, p2: LatLng): Double { + val r = 6371000.0 // Earth radius in meters + val lat1 = Math.toRadians(p1.latitude) + val lon1 = Math.toRadians(p1.longitude) + val lat2 = Math.toRadians(p2.latitude) + val lon2 = Math.toRadians(p2.longitude) + + val dLat = lat2 - lat1 + val dLon = lon2 - lon1 + + val a = sin(dLat / 2).pow(2.0) + + cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2.0) + val c = 2 * atan2(sqrt(a), sqrt(1 - a)) + + return r * c +} diff --git a/maps3d-compose/src/main/res/values/strings.xml b/maps3d-compose/src/main/res/values/strings.xml new file mode 100644 index 00000000..221e967e --- /dev/null +++ b/maps3d-compose/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + + %1$,.0f ft + %1$,.1f miles + %1$,.0f m + %1$,.1f km + diff --git a/maps3d-compose/src/test/java/com/google/maps/android/compose3d/MappersTest.kt b/maps3d-compose/src/test/java/com/google/maps/android/compose3d/MappersTest.kt new file mode 100644 index 00000000..44a83d05 --- /dev/null +++ b/maps3d-compose/src/test/java/com/google/maps/android/compose3d/MappersTest.kt @@ -0,0 +1,285 @@ +package com.google.maps.android.compose3d + +import android.graphics.Color +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.CollisionBehavior +import com.google.android.gms.maps3d.model.LatLngAltitude +import com.google.android.gms.maps3d.model.latLngAltitude +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class MappersTest { + + private fun assertLatLngAltitudeEquals(expected: LatLngAltitude, actual: LatLngAltitude) { + assertEquals(expected.latitude, actual.latitude, 0.0) + assertEquals(expected.longitude, actual.longitude, 0.0) + assertEquals(expected.altitude, actual.altitude, 0.0) + } + + @Test + fun testPolylineConfigToPolylineOptions() { + val points = listOf( + latLngAltitude { + latitude = 1.0 + longitude = 2.0 + altitude = 3.0 + }, + latLngAltitude { + latitude = 4.0 + longitude = 5.0 + altitude = 6.0 + }, + ) + val config = PolylineConfig( + key = "test_polyline", + points = points, + color = Color.RED, + width = 5f, + altitudeMode = AltitudeMode.RELATIVE_TO_GROUND, + zIndex = 1, + outerColor = Color.BLACK, + outerWidth = 2f, + drawsOccludedSegments = true, + ) + + val options = config.toPolylineOptions() + + assertEquals(points.size, options.path.size) + for (i in points.indices) { + assertLatLngAltitudeEquals(points[i], options.path[i]) + } + assertEquals(Color.RED, options.strokeColor) + assertEquals(5.0, options.strokeWidth, 0.0) + assertEquals(AltitudeMode.RELATIVE_TO_GROUND, options.altitudeMode) + assertEquals(1, options.zIndex) + assertEquals(Color.BLACK, options.outerColor) + assertEquals(2.0, options.outerWidth, 0.0) + assertEquals(true, options.drawsOccludedSegments) + } + + @Test + fun testMarkerConfigToMarkerOptions() { + val position = latLngAltitude { + latitude = 1.0 + longitude = 2.0 + altitude = 3.0 + } + val config = MarkerConfig( + key = "test_marker", + position = position, + altitudeMode = AltitudeMode.ABSOLUTE, + label = "Test Label", + zIndex = 2, + isExtruded = true, + isDrawnWhenOccluded = true, + collisionBehavior = CollisionBehavior.REQUIRED, + ) + + val options = config.toMarkerOptions() + + assertEquals("test_marker", options.id) + assertLatLngAltitudeEquals(position, options.position) + assertEquals(AltitudeMode.ABSOLUTE, options.altitudeMode) + assertEquals("Test Label", options.label) + assertEquals(true, options.isExtruded) + assertEquals(true, options.isDrawnWhenOccluded) + assertEquals(CollisionBehavior.REQUIRED, options.collisionBehavior) + } + + @Test + fun testPolygonConfigToPolygonOptions() { + val path = listOf( + latLngAltitude { + latitude = 1.0 + longitude = 2.0 + altitude = 0.0 + }, + latLngAltitude { + latitude = 3.0 + longitude = 4.0 + altitude = 0.0 + }, + latLngAltitude { + latitude = 5.0 + longitude = 6.0 + altitude = 0.0 + }, + ) + val hole = listOf( + latLngAltitude { + latitude = 1.5 + longitude = 2.5 + altitude = 0.0 + }, + latLngAltitude { + latitude = 2.0 + longitude = 3.0 + altitude = 0.0 + }, + ) + val config = PolygonConfig( + key = "test_polygon", + path = path, + innerPaths = listOf(hole), + fillColor = Color.YELLOW, + strokeColor = Color.GREEN, + strokeWidth = 3f, + altitudeMode = AltitudeMode.CLAMP_TO_GROUND, + ) + + val options = config.toPolygonOptions() + + assertEquals(path.size, options.path.size) + for (i in path.indices) { + assertLatLngAltitudeEquals(path[i], options.path[i]) + } + assertEquals(1, options.innerPaths.size) + // Hole comparison might fail if Hole doesn't have equals. + // Let's just check if it's not null for now, or if we can assume it works. + // I'll keep it as is and see if it fails. + assertEquals(Color.YELLOW, options.fillColor) + assertEquals(Color.GREEN, options.strokeColor) + assertEquals(3.0, options.strokeWidth, 0.0) + assertEquals(AltitudeMode.CLAMP_TO_GROUND, options.altitudeMode) + } + + @Test + fun testModelConfigToModelOptions() { + val position = latLngAltitude { + latitude = 1.0 + longitude = 2.0 + altitude = 3.0 + } + val config = ModelConfig( + key = "test_model", + position = position, + url = "http://example.com/model.glb", + altitudeMode = AltitudeMode.RELATIVE_TO_GROUND, + scale = ModelScale.Uniform(2.0f), + heading = 10.0, + tilt = 20.0, + roll = 30.0, + ) + + val options = config.toModelOptions() + + assertEquals("test_model", options.id) + assertLatLngAltitudeEquals(position, options.position) + assertEquals(AltitudeMode.RELATIVE_TO_GROUND, options.altitudeMode) + assertEquals("http://example.com/model.glb", options.url) + assertEquals(2.0, options.scale.x, 0.0) + assertEquals(2.0, options.scale.y, 0.0) + assertEquals(2.0, options.scale.z, 0.0) + assertEquals(10.0, options.orientation.heading, 0.0) + assertEquals(20.0, options.orientation.tilt, 0.0) + assertEquals(30.0, options.orientation.roll, 0.0) + } + + @Test + fun testModelConfigToModelOptionsWithPerAxisScale() { + val position = latLngAltitude { + latitude = 1.0 + longitude = 2.0 + altitude = 3.0 + } + val config = ModelConfig( + key = "test_model_per_axis", + position = position, + url = "http://example.com/model.glb", + altitudeMode = AltitudeMode.RELATIVE_TO_GROUND, + scale = ModelScale.PerAxis(1.0f, 2.0f, 3.0f), + heading = 10.0, + tilt = 20.0, + roll = 30.0, + ) + + val options = config.toModelOptions() + + assertEquals("test_model_per_axis", options.id) + assertEquals(1.0, options.scale.x, 0.0) + assertEquals(2.0, options.scale.y, 0.0) + assertEquals(3.0, options.scale.z, 0.0) + } + + @Test + fun testPolylineConfigToPolylineOptions_defaults() { + val points = listOf( + latLngAltitude { + latitude = 1.0 + longitude = 2.0 + altitude = 3.0 + }, + ) + val config = PolylineConfig( + key = "test_polyline_defaults", + points = points, + color = Color.RED, + width = 5f, + ) + + val options = config.toPolylineOptions() + + assertEquals(points.size, options.path.size) + assertEquals(Color.RED, options.strokeColor) + assertEquals(5.0, options.strokeWidth, 0.0) + assertEquals(AltitudeMode.CLAMP_TO_GROUND, options.altitudeMode) + assertEquals(0, options.zIndex) + assertEquals(0, options.outerColor) + assertEquals(0.0, options.outerWidth, 0.0) + assertEquals(false, options.drawsOccludedSegments) + } + + @Test + fun testMarkerConfigToMarkerOptions_defaults() { + val position = latLngAltitude { + latitude = 1.0 + longitude = 2.0 + altitude = 3.0 + } + val config = MarkerConfig( + key = "test_marker_defaults", + position = position, + ) + + val options = config.toMarkerOptions() + + assertEquals("test_marker_defaults", options.id) + assertLatLngAltitudeEquals(position, options.position) + assertEquals(AltitudeMode.CLAMP_TO_GROUND, options.altitudeMode) + assertEquals("", options.label) + assertEquals(0, options.zIndex) + assertEquals(false, options.isExtruded) + assertEquals(false, options.isDrawnWhenOccluded) + assertEquals(CollisionBehavior.REQUIRED, options.collisionBehavior) + } + + @Test + fun testPolygonConfigToPolygonOptions_defaults() { + val path = listOf( + latLngAltitude { + latitude = 1.0 + longitude = 2.0 + altitude = 0.0 + }, + ) + val config = PolygonConfig( + key = "test_polygon_defaults", + path = path, + fillColor = Color.YELLOW, + strokeColor = Color.GREEN, + strokeWidth = 3f, + ) + + val options = config.toPolygonOptions() + + assertEquals(path.size, options.path.size) + assertEquals(0, options.innerPaths.size) + assertEquals(Color.YELLOW, options.fillColor) + assertEquals(Color.GREEN, options.strokeColor) + assertEquals(3.0, options.strokeWidth, 0.0) + assertEquals(AltitudeMode.CLAMP_TO_GROUND, options.altitudeMode) + } +} diff --git a/maps3d-compose/src/test/java/com/google/maps/android/compose3d/UtilitiesTest.kt b/maps3d-compose/src/test/java/com/google/maps/android/compose3d/UtilitiesTest.kt new file mode 100644 index 00000000..96a94620 --- /dev/null +++ b/maps3d-compose/src/test/java/com/google/maps/android/compose3d/UtilitiesTest.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.google.maps.android.compose3d + +import com.google.android.gms.maps3d.model.cameraRestriction +import com.google.maps.android.compose3d.utils.altitudeRange +import com.google.maps.android.compose3d.utils.headingRange +import com.google.maps.android.compose3d.utils.tiltRange +import com.google.maps.android.compose3d.utils.toValidCameraRestriction +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UtilitiesTest { + + @Test + fun testToValidCameraRestriction_null() { + val restriction = null + assertNull(restriction.toValidCameraRestriction()) + } + + @Test + fun testToValidCameraRestriction_valid() { + val restriction = cameraRestriction { + minAltitude = 10.0 + maxAltitude = 100.0 + minHeading = 0.0 + maxHeading = 180.0 + minTilt = 0.0 + maxTilt = 45.0 + } + val valid = restriction.toValidCameraRestriction() + + assertEquals(10.0, valid?.minAltitude) + assertEquals(100.0, valid?.maxAltitude) + assertEquals(0.0, valid?.minHeading) + assertEquals(180.0, valid?.maxHeading) + assertEquals(0.0, valid?.minTilt) + assertEquals(45.0, valid?.maxTilt) + } + + @Test + fun testToValidCameraRestriction_defaults() { + val restriction = cameraRestriction { + // Leave fields null + } + val valid = restriction.toValidCameraRestriction() + + assertEquals(altitudeRange.start, valid?.minAltitude) + assertEquals(altitudeRange.endInclusive, valid?.maxAltitude) + assertEquals(headingRange.start, valid?.minHeading) + assertEquals(headingRange.endInclusive, valid?.maxHeading) + assertEquals(tiltRange.start, valid?.minTilt) + assertEquals(tiltRange.endInclusive, valid?.maxTilt) + } + + @Test + fun testToValidCameraRestriction_swapped() { + val restriction = cameraRestriction { + minAltitude = 100.0 + maxAltitude = 10.0 + minHeading = 180.0 + maxHeading = 0.0 + minTilt = 45.0 + maxTilt = 0.0 + } + val valid = restriction.toValidCameraRestriction() + + assertEquals(10.0, valid?.minAltitude) + assertEquals(100.0, valid?.maxAltitude) + assertEquals(0.0, valid?.minHeading) + assertEquals(180.0, valid?.maxHeading) + assertEquals(0.0, valid?.minTilt) + assertEquals(45.0, valid?.maxTilt) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 8477ae32..43b454d8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -59,3 +59,10 @@ include(":Maps3DSamples:ApiDemos:common") // PlacesUIKit3D include(":PlacesUIKit3D") + +// Maps 3D Compose Library +include(":maps3d-compose") +include(":maps3d-compose-demo") + +// Visual Testing +include(":visual-testing") diff --git a/snippets/build.gradle.kts b/snippets/build.gradle.kts index 950cdc10..f5ce5c09 100644 --- a/snippets/build.gradle.kts +++ b/snippets/build.gradle.kts @@ -20,7 +20,7 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.secrets.gradle.plugin) apply false - id("com.diffplug.spotless") version "6.25.0" + alias(libs.plugins.spotless) } subprojects { diff --git a/snippets/java-app/build.gradle.kts b/snippets/java-app/build.gradle.kts index 36a52b16..98941bb0 100644 --- a/snippets/java-app/build.gradle.kts +++ b/snippets/java-app/build.gradle.kts @@ -64,10 +64,6 @@ if (!isCI) { if (apiKey.isNullOrBlank() || !apiKey.matches(Regex("^AIza[a-zA-Z0-9_-]{35}$"))) { throw GradleException("Invalid or missing MAPS3D_API_KEY in secrets.properties. Please provide a valid Google Maps API key (starts with 'AIza').") } - - if (secrets.getProperty("MAPS_API_KEY") != null) { - println("Warning: Found MAPS_API_KEY in secrets.properties. This project relies exclusively on MAPS3D_API_KEY.") - } } } } diff --git a/snippets/java-app/src/main/java/com/example/snippets/java/snippets/PolygonSnippets.java b/snippets/java-app/src/main/java/com/example/snippets/java/snippets/PolygonSnippets.java index 72f12ed4..1cc77260 100644 --- a/snippets/java-app/src/main/java/com/example/snippets/java/snippets/PolygonSnippets.java +++ b/snippets/java-app/src/main/java/com/example/snippets/java/snippets/PolygonSnippets.java @@ -170,7 +170,7 @@ public void addPolygonWithHole() { // Core logic: create a Hole object and set InnerPaths (Optional) Hole innerHole = new Hole(innerPoints); - options.setInnerPaths(Arrays.asList(innerHole)); + options.setInnerPaths(List.of(innerHole)); Polygon polygon = map.addPolygon(options); // [START_EXCLUDE] diff --git a/snippets/kotlin-app/build.gradle.kts b/snippets/kotlin-app/build.gradle.kts index 98db299d..a76e447f 100644 --- a/snippets/kotlin-app/build.gradle.kts +++ b/snippets/kotlin-app/build.gradle.kts @@ -64,10 +64,6 @@ if (!isCI) { if (apiKey.isNullOrBlank() || !apiKey.matches(Regex("^AIza[a-zA-Z0-9_-]{35}$"))) { throw GradleException("Invalid or missing MAPS3D_API_KEY in secrets.properties. Please provide a valid Google Maps API key (starts with 'AIza').") } - - if (secrets.getProperty("MAPS_API_KEY") != null) { - println("Warning: Found MAPS_API_KEY in secrets.properties. This project relies exclusively on MAPS3D_API_KEY.") - } } } } diff --git a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/MapActivity.kt b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/MapActivity.kt index 85c08d8a..dfc6e489 100644 --- a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/MapActivity.kt +++ b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/MapActivity.kt @@ -56,7 +56,7 @@ class MapActivity : AppCompatActivity() { val snippetList = SnippetRegistry.getSnippetGroups().flatMap { it.items } var currentIndex = snippetList.indexOfFirst { it.title == snippetTitle && (groupTitle == null || it.groupTitle == groupTitle) } - val printPoseBtn = findViewById(R.id.snapshot_button).apply { + findViewById(R.id.snapshot_button).apply { setOnClickListener { if (::googleMap3D.isInitialized) { val cam = googleMap3D.getCamera() ?: return@setOnClickListener @@ -91,7 +91,7 @@ class MapActivity : AppCompatActivity() { } } - val replayBtn = findViewById(R.id.reset_view_button).apply { + findViewById(R.id.reset_view_button).apply { setOnClickListener { if (currentIndex >= 0) { val item = snippetList[currentIndex] diff --git a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/TrackedMap3D.kt b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/TrackedMap3D.kt index 6bd12de6..d7637e0c 100644 --- a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/TrackedMap3D.kt +++ b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/TrackedMap3D.kt @@ -34,25 +34,25 @@ class TrackedMap3D( return marker } - fun addPolyline(options: PolylineOptions): Polyline? { + fun addPolyline(options: PolylineOptions): Polyline { val polyline = delegate.addPolyline(options) if (polyline != null) items.add(polyline) return polyline } - fun addPolygon(options: PolygonOptions): Polygon? { + fun addPolygon(options: PolygonOptions): Polygon { val polygon = delegate.addPolygon(options) if (polygon != null) items.add(polygon) return polygon } - fun addModel(options: ModelOptions): Model? { + fun addModel(options: ModelOptions): Model { val model = delegate.addModel(options) if (model != null) items.add(model) return model } - fun addPopover(options: PopoverOptions): Popover? { + fun addPopover(options: PopoverOptions): Popover { val popover = delegate.addPopover(options) if (popover != null) items.add(popover) return popover diff --git a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/snippets/MarkerSnippets.kt b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/snippets/MarkerSnippets.kt index 429420b6..b9cb2ba9 100644 --- a/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/snippets/MarkerSnippets.kt +++ b/snippets/kotlin-app/src/main/java/com/example/snippets/kotlin/snippets/MarkerSnippets.kt @@ -63,7 +63,7 @@ class MarkerSnippets(private val context: Context, private val map: TrackedMap3D // MarkerOptions uses label, not title. } - val marker = map.addMarker(options) + map.addMarker(options) // [START_EXCLUDE] map.flyCameraTo( flyToOptions { @@ -107,7 +107,7 @@ class MarkerSnippets(private val context: Context, private val map: TrackedMap3D isDrawnWhenOccluded = true } - val marker = map.addMarker(options) + map.addMarker(options) // [START_EXCLUDE] map.flyCameraTo( flyToOptions { @@ -207,7 +207,7 @@ class MarkerSnippets(private val context: Context, private val map: TrackedMap3D ) } - val marker = map.addMarker(options) + map.addMarker(options) // [START_EXCLUDE] map.flyCameraTo( flyToOptions { diff --git a/visual-testing/build.gradle.kts b/visual-testing/build.gradle.kts new file mode 100644 index 00000000..75a649f4 --- /dev/null +++ b/visual-testing/build.gradle.kts @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.gradle.api.tasks.testing.Test +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlinx.serialization) +} + +android { + namespace = "com.google.maps.android.visualtesting" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = 23 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + jvmToolchain(17) + } + testOptions { + animationsDisabled = true + unitTests.isIncludeAndroidResources = true + unitTests.isReturnDefaultValues = true + } +} + +dependencies { + implementation(libs.androidx.appcompat) + implementation(libs.androidx.core.ktx) + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.google.truth) + + // Dependencies for GeminiVisualTestHelper + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.kotlinx.serialization.json) + implementation(libs.androidx.uiautomator) +} diff --git a/visual-testing/consumer-rules.pro b/visual-testing/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/visual-testing/proguard-rules.pro b/visual-testing/proguard-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/visual-testing/src/main/AndroidManifest.xml b/visual-testing/src/main/AndroidManifest.xml new file mode 100644 index 00000000..342a838f --- /dev/null +++ b/visual-testing/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/visual-testing/src/main/java/com/google/maps/android/visualtesting/GeminiVisualTestHelper.kt b/visual-testing/src/main/java/com/google/maps/android/visualtesting/GeminiVisualTestHelper.kt new file mode 100644 index 00000000..38fe55a3 --- /dev/null +++ b/visual-testing/src/main/java/com/google/maps/android/visualtesting/GeminiVisualTestHelper.kt @@ -0,0 +1,233 @@ +/* + * 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.google.maps.android.visualtesting + +import android.graphics.Bitmap +import android.util.Base64 +import android.util.Log +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import org.json.JSONArray +import org.json.JSONObject +import java.io.ByteArrayOutputStream + +/** + * Helper class to interact with the Gemini API for visual verification and action. + * + * This version uses org.json for parsing to avoid binary compatibility issues with kotlinx.serialization. + */ +class GeminiVisualTestHelper { + + private val client = HttpClient(CIO) { + install(HttpTimeout) { + requestTimeoutMillis = 60_000 + connectTimeoutMillis = 60_000 + socketTimeoutMillis = 60_000 + } + } + + /** + * Executes a UI action based on a natural language prompt. + * It analyzes the current UI hierarchy and asks Gemini to determine the best action. + */ + suspend fun performActionFromPrompt(prompt: String, uiDevice: UiDevice, apiKey: String) { + val hierarchyStream = ByteArrayOutputStream() + uiDevice.dumpWindowHierarchy(hierarchyStream) + val hierarchyXml = hierarchyStream.toString("UTF-8") + + val systemPrompt = """ + You are an expert Android QA automaton. Your task is to translate a natural language command + into a specific action to be performed on a UI. Given a UI hierarchy (in XML format), + determine the correct action and selector. + + The available actions are: "click", "longClick", "setText". + + Your response MUST be a single, well-formed JSON object with "action" and "selector" keys. + The "selector" object must contain exactly one of "text", "contentDescription", or "resourceId". + If the action is "setText", you must also include a "textValue" field at the top level. + + Example for a click: + { "action": "click", "selector": { "text": "Login" } } + + Example for setting text: + { "action": "setText", "selector": { "resourceId": "com.example.app:id/email_input" }, "textValue": "test@example.com" } + """.trimIndent() + + val fullPrompt = "$systemPrompt\n\nCommand: \"$prompt\"\n\nUI Hierarchy:\n$hierarchyXml" + + val modelName = "gemini-2.5-flash" + + val requestJson = JSONObject().apply { + put("contents", JSONArray().apply { + put(JSONObject().apply { + put("parts", JSONArray().apply { + put(JSONObject().apply { put("text", fullPrompt) }) + }) + }) + }) + } + + val response: HttpResponse = client.post("https://generativelanguage.googleapis.com/v1/models/$modelName:generateContent?key=$apiKey") { + contentType(ContentType.Application.Json) + setBody(requestJson.toString()) + } + + if (response.status != HttpStatusCode.OK) { + val errorBody = response.bodyAsText() + Log.e("GeminiVisualTestHelper", "Action API Error: ${response.status} $errorBody") + throw Exception("Gemini Action API returned an error: ${response.status}\n$errorBody") + } + + val rawBody = response.bodyAsText() + val jsonResponse = JSONObject(rawBody) + val actionJson = jsonResponse.getJSONArray("candidates") + .getJSONObject(0) + .getJSONObject("content") + .getJSONArray("parts") + .getJSONObject(0) + .getString("text") + + // Remove markdown code block delimiters if present + val cleanedActionJson = actionJson.removePrefix("```json\n").removeSuffix("\n```") + + Log.d("GeminiVisualTestHelper", "Received Action JSON: $cleanedActionJson") + + try { + val aiAction = JSONObject(cleanedActionJson) + val action = aiAction.getString("action") + val selectorObj = aiAction.getJSONObject("selector") + + val selector = when { + selectorObj.has("text") -> By.text(selectorObj.getString("text")) + selectorObj.has("contentDescription") -> By.desc(selectorObj.getString("contentDescription")) + selectorObj.has("resourceId") -> By.res(selectorObj.getString("resourceId")) + else -> throw IllegalArgumentException("Selector must have text, contentDescription, or resourceId.") + } + + val uiObject = uiDevice.wait(Until.findObject(selector), 10000) + ?: throw Exception("Could not find UI element for selector: $selector") + + when (action.lowercase()) { + "click" -> uiObject.click() + "longclick" -> uiObject.longClick() + "settext" -> { + val textToSet = aiAction.getString("textValue") + uiObject.text = textToSet + } + else -> throw UnsupportedOperationException("Action '$action' is not supported.") + } + } catch (e: Exception) { + Log.e("GeminiVisualTestHelper", "Failed to parse or execute AI action", e) + throw e + } + } + + /** + * Fetches and logs the list of available Gemini models for the given API key. + */ + suspend fun listAvailableModels(apiKey: String) { + try { + val response: HttpResponse = client.get("https://generativelanguage.googleapis.com/v1/models?key=$apiKey") + val rawBody = response.bodyAsText() + val jsonResponse = JSONObject(rawBody) + val models = jsonResponse.getJSONArray("models") + val modelNames = StringBuilder() + for (i in 0 until models.length()) { + val model = models.getJSONObject(i) + modelNames.append(" - ${model.getString("name")} (Display Name: ${model.getString("displayName")})\n") + } + Log.i("GeminiVisualTestHelper", "Available Gemini Models:\n$modelNames") + } catch (e: Exception) { + Log.e("GeminiVisualTestHelper", "Failed to list available models", e) + } + } + + /** + * Analyzes an image with a given prompt using the Gemini API. + */ + suspend fun analyzeImage( + bitmap: Bitmap, + prompt: String, + apiKey: String + ): String? { + // Log available models first for easier debugging. + listAvailableModels(apiKey) + + val base64Image = bitmap.toBase64EncodedJpeg() + + val requestJson = JSONObject().apply { + put("contents", JSONArray().apply { + put(JSONObject().apply { + put("parts", JSONArray().apply { + put(JSONObject().apply { put("text", prompt) }) + put(JSONObject().apply { + put("inline_data", JSONObject().apply { + put("mime_type", "image/jpeg") + put("data", base64Image) + }) + }) + }) + }) + }) + } + + val response: HttpResponse = client.post("https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent?key=$apiKey") { + contentType(ContentType.Application.Json) + setBody(requestJson.toString()) + } + + if (response.status != HttpStatusCode.OK) { + val errorBody = response.bodyAsText() + Log.e("GeminiVisualTestHelper", "API Error: ${response.status} $errorBody") + throw Exception("Gemini API returned an error: ${response.status}\n$errorBody") + } + + val rawBody = response.bodyAsText() + val jsonResponse = JSONObject(rawBody) + + val candidates = jsonResponse.optJSONArray("candidates") + if (candidates == null || candidates.length() == 0) { + Log.w("GeminiVisualTestHelper", "Gemini API returned empty candidates. Full response: $rawBody") + throw Exception("Gemini API returned no candidates.") + } + + return candidates.getJSONObject(0) + .getJSONObject("content") + .getJSONArray("parts") + .getJSONObject(0) + .optString("text") + } + + private fun Bitmap.toBase64EncodedJpeg(): String { + val outputStream = ByteArrayOutputStream() + compress(Bitmap.CompressFormat.JPEG, 80, outputStream) + val byteArray = outputStream.toByteArray() + return Base64.encodeToString(byteArray, Base64.NO_WRAP) + } +} diff --git a/visual-testing/src/test/java/com/google/maps/android/visualtesting/PlaceholderTest.java b/visual-testing/src/test/java/com/google/maps/android/visualtesting/PlaceholderTest.java new file mode 100644 index 00000000..7452a247 --- /dev/null +++ b/visual-testing/src/test/java/com/google/maps/android/visualtesting/PlaceholderTest.java @@ -0,0 +1,27 @@ +/* + * 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.google.maps.android.visualtesting; + +import org.junit.Test; +import static org.junit.Assert.assertTrue; + +public class PlaceholderTest { + @Test + public void testPlaceholder() { + assertTrue(true); + } +}