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