diff --git a/AGENTS.md b/AGENTS.md
index 8757d468..2a4de533 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -95,11 +95,15 @@ When generating, refactoring, or editing code, strictly adhere to these rules:
- Prefer Android KTX extensions (e.g., `hexColorString.toColorInt()`) over legacy utility methods.
- Keep KDoc references clean by importing or fully qualifying symbols in brackets (e.g., `[PlaceSearch3DScreenState]`).
- For unavoidable warnings (e.g., experimental Compose APIs or external SDK deprecations), use targeted `@OptIn(...)` or `@Suppress(...)` with an explanatory comment rather than forcing brittle workarounds.
-4. **Resource & Design Token Discipline:**
- - Externalize user-facing strings to `res/values/strings.xml`.
+4. **Resource & Design Token Discipline (Zero Hardcoded Strings):**
+ - ❌ **No Hardcoded Strings:** Hardcoding user-facing strings, titles, button labels, telemetry text, or format templates in Activity, Fragment, Composable, or Helper classes is **strictly forbidden**.
+ - ✅ **Externalize to `strings.xml`:** Always externalize human-readable strings and format templates (e.g. `"%1$d px"`) to `res/values/strings.xml`. Reference them via `stringResource(R.string...)` in Jetpack Compose or `getString(R.string...)` in Android Views.
- Prefer theme tokens (`MaterialTheme.colorScheme.*`) over hardcoded hex values. Document any canonical brand colors (e.g. `#F4B400` Google yellow) with an explanatory comment.
- Compose state collections must use `collectAsStateWithLifecycle()` from `androidx.lifecycle.compose`.
-5. **Snippet Region Tag Discipline (`snippets/`):**
+5. **KDoc / Javadoc Notation Discipline (No Raw LaTeX):**
+ - ❌ **No Raw LaTeX Markup:** Do NOT write LaTeX markup (e.g. `$math$`, `$\mathbf{...}$`, `\Delta`, `\cdot`) in KDoc or Javadoc comments. Standard Android Studio hover documentation tooltips, Dokka documentation pipelines, and GitHub code views do not parse LaTeX, leaving raw, unrendered markup.
+ - ✅ **Readable Unicode & Plain Text Math:** Use standard Unicode characters (e.g., `θ`, `φ`, `Δ`, `×`, `·`, `→`, subscripts like `P₀`, superscripts like `R²`) and clean ASCII math expressions for universal readability across IDEs and documentation.
+6. **Snippet Region Tag Discipline (`snippets/`):**
- When creating, modifying, or refactoring code in `snippets/`, always preserve and properly place region tags (`// [START ...]` and `// [END ...]`, along with `// [START_EXCLUDE]` / `// [END_EXCLUDE]`).
- Ensures snippet boundaries remain discoverable and fully compatible with automated catalog scripts (`SAMPLE_CATALOG.md`) and documentation extractors.
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..52da0c1f
--- /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 & Formulation
+ *
+ * In the Google Maps 3D SDK, the camera pose is defined by its focal center
+ * P₀ = (lat₀, lng₀, alt₀), viewing heading H, vertical tilt θ, roll φ, and observation range R:
+ *
+ * 1. **Camera Eye Vantage Point**:
+ * In local East-North-Up (ENU) tangent space centered at P₀, the physical camera eye is located:
+ * - D = R · sin(θ) (Horizontal Ground Distance)
+ * - ΔZ = R · cos(θ) (Vertical Height above target center)
+ * - E_eye = -D · sin(H)
+ * - N_eye = -D · cos(H)
+ * - U_eye = ΔZ
+ *
+ * 2. **Camera Orthonormal Basis**:
+ * - Forward vector: f = (sin(θ) · sin(H), sin(θ) · cos(H), -cos(θ))
+ * - Right vector: r = (cos(H), -sin(H), 0) (rotated by roll φ)
+ * - Up vector: u = r × f (rotated by roll φ)
+ *
+ * 3. **Perspective Projection Matrix**:
+ * Transforms a world point P → v = P - Eye, projects onto camera axes (X_cam, Y_cam, Z_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 @JvmOverloads constructor(
+ 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..a19e866c 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,21 @@
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
+ Transamerica Pyramid
+ Coit Tower
+ Ferry Building
+ Custom Ground Pin
+ 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/java-app/src/main/AndroidManifest.xml b/Maps3DSamples/ApiDemos/java-app/src/main/AndroidManifest.xml
index 7649c6cd..7385455c 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/AndroidManifest.xml
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/AndroidManifest.xml
@@ -200,6 +200,13 @@
android:exported="true"
/>
+
+
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mainactivity/MainActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mainactivity/MainActivity.java
index 7e4b9f0d..2c211868 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mainactivity/MainActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/mainactivity/MainActivity.java
@@ -51,6 +51,7 @@
import com.example.maps3djava.cloudstyling.CloudStylingActivity;
import com.example.maps3djava.roadmapmode.RoadmapModeActivity;
import com.example.maps3djava.fieldofview.FieldOfViewActivity;
+import com.example.maps3djava.projection3d.Projection3DActivity;
import com.google.android.material.appbar.MaterialToolbar;
import java.util.LinkedHashMap;
@@ -81,6 +82,7 @@ public class MainActivity extends AppCompatActivity {
put(R.string.feature_title_cloud_styling, CloudStylingActivity.class);
put(R.string.feature_title_roadmap_mode, RoadmapModeActivity.class);
put(R.string.feature_title_field_of_view, FieldOfViewActivity.class);
+ put(R.string.feature_title_projection_3d, Projection3DActivity.class);
}};
@Override
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/projection3d/Projection3DActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/projection3d/Projection3DActivity.java
new file mode 100644
index 00000000..0acee009
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/projection3d/Projection3DActivity.java
@@ -0,0 +1,325 @@
+/*
+ * 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.maps3djava.projection3d;
+
+import static com.example.maps3d.common.UtilitiesKt.toValidCamera;
+
+import android.os.Bundle;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.cardview.widget.CardView;
+
+import com.example.maps3d.common.Projection3D;
+import com.example.maps3d.common.ScreenCoordinate;
+import com.example.maps3dcommon.R;
+import com.example.maps3djava.sampleactivity.SampleBaseActivity;
+import com.google.android.gms.maps3d.GoogleMap3D;
+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.material.appbar.MaterialToolbar;
+import com.google.android.material.button.MaterialButton;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Demonstrates 3D-to-2D Screen Projection using [Projection3D] in Android Views (Java).
+ *
+ * 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.
+ */
+public class Projection3DActivity extends SampleBaseActivity {
+
+ @NonNull
+ @Override
+ public String getTAG() {
+ return "Projection3DActivity";
+ }
+
+ @NonNull
+ @Override
+ public Camera getInitialCamera() {
+ return toValidCamera(new Camera(
+ new LatLngAltitude(
+ SF_LANDMARKS.get(0).location.getLatitude(),
+ SF_LANDMARKS.get(0).location.getLongitude(),
+ 150.0
+ ),
+ 45.0,
+ 65.0,
+ 0.0,
+ 800.0
+ ));
+ }
+
+ // --- UI Elements ---
+
+ private View floatingCallout;
+ private TextView calloutTitle;
+ private TextView calloutCoords;
+ private TextView calloutAltitude;
+
+ private CardView controlsCard;
+ private View cardHeader;
+ private View cardContent;
+ private MaterialButton btnCollapse;
+
+ private TextView tvTarget;
+ private TextView tvScreenCoords;
+ private TextView tvStatus;
+
+ // --- State ---
+
+ private int activeLandmarkNameRes = SF_LANDMARKS.get(0).nameRes;
+ private LatLngAltitude activeLocation = SF_LANDMARKS.get(0).location;
+ private boolean isCollapsed = false;
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Hide default scroll view from base skeleton
+ View baseScrollView = findViewById(R.id.control_scroll_view);
+ if (baseScrollView != null) {
+ baseScrollView.setVisibility(View.GONE);
+ }
+
+ // Inflate Projection 3D overlay panel into map container
+ ViewGroup container = findViewById(R.id.map_container);
+ if (container != null) {
+ getLayoutInflater().inflate(R.layout.control_panel_projection_3d, container, true);
+ }
+
+ MaterialToolbar topBar = findViewById(R.id.top_bar);
+ if (topBar != null) {
+ topBar.setTitle(R.string.feature_title_projection_3d);
+ topBar.setNavigationOnClickListener(v -> finish());
+ }
+
+ initViews();
+ }
+
+ private void 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);
+
+ if (btnCollapse != null) {
+ btnCollapse.setOnClickListener(v -> toggleControls());
+ }
+
+ if (cardHeader != null) {
+ cardHeader.setOnClickListener(v -> toggleControls());
+ }
+
+ Button btnTransamerica = findViewById(R.id.btn_landmark_transamerica);
+ if (btnTransamerica != null) {
+ btnTransamerica.setOnClickListener(v -> selectLandmark(SF_LANDMARKS.get(0)));
+ }
+
+ Button btnCoit = findViewById(R.id.btn_landmark_coit);
+ if (btnCoit != null) {
+ btnCoit.setOnClickListener(v -> selectLandmark(SF_LANDMARKS.get(1)));
+ }
+
+ Button btnFerry = findViewById(R.id.btn_landmark_ferry);
+ if (btnFerry != null) {
+ btnFerry.setOnClickListener(v -> selectLandmark(SF_LANDMARKS.get(2)));
+ }
+ }
+
+ @Override
+ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
+ super.onMap3DViewReady(googleMap3D);
+
+ // Enable surface tap to re-project arbitrary coordinates
+ googleMap3D.setMap3DClickListener((location, placeId) -> {
+ activeLandmarkNameRes = R.string.landmark_custom_pin;
+ activeLocation = location;
+ Camera cam = googleMap3D.getCamera();
+ if (cam != null) {
+ updateProjection(cam);
+ }
+ });
+
+ // Listen for camera updates
+ googleMap3D.setCameraChangedListener(this::updateProjection);
+ }
+
+ private void selectLandmark(LandmarkData landmark) {
+ activeLandmarkNameRes = landmark.nameRes;
+ activeLocation = landmark.location;
+
+ if (googleMap3D != null) {
+ Camera curr = googleMap3D.getCamera();
+ if (curr == null) {
+ curr = getInitialCamera();
+ }
+ double heading = curr.getHeading() != null ? curr.getHeading() : 45.0;
+ double tilt = curr.getTilt() != null ? curr.getTilt() : 65.0;
+ Camera endCamera = toValidCamera(new Camera(
+ landmark.location,
+ heading,
+ tilt,
+ 0.0,
+ 750.0
+ ));
+ googleMap3D.flyCameraTo(new FlyToOptions(endCamera, 1500));
+ }
+ }
+
+ private void updateProjection(Camera currentCamera) {
+ if (map3DView == null) return;
+ int width = map3DView.getWidth();
+ int height = map3DView.getHeight();
+ if (width <= 0 || height <= 0) return;
+
+ Projection3D projection = new Projection3D(
+ currentCamera,
+ width,
+ height,
+ Projection3D.DEFAULT_FOV_Y_DEGREES
+ );
+
+ ScreenCoordinate screenCoord = projection.toScreenCoordinate(activeLocation);
+
+ runOnUiThread(() -> {
+ String landmarkName = getString(activeLandmarkNameRes);
+ if (tvTarget != null) {
+ tvTarget.setText(getString(R.string.projection_target_format, landmarkName));
+ }
+
+ if (screenCoord.isVisible() && !Float.isNaN(screenCoord.getX()) && !Float.isNaN(screenCoord.getY())) {
+ if (tvScreenCoords != null) {
+ tvScreenCoords.setText(getString(
+ R.string.projection_screen_coords_format,
+ (int) screenCoord.getX(),
+ (int) screenCoord.getY()
+ ));
+ }
+ if (tvStatus != null) {
+ tvStatus.setText(getString(R.string.projection_visible));
+ tvStatus.setTextColor(0xFF2E7D32);
+ }
+
+ if (floatingCallout != null) {
+ floatingCallout.setVisibility(View.VISIBLE);
+ }
+ if (calloutTitle != null) {
+ calloutTitle.setText(landmarkName);
+ }
+ if (calloutCoords != null) {
+ calloutCoords.setText(getString(
+ R.string.projection_coords_simple_format,
+ (int) screenCoord.getX(),
+ (int) screenCoord.getY()
+ ));
+ }
+ if (calloutAltitude != null) {
+ calloutAltitude.setText(getString(
+ R.string.projection_depth_format,
+ (int) screenCoord.getDepth(),
+ (int) activeLocation.getAltitude()
+ ));
+ }
+
+ // Anchor callout horizontally centered and pinned directly above the target coordinate
+ int calloutW = floatingCallout != null ? floatingCallout.getWidth() : 0;
+ int calloutH = floatingCallout != null ? floatingCallout.getHeight() : 0;
+ if (floatingCallout != null) {
+ floatingCallout.setTranslationX(screenCoord.getX() - (calloutW / 2f));
+ floatingCallout.setTranslationY(screenCoord.getY() - calloutH);
+ }
+ } else {
+ if (tvScreenCoords != null) {
+ tvScreenCoords.setText(getString(R.string.projection_out_of_view));
+ }
+ if (tvStatus != null) {
+ tvStatus.setText(getString(R.string.projection_culled));
+ tvStatus.setTextColor(0xFFC62828);
+ }
+ if (floatingCallout != null) {
+ floatingCallout.setVisibility(View.GONE);
+ }
+ }
+ });
+ }
+
+ private void toggleControls() {
+ isCollapsed = !isCollapsed;
+ if (isCollapsed) {
+ if (cardContent != null) {
+ cardContent.setVisibility(View.GONE);
+ }
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_less_24px);
+ }
+ } else {
+ if (cardContent != null) {
+ cardContent.setVisibility(View.VISIBLE);
+ }
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_more_24px);
+ }
+ }
+ }
+
+ private static class LandmarkData {
+ final int nameRes;
+ final LatLngAltitude location;
+
+ LandmarkData(int nameRes, LatLngAltitude location) {
+ this.nameRes = nameRes;
+ this.location = location;
+ }
+ }
+
+ private static final List SF_LANDMARKS = Arrays.asList(
+ new LandmarkData(R.string.landmark_transamerica_pyramid, new LatLngAltitude(37.7952, -122.4028, 260.0)),
+ new LandmarkData(R.string.landmark_coit_tower, new LatLngAltitude(37.8024, -122.4058, 110.0)),
+ new LandmarkData(R.string.landmark_ferry_building, new LatLngAltitude(37.7955, -122.3937, 75.0))
+ );
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/test/java/com/example/maps3djava/mainactivity/MainActivityTest.java b/Maps3DSamples/ApiDemos/java-app/src/test/java/com/example/maps3djava/mainactivity/MainActivityTest.java
index 2251e263..13177712 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/test/java/com/example/maps3djava/mainactivity/MainActivityTest.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/test/java/com/example/maps3djava/mainactivity/MainActivityTest.java
@@ -35,8 +35,9 @@ public void testSampleActivitiesContainsAllSamples() throws Exception {
field.setAccessible(true);
Map> samples = (Map>) field.get(activity);
- assertThat(samples).hasSize(22);
+ assertThat(samples).hasSize(23);
assertThat(samples.values()).contains(com.example.maps3djava.popovers.PopoversActivity.class);
assertThat(samples.values()).contains(com.example.maps3djava.mapinteractions.MapInteractionsActivity.class);
+ assertThat(samples.values()).contains(com.example.maps3djava.projection3d.Projection3DActivity.class);
}
}
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..e4aaaae9
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/projection3d/Projection3DActivity.kt
@@ -0,0 +1,271 @@
+/*
+ * 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 activeLandmarkNameRes: Int = SF_LANDMARKS[0].nameRes
+ 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