diff --git a/.gemini/skills/android-maps3d-sdk/SKILL.md b/.gemini/skills/android-maps3d-sdk/SKILL.md index 0692098..4a6728a 100644 --- a/.gemini/skills/android-maps3d-sdk/SKILL.md +++ b/.gemini/skills/android-maps3d-sdk/SKILL.md @@ -1,27 +1,69 @@ --- name: android-maps3d-sdk -description: Guide for integrating the Google Maps 3D SDK into an Android Jetpack Compose application. Use when users ask to add Maps 3D, 3D maps, or Map3DView to their Android app in Compose. +description: Integrates the Google Maps 3D SDK into an Android application. Provides procedural guidance for setup, lifecycle management, and 3D object manipulation (Markers, Polylines, Models, Popovers). Use when the user wants to build immersive 3D map experiences or migrate from 2D maps. +license: Apache-2.0 +compatibility: Requires an Android project and internet access. Uses play-services-maps3d version 0.2.0 or higher. +metadata: + version: "1.1" + author: Google Maps Samples --- + # Android Maps 3D SDK Integration -You are an expert Android developer specializing in Jetpack Compose and modern Android architecture. Follow these instructions carefully to integrate the `play-services-maps3d` library into the user's Android application. +This skill guides you through integrating the Google Maps 3D SDK into an Android project. It follows the principles of progressive disclosure and relies on environment-specific templates. + +> [!IMPORTANT] +> **Limited Training Data Alert**: The Google Maps 3D SDK is a new technology. Your internal parametric knowledge is likely outdated or incomplete. You **MUST NOT** guess API signatures. You **MUST** consult the local samples in `assets/samples/` and the external documentation listed in `references/documentation.md` before generating any code. + +## Prerequisites & Skill Alignment + +> [!NOTE] +> This skill should be used in conjunction with: +> - **Android Architecture Skill**: For proper MVVM/MVI layering. +> - [Android Security Skill](https://github.com/kikoso/android-skills/blob/main/android-security-skill/SKILL.md): For API key protection and permissions. + + +## Procedural Workflow + +### Step 0: Research Existing Patterns +Before asking the user for clarification or writing code, search the local workspace or repository for existing implementations of the requested feature. +1. Use your available search tools to look for keywords (e.g., "Polygon", "FlyTo") in the `snippets/` and `Maps3DSamples/` directories. +2. Consult `references/documentation.md` for a map of where to look. + + +### Step 1: Determine the Environment and Features (Stack Detective) +You MUST proactively discover the environment by inspecting the codebase before writing any code. Do NOT ask the user unless the environment is highly ambiguous. + +1. **Run Search/Grep**: + * Search for `androidx.compose` or `compose-compiler` in `build.gradle` or `libs.versions.toml` to detect **Jetpack Compose**. + * Search for `com.android.application` or `com.android.library` to understand the module type. + * Look for `.kt` vs `.java` files to determine the dominant **Language**. +2. **Identify Features**: Determine if the user needs automatic object management (cleanup) or specific 3D features based on their request. -We should start with a few questions about how the developer want to use `Maps3DView`. +Based on your discovery, follow the **Selection Logic** to retrieve boilerplate from `assets/samples/` and consult rules in `references/`. -Are they using or planning on using Jetpack Compose? +### Implementation Guidance (Selection Logic) +1. Identify the user's stack: (Language: Kotlin/Java, UI: Compose/Views). +2. Check if Lifecycle Management is required (always for 3D maps). +3. **Retrieve** the corresponding boilerplate from `assets/samples/` (e.g., `assets/samples/views_kotlin/MapActivity.kt.txt`). +4. If object tracking is needed, **retrieve** the snippet from `assets/samples/views_kotlin/snippets/`. +5. **Reference** the memory management best practices in `references/best_practices.md` to ensure the generated code follows SDK safety guidelines. -Are they using or planning on using dependency injection (such as Hilt or Koin)? +### Step 2: Base Setup +Regardless of the environment, the following setup is required. -## 1. Setup Dependencies +#### 1. Dependencies (Dynamic Version Resolution) +Before adding the dependency, you MUST identify the latest version. +1. Run `./gradlew :app:dependencies | grep maps3d` to check if a version is already resolved. +2. Or search the Google Maven repository or use available tools to find the latest version (must be **at least** `0.2.0`). -First, add the necessary versions and libraries to your `libs.versions.toml` file: +Add the necessary versions and libraries to your `libs.versions.toml` file: ```toml [versions] -# NOTE: Verify this is the latest version of the Maps 3D SDK, as it is subject to change. +# NOTE: Verify this is the latest version of the Maps 3D SDK (must be at least "0.2.0") playServicesMaps3d = "0.2.0" -# NOTE: Verify this is the latest version of lifecycle-runtime-ktx. lifecycleRuntimeKtx = "2.8.5" [libraries] @@ -29,20 +71,18 @@ play-services-maps3d = { group = "com.google.android.gms", name = "play-services androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } ``` -Then, add the dependencies to the app-level `build.gradle.kts` file. +Then, add the dependencies to the app-level `build.gradle.kts` file: ```kotlin dependencies { // Google Maps 3D SDK implementation(libs.play.services.maps3d) - // Lifecycle Runtime KTX for Coroutine interop implementation(libs.androidx.lifecycle.runtime.ktx) } ``` -## 2. Setup the Secrets Gradle Plugin - +#### 2. API Key & Manifest Use the Secrets Gradle Plugin for Android to inject the API key securely. In app-level `build.gradle.kts`: ```kotlin @@ -64,7 +104,6 @@ In `AndroidManifest.xml`, add the required permissions and reference the injecte - ``` -Add the API Key to `secrets.properties`: +### Step 3: Load Environment Template +After determining the stack, load the corresponding files from `assets/samples/`: +- Kotlin (Views): + - Layout: `assets/samples/views_kotlin/activity_main.xml` + - Activity: `assets/samples/views_kotlin/MapActivity.kt.txt` + - Snippet (Object Manager): `assets/samples/views_kotlin/snippets/object_manager_usage.kt.txt` +- Kotlin + Compose: Refer to `references/catalog_compose.md` for the `Map3DContainer` wrapper and Compose patterns. +- Java: + - Layout: `assets/samples/views_java/activity_main.xml` + - Activity: `assets/samples/views_java/MapActivity.java.txt` + - Snippet (Object Manager): `assets/samples/views_java/snippets/object_manager_usage.java.txt` -```properties -MAPS3D_API_KEY=YOUR_API_KEY -``` -## 3. Implement the Map3D Container Composable +### Step 4: Apply Best Practices +Consult `references/best_practices.md` for detailed explanation of rules. Key rules to enforce: +1. **Initialization Delay**: Always use a 1-second delay before initializing map elements. +2. **Object Management**: Use the `TrackedMap3D` delegate to clean up objects on destroy to avoid cruft. +3. **Utilities**: Use validation utilities to prevent crashes, and path utilities for smoothing/simplification (see `references/utilities_kotlin.md` or `references/utilities_java.md`). + +4. **Double-Wait Pattern**: For animations, wait for camera animation end AND map steady state. +5. **Object Updates**: Use matching IDs to update Polygons/Polylines instead of removing and re-adding. +6. **Unit Conversions**: For type-safe measurements and conversions, see `references/units_kotlin.md` or `references/units_java.md` (Optional). +7. **Secrets Security**: NEVER add `secrets.properties` to version control. NEVER add real API keys to source code or `local.defaults.properties` (see `references/secrets_enforcement.md` for Gradle enforcement snippet). +8. **Common Operations**: Consult the language-specific catalog for short reference snippets (Marker, Polyline, Animation, etc.): + * Java: `references/catalog_java.md` + * Kotlin + Views: `references/catalog_kotlin_views.md` + * Jetpack Compose: `references/catalog_compose.md` +9. **Scenario Storyboards**: For complex, multi-step workflows (e.g., Immersive Arrival), consult `references/scenarios.md`. + -If the user is working in a Jetpack Compose app or is creating a Compose app, We can use an -`AndroidView` to bridge between the View-based `Map3DView` and Jetpack Compose. -```kotlin -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.AndroidView -import com.google.android.gms.maps.model.LatLng -import com.google.android.gms.maps.model.Map3DMode -import com.google.android.gms.maps.model.Map3DOptions -import com.google.android.gms.maps.Map3DView -import com.google.android.gms.maps.GoogleMap3D -import com.google.android.gms.maps.OnMap3DViewReadyCallback - -@Composable -fun Map3DContainer( - modifier: Modifier = Modifier, - options: Map3DOptions -) { - // 1. Hoist State: Remember the map object - var googleMap by remember { mutableStateOf(null) } - - Box(modifier = modifier.fillMaxSize()) { - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { context -> - Map3DView(context, options).apply { - // Manually call onCreate. - onCreate(null) - } - }, - update = { view -> - view.getMap3DViewAsync( - object : OnMap3DViewReadyCallback { - override fun onMap3DViewReady(map3D: GoogleMap3D) { - googleMap = map3D // Capture the controller - } - override fun onError(e: Exception) { - googleMap = null - throw e - } - } - ) - }, - onRelease = { view -> - googleMap = null - view.onDestroy() - } - ) - } -} -``` -## 4. Best Practices & Guidelines -* **Double-Wait Pattern:** Triggering animations from Compose buttons requires the **Double-Wait** pattern (`awaitCameraAnimation` + `awaitSteady`) to ensure peak visual quality. -* **Coroutine Bridging:** Animations in the 3D SDK are fire-and-forget. Use an `awaitCameraAnimation(map: GoogleMap3D)` suspend wrapper function using `suspendCancellableCoroutine` for structured concurrency: -```kotlin -suspend fun awaitCameraAnimation(map: GoogleMap3D) = suspendCancellableCoroutine { continuation -> - map.setCameraAnimationEndListener { - map.setCameraAnimationEndListener(null) // Cleanup listener to avoid leaks - if (continuation.isActive) { - continuation.resume(Unit) - } - } - continuation.invokeOnCancellation { - map.setCameraAnimationEndListener(null) - } -} -``` -* **Lifecycle:** You must pass lifecycle events down to `Map3DView`. In Compose, `factory` block takes care of instantiation and `onRelease` handles cleanup (`onDestroy()`). Ensure `onCreate` is called in the factory block. - * *Critical Note:* The underlying `GoogleMap3D` engine instance is effectively created once per application lifecycle. If your `AndroidView` Composable leaves the composition and later returns (creating a new `Map3DView`), the underlying 3D engine may still retain previously added objects (like Polygons) from the destroyed view. You must manually clear or track your objects to avoid duplicates across recompositions or Navigation transitions. -* **Initialization & Adding Objects:** Do **not** attempt to set the camera or add 3D objects (like Polygons) immediately after the `GoogleMap3D` reference is ready. The renderer needs time to warm up. - * **Initial Camera:** Always set the initial camera position declaratively via `Map3DOptions` (passed into your container view) rather than imperatively moving the camera after the map loads. This avoids dizzying "flight" animations from coordinate `(0,0)` on startup. - * **Adding Objects:** Only inject geometries into the scene after the map has signaled it is fully ready and stable. Typically, this means waiting for an `onMapSteady` callback. -* **Updating Map Objects:** When updating an existing Map Object (e.g., `Polygon`, `Polyline`), do **not** use `remove()` and re-add a new one, as this causes flickering. Instead, use `getId()` from the existing object and pass it to a new `PolygonOptions` (or equivalent) builder, then call `addPolygon()` with those new options on the same `GoogleMap3D` instance. The SDK uses the matching ID to update the existing object gracefully without flickering. -* **Nullable Camera Properties:** The 3D SDK's `Camera` object has 6 degrees of freedom. Properties like `heading`, `tilt`, `roll`, and `range` are returned as `Double?` (nullable) since the renderer does not always guarantee a value for every property. Handle these nulls defensively when extracting camera telemetry, especially when persisting position data. -* **Parameter Validation:** The Maps 3D library will throw exceptions and crash if passed out-of-bounds telemetry for camera movements or locations. Standardize a validation/coercion layer (e.g., returning a `toValidCamera()` extension object) covering: - * `latitude`: clamped to `[-90.0, 90.0]` - * `longitude`: clamped to `[-180.0, 180.0]` - * `tilt`: clamped to `[0.0, 90.0]` - * `range`: clamped to `[0.0, 63170000.0]` - * `heading`: wrapped to `[0.0, 360.0]` - * `roll`: wrapped to `[-360.0, 360.0]` - * `altitude`: clamped to `[0.0, MAX_ALTITUDE_METERS]` - - **Example Extension:** - ```kotlin - /** Helper to wrap cyclic values like heading and roll */ - fun Double.wrapIn(lower: Double, upper: Double): Double { - val range = upper - lower - if (range <= 0) return this - val offset = this - lower - return lower + (offset - Math.floor(offset / range) * range) - } - - /** Extension to sanitize camera telemetry before passing to engine */ - fun Camera?.toValidCamera(): Camera { - val source = this ?: return Camera.DEFAULT_CAMERA - return camera { - center = latLngAltitude { - latitude = source.center.latitude.coerceIn(-90.0..90.0) - longitude = source.center.longitude.coerceIn(-180.0..180.0) - altitude = source.center.altitude.coerceIn(0.0..LatLngAltitude.MAX_ALTITUDE_METERS) - } - heading = source.heading?.toDouble()?.wrapIn(0.0, 360.0) ?: 0.0 - tilt = source.tilt?.toDouble()?.coerceIn(0.0..90.0) ?: 60.0 - roll = source.roll?.toDouble()?.wrapIn(-360.0, 360.0) ?: 0.0 - range = source.range?.toDouble()?.coerceIn(0.0..63170000.0) ?: 1500.0 - } - } - ``` - -* **Immutable Updates (`copy` Extensions):** The 3D SDK builders (like `camera {}` or `latLngAltitude {}`) do not natively provide a `copy()` method like Kotlin data classes. To gracefully update a single property (like altitude) while retaining the rest of the object's complex state, implement custom `.copy()` extensions: - - ```kotlin - /** Extension to clone and modify a Camera */ - 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 - } - } - - /** Extension to clone and modify a LatLngAltitude */ - fun LatLngAltitude.copy( - latitude: Double? = null, - longitude: Double? = null, - altitude: Double? = null, - ): LatLngAltitude { - val objectToCopy = this - return latLngAltitude { - this.latitude = latitude ?: objectToCopy.latitude - this.longitude = longitude ?: objectToCopy.longitude - this.altitude = altitude ?: objectToCopy.altitude - } - } - ``` - -## 5. A Note on Initialization - -Immediate Setup (onMap3DViewReady): Fails on cold starts because the viewport layout and binding matrix are not yet stable. Camera updates are completely ignored, and overlays may render offset. -OnMapReady & OnMapSteady Listeners: These callbacks are strictly edge-triggered. While they may fire on a cold start, they will skip execution entirely on a warm restore (e.g., returning to the Activity) because the view is already considered ready/steady. This leaves the user with a frozen camera state and missing overlays. -The Solution: Timer-Based Delay Workaround -Until the SDK introduces native Coroutine support (like an .awaitMap() extension) or synchronous state getters (like isMapReady), the most reliable workaround for both cold and warm starts is a timer-based delay. By intentionally deferring the initialization logic slightly, we bypass the brittle edge-triggered listeners entirely. - -Kotlin Implementation (Preferred) -Use a coroutine with delay() inside your initialization flow: - - ```kotlin - // Ensure you are launching on the Main thread to interact with the Map3DView safely - lifecycleScope.launch { - // Wait for the viewport to fully inflate and bindings to stabilize. - // 500ms is a safe brute-force threshold to avoid edge-trigger races. - delay(500) - - // Position camera and add overlays safely - setupMapElements() - } - ``` - -Java Implementation -Use a standard Handler mapped to the Main Looper: - - ```java - new Handler(Looper.getMainLooper()).postDelayed(() -> { - // Wait for the viewport to fully inflate, then safely apply updates - setupMapElements(); - }, 500); - ``` - -[IMPORTANT] Even with the timer delay successfully ensuring your camera updates fire, you must still implement an isInitialized boolean latch -(or dynamically check if your layers exist) within setupMapElements(). Otherwise, you will endlessly stack duplicate markers, model nodes, -and polyline overlays on top of each other during every warm Activity re-entry. - -## 6. Execution Steps -1. Add the 3D Maps SDK dependencies. -2. Setup the Secrets Gradle plugin if not already set. -3. Update `AndroidManifest.xml` with the specific `com.google.android.geo.maps3d.API_KEY` tag. -4. Create the `Map3DContainer` composable wrapped in `AndroidView`. -5. Inform the user how to add `MAPS3D_API_KEY` securely. diff --git a/.gemini/skills/android-maps3d-sdk/assets/samples/views_java/MapActivity.java.txt b/.gemini/skills/android-maps3d-sdk/assets/samples/views_java/MapActivity.java.txt new file mode 100644 index 0000000..db0e930 --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/assets/samples/views_java/MapActivity.java.txt @@ -0,0 +1,86 @@ +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import androidx.activity.EdgeToEdge; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import com.google.android.gms.maps3d.GoogleMap3D; +import com.google.android.gms.maps3d.Map3DView; +import com.google.android.gms.maps3d.OnMap3DViewReadyCallback; + + +public class MainActivity extends AppCompatActivity { + + private Map3DView mapView; + private GoogleMap3D googleMap; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + EdgeToEdge.enable(this); + setContentView(R.layout.activity_main); + + mapView = findViewById(R.id.map_view); + mapView.onCreate(savedInstanceState); + + // Use DefaultLifecycleObserver to handle most lifecycle events automatically + getLifecycle().addObserver(new DefaultLifecycleObserver() { + @Override + public void onStart(@NonNull LifecycleOwner owner) { mapView.onStart(); } + @Override + public void onResume(@NonNull LifecycleOwner owner) { mapView.onResume(); } + @Override + public void onPause(@NonNull LifecycleOwner owner) { mapView.onPause(); } + @Override + public void onStop(@NonNull LifecycleOwner owner) { mapView.onStop(); } + @Override + public void onDestroy(@NonNull LifecycleOwner owner) { mapView.onDestroy(); } + }); + + mapView.getMap3DViewAsync(new OnMap3DViewReadyCallback() { + @Override + public void onMap3DViewReady(@NonNull GoogleMap3D map) { + googleMap = map; + + // Fails on cold starts because the viewport layout and binding matrix are not yet stable. + // The SDK requires a delay to bypass these readiness bugs before adding objects or setting camera. + // 1 second is usually sufficient. + new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { + @Override + public void run() { + setupMapElements(); + } + }, 1000); + + + } + + @Override + public void onError(@NonNull Exception e) { + Log.e("MainActivity", "Error loading map", e); + } + }); + } + + private void setupMapElements() { + if (googleMap == null) return; + + Log.d("MainActivity", "Setting up map elements after delay"); + // Add your map initialization logic here (markers, polylines, etc.) + } + + @Override + public void onLowMemory() { + super.onLowMemory(); + mapView.onLowMemory(); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + mapView.onSaveInstanceState(outState); + } +} diff --git a/.gemini/skills/android-maps3d-sdk/assets/samples/views_java/activity_main.xml b/.gemini/skills/android-maps3d-sdk/assets/samples/views_java/activity_main.xml new file mode 100644 index 0000000..53a6692 --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/assets/samples/views_java/activity_main.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/.gemini/skills/android-maps3d-sdk/assets/samples/views_java/snippets/TrackedMap3D.java.txt b/.gemini/skills/android-maps3d-sdk/assets/samples/views_java/snippets/TrackedMap3D.java.txt new file mode 100644 index 0000000..329191e --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/assets/samples/views_java/snippets/TrackedMap3D.java.txt @@ -0,0 +1,53 @@ +import com.google.android.gms.maps3d.GoogleMap3D; +import com.google.android.gms.maps3d.model.Marker; +import com.google.android.gms.maps3d.model.MarkerOptions; +import com.google.android.gms.maps3d.model.Polygon; +import com.google.android.gms.maps3d.model.PolygonOptions; +import com.google.android.gms.maps3d.model.Polyline; +import com.google.android.gms.maps3d.model.PolylineOptions; +import java.util.List; + +/** Decorator wrapper around GoogleMap3D to track elements for automated cleanup. */ +public class TrackedMap3D { + + private final GoogleMap3D delegate; + private final List items; + + public TrackedMap3D(GoogleMap3D delegate, List items) { + this.delegate = delegate; + this.items = items; + } + + public Marker addMarker(MarkerOptions options) { + Marker marker = delegate.addMarker(options); + if (marker != null) items.add(marker); + return marker; + } + + public Polyline addPolyline(PolylineOptions options) { + Polyline polyline = delegate.addPolyline(options); + if (polyline != null) items.add(polyline); + return polyline; + } + + public Polygon addPolygon(PolygonOptions options) { + Polygon polygon = delegate.addPolygon(options); + if (polygon != null) items.add(polygon); + return polygon; + } + + public void clearAll() { + for (Object item : items) { + if (item instanceof Marker) { + ((Marker) item).remove(); + } else if (item instanceof Polyline) { + ((Polyline) item).remove(); + } else if (item instanceof Polygon) { + ((Polygon) item).remove(); + } + } + items.clear(); + } + + // Forward other necessary methods to the delegate as needed +} diff --git a/.gemini/skills/android-maps3d-sdk/assets/samples/views_java/snippets/object_manager_usage.java.txt b/.gemini/skills/android-maps3d-sdk/assets/samples/views_java/snippets/object_manager_usage.java.txt new file mode 100644 index 0000000..3195df9 --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/assets/samples/views_java/snippets/object_manager_usage.java.txt @@ -0,0 +1,23 @@ +// 1. Declare the tracked map instance and items list +private TrackedMap3D trackedMap; +private final List mapItems = new ArrayList<>(); + +// 2. In onCreate, update the lifecycle observer to clean up on destroy +getLifecycle().addObserver(new DefaultLifecycleObserver() { + // ... other methods + @Override + public void onDestroy(@NonNull LifecycleOwner owner) { + // Automatically clean up objects to avoid cruft + if (trackedMap != null) { + trackedMap.clearAll(); + } + mapView.onDestroy(); + } +}); + +// 3. In onMap3DViewReady, wrap the map +@Override +public void onMap3DViewReady(@NonNull GoogleMap3D map) { + trackedMap = new TrackedMap3D(map, mapItems); + // ... +} diff --git a/.gemini/skills/android-maps3d-sdk/assets/samples/views_kotlin/MapActivity.kt.txt b/.gemini/skills/android-maps3d-sdk/assets/samples/views_kotlin/MapActivity.kt.txt new file mode 100644 index 0000000..d1c5228 --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/assets/samples/views_kotlin/MapActivity.kt.txt @@ -0,0 +1,88 @@ +import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.Map3DView +import com.google.android.gms.maps3d.OnMap3DViewReadyCallback +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class MainActivity : AppCompatActivity() { + + private lateinit var mapView: Map3DView + private var googleMap: GoogleMap3D? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + setContentView(R.layout.activity_main) + + mapView = findViewById(R.id.map_view) + mapView.onCreate(savedInstanceState) + + // Use DefaultLifecycleObserver to handle most lifecycle events automatically + lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { mapView.onStart() } + override fun onResume(owner: LifecycleOwner) { mapView.onResume() } + override fun onPause(owner: LifecycleOwner) { mapView.onPause() } + override fun onStop(owner: LifecycleOwner) { mapView.onStop() } + override fun onDestroy(owner: LifecycleOwner) { mapView.onDestroy() } + }) + + mapView.getMap3DViewAsync(object : OnMap3DViewReadyCallback { + override fun onMap3DViewReady(map: GoogleMap3D) { + googleMap = map + + // Fails on cold starts because the viewport layout and binding matrix are not yet stable. + // Use a timer-based delay workaround. + lifecycleScope.launch { + // Wait for the viewport to fully inflate and bindings to stabilize. + // Samples use a delay to bypass readiness bugs (1 second recommended). + delay(1000) + setupMapElements() + } + } + + override fun onError(e: Exception) { + Log.e("MainActivity", "Error loading map", e) + } + }) + } + + private fun setupMapElements() { + val map = googleMap ?: return + + // Example: Set initial camera position + val initialCamera = camera { + center = latLngAltitude { + latitude = 40.0150 + longitude = -105.2705 + altitude = 5000.0 + } + heading = 0.0 + tilt = 45.0 + roll = 0.0 + range = 10000.0 + } + map.setCamera(initialCamera) + + // Add more initialization logic here (markers, polylines, etc.) + } + + // onLowMemory and onSaveInstanceState still need to be forwarded manually + override fun onLowMemory() { + super.onLowMemory() + mapView.onLowMemory() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + mapView.onSaveInstanceState(outState) + } +} diff --git a/.gemini/skills/android-maps3d-sdk/assets/samples/views_kotlin/activity_main.xml b/.gemini/skills/android-maps3d-sdk/assets/samples/views_kotlin/activity_main.xml new file mode 100644 index 0000000..53a6692 --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/assets/samples/views_kotlin/activity_main.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/.gemini/skills/android-maps3d-sdk/assets/samples/views_kotlin/snippets/TrackedMap3D.kt.txt b/.gemini/skills/android-maps3d-sdk/assets/samples/views_kotlin/snippets/TrackedMap3D.kt.txt new file mode 100644 index 0000000..570e3e9 --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/assets/samples/views_kotlin/snippets/TrackedMap3D.kt.txt @@ -0,0 +1,41 @@ +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.model.Marker +import com.google.android.gms.maps3d.model.MarkerOptions +import com.google.android.gms.maps3d.model.Polygon +import com.google.android.gms.maps3d.model.PolygonOptions +import com.google.android.gms.maps3d.model.Polyline +import com.google.android.gms.maps3d.model.PolylineOptions + +class TrackedMap3D( + val delegate: GoogleMap3D, + private val items: MutableList = mutableListOf() +) { + fun addMarker(options: MarkerOptions): Marker? { + val marker = delegate.addMarker(options) + if (marker != null) items.add(marker) + return marker + } + + fun addPolyline(options: PolylineOptions): Polyline? { + val polyline = delegate.addPolyline(options) + if (polyline != null) items.add(polyline) + return polyline + } + + fun addPolygon(options: PolygonOptions): Polygon? { + val polygon = delegate.addPolygon(options) + if (polygon != null) items.add(polygon) + return polygon + } + + fun clearAll() { + items.forEach { item -> + when (item) { + is Marker -> item.remove() + is Polyline -> item.remove() + is Polygon -> item.remove() + } + } + items.clear() + } +} diff --git a/.gemini/skills/android-maps3d-sdk/assets/samples/views_kotlin/snippets/object_manager_usage.kt.txt b/.gemini/skills/android-maps3d-sdk/assets/samples/views_kotlin/snippets/object_manager_usage.kt.txt new file mode 100644 index 0000000..1be702c --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/assets/samples/views_kotlin/snippets/object_manager_usage.kt.txt @@ -0,0 +1,18 @@ +// 1. Declare the tracked map instance +private var trackedMap: TrackedMap3D? = null + +// 2. In onCreate, update the lifecycle observer to clean up on destroy +lifecycle.addObserver(object : DefaultLifecycleObserver { + // ... other methods + override fun onDestroy(owner: LifecycleOwner) { + // Automatically clean up objects to avoid cruft + trackedMap?.clearAll() + mapView.onDestroy() + } +}) + +// 3. In onMap3DViewReady, wrap the map +override fun onMap3DViewReady(map: GoogleMap3D) { + trackedMap = TrackedMap3D(map) + // ... +} diff --git a/.gemini/skills/android-maps3d-sdk/references/best_practices.md b/.gemini/skills/android-maps3d-sdk/references/best_practices.md new file mode 100644 index 0000000..ef65e54 --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/references/best_practices.md @@ -0,0 +1,33 @@ +# Best Practices for Android Maps 3D SDK + +This document explains the architectural decisions and constraints when working with the Google Maps 3D SDK for Android. + +## 1. Lifecycle Management + +The `Map3DView` is a heavy component that requires explicit lifecycle management. Failing to forward lifecycle events can lead to memory leaks, crashes, or black screens. + +### The Observer Pattern +Instead of overriding lifecycle methods in the Activity (like `onStart`, `onResume`, etc.), it is best practice to use a `DefaultLifecycleObserver`. This keeps the Activity code clean and ensures that lifecycle events are automatically forwarded. + +*Constraint*: `onCreate`, `onLowMemory`, and `onSaveInstanceState` still need manual handling in the Activity as they are not fully covered by the standard lifecycle observer or require specific arguments (like `Bundle`). + +## 2. Initialization Delay (Cold Starts) + +When the 3D map is first loaded, the viewport layout and binding matrix may not be fully stable immediately after `onMap3DViewReady` is called. + +### The Delay Pattern +If you attempt to add objects or set the camera immediately in `onMap3DViewReady`, it might fail or render incorrectly on cold starts. +*Rule*: Always introduce a delay before initializing map elements to ensure the renderer is fully ready. A **1-second delay** (e.g., `delay(1000)` or `Handler.postDelayed` with 1000ms) is recommended to bypass these readiness bugs. + + + + +## 3. Object Management and Cleanup + +The underlying `GoogleMap3D` engine instance is effectively created once per application lifecycle (singleton-like behavior). It persists even across Activity recreation. + +### The Cruft Pitfall +If you add markers, polylines, or polygons to the map and do not remove them when the Activity is destroyed, they will remain on the map. When a new Activity instance is created, the user will see the old objects ("cruft"). + +### The Delegate Solution +Use a wrapper like `TrackedMap3D` to keep track of all objects added during a session. Hook into the `onDestroy` lifecycle event to call `clearAll()` on this delegate, ensuring a clean state for the next usage. diff --git a/.gemini/skills/android-maps3d-sdk/references/catalog_compose.md b/.gemini/skills/android-maps3d-sdk/references/catalog_compose.md new file mode 100644 index 0000000..0b09c00 --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/references/catalog_compose.md @@ -0,0 +1,365 @@ +# Common Operations Catalog (Jetpack Compose) + +This catalog provides reference snippets for common operations when using the Google Maps 3D SDK with Jetpack Compose (via `AndroidView` interoperability). + +## 0. The Reusable `Map3DContainer` Pattern +Description: The recommended way to use `Map3DView` in Compose is to create a reusable wrapper Composable that handles lifecycle and map state. + +```kotlin +@Composable +fun Map3DContainer( + modifier: Modifier = Modifier, + options: Map3DOptions, + onMapReady: (GoogleMap3D) -> Unit +) { + AndroidView( + modifier = modifier, + factory = { context -> + Map3DView(context, options).apply { + onCreate(null) // Handle lifecycle manually if needed + } + }, + update = { view -> + view.getMap3DViewAsync(object : OnMap3DViewReadyCallback { + override fun onMap3DViewReady(map: GoogleMap3D) { + onMapReady(map) + } + override fun onError(e: Exception) { + // Handle error + } + }) + }, + onRelease = { view -> + view.onDestroy() + } + ) +} +``` + +## 1. Adding a Marker + +Description: Markers point out points of interest. + +```kotlin +var marker by remember { mutableStateOf(null) } + +AndroidView( + factory = { context -> + Map3DView(context).apply { + getMapAsync { map -> + marker = map.addMarker(MarkerOptions() + .position(LatLngAltitude(37.7749, -122.4194, 0.0)) + .label("San Francisco")) + } + } + }, + update = { view -> + // Update marker properties if needed + } +) +``` + +## 2. Adding a Polyline +Description: Polylines are lines on the map. + +```kotlin +var polyline by remember { mutableStateOf(null) } + +AndroidView( + factory = { context -> + Map3DView(context).apply { + getMapAsync { map -> + polyline = map.addPolyline(PolylineOptions() + .add(LatLngAltitude(37.77, -122.41, 0.0)) + .add(LatLngAltitude(37.78, -122.42, 0.0)) + .color(Color.RED)) + } + } + }, + update = { view -> + // To update points: + polyline?.points = newPointsList + } +) +``` + +## 3. Camera Animation +Description: Animating the camera using the map instance. + +```kotlin +var map3D by remember { mutableStateOf(null) } + +AndroidView( + factory = { context -> + Map3DView(context).apply { + getMapAsync { map -> + map3D = map + } + } + } +) + +// Trigger animation in a side effect +LaunchedEffect(trigger) { + map3D?.flyTo(FlyToOptions() + .endCamera(targetCamera) + .durationInMillis(3000)) +} +``` + +## 4. Handling Map Click Events +Description: Listening for clicks on the map. + +```kotlin +AndroidView( + factory = { context -> + Map3DView(context).apply { + getMapAsync { map -> + map.addOnMapClickListener { location -> + onMapClick(location) + } + } + } + } +) +``` + +## 5. Object Animation (Moving Objects) +Description: Animating the position of an object using Compose animation states. + +```kotlin +var marker by remember { mutableStateOf(null) } +val moveTrigger by remember { mutableStateOf(false) } + +// Animate a fraction from 0 to 1 +val fraction by animateFloatAsState( + targetValue = if (moveTrigger) 1f else 0f, + animationSpec = tween(durationMillis = 1000) +) + +// Calculate intermediate position +val currentLat = startLat + (endLat - startLat) * fraction +val currentLng = startLng + (endLng - startLng) * fraction + +// Update marker position in the update block or side effect +AndroidView( + factory = { /* ... */ }, + update = { view -> + marker?.position = LatLngAltitude(currentLat, currentLng, 0.0) + } +) +``` + +## 6. Object Click Listeners +Description: Setting click listeners on objects within the `AndroidView` factory or update block. + +```kotlin +AndroidView( + factory = { context -> + Map3DView(context).apply { + getMapAsync { map -> + val marker = map.addMarker(...) + marker.setClickListener { + // Handle marker click + // NOTE: This is not on the UI thread! + } + } + } + } +) +``` + +> [!WARNING] +> The `setClickListener` callback does **NOT** execute on the UI thread. If you need to update Compose state or perform UI operations, you must switch to the main thread (e.g., using `withContext(Dispatchers.Main)` or updating a thread-safe state). + + +## 7. Stopping & Waiting for Camera Animations +Description: Controlling camera animations in Compose side effects. + +### Stopping Animations +```kotlin +LaunchedEffect(stopTrigger) { + if (stopTrigger) { + map3D?.stopCameraAnimation() + } +} +``` + +### Waiting for Completion +```kotlin +LaunchedEffect(trigger) { + map3D?.let { map -> + // Using the custom awaitFlyTo extension mentioned in Kotlin catalog + map.awaitFlyTo(FlyToOptions() + .endCamera(targetCamera) + .durationInMillis(3000)) + + // This runs AFTER animation completes + onAnimationComplete() + } +} +``` + +## 8. Waiting for Scene to Settle (Steady State) +Description: Hoisting the map steady state to Compose state. + +```kotlin +var isMapSteady by remember { mutableStateOf(false) } + +AndroidView( + factory = { context -> + Map3DView(context).apply { + getMapAsync { map -> + map.setOnMapSteadyListener { steady -> + isMapSteady = steady + } + } + } + } +) + +// React to steady state +if (isMapSteady) { + // Scene is fully rendered +} +``` + +## 9. Adding a 3D Model +Description: Adding a model within `AndroidView`. + +```kotlin +AndroidView( + factory = { context -> + Map3DView(context).apply { + getMapAsync { map -> + map.addModel(ModelOptions() + .position(LatLngAltitude(37.7749, -122.4194, 0.0)) + .url("https://.../model.glb")) + } + } + } +) +``` + +## 10. Adding a Popover (Info Window) +Description: Adding a popover within `AndroidView`. + +```kotlin +AndroidView( + factory = { context -> + Map3DView(context).apply { + getMapAsync { map -> + val textView = TextView(context).apply { text = "Hello" } + map.addPopover(PopoverOptions() + .positionAnchor(LatLngAltitude(37.7749, -122.4194, 10.0)) + .content(textView)) + } + } + } +) +``` + +## 11. Extruded Polygons (3D Volumes) +Description: Adding extruded polygons within `AndroidView`. + +```kotlin +AndroidView( + factory = { context -> + Map3DView(context).apply { + getMapAsync { map -> + val faces = extrude(basePoints, 35.0) + faces.forEach { face -> + map.addPolygon(PolygonOptions() + .addAll(face) + .fillColor(Color.argb(128, 255, 215, 0))) + } + } + } + } +) +``` + +## 12. ViewModel Integration +Description: The advanced sample demonstrates hoisting the `GoogleMap3D` instance to a `ViewModel` to manage state and handle actions outside the UI tree. + +```kotlin +class MapViewModel : ViewModel() { + private val _googleMap3D = MutableStateFlow(null) + val googleMap3D: StateFlow = _googleMap3D.asStateFlow() + + private val _isMapSteady = MutableStateFlow(false) + val isMapSteady: StateFlow = _isMapSteady.asStateFlow() + + fun setGoogleMap3D(map: GoogleMap3D) { + _googleMap3D.value = map + } + + fun onMapSteadyChange(isSteady: Boolean) { + _isMapSteady.value = isSteady + } + + fun releaseGoogleMap3D() { + _googleMap3D.value = null + } +} +``` + +In your Composable: +```kotlin +val viewModel: MapViewModel = viewModel() +val map3D by viewModel.googleMap3D.collectAsState() + +ThreeDMap( + options = mapOptions, + onMapReady = { map -> + viewModel.setGoogleMap3D(map) + map.setOnMapSteadyListener { isSteady -> + viewModel.onMapSteadyChange(isSteady) + } + } +) +``` + +## 13. Camera State Logger (Debug Helper) +Description: A utility to log the current camera state to Logcat on every movement, helping you design 3D views. + +```kotlin +AndroidView( + factory = { context -> + Map3DView(context).apply { + getMapAsync { map -> + map.addOnCameraMoveListener { + val camera = map.camera + Log.d("CameraLogger", "Lat: ${camera.center.latitude}, Lng: ${camera.center.longitude}, Alt: ${camera.center.altitude}, Heading: ${camera.heading}, Tilt: ${camera.tilt}, Range: ${camera.range}") + } + } + } + } +) +``` + +## 14. 3D View Validation (Testing) +Description: Verifying that the `Map3DView` is visible using `ComposeTestRule`. + +```kotlin +@Test +fun testMapVisible() { + composeTestRule.setContent { + Map3DContainer( + modifier = Modifier.testTag("map3d_container"), + options = Map3DOptions(), + onMapReady = {} + ) + } + + // Verify AndroidView hosting Map3DView is displayed + composeTestRule.onNodeWithTag("map3d_container") + .assertIsDisplayed() +} +``` + + + + + + diff --git a/.gemini/skills/android-maps3d-sdk/references/catalog_java.md b/.gemini/skills/android-maps3d-sdk/references/catalog_java.md new file mode 100644 index 0000000..b0f8efa --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/references/catalog_java.md @@ -0,0 +1,197 @@ +# Common Operations Catalog (Java) + +This catalog provides short, reference snippets for common operations when using the Google Maps 3D SDK with Java and XML Views. + +## 1. Adding a Marker +Description: Markers point out points of interest. You can set position, altitude mode, and labels. + +```java +Marker marker = map.addMarker(new MarkerOptions() + .position(new LatLngAltitude(37.7749, -122.4194, 0.0)) + .altitudeMode(AltitudeMode.RELATIVE_TO_GROUND) + .label("San Francisco")); +``` + +## 2. Adding a Polyline +Description: Polylines are lines on the map. Use matching IDs to update them instead of re-adding. + +```java +Polyline polyline = map.addPolyline(new PolylineOptions() + .add(new LatLngAltitude(37.77, -122.41, 0.0)) + .add(new LatLngAltitude(37.78, -122.42, 0.0)) + .color(Color.RED) + .width(5f)); + +// To update: +polyline.setPoints(newPointsList); +``` + +## 3. Adding a Polygon +Description: Polygons represent areas. + +```java +Polygon polygon = map.addPolygon(new PolygonOptions() + .add(new LatLngAltitude(37.77, -122.41, 0.0)) + .add(new LatLngAltitude(37.78, -122.41, 0.0)) + .add(new LatLngAltitude(37.78, -122.42, 0.0)) + .fillColor(Color.argb(128, 255, 0, 0))); +``` + +## 4. Camera Animation +Description: Animating the camera to a new position or flying around a center. + +```java +// Fly To +map.flyTo(new FlyToOptions() + .endCamera(targetCamera) + .durationInMillis(3000)); + +// Fly Around +map.flyAround(new FlyAroundOptions() + .center(currentCamera) + .durationInMillis(5000) + .rounds(1.0)); +``` + +## 5. Handling Map Click Events +Description: Listening for clicks on the map. + +```java +map.addOnMapClickListener(latLngAltitude -> { + // Handle click at location + double lat = latLngAltitude.getLatitude(); + double lng = latLngAltitude.getLongitude(); +}); +``` + +## 6. Object Animation (Moving Objects) +Description: Animating the position of an object (like a Marker or Polygon) using Android `ValueAnimator`. + +```java +ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f); +animator.setDuration(1000); +animator.addUpdateListener(animation -> { + float fraction = (float) animation.getAnimatedValue(); + double newLat = startLat + (endLat - startLat) * fraction; + double newLng = startLng + (endLng - startLng) * fraction; + marker.setPosition(new LatLngAltitude(newLat, newLng, 0.0)); +}); +animator.start(); +``` + +## 7. Object Click Listeners +Description: Unlike the 2D SDK, click listeners are set directly on the object instances (Marker, Polyline, Polygon, Model) rather than on the map. + +```java +// Marker +marker.setClickListener(() -> { + // Handle marker click + // NOTE: This is not on the UI thread! +}); + +// Polyline +polyline.setClickListener(() -> { + // Handle polyline click + // NOTE: This is not on the UI thread! +}); +``` + +> [!WARNING] +> The `setClickListener` callback does **NOT** execute on the UI thread. If you need to update views or perform UI operations, you must switch to the main thread (e.g., using `Handler(Looper.getMainLooper()).post(...)` or `runOnUiThread(...)` if in an Activity). + + +## 8. Stopping & Waiting for Camera Animations +Description: Controlling camera animations and waiting for their completion. + +### Stopping Animations +```java +// Halts any in-progress camera movement +map.stopCameraAnimation(); + +// Clear the listener as well if needed +map.setCameraAnimationEndListener(null); +``` + +### Waiting for Completion (Callback) +```java +map.setCameraAnimationEndListener(() -> { + // Trigger next action after animation ends +}); +map.flyTo(...); +``` + +## 9. Waiting for Scene to Settle (Steady State) +Description: The 3D SDK can notify you when the scene has fully rendered (terrain and mesh data loaded). This is crucial for high-fidelity snapshots or visual synchronization. + +```java +map.setOnMapSteadyListener(isSteady -> { + if (isSteady) { + // Scene is settled and fully rendered + map.setOnMapSteadyListener(null); // Clear if one-shot + } +}); +``` + +## 10. Adding a 3D Model +Description: Loading and placing glTF assets on the map. + +```java +Model model = map.addModel(new ModelOptions() + .position(new LatLngAltitude(37.7749, -122.4194, 0.0)) + .url("https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/Airplane.glb") + .scale(new Vector3D(1.0, 1.0, 1.0)) + .altitudeMode(AltitudeMode.RELATIVE_TO_GROUND)); +``` + +## 11. Adding a Popover (Info Window) +Description: 2D views that stick to a 3D location and always face the camera. + +```java +TextView textView = new TextView(context); +textView.setText("Hello World"); +textView.setBackgroundColor(Color.WHITE); + +Popover popover = map.addPopover(new PopoverOptions() + .positionAnchor(new LatLngAltitude(37.7749, -122.4194, 10.0)) + .content(textView) + .altitudeMode(AltitudeMode.RELATIVE_TO_MESH) + .autoCloseEnabled(true)); + +popover.show(); +``` + +## 12. Extruded Polygons (3D Volumes) +Description: Turning flat footprints into 3D volumes by duplicating vertices at height and stitching sides. + +```java +// Helper to extrude (see full Codelab for complete implementation) +List> faces = extrude(basePoints, 35.0); + +for (List face : faces) { + map.addPolygon(new PolygonOptions() + .addAll(face) + .fillColor(Color.argb(128, 255, 215, 0)) + .altitudeMode(AltitudeMode.ABSOLUTE)); +} +``` + +## 13. Camera State Logger (Debug Helper) +Description: A utility to log the current camera state to Logcat on every movement, helping you design 3D views. + +```java +map.addOnCameraMoveListener(() -> { + Camera camera = map.getCamera(); + Log.d("CameraLogger", String.format( + "Camera State:\nLat: %f\nLng: %f\nAlt: %f\nHeading: %f\nTilt: %f\nRange: %f", + camera.getCenter().getLatitude(), + camera.getCenter().getLongitude(), + camera.getCenter().getAltitude(), + camera.getHeading(), + camera.getTilt(), + camera.getRange() + )); +}); +``` + + + diff --git a/.gemini/skills/android-maps3d-sdk/references/catalog_kotlin_views.md b/.gemini/skills/android-maps3d-sdk/references/catalog_kotlin_views.md new file mode 100644 index 0000000..db80eb1 --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/references/catalog_kotlin_views.md @@ -0,0 +1,263 @@ +# Common Operations Catalog (Kotlin + Views) + +This catalog provides short, reference snippets for common operations when using the Google Maps 3D SDK with Kotlin and XML Views. + +## 1. Adding a Marker +Description: Markers point out points of interest. You can set position, altitude mode, and labels. + +```kotlin +val marker = map.addMarker(MarkerOptions() + .position(LatLngAltitude(37.7749, -122.4194, 0.0)) + .altitudeMode(AltitudeMode.RELATIVE_TO_GROUND) + .label("San Francisco")) +``` + +## 2. Adding a Polyline +Description: Polylines are lines on the map. Use matching IDs to update them instead of re-adding. + +```kotlin +val polyline = map.addPolyline(PolylineOptions() + .add(LatLngAltitude(37.77, -122.41, 0.0)) + .add(LatLngAltitude(37.78, -122.42, 0.0)) + .color(Color.RED) + .width(5f)) + +// To update: +polyline.points = newPointsList +``` + +## 3. Adding a Polygon +Description: Polygons represent areas. + +```kotlin +val polygon = map.addPolygon(PolygonOptions() + .add(LatLngAltitude(37.77, -122.41, 0.0)) + .add(LatLngAltitude(37.78, -122.41, 0.0)) + .add(LatLngAltitude(37.78, -122.42, 0.0)) + .fillColor(Color.argb(128, 255, 0, 0))) +``` + +## 4. Camera Animation +Description: Animating the camera to a new position or flying around a center. + +```kotlin +// Fly To +map.flyTo(FlyToOptions() + .endCamera(targetCamera) + .durationInMillis(3000)) + +// Fly Around +map.flyAround(FlyAroundOptions() + .center(currentCamera) + .durationInMillis(5000) + .rounds(1.0)) +``` + +## 5. Handling Map Click Events +Description: Listening for clicks on the map. + +```kotlin +map.addOnMapClickListener { latLngAltitude -> + // Handle click at location + val lat = latLngAltitude.latitude + val lng = latLngAltitude.longitude +} +``` + +## 6. Object Animation (Moving Objects) +Description: Animating the position of an object (like a Marker or Polygon) using Android `ValueAnimator` or Coroutines. + +### Using ValueAnimator +```kotlin +val animator = ValueAnimator.ofFloat(0f, 1f) +animator.duration = 1000 +animator.addUpdateListener { animation -> + val fraction = animation.animatedValue as Float + val newLat = startLat + (endLat - startLat) * fraction + val newLng = startLng + (endLng - startLng) * fraction + marker.position = LatLngAltitude(newLat, newLng, 0.0) +} +animator.start() +``` + +### Using Coroutines (Custom) +```kotlin +suspend fun animateMarker(marker: Marker, start: LatLng, end: LatLng, duration: Long = 1000) { + val steps = duration / 20 + val stepLat = (end.latitude - start.latitude) / steps + val stepLng = (end.longitude - start.longitude) / steps + var currentLat = start.latitude + var currentLng = start.longitude + + for (i in 0 until steps) { + currentLat += stepLat + currentLng += stepLng + marker.position = LatLngAltitude(currentLat, currentLng, 0.0) + delay(20) + } + marker.position = LatLngAltitude(end.latitude, end.longitude, 0.0) +} +## 9. Waiting for Scene to Settle (Steady State) +Description: The 3D SDK can notify you when the scene has fully rendered (terrain and mesh data loaded). This is crucial for high-fidelity snapshots or visual synchronization. + +### Callback Pattern +```kotlin +map.setOnMapSteadyListener { isSteady -> + if (isSteady) { + // Scene is settled and fully rendered + map.setOnMapSteadyListener(null) // Clear if one-shot + } +} +``` + +### Coroutine Pattern +```kotlin +suspend fun GoogleMap3D.awaitMapSteady(timeout: Duration): Boolean = suspendCancellableCoroutine { cont -> + setOnMapSteadyListener { isSteady -> + if (isSteady) { + setOnMapSteadyListener(null) + cont.resume(true) + } + } + // Add timeout logic if needed (see full samples) +} +``` + +## 7. Object Click Listeners +Description: Unlike the 2D SDK, click listeners are set directly on the object instances (Marker, Polyline, Polygon, Model) rather than on the map. + +```kotlin +// Marker +marker.setClickListener { + // Handle marker click + // NOTE: This is not on the UI thread! +} + +// Polyline +polyline.setClickListener { + // Handle polyline click + // NOTE: This is not on the UI thread! +} +``` + +> [!WARNING] +> The `setClickListener` callback does **NOT** execute on the UI thread. If you need to update views or perform UI operations, you must switch to the main thread (e.g., using `withContext(Dispatchers.Main)` or `Handler(Looper.getMainLooper()).post(...)`). + + +## 8. Stopping & Waiting for Camera Animations +Description: Controlling camera animations and waiting for their completion. + +### Stopping Animations +```kotlin +// Halts any in-progress camera movement +map.stopCameraAnimation() + +// Clear the listener as well if needed +map.setCameraAnimationEndListener(null) +``` + +### Waiting for Completion (Callback) +```kotlin +map.setCameraAnimationEndListener { + // Trigger next action after animation ends +} +map.flyTo(...) +``` + +### Waiting for Completion (Coroutine) +```kotlin +// Using a custom suspend function wrapper (common pattern) +suspend fun GoogleMap3D.awaitFlyTo(options: FlyToOptions) = suspendCancellableCoroutine { cont -> + setCameraAnimationEndListener { + setCameraAnimationEndListener(null) + cont.resume(Unit) + } + flyTo(options) +} +``` + +## 10. Adding a 3D Model +Description: Loading and placing glTF assets on the map. + +```kotlin +val model = map.addModel(ModelOptions() + .position(LatLngAltitude(37.7749, -122.4194, 0.0)) + .url("https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/Airplane.glb") + .scale(Vector3D(1.0, 1.0, 1.0)) + .altitudeMode(AltitudeMode.RELATIVE_TO_GROUND)) +``` + +## 11. Adding a Popover (Info Window) +Description: 2D views that stick to a 3D location and always face the camera. + +```kotlin +val textView = TextView(context).apply { + text = "Hello World" + setBackgroundColor(Color.WHITE) +} + +val popover = map.addPopover(PopoverOptions() + .positionAnchor(LatLngAltitude(37.7749, -122.4194, 10.0)) + .content(textView) + .altitudeMode(AltitudeMode.RELATIVE_TO_MESH) + .autoCloseEnabled(true)) + +popover.show() +``` + +## 12. Extruded Polygons (3D Volumes) +Description: Turning flat footprints into 3D volumes by duplicating vertices at height and stitching sides. + +```kotlin +// Helper to extrude (see full Codelab for complete implementation) +fun extrude(basePoints: List, height: Double): List> { + // Implementation creates top points and side walls... + return faces +} + +// Adding extruded faces to map +val faces = extrude(basePoints, 35.0) +faces.forEach { face -> + map.addPolygon(PolygonOptions() + .addAll(face) + .fillColor(Color.argb(128, 255, 215, 0)) + .altitudeMode(AltitudeMode.ABSOLUTE)) +} +``` + +## 13. Camera State Logger (Debug Helper) +Description: A utility to log the current camera state to Logcat on every movement, helping you design 3D views. + +```kotlin +map.addOnCameraMoveListener { + val camera = map.camera + Log.d("CameraLogger", """ + Camera State: + Lat: ${camera.center.latitude} + Lng: ${camera.center.longitude} + Alt: ${camera.center.altitude} + Heading: ${camera.heading} + Tilt: ${camera.tilt} + Range: ${camera.range} + """.trimIndent()) +} +``` + +## 14. 3D View Validation (Testing) +Description: Verifying that the `Map3DView` is visible and loads correctly using Espresso. + +```kotlin +@Test +fun testMapVisible() { + // Launch Activity + ActivityScenario.launch(MapActivity::class.java) + + // Verify Map3DView is displayed + onView(withId(R.id.map3d_view)) + .check(matches(isDisplayed())) +} +``` + + + + diff --git a/.gemini/skills/android-maps3d-sdk/references/documentation.md b/.gemini/skills/android-maps3d-sdk/references/documentation.md new file mode 100644 index 0000000..b63739d --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/references/documentation.md @@ -0,0 +1,35 @@ +# Google Maps 3D SDK Documentation & Resources + +This file guides you to external and internal resources for the Google Maps 3D SDK. Because this SDK is new, these resources are your primary source of truth. + +## External Documentation +* **Official Reference**: [Google Maps Platform 3D SDK for Android](https://developers.google.com/maps/documentation/android-3d-sdk) +* **API Reference**: Refer to the Javadoc/KDoc links provided on the developer site for specific class signatures. + +## Resources & Samples + +Depending on whether you are working inside the `android-maps3d-samples` repository or in a standalone project, use the appropriate source below. + +### 1. Local Workspace (If working inside the sample repo) +If you are working directly in the `android-maps3d-samples` repository, use these local directories: +* **Snippets**: `./snippets/` +* **Sample Apps**: `./Maps3DSamples/` (Contains advanced usage and Compose integration examples). + + +### 2. GitHub Repository (If working in a separate project) +If you are working in a new or separate project, you can read the reference implementations directly from GitHub using your tools (e.g., `read_url_content`): +* **Main Repository**: `https://github.com/googlemaps-samples/android-maps3d-samples` +* **Kotlin Snippets**: `https://github.com/googlemaps-samples/android-maps3d-samples/tree/main/snippets/kotlin-app` +* **Java Snippets**: `https://github.com/googlemaps-samples/android-maps3d-samples/tree/main/snippets/java-app` +* **Advanced Samples**: `https://github.com/googlemaps-samples/android-maps3d-samples/tree/main/Maps3DSamples` (Excellent reference for Jetpack Compose integration). + + + +## Search Strategies for Agents +When asked to implement a feature, do not assume the API. Use your search tools to find examples in the local workspace or repository first. + +### Example Searches: +* To find how to add a Marker: Search for `"Marker"` in `snippets/` or `Maps3DSamples/`. +* To find camera animation examples: Search for `"animateCamera"` or `"FlyTo"`. +* To find polyline usage: Search for `"Polyline"`. + diff --git a/.gemini/skills/android-maps3d-sdk/references/scenarios.md b/.gemini/skills/android-maps3d-sdk/references/scenarios.md new file mode 100644 index 0000000..6401dae --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/references/scenarios.md @@ -0,0 +1,242 @@ +# Scenario Storyboard Catalog + +This catalog provides "orchestration recipes" for complex, multi-step workflows in the 3D SDK. Instead of atomic snippets, these scenarios demonstrate how to synchronize asynchronous events (camera motion, asset loading, rendering steady state) to create high-fidelity user experiences. + +--- + +## Scenario 1: The Immersive Arrival +Description: Transitioning from a high-altitude "Global View" to a specific building with high-detail 3D assets. This pattern handles the race condition between camera movement and mesh loading. + +### The Strategy (Orchestration Logic) +1. **Travel**: Execute a cinematic `flyTo` from the current camera to the target. +2. **Synchronization**: Use the **Double-Wait Pattern**. Wait for the camera animation to end and for the map steady-state (ensures the building mesh is loaded before placing assets). +3. **Enhancement**: Add a high-detail 3D model (glTF) at the location. +4. **Context**: Show a Popover with metadata. +5. **Engagement**: Start a slow `flyAround` to provide a 360-degree context. + +### The Storyboard Code (Kotlin + Coroutines) + +```kotlin +/** + * SCENARIO: The Immersive Arrival + * Orchestrates a high-to-low altitude transition with asset loading. + * + * Note: Assumes extensions like `awaitFlyTo` and `awaitMapSteady` are available + * (see catalogs for implementation). + */ +suspend fun GoogleMap3D.executeImmersiveArrival( + targetLocation: LatLngAltitude, + modelUrl: String, + title: String, + context: Context +) { + // 1. Travel: High-speed cinematic flight + val targetCamera = Camera.builder() + .center(targetLocation) + .range(500.0) // Close zoom + .tilt(45.0) + .heading(0.0) + .build() + + // Custom awaitFlyTo extension ensures we don't proceed until we arrive + awaitFlyTo(FlyToOptions.builder() + .endCamera(targetCamera) + .durationInMillis(4000) + .build()) + + // 2. Synchronization: Wait for the 3D mesh (buildings/terrain) to settle + // This prevents the "popping" of 3D models into empty space + awaitMapSteady(timeout = 5000) + + // 3. Enhancement: Place the hero asset + val model = addModel(ModelOptions.builder() + .position(targetLocation) + .url(modelUrl) + .altitudeMode(AltitudeMode.RELATIVE_TO_MESH) + .build()) + + // 4. Context: Show the info UI + val textView = TextView(context).apply { text = title } + val popover = addPopover(PopoverOptions.builder() + .positionAnchor(LatLngAltitude(targetLocation.latitude, targetLocation.longitude, targetLocation.altitude + 20.0)) + .content(textView) + .build()) + popover.show() + + // 5. Engagement: Subtle rotation to show off the 3D space + flyAround(FlyAroundOptions.builder() + .center(camera) // Rotate around current view + .durationInMillis(20000) + .rounds(0.5) + .build()) +} +``` + +--- + +## Scenario 2: The Flyover Tour +Description: A guided tour that visits multiple points of interest sequentially, waiting for the scene to settle at each stop before proceeding. + +### The Strategy +1. **Fly to Stop**: Move to the first POI. +2. **Wait**: Wait for camera and mesh to settle. +3. **Pause/Inspect**: Hold the view for a few seconds or trigger an action (e.g., show a marker). +4. **Repeat**: Move to the next POI. + +### The Storyboard Code (Kotlin + Coroutines) + +```kotlin +suspend fun GoogleMap3D.executeFlyoverTour( + stops: List, + onStopVisited: (Int) -> Unit +) { + stops.forEachIndexed { index, stop -> + // Fly to stop + val camera = Camera.builder().center(stop).range(1000.0).tilt(30.0).build() + awaitFlyTo(FlyToOptions.builder().endCamera(camera).durationInMillis(3000).build()) + + // Wait for mesh + awaitMapSteady(timeout = 5000) + + // Trigger callback (e.g., show UI or highlight asset) + onStopVisited(index) + + // Hold for inspection + delay(2000) + } +} +``` + +--- + +## Scenario 3: The Fragment Interop Overlay (Places UI Kit) +Description: Hosting a traditional Android Fragment (like the Places UI Kit `PlaceDetailsCompactFragment`) inside a Compose UI anchored to map events. This solves the problem of rapid recomposition causing fragment recreation. + +### The Strategy +1. **Single Instantiation**: Create the `FragmentContainerView` and transact the Fragment exactly ONCE in the `factory` block of `AndroidView`. +2. **Decoupled Updates**: Use `LaunchedEffect` keyed on the state (e.g., `placeId`) to update the existing fragment, avoiding full recreation. +3. **Activity Support**: Use the Activity's `supportFragmentManager` directly to avoid Hilt context casting issues (cast `LocalContext.current` to `FragmentActivity`). + +### The Storyboard Code (Kotlin + Compose) + +```kotlin +@Composable +fun PlaceDetailsOverlay( + placeId: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val containerId = remember { View.generateViewId() } + val context = LocalContext.current + val supportFragmentManager = remember(context) { + (context as FragmentActivity).supportFragmentManager + } + + // Decoupled state observer: Updates existing fragment when placeId changes + LaunchedEffect(placeId) { + val fragment = supportFragmentManager.findFragmentById(containerId) as? PlaceDetailsCompactFragment + if (fragment != null) { + fragment.loadWithPlaceId(placeId) + } + } + + Box(modifier = modifier) { + AndroidView( + factory = { ctx -> + FragmentContainerView(ctx).apply { + id = containerId + + val newFragment = PlaceDetailsCompactFragment.newInstance( + PlaceDetailsCompactFragment.ALL_CONTENT, + Orientation.VERTICAL, + R.style.CustomizedPlaceDetailsTheme + ) + + supportFragmentManager.commit { + replace(containerId, newFragment) + } + + post { newFragment.loadWithPlaceId(placeId) } + } + }, + modifier = Modifier.fillMaxWidth() + ) + } + + // Clean up fragment when leaving composition + DisposableEffect(containerId) { + onDispose { + supportFragmentManager.findFragmentById(containerId)?.let { + supportFragmentManager.commit { remove(it) } + } + } + } +} +``` + +--- + +## Scenario 4: Continuous Route Tracking (Drone View) +Description: Simulating a smooth, frame-driven flight along a complex polyline route, updating camera and markers on each frame. This avoids the jerky movement of waypoint jumping. + +### The Strategy +1. **Headless Engine**: Use a `LaunchedEffect` with `withFrameMillis` to run a continuous physics loop. +2. **Interpolation**: Calculate the precise position along the route based on elapsed distance. +3. **Smooth Camera**: Use linear interpolation (lerp) and spherical linear interpolation (slerp) for camera position and heading to prevent jitter. +4. **Object Mutation**: Directly mutate existing object properties (e.g., `m.orientation = ...`) rather than recreating objects to maintain 60FPS. + +### The Storyboard Code (Kotlin + Compose) + +```kotlin +@Composable +fun RouteFlightEngine( + map3D: GoogleMap3D?, + path: List, + isPlaying: Boolean, + speedMps: Float +) { + val safeMap = map3D ?: return + val cumulativeDistances = remember(path) { calculateCumulativeDistances(path) } + val totalDistance = cumulativeDistances.last() + + var elapsedDistance by remember { mutableFloatStateOf(0f) } + var lastFrameTime by remember { mutableLongStateOf(0L) } + + LaunchedEffect(isPlaying, path) { + if (!isPlaying) return@LaunchedEffect + + while (isPlaying) { + withFrameMillis { frameTime -> + if (lastFrameTime == 0L) { + lastFrameTime = frameTime + return@withFrameMillis + } + val dtMs = frameTime - lastFrameTime + lastFrameTime = frameTime + + // Advance distance + elapsedDistance += (speedMps * (dtMs / 1000.0)).toFloat() + if (elapsedDistance >= totalDistance) elapsedDistance = totalDistance.toFloat() + + // Calculate interpolated position + val targetPos = getInterpolatedPoint(elapsedDistance.toDouble(), path, cumulativeDistances) + + // Update camera smoothly + val currentCamera = safeMap.camera + val newCamera = Camera.builder() + .center(targetPos) + .heading(currentCamera.heading) // Or calculate lookahead heading + .tilt(currentCamera.tilt) + .range(currentCamera.range) + .build() + + safeMap.setCamera(newCamera) + } + } + } +} +``` +> [!NOTE] +> This is a simplified extraction. Refer to the full sample in `Maps3DSamples/advanced/app/src/main/java/com/example/advancedmaps3dsamples/route/RouteSampleActivity.kt` for advanced details on `slerpHeading`, lookahead vectors, and the "Teleport to Null Island" pattern for hiding inactive models. + + diff --git a/.gemini/skills/android-maps3d-sdk/references/secrets_enforcement.md b/.gemini/skills/android-maps3d-sdk/references/secrets_enforcement.md new file mode 100644 index 0000000..5e2d077 --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/references/secrets_enforcement.md @@ -0,0 +1,94 @@ +# Secrets Enforcement (Gradle) + +To prevent accidental exposure of API keys, add this Gradle task to your project's root `build.gradle.kts` or module-level `build.gradle.kts`. It checks if `secrets.properties` is tracked by Git and fails the build if it is. + +## Kotlin DSL (`build.gradle.kts`) + +```kotlin +tasks.register("checkSecretsExposure") { + doLast { + // 1. Check if secrets.properties is tracked by Git + val secretsFile = file("secrets.properties") + if (secretsFile.exists()) { + val process = ProcessBuilder("git", "ls-files", "--error-unmatch", "secrets.properties") + .directory(project.rootDir) + .start() + val exitCode = process.waitFor() + + // If git ls-files finds the file, it returns 0 + if (exitCode == 0) { + throw GradleException( + "SECURITY ALERT: 'secrets.properties' is tracked by Git! " + + "Remove it from version control immediately using 'git rm --cached secrets.properties'." + ) + } + } + + // 2. Check if local.defaults.properties contains a real-looking key + val defaultsFile = file("local.defaults.properties") + if (defaultsFile.exists()) { + val props = java.util.Properties() + defaultsFile.inputStream().use { props.load(it) } + + props.forEach { key, value -> + val valueStr = value.toString() + // Simple heuristic: if it's long and doesn't look like a placeholder + if (valueStr.length > 20 && !valueStr.contains("YOUR_API_KEY") && !valueStr.contains("PLACEHOLDER")) { + throw GradleException( + "SECURITY ALERT: Potential real API key found in 'local.defaults.properties' for key '$key'. " + + "Use placeholders in this file and put real keys in 'secrets.properties'." + ) + } + } + } + } +} + +// Run this check before building +tasks.named("preBuild") { + dependsOn("checkSecretsExposure") +} +``` + +## Groovy DSL (`build.gradle`) + +```groovy +task checkSecretsExposure { + doLast { + // 1. Check if secrets.properties is tracked by Git + def secretsFile = file("secrets.properties") + if (secretsFile.exists()) { + def process = new ProcessBuilder("git", "ls-files", "--error-unmatch", "secrets.properties") + .directory(project.rootDir) + .start() + def exitCode = process.waitFor() + + if (exitCode == 0) { + throw new GradleException( + "SECURITY ALERT: 'secrets.properties' is tracked by Git! " + + "Remove it from version control immediately using 'git rm --cached secrets.properties'." + ) + } + } + + // 2. Check if local.defaults.properties contains a real-looking key + def defaultsFile = file("local.defaults.properties") + if (defaultsFile.exists()) { + def props = new Properties() + defaultsFile.withInputStream { props.load(it) } + + props.each { key, value -> + def valueStr = value.toString() + if (valueStr.length() > 20 && !valueStr.contains("YOUR_API_KEY") && !valueStr.contains("PLACEHOLDER")) { + throw new GradleException( + "SECURITY ALERT: Potential real API key found in 'local.defaults.properties' for key '$key'. " + + "Use placeholders in this file and put real keys in 'secrets.properties'." + ) + } + } + } + } +} + +preBuild.dependsOn checkSecretsExposure +``` diff --git a/.gemini/skills/android-maps3d-sdk/references/units_java.md b/.gemini/skills/android-maps3d-sdk/references/units_java.md new file mode 100644 index 0000000..1a1c841 --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/references/units_java.md @@ -0,0 +1,121 @@ +# Unit Conversions (Java) + +These utilities provide type-safe measurements in meters and conversions to other units (feet, miles, kilometers), which are useful for displaying distances in 3D map applications. + +## Meters Class + +A class to wrap a value representing a measurement in meters. + +```java +public class Meters implements Comparable { + public static final double METERS_PER_FOOT = 3.28084; + public static final double METERS_PER_KILOMETER = 1000; + public static final double FEET_PER_METER = 1 / METERS_PER_FOOT; + public static final double FEET_PER_MILE = 5280; + public static final double MILES_PER_METER = 0.000621371; + + private final double value; + + public Meters(double value) { + this.value = value; + } + + public double getValue() { + return value; + } + + @Override + public int compareTo(Meters other) { + return Double.compare(this.value, other.value); + } + + public Meters minus(Meters other) { + return new Meters(this.value - other.value); + } + + public Meters plus(Meters other) { + return new Meters(this.value + other.value); + } + + // Factory methods + public static Meters fromMeters(double value) { + return new Meters(value); + } + + public static Meters fromKilometers(double km) { + return new Meters(km * METERS_PER_KILOMETER); + } + + public static Meters fromFeet(double feet) { + return new Meters(feet * FEET_PER_METER); + } + + public static Meters fromMiles(double miles) { + return new Meters(miles / MILES_PER_METER); + } + + // Conversions + public double toFeet() { + return value * METERS_PER_FOOT; + } + + public double toKilometers() { + return value / METERS_PER_KILOMETER; + } + + public double toMiles() { + return value * MILES_PER_METER; + } +} +``` + +## Units Converter (Optional) + +```java +public class ValueWithUnits { + public final double value; + public final String unitLabel; + + public ValueWithUnits(double value, String unitLabel) { + this.value = value; + this.unitLabel = unitLabel; + } +} + +public interface UnitsConverter { + ValueWithUnits toDistanceUnits(Meters meters); + ValueWithUnits toElevationUnits(Meters meters); +} + +class ImperialUnitsConverter implements UnitsConverter { + @Override + public ValueWithUnits toDistanceUnits(Meters meters) { + if (meters.getValue() < Meters.fromMiles(0.25).getValue()) { + return new ValueWithUnits(meters.toFeet(), "ft"); + } else { + return new ValueWithUnits(meters.toMiles(), "mi"); + } + } + + @Override + public ValueWithUnits toElevationUnits(Meters meters) { + return new ValueWithUnits(meters.toFeet(), "ft"); + } +} + +class MetricUnitsConverter implements UnitsConverter { + @Override + public ValueWithUnits toDistanceUnits(Meters meters) { + if (meters.getValue() < 1000) { + return new ValueWithUnits(meters.getValue(), "m"); + } else { + return new ValueWithUnits(meters.toKilometers(), "km"); + } + } + + @Override + public ValueWithUnits toElevationUnits(Meters meters) { + return new ValueWithUnits(meters.getValue(), "m"); + } +} +``` diff --git a/.gemini/skills/android-maps3d-sdk/references/units_kotlin.md b/.gemini/skills/android-maps3d-sdk/references/units_kotlin.md new file mode 100644 index 0000000..12581b0 --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/references/units_kotlin.md @@ -0,0 +1,100 @@ +# Unit Conversions (Kotlin) + +These utilities provide type-safe measurements in meters and conversions to other units (feet, miles, kilometers), which are useful for displaying distances in 3D map applications. + +## Meters Value Class + +A value class to wrap a value representing a measurement in meters, preventing accidental mixing of units. + +```kotlin +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 + +const val METERS_PER_FOOT = 3.28084 +const val METERS_PER_KILOMETER = 1000 +const val FEET_PER_METER = 1 / METERS_PER_FOOT +const val FEET_PER_MILE = 5280 +const val MILES_PER_METER = 0.000621371 + +@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) +} + +@Stable +inline val Number.meters: Meters get() = Meters(value = this.toDouble()) + +@Stable +inline val Number.m: Meters get() = Meters(value = this.toDouble()) + +@Stable +inline val Number.km: Meters get() = Meters(value = this.toDouble() * METERS_PER_KILOMETER) + +@Stable +inline val Number.feet: Meters get() = Meters(value = this.toDouble() * FEET_PER_METER) + +@Stable +inline val Number.miles: Meters get() = Meters(value = this.toDouble() / MILES_PER_METER) + +@Stable +inline val Meters.toFeet: Double get() = value * METERS_PER_FOOT + +@Stable +inline val Meters.toMeters: Double get() = value + +@Stable +inline val Meters.toKilometers: Double get() = value / METERS_PER_KILOMETER + +@Stable +inline val Meters.toMiles: Double get() = (value * MILES_PER_METER) + +@Stable +fun Meters.plus(other: Meters) = Meters(value = this.value + other.value) +``` + +## Units Converter (Optional) + +If you need to display localized strings, you can use a pattern like this (requires defining corresponding string resources in your project): + +```kotlin +data class ValueWithUnitsTemplate(val value: Double, val unitLabel: String) + +abstract class UnitsConverter { + abstract fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate + abstract fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate +} + +object ImperialUnitsConverter : UnitsConverter() { + override fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate { + return if (meters < 0.25.miles) { + ValueWithUnitsTemplate(meters.toFeet, "ft") + } else { + ValueWithUnitsTemplate(meters.toMiles, "mi") + } + } + + override fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate { + return ValueWithUnitsTemplate(meters.toFeet, "ft") + } +} + +object MetricUnitsConverter : UnitsConverter() { + override fun toDistanceUnits(meters: Meters): ValueWithUnitsTemplate { + return if (meters < 1000.meters) { + ValueWithUnitsTemplate(meters.toMeters, "m") + } else { + ValueWithUnitsTemplate(meters.toKilometers, "km") + } + } + + override fun toElevationUnits(meters: Meters): ValueWithUnitsTemplate { + return ValueWithUnitsTemplate(meters.toMeters, "m") + } +} +``` diff --git a/.gemini/skills/android-maps3d-sdk/references/utilities_java.md b/.gemini/skills/android-maps3d-sdk/references/utilities_java.md new file mode 100644 index 0000000..a37be4c --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/references/utilities_java.md @@ -0,0 +1,243 @@ +# Camera Utilities (Java) + +These utility functions help ensure that camera parameters are within acceptable ranges for the Maps 3D SDK, preventing crashes due to invalid values. + +## Camera Validation + +Use these static methods to sanitize camera parameters before applying them to the map. + +```java +import com.google.android.gms.maps3d.model.Camera; +import com.google.android.gms.maps3d.model.LatLngAltitude; + +public class CameraUtils { + + public static final double DEFAULT_HEADING = 0.0; + public static final double DEFAULT_TILT = 60.0; + public static final double DEFAULT_RANGE = 1500.0; + public static final double DEFAULT_ROLL = 0.0; + + /** + * Validates a LatLngAltitude object, clamping values to valid ranges. + */ + public static LatLngAltitude toValidLocation(LatLngAltitude location) { + if (location == null) { + return new LatLngAltitude(0, 0, 0); + } + + double lat = Math.max(-90.0, Math.min(90.0, location.getLatitude())); + double lng = Math.max(-180.0, Math.min(180.0, location.getLongitude())); + double alt = Math.max(0.0, Math.min(LatLngAltitude.MAX_ALTITUDE_METERS, location.getAltitude())); + + return new LatLngAltitude(lat, lng, alt); + } + + /** + * Validates heading, wrapping values to [0, 360). + */ + public static double toHeading(Double heading) { + if (heading == null) return DEFAULT_HEADING; + return wrapIn(heading, 0.0, 360.0); + } + + /** + * Validates tilt, clamping values to [0, 90]. + */ + public static double toTilt(Double tilt) { + if (tilt == null) return DEFAULT_TILT; + return Math.max(0.0, Math.min(90.0, tilt)); + } + + /** + * Validates roll, wrapping values to [-360, 360]. + */ + public static double toRoll(Double roll) { + if (roll == null) return DEFAULT_ROLL; + return wrapIn(roll, -360.0, 360.0); + } + + /** + * Validates range, clamping values to [0, 63170000]. + */ + public static double toRange(Double range) { + if (range == null) return DEFAULT_RANGE; + return Math.max(0.0, Math.min(63170000.0, range)); + } + + /** + * Helper to wrap values within a range [lower, upper). + */ + public static double wrapIn(double value, double lower, double upper) { + double range = upper - lower; + if (range <= 0) { + throw new IllegalArgumentException("Upper bound must be greater than lower bound"); + } + double offset = value - lower; + return lower + (offset - Math.floor(offset / range) * range); + } +} +``` + +### Usage Example + +When updating the camera, use these methods to ensure values are valid: + +```java +LatLngAltitude validCenter = CameraUtils.toValidLocation(currentCenter); +double validHeading = CameraUtils.toHeading(currentHeading); +double validTilt = CameraUtils.toTilt(currentTilt); + +// Rebuild your camera object using these valid values... +``` + +## Path and Animation Utilities + +These utilities help with path smoothing, simplification, heading calculation, and distance calculations. + +```java +import com.google.android.gms.maps.model.LatLng; +import java.util.ArrayList; +import java.util.List; + +public class PathUtils { + + /** + * Smooths a path of LatLng points using Chaikin's algorithm. + */ + public static List smoothPath(List path, int iterations) { + if (path.size() < 3 || iterations <= 0) return path; + + List currentPath = path; + for (int iter = 0; iter < iterations; iter++) { + List nextPath = new ArrayList<>(); + nextPath.add(currentPath.get(0)); + + for (int i = 0; i < currentPath.size() - 1; i++) { + LatLng p0 = currentPath.get(i); + LatLng p1 = currentPath.get(i + 1); + + LatLng q = new LatLng( + p0.latitude * 0.75 + p1.latitude * 0.25, + p0.longitude * 0.75 + p1.longitude * 0.25 + ); + + LatLng r = new LatLng( + p0.latitude * 0.25 + p1.latitude * 0.75, + p0.longitude * 0.25 + p1.longitude * 0.75 + ); + + nextPath.add(q); + nextPath.add(r); + } + + nextPath.add(currentPath.get(currentPath.size() - 1)); + currentPath = nextPath; + } + + return currentPath; + } + + /** + * Calculates the heading (bearing) from one LatLng to another. + */ + public static double calculateHeading(LatLng from, LatLng to) { + double lat1 = Math.toRadians(from.latitude); + double lon1 = Math.toRadians(from.longitude); + double lat2 = Math.toRadians(to.latitude); + double lon2 = Math.toRadians(to.longitude); + + double dLon = lon2 - lon1; + double y = Math.sin(dLon) * Math.cos(lat2); + double x = Math.cos(lat1) * Math.sin(lat2) - + Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon); + + double bearing = Math.toDegrees(Math.atan2(y, x)); + return (bearing + 360.0) % 360.0; + } + + /** + * Simplifies a path of LatLng points using the Ramer-Douglas-Peucker algorithm. + */ + public static List simplifyPath(List path, double epsilon) { + if (path.size() < 3) return path; + + double maxDistance = 0.0; + int index = 0; + LatLng first = path.get(0); + LatLng last = path.get(path.size() - 1); + + for (int i = 1; i < path.size() - 1; i++) { + double distance = perpendicularDistance(path.get(i), first, last); + if (distance > maxDistance) { + index = i; + maxDistance = distance; + } + } + + if (maxDistance > epsilon) { + List left = simplifyPath(path.subList(0, index + 1), epsilon); + List right = simplifyPath(path.subList(index, path.size()), epsilon); + + List result = new ArrayList<>(left.subList(0, left.size() - 1)); + result.addAll(right); + return result; + } else { + List result = new ArrayList<>(); + result.add(first); + result.add(last); + return result; + } + } + + private static double perpendicularDistance(LatLng point, LatLng start, LatLng end) { + double x = point.longitude; + double y = point.latitude; + double x1 = start.longitude; + double y1 = start.latitude; + double x2 = end.longitude; + double y2 = end.latitude; + + double area = Math.abs((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1); + double bottom = Math.sqrt(Math.pow(y2 - y1, 2.0) + Math.pow(x2 - x1, 2.0)); + return area / bottom; + } + + /** + * Calculates the distance in meters between two [LatLng] points using the Haversine formula. + */ + public static double haversineDistance(LatLng p1, LatLng p2) { + double r = 6371000.0; // Earth radius in meters + double lat1 = Math.toRadians(p1.latitude); + double lon1 = Math.toRadians(p1.longitude); + double lat2 = Math.toRadians(p2.latitude); + double lon2 = Math.toRadians(p2.longitude); + + double dLat = lat2 - lat1; + double dLon = lon2 - lon1; + + double a = Math.pow(Math.sin(dLat / 2), 2.0) + + Math.cos(lat1) * Math.cos(lat2) * Math.pow(Math.sin(dLon / 2), 2.0); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return r * c; + } + + /** + * Standardized "Double-Wait" utility for Java. + * Returns a CompletableFuture that completes when the map becomes steady. + * Use this after starting a camera animation to ensure the scene is fully loaded. + */ + public static java.util.concurrent.CompletableFuture awaitArrivedAndSteady(com.google.android.gms.maps3d.GoogleMap3D map) { + java.util.concurrent.CompletableFuture future = new java.util.concurrent.CompletableFuture<>(); + map.setOnMapSteadyListener(isSteady -> { + if (isSteady) { + map.setOnMapSteadyListener(null); + future.complete(true); + } + }); + return future; + } +} + +``` + diff --git a/.gemini/skills/android-maps3d-sdk/references/utilities_kotlin.md b/.gemini/skills/android-maps3d-sdk/references/utilities_kotlin.md new file mode 100644 index 0000000..ebd3cb0 --- /dev/null +++ b/.gemini/skills/android-maps3d-sdk/references/utilities_kotlin.md @@ -0,0 +1,264 @@ +# Camera Utilities (Kotlin) + +These utility functions help ensure that camera parameters are within acceptable ranges for the Maps 3D SDK, preventing crashes due to invalid values. + +## Camera Validation + +Use `toValidCamera()` to sanitize a `Camera` object before applying it to the map. + +```kotlin +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.LatLngAltitude +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import kotlin.math.floor + +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 + +val ORIGIN = latLngAltitude { + latitude = 0.0 + longitude = 0.0 + altitude = 0.0 +} + +val DEFAULT_CAMERA: Camera = camera { + center = ORIGIN + heading = 0.0 + tilt = 0.0 + roll = 0.0 + range = 1000.0 +} + +/** + * Converts a nullable Camera object into a valid, non-null Camera object. + */ +fun Camera?.toValidCamera(): Camera { + val source = this ?: return DEFAULT_CAMERA + + return camera { + center = source.center.toValidLocation() + heading = source.heading.toHeading() + tilt = source.tilt.toTilt() + roll = source.roll.toRoll() + range = source.range.toRange() + } +} + +fun LatLngAltitude.toValidLocation(): LatLngAltitude { + val objectToCopy = this + return latLngAltitude { + latitude = objectToCopy.latitude.coerceIn(latitudeRange) + longitude = objectToCopy.longitude.coerceIn(longitudeRange) + altitude = objectToCopy.altitude.coerceIn(altitudeRange) + } +} + +fun Number?.toHeading(): Double = + this?.toDouble()?.wrapIn(headingRange.start, headingRange.endInclusive) ?: 0.0 + +fun Number?.toTilt(): Double = this?.toDouble()?.coerceIn(tiltRange) ?: 0.0 + +fun Number?.toRoll(): Double = this?.toDouble()?.wrapIn(rollRange) ?: 0.0 + +fun Number?.toRange(): Double = this?.toDouble()?.coerceIn(rangeRange) ?: 0.0 + +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) +} +``` + +## Path and Animation Utilities + +These utilities help with path smoothing, simplification, heading calculation, and distance calculations. + +```kotlin +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps3d.model.FlyAroundOptions +import com.google.android.gms.maps3d.model.FlyToOptions +import com.google.android.gms.maps3d.model.flyAroundOptions +import com.google.android.gms.maps3d.model.flyToOptions +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt + +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 + } +} + +/** + * Smooths a path of LatLng points using Chaikin's algorithm. + */ +fun List.smoothPath(iterations: Int = 1): List { + if (size < 3 || iterations <= 0) return this + + var currentPath = this + repeat(iterations) { + val nextPath = mutableListOf() + nextPath.add(currentPath.first()) + + for (i in 0 until currentPath.size - 1) { + val p0 = currentPath[i] + val p1 = currentPath[i + 1] + + val q = LatLng( + p0.latitude * 0.75 + p1.latitude * 0.25, + p0.longitude * 0.75 + p1.longitude * 0.25 + ) + + 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) + } + + nextPath.add(currentPath.last()) + currentPath = nextPath + } + + return currentPath +} + +/** + * Calculates the heading (bearing) from one LatLng to another. + */ +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. + */ +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) + } +} + +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 +} +``` + +## Synchronization Utilities + +### Double-Wait Utility + +Use `awaitArrivedAndSteady()` to ensure the camera has arrived and the 3D scene has fully rendered before proceeding. + +```kotlin +import com.google.android.gms.maps3d.GoogleMap3D +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +/** + * Standardized "Double-Wait" utility. + * Waits for the map to become steady (rendering complete and camera idle). + * Use this after starting a camera animation to ensure the scene is fully loaded. + */ +suspend fun GoogleMap3D.awaitArrivedAndSteady(timeoutMs: Long = 5000): Boolean = suspendCancellableCoroutine { cont -> + setOnMapSteadyListener { isSteady -> + if (isSteady) { + setOnMapSteadyListener(null) + cont.resume(true) + } + } +} +``` + diff --git a/.gitignore b/.gitignore index 62c6c00..70ff3fc 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ google-services.json # VS Code .vscode/ snippets/docs/ + + +