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