Skip to content

feat(camera): add 4-way AltitudeMode camera and marker controls across Kotlin, Java, and Compose - #86

Open
LoyalAbbas wants to merge 4 commits into
mainfrom
feat/camera_control_with_altitudemode
Open

LoyalAbbas wants to merge 4 commits into
mainfrom
feat/camera_control_with_altitudemode

Conversation

@LoyalAbbas

Copy link
Copy Markdown
Collaborator

Summary

Enhances CameraControlsActivity across Kotlin Views, Java Views, and Jetpack Compose to demonstrate all four AltitudeMode options (RELATIVE_TO_GROUND, RELATIVE_TO_MESH, CLAMP_TO_GROUND, and ABSOLUTE) at both the Empire State Building and Mount Rainier summit.

Key Changes

  • Unified AltitudeMode Pipeline: Consolidated camera and marker configuration into shared buildTargetCamera and buildTargetBeaconOptions / buildTargetBeaconConfig builders in DataModel across all three apps.
  • 4-Way AltitudeMode Support: Added interactive filter chips for Relative to Ground (+100m DTM), Relative to Mesh (+360m DSM), Clamp to Ground (0m DTM), and Absolute (212m MSL at ESB / 4,392m MSL at Mt. Rainier).
  • High-Altitude Mountain Target: Added a Fly to Mt. Rainier (Relative Alt) action that flies to the 4,392m peak and chains a 360° flyCameraAround orbit on arrival.
  • 3D Mesh & Marker Synchronization: Added extruded reference beacons with dynamic refresh on flight completion and onMapSteady so RELATIVE_TO_MESH accurately samples loaded 3D building/terrain tiles.
  • Camera Utilities: Updated toValidCamera() and Camera.copy() in common and maps3d-compose to preserve valid altitudeMode values.
  • Startup & Telemetry: Configured CameraControlsActivity to launch directly at the Empire State Building and display the active target and AltitudeMode in the camera telemetry panel.

AltitudeMode Comparison

Mode Reference Surface ESB Target / Beacon Mt. Rainier Target / Beacon
CLAMP_TO_GROUND Bare earth (DTM) 0m (street base) 0m (summit snowpack)
RELATIVE_TO_GROUND Bare earth (DTM) +100m (lower tower) +100m above terrain
ABSOLUTE Mean sea level (EGM96) 212m MSL (mid-tower) 4,392m MSL (summit)
RELATIVE_TO_MESH 3D surface + buildings (DSM) +360m (above spire) +360m above 3D mesh

Testing & Verification

  • Added unit tests (DataModelTest) across Kotlin Views, Java Views, and Jetpack Compose verifying camera framing, marker extrusion, and AltitudeMode preservation.
  • Ran ./gradlew spotlessApply across all modified modules.
  • Verified builds and unit tests pass:
    • ./gradlew :Maps3DSamples:ApiDemos:kotlin-app:assembleDebug :Maps3DSamples:ApiDemos:kotlin-app:testDebugUnitTest
    • ./gradlew :Maps3DSamples:ApiDemos:java-app:assembleDebug :Maps3DSamples:ApiDemos:java-app:testDebugUnitTest
    • ./gradlew :Maps3DSamples:ComposeDemos:app:assembleDebug :Maps3DSamples:ComposeDemos:app:testDebugUnitTest

@LoyalAbbas
LoyalAbbas requested review from dkhawk and kikoso and removed request for dkhawk October 16, 2026 05:36

@kikoso kikoso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @LoyalAbbas ! This is a nice addition, and the comparison table in the description is very useful. A few comments inline. It is also quite a big PR (around 2.3k lines across the three apps), so would it make sense to move the maps3d-compose change to its own PR? Thanks!

<string name="fly_around">Fly around center</string>
<!-- Fly to the Empire State Building -->
<string name="fly_to">Fly to ESB 🏢</string>
<string name="fly_to_mountain">Fly to Mt. Rainier (Relative Alt)</string>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the button uses whatever mode is selected in the chips, so "(Relative Alt)" is misleading. What about matching the ESB button?

Suggested change
<string name="fly_to_mountain">Fly to Mt. Rainier (Relative Alt)</string>
<string name="fly_to_mountain">Fly to Mt. Rainier ⛰️</string>

