diff --git a/.gitignore b/.gitignore
index c32cfa86..2b8fb646 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# Gradle files
.gradle/
build/
+gradle/gradle-daemon-jvm.properties
# Local configuration file (sdk path, etc)
local.properties
@@ -40,3 +41,5 @@ google-services.json
.vscode/
snippets/docs/
.kotlin/
+.android/
+scratch/
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_less_24px.xml b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_less_24px.xml
new file mode 100644
index 00000000..df9af507
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_less_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_more_24px.xml b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_more_24px.xml
new file mode 100644
index 00000000..391e875f
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/drawable/expand_more_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_cloud_styling.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_cloud_styling.xml
new file mode 100644
index 00000000..61ebe477
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_cloud_styling.xml
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_path_following.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_path_following.xml
index 2d9a0c2d..3d5da955 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_path_following.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_path_following.xml
@@ -41,25 +41,67 @@
app:cardElevation="8dp"
>
-
+
+
+
+
+
+
+
+
+
+
@@ -77,7 +119,7 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:checked="true"
- android:text="Urban"
+ android:text="@string/urban"
/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_advanced_animation.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_advanced_animation.xml
index d0a40acc..c66d9171 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_advanced_animation.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_advanced_animation.xml
@@ -36,57 +36,29 @@
>
-
-
-
-
-
-
-
-
-
-
+ android:text="@string/aerial_tour_status_idle"
+ android:textColor="?android:attr/textColorSecondary"
+ />
@@ -103,7 +75,7 @@
style="@style/Widget.Material3.Button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:text="Play"
+ android:text="@string/play"
/>
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_cloud_styling.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_cloud_styling.xml
new file mode 100644
index 00000000..ec83a2bd
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_cloud_styling.xml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_data_visualization.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_data_visualization.xml
index ad9d35c0..43767b1b 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_data_visualization.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_data_visualization.xml
@@ -31,79 +31,127 @@
+
-
-
-
+
-
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="2dp"
+ >
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_field_of_view.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_field_of_view.xml
index 67695a75..9025799e 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_field_of_view.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_field_of_view.xml
@@ -31,95 +31,142 @@
-
-
-
-
-
-
+
-
-
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_roadmap_mode.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_roadmap_mode.xml
index ec83a2bd..4d61bc7c 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_roadmap_mode.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_roadmap_mode.xml
@@ -31,52 +31,91 @@
-
-
-
+
-
-
+
-
+
+
+
-
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_marginTop="4dp"
+ >
+
+
+
+
+
+
+
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_routes.xml b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_routes.xml
similarity index 70%
rename from Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_routes.xml
rename to Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_routes.xml
index 014aa8b2..583a8265 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/layout/activity_routes.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/layout/control_panel_routes.xml
@@ -14,61 +14,71 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -99,7 +109,6 @@
android:value="0.0"
app:labelBehavior="gone"
/>
-
@@ -163,6 +172,5 @@
/>
-
-
-
+
+
diff --git a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
index 41293504..29ec21da 100644
--- a/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
+++ b/Maps3DSamples/ApiDemos/common/src/main/res/values/strings.xml
@@ -121,13 +121,89 @@
Fly to NYC
+ Route Simulation Controls
+ Route is still loading…Play or pause route animationCamera Altitude: %1$dmVehicle Speed: %1$dm/sCamera Yaw Offset: %1$d°Offline: Using local Oahu fallback route
+ Play
+ Pause
+ Reset
+
+
+ 3D Map Mode ControlsMap ModeRoadmapHybridSatellite
+
+
+ San Francisco Aerial Tour
+ Press Play to begin the multi-step aerial tour.
+ Step %1$d of %2$d: %3$s
+ Tour complete. Press Reset to replay.
+ 1. Swoop to Golden Gate
+ Descend from high-altitude SF panorama to Golden Gate Bridge
+ 2. Mid-Air Observation
+ Dwell pause observing 3D airplane over Golden Gate
+ 3. Golden Gate 360° Orbit
+ 360° orbital camera spin around airplane
+ 4. Transit to Coit Tower
+ Airplane flight across San Francisco to Coit Tower
+ Continuous 360° Orbit
+ Continuous orbital camera spin around Golden Gate Bridge
+ Dwell Pause
+ Observing current location
+
+
+ Path Controls
+ Collapse Controls
+ Expand Controls
+ Path Environment:
+ Urban
+ Rural
+ Altitude Mode:
+ Relative to Ground
+ Clamp to Ground
+ Relative to Mesh
+ Absolute
+ Path Height: %1$.1fm
+ Camera Range: %1$dm
+ Ground Altitude: %1$dm
+ Heading Offset: %1$d°
+ Camera Tilt: %1$d°
+ Follow Speed: %1$d m/s
+ Path Height: 0.5m
+ Camera Range: 300m
+ Ground Altitude: 20m
+ Heading Offset: 0°
+ Camera Tilt: 70°
+ Follow Speed: 30 m/s
+ Play or pause animation
+
+
+ Flood Simulation Controls
+ Flood Elevation: +%1$.1f m (%2$.1f ft)
+ Flood Elevation: +10.0 m (32.8 ft)
+ 🌊 Baseline Tide
+ ⚠️ Minor Inundation
+ 🌊 Moderate Flooding
+ 🚨 Storm Surge (Cat 3)
+ ⛔ Extreme Inundation
+ Continuous Tide Simulation:
+ ▶ Start Simulation
+ ⏹ Stop Simulation
+ San Francisco Waterfront - Water Level: +%1$.1f m
+
+
+ Field of View Controls
+ Field of View: %1$d°
+ Field of View: 45°
+ FOV Presets:
+ 20° Tele
+ 45° Standard
+ 90° Wide
+ 120° Ultra
diff --git a/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/CloudStylingVisualTest.java b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/CloudStylingVisualTest.java
new file mode 100644
index 00000000..c20904d3
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/androidTest/java/com/example/maps3djava/CloudStylingVisualTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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;
+
+import static org.junit.Assert.assertTrue;
+
+import android.content.Intent;
+import android.graphics.Bitmap;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.uiautomator.By;
+import androidx.test.uiautomator.Until;
+
+import com.example.maps3djava.cloudstyling.CloudStylingActivity;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * A premium visual regression test for the View-based Java Cloud-Based Map Styling sample.
+ *
+ * Demonstrates robust programmatic testing of custom cloud-styled 3D map features by launching the Java-based
+ * [CloudStylingActivity], waiting for cloud-styled map tiles over San Francisco to load, capturing a screenshot of the
+ * active map scene, and verifying visual correctness using the Gemini API.
+ */
+@RunWith(AndroidJUnit4.class)
+public class CloudStylingVisualTest extends BaseVisualTest {
+
+ @Test
+ public void verifyCloudStylingRenders() {
+ // Launch CloudStylingActivity
+ Intent intent = new Intent(context, CloudStylingActivity.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(intent);
+
+ // Wait for the activity to be displayed in the foreground
+ uiDevice.wait(Until.hasObject(By.pkg(context.getPackageName()).depth(0)), 10000);
+
+ // Wait for Cloud-styled 3D map tiles to render and feature styling to settle
+ waitForMapRendering(15);
+
+ // Capture high-resolution screenshot of the active 3D map scene
+ Bitmap screenshotBitmap = captureScreenshot("cloud_styling_screenshot.png");
+
+ // Define the verification prompt for the visual testing agent
+ String prompt = "Please act as a UI tester and analyze this screenshot.\n" +
+ "1. Confirm that a 3D map view is visible over San Francisco.\n" +
+ "2. Confirm that custom cloud-based stylized map tiles / elements are displayed on the 3D map scene.\n" +
+ "3. Confirm that the Map Mode selector card (with radio options for Roadmap, Hybrid, and Satellite) is visible at the bottom of the screen.\n" +
+ "\n" +
+ "If and ONLY IF you can clearly see the 3D Cloud-styled map view and bottom Map Mode selection card, reply with \"PASSED\".\n" +
+ "If you cannot see the 3D map scene or control card, reply with \"FAILED: 3D map scene or Map Mode controls not visible\".\n" +
+ "Report what you see in detail.";
+
+ // Analyze the image using Gemini (using blocking wrapper)
+ String geminiResponse = helper.analyzeImageBlocking(screenshotBitmap, prompt, geminiApiKey);
+ System.out.println("Gemini's analysis: " + geminiResponse);
+
+ // Assert on Gemini's response
+ assertTrue(
+ "Visual verification failed. Gemini response: " + geminiResponse,
+ geminiResponse != null && geminiResponse.toUpperCase().contains("PASSED")
+ );
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java
index 2c174042..11e05b62 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AdvancedCameraAnimationActivity.java
@@ -19,13 +19,12 @@
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
+import android.util.Log;
import android.view.ViewGroup;
import android.widget.Button;
-import android.widget.RadioGroup;
+import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import com.example.maps3d.common.PositionAndHeading;
-import com.example.maps3d.common.RouteEngine;
import com.example.maps3dcommon.R;
import com.example.maps3djava.sampleactivity.SampleBaseActivity;
import com.google.android.gms.maps.model.LatLng;
@@ -40,134 +39,98 @@
import com.google.android.gms.maps3d.model.Vector3D;
import com.google.android.material.appbar.MaterialToolbar;
import com.google.maps.android.SphericalUtil;
-import java.util.Arrays;
-import java.util.List;
+/**
+ * Demonstrates advanced camera animations on Google Maps 3D using {@link Map3DAnimator}.
+ *
+ *
Showcases a declarative multi-step aerial tour with native flight transitions ({@link
+ * FlyToStep}), stationary observation pauses ({@link DwellStep}), and 360-degree orbital spins
+ * ({@link OrbitStep}) around synchronized 3D assets.
+ */
public class AdvancedCameraAnimationActivity extends SampleBaseActivity {
- public enum AnimationApproach {
- SIMPLE_FLY_TO,
- KEYFRAME_TOUR,
- DISPATCHER_FRAME_LOOP,
- ORBIT_360_SPIN
- }
-
- public abstract static class Keyframe {
-
- public final String title;
- public final String description;
-
- public Keyframe(String title, String description) {
- this.title = title;
- this.description = description;
- }
- }
-
- public static class KeyframeFlyTo extends Keyframe {
-
- public final LatLng targetCenter;
- public final double targetAltitude;
- public final double targetHeading;
- public final double targetTilt;
- public final double targetRange;
- public final long durationMs;
-
- public KeyframeFlyTo(String title, String description, LatLng targetCenter,
- double targetAltitude, double targetHeading, double targetTilt, double targetRange,
- long durationMs) {
- super(title, description);
- this.targetCenter = targetCenter;
- this.targetAltitude = targetAltitude;
- this.targetHeading = targetHeading;
- this.targetTilt = targetTilt;
- this.targetRange = targetRange;
- this.durationMs = durationMs;
- }
- }
-
- public static class KeyframeDwell extends Keyframe {
-
- public final long durationMs;
-
- public KeyframeDwell(String title, String description, long durationMs) {
- super(title, description);
- this.durationMs = durationMs;
- }
- }
-
- public static class KeyframeOrbit extends Keyframe {
-
- public final LatLng center;
- public final double altitude;
- public final double range;
- public final double tilt;
- public final double startHeading;
- public final double endHeading;
- public final long durationMs;
-
- public KeyframeOrbit(String title, String description, LatLng center, double altitude,
- double range, double tilt, double startHeading, double endHeading, long durationMs) {
- super(title, description);
- this.center = center;
- this.altitude = altitude;
- this.range = range;
- this.tilt = tilt;
- this.startHeading = startHeading;
- this.endHeading = endHeading;
- this.durationMs = durationMs;
- }
- }
-
private static final String MODEL_ID = "airplane_model";
- private static final String PLANE_URL = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/Airplane.glb";
+ private static final String PLANE_URL =
+ "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/Airplane.glb";
+ // Key landmarks for the aerial tour
+ private static final LatLng SF_PANORAMA_CENTER = new LatLng(37.7650, -122.4400);
+ private static final LatLng GOLDEN_GATE_BRIDGE = new LatLng(37.8199, -122.4783);
+ private static final LatLng COIT_TOWER = new LatLng(37.8024, -122.4058);
+
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private Map3DAnimator tourAnimator;
private Model airplaneModel;
- private int currentStepIndex = 0;
private boolean isPlaying = false;
- private AnimationApproach selectedApproach = AnimationApproach.DISPATCHER_FRAME_LOOP;
+ private TextView tvTourStatus;
private Button btnPlayPause;
- private final Handler handler = new Handler(Looper.getMainLooper());
- private Runnable animationRunnable = null;
-
- // 15 Fine-Grained Waypoints on the direct route from Golden Gate Bridge to Coit Tower
- public static final List AIRPLANE_FLIGHT_PATH = Arrays.asList(
- new LatLng(37.8199, -122.4783), // 1. Golden Gate Bridge (Source)
- new LatLng(37.8188, -122.4735), // 2. Fort Point / Presidio Overlook
- new LatLng(37.8175, -122.4685), // 3. Crissy Field West
- new LatLng(37.8160, -122.4635), // 4. Crissy Field East
- new LatLng(37.8145, -122.4585), // 5. Marina Green West
- new LatLng(37.8130, -122.4530), // 6. Marina District Center
- new LatLng(37.8115, -122.4475), // 7. Fort Mason West
- new LatLng(37.8100, -122.4420), // 8. Fort Mason Heights
- new LatLng(37.8085, -122.4365), // 9. Aquatic Park Cove
- new LatLng(37.8070, -122.4310), // 10. Fisherman's Wharf West
- new LatLng(37.8058, -122.4250), // 11. Fisherman's Wharf Center
- new LatLng(37.8048, -122.4195), // 12. Pier 39 Promenade
- new LatLng(37.8038, -122.4140), // 13. Embarcadero North
- new LatLng(37.8030, -122.4090), // 14. Telegraph Hill Slopes
- new LatLng(37.8024, -122.4058) // 15. Coit Tower (Destination)
- );
-
- public static final List SAN_FRANCISCO_TOUR = Arrays.asList(
- new KeyframeFlyTo("1. Golden Gate Flight", "3D Airplane flight over Golden Gate Bridge",
- new LatLng(37.8199, -122.4783), 200.0, 105.0, 65.0, 600.0, 2500L),
- new KeyframeDwell("2. Mid-Air Observation", "Dwell pause observing 3D airplane", 1500L),
- new KeyframeOrbit("3. Golden Gate 360° Orbit",
- "360° orbital camera spin around flying airplane", new LatLng(37.8199, -122.4783), 200.0,
- 600.0, 65.0, 105.0, 465.0, 4000L),
- new KeyframeFlyTo("4. Transit to Coit Tower", "Airplane flight to Coit Tower Landmark",
- new LatLng(37.8024, -122.4058), 200.0, 105.0, 65.0, 600.0, 3000L)
- );
-
- private double[] cumulativeDistances;
+ /**
+ * Constructs the declarative multi-step camera tour using {@link Map3DAnimator.Builder}.
+ */
+ private Map3DAnimator buildTourAnimator() {
+ double planeHeading = SphericalUtil.computeHeading(GOLDEN_GATE_BRIDGE, COIT_TOWER);
+
+ OrbitOptions goldenGateOrbit =
+ new OrbitOptions.Builder()
+ .setCenter(GOLDEN_GATE_BRIDGE)
+ .setAltitude(200.0)
+ .setRange(600.0)
+ .setTilt(65.0)
+ .setHeadingRange(/* startHeading= */ 105.0, /* endHeading= */ 465.0)
+ .setDurationMs(4500L)
+ .build();
+
+ return new Map3DAnimator.Builder()
+ // Step 1: Smooth swoop from high-altitude SF panorama down into Golden Gate flight path
+ .flyTo(
+ getString(R.string.tour_step_1_title),
+ getString(R.string.tour_step_1_desc),
+ new FlyToOptions(
+ new Camera(
+ new LatLngAltitude(
+ GOLDEN_GATE_BRIDGE.latitude, GOLDEN_GATE_BRIDGE.longitude, 200.0),
+ /* heading= */ 105.0,
+ /* tilt= */ 65.0,
+ /* roll= */ 0.0,
+ /* range= */ 600.0),
+ /* durationMs= */ 3000L),
+ /* durationMs= */ 3000L,
+ () -> updateAirplaneModel(GOLDEN_GATE_BRIDGE, planeHeading + 180.0))
+ // Step 2: Dwell pause holding camera steady on the airplane over the bridge
+ .dwell(
+ getString(R.string.tour_step_2_title),
+ getString(R.string.tour_step_2_desc),
+ /* durationMs= */ 1500L)
+ // Step 3: 360-degree orbital camera spin around the airplane
+ .orbit(
+ getString(R.string.tour_step_3_title),
+ getString(R.string.tour_step_3_desc),
+ goldenGateOrbit)
+ // Step 4: High-speed transit across San Francisco coastline to Coit Tower
+ .flyTo(
+ getString(R.string.tour_step_4_title),
+ getString(R.string.tour_step_4_desc),
+ new FlyToOptions(
+ new Camera(
+ new LatLngAltitude(COIT_TOWER.latitude, COIT_TOWER.longitude, 200.0),
+ /* heading= */ 115.0,
+ /* tilt= */ 65.0,
+ /* roll= */ 0.0,
+ /* range= */ 600.0),
+ /* durationMs= */ 3500L),
+ /* durationMs= */ 3500L,
+ () -> handler.postDelayed(
+ () -> updateAirplaneModel(COIT_TOWER, planeHeading + 180.0),
+ /* delayMillis= */ 3500L / 2))
+ .build();
+ }
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- // Inflate control panel overlay into the map container managed by SampleBaseActivity
ViewGroup container = findViewById(R.id.map_container);
if (container != null) {
getLayoutInflater().inflate(R.layout.control_panel_advanced_animation, container, true);
@@ -175,61 +138,39 @@ protected void onCreate(@Nullable Bundle savedInstanceState) {
MaterialToolbar topBar = findViewById(R.id.top_bar);
if (topBar != null) {
- topBar.setTitle("Advanced Camera Animation");
+ topBar.setTitle(R.string.feature_title_advanced_camera_animation);
topBar.setNavigationOnClickListener(v -> finish());
}
- RadioGroup rgApproach = findViewById(R.id.rg_approach);
- if (rgApproach != null) {
- rgApproach.setOnCheckedChangeListener((group, checkedId) -> {
- if (checkedId == R.id.rb_simple_flyto) {
- selectedApproach = AnimationApproach.SIMPLE_FLY_TO;
- } else if (checkedId == R.id.rb_keyframe_tour) {
- selectedApproach = AnimationApproach.KEYFRAME_TOUR;
- } else if (checkedId == R.id.rb_orbit_spin) {
- selectedApproach = AnimationApproach.ORBIT_360_SPIN;
- } else {
- selectedApproach = AnimationApproach.DISPATCHER_FRAME_LOOP;
- }
- resetAndRestartTour();
- });
- }
-
+ tvTourStatus = findViewById(R.id.tv_tour_status);
+ btnPlayPause = findViewById(R.id.btn_play_pause);
Button btnReset = findViewById(R.id.btn_reset);
- if (btnReset != null) {
- btnReset.setOnClickListener(v -> resetAndRestartTour());
- }
- btnPlayPause = findViewById(R.id.btn_play_pause);
if (btnPlayPause != null) {
- btnPlayPause.setOnClickListener(v -> {
- if (isPlaying) {
- stopTour();
- } else {
- startSelectedApproach();
- }
- });
+ btnPlayPause.setOnClickListener(
+ v -> {
+ if (isPlaying) {
+ pauseTour();
+ } else {
+ startOrResumeTour();
+ }
+ });
}
- }
- private void updatePlayPauseButtonState() {
- if (btnPlayPause != null) {
- btnPlayPause.setText(isPlaying ? "Pause" : "Play");
+ if (btnReset != null) {
+ btnReset.setOnClickListener(v -> resetTour());
}
}
@NonNull
@Override
public Camera getInitialCamera() {
- LatLng startLoc = AIRPLANE_FLIGHT_PATH.get(0);
- double initialHeading = SphericalUtil.computeHeading(startLoc, AIRPLANE_FLIGHT_PATH.get(1));
return new Camera(
- new LatLngAltitude(startLoc.latitude, startLoc.longitude, 200.0),
- normalizeHeading(initialHeading),
- 65.0,
- 0.0,
- 600.0
- );
+ new LatLngAltitude(SF_PANORAMA_CENTER.latitude, SF_PANORAMA_CENTER.longitude, 300.0),
+ /* heading= */ 30.0,
+ /* tilt= */ 60.0,
+ /* roll= */ 0.0,
+ /* range= */ 4500.0);
}
@NonNull
@@ -241,327 +182,131 @@ public String getTAG() {
@Override
public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
super.onMap3DViewReady(googleMap3D);
+ googleMap3D.setCamera(getInitialCamera());
+ googleMap3D.flyCameraTo(new FlyToOptions(getInitialCamera(), 0L));
+
+ // Re-apply after a short delay to guarantee native viewport receives target coordinates
+ handler.postDelayed(
+ () -> {
+ if (this.googleMap3D != null) {
+ this.googleMap3D.setCamera(getInitialCamera());
+ this.googleMap3D.flyCameraTo(new FlyToOptions(getInitialCamera(), 0L));
+ }
+ },
+ 150L);
- cumulativeDistances = RouteEngine.calculateCumulativeDistances(AIRPLANE_FLIGHT_PATH);
-
- LatLng startLoc = AIRPLANE_FLIGHT_PATH.get(0);
- double initialHeading = SphericalUtil.computeHeading(startLoc, AIRPLANE_FLIGHT_PATH.get(1));
- updateAirplaneModel(startLoc, initialHeading + 180.0);
-
- // Auto-start smooth flight animation after 1 second delay
- handler.postDelayed(this::startSelectedApproach, 1000L);
- }
-
- private void updateAirplaneModel(LatLng position, double planeHeadingDeg) {
- if (googleMap3D != null) {
- ModelOptions modelOptions = new ModelOptions();
- modelOptions.setId(MODEL_ID);
- modelOptions.setPosition(new LatLngAltitude(position.latitude, position.longitude, 200.0));
- modelOptions.setUrl(PLANE_URL);
- modelOptions.setAltitudeMode(AltitudeMode.ABSOLUTE);
- modelOptions.setScale(new Vector3D(0.08, 0.08, 0.08));
- modelOptions.setOrientation(new Orientation(normalizeHeading(planeHeadingDeg), -90.0, 0.0));
-
- airplaneModel = googleMap3D.addModel(modelOptions);
- }
- }
-
- private void startSelectedApproach() {
- stopTour();
- if (selectedApproach == AnimationApproach.SIMPLE_FLY_TO) {
- runSimpleFlyTo();
- } else if (selectedApproach == AnimationApproach.KEYFRAME_TOUR) {
- startOrResumeTour();
- } else if (selectedApproach == AnimationApproach.DISPATCHER_FRAME_LOOP) {
- runFrameDispatcherLoop();
- } else if (selectedApproach == AnimationApproach.ORBIT_360_SPIN) {
- run360OrbitSpin();
- }
- }
-
- private void runSimpleFlyTo() {
- stopTour();
- isPlaying = true;
- updatePlayPauseButtonState();
-
- LatLng target = AIRPLANE_FLIGHT_PATH.get(AIRPLANE_FLIGHT_PATH.size() - 1);
- double heading = SphericalUtil.computeHeading(
- AIRPLANE_FLIGHT_PATH.get(AIRPLANE_FLIGHT_PATH.size() - 2), target);
- updateAirplaneModel(target, heading + 180.0);
-
- Camera targetCam = new Camera(
- new LatLngAltitude(target.latitude, target.longitude, 200.0),
- normalizeHeading(heading),
- 65.0,
- 0.0,
- 600.0
- );
- googleMap3D.flyCameraTo(new FlyToOptions(targetCam, 1500L));
- isPlaying = false;
- updatePlayPauseButtonState();
- }
-
- /**
- * Executes multi-step keyframe queue tour stage by stage in Java.
- */
- private void startOrResumeTour() {
- stopTour();
- isPlaying = true;
- updatePlayPauseButtonState();
- currentStepIndex = 0;
- executeNextKeyframeStep();
+ double planeHeading = SphericalUtil.computeHeading(GOLDEN_GATE_BRIDGE, COIT_TOWER);
+ updateAirplaneModel(GOLDEN_GATE_BRIDGE, planeHeading + 180.0);
}
- private void executeNextKeyframeStep() {
- if (!isPlaying || currentStepIndex >= SAN_FRANCISCO_TOUR.size()) {
- isPlaying = false;
- updatePlayPauseButtonState();
- return;
- }
-
- Keyframe step = SAN_FRANCISCO_TOUR.get(currentStepIndex);
-
- if (step instanceof KeyframeFlyTo) {
- KeyframeFlyTo flyToStep = (KeyframeFlyTo) step;
- updateAirplaneModel(flyToStep.targetCenter, flyToStep.targetHeading + 180.0);
- Camera targetCam = new Camera(
- new LatLngAltitude(flyToStep.targetCenter.latitude, flyToStep.targetCenter.longitude,
- flyToStep.targetAltitude),
- normalizeHeading(flyToStep.targetHeading),
- flyToStep.targetTilt,
- 0.0,
- flyToStep.targetRange
- );
- if (googleMap3D != null) {
- googleMap3D.flyCameraTo(new FlyToOptions(targetCam, flyToStep.durationMs));
- }
- handler.postDelayed(() -> {
- currentStepIndex++;
- executeNextKeyframeStep();
- }, flyToStep.durationMs);
-
- } else if (step instanceof KeyframeDwell) {
- KeyframeDwell dwellStep = (KeyframeDwell) step;
- handler.postDelayed(() -> {
- currentStepIndex++;
- executeNextKeyframeStep();
- }, dwellStep.durationMs);
-
- } else if (step instanceof KeyframeOrbit) {
- KeyframeOrbit orbitStep = (KeyframeOrbit) step;
- final long frameMs = 16L;
- final long totalFrames = Math.max(1, orbitStep.durationMs / frameMs);
-
- animationRunnable = new Runnable() {
- private long currentFrame = 0;
-
+ private final Map3DAnimator.Listener animatorListener =
+ new Map3DAnimator.Listener() {
@Override
- public void run() {
- if (!isPlaying) {
- return;
- }
-
- double t = (double) currentFrame / totalFrames;
- double orbitHeading = interpolateAngle(orbitStep.startHeading, orbitStep.endHeading, t);
-
- updateAirplaneModel(orbitStep.center, orbitHeading + 180.0);
-
- Camera updatedCam = new Camera(
- new LatLngAltitude(orbitStep.center.latitude, orbitStep.center.longitude,
- orbitStep.altitude),
- normalizeHeading(orbitHeading),
- orbitStep.tilt,
- 0.0,
- orbitStep.range
- );
- if (googleMap3D != null) {
- googleMap3D.setCamera(updatedCam);
- }
-
- currentFrame++;
- if (currentFrame <= totalFrames) {
- handler.postDelayed(this, frameMs);
- } else {
- currentStepIndex++;
- executeNextKeyframeStep();
+ public void onStepStarted(int index, @NonNull KeyframeStep step) {
+ Log.d(getTAG(), "Keyframe Step " + (index + 1) + " started: " + step.getTitle());
+ if (tvTourStatus != null && tourAnimator != null) {
+ tvTourStatus.setText(
+ getString(
+ R.string.aerial_tour_status_running,
+ index + 1,
+ tourAnimator.getSteps().size(),
+ step.getTitle()));
}
}
- };
- handler.post(animationRunnable);
- }
- }
- /**
- * Frame Dispatcher Animation Loop. High-speed flight animation (400 m/s) stopping cleanly at
- * destination.
- */
- private void runFrameDispatcherLoop() {
- stopTour();
- isPlaying = true;
- updatePlayPauseButtonState();
-
- final double totalDistance = Math.max(1.0, cumulativeDistances[cumulativeDistances.length - 1]);
- final double flightSpeedMps = 400.0;
-
- animationRunnable = new Runnable() {
- private double elapsedDistance = 0.0;
- private long lastTime = System.currentTimeMillis();
-
- @Override
- public void run() {
- if (!isPlaying) {
- return;
- }
+ @Override
+ public void onStepCompleted(int index, @NonNull KeyframeStep step) {
+ Log.d(getTAG(), "Keyframe Step " + (index + 1) + " completed: " + step.getTitle());
+ }
- long now = System.currentTimeMillis();
- double dt = (now - lastTime) / 1000.0;
- lastTime = now;
-
- elapsedDistance += flightSpeedMps * dt;
-
- // Stop cleanly at destination
- if (elapsedDistance >= totalDistance) {
- elapsedDistance = totalDistance;
- PositionAndHeading posAndHeading = RouteEngine.calculatePositionAndHeading(
- AIRPLANE_FLIGHT_PATH,
- cumulativeDistances,
- elapsedDistance,
- 30.0
- );
- double planeHeading = posAndHeading.getHeading() + 180.0;
- updateAirplaneModel(posAndHeading.getPosition(), planeHeading);
-
- Camera finalCam = new Camera(
- new LatLngAltitude(posAndHeading.getPosition().latitude,
- posAndHeading.getPosition().longitude, 200.0),
- normalizeHeading(posAndHeading.getHeading()),
- 65.0,
- 0.0,
- 600.0
- );
- if (googleMap3D != null) {
- googleMap3D.setCamera(finalCam);
- }
+ @Override
+ public void onAnimationFinished() {
+ Log.d(getTAG(), "Aerial tour completed successfully.");
isPlaying = false;
updatePlayPauseButtonState();
- return;
+ if (tvTourStatus != null) {
+ tvTourStatus.setText(R.string.aerial_tour_status_finished);
+ }
}
+ };
- PositionAndHeading posAndHeading = RouteEngine.calculatePositionAndHeading(
- AIRPLANE_FLIGHT_PATH,
- cumulativeDistances,
- elapsedDistance,
- 30.0
- );
-
- double planeHeading = posAndHeading.getHeading() + 180.0;
- updateAirplaneModel(posAndHeading.getPosition(), planeHeading);
-
- Camera updatedCam = new Camera(
- new LatLngAltitude(posAndHeading.getPosition().latitude,
- posAndHeading.getPosition().longitude, 200.0),
- normalizeHeading(posAndHeading.getHeading()),
- 65.0,
- 0.0,
- 600.0
- );
-
- if (googleMap3D != null) {
- googleMap3D.setCamera(updatedCam);
- }
+ private void startOrResumeTour() {
+ isPlaying = true;
+ updatePlayPauseButtonState();
- handler.postDelayed(this, 16L);
+ if (googleMap3D != null) {
+ if (tourAnimator == null) {
+ tourAnimator = buildTourAnimator();
+ tourAnimator.start(googleMap3D, animatorListener);
+ } else if (tourAnimator.getCurrentStepIndex() < tourAnimator.getSteps().size()) {
+ tourAnimator.resume();
+ } else {
+ tourAnimator.start(googleMap3D, animatorListener);
}
- };
- handler.post(animationRunnable);
+ }
}
- /**
- * Option 4: Continuous 360-degree orbital camera spin around landmark.
- */
- private void run360OrbitSpin() {
- stopTour();
- isPlaying = true;
+ private void pauseTour() {
+ isPlaying = false;
updatePlayPauseButtonState();
-
- final LatLng targetCenter = AIRPLANE_FLIGHT_PATH.get(0);
- updateAirplaneModel(targetCenter, 105.0 + 180.0);
-
- final long frameMs = 16L;
- final long totalMs = 6000L;
- final long totalFrames = Math.max(1, totalMs / frameMs);
- final double startHeading = 105.0;
-
- animationRunnable = new Runnable() {
- private long currentFrame = 0;
-
- @Override
- public void run() {
- if (!isPlaying) {
- return;
- }
-
- double t = (double) currentFrame / totalFrames;
- double headingDeg = (startHeading + t * 360.0) % 360.0;
-
- Camera currentCam = new Camera(
- new LatLngAltitude(targetCenter.latitude, targetCenter.longitude, 200.0),
- normalizeHeading(headingDeg),
- 65.0,
- 0.0,
- 600.0
- );
- if (googleMap3D != null) {
- googleMap3D.setCamera(currentCam);
- }
-
- currentFrame++;
- if (currentFrame <= totalFrames) {
- handler.postDelayed(this, frameMs);
- } else {
- isPlaying = false;
- updatePlayPauseButtonState();
- }
- }
- };
- handler.post(animationRunnable);
+ handler.removeCallbacksAndMessages(null);
+ if (tourAnimator != null) {
+ tourAnimator.pause();
+ }
}
private void stopTour() {
isPlaying = false;
updatePlayPauseButtonState();
- if (animationRunnable != null) {
- handler.removeCallbacks(animationRunnable);
- animationRunnable = null;
+ handler.removeCallbacksAndMessages(null);
+ if (tourAnimator != null) {
+ tourAnimator.stop();
}
if (googleMap3D != null) {
+ googleMap3D.setCameraAnimationEndListener(null);
googleMap3D.stopCameraAnimation();
}
}
- /**
- * Resets the camera and airplane model to the initial start location and restarts animation.
- */
- public void resetAndRestartTour() {
+ public void resetTour() {
stopTour();
- currentStepIndex = 0;
-
- LatLng startLoc = AIRPLANE_FLIGHT_PATH.get(0);
- double initialHeading = SphericalUtil.computeHeading(startLoc, AIRPLANE_FLIGHT_PATH.get(1));
- updateAirplaneModel(startLoc, initialHeading + 180.0);
-
- Camera resetCam = new Camera(
- new LatLngAltitude(startLoc.latitude, startLoc.longitude, 200.0),
- normalizeHeading(initialHeading),
- 65.0,
- 0.0,
- 600.0
- );
+ tourAnimator = null;
+
+ if (googleMap3D != null) {
+ googleMap3D.setCamera(getInitialCamera());
+ googleMap3D.flyCameraTo(new FlyToOptions(getInitialCamera(), 0L));
+ }
+
+ double planeHeading = SphericalUtil.computeHeading(GOLDEN_GATE_BRIDGE, COIT_TOWER);
+ updateAirplaneModel(GOLDEN_GATE_BRIDGE, planeHeading + 180.0);
+
+ if (tvTourStatus != null) {
+ tvTourStatus.setText(R.string.aerial_tour_status_idle);
+ }
+ }
+
+ private void updateAirplaneModel(LatLng position, double planeHeadingDeg) {
if (googleMap3D != null) {
- googleMap3D.setCamera(resetCam);
+ ModelOptions modelOptions = new ModelOptions();
+ modelOptions.setId(MODEL_ID);
+ modelOptions.setPosition(new LatLngAltitude(position.latitude, position.longitude, 200.0));
+ modelOptions.setUrl(PLANE_URL);
+ modelOptions.setAltitudeMode(AltitudeMode.ABSOLUTE);
+ modelOptions.setScale(new Vector3D(0.08, 0.08, 0.08));
+ modelOptions.setOrientation(
+ new Orientation(
+ /* heading= */ normalizeHeading(planeHeadingDeg),
+ /* tilt= */ -90.0,
+ /* roll= */ 0.0));
+
+ airplaneModel = googleMap3D.addModel(modelOptions);
}
+ }
- handler.postDelayed(this::startSelectedApproach, 300L);
+ private void updatePlayPauseButtonState() {
+ if (btnPlayPause != null) {
+ btnPlayPause.setText(isPlaying ? R.string.pause : R.string.play);
+ }
}
private static double normalizeHeading(double headingDeg) {
@@ -569,20 +314,9 @@ private static double normalizeHeading(double headingDeg) {
return normalized < 0.0 ? normalized + 360.0 : normalized;
}
- private static double interpolateAngle(double start, double end, double fraction) {
- double diff = (end - start) % 360.0;
- if (diff > 180.0) {
- diff -= 360.0;
- }
- if (diff < -180.0) {
- diff += 360.0;
- }
- return (start + diff * fraction + 360.0) % 360.0;
- }
-
@Override
protected void onPause() {
super.onPause();
- stopTour();
+ pauseTour();
}
}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AnimationStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AnimationStep.java
new file mode 100644
index 00000000..d8253cc6
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/AnimationStep.java
@@ -0,0 +1,62 @@
+/*
+ * 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.advancedcameraanimation;
+
+import androidx.annotation.NonNull;
+import com.google.android.gms.maps3d.GoogleMap3D;
+
+/**
+ * Abstract base class for camera animation steps.
+ */
+public abstract class AnimationStep implements KeyframeStep {
+
+ private final String title;
+ private final String description;
+ private final long durationMs;
+ protected GoogleMap3D activeMap;
+
+ public AnimationStep(@NonNull String title, @NonNull String description, long durationMs) {
+ this.title = title;
+ this.description = description;
+ this.durationMs = durationMs;
+ }
+
+ @NonNull
+ @Override
+ public String getTitle() {
+ return title;
+ }
+
+ @NonNull
+ @Override
+ public String getDescription() {
+ return description;
+ }
+
+ public long getDurationMs() {
+ return durationMs;
+ }
+
+ @Override
+ public void cancel() {
+ if (activeMap != null) {
+ activeMap.setCameraAnimationEndListener(null);
+ activeMap.stopCameraAnimation();
+ activeMap = null;
+ }
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/DwellStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/DwellStep.java
new file mode 100644
index 00000000..a206d473
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/DwellStep.java
@@ -0,0 +1,75 @@
+/*
+ * 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.advancedcameraanimation;
+
+import android.os.Handler;
+import android.os.Looper;
+import androidx.annotation.NonNull;
+import com.google.android.gms.maps3d.GoogleMap3D;
+
+/**
+ * Keyframe step that pauses for a specified duration without animating the camera.
+ */
+public class DwellStep implements KeyframeStep {
+
+ private final String title;
+ private final String description;
+ private final long durationMs;
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private Runnable pendingRunnable;
+
+ public DwellStep(@NonNull String title, @NonNull String description, long durationMs) {
+ this.title = title;
+ this.description = description;
+ this.durationMs = durationMs;
+ }
+
+ public DwellStep(long durationMs) {
+ this("Dwell Pause", "Observing current location", durationMs);
+ }
+
+ @NonNull
+ @Override
+ public String getTitle() {
+ return title;
+ }
+
+ @NonNull
+ @Override
+ public String getDescription() {
+ return description;
+ }
+
+ public long getDurationMs() {
+ return durationMs;
+ }
+
+ @Override
+ public void execute(@NonNull GoogleMap3D map, @NonNull StepCallback callback) {
+ cancel();
+ pendingRunnable = callback::onComplete;
+ handler.postDelayed(pendingRunnable, durationMs);
+ }
+
+ @Override
+ public void cancel() {
+ if (pendingRunnable != null) {
+ handler.removeCallbacks(pendingRunnable);
+ pendingRunnable = null;
+ }
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyAroundStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyAroundStep.java
new file mode 100644
index 00000000..851ad685
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyAroundStep.java
@@ -0,0 +1,72 @@
+/*
+ * 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.advancedcameraanimation;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.android.gms.maps3d.GoogleMap3D;
+import com.google.android.gms.maps3d.model.FlyAroundOptions;
+
+/**
+ * Animation step that executes an orbital camera fly-around using the native Maps 3D SDK.
+ */
+public class FlyAroundStep extends AnimationStep {
+
+ private final FlyAroundOptions options;
+ private final Runnable onStartAction;
+
+ public FlyAroundStep(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull FlyAroundOptions options,
+ long durationMs,
+ @Nullable Runnable onStartAction) {
+ super(title, description, durationMs);
+ this.options = options;
+ this.onStartAction = onStartAction;
+ }
+
+ public FlyAroundStep(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull FlyAroundOptions options,
+ long durationMs) {
+ this(title, description, options, durationMs, null);
+ }
+
+ @NonNull
+ public FlyAroundOptions getOptions() {
+ return options;
+ }
+
+ @Override
+ public void execute(@NonNull GoogleMap3D map, @NonNull StepCallback callback) {
+ cancel();
+ this.activeMap = map;
+ if (onStartAction != null) {
+ onStartAction.run();
+ }
+ map.setCameraAnimationEndListener(() -> {
+ if (activeMap != null) {
+ activeMap.setCameraAnimationEndListener(null);
+ activeMap = null;
+ }
+ callback.onComplete();
+ });
+ map.flyCameraAround(options);
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyToStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyToStep.java
new file mode 100644
index 00000000..011432f0
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/FlyToStep.java
@@ -0,0 +1,81 @@
+/*
+ * 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.advancedcameraanimation;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.android.gms.maps3d.GoogleMap3D;
+import com.google.android.gms.maps3d.model.Camera;
+import com.google.android.gms.maps3d.model.FlyToOptions;
+
+/**
+ * Animation step that flies the camera to a target position using native Maps 3D SDK animations.
+ */
+public class FlyToStep extends AnimationStep {
+
+ private final FlyToOptions options;
+ private final Runnable onStartAction;
+
+ public FlyToStep(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull FlyToOptions options,
+ long durationMs,
+ @Nullable Runnable onStartAction) {
+ super(title, description, durationMs);
+ this.options = options;
+ this.onStartAction = onStartAction;
+ }
+
+ public FlyToStep(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull FlyToOptions options,
+ long durationMs) {
+ this(title, description, options, durationMs, null);
+ }
+
+ public FlyToStep(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull Camera targetCamera,
+ long durationMs) {
+ this(title, description, new FlyToOptions(targetCamera, durationMs), durationMs, null);
+ }
+
+ @NonNull
+ public FlyToOptions getOptions() {
+ return options;
+ }
+
+ @Override
+ public void execute(@NonNull GoogleMap3D map, @NonNull StepCallback callback) {
+ cancel();
+ this.activeMap = map;
+ if (onStartAction != null) {
+ onStartAction.run();
+ }
+ map.setCameraAnimationEndListener(() -> {
+ if (activeMap != null) {
+ activeMap.setCameraAnimationEndListener(null);
+ activeMap = null;
+ }
+ callback.onComplete();
+ });
+ map.flyCameraTo(options);
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/KeyframeStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/KeyframeStep.java
new file mode 100644
index 00000000..22464d17
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/KeyframeStep.java
@@ -0,0 +1,51 @@
+/*
+ * 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.advancedcameraanimation;
+
+import androidx.annotation.NonNull;
+import com.google.android.gms.maps3d.GoogleMap3D;
+
+/**
+ * Interface representing an executable keyframe step in a multi-step camera tour.
+ */
+public interface KeyframeStep {
+
+ /**
+ * Returns human-readable title for this step.
+ */
+ @NonNull
+ String getTitle();
+
+ /**
+ * Returns description of what occurs during this step.
+ */
+ @NonNull
+ String getDescription();
+
+ /**
+ * Executes this step against the provided non-null {@link GoogleMap3D} instance.
+ *
+ * @param map The map instance to manipulate.
+ * @param callback Callback to invoke when this step has finished.
+ */
+ void execute(@NonNull GoogleMap3D map, @NonNull StepCallback callback);
+
+ /**
+ * Cancels this step if it is currently executing, clearing any scheduled callbacks or animations.
+ */
+ void cancel();
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/Map3DAnimator.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/Map3DAnimator.java
new file mode 100644
index 00000000..cab7684d
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/Map3DAnimator.java
@@ -0,0 +1,267 @@
+/*
+ * 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.advancedcameraanimation;
+
+import android.os.Handler;
+import android.os.Looper;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.android.gms.maps3d.GoogleMap3D;
+import com.google.android.gms.maps3d.model.Camera;
+import com.google.android.gms.maps3d.model.FlyAroundOptions;
+import com.google.android.gms.maps3d.model.FlyToOptions;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Orchestrates multi-step keyframe camera animations on a {@link GoogleMap3D}.
+ *
+ * Supports sequential playback, step lifecycle callbacks, pause, resume, and cancellation.
+ */
+public class Map3DAnimator {
+
+ /**
+ * Listener interface for monitoring animator lifecycle events.
+ */
+ public interface Listener {
+
+ /**
+ * Called when a keyframe step begins execution.
+ */
+ default void onStepStarted(int index, @NonNull KeyframeStep step) {}
+
+ /**
+ * Called when a keyframe step successfully finishes.
+ */
+ default void onStepCompleted(int index, @NonNull KeyframeStep step) {}
+
+ /**
+ * Called when all steps in the animation sequence have completed.
+ */
+ default void onAnimationFinished() {}
+
+ /**
+ * Called when the animation sequence is stopped or cancelled.
+ */
+ default void onAnimationCancelled() {}
+ }
+
+ private final List steps;
+ private final Handler mainHandler = new Handler(Looper.getMainLooper());
+ private GoogleMap3D activeMap;
+ private Listener listener;
+ private int currentStepIndex = 0;
+ private boolean isPlaying = false;
+ private KeyframeStep currentlyExecutingStep;
+
+ public Map3DAnimator(@NonNull List steps) {
+ this.steps = Collections.unmodifiableList(new ArrayList<>(steps));
+ }
+
+ @NonNull
+ public List getSteps() {
+ return steps;
+ }
+
+ public int getCurrentStepIndex() {
+ return currentStepIndex;
+ }
+
+ public boolean isPlaying() {
+ return isPlaying;
+ }
+
+ /**
+ * Starts or restarts the keyframe animation sequence from the beginning.
+ */
+ public void start(@NonNull GoogleMap3D map, @Nullable Listener listener) {
+ stop();
+ this.activeMap = map;
+ this.listener = listener;
+ this.isPlaying = true;
+ this.currentStepIndex = 0;
+ mainHandler.post(this::executeNextStep);
+ }
+
+ /**
+ * Pauses the currently running animation step.
+ */
+ public void pause() {
+ if (!isPlaying) {
+ return;
+ }
+ isPlaying = false;
+ if (currentlyExecutingStep != null) {
+ currentlyExecutingStep.cancel();
+ currentlyExecutingStep = null;
+ }
+ if (activeMap != null) {
+ activeMap.setCameraAnimationEndListener(null);
+ activeMap.stopCameraAnimation();
+ }
+ }
+
+ /**
+ * Resumes playback from the current step index.
+ */
+ public void resume() {
+ if (isPlaying || activeMap == null || currentStepIndex >= steps.size()) {
+ return;
+ }
+ isPlaying = true;
+ mainHandler.post(this::executeNextStep);
+ }
+
+ /**
+ * Stops the animation, cancels executing steps, and resets step index to 0.
+ */
+ public void stop() {
+ isPlaying = false;
+ if (currentlyExecutingStep != null) {
+ currentlyExecutingStep.cancel();
+ currentlyExecutingStep = null;
+ }
+ if (activeMap != null) {
+ activeMap.setCameraAnimationEndListener(null);
+ activeMap.stopCameraAnimation();
+ activeMap = null;
+ }
+ currentStepIndex = 0;
+ }
+
+ private void executeNextStep() {
+ if (!isPlaying || activeMap == null) {
+ return;
+ }
+
+ if (currentStepIndex >= steps.size()) {
+ isPlaying = false;
+ if (listener != null) {
+ listener.onAnimationFinished();
+ }
+ return;
+ }
+
+ KeyframeStep step = steps.get(currentStepIndex);
+ currentlyExecutingStep = step;
+
+ if (listener != null) {
+ listener.onStepStarted(currentStepIndex, step);
+ }
+
+ step.execute(
+ activeMap,
+ () -> mainHandler.post(() -> {
+ if (!isPlaying) {
+ return;
+ }
+ currentlyExecutingStep = null;
+ if (listener != null) {
+ listener.onStepCompleted(currentStepIndex, step);
+ }
+ currentStepIndex++;
+ executeNextStep();
+ }));
+ }
+
+ /**
+ * Builder for constructing a {@link Map3DAnimator} with a fluent API.
+ */
+ public static class Builder {
+
+ private final List steps = new ArrayList<>();
+
+ public Builder addStep(@NonNull KeyframeStep step) {
+ steps.add(step);
+ return this;
+ }
+
+ public Builder flyTo(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull Camera targetCamera,
+ long durationMs) {
+ return addStep(new FlyToStep(title, description, targetCamera, durationMs));
+ }
+
+ public Builder flyTo(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull FlyToOptions options,
+ long durationMs,
+ @Nullable Runnable onStartAction) {
+ return addStep(new FlyToStep(title, description, options, durationMs, onStartAction));
+ }
+
+ public Builder flyTo(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull FlyToOptions options,
+ long durationMs) {
+ return addStep(new FlyToStep(title, description, options, durationMs));
+ }
+
+ public Builder dwell(
+ @NonNull String title,
+ @NonNull String description,
+ long durationMs) {
+ return addStep(new DwellStep(title, description, durationMs));
+ }
+
+ public Builder dwell(long durationMs) {
+ return addStep(new DwellStep(durationMs));
+ }
+
+ public Builder flyAround(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull FlyAroundOptions options,
+ long durationMs,
+ @Nullable Runnable onStartAction) {
+ return addStep(new FlyAroundStep(title, description, options, durationMs, onStartAction));
+ }
+
+ public Builder flyAround(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull FlyAroundOptions options,
+ long durationMs) {
+ return addStep(new FlyAroundStep(title, description, options, durationMs));
+ }
+
+ public Builder orbit(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull OrbitOptions options) {
+ return addStep(new OrbitStep(title, description, options));
+ }
+
+ public Builder orbit(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull OrbitOptions options,
+ @Nullable OrbitStep.OnOrbitFrameListener frameListener) {
+ return addStep(new OrbitStep(title, description, options, frameListener));
+ }
+
+ @NonNull
+ public Map3DAnimator build() {
+ return new Map3DAnimator(steps);
+ }
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitOptions.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitOptions.java
new file mode 100644
index 00000000..3d1c66c6
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitOptions.java
@@ -0,0 +1,138 @@
+/*
+ * 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.advancedcameraanimation;
+
+import androidx.annotation.NonNull;
+import com.google.android.gms.maps.model.LatLng;
+
+/**
+ * Immutable configuration options for orbital camera animations.
+ *
+ * Use {@link OrbitOptions.Builder} to construct instances with clear, self-documenting parameters.
+ */
+public class OrbitOptions {
+
+ private final LatLng center;
+ private final double altitude;
+ private final double tilt;
+ private final double range;
+ private final double startHeading;
+ private final double endHeading;
+ private final long durationMs;
+
+ private OrbitOptions(Builder builder) {
+ this.center = builder.center;
+ this.altitude = builder.altitude;
+ this.tilt = builder.tilt;
+ this.range = builder.range;
+ this.startHeading = builder.startHeading;
+ this.endHeading = builder.endHeading;
+ this.durationMs = builder.durationMs;
+ }
+
+ @NonNull
+ public LatLng getCenter() {
+ return center;
+ }
+
+ public double getAltitude() {
+ return altitude;
+ }
+
+ public double getTilt() {
+ return tilt;
+ }
+
+ public double getRange() {
+ return range;
+ }
+
+ public double getStartHeading() {
+ return startHeading;
+ }
+
+ public double getEndHeading() {
+ return endHeading;
+ }
+
+ public long getDurationMs() {
+ return durationMs;
+ }
+
+ /**
+ * Builder for creating {@link OrbitOptions} with named, self-describing parameters.
+ */
+ public static class Builder {
+
+ private LatLng center;
+ private double altitude = 200.0;
+ private double tilt = 65.0;
+ private double range = 600.0;
+ private double startHeading = 0.0;
+ private double endHeading = 360.0;
+ private long durationMs = 4000L;
+
+ public Builder setCenter(@NonNull LatLng center) {
+ this.center = center;
+ return this;
+ }
+
+ public Builder setAltitude(double altitude) {
+ this.altitude = altitude;
+ return this;
+ }
+
+ public Builder setTilt(double tilt) {
+ this.tilt = tilt;
+ return this;
+ }
+
+ public Builder setRange(double range) {
+ this.range = range;
+ return this;
+ }
+
+ public Builder setStartHeading(double startHeading) {
+ this.startHeading = startHeading;
+ return this;
+ }
+
+ public Builder setEndHeading(double endHeading) {
+ this.endHeading = endHeading;
+ return this;
+ }
+
+ public Builder setHeadingRange(double startHeading, double endHeading) {
+ this.startHeading = startHeading;
+ this.endHeading = endHeading;
+ return this;
+ }
+
+ public Builder setDurationMs(long durationMs) {
+ this.durationMs = durationMs;
+ return this;
+ }
+
+ @NonNull
+ public OrbitOptions build() {
+ if (center == null) {
+ throw new IllegalStateException("Center LatLng must not be null for OrbitOptions");
+ }
+ return new OrbitOptions(this);
+ }
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitStep.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitStep.java
new file mode 100644
index 00000000..b70c7b1c
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/OrbitStep.java
@@ -0,0 +1,134 @@
+/*
+ * 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.advancedcameraanimation;
+
+import android.os.Handler;
+import android.os.Looper;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.google.android.gms.maps3d.GoogleMap3D;
+import com.google.android.gms.maps3d.model.Camera;
+import com.google.android.gms.maps3d.model.LatLngAltitude;
+
+/**
+ * Keyframe step that runs a smooth frame-by-frame orbital spin around a landmark.
+ */
+public class OrbitStep extends AnimationStep {
+
+ /**
+ * Callback invoked on every frame of the orbital animation.
+ */
+ @FunctionalInterface
+ public interface OnOrbitFrameListener {
+ void onFrame(double fraction, double currentHeading);
+ }
+
+ private final OrbitOptions options;
+ private final OnOrbitFrameListener frameListener;
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private Runnable frameRunnable;
+
+ public OrbitStep(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull OrbitOptions options,
+ @Nullable OnOrbitFrameListener frameListener) {
+ super(title, description, options.getDurationMs());
+ this.options = options;
+ this.frameListener = frameListener;
+ }
+
+ public OrbitStep(
+ @NonNull String title,
+ @NonNull String description,
+ @NonNull OrbitOptions options) {
+ this(title, description, options, null);
+ }
+
+ @NonNull
+ public OrbitOptions getOptions() {
+ return options;
+ }
+
+ @Override
+ public void execute(@NonNull GoogleMap3D map, @NonNull StepCallback callback) {
+ cancel();
+ this.activeMap = map;
+
+ final long frameMs = 16L;
+ final long totalFrames = Math.max(1, options.getDurationMs() / frameMs);
+
+ frameRunnable = new Runnable() {
+ private long currentFrame = 0;
+
+ @Override
+ public void run() {
+ if (activeMap == null) {
+ return;
+ }
+
+ double t = (double) currentFrame / totalFrames;
+ double heading = interpolateAngle(options.getStartHeading(), options.getEndHeading(), t);
+
+ if (frameListener != null) {
+ frameListener.onFrame(t, heading);
+ }
+
+ Camera orbitCam = new Camera(
+ new LatLngAltitude(
+ options.getCenter().latitude,
+ options.getCenter().longitude,
+ options.getAltitude()),
+ normalizeHeading(heading),
+ options.getTilt(),
+ 0.0,
+ options.getRange()
+ );
+ activeMap.setCamera(orbitCam);
+
+ currentFrame++;
+ if (currentFrame <= totalFrames) {
+ handler.postDelayed(this, frameMs);
+ } else {
+ activeMap = null;
+ frameRunnable = null;
+ callback.onComplete();
+ }
+ }
+ };
+
+ handler.post(frameRunnable);
+ }
+
+ @Override
+ public void cancel() {
+ super.cancel();
+ if (frameRunnable != null) {
+ handler.removeCallbacks(frameRunnable);
+ frameRunnable = null;
+ }
+ }
+
+ private static double normalizeHeading(double headingDeg) {
+ double normalized = headingDeg % 360.0;
+ return normalized < 0.0 ? normalized + 360.0 : normalized;
+ }
+
+ private static double interpolateAngle(double start, double end, double fraction) {
+ return start + (end - start) * fraction;
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/StepCallback.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/StepCallback.java
new file mode 100644
index 00000000..0d215ae3
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/advancedcameraanimation/StepCallback.java
@@ -0,0 +1,29 @@
+/*
+ * 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.advancedcameraanimation;
+
+/**
+ * Callback invoked when an individual {@link KeyframeStep} finishes execution.
+ */
+@FunctionalInterface
+public interface StepCallback {
+
+ /**
+ * Invoked when the keyframe step has completed its execution.
+ */
+ void onComplete();
+}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/cloudstyling/CloudStylingActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/cloudstyling/CloudStylingActivity.java
index 6997460a..a8599bed 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/cloudstyling/CloudStylingActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/cloudstyling/CloudStylingActivity.java
@@ -16,15 +16,88 @@
package com.example.maps3djava.cloudstyling;
+import static com.example.maps3d.common.UtilitiesKt.toValidCamera;
+
+import android.os.Bundle;
+import android.widget.RadioGroup;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import com.example.maps3dcommon.R;
-import com.example.maps3djava.common.BaseSkeletonActivity;
+import com.example.maps3djava.sampleactivity.SampleBaseActivity;
+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.Map3DMode;
+import com.google.android.material.appbar.MaterialToolbar;
/**
- * Skeleton activity for CloudStylingActivity.
+ * Showcases **Cloud-Based Map Styling** in Google Maps 3D SDK (Java implementation). Uses
+ * standalone layout [R.layout.activity_cloud_styling] with declarative custom Map ID.
*/
-public class CloudStylingActivity extends BaseSkeletonActivity {
- @Override
- protected int getTitleResId() {
- return R.string.feature_title_cloud_styling;
+public class CloudStylingActivity extends SampleBaseActivity {
+
+ public static final LatLng SF_LOCATION = new LatLng(37.7915, -122.4010);
+
+ private int currentMapMode = Map3DMode.ROADMAP;
+
+ @NonNull
+ @Override
+ public String getTAG() {
+ return getClass().getSimpleName();
+ }
+
+ @NonNull
+ @Override
+ public Camera getInitialCamera() {
+ return toValidCamera(new Camera(
+ new LatLngAltitude(SF_LOCATION.latitude, SF_LOCATION.longitude, 250.0),
+ 45.0,
+ 65.0,
+ 0.0,
+ 800.0
+ ));
+ }
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Set dedicated standalone layout with declarative mapId="9a35234a36da44d2c47bf626"
+ setContentView(R.layout.activity_cloud_styling);
+
+ MaterialToolbar topBar = findViewById(R.id.top_bar);
+ if (topBar != null) {
+ topBar.setTitle(R.string.feature_title_cloud_styling);
+ topBar.setNavigationOnClickListener(v -> finish());
+ }
+
+ map3DView = findViewById(R.id.map3dView);
+ if (map3DView != null) {
+ map3DView.onCreate(savedInstanceState);
+ map3DView.getMap3DViewAsync(this);
}
+
+ RadioGroup rgMapMode = findViewById(R.id.rg_map_mode);
+ if (rgMapMode != null) {
+ rgMapMode.setOnCheckedChangeListener((group, checkedId) -> {
+ int newMode = Map3DMode.ROADMAP;
+ if (checkedId == R.id.rb_hybrid) {
+ newMode = Map3DMode.HYBRID;
+ } else if (checkedId == R.id.rb_satellite) {
+ newMode = Map3DMode.SATELLITE;
+ }
+ currentMapMode = newMode;
+ if (googleMap3D != null) {
+ googleMap3D.setMapMode(newMode);
+ }
+ });
+ }
+ }
+
+ @Override
+ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
+ super.onMap3DViewReady(googleMap3D);
+ googleMap3D.setMapMode(currentMapMode);
+ }
}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java
index 8f3578d3..242e9b4b 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/datavisualization/DataVisualizationActivity.java
@@ -22,13 +22,16 @@
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
+import android.transition.TransitionManager;
+import android.view.MotionEvent;
+import android.view.View;
import android.view.ViewGroup;
-import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.cardview.widget.CardView;
import com.example.maps3dcommon.R;
import com.example.maps3djava.sampleactivity.SampleBaseActivity;
@@ -42,22 +45,50 @@
import com.google.android.gms.maps3d.model.Polygon;
import com.google.android.gms.maps3d.model.PolygonOptions;
import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.button.MaterialButton;
import com.google.android.material.slider.Slider;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
-import java.util.Locale;
/**
- * Showcases dynamic 3D volume extrusion in Google Maps 3D SDK (Java) by simulating elevated flood
- * tides.
+ * =================================================================================================
+ * Data Visualization: 3D Extruded Flood Simulation (Java)
+ * =================================================================================================
+ *
+ * This sample demonstrates how to render and dynamically animate volumetric 3D extruded polygons
+ * using the Google Maps 3D SDK.
+ *
+ * Key Concepts Demonstrated:
+ * 1. 3D Volumetric Polygon Extrusion:
+ * - Uses {@link PolygonOptions#setExtruded(boolean)} to generate 3D vertical walls extending
+ * from ground level up to an absolute altitude ceiling.
+ * - Configures {@link AltitudeMode#ABSOLUTE} so the polygon elevation represents true mean sea
+ * level (MSL) altitude rather than terrain-relative offsets.
+ *
+ * 2. Real-Time Elevation Updates & Animation:
+ * - Updates the polygon altitude in real-time in response to slider gestures or an automated
+ * continuous tide simulation loop.
+ * - Re-uses a static {@link PolygonOptions#setId(String)} to upsert the polygon in place within
+ * the Maps 3D rendering engine.
+ *
+ * 3. Modern Material UI & Collapse Affordances:
+ * - Provides a bottom overlay card with dynamic flood elevation readouts and risk badges.
+ * - Features a header toggle with {@link MaterialButton} to expand/collapse controls for
+ * unobstructed 3D scene inspection.
*/
public class DataVisualizationActivity extends SampleBaseActivity {
+ // --- Constants & Geographical Bounds ---
+
+ /** Focal viewpoint centered on the San Francisco Embarcadero waterfront. */
public static final LatLng SF_FLOOD_CENTER = new LatLng(37.8025, -122.4030);
+
+ /** Stable identifier for upserting the flood polygon in the 3D map engine. */
private static final String POLYGON_ID = "flood_zone_polygon";
+ /** Boundary coordinates outlining the San Francisco waterfront flood study area. */
public static final List floodZoneCoords = Arrays.asList(
new double[]{37.805156, -122.403256},
new double[]{37.803370, -122.401287},
@@ -67,21 +98,49 @@ public class DataVisualizationActivity extends SampleBaseActivity {
new double[]{37.805156, -122.403256}
);
+ /** Translucent water body fill color. */
private final int waterFillColor = Color.argb(140, 230, 40, 40);
+
+ /** Opaque perimeter boundary stroke color. */
private final int waterStrokeColor = Color.argb(255, 180, 0, 0);
+
+ /** Width of the polygon boundary line in screen pixels. */
private final double waterStrokeWidth = 2.5;
+ // --- UI Elements ---
+
+ private CardView controlsCard;
+ private View cardHeader;
+ private View cardContent;
+ private MaterialButton btnCollapse;
private TextView floodDepthLabel;
private TextView floodRiskBadge;
private Slider floodSlider;
- private Button btnAnimateFlood;
+ private MaterialButton btnAnimateFlood;
+
+ // --- State Variables ---
private Polygon floodPolygon = null;
private double currentFloodElevation = 10.0;
+ private boolean isSimulating = false;
+ private boolean isCollapsed = false;
+
+ // --- Handlers & Runnables ---
private final Handler simulationHandler = new Handler(Looper.getMainLooper());
private Runnable simulationRunnable;
- private boolean isSimulating = false;
+
+ private final Handler fadeHandler = new Handler(Looper.getMainLooper());
+ private final Runnable fadeOutRunnable = () -> {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(0.85f)
+ .setDuration(400)
+ .start();
+ }
+ };
+
+ // --- Base Activity Overrides ---
@NonNull
@Override
@@ -101,10 +160,18 @@ public Camera getInitialCamera() {
));
}
+ // --- Lifecycle & Initialization ---
+
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+ // Hide base pill scroll view as this activity manages its own overlay card
+ View baseScrollView = findViewById(R.id.control_scroll_view);
+ if (baseScrollView != null) {
+ baseScrollView.setVisibility(View.GONE);
+ }
+
ViewGroup container = findViewById(R.id.map_container);
if (container != null) {
getLayoutInflater().inflate(R.layout.control_panel_data_visualization, container, true);
@@ -116,11 +183,44 @@ protected void onCreate(@Nullable Bundle savedInstanceState) {
topBar.setNavigationOnClickListener(v -> finish());
}
+ initViews();
+ updateControlLabels(currentFloodElevation);
+ }
+
+ /**
+ * Initializes view references and wires up touch and click listeners.
+ */
+ private void initViews() {
+ controlsCard = findViewById(R.id.control_panel);
+ cardHeader = findViewById(R.id.card_header);
+ cardContent = findViewById(R.id.card_content);
+ btnCollapse = findViewById(R.id.btn_collapse);
+
floodDepthLabel = findViewById(R.id.tv_flood_depth_label);
floodRiskBadge = findViewById(R.id.tv_flood_risk_badge);
floodSlider = findViewById(R.id.flood_slider);
btnAnimateFlood = findViewById(R.id.btn_animate_flood);
+ if (btnCollapse != null) {
+ btnCollapse.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
+ if (cardHeader != null) {
+ cardHeader.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
if (floodSlider != null) {
floodSlider.addOnChangeListener((slider, value, fromUser) -> {
if (fromUser) {
@@ -139,8 +239,72 @@ protected void onCreate(@Nullable Bundle savedInstanceState) {
}
});
}
+
+ // Schedule subtle initial auto-fade for unobstructed viewing
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ // --- UI Collapse / Expand Mechanics ---
+
+ /**
+ * Collapses the control card downward, leaving only the title header visible.
+ */
+ private void collapseControls() {
+ if (controlsCard == null || cardContent == null) {
+ return;
+ }
+ if (isCollapsed) {
+ return;
+ }
+ isCollapsed = true;
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_less_24px);
+ btnCollapse.setContentDescription(getString(R.string.expand_controls));
+ }
+ TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.GONE);
+ }
+
+ /**
+ * Expands the control card back to its full height.
+ */
+ private void expandControls() {
+ if (controlsCard == null || cardContent == null) {
+ return;
+ }
+ if (!isCollapsed) {
+ return;
+ }
+ isCollapsed = false;
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_more_24px);
+ btnCollapse.setContentDescription(getString(R.string.collapse_controls));
+ }
+ TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.VISIBLE);
+
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ if (ev.getAction() == MotionEvent.ACTION_DOWN || ev.getAction() == MotionEvent.ACTION_MOVE) {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(1.0f)
+ .setDuration(150)
+ .start();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+ }
+ return super.dispatchTouchEvent(ev);
}
+ // --- 3D Map Setup & Extrusion Engine ---
+
@Override
public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
super.onMap3DViewReady(googleMap3D);
@@ -154,50 +318,34 @@ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
});
}
+ /**
+ * Updates the 3D polygon height and refreshes formatted textual status indicators.
+ *
+ * @param currentFloodHeightMeters Target sea level elevation in meters.
+ */
public void updateFloodElevation(double currentFloodHeightMeters) {
currentFloodElevation = currentFloodHeightMeters;
runOnUiThread(() -> {
- double feet = currentFloodHeightMeters * 3.28084;
- if (floodDepthLabel != null) {
- floodDepthLabel.setText(
- String.format(Locale.US, "Flood Elevation: +%.1f m (%.1f ft)", currentFloodHeightMeters,
- feet));
- }
+ updateControlLabels(currentFloodHeightMeters);
- if (floodRiskBadge != null) {
- if (currentFloodHeightMeters <= 2.0) {
- floodRiskBadge.setText("🌊 Baseline Tide");
- floodRiskBadge.setTextColor(Color.parseColor("#008800"));
- floodRiskBadge.setBackgroundColor(Color.parseColor("#2000AA00"));
- } else if (currentFloodHeightMeters <= 8.0) {
- floodRiskBadge.setText("⚠️ Minor Inundation");
- floodRiskBadge.setTextColor(Color.parseColor("#BB7700"));
- floodRiskBadge.setBackgroundColor(Color.parseColor("#20FFAA00"));
- } else if (currentFloodHeightMeters <= 20.0) {
- floodRiskBadge.setText("🌊 Moderate Flooding");
- floodRiskBadge.setTextColor(Color.parseColor("#0077CC"));
- floodRiskBadge.setBackgroundColor(Color.parseColor("#200088FF"));
- } else if (currentFloodHeightMeters <= 35.0) {
- floodRiskBadge.setText("🚨 Storm Surge (Cat 3)");
- floodRiskBadge.setTextColor(Color.parseColor("#DD4400"));
- floodRiskBadge.setBackgroundColor(Color.parseColor("#25FF5500"));
- } else {
- floodRiskBadge.setText("⛔ Extreme Inundation");
- floodRiskBadge.setTextColor(Color.parseColor("#CC0000"));
- floodRiskBadge.setBackgroundColor(Color.parseColor("#25FF0000"));
- }
+ if (googleMap3D == null) {
+ return;
}
- if (googleMap3D == null) {
- return;
- }
-
+ // Build 3D path vertices at the specified absolute altitude
List path = new ArrayList<>();
for (double[] coord : floodZoneCoords) {
path.add(new LatLngAltitude(coord[0], coord[1], currentFloodHeightMeters));
}
+ // Volumetric 3D Polygon Extrusion Technique:
+ // 1. AltitudeMode.ABSOLUTE: Water elevation represents true Mean Sea Level (MSL).
+ // Unlike RELATIVE_TO_GROUND, ABSOLUTE ensures a flat, uniform horizontal water plane.
+ // 2. setExtruded(true): Instructs the 3D rendering engine to drop vertical skirt walls
+ // from the polygon vertices down to the ground terrain mesh, forming a 3D volumetric water body.
+ // 3. setId(POLYGON_ID): Re-using a stable ID upserts the existing polygon in place,
+ // eliminating render flickering during rapid slider or animation updates.
PolygonOptions options = new PolygonOptions();
options.setId(POLYGON_ID);
options.setPath(path);
@@ -213,14 +361,53 @@ public void updateFloodElevation(double currentFloodHeightMeters) {
if (floodPolygon != null) {
floodPolygon.setClickListener(() -> runOnUiThread(() -> Toast.makeText(
DataVisualizationActivity.this,
- String.format(Locale.US, "San Francisco Waterfront - Water Level: +%.1f m",
- currentFloodElevation),
+ getString(R.string.flood_toast_format, currentFloodElevation),
Toast.LENGTH_SHORT
).show()));
}
});
}
+ /**
+ * Refreshes textual status labels and risk severity badges based on water height.
+ */
+ private void updateControlLabels(double currentFloodHeightMeters) {
+ double feet = currentFloodHeightMeters * 3.28084;
+ if (floodDepthLabel != null) {
+ floodDepthLabel.setText(
+ getString(R.string.flood_elevation_format, currentFloodHeightMeters, feet));
+ }
+
+ if (floodRiskBadge != null) {
+ if (currentFloodHeightMeters <= 2.0) {
+ floodRiskBadge.setText(R.string.flood_risk_baseline);
+ floodRiskBadge.setTextColor(Color.parseColor("#008800"));
+ floodRiskBadge.setBackgroundColor(Color.parseColor("#2000AA00"));
+ } else if (currentFloodHeightMeters <= 8.0) {
+ floodRiskBadge.setText(R.string.flood_risk_minor);
+ floodRiskBadge.setTextColor(Color.parseColor("#BB7700"));
+ floodRiskBadge.setBackgroundColor(Color.parseColor("#20FFAA00"));
+ } else if (currentFloodHeightMeters <= 20.0) {
+ floodRiskBadge.setText(R.string.flood_risk_moderate);
+ floodRiskBadge.setTextColor(Color.parseColor("#0077CC"));
+ floodRiskBadge.setBackgroundColor(Color.parseColor("#200088FF"));
+ } else if (currentFloodHeightMeters <= 35.0) {
+ floodRiskBadge.setText(R.string.flood_risk_storm_surge);
+ floodRiskBadge.setTextColor(Color.parseColor("#DD4400"));
+ floodRiskBadge.setBackgroundColor(Color.parseColor("#25FF5500"));
+ } else {
+ floodRiskBadge.setText(R.string.flood_risk_extreme);
+ floodRiskBadge.setTextColor(Color.parseColor("#CC0000"));
+ floodRiskBadge.setBackgroundColor(Color.parseColor("#25FF0000"));
+ }
+ }
+ }
+
+ // --- Automated Continuous Simulation Loop ---
+
+ /**
+ * Starts continuous incremental sea level rise simulation.
+ */
private void startSimulation() {
double maxVal = floodSlider != null ? floodSlider.getValueTo() : 100.0;
double minVal = floodSlider != null ? floodSlider.getValueFrom() : 0.0;
@@ -233,15 +420,15 @@ private void startSimulation() {
isSimulating = true;
if (btnAnimateFlood != null) {
- btnAnimateFlood.setText("⏹ Stop Simulation");
+ btnAnimateFlood.setText(R.string.stop_simulation);
}
simulationRunnable = new Runnable() {
@Override
public void run() {
- if (!isSimulating) {
- return;
- }
+ if (!isSimulating) {
+ return;
+ }
double currentMax = floodSlider != null ? floodSlider.getValueTo() : 100.0;
double newElevation = currentFloodElevation + 0.2;
@@ -262,6 +449,9 @@ public void run() {
simulationHandler.post(simulationRunnable);
}
+ /**
+ * Stops the ongoing sea level rise simulation loop.
+ */
private void stopSimulation() {
isSimulating = false;
if (simulationRunnable != null) {
@@ -269,13 +459,28 @@ private void stopSimulation() {
simulationRunnable = null;
}
if (btnAnimateFlood != null) {
- btnAnimateFlood.setText("▶ Start Simulation");
+ btnAnimateFlood.setText(R.string.start_simulation);
}
}
+ // --- Lifecycle Teardown ---
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ stopSimulation();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ }
+
@Override
protected void onDestroy() {
stopSimulation();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (floodPolygon != null) {
+ floodPolygon.remove();
+ floodPolygon = null;
+ }
super.onDestroy();
}
}
+
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/fieldofview/FieldOfViewActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/fieldofview/FieldOfViewActivity.java
index 2d5c352e..f2585ec2 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/fieldofview/FieldOfViewActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/fieldofview/FieldOfViewActivity.java
@@ -16,15 +16,21 @@
package com.example.maps3djava.fieldofview;
+import static com.example.maps3d.common.UtilitiesKt.toValidCamera;
+
import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.MotionEvent;
+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.UtilitiesKt;
import com.example.maps3dcommon.R;
import com.example.maps3djava.sampleactivity.SampleBaseActivity;
import com.google.android.gms.maps.model.LatLng;
@@ -32,122 +38,311 @@
import com.google.android.gms.maps3d.model.Camera;
import com.google.android.gms.maps3d.model.LatLngAltitude;
import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.button.MaterialButton;
import com.google.android.material.slider.Slider;
/**
- * Showcases Field of View (FOV) perspective scaling in Google Maps 3D SDK (Java implementation).
+ * =================================================================================================
+ * Field of View (FOV) Perspective Scaling (Java)
+ * =================================================================================================
+ *
+ * This sample demonstrates perspective Field of View (FOV) scaling and optical dolly-zoom simulation
+ * using the Google Maps 3D SDK.
+ *
+ * Key Concepts Demonstrated:
+ * 1. Perspective Field of View (FOV) Adjustments:
+ * - Adjusts the camera distance (range) proportionally using trigonometric perspective projection
+ * math to simulate optical lens focal length changes (Telephoto, Standard, Wide, Ultra-Wide).
+ * - Formula: range = baseRange * (tan(baseFov / 2) / tan(targetFov / 2)).
+ *
+ * 2. Seamless Camera State Preservation:
+ * - Inspects the active live camera before adjusting perspective, ensuring custom panning,
+ * heading rotations, and tilt angles applied by user gestures are smoothly retained.
*
- * Features:
- * - Interactive FOV slider and quick presets preserving current active map location smoothly.
- * - Robust toValidCamera validation preventing out-of-range heading/tilt crash during manual map rotation.
+ * 3. Modern Material UI & Collapse Affordances:
+ * - Features a bottom control card with dynamic FOV angle readout and quick preset buttons.
+ * - Supports expanding and collapsing the card header to maximize the visible 3D map scene.
+ * - Implements subtle UI idle auto-fade with touch-to-wake responsiveness.
*/
public class FieldOfViewActivity extends SampleBaseActivity {
- public static final LatLng SF_FINANCIAL_DISTRICT = new LatLng(37.7952, -122.4028);
+ // --- Constants & Perspective Geometry ---
+
+ /** Focal landmark centered near the San Francisco Transamerica Pyramid & Financial District. */
+ public static final LatLng SF_FINANCIAL_DISTRICT = new LatLng(37.7952, -122.4028);
+
+ /** Baseline field of view angle in degrees (human eye / standard focal length). */
+ private static final double BASE_FOV_DEGREES = 45.0;
+
+ /** Baseline camera range in meters corresponding to the standard 45° FOV baseline. */
+ private static final double BASE_RANGE_METERS = 800.0;
- private TextView fovSliderLabel;
- private Slider fovSlider;
- private double currentFov = 45.0;
+ /** Minimum optical range boundary in meters. */
+ private static final double MIN_RANGE_METERS = 150.0;
- @NonNull
- @Override
- public String getTAG() {
- return "FieldOfViewActivity";
+ /** Maximum optical range boundary in meters. */
+ private static final double MAX_RANGE_METERS = 3000.0;
+
+ // --- UI Elements ---
+
+ private CardView controlsCard;
+ private View cardHeader;
+ private View cardContent;
+ private MaterialButton btnCollapse;
+ private TextView fovSliderLabel;
+ private Slider fovSlider;
+
+ // --- State Variables ---
+
+ private double currentFov = 45.0;
+ private boolean isCollapsed = false;
+
+ // --- Handlers & Runnables ---
+
+ private final Handler fadeHandler = new Handler(Looper.getMainLooper());
+ private final Runnable fadeOutRunnable = () -> {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(0.85f)
+ .setDuration(400)
+ .start();
}
+ };
+
+ // --- Base Activity Overrides ---
- @NonNull
- @Override
- public Camera getInitialCamera() {
- return UtilitiesKt.toValidCamera(new Camera(
- new LatLngAltitude(SF_FINANCIAL_DISTRICT.latitude, SF_FINANCIAL_DISTRICT.longitude, 150.0),
- 45.0,
- 65.0,
- 0.0,
- 800.0
- ));
+ @NonNull
+ @Override
+ public String getTAG() {
+ return "FieldOfViewActivity";
+ }
+
+ @NonNull
+ @Override
+ public Camera getInitialCamera() {
+ return toValidCamera(new Camera(
+ new LatLngAltitude(SF_FINANCIAL_DISTRICT.latitude, SF_FINANCIAL_DISTRICT.longitude, 150.0),
+ 45.0,
+ 65.0,
+ 0.0,
+ BASE_RANGE_METERS
+ ));
+ }
+
+ // --- Lifecycle & Initialization ---
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Hide base pill scroll view as this activity manages its own overlay card
+ View baseScrollView = findViewById(R.id.control_scroll_view);
+ if (baseScrollView != null) {
+ baseScrollView.setVisibility(View.GONE);
}
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
+ ViewGroup container = findViewById(R.id.map_container);
+ if (container != null) {
+ getLayoutInflater().inflate(R.layout.control_panel_field_of_view, container, true);
+ }
+
+ MaterialToolbar topBar = findViewById(R.id.top_bar);
+ if (topBar != null) {
+ topBar.setTitle(R.string.feature_title_field_of_view);
+ topBar.setNavigationOnClickListener(v -> finish());
+ }
- // Inflate FOV control panel overlay into map container managed by SampleBaseActivity
- ViewGroup container = findViewById(R.id.map_container);
- if (container != null) {
- getLayoutInflater().inflate(R.layout.control_panel_field_of_view, container, true);
+ initViews();
+ }
+
+ /**
+ * Initializes view references and wires up touch and click listeners.
+ */
+ private void initViews() {
+ controlsCard = findViewById(R.id.control_panel);
+ cardHeader = findViewById(R.id.card_header);
+ cardContent = findViewById(R.id.card_content);
+ btnCollapse = findViewById(R.id.btn_collapse);
+
+ fovSliderLabel = findViewById(R.id.fov_slider_label);
+ fovSlider = findViewById(R.id.fov_slider);
+
+ if (btnCollapse != null) {
+ btnCollapse.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
}
+ });
+ }
- MaterialToolbar topBar = findViewById(R.id.top_bar);
- if (topBar != null) {
- topBar.setTitle("Field of View (FOV)");
- topBar.setNavigationOnClickListener(v -> finish());
+ if (cardHeader != null) {
+ cardHeader.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
}
+ });
+ }
- fovSliderLabel = findViewById(R.id.fov_slider_label);
- fovSlider = findViewById(R.id.fov_slider);
+ if (fovSlider != null) {
+ fovSlider.addOnChangeListener((slider, value, fromUser) -> updateFov(value));
+ }
+ Button btnTele = findViewById(R.id.btn_fov_telephoto);
+ if (btnTele != null) {
+ btnTele.setOnClickListener(v -> {
if (fovSlider != null) {
- fovSlider.addOnChangeListener((slider, value, fromUser) -> updateFov(value));
+ fovSlider.setValue(20.0f);
}
+ });
+ }
- Button btnTele = findViewById(R.id.btn_fov_telephoto);
- if (btnTele != null) {
- btnTele.setOnClickListener(v -> { if (fovSlider != null) fovSlider.setValue(20.0f); });
+ Button btnStd = findViewById(R.id.btn_fov_standard);
+ if (btnStd != null) {
+ btnStd.setOnClickListener(v -> {
+ if (fovSlider != null) {
+ fovSlider.setValue(45.0f);
}
+ });
+ }
- Button btnStd = findViewById(R.id.btn_fov_standard);
- if (btnStd != null) {
- btnStd.setOnClickListener(v -> { if (fovSlider != null) fovSlider.setValue(45.0f); });
+ Button btnWide = findViewById(R.id.btn_fov_wide);
+ if (btnWide != null) {
+ btnWide.setOnClickListener(v -> {
+ if (fovSlider != null) {
+ fovSlider.setValue(90.0f);
}
+ });
+ }
- Button btnWide = findViewById(R.id.btn_fov_wide);
- if (btnWide != null) {
- btnWide.setOnClickListener(v -> { if (fovSlider != null) fovSlider.setValue(90.0f); });
+ Button btnUltra = findViewById(R.id.btn_fov_ultrawide);
+ if (btnUltra != null) {
+ btnUltra.setOnClickListener(v -> {
+ if (fovSlider != null) {
+ fovSlider.setValue(120.0f);
}
+ });
+ }
- Button btnUltra = findViewById(R.id.btn_fov_ultrawide);
- if (btnUltra != null) {
- btnUltra.setOnClickListener(v -> { if (fovSlider != null) fovSlider.setValue(120.0f); });
- }
+ // Schedule subtle initial auto-fade for unobstructed viewing
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ // --- UI Collapse / Expand Mechanics ---
+
+ /**
+ * Collapses the control card downward, leaving only the title header visible.
+ */
+ private void collapseControls() {
+ if (controlsCard == null || cardContent == null) {
+ return;
}
+ isCollapsed = true;
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_less_24px);
+ btnCollapse.setContentDescription(getString(R.string.expand_controls));
+ }
+ android.transition.TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.GONE);
+ }
- @Override
- public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
- super.onMap3DViewReady(googleMap3D);
- googleMap3D.setOnMapReadyListener(sceneReadiness -> {
- googleMap3D.setOnMapReadyListener(null);
- runOnUiThread(() -> updateFov((float) currentFov));
- });
- }
-
- private void updateFov(float fovAngle) {
- currentFov = fovAngle;
- runOnUiThread(() -> {
- if (fovSliderLabel != null) {
- fovSliderLabel.setText("Field of View: " + (int) fovAngle + "°");
- }
- });
-
- if (googleMap3D != null) {
- Camera liveCam = googleMap3D.getCamera() != null ? UtilitiesKt.toValidCamera(googleMap3D.getCamera()) : null;
- Camera currCam = (liveCam != null && (Math.abs(liveCam.getCenter().getLatitude()) > 0.001 || Math.abs(liveCam.getCenter().getLongitude()) > 0.001))
- ? liveCam
- : getInitialCamera();
- double baseFovRad = Math.toRadians(45.0 / 2.0);
- double targetFovRad = Math.toRadians(fovAngle / 2.0);
-
- double targetRange = 800.0 * Math.tan(baseFovRad) / Math.tan(targetFovRad);
- if (targetRange < 150.0) targetRange = 150.0;
- if (targetRange > 3000.0) targetRange = 3000.0;
-
- Camera updatedCam = UtilitiesKt.toValidCamera(new Camera(
- currCam.getCenter(),
- currCam.getHeading(),
- currCam.getTilt(),
- currCam.getRoll(),
- targetRange
- ));
- googleMap3D.setCamera(updatedCam);
- }
+ /**
+ * Expands the control card back to its full height.
+ */
+ private void expandControls() {
+ if (controlsCard == null || cardContent == null) {
+ return;
+ }
+ isCollapsed = false;
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_more_24px);
+ btnCollapse.setContentDescription(getString(R.string.collapse_controls));
}
+ android.transition.TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.VISIBLE);
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ if (ev.getAction() == MotionEvent.ACTION_DOWN || ev.getAction() == MotionEvent.ACTION_MOVE) {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(1.0f)
+ .setDuration(150)
+ .start();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+ }
+ return super.dispatchTouchEvent(ev);
+ }
+
+ // --- 3D Map Setup & Perspective Scaling Engine ---
+
+ @Override
+ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
+ super.onMap3DViewReady(googleMap3D);
+ googleMap3D.setOnMapReadyListener(sceneReadiness -> {
+ googleMap3D.setOnMapReadyListener(null);
+ runOnUiThread(() -> updateFov((float) currentFov));
+ });
+ }
+
+ /**
+ * Calculates and applies the optical dolly-zoom perspective range for the specified FOV angle.
+ *
+ * @param fovAngle Perspective field of view angle in degrees (e.g. 15° to 120°).
+ */
+ private void updateFov(float fovAngle) {
+ currentFov = fovAngle;
+ runOnUiThread(() -> {
+ if (fovSliderLabel != null) {
+ fovSliderLabel.setText(getString(R.string.field_of_view_format, (int) fovAngle));
+ }
+ });
+
+ if (googleMap3D != null) {
+ Camera liveCam = googleMap3D.getCamera() != null ? toValidCamera(googleMap3D.getCamera()) : null;
+ Camera currCam = (liveCam != null && (Math.abs(liveCam.getCenter().getLatitude()) > 0.001
+ || Math.abs(liveCam.getCenter().getLongitude()) > 0.001))
+ ? liveCam
+ : getInitialCamera();
+
+ // Optical perspective transformation: range = baseRange * tan(baseFov/2) / tan(targetFov/2)
+ double baseFovRad = Math.toRadians(BASE_FOV_DEGREES / 2.0);
+ double targetFovRad = Math.toRadians(fovAngle / 2.0);
+
+ double targetRange = BASE_RANGE_METERS * Math.tan(baseFovRad) / Math.tan(targetFovRad);
+ if (targetRange < MIN_RANGE_METERS) {
+ targetRange = MIN_RANGE_METERS;
+ }
+ if (targetRange > MAX_RANGE_METERS) {
+ targetRange = MAX_RANGE_METERS;
+ }
+
+ Camera updatedCam = toValidCamera(new Camera(
+ currCam.getCenter(),
+ currCam.getHeading(),
+ currCam.getTilt(),
+ currCam.getRoll(),
+ targetRange
+ ));
+ googleMap3D.setCamera(updatedCam);
+ }
+ }
+
+ // --- Teardown ---
+
+ @Override
+ protected void onDestroy() {
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ super.onDestroy();
+ }
}
+
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java
index c0551815..b1daec95 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/pathfollowing/PathFollowingActivity.java
@@ -22,10 +22,12 @@
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
+import android.view.MotionEvent;
import android.widget.RadioGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
+import androidx.cardview.widget.CardView;
import androidx.core.view.WindowCompat;
import com.example.maps3dcommon.R;
import com.google.android.gms.maps.model.LatLng;
@@ -43,13 +45,14 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.Locale;
/**
* Advanced sample demonstrating ground-level path following in Java.
*
* Features: - Urban vs Rural ground-level paths - Real-time camera controls via sliders: Range,
* Ground Altitude, Heading Offset, Tilt, Follow Speed - Smooth Handler-based frame animation along
- * the route
+ * the route - Contracting and fading polyline playback visualization
*/
public class PathFollowingActivity extends AppCompatActivity implements OnMap3DViewReadyCallback {
@@ -91,6 +94,9 @@ public class PathFollowingActivity extends AppCompatActivity implements OnMap3DV
// View Bindings
private RadioGroup rgEnvironment;
+ private RadioGroup rgAltitudeMode;
+ private Slider pathAltitudeSlider;
+ private TextView pathAltitudeSliderLabel;
private MaterialButton btnPlayPause;
private Slider progressSlider;
private Slider rangeSlider;
@@ -110,6 +116,8 @@ public class PathFollowingActivity extends AppCompatActivity implements OnMap3DV
private double headingOffset = 0.0;
private double cameraTilt = 70.0;
private double followSpeedMps = 30.0;
+ private @AltitudeMode int pathAltitudeMode = AltitudeMode.CLAMP_TO_GROUND;
+ private double pathAltitudeOffset = 0.5;
// Path state
private List currentPath = URBAN_PATH;
@@ -119,10 +127,84 @@ public class PathFollowingActivity extends AppCompatActivity implements OnMap3DV
private boolean isPlaying = false;
private boolean isUserScrubbing = false;
- private Polyline pathPolyline = null;
+ // Polyline IDs
+ private static final String STATIC_ROUTE_POLYLINE_ID = "path_following_static_route";
+ private static final String PROGRESS_POLYLINE_ID = "path_following_progress_route";
+
+ private Polyline staticRoutePolyline = null;
+ private Polyline progressPolyline = null;
private final Handler handler = new Handler(Looper.getMainLooper());
private Runnable animationRunnable;
+ private CardView controlsCard;
+ private android.view.View cardHeader;
+ private MaterialButton btnCollapse;
+ private boolean isCollapsed = false;
+
+ private final Handler fadeHandler = new Handler(Looper.getMainLooper());
+ private final Runnable fadeOutRunnable = () -> {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(0.8f) // Subtle fade when idle
+ .setDuration(400)
+ .start();
+ }
+ };
+
+ private void collapseControls() {
+ if (controlsCard == null) {
+ return;
+ }
+ isCollapsed = true;
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_less_24px);
+ btnCollapse.setContentDescription(getString(R.string.expand_controls));
+ }
+ int headerHeight = (cardHeader != null && cardHeader.getHeight() > 0)
+ ? cardHeader.getHeight()
+ : (int) (48 * getResources().getDisplayMetrics().density);
+ float targetTranslationY = Math.max(0, controlsCard.getHeight() - headerHeight);
+ controlsCard.animate()
+ .translationY(targetTranslationY)
+ .alpha(0.9f)
+ .setDuration(300)
+ .start();
+ }
+
+ private void expandControls() {
+ if (controlsCard == null) {
+ return;
+ }
+ isCollapsed = false;
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_more_24px);
+ btnCollapse.setContentDescription(getString(R.string.collapse_controls));
+ }
+ controlsCard.animate()
+ .translationY(0f)
+ .alpha(1.0f)
+ .setDuration(250)
+ .start();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ if (ev.getAction() == MotionEvent.ACTION_DOWN || ev.getAction() == MotionEvent.ACTION_MOVE) {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(1.0f)
+ .setDuration(150)
+ .start();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+ }
+ return super.dispatchTouchEvent(ev);
+ }
+
@Override
protected void onCreate(Bundle savedInstanceState) {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
@@ -154,9 +236,14 @@ protected void onPause() {
protected void onDestroy() {
super.onDestroy();
pauseAnimation();
- if (pathPolyline != null) {
- pathPolyline.remove();
- pathPolyline = null;
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (staticRoutePolyline != null) {
+ staticRoutePolyline.remove();
+ staticRoutePolyline = null;
+ }
+ if (progressPolyline != null) {
+ progressPolyline.remove();
+ progressPolyline = null;
}
map3DView.onDestroy();
}
@@ -174,7 +261,34 @@ protected void onSaveInstanceState(@NonNull Bundle outState) {
}
private void initViews() {
+ controlsCard = findViewById(R.id.controls_card);
+ cardHeader = findViewById(R.id.card_header);
+ btnCollapse = findViewById(R.id.btn_collapse);
+
+ if (btnCollapse != null) {
+ btnCollapse.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
+ if (cardHeader != null) {
+ cardHeader.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ }
+ });
+ }
+
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+
rgEnvironment = findViewById(R.id.rg_environment);
+ rgAltitudeMode = findViewById(R.id.rg_altitude_mode);
+ pathAltitudeSlider = findViewById(R.id.path_altitude_slider);
+ pathAltitudeSliderLabel = findViewById(R.id.path_altitude_slider_label);
btnPlayPause = findViewById(R.id.btn_play_pause);
progressSlider = findViewById(R.id.progress_slider);
@@ -189,6 +303,8 @@ private void initViews() {
speedSlider = findViewById(R.id.speed_slider);
speedSliderLabel = findViewById(R.id.speed_slider_label);
+ updateControlLabels();
+
// Radio group environment selection
rgEnvironment.setOnCheckedChangeListener((group, checkedId) -> {
if (checkedId == R.id.rb_urban) {
@@ -200,6 +316,30 @@ private void initViews() {
}
});
+ // Radio group altitude mode selection
+ rgAltitudeMode.setOnCheckedChangeListener((group, checkedId) -> {
+ if (checkedId == R.id.rb_relative_to_ground) {
+ pathAltitudeMode = AltitudeMode.RELATIVE_TO_GROUND;
+ } else if (checkedId == R.id.rb_clamp_to_ground) {
+ pathAltitudeMode = AltitudeMode.CLAMP_TO_GROUND;
+ } else if (checkedId == R.id.rb_relative_to_mesh) {
+ pathAltitudeMode = AltitudeMode.RELATIVE_TO_MESH;
+ } else if (checkedId == R.id.rb_absolute) {
+ pathAltitudeMode = AltitudeMode.ABSOLUTE;
+ }
+ drawStaticRoutePolyline();
+ updateCameraPositionForDistance(elapsedDistance);
+ });
+
+ // Path height slider (relative altitude)
+ pathAltitudeSlider.addOnChangeListener((slider, value, fromUser) -> {
+ pathAltitudeOffset = value;
+ pathAltitudeSliderLabel.setText(
+ getString(R.string.path_height_format, pathAltitudeOffset));
+ drawStaticRoutePolyline();
+ updateCameraPositionForDistance(elapsedDistance);
+ });
+
// Play/Pause button
btnPlayPause.setOnClickListener(v -> {
if (isPlaying) {
@@ -232,41 +372,57 @@ public void onStopTrackingTouch(@NonNull Slider slider) {
// Sliders listeners
rangeSlider.addOnChangeListener((slider, value, fromUser) -> {
cameraRange = value;
- rangeSliderLabel.setText(String.format("Camera Range: %dm", (int) cameraRange));
+ rangeSliderLabel.setText(getString(R.string.camera_range_format, (int) cameraRange));
updateCameraPositionForDistance(elapsedDistance);
});
altitudeSlider.addOnChangeListener((slider, value, fromUser) -> {
groundAltitude = value;
- altitudeSliderLabel.setText(String.format("Ground Altitude: %dm", (int) groundAltitude));
+ altitudeSliderLabel.setText(getString(R.string.ground_altitude_format, (int) groundAltitude));
updateCameraPositionForDistance(elapsedDistance);
});
headingSlider.addOnChangeListener((slider, value, fromUser) -> {
headingOffset = value;
- headingSliderLabel.setText(String.format("Heading Offset: %d°", (int) headingOffset));
+ headingSliderLabel.setText(getString(R.string.heading_offset_format, (int) headingOffset));
updateCameraPositionForDistance(elapsedDistance);
});
tiltSlider.addOnChangeListener((slider, value, fromUser) -> {
cameraTilt = value;
- tiltSliderLabel.setText(String.format("Camera Tilt: %d°", (int) cameraTilt));
+ tiltSliderLabel.setText(getString(R.string.camera_tilt_format, (int) cameraTilt));
updateCameraPositionForDistance(elapsedDistance);
});
speedSlider.addOnChangeListener((slider, value, fromUser) -> {
followSpeedMps = value;
- speedSliderLabel.setText(String.format("Follow Speed: %d m/s", (int) followSpeedMps));
+ speedSliderLabel.setText(getString(R.string.follow_speed_format, (int) followSpeedMps));
});
}
+ private void updateControlLabels() {
+ pathAltitudeSliderLabel.setText(
+ getString(R.string.path_height_format, pathAltitudeOffset));
+ rangeSliderLabel.setText(getString(R.string.camera_range_format, (int) cameraRange));
+ altitudeSliderLabel.setText(
+ getString(R.string.ground_altitude_format, (int) groundAltitude));
+ headingSliderLabel.setText(
+ getString(R.string.heading_offset_format, (int) headingOffset));
+ tiltSliderLabel.setText(getString(R.string.camera_tilt_format, (int) cameraTilt));
+ speedSliderLabel.setText(
+ getString(R.string.follow_speed_format, (int) followSpeedMps));
+ }
+
@Override
public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
this.googleMap3D = googleMap3D;
googleMap3D.setOnMapReadyListener((map) -> {
googleMap3D.setOnMapReadyListener(null);
- drawPathPolyline();
- updateCameraPositionForDistance(0.0);
+ runOnUiThread(() -> {
+ drawPathPolylines();
+ updateCameraPositionForDistance(0.0);
+ startAnimation();
+ });
});
}
@@ -285,9 +441,9 @@ private void switchEnvironment(List path) {
rangeSlider.setValue(450f);
altitudeSlider.setValue(40f);
tiltSlider.setValue(75f);
- rangeSliderLabel.setText("Camera Range: 450m");
- altitudeSliderLabel.setText("Ground Altitude: 40m");
- tiltSliderLabel.setText("Camera Tilt: 75°");
+ rangeSliderLabel.setText(getString(R.string.camera_range_format, 450));
+ altitudeSliderLabel.setText(getString(R.string.ground_altitude_format, 40));
+ tiltSliderLabel.setText(getString(R.string.camera_tilt_format, 75));
} else {
cameraRange = 300.0;
groundAltitude = 20.0;
@@ -295,13 +451,13 @@ private void switchEnvironment(List path) {
rangeSlider.setValue(300f);
altitudeSlider.setValue(20f);
tiltSlider.setValue(70f);
- rangeSliderLabel.setText("Camera Range: 300m");
- altitudeSliderLabel.setText("Ground Altitude: 20m");
- tiltSliderLabel.setText("Camera Tilt: 70°");
+ rangeSliderLabel.setText(getString(R.string.camera_range_format, 300));
+ altitudeSliderLabel.setText(getString(R.string.ground_altitude_format, 20));
+ tiltSliderLabel.setText(getString(R.string.camera_tilt_format, 70));
}
loadPathData(path);
- drawPathPolyline();
+ drawPathPolylines();
updateCameraPositionForDistance(0.0);
}
@@ -317,26 +473,84 @@ private void loadPathData(List path) {
}
}
- private void drawPathPolyline() {
- if (googleMap3D == null) {
+ private void drawPathPolylines() {
+ drawStaticRoutePolyline();
+ if (!currentPath.isEmpty()) {
+ updateProgressPolyline(elapsedDistance, currentPath.get(0), 0);
+ }
+ }
+
+ private void drawStaticRoutePolyline() {
+ if (googleMap3D == null || currentPath.isEmpty()) {
return;
}
- if (pathPolyline != null) {
- pathPolyline.remove();
+
+ double pathAltitude = (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND)
+ ? 0.0
+ : pathAltitudeOffset;
+ if (pathAltitudeMode == AltitudeMode.ABSOLUTE) {
+ pathAltitude = (currentPath.equals(RURAL_PATH)) ? 40.0 : 15.0;
}
- List vertices = new ArrayList<>();
+ List staticVertices = new ArrayList<>();
for (LatLng latLng : currentPath) {
- vertices.add(new LatLngAltitude(latLng.latitude, latLng.longitude, 5.0));
+ staticVertices.add(new LatLngAltitude(latLng.latitude, latLng.longitude, pathAltitude));
}
- PolylineOptions polyOptions = new PolylineOptions();
- polyOptions.setPath(vertices);
- polyOptions.setStrokeColor(Color.parseColor("#4285F4"));
- polyOptions.setStrokeWidth(10.0);
- polyOptions.setAltitudeMode(AltitudeMode.RELATIVE_TO_GROUND);
+ PolylineOptions staticOptions = new PolylineOptions();
+ staticOptions.setId(STATIC_ROUTE_POLYLINE_ID); // Fixed ID prevents flickering and updates in place
+ staticOptions.setPath(staticVertices);
+ staticOptions.setStrokeColor(Color.parseColor("#4285F4")); // Route line: blue
+ staticOptions.setStrokeWidth(16.0); // Route line: wider
+ staticOptions.setZIndex(1); // Route line: lower z-index
+ staticOptions.setAltitudeMode(pathAltitudeMode);
- pathPolyline = googleMap3D.addPolyline(polyOptions);
+ staticRoutePolyline = googleMap3D.addPolyline(staticOptions);
+ }
+
+ private void updateProgressPolyline(double dist, LatLng currentLatLng, int index) {
+ if (googleMap3D == null || currentPath.isEmpty() || totalDistance <= 0.0) {
+ return;
+ }
+
+ double pathAltitude = (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND)
+ ? 0.0
+ : pathAltitudeOffset;
+ if (pathAltitudeMode == AltitudeMode.ABSOLUTE) {
+ pathAltitude = (currentPath.equals(RURAL_PATH)) ? 40.0 : 15.0;
+ }
+
+ double progressAltitude = pathAltitude + 0.2;
+
+ // Build traversed route from start to current position
+ List progressCoordinates = new ArrayList<>();
+ for (int i = 0; i <= index && i < currentPath.size(); i++) {
+ LatLng pt = currentPath.get(i);
+ progressCoordinates.add(new LatLngAltitude(pt.latitude, pt.longitude, progressAltitude));
+ }
+ progressCoordinates.add(
+ new LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, progressAltitude));
+
+ // Polyline requires at least 2 vertices
+ if (progressCoordinates.size() < 2) {
+ LatLng startPt = currentPath.get(0);
+ progressCoordinates.add(new LatLngAltitude(startPt.latitude, startPt.longitude, progressAltitude));
+ }
+
+ // Dual-Polyline Rendering Technique:
+ // We render two layered polylines to clearly visualize completed vs. remaining route:
+ // 1. Base Static Polyline: A wider (#4285F4 blue, 16dp) static path at ZIndex=1.
+ // 2. Traversed Progress Polyline: A narrower (#9C27B0 purple, 8dp) line at ZIndex=2.
+ // We also apply a slight vertical altitude bias (+0.2m) to prevent 3D depth buffer z-fighting.
+ PolylineOptions progressOptions = new PolylineOptions();
+ progressOptions.setId(PROGRESS_POLYLINE_ID); // Fixed ID upserts in place to eliminate flickering
+ progressOptions.setPath(progressCoordinates);
+ progressOptions.setStrokeColor(Color.parseColor("#9C27B0")); // Progress line: purple
+ progressOptions.setStrokeWidth(8.0); // Progress line: narrower
+ progressOptions.setZIndex(2); // Progress line: higher z-index on top of static route
+ progressOptions.setAltitudeMode(pathAltitudeMode);
+
+ progressPolyline = googleMap3D.addPolyline(progressOptions);
}
private void startAnimation() {
@@ -348,13 +562,21 @@ private void startAnimation() {
final long frameDurationMs = 16L;
animationRunnable = new Runnable() {
+ private long lastTime = System.currentTimeMillis();
+
@Override
public void run() {
if (!isPlaying) {
return;
}
- double stepDistance = followSpeedMps * (frameDurationMs / 1000.0);
+ long now = System.currentTimeMillis();
+ // Delta-time (dt) integration: Scale motion by actual elapsed seconds to ensure
+ // consistent travel speed across 60Hz, 90Hz, and 120Hz display refresh rates.
+ double dt = (now - lastTime) / 1000.0;
+ lastTime = now;
+
+ double stepDistance = followSpeedMps * dt;
elapsedDistance += stepDistance;
if (elapsedDistance >= totalDistance) {
@@ -408,6 +630,10 @@ private void updateCameraPositionForDistance(double dist) {
LatLng currentLatLng = SphericalUtil.interpolate(p1, p2, fraction);
double bearing = SphericalUtil.computeHeading(p1, p2);
+ // Kinematic Heading Smoothing (Exponential Moving Average):
+ // Sharp polyline bends can produce disorienting camera jerks. We compute the shortest
+ // angular difference wrapped to [-180, 180] and apply an exponential low-pass filter (12% lerp)
+ // to smoothly turn the camera around street corners and mountain switchbacks.
double targetHeadingRaw = toHeading(bearing + headingOffset);
double targetHeading;
if (currentHeading == null || isUserScrubbing || !isPlaying) {
@@ -433,5 +659,6 @@ private void updateCameraPositionForDistance(double dist) {
);
googleMap3D.setCamera(newCamera);
+ updateProgressPolyline(dist, currentLatLng, index);
}
}
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/roadmapmode/RoadmapModeActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/roadmapmode/RoadmapModeActivity.java
index 76dc8340..b77a592a 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/roadmapmode/RoadmapModeActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/roadmapmode/RoadmapModeActivity.java
@@ -16,12 +16,20 @@
package com.example.maps3djava.roadmapmode;
+import static com.example.maps3d.common.UtilitiesKt.toValidCamera;
+
import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.transition.TransitionManager;
+import android.view.MotionEvent;
+import android.view.View;
import android.view.ViewGroup;
import android.widget.RadioGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.cardview.widget.CardView;
import com.example.maps3dcommon.R;
import com.example.maps3djava.sampleactivity.SampleBaseActivity;
@@ -31,16 +39,67 @@
import com.google.android.gms.maps3d.model.LatLngAltitude;
import com.google.android.gms.maps3d.model.Map3DMode;
import com.google.android.material.appbar.MaterialToolbar;
+import com.google.android.material.button.MaterialButton;
/**
- * Showcases 3D Roadmap mode in Google Maps 3D SDK (Java implementation).
- * Features: - Switching between ROADMAP, HYBRID, and SATELLITE 3D render modes focused on San
- * Francisco.
+ * =================================================================================================
+ * 3D Roadmap Mode & Render Style Switching (Java)
+ * =================================================================================================
+ *
+ * This sample demonstrates switching between distinct visual rendering modes provided by the
+ * Google Maps 3D SDK:
+ *
+ * Key Concepts Demonstrated:
+ * 1. 3D Map Rendering Modes ({@link Map3DMode}):
+ * - {@link Map3DMode#ROADMAP}: High-contrast 3D vector street network with clean white building
+ * massings, road labels, and stylized transit geometry.
+ * - {@link Map3DMode#HYBRID}: High-resolution 3D photorealistic mesh overlaid with prominent
+ * vector road networks, street names, and point-of-interest labels.
+ * - {@link Map3DMode#SATELLITE}: Pure photorealistic 3D mesh rendering without overlay labels
+ * or vector lines, ideal for cinematic aerial exploration.
+ *
+ * 2. Camera Stability & Safe Angle Validation:
+ * - Configures a dramatic 3D perspective centered on the San Francisco Financial District with
+ * {@link com.example.maps3d.common.UtilitiesKt#toValidCamera(Camera)}.
+ *
+ * 3. Modern Material UI & Collapse Affordances:
+ * - Bottom control card with quick radio button switching between map rendering styles.
+ * - Expandable / collapsible header bar for unobstructed 3D scene inspection.
+ * - Subtle UI idle auto-fade with touch-to-wake responsiveness.
*/
public class RoadmapModeActivity extends SampleBaseActivity {
+ // --- Constants & Geographical Bounds ---
+
+ /** Focal landmark centered on the San Francisco Financial District. */
public static final LatLng SF_LOCATION = new LatLng(37.7915, -122.4010);
+ // --- UI Elements ---
+
+ private CardView controlsCard;
+ private View cardHeader;
+ private View cardContent;
+ private MaterialButton btnCollapse;
+ private RadioGroup rgMapMode;
+
+ // --- State Variables ---
+
+ private boolean isCollapsed = false;
+
+ // --- Handlers & Runnables ---
+
+ private final Handler fadeHandler = new Handler(Looper.getMainLooper());
+ private final Runnable fadeOutRunnable = () -> {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(0.85f)
+ .setDuration(400)
+ .start();
+ }
+ };
+
+ // --- Base Activity Overrides ---
+
@NonNull
@Override
public String getTAG() {
@@ -50,20 +109,27 @@ public String getTAG() {
@NonNull
@Override
public Camera getInitialCamera() {
- return new Camera(
+ return toValidCamera(new Camera(
new LatLngAltitude(SF_LOCATION.latitude, SF_LOCATION.longitude, 250.0),
45.0,
65.0,
0.0,
800.0
- );
+ ));
}
+ // --- Lifecycle & Initialization ---
+
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- // Inflate roadmap control panel overlay into map container managed by SampleBaseActivity
+ // Hide base pill scroll view as this activity manages its own overlay card
+ View baseScrollView = findViewById(R.id.control_scroll_view);
+ if (baseScrollView != null) {
+ baseScrollView.setVisibility(View.GONE);
+ }
+
ViewGroup container = findViewById(R.id.map_container);
if (container != null) {
getLayoutInflater().inflate(R.layout.control_panel_roadmap_mode, container, true);
@@ -71,34 +137,132 @@ protected void onCreate(@Nullable Bundle savedInstanceState) {
MaterialToolbar topBar = findViewById(R.id.top_bar);
if (topBar != null) {
- topBar.setTitle("3D Roadmap Mode");
+ topBar.setTitle(R.string.feature_title_roadmap_mode);
topBar.setNavigationOnClickListener(v -> finish());
}
- RadioGroup rgMapMode = findViewById(R.id.rg_map_mode);
+ initViews();
+ }
+
+ /**
+ * Initializes view references and wires up touch and click listeners.
+ */
+ private void initViews() {
+ controlsCard = findViewById(R.id.control_panel);
+ cardHeader = findViewById(R.id.card_header);
+ cardContent = findViewById(R.id.card_content);
+ btnCollapse = findViewById(R.id.btn_collapse);
+ rgMapMode = findViewById(R.id.rg_map_mode);
+
+ if (btnCollapse != null) {
+ btnCollapse.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
+ if (cardHeader != null) {
+ cardHeader.setOnClickListener(v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
if (rgMapMode != null) {
rgMapMode.setOnCheckedChangeListener((group, checkedId) -> {
- if (googleMap3D != null) {
- if (checkedId == R.id.rb_roadmap) {
- googleMap3D.setMapMode(Map3DMode.ROADMAP);
- } else if (checkedId == R.id.rb_hybrid) {
- googleMap3D.setMapMode(Map3DMode.HYBRID);
- } else if (checkedId == R.id.rb_satellite) {
- googleMap3D.setMapMode(Map3DMode.SATELLITE);
- }
+ if (googleMap3D == null) {
+ return;
+ }
+ if (checkedId == R.id.rb_roadmap) {
+ googleMap3D.setMapMode(Map3DMode.ROADMAP);
+ } else if (checkedId == R.id.rb_hybrid) {
+ googleMap3D.setMapMode(Map3DMode.HYBRID);
+ } else if (checkedId == R.id.rb_satellite) {
+ googleMap3D.setMapMode(Map3DMode.SATELLITE);
}
});
}
+
+ // Schedule subtle initial auto-fade for unobstructed viewing
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ // --- UI Collapse / Expand Mechanics ---
+
+ /**
+ * Collapses the control card downward, leaving only the title header visible.
+ */
+ private void collapseControls() {
+ if (controlsCard == null || cardContent == null) {
+ return;
+ }
+ isCollapsed = true;
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_less_24px);
+ btnCollapse.setContentDescription(getString(R.string.expand_controls));
+ }
+ TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.GONE);
+ }
+
+ /**
+ * Expands the control card back to its full height.
+ */
+ private void expandControls() {
+ if (controlsCard == null || cardContent == null) {
+ return;
+ }
+ isCollapsed = false;
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_more_24px);
+ btnCollapse.setContentDescription(getString(R.string.collapse_controls));
+ }
+ TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.VISIBLE);
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+
+ @Override
+ public boolean dispatchTouchEvent(MotionEvent ev) {
+ if (ev.getAction() == MotionEvent.ACTION_DOWN || ev.getAction() == MotionEvent.ACTION_MOVE) {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate()
+ .alpha(1.0f)
+ .setDuration(150)
+ .start();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ fadeHandler.postDelayed(fadeOutRunnable, 3000L);
+ }
+ }
+ return super.dispatchTouchEvent(ev);
}
+ // --- 3D Map Setup ---
+
@Override
public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
super.onMap3DViewReady(googleMap3D);
- googleMap3D.setOnMapReadyListener((map) -> {
+ googleMap3D.setOnMapReadyListener(sceneReadiness -> {
googleMap3D.setOnMapReadyListener(null);
googleMap3D.setMapMode(Map3DMode.ROADMAP);
googleMap3D.setCamera(getInitialCamera());
});
+ }
+ // --- Teardown ---
+
+ @Override
+ protected void onDestroy() {
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ super.onDestroy();
}
}
+
diff --git a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/routes/RoutesActivity.java b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/routes/RoutesActivity.java
index e8b7d7c4..bf694e77 100644
--- a/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/routes/RoutesActivity.java
+++ b/Maps3DSamples/ApiDemos/java-app/src/main/java/com/example/maps3djava/routes/RoutesActivity.java
@@ -17,22 +17,28 @@
package com.example.maps3djava.routes;
import static com.example.maps3d.common.UtilitiesKt.toHeading;
+import static com.example.maps3d.common.UtilitiesKt.toValidCamera;
+import android.annotation.SuppressLint;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
+import android.transition.TransitionManager;
import android.util.Log;
+import android.view.MotionEvent;
import android.view.View;
+import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
-import androidx.core.view.WindowCompat;
+import androidx.annotation.Nullable;
+import androidx.cardview.widget.CardView;
+import com.example.maps3d.common.OahuRouteData;
import com.example.maps3d.common.PositionAndHeading;
import com.example.maps3d.common.RouteEngine;
-import com.example.maps3d.common.OahuRouteData;
import com.example.maps3dcommon.R;
import com.example.maps3djava.BuildConfig;
import com.example.maps3djava.sampleactivity.SampleBaseActivity;
@@ -41,6 +47,7 @@
import com.google.android.gms.maps3d.OnMap3DViewReadyCallback;
import com.google.android.gms.maps3d.model.AltitudeMode;
import com.google.android.gms.maps3d.model.Camera;
+import com.google.android.gms.maps3d.model.LatLngAltitude;
import com.google.android.gms.maps3d.model.Map3DMode;
import com.google.android.gms.maps3d.model.Model;
import com.google.android.gms.maps3d.model.ModelOptions;
@@ -48,7 +55,6 @@
import com.google.android.gms.maps3d.model.Polyline;
import com.google.android.gms.maps3d.model.PolylineOptions;
import com.google.android.gms.maps3d.model.Vector3D;
-import com.google.android.gms.maps3d.model.LatLngAltitude;
import com.google.android.material.appbar.MaterialToolbar;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.slider.Slider;
@@ -58,210 +64,374 @@
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
/**
- * A premium View-based sample activity demonstrating cross-product integration with the Routes API in Java.
+ * Demonstrates cross-product integration between Google Maps Platform Routes API and GoogleMap3D.
*
- * This sample executes background threads to fetch driving routes in Honolulu, parses the encoded polyline,
- * renders the route line on [GoogleMap3D], loads a 3D model (.glb), and implements an android.os.Handler
- * framework to animate the car smoothly along the path with real-time camera tracking.
+ *
Key Architecture Highlights:
+ *
+ *
Async Route Calculation: Dispatches background tasks via {@link RouteRepository} to
+ * fetch driving directions in Honolulu, falling back gracefully to bundled Oahu coordinates
+ * when offline or unauthenticated.
+ *
3D Polyline Visualization: Renders clamped-to-ground 3D route paths that conform
+ * seamlessly to elevation and terrain variations.
+ *
3D glTF Model Anchoring: Loads a 3D vehicle model and updates its geographic
+ * coordinates and heading on each tick along the path.
+ *
Dynamic Camera Tracking: Synchronizes the 3D camera to follow behind the vehicle
+ * with configurable altitude, speed, and yaw offsets.
+ *
Ergonomic Collapsible UI: Employs an animated Material3 floating card with
+ * touch-aware auto-fade after 3 seconds of inactivity.
+ *
*/
public class RoutesActivity extends SampleBaseActivity implements OnMap3DViewReadyCallback {
- @Override
- public final String getTAG() {
- return "RoutesActivity";
+ // --- Constants ---
+
+ private static final long FADE_DELAY_MS = 3000L;
+ private static final float ACTIVE_ALPHA = 1.0f;
+ private static final float FADED_ALPHA = 0.85f;
+
+ // --- View Bindings ---
+
+ private CardView controlsCard;
+ private View cardHeader;
+ private View cardContent;
+ private MaterialButton btnCollapse;
+ private MaterialButton btnPlayPause;
+ private Slider progressSlider;
+ private Slider rangeSlider;
+ private TextView rangeSliderLabel;
+ private Slider speedSlider;
+ private TextView speedSliderLabel;
+ private Slider headingSlider;
+ private TextView headingSliderLabel;
+
+ // --- State Variables ---
+
+ private final RouteRepository routeRepository = new RouteRepository();
+ private List decodedRoute = new ArrayList<>();
+ private double[] cumulativeDistances = new double[] {0.0};
+ private double totalDistance = 0.0;
+ private double elapsedDistance = 0.0;
+
+ private boolean isPlaying = false;
+ private boolean isUserScrubbing = false;
+ private boolean isCollapsed = false;
+
+ // --- Slider Parameters ---
+
+ private float cameraRange = 1500f;
+ private float vehicleSpeedMps = 150f;
+ private float yawOffset = 0f;
+
+ // --- Map References ---
+
+ private Polyline routePolyline;
+ private Model vehicleModel;
+
+ // --- Background Executors & Handlers ---
+
+ private final ExecutorService executorService = Executors.newSingleThreadExecutor();
+ private final Handler mainHandler = new Handler(Looper.getMainLooper());
+ private final Handler fadeHandler = new Handler(Looper.getMainLooper());
+
+ private final Runnable fadeOutRunnable =
+ () -> {
+ if (controlsCard != null && !isCollapsed) {
+ controlsCard.animate().alpha(FADED_ALPHA).setDuration(400).start();
+ }
+ };
+
+ // --- Base Activity Overrides ---
+
+ @NonNull
+ @Override
+ public String getTAG() {
+ return "RoutesActivity";
+ }
+
+ @NonNull
+ @Override
+ public Camera getInitialCamera() {
+ return toValidCamera(
+ new Camera(
+ new LatLngAltitude(21.348567, -157.803961, 0.0),
+ 38.6,
+ 45.0,
+ 0.0,
+ 20000.0));
+ }
+
+ // --- Lifecycle & Layout Setup ---
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Hide base pill scroll view as this activity manages its own overlay card
+ View baseScrollView = findViewById(R.id.control_scroll_view);
+ if (baseScrollView != null) {
+ baseScrollView.setVisibility(View.GONE);
}
- // Honolulu Overview starting position
- @Override
- public final Camera getInitialCamera() {
- return new Camera(
- new LatLngAltitude(21.348567, -157.803961, 0.0),
- 38.6,
- 45.0,
- 0.0,
- 20000.0
- );
+ ViewGroup container = findViewById(R.id.map_container);
+ if (container != null) {
+ getLayoutInflater().inflate(R.layout.control_panel_routes, container, true);
}
- // View Bindings
- private MaterialButton btnPlayPause;
- private Slider progressSlider;
- private Slider rangeSlider;
- private TextView rangeSliderLabel;
- private Slider speedSlider;
- private TextView speedSliderLabel;
- private Slider headingSlider;
- private TextView headingSliderLabel;
-
- // Core State Variables
- private final RouteRepository routeRepository = new RouteRepository();
- private List decodedRoute = new ArrayList<>();
- private double[] cumulativeDistances = new double[]{0.0};
- private double totalDistance = 0.0;
- private double elapsedDistance = 0.0;
-
- private boolean isPlaying = false;
- private boolean isUserScrubbing = false;
-
- // Slider default parameters
- private float cameraRange = 1500f;
- private float vehicleSpeedMps = 150f;
- private float yawOffset = 0f;
-
- // Map references
- private Polyline routePolyline;
- private Model vehicleModel;
-
- // Background Executors
- private final ExecutorService executorService = Executors.newSingleThreadExecutor();
- private final Handler mainHandler = new Handler(Looper.getMainLooper());
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_routes);
-
- // Re-bind map3DView to the new active instance in activity_routes.xml and forward lifecycle
- map3DView = findViewById(R.id.map3dView);
- map3DView.onCreate(savedInstanceState);
- map3DView.getMap3DViewAsync(this);
-
- // Configure custom toolbar
- MaterialToolbar toolbar = findViewById(R.id.top_bar);
- toolbar.setTitle(getString(R.string.feature_title_routes_api));
- toolbar.setNavigationOnClickListener(view -> finish());
-
- // Bind control panel views
- btnPlayPause = findViewById(R.id.btn_play_pause);
- progressSlider = findViewById(R.id.progress_slider);
- rangeSlider = findViewById(R.id.range_slider);
- rangeSliderLabel = findViewById(R.id.range_slider_label);
- speedSlider = findViewById(R.id.speed_slider);
- speedSliderLabel = findViewById(R.id.speed_slider_label);
- headingSlider = findViewById(R.id.heading_slider);
- headingSliderLabel = findViewById(R.id.heading_slider_label);
-
- setupControls();
+ MaterialToolbar topBar = findViewById(R.id.top_bar);
+ if (topBar != null) {
+ topBar.setTitle(R.string.feature_title_routes_api);
+ topBar.setNavigationOnClickListener(v -> finish());
}
- /**
- * Configures seekbars and button click behaviors.
- */
- private void setupControls() {
- btnPlayPause.setOnClickListener(view -> {
+ initViews();
+ }
+
+ /** Initializes view references and wires up touch and click listeners. */
+ @SuppressLint("ClickableViewAccessibility")
+ private void initViews() {
+ controlsCard = findViewById(R.id.control_panel);
+ cardHeader = findViewById(R.id.card_header);
+ cardContent = findViewById(R.id.card_content);
+ btnCollapse = findViewById(R.id.btn_collapse);
+
+ btnPlayPause = findViewById(R.id.btn_play_pause);
+ progressSlider = findViewById(R.id.progress_slider);
+ rangeSlider = findViewById(R.id.range_slider);
+ rangeSliderLabel = findViewById(R.id.range_slider_label);
+ speedSlider = findViewById(R.id.speed_slider);
+ speedSliderLabel = findViewById(R.id.speed_slider_label);
+ headingSlider = findViewById(R.id.heading_slider);
+ headingSliderLabel = findViewById(R.id.heading_slider_label);
+
+ if (btnCollapse != null) {
+ btnCollapse.setOnClickListener(
+ v -> {
+ if (isCollapsed) {
+ expandControls();
+ } else {
+ collapseControls();
+ }
+ });
+ }
+
+ if (cardHeader != null) {
+ cardHeader.setOnClickListener(
+ v -> {
+ if (isCollapsed) {
+ expandControls();
+ }
+ });
+ }
+
+ if (controlsCard != null) {
+ controlsCard.setOnTouchListener(
+ (v, event) -> {
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ resetFadeTimer();
+ }
+ return false;
+ });
+ }
+
+ setupControls();
+ resetFadeTimer();
+ }
+
+ /** Configures sliders and playback button behaviors. */
+ private void setupControls() {
+ if (btnPlayPause != null) {
+ btnPlayPause.setOnClickListener(
+ view -> {
+ resetFadeTimer();
if (decodedRoute.isEmpty()) {
- Toast.makeText(this, "Route is still loading...", Toast.LENGTH_SHORT).show();
- return;
+ Toast.makeText(this, R.string.route_loading, Toast.LENGTH_SHORT).show();
+ return;
}
togglePlayback(!isPlaying);
- });
+ });
+ }
- // Scrub progress manually
- progressSlider.addOnSliderTouchListener(new Slider.OnSliderTouchListener() {
+ // Scrub progress manually
+ if (progressSlider != null) {
+ progressSlider.addOnSliderTouchListener(
+ new Slider.OnSliderTouchListener() {
@Override
public void onStartTrackingTouch(@NonNull Slider slider) {
- isUserScrubbing = true;
+ resetFadeTimer();
+ isUserScrubbing = true;
}
@Override
public void onStopTrackingTouch(@NonNull Slider slider) {
- isUserScrubbing = false;
- elapsedDistance = totalDistance * slider.getValue();
- updateVehiclePositionAndCamera();
+ resetFadeTimer();
+ isUserScrubbing = false;
+ elapsedDistance = totalDistance * slider.getValue();
+ updateVehiclePositionAndCamera();
}
- });
+ });
- progressSlider.addOnChangeListener((slider, value, fromUser) -> {
+ progressSlider.addOnChangeListener(
+ (slider, value, fromUser) -> {
if (fromUser && isUserScrubbing) {
- elapsedDistance = totalDistance * value;
- updateVehiclePositionAndCamera();
+ resetFadeTimer();
+ elapsedDistance = totalDistance * value;
+ updateVehiclePositionAndCamera();
}
- });
+ });
+ }
- // Camera altitude adjustments
- rangeSliderLabel.setText(getString(R.string.camera_altitude_format, (int) cameraRange));
- rangeSlider.setValue(cameraRange);
- rangeSlider.addOnChangeListener((slider, value, fromUser) -> {
+ // Camera altitude adjustments
+ if (rangeSliderLabel != null && rangeSlider != null) {
+ rangeSliderLabel.setText(getString(R.string.camera_altitude_format, (int) cameraRange));
+ rangeSlider.setValue(cameraRange);
+ rangeSlider.addOnChangeListener(
+ (slider, value, fromUser) -> {
+ resetFadeTimer();
cameraRange = value;
rangeSliderLabel.setText(getString(R.string.camera_altitude_format, (int) value));
updateVehiclePositionAndCamera();
- });
+ });
+ }
- // Speed configurations
- speedSliderLabel.setText(getString(R.string.vehicle_speed_format, (int) vehicleSpeedMps));
- speedSlider.setValue(vehicleSpeedMps);
- speedSlider.addOnChangeListener((slider, value, fromUser) -> {
+ // Speed configurations
+ if (speedSliderLabel != null && speedSlider != null) {
+ speedSliderLabel.setText(getString(R.string.vehicle_speed_format, (int) vehicleSpeedMps));
+ speedSlider.setValue(vehicleSpeedMps);
+ speedSlider.addOnChangeListener(
+ (slider, value, fromUser) -> {
+ resetFadeTimer();
vehicleSpeedMps = value;
speedSliderLabel.setText(getString(R.string.vehicle_speed_format, (int) value));
- });
+ });
+ }
- // Camera yaw offset adjustments
- headingSliderLabel.setText(getString(R.string.camera_yaw_offset_format, (int) yawOffset));
- headingSlider.setValue(yawOffset);
- headingSlider.addOnChangeListener((slider, value, fromUser) -> {
+ // Camera yaw offset adjustments
+ if (headingSliderLabel != null && headingSlider != null) {
+ headingSliderLabel.setText(getString(R.string.camera_yaw_offset_format, (int) yawOffset));
+ headingSlider.setValue(yawOffset);
+ headingSlider.addOnChangeListener(
+ (slider, value, fromUser) -> {
+ resetFadeTimer();
yawOffset = value;
headingSliderLabel.setText(getString(R.string.camera_yaw_offset_format, (int) value));
updateVehiclePositionAndCamera();
- });
+ });
}
-
- private void togglePlayback(boolean play) {
- isPlaying = play;
- if (play) {
- btnPlayPause.setIconResource(R.drawable.pause_24px);
- startAnimationLoop();
- } else {
- btnPlayPause.setIconResource(R.drawable.play_arrow_24px);
- stopAnimationLoop();
- }
+ }
+
+ // --- Collapsible UI Transitions ---
+
+ /**
+ * Collapses the control card to reveal more of the 3D map scene.
+ *
+ * Note on TransitionManager: Using TransitionManager.beginDelayedTransition automatically
+ * animates the parent CardView height changes smoothly while preserving system navigation bar
+ * insets on edge-to-edge layouts, avoiding visual bottom-clipping.
+ */
+ private void collapseControls() {
+ if (controlsCard == null || cardContent == null || isCollapsed) return;
+ isCollapsed = true;
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ controlsCard.animate().alpha(ACTIVE_ALPHA).setDuration(150).start();
+ TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.GONE);
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_less_24px);
+ btnCollapse.setContentDescription(getString(R.string.expand_controls));
}
-
- @Override
- public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
- super.onMap3DViewReady(googleMap3D);
- googleMap3D.setMapMode(Map3DMode.SATELLITE);
-
- // Trigger async route fetch on executor thread
- loadAndRenderRouteAsync(googleMap3D);
+ }
+
+ private void expandControls() {
+ if (controlsCard == null || cardContent == null || !isCollapsed) return;
+ isCollapsed = false;
+ TransitionManager.beginDelayedTransition(controlsCard);
+ cardContent.setVisibility(View.VISIBLE);
+ if (btnCollapse != null) {
+ btnCollapse.setIconResource(R.drawable.expand_more_24px);
+ btnCollapse.setContentDescription(getString(R.string.collapse_controls));
}
+ resetFadeTimer();
+ }
- private void loadAndRenderRouteAsync(GoogleMap3D googleMap3D) {
- String apiKey = BuildConfig.MAPS3D_API_KEY;
- LatLng origin = new LatLng(21.307043, -157.858984);
- LatLng destination = new LatLng(21.390177, -157.719454);
-
- executorService.execute(() -> {
- List decoded;
- try {
- if (apiKey.isEmpty() || apiKey.contains("YOUR_API_KEY")) {
- throw new Exception("Invalid or missing API Key");
- }
- RouteData routeData = routeRepository.fetchRouteCallable(apiKey, origin, destination).call();
- decoded = PolyUtil.decode(routeData.getEncodedPolyline());
- } catch (Exception e) {
- Log.w(getTAG(), "Routes API fetch failed (" + e.getLocalizedMessage() + "). Falling back to pre-baked Oahu mountain route.");
- decoded = OahuRouteData.getFALLBACK_ROUTE();
- mainHandler.post(() -> Toast.makeText(
- RoutesActivity.this,
- "Offline: Using local Oahu fallback route",
- Toast.LENGTH_LONG
- ).show());
+ private void resetFadeTimer() {
+ if (controlsCard != null) {
+ controlsCard.animate().alpha(ACTIVE_ALPHA).setDuration(150).start();
+ }
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ if (!isCollapsed) {
+ fadeHandler.postDelayed(fadeOutRunnable, FADE_DELAY_MS);
+ }
+ }
+
+ private void togglePlayback(boolean play) {
+ isPlaying = play;
+ if (btnPlayPause != null) {
+ if (play) {
+ btnPlayPause.setIconResource(R.drawable.pause_24px);
+ startAnimationLoop();
+ } else {
+ btnPlayPause.setIconResource(R.drawable.play_arrow_24px);
+ stopAnimationLoop();
+ }
+ }
+ }
+
+ // --- Map Readiness & Route Rendering ---
+
+ @Override
+ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) {
+ super.onMap3DViewReady(googleMap3D);
+ googleMap3D.setMapMode(Map3DMode.SATELLITE);
+
+ // Trigger async route fetch on executor thread
+ loadAndRenderRouteAsync(googleMap3D);
+ }
+
+ private void loadAndRenderRouteAsync(@NonNull GoogleMap3D googleMap3D) {
+ String apiKey = BuildConfig.MAPS3D_API_KEY;
+ LatLng origin = new LatLng(21.307043, -157.858984);
+ LatLng destination = new LatLng(21.390177, -157.719454);
+
+ executorService.execute(
+ () -> {
+ List decoded;
+ try {
+ if (apiKey.isEmpty() || apiKey.contains("YOUR_API_KEY")) {
+ throw new Exception("Invalid or missing API Key");
}
-
- final List finalDecoded = decoded;
- mainHandler.post(() -> {
+ RouteData routeData =
+ routeRepository.fetchRouteCallable(apiKey, origin, destination).call();
+ decoded = PolyUtil.decode(routeData.getEncodedPolyline());
+ } catch (Exception e) {
+ Log.w(
+ getTAG(),
+ "Routes API fetch failed ("
+ + e.getLocalizedMessage()
+ + "). Falling back to pre-baked Oahu mountain route.");
+ decoded = OahuRouteData.getFALLBACK_ROUTE();
+ mainHandler.post(
+ () ->
+ Toast.makeText(
+ RoutesActivity.this,
+ R.string.routes_offline_fallback,
+ Toast.LENGTH_LONG)
+ .show());
+ }
+
+ final List finalDecoded = decoded;
+ mainHandler.post(
+ () -> {
decodedRoute = finalDecoded;
cumulativeDistances = RouteEngine.calculateCumulativeDistances(finalDecoded);
totalDistance = cumulativeDistances[cumulativeDistances.length - 1];
- // 1. Draw the blue route polyline
+ // 1. Draw the blue route polyline using CLAMP_TO_GROUND so it drapes naturally
+ // over the 3D terrain mesh and elevations without floating or clipping into hills.
List linePath = new ArrayList<>();
for (LatLng point : finalDecoded) {
- linePath.add(new LatLngAltitude(point.latitude, point.longitude, 0.0));
+ linePath.add(new LatLngAltitude(point.latitude, point.longitude, 0.0));
}
PolylineOptions polyOptions = new PolylineOptions();
@@ -272,13 +442,19 @@ private void loadAndRenderRouteAsync(GoogleMap3D googleMap3D) {
polyOptions.setZIndex(5);
routePolyline = googleMap3D.addPolyline(polyOptions);
- // 2. Load the 3D Car model
+ // 2. Load the 3D Car model (.glb).
+ // Note on glTF Orientation: Pitch is set to -90.0 degrees because standard glTF
+ // models use a +Y up coordinate system, requiring a -90 degree pitch adjustment
+ // to align the car body horizontally with the tangent plane of the Earth surface.
ModelOptions modelOpts = new ModelOptions();
modelOpts.setId("vehicle_car_java");
- modelOpts.setPosition(new LatLngAltitude(finalDecoded.get(0).latitude, finalDecoded.get(0).longitude, 25.0));
+ modelOpts.setPosition(
+ new LatLngAltitude(
+ finalDecoded.get(0).latitude, finalDecoded.get(0).longitude, 25.0));
modelOpts.setAltitudeMode(AltitudeMode.RELATIVE_TO_GROUND);
modelOpts.setOrientation(new Orientation(0.0, -90.0, 0.0));
- modelOpts.setUrl("https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/red_car.glb");
+ modelOpts.setUrl(
+ "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/red_car.glb");
modelOpts.setScale(new Vector3D(50.0, 50.0, 50.0));
vehicleModel = googleMap3D.addModel(modelOpts);
@@ -286,97 +462,105 @@ private void loadAndRenderRouteAsync(GoogleMap3D googleMap3D) {
// Trigger play automatically once map is populated
togglePlayback(true);
- });
+ });
});
- }
+ }
+
+ // --- Animation Tick Engine ---
- // Animation Tick Engine Runnable
- private long lastTime = 0;
- private final Runnable animationTickRunnable = new Runnable() {
+ private long lastTime = 0;
+ private final Runnable animationTickRunnable =
+ new Runnable() {
@Override
public void run() {
- if (!isPlaying || totalDistance <= 0.0) return;
-
- long now = System.currentTimeMillis();
- double dt = (now - lastTime) / 1000.0; // Delta time in seconds
- lastTime = now;
-
- elapsedDistance += vehicleSpeedMps * dt;
-
- // Clamp/loop playback
- if (elapsedDistance >= totalDistance) {
- elapsedDistance = 0.0;
- }
-
- // Sync seekBar
- if (!isUserScrubbing) {
- progressSlider.setValue((float) (elapsedDistance / totalDistance));
- }
+ if (!isPlaying || totalDistance <= 0.0) return;
- updateVehiclePositionAndCamera();
+ long now = System.currentTimeMillis();
+ // Delta-time (dt) integration: Multiply velocity by actual elapsed wall-clock seconds.
+ // This guarantees consistent movement speed regardless of whether the device displays
+ // at 60Hz, 90Hz, or 120Hz refresh rates.
+ double dt = (now - lastTime) / 1000.0;
+ lastTime = now;
- // Post next frame with ~16ms delays (60fps targets)
- mainHandler.postDelayed(this, 16);
- }
- };
+ elapsedDistance += vehicleSpeedMps * dt;
- private void startAnimationLoop() {
- lastTime = System.currentTimeMillis();
- mainHandler.post(animationTickRunnable);
- }
+ // Clamp / loop playback
+ if (elapsedDistance >= totalDistance) {
+ elapsedDistance = 0.0;
+ }
- private void stopAnimationLoop() {
- mainHandler.removeCallbacks(animationTickRunnable);
- }
+ // Sync progress slider
+ if (!isUserScrubbing && progressSlider != null) {
+ progressSlider.setValue((float) (elapsedDistance / totalDistance));
+ }
- /**
- * Interpolates geographic vectors and repositions models/camera.
- */
- private void updateVehiclePositionAndCamera() {
- if (decodedRoute.isEmpty() || totalDistance <= 0.0) return;
-
- PositionAndHeading posAndHeading = RouteEngine.calculatePositionAndHeading(
- decodedRoute,
- cumulativeDistances,
- elapsedDistance,
- 30.0
- );
-
- // 1. Upsert Model position and rotation on every tick using the same ID
- if (googleMap3D != null) {
- ModelOptions modelOpts = new ModelOptions();
- modelOpts.setId("vehicle_car_java");
- modelOpts.setPosition(new LatLngAltitude(posAndHeading.getPosition().latitude, posAndHeading.getPosition().longitude, 25.0));
- modelOpts.setAltitudeMode(AltitudeMode.RELATIVE_TO_GROUND);
- modelOpts.setOrientation(new Orientation(posAndHeading.getHeading(), -90.0, 0.0));
- modelOpts.setUrl("https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/red_car.glb");
- modelOpts.setScale(new Vector3D(50.0, 50.0, 50.0));
- vehicleModel = googleMap3D.addModel(modelOpts);
- }
+ updateVehiclePositionAndCamera();
- // 2. Update Camera center and bearing
- if (googleMap3D != null) {
- Camera trackingCamera = new Camera(
- new LatLngAltitude(posAndHeading.getPosition().latitude, posAndHeading.getPosition().longitude, 0.0),
- toHeading(posAndHeading.getHeading() + yawOffset),
- 65.0,
- 0.0,
- (double) cameraRange
- );
- googleMap3D.setCamera(trackingCamera);
+ // Post next frame with ~16ms delays (60fps target)
+ mainHandler.postDelayed(this, 16);
}
+ };
+
+ private void startAnimationLoop() {
+ lastTime = System.currentTimeMillis();
+ mainHandler.post(animationTickRunnable);
+ }
+
+ private void stopAnimationLoop() {
+ mainHandler.removeCallbacks(animationTickRunnable);
+ }
+
+ /** Interpolates geographic vectors and repositions models/camera. */
+ private void updateVehiclePositionAndCamera() {
+ if (decodedRoute.isEmpty() || totalDistance <= 0.0) return;
+
+ PositionAndHeading posAndHeading =
+ RouteEngine.calculatePositionAndHeading(
+ decodedRoute, cumulativeDistances, elapsedDistance, 30.0);
+
+ // 1. Upsert Model position and rotation on every tick using the same ID
+ if (googleMap3D != null) {
+ ModelOptions modelOpts = new ModelOptions();
+ modelOpts.setId("vehicle_car_java");
+ modelOpts.setPosition(
+ new LatLngAltitude(
+ posAndHeading.getPosition().latitude, posAndHeading.getPosition().longitude, 25.0));
+ modelOpts.setAltitudeMode(AltitudeMode.RELATIVE_TO_GROUND);
+ modelOpts.setOrientation(new Orientation(posAndHeading.getHeading(), -90.0, 0.0));
+ modelOpts.setUrl("https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/red_car.glb");
+ modelOpts.setScale(new Vector3D(50.0, 50.0, 50.0));
+ vehicleModel = googleMap3D.addModel(modelOpts);
}
- @Override
- protected void onPause() {
- super.onPause();
- togglePlayback(false);
- }
-
- @Override
- protected void onDestroy() {
- super.onDestroy();
- stopAnimationLoop();
- executorService.shutdown();
+ // 2. Update Camera center and bearing
+ if (googleMap3D != null) {
+ Camera trackingCamera =
+ toValidCamera(
+ new Camera(
+ new LatLngAltitude(
+ posAndHeading.getPosition().latitude,
+ posAndHeading.getPosition().longitude,
+ 0.0),
+ toHeading(posAndHeading.getHeading() + yawOffset),
+ 65.0,
+ 0.0,
+ (double) cameraRange));
+ googleMap3D.setCamera(trackingCamera);
}
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ togglePlayback(false);
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ }
+
+ @Override
+ protected void onDestroy() {
+ super.onDestroy();
+ stopAnimationLoop();
+ fadeHandler.removeCallbacks(fadeOutRunnable);
+ executorService.shutdown();
+ }
}
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/androidTest/java/com/example/maps3dkotlin/CloudStylingVisualTest.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/androidTest/java/com/example/maps3dkotlin/CloudStylingVisualTest.kt
new file mode 100644
index 00000000..e83306ce
--- /dev/null
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/androidTest/java/com/example/maps3dkotlin/CloudStylingVisualTest.kt
@@ -0,0 +1,82 @@
+/*
+ * 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
+
+import android.content.Intent
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Until
+import com.example.maps3dkotlin.cloudstyling.CloudStylingActivity
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * A premium visual regression test for the View-based Kotlin Cloud-Based Map Styling sample.
+ *
+ * This test automates launching the [CloudStylingActivity], waiting for cloud-styled 3D map tiles configured via custom
+ * Map ID over San Francisco to initialize, capturing a screenshot of the live rendering scene, and verifying visual
+ * correctness using the Gemini API.
+ */
+@RunWith(AndroidJUnit4::class)
+class CloudStylingVisualTest : BaseVisualTest() {
+
+ @Test
+ fun verifyCloudStylingRenders() {
+ runBlocking {
+ // Launch CloudStylingActivity
+ val intent = Intent(context, CloudStylingActivity::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+
+ // Wait for the activity to be displayed in the foreground
+ uiDevice.wait(Until.hasObject(By.pkg(context.packageName).depth(0)), 10000)
+
+ // Wait for Cloud-styled 3D map tiles to render and feature styling to settle
+ waitForMapRendering(15)
+
+ // Capture high-resolution screenshot of the active 3D map scene
+ val screenshotBitmap = captureScreenshot("cloud_styling_screenshot.png")
+
+ // Define the verification prompt for the visual testing agent
+ val prompt = """
+ Please act as a UI tester and analyze this screenshot.
+ 1. Confirm that a 3D map view is visible over San Francisco.
+ 2. Confirm that custom cloud-based stylized map tiles / elements are displayed on the 3D map scene.
+ 3. Confirm that the Map Mode selector card (with radio options for Roadmap, Hybrid, and Satellite) is visible at the bottom of the screen.
+
+ If and ONLY IF you can clearly see the 3D Cloud-styled map view and bottom Map Mode selection card, reply with "PASSED".
+ If you cannot see the 3D map scene or control card, reply with "FAILED: 3D map scene or Map Mode controls not visible".
+ Report what you see in detail.
+ """.trimIndent()
+
+ // Analyze the image using Gemini
+ val geminiResponse = helper.analyzeImage(screenshotBitmap, prompt, geminiApiKey)
+ println("Gemini's analysis: ${'$'}geminiResponse")
+
+ // Assert on Gemini's response
+ assertTrue(
+ "Visual verification failed. Gemini response: ${'$'}geminiResponse",
+ geminiResponse?.contains("PASSED", ignoreCase = true) == true
+ )
+ }
+ }
+}
diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/advancedcameraanimation/AdvancedCameraAnimationActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/advancedcameraanimation/AdvancedCameraAnimationActivity.kt
index c1ef67fe..3f718a1a 100644
--- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/advancedcameraanimation/AdvancedCameraAnimationActivity.kt
+++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/advancedcameraanimation/AdvancedCameraAnimationActivity.kt
@@ -19,7 +19,6 @@ package com.example.maps3dkotlin.advancedcameraanimation
import android.os.Bundle
import android.view.ViewGroup
import android.widget.Button
-import android.widget.RadioGroup
import androidx.lifecycle.lifecycleScope
import com.example.maps3d.common.RouteEngine
import com.example.maps3dcommon.R
@@ -38,13 +37,33 @@ import com.google.android.gms.maps3d.model.camera
import com.google.android.gms.maps3d.model.latLngAltitude
import com.google.android.material.appbar.MaterialToolbar
import com.google.maps.android.SphericalUtil
+import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.time.Duration.Companion.milliseconds
+/**
+ * Suspends until the 3D map camera animation completes using [com.google.android.gms.maps3d.OnCameraAnimationEndListener].
+ */
+private suspend fun GoogleMap3D.awaitFlyCameraTo(options: FlyToOptions) =
+ suspendCancellableCoroutine { continuation ->
+ setCameraAnimationEndListener {
+ setCameraAnimationEndListener(null)
+ if (continuation.isActive) {
+ continuation.resume(Unit)
+ }
+ }
+ flyCameraTo(options)
+ continuation.invokeOnCancellation {
+ setCameraAnimationEndListener(null)
+ stopCameraAnimation()
+ }
+ }
+
enum class AnimationApproach {
SIMPLE_FLY_TO,
KEYFRAME_TOUR,
@@ -132,16 +151,6 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() {
setNavigationOnClickListener { finish() }
}
- findViewById(R.id.rg_approach)?.setOnCheckedChangeListener { _, checkedId ->
- selectedApproach = when (checkedId) {
- R.id.rb_simple_flyto -> AnimationApproach.SIMPLE_FLY_TO
- R.id.rb_keyframe_tour -> AnimationApproach.KEYFRAME_TOUR
- R.id.rb_orbit_spin -> AnimationApproach.ORBIT_360_SPIN
- else -> AnimationApproach.DISPATCHER_FRAME_LOOP
- }
- resetAndRestartTour()
- }
-
findViewById