From 9268588dc10f562c7c0fc1d1357660eeafeabdb4 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:06:27 -0600 Subject: [PATCH 1/4] feat(projection): introduce rudimentary Projection3D utility and 2D overlay samples --- .../com/example/maps3d/common/Projection3D.kt | 316 ++++++++++++++++++ .../src/main/res/drawable/reticle_dot.xml | 26 ++ .../layout/control_panel_projection_3d.xml | 244 ++++++++++++++ .../common/src/main/res/values/strings.xml | 16 + .../example/maps3d/common/Projection3DTest.kt | 189 +++++++++++ .../kotlin-app/src/main/AndroidManifest.xml | 7 + .../maps3dkotlin/mainactivity/MainActivity.kt | 2 + .../projection3d/Projection3DActivity.kt | 270 +++++++++++++++ .../src/main/AndroidManifest.xml | 4 + .../example/maps3dcomposedemo/MainActivity.kt | 5 + .../maps3dcomposedemo/Projection3DActivity.kt | 311 +++++++++++++++++ .../android/compose3d/utils/Projection3D.kt | 307 +++++++++++++++++ 12 files changed, 1697 insertions(+) create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Projection3D.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/res/drawable/reticle_dot.xml create mode 100644 Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_projection_3d.xml create mode 100644 Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/Projection3DTest.kt create mode 100644 Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/projection3d/Projection3DActivity.kt create mode 100644 maps3d-compose-demo/src/main/java/com/example/maps3dcomposedemo/Projection3DActivity.kt create mode 100644 maps3d-compose/src/main/java/com/google/maps/android/compose3d/utils/Projection3D.kt diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Projection3D.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Projection3D.kt new file mode 100644 index 00000000..8772d265 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Projection3D.kt @@ -0,0 +1,316 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import android.graphics.Point +import android.graphics.PointF +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.LatLngAltitude +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.maps.android.SphericalUtil +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.math.tan + +/** + * Encapsulates a projected 2D screen coordinate and its view frustum visibility state. + * + * @property x The horizontal pixel position on screen (0.0 = left edge, [viewportWidth] = right edge). + * @property y The vertical pixel position on screen (0.0 = top edge, [viewportHeight] = bottom edge). + * @property isVisible True if the point is in front of the camera and within the visible view frustum. + * @property depth Distance in meters along the camera line of sight (depth > 0 means in front of camera). + */ +data class ScreenCoordinate( + val x: Float, + val y: Float, + val isVisible: Boolean, + val depth: Double +) { + /** Converts the projected position to an integer [Point] for standard Android View layout positioning. */ + fun toPoint(): Point = Point(x.toInt(), y.toInt()) + + /** Converts the projected position to a high-precision [PointF] for subpixel positioning. */ + fun toPointF(): PointF = PointF(x, y) +} + +/** + * Rudimentary 3D Perspective Projection Engine for the Google Maps 3D SDK. + * + * Provides coordinate transformations between 3D world coordinates ([LatLngAltitude]) and + * 2D screen viewport pixel space ([ScreenCoordinate] / [PointF]). + * + * ### Mathematical Mechanics & Literate Formulation + * + * In the Google Maps 3D SDK, the camera pose is defined by its focal center $\mathbf{P}_0 = (\text{lat}_0, \text{lng}_0, \text{alt}_0)$, + * viewing heading $H$, vertical tilt $\theta$, roll $\phi$, and observation range $R$: + * + * 1. **Camera Eye Vantage Point**: + * In local East-North-Up (ENU) tangent space centered at $\mathbf{P}_0$, the physical camera eye is located: + * - $D = R \cdot \sin(\theta)$ (Horizontal Ground Distance) + * - $\Delta Z = R \cdot \cos(\theta)$ (Vertical Height above target center) + * - $\mathbf{E}_{\text{eye}} = -D \cdot \sin(H)$ + * - $\mathbf{N}_{\text{eye}} = -D \cdot \cos(H)$ + * - $\mathbf{U}_{\text{eye}} = \Delta Z$ + * + * 2. **Camera Orthonormal Basis**: + * - Forward vector: $\mathbf{f} = (\sin\theta \sin H, \; \sin\theta \cos H, \; -\cos\theta)$ + * - Right vector: $\mathbf{r} = (\cos H, \; -\sin H, \; 0)$ (rotated by roll $\phi$) + * - Up vector: $\mathbf{u} = \mathbf{r} \times \mathbf{f}$ (rotated by roll $\phi$) + * + * 3. **Perspective Projection Matrix**: + * Transforms a world point $\mathbf{P} \to \mathbf{v} = \mathbf{P} - \mathbf{E}_{\text{eye}}$, + * projects onto camera axes $(X_{\text{cam}}, Y_{\text{cam}}, Z_{\text{cam}})$, and calculates + * Normalized Device Coordinates (NDC) scaled by viewport aspect ratio and Field of View (FOV). + * + * @param camera The active [Camera] pose from the 3D map. + * @param viewportWidth Width of the 3D map viewport in pixels. + * @param viewportHeight Height of the 3D map viewport in pixels. + * @param fovYDegrees Vertical field of view in degrees (defaults to standard 45.0° baseline). + */ +class Projection3D( + val camera: Camera, + val viewportWidth: Int, + val viewportHeight: Int, + val fovYDegrees: Double = DEFAULT_FOV_Y_DEGREES +) { + private val centerLat: Double = camera.center.latitude + private val centerLng: Double = camera.center.longitude + private val centerAlt: Double = camera.center.altitude + + private val headingDeg: Double = camera.heading ?: 0.0 + private val tiltDeg: Double = camera.tilt ?: 0.0 + private val rollDeg: Double = camera.roll ?: 0.0 + private val rangeMeters: Double = camera.range ?: 1000.0 + + private val tiltRad = Math.toRadians(tiltDeg) + private val headingRad = Math.toRadians(headingDeg) + private val rollRad = Math.toRadians(rollDeg) + + // Eye position in local ENU relative to focal center + private val horizDist = rangeMeters * sin(tiltRad) + private val vertDist = rangeMeters * cos(tiltRad) + + private val eyeEast = -horizDist * sin(headingRad) + private val eyeNorth = -horizDist * cos(headingRad) + private val eyeUp = vertDist + + // Basis vectors + private val fEast = sin(tiltRad) * sin(headingRad) + private val fNorth = sin(tiltRad) * cos(headingRad) + private val fUp = -cos(tiltRad) + + private val rEast: Double + private val rNorth: Double + private val rUp: Double + + private val uEast: Double + private val uNorth: Double + private val uUp: Double + + init { + val rEast0 = cos(headingRad) + val rNorth0 = -sin(headingRad) + val rUp0 = 0.0 + + val uEast0 = sin(headingRad) * cos(tiltRad) + val uNorth0 = cos(headingRad) * cos(tiltRad) + val uUp0 = sin(tiltRad) + + if (abs(rollDeg) > 1e-4) { + val cosRoll = cos(rollRad) + val sinRoll = sin(rollRad) + + rEast = cosRoll * rEast0 + sinRoll * uEast0 + rNorth = cosRoll * rNorth0 + sinRoll * uNorth0 + rUp = cosRoll * rUp0 + sinRoll * uUp0 + + uEast = -sinRoll * rEast0 + cosRoll * uEast0 + uNorth = -sinRoll * rNorth0 + cosRoll * uNorth0 + uUp = -sinRoll * rUp0 + cosRoll * uUp0 + } else { + rEast = rEast0 + rNorth = rNorth0 + rUp = rUp0 + + uEast = uEast0 + uNorth = uNorth0 + uUp = uUp0 + } + } + + /** + * Converts a 3D world coordinate ([LatLngAltitude]) into 2D viewport screen coordinates. + * + * @param point The 3D geographical coordinate to project. + * @return A [ScreenCoordinate] containing pixel coordinates, frustum visibility, and depth. + */ + fun toScreenCoordinate(point: LatLngAltitude): ScreenCoordinate { + if (viewportWidth <= 0 || viewportHeight <= 0) { + return ScreenCoordinate(x = 0f, y = 0f, isVisible = false, depth = 0.0) + } + + val refLatLng = LatLng(centerLat, centerLng) + val targetLatLng = LatLng(point.latitude, point.longitude) + + val dist = SphericalUtil.computeDistanceBetween(refLatLng, targetLatLng) + val bearingRad = if (dist > 1e-4) { + Math.toRadians(SphericalUtil.computeHeading(refLatLng, targetLatLng)) + } else { + 0.0 + } + + val pEast = dist * sin(bearingRad) + val pNorth = dist * cos(bearingRad) + val pUp = point.altitude - centerAlt + + // Vector from camera eye to world point + val vEast = pEast - eyeEast + val vNorth = pNorth - eyeNorth + val vUp = pUp - eyeUp + + // Project onto camera axes + val xCam = vEast * rEast + vNorth * rNorth + vUp * rUp + val yCam = vEast * uEast + vNorth * uNorth + vUp * uUp + val zCam = vEast * fEast + vNorth * fNorth + vUp * fUp + + if (zCam <= 1e-3) { + // Point is behind camera + return ScreenCoordinate(x = Float.NaN, y = Float.NaN, isVisible = false, depth = zCam) + } + + val halfFovYRad = Math.toRadians(fovYDegrees / 2.0) + val tanHalfFovY = tan(halfFovYRad) + val aspectRatio = viewportWidth.toDouble() / viewportHeight.toDouble() + val tanHalfFovX = tanHalfFovY * aspectRatio + + val ndcX = xCam / (zCam * tanHalfFovX) + val ndcY = yCam / (zCam * tanHalfFovY) + + val screenX = ((ndcX + 1.0) * 0.5 * viewportWidth).toFloat() + val screenY = ((1.0 - ndcY) * 0.5 * viewportHeight).toFloat() + + val isVisible = ndcX in -1.0..1.0 && ndcY in -1.0..1.0 + + return ScreenCoordinate( + x = screenX, + y = screenY, + isVisible = isVisible, + depth = zCam + ) + } + + /** + * Projects a 3D world coordinate to integer screen pixels [Point]. + * + * @param point The 3D coordinate to project. + * @return An integer [Point] on screen, or null if the point is behind the camera. + */ + fun toScreenLocation(point: LatLngAltitude): Point? { + val coord = toScreenCoordinate(point) + return if (coord.depth > 0.0) coord.toPoint() else null + } + + /** + * Projects a 3D world coordinate to high-precision screen pixels [PointF]. + * + * @param point The 3D coordinate to project. + * @return A [PointF] on screen, or null if the point is behind the camera. + */ + fun toScreenLocationF(point: LatLngAltitude): PointF? { + val coord = toScreenCoordinate(point) + return if (coord.depth > 0.0) coord.toPointF() else null + } + + /** + * Unprojects a 2D screen coordinate $(X, Y)$ onto a horizontal ground plane at [targetAltitude]. + * + * @param screenX Horizontal pixel position on screen. + * @param screenY Vertical pixel position on screen. + * @param targetAltitude Ground plane altitude in meters above sea level (defaults to 0.0). + * @return The intersected [LatLngAltitude], or null if the cast ray does not intersect the plane. + */ + fun fromScreenLocation(screenX: Float, screenY: Float, targetAltitude: Double = 0.0): LatLngAltitude? { + if (viewportWidth <= 0 || viewportHeight <= 0) return null + + val ndcX = (screenX.toDouble() / viewportWidth.toDouble()) * 2.0 - 1.0 + val ndcY = 1.0 - (screenY.toDouble() / viewportHeight.toDouble()) * 2.0 + + val halfFovYRad = Math.toRadians(fovYDegrees / 2.0) + val tanHalfFovY = tan(halfFovYRad) + val aspectRatio = viewportWidth.toDouble() / viewportHeight.toDouble() + val tanHalfFovX = tanHalfFovY * aspectRatio + + val dirCamX = ndcX * tanHalfFovX + val dirCamY = ndcY * tanHalfFovY + val dirCamZ = 1.0 + + // Transform camera ray direction into ENU space + val dEast = dirCamX * rEast + dirCamY * uEast + dirCamZ * fEast + val dNorth = dirCamX * rNorth + dirCamY * uNorth + dirCamZ * fNorth + val dUp = dirCamX * rUp + dirCamY * uUp + dirCamZ * fUp + + val targetRelUp = targetAltitude - centerAlt + val deltaUp = targetRelUp - eyeUp + + if (abs(dUp) < 1e-6) return null // Ray parallel to ground + val t = deltaUp / dUp + if (t <= 0.0) return null // Intersection behind camera + + val hitEast = eyeEast + t * dEast + val hitNorth = eyeNorth + t * dNorth + + val hitDist = sqrt(hitEast * hitEast + hitNorth * hitNorth) + val hitBearingDeg = (Math.toDegrees(atan2(hitEast, hitNorth)) + 360.0) % 360.0 + + val refLatLng = LatLng(centerLat, centerLng) + val hitLatLng = SphericalUtil.computeOffset(refLatLng, hitDist, hitBearingDeg) + + return latLngAltitude { + latitude = hitLatLng.latitude + longitude = hitLatLng.longitude + altitude = targetAltitude + } + } + + companion object { + /** Baseline vertical field of view angle in degrees. */ + const val DEFAULT_FOV_Y_DEGREES = 45.0 + } +} + +/** + * Creates a [Projection3D] utility configured with this [GoogleMap3D]'s active camera and dimensions. + */ +fun GoogleMap3D.getProjection3D( + viewportWidth: Int, + viewportHeight: Int, + fovYDegrees: Double = Projection3D.DEFAULT_FOV_Y_DEGREES +): Projection3D? { + val currentCamera = getCamera()?.toValidCamera() ?: return null + return Projection3D( + camera = currentCamera, + viewportWidth = viewportWidth, + viewportHeight = viewportHeight, + fovYDegrees = fovYDegrees + ) +} diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/reticle_dot.xml b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/reticle_dot.xml new file mode 100644 index 00000000..4e60b854 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/reticle_dot.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_projection_3d.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_projection_3d.xml new file mode 100644 index 00000000..c2b553ec --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_projection_3d.xml @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml index 138b4de3..0dccc6bb 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml +++ b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml @@ -44,6 +44,7 @@ Cloud Map Styling Roadmap Mode Field Of View + 3D Screen Projection Coming soon! @@ -246,4 +247,19 @@ 3D Model Animation Mode: Synchronized (High CPU) Midpoint Jump (Low CPU) + + + 3D Screen Projection + Target: %1$s + Screen: (%1$d px, %2$d px) + Depth: %1$d m | Target Alt: %2$d m + Frustum: VISIBLE + Frustum: CULLED / OFF-SCREEN + Drag or tilt map to observe real-time projected coordinate tracking + Transamerica + Coit Tower + Ferry Building + Landmark Targets: + (%1$d px, %2$d px) + Screen: (Out of view) diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/Projection3DTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/Projection3DTest.kt new file mode 100644 index 00000000..2a552c25 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/Projection3DTest.kt @@ -0,0 +1,189 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.LatLngAltitude +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests verifying [Projection3D] perspective projection mathematics and coordinate accuracy. + */ +class Projection3DTest { + + private val origin = LatLngAltitude(37.7952, -122.4028, 0.0) // San Francisco + private val width = 1000 + private val height = 1000 + + @Test + fun cameraCenter_projectsToExactScreenCenter() { + val testCamera = camera { + center = origin + heading = 45.0 + tilt = 30.0 + roll = 0.0 + range = 1000.0 + } + + val projection = Projection3D( + camera = testCamera, + viewportWidth = width, + viewportHeight = height, + fovYDegrees = 45.0 + ) + + val screenCoord = projection.toScreenCoordinate(origin) + + assertThat(screenCoord.isVisible).isTrue() + assertThat(screenCoord.depth).isWithin(0.1).of(1000.0) + assertThat(screenCoord.x).isWithin(0.5f).of(500f) + assertThat(screenCoord.y).isWithin(0.5f).of(500f) + + val point = projection.toScreenLocation(origin) + assertThat(point).isNotNull() + } + + @Test + fun pointBehindCamera_isMarkedNotVisible() { + // Camera looking North (heading 0), tilted down at 45 degrees, range 1000m. + // Eye is located South of origin. A point placed far behind the camera eye will have negative depth. + val testCamera = camera { + center = origin + heading = 0.0 + tilt = 45.0 + roll = 0.0 + range = 1000.0 + } + + val projection = Projection3D( + camera = testCamera, + viewportWidth = width, + viewportHeight = height + ) + + // Point far south (behind camera eye) + val farBehindPoint = LatLngAltitude(origin.latitude - 0.1, origin.longitude, 0.0) + val screenCoord = projection.toScreenCoordinate(farBehindPoint) + + assertThat(screenCoord.isVisible).isFalse() + assertThat(screenCoord.depth).isLessThan(0.0) + assertThat(screenCoord.x.isNaN()).isTrue() + assertThat(screenCoord.y.isNaN()).isTrue() + assertThat(projection.toScreenLocation(farBehindPoint)).isNull() + } + + @Test + fun cardinalDirections_projectToCorrectScreenQuadrants() { + // Camera looking straight down (tilt = 0, heading = 0) + val nadirCamera = camera { + center = origin + heading = 0.0 + tilt = 0.0 + roll = 0.0 + range = 1000.0 + } + + val projection = Projection3D( + camera = nadirCamera, + viewportWidth = width, + viewportHeight = height + ) + + // Point East of center -> should project to right half of screen (X > 500) + val eastPoint = LatLngAltitude(origin.latitude, origin.longitude + 0.001, 0.0) + val eastCoord = projection.toScreenCoordinate(eastPoint) + assertThat(eastCoord.isVisible).isTrue() + assertThat(eastCoord.x).isGreaterThan(500f) + assertThat(eastCoord.y).isWithin(1.0f).of(500f) + + // Point West of center -> should project to left half of screen (X < 500) + val westPoint = LatLngAltitude(origin.latitude, origin.longitude - 0.001, 0.0) + val westCoord = projection.toScreenCoordinate(westPoint) + assertThat(westCoord.isVisible).isTrue() + assertThat(westCoord.x).isLessThan(500f) + assertThat(westCoord.y).isWithin(1.0f).of(500f) + + // Point North of center -> should project to top half of screen (Y < 500 in screen pixels) + val northPoint = LatLngAltitude(origin.latitude + 0.001, origin.longitude, 0.0) + val northCoord = projection.toScreenCoordinate(northPoint) + assertThat(northCoord.isVisible).isTrue() + assertThat(northCoord.x).isWithin(1.0f).of(500f) + assertThat(northCoord.y).isLessThan(500f) + + // Point South of center -> should project to bottom half of screen (Y > 500 in screen pixels) + val southPoint = LatLngAltitude(origin.latitude - 0.001, origin.longitude, 0.0) + val southCoord = projection.toScreenCoordinate(southPoint) + assertThat(southCoord.isVisible).isTrue() + assertThat(southCoord.x).isWithin(1.0f).of(500f) + assertThat(southCoord.y).isGreaterThan(500f) + } + + @Test + fun fromScreenLocation_centerPixel_recoversCameraCenter() { + val testCamera = camera { + center = origin + heading = 35.0 + tilt = 45.0 + roll = 0.0 + range = 800.0 + } + + val projection = Projection3D( + camera = testCamera, + viewportWidth = width, + viewportHeight = height + ) + + // Center pixel (500, 500) should unproject back to origin coordinates + val unprojected = projection.fromScreenLocation(500f, 500f, targetAltitude = 0.0) + + assertThat(unprojected).isNotNull() + assertThat(unprojected?.latitude).isWithin(1e-5).of(origin.latitude) + assertThat(unprojected?.longitude).isWithin(1e-5).of(origin.longitude) + assertThat(unprojected?.altitude).isWithin(0.1).of(0.0) + } + + @Test + fun roundTrip_projectAndUnproject_matchesOriginalGroundCoordinates() { + val testCamera = camera { + center = origin + heading = 60.0 + tilt = 30.0 + roll = 0.0 + range = 1200.0 + } + + val projection = Projection3D( + camera = testCamera, + viewportWidth = width, + viewportHeight = height + ) + + // Nearby landmark point on the ground + val testPoint = LatLngAltitude(origin.latitude + 0.001, origin.longitude + 0.001, 0.0) + val screenCoord = projection.toScreenCoordinate(testPoint) + assertThat(screenCoord.isVisible).isTrue() + + val recovered = projection.fromScreenLocation(screenCoord.x, screenCoord.y, targetAltitude = 0.0) + assertThat(recovered).isNotNull() + assertThat(recovered?.latitude).isWithin(1e-5).of(testPoint.latitude) + assertThat(recovered?.longitude).isWithin(1e-5).of(testPoint.longitude) + } +} diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/AndroidManifest.xml b/Maps3DSamples/ApiDemos/kotlin-app/src/main/AndroidManifest.xml index 5de5d795..8f56448d 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/AndroidManifest.xml +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/AndroidManifest.xml @@ -203,6 +203,13 @@ android:exported="true" /> + + diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/mainactivity/MainActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/mainactivity/MainActivity.kt index 00fd104b..f84d8f41 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/mainactivity/MainActivity.kt +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/mainactivity/MainActivity.kt @@ -67,6 +67,7 @@ import com.example.maps3dkotlin.datavisualization.DataVisualizationActivity import com.example.maps3dkotlin.cloudstyling.CloudStylingActivity import com.example.maps3dkotlin.roadmapmode.RoadmapModeActivity import com.example.maps3dkotlin.fieldofview.FieldOfViewActivity +import com.example.maps3dkotlin.projection3d.Projection3DActivity import com.example.maps3dkotlin.theme.Maps3DSamplesTheme import kotlinx.coroutines.launch @@ -114,6 +115,7 @@ class MainActivity : ComponentActivity() { Sample(R.string.feature_title_cloud_styling, CloudStylingActivity::class.java), Sample(R.string.feature_title_roadmap_mode, RoadmapModeActivity::class.java), Sample(R.string.feature_title_field_of_view, FieldOfViewActivity::class.java), + Sample(R.string.feature_title_projection_3d, Projection3DActivity::class.java), ) @OptIn(ExperimentalMaterial3Api::class) diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/projection3d/Projection3DActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/projection3d/Projection3DActivity.kt new file mode 100644 index 00000000..ef8ba751 --- /dev/null +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/projection3d/Projection3DActivity.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.maps3dkotlin.projection3d + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.TextView +import androidx.cardview.widget.CardView +import androidx.lifecycle.lifecycleScope +import com.example.maps3d.common.Projection3D +import com.example.maps3d.common.toValidCamera +import com.example.maps3dcommon.R +import com.example.maps3dkotlin.sampleactivity.SampleBaseActivity +import com.google.android.gms.maps3d.GoogleMap3D +import com.google.android.gms.maps3d.model.Camera +import com.google.android.gms.maps3d.model.camera +import com.google.android.gms.maps3d.model.flyToOptions +import com.google.android.gms.maps3d.model.latLngAltitude +import com.google.android.gms.maps3d.model.LatLngAltitude +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.button.MaterialButton +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * Demonstrates 3D-to-2D Screen Projection using [Projection3D] in Android Views. + * + * Key Concepts Demonstrated: + * 1. Perspective Screen Coordinate Transformation: + * - Transforms 3D landmark coordinates ([LatLngAltitude]) to 2D screen viewport pixels using + * [Projection3D.toScreenCoordinate] based on real-time camera parameters. + * + * 2. Anchoring Native Android Views over 3D World Geometry: + * - Dynamically translates a native 2D [CardView] overlay across $(X, Y)$ screen space to stay + * tightly anchored above the landmark as the camera pans, tilts, zooms, or orbits. + * + * 3. Frustum Clipping & Depth Detection: + * - Automatically hides or displays the floating callout based on whether the 3D coordinate is + * inside the visible camera view frustum. + * + * 4. Interactive Map Tap Re-Projection: + * - Tapping anywhere on the 3D map surface moves the focus target to the tapped point. + */ +class Projection3DActivity : SampleBaseActivity() { + + override val TAG = "Projection3DActivity" + + override val initialCamera: Camera + get() = camera { + center = latLngAltitude { + latitude = SF_LANDMARKS[0].location.latitude + longitude = SF_LANDMARKS[0].location.longitude + altitude = 150.0 + } + heading = 45.0 + tilt = 65.0 + roll = 0.0 + range = 800.0 + }.toValidCamera() + + // --- UI Elements --- + + private var floatingCallout: View? = null + private var calloutTitle: TextView? = null + private var calloutCoords: TextView? = null + private var calloutAltitude: TextView? = null + + private var controlsCard: CardView? = null + private var cardHeader: View? = null + private var cardContent: View? = null + private var btnCollapse: MaterialButton? = null + + private var tvTarget: TextView? = null + private var tvScreenCoords: TextView? = null + private var tvStatus: TextView? = null + + // --- State --- + + private var activeLandmarkName: String = SF_LANDMARKS[0].name + private var activeLocation: LatLngAltitude = SF_LANDMARKS[0].location + private var isCollapsed: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Hide default scroll view from base skeleton + findViewById(R.id.control_scroll_view)?.visibility = View.GONE + + // Inflate Projection 3D overlay panel into map container + findViewById(R.id.map_container)?.let { container -> + layoutInflater.inflate(R.layout.control_panel_projection_3d, container, true) + } + + findViewById(R.id.top_bar)?.apply { + setTitle(R.string.feature_title_projection_3d) + setNavigationOnClickListener { finish() } + } + + initViews() + } + + private fun initViews() { + floatingCallout = findViewById(R.id.floating_callout) + calloutTitle = findViewById(R.id.callout_title) + calloutCoords = findViewById(R.id.callout_coords) + calloutAltitude = findViewById(R.id.callout_altitude) + + controlsCard = findViewById(R.id.control_panel) + cardHeader = findViewById(R.id.card_header) + cardContent = findViewById(R.id.card_content) + btnCollapse = findViewById(R.id.btn_collapse) + + tvTarget = findViewById(R.id.tv_projection_target) + tvScreenCoords = findViewById(R.id.tv_projection_screen_coords) + tvStatus = findViewById(R.id.tv_projection_status) + + btnCollapse?.setOnClickListener { + toggleControls() + } + + cardHeader?.setOnClickListener { + toggleControls() + } + + findViewById