tilt = source.tilt.toTilt()
roll = source.roll.toRoll()
range = source.range.toRange()
if (source.altitudeMode == AltitudeMode.ABSOLUTE ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We repeat this same three-mode check four times (here, in copy, and in the two maps3d-compose copies). Could we extract a small isValidCameraAltitudeMode() helper? It would also be a good place to document why CLAMP_TO_GROUND is left out for cameras.

if (oldConfig != config) {
// Config changed, update by adding again with same ID!
// Config changed, remove previous marker and recreate with updated options
marker.remove()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to understand it: the previous comment said that adding again with the same ID updates the marker, so why do we need the remove() now? Since the beacon key includes the mode and the refresh token, this sample never reaches this branch, so I would move this library change to its own PR (with a test) instead of hiding it inside a feat(camera).

RELATIVE_CLEARANCE_METERS to AltitudeMode.RELATIVE_TO_GROUND
}
return markerOptions {
id = "${markerIdPrefix}_${mode}_$refreshToken"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we need the refreshToken at all. Recreating the marker with a new ID after every flight and on onMapSteady reads like a workaround for RELATIVE_TO_MESH not resampling once the tiles load. If that's the case, could we add a comment explaining it (and maybe raise it with the SDK team)? Otherwise developers will copy this pattern thinking is required.

altitudeMode = AltitudeMode.RELATIVE_TO_MESH
}

AltitudeMode.CLAMP_TO_GROUND -> camera {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since Camera does not accept CLAMP_TO_GROUND, this branch ends up as a RELATIVE_TO_GROUND camera at 0m, while the telemetry panel still shows Mode: CLAMP_TO_GROUND. Should we show the mode the camera actually uses, or make the clamp chip affect only the beacon? Otherwise it may confuse people learning the API.

activeTargetResId = targetResId
val isMountain = (targetResId == R.string.camera_target_mt_rainier)

// Clear NYC bounds restriction if active so the camera can travel to Washington State

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this duplicates the logic of the toggle_restriction click listener. What about extracting a small setCameraRestricted(enabled: Boolean) and using it in both places?

class DataModelTest {

@Test
fun mtRainierCamera_relativeToGround_usesUnifiedClearanceAltitude() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of these tests just assert that the builder returns the constants we passed in, so they will break when we tweak the framing values rather than catch real bugs. My two cents: keep the ones that check the mode mapping (e.g. CLAMP_TO_GROUND falling back to RELATIVE_TO_GROUND, isExtruded) and drop the rest. That would also trim a few hundred lines across the three apps.

@LoyalAbbas

Copy link
Copy Markdown
Collaborator Author

Hi @LoyalAbbas ! This is a nice addition, and the comparison table in the description is very useful. A few comments inline. It is also quite a big PR (around 2.3k lines across the three apps), so would it make sense to move the maps3d-compose change to its own PR? Thanks!

Thanks for the review @kikoso ! I reverted the Map3DState.kt change completely and trimmed ~350 lines of redundant tests to shrink the PR. The only remaining maps3d-compose change is 23 lines in Utilities.kt (toValidCamera() / Camera.copy()) so GoogleMap3D(camera = ...) preserves altitudeMode for the Compose sample. Happy to split those 23 lines into a separate PR first if you still prefer!

@LoyalAbbas
LoyalAbbas requested a review from kikoso October 19, 2026 08:40

@kikoso kikoso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@kikoso kikoso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

- Update Mt. Rainier button label to "Fly to Mt. Rainier ⛰️" and remove unused clamp string resource
- Extract isValidCameraAltitudeMode() helper in common and maps3d-compose Utilities
- Revert Map3DState.kt marker.remove() change in maps3d-compose
- Refresh beacon marker only on onMapSteady when RELATIVE_TO_MESH is active
- Show actual camera altitude mode (RELATIVE_TO_GROUND) in telemetry when CLAMP_TO_GROUND is selected
- Extract setCameraRestricted(enabled) helper in Kotlin and Java CameraControlsActivity
- Trim redundant constant assertions in DataModelTest across Kotlin, Java, and Compose
@LoyalAbbas
LoyalAbbas force-pushed the feat/camera_control_with_altitudemode branch from 4512993 to 4a07c60 Compare October 22, 2026 04:47
@dkhawk

dkhawk commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Hey @LoyalAbbas — thanks for putting together the 4-way AltitudeMode camera & marker controls across Kotlin, Java, and Compose! The DataModel landmark presets and the two-step surface-relative beacon refresh (needsMeshBeaconRefresh) work nicely once in 3D mode.

We ran a full review + live-device verification across all three variants (kotlin-app, java-app, and ComposeDemos) on a Pixel 6 and also checked the branch against latest main. Here is what we found and what needs to be addressed:

1. Merge Conflicts with main & Session Settings Regression

  • Map3DSettingsController.kt / Map3DQuickSettingsWidget.kt: Commit eded1dc replaced onSampleEntered(defaultMapMode) in rememberMap3DSessionSettings with applySampleDefaultMapMode(defaultMapMode). Because applySampleDefaultMapMode only updates sampleDefaultMapMode without updating _settings.value.mapMode or resetting hasUserOverriddenMapMode = false, navigating from another sample leaves the previous sample's mapMode active until the user manually clicks Reset. This also conflicts with main (6cbad07).
    • Fix: Remove applySampleDefaultMapMode and restore controller.onSampleEntered(defaultMapMode) in rememberMap3DSessionSettings.
  • maps3d-compose/.../Map3DState.kt: As noted in your reply to @kikoso, syncMarkers still had the MarkerConfig equality diff in eded1dc. Since targetBeaconConfig already appends refreshToken to the marker id, Map3DState.kt can be reverted cleanly to main.

2. Cold-Start Blank Canvas in Kotlin & Java (SampleBaseActivity Double Inflation)

  • Symptom: Launching Camera Controls in both kotlin-app and java-app starts on a completely blank beige canvas.
  • Root Cause: SampleBaseActivity.onCreate() inflates layoutResId (which defaults to R.layout.activity_common_map) and initializes its map3DView. CameraControlsActivity.onCreate() did not override layoutResId / getLayoutResId(), called setContentView(R.layout.activity_camera_controls) a second time, and called map3DView.onCreate(savedInstanceState) a second time. Additionally, <com.google.android.gms.maps3d.Map3DView> in activity_camera_controls.xml still had the old legacy camera attributes (centerAlt="175", tilt="67", range="4000").
  • Fix: Override override val layoutResId = R.layout.activity_camera_controls (and getLayoutResId() in Java), remove the duplicate setContentView and map3DView.onCreate calls in CameraControlsActivity.onCreate, and update the XML attributes in activity_camera_controls.xml to match esbCamera(RELATIVE_TO_GROUND) (mapMode="hybrid").

3. Default Map3DMode Should Be HYBRID (Not ROADMAP)

  • Symptom: Because defaultMapMode was changed to Map3DMode.ROADMAP, resolveCameraForMapMode() forces altitude = 0.0, tilt = 0.0, and altitudeMode = RELATIVE_TO_GROUND. Tapping all 4 AltitudeMode chips right after opening the sample produces zero visual camera change (Alt: 0m, Tilt: 0°) while the telemetry label claims Mode: ABSOLUTE / RELATIVE_TO_MESH.
  • Fix: Default CameraControlsActivity to Map3DMode.HYBRID across Kotlin, Java, Compose, and activity_camera_controls.xml so the 3D AltitudeMode behavior is immediately visible on launch, and in ROADMAP mode report RELATIVE_TO_GROUND in the telemetry string since resolveCameraForMapMode overrides the camera mode.

4. Camera Plunges Underground (Alt: -5731m) When Switching ROADMAP → HYBRID

  • Symptom: When in ROADMAP mode (Alt: 0m, Tilt: 0°) with Absolute selected, switching to Hybrid calls map.setMapMode(HYBRID) immediately followed by map.flyCameraTo(...). Interpolating a flight while the renderer switches asynchronously from flat 2D projection to 3D mesh corrupts the flight trajectory and strands the camera 5.7 km underground in New Jersey (40.792459°, -74.054994°, Alt: -5731m, Range: 10896m). Furthermore, map.getCamera()?.tilt in ROADMAP mode can still report the pre-roadmap 3D tilt (> 30.0), causing if (mode != Map3DMode.ROADMAP && currentTilt > 30.0) return to bail out early.
  • Fix: Track lastAppliedMapMode (only updating camera perspective when transitioning into or out of Map3DMode.ROADMAP) and use map.setCamera(targetCam) (with a 250ms post-transition pass after setMapMode) instead of flyCameraTo.

5. Landscape Occlusion (100% Map Covered) & Screen Rotation State Loss

  • Symptom 1 (Landscape): Rotating to landscape causes the bottom CardView / Compose Card to cover 100% of the viewport below the toolbar and clips the Camera State readout off-screen without scrollbars.
  • Symptom 2 (Rotation State Desync): Rotating the screen resets selectedAltitudeMode and activeTargetResId to Empire State Building (RELATIVE_TO_GROUND), while in Kotlin/Java the map camera stays at Mt. Rainier (46.852348°, -121.760383°, Alt: 4386m) — leaving the telemetry coordinates at Mt. Rainier while the Target: label says Empire State Building.
  • Fix:
    • Wrap @+id/card_content in a NestedScrollView with app:layout_constrainedHeight="true" and app:layout_constraintHeight_percent="0.63" on @+id/camera_state in activity_camera_controls.xml (and .heightIn(max = (screenHeightDp * 0.64f).dp) + .verticalScroll(rememberScrollState()) in Compose).
    • Save and restore selectedAltitudeMode, activeTargetResId, and isCollapsed in onSaveInstanceState (and use rememberSaveable in Compose), and compute initialCamera from targetCamera(isMountain, selectedAltitudeMode).

6. Compose State Sync & Quick Settings FAB Margin

  • Compose isCameraRestricted Desync: In Compose, flyToTarget() called map.setCameraRestriction(null) imperatively when isMountain == true, so tapping an AltitudeMode chip at Mt. Rainier cleared the map restriction without updating isCameraRestricted = false. Set if (isMountainTarget) isCameraRestricted = false in the FilterChip onClick handler instead.
  • Quick Settings FAB Margin: On latest main (cb553e0), 3D UI controls default to false (calculateFabMarginDp() = 16dp), so quick_settings_button in activity_camera_controls.xml should also use android:layout_marginTop="16dp".

…ith_altitudemode

# Conflicts:
#	Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Map3DSettingsController.kt
#	Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/Utilities.kt
#	Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/cameracontrols/CameraControlsActivity.kt

@dkhawk dkhawk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please consider the comments I left, but good otherwise

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants