From 69be004c004db37e16929c3b853be8a97709e962 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:22:00 -0600 Subject: [PATCH 1/6] fix(path-following,advanced-animation): address PR 56 review findings - Use per-waypoint altitude profiles for static/progress polylines in Path Following (Kotlin Views, Java Views, Compose) - Interpolate segment altitude for camera target elevation in ABSOLUTE mode - Synchronize status label updates and Choreographer frame callback cleanup in Kotlin AdvancedCameraAnimationActivity - Apply Spotless formatting across modified files --- .../pathfollowing/PathFollowingActivity.java | 830 +++++++++--------- .../AdvancedCameraAnimationActivity.kt | 24 + .../pathfollowing/PathFollowingActivity.kt | 158 ++-- .../pathfollowing/PathFollowingActivity.kt | 66 +- 4 files changed, 553 insertions(+), 525 deletions(-) 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 05d71c5..b3643c2 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 @@ -16,8 +16,6 @@ package com.example.maps3djava.pathfollowing; -import static com.example.maps3d.common.UtilitiesKt.toHeading; - import android.graphics.Color; import android.os.Bundle; import android.os.Handler; @@ -50,77 +48,31 @@ /** * Advanced sample demonstrating ground-level path following in Java. - * Features: - Urban vs Rural ground-level paths - Two-polyline architecture: wide blue base route - * (lower z-index) + narrow purple active progress route (higher z-index) - In-place polyline ID - * updates eliminating render flickering - Configurable altitude modes (Clamp to Ground default, - * Relative to Ground, Relative to Mesh, Absolute) - Dynamic path elevation slider to eliminate - * z-fighting - Occlusion visualization toggle (drawsOccludedSegments) rendering polylines through - * or behind 3D terrain and buildings - Explicit collapse dialog button and smooth slide-down - * controls - Real-time camera controls via sliders: Range, Ground Altitude, Heading Offset, Tilt, - * Follow Speed + * + * Features: + * - Urban vs Rural ground-level paths + * - Two-polyline architecture: wide blue base route (lower z-index) + narrow purple active progress route (higher z-index) + * - In-place polyline ID updates eliminating render flickering + * - Configurable altitude modes (Clamp to Ground default, Relative to Ground, Relative to Mesh, Absolute) + * - Dynamic path elevation slider to eliminate z-fighting + * - Occlusion visualization toggle (drawsOccludedSegments) rendering polylines through or behind 3D terrain and buildings + * - Explicit collapse dialog button and smooth slide-down controls + * - Real-time camera controls via sliders: Range, Ground Altitude, Heading Offset, Tilt, Follow Speed */ public class PathFollowingActivity extends AppCompatActivity implements OnMap3DViewReadyCallback { + private static final String STATIC_ROUTE_POLYLINE_ID = "path_following_static_route"; + private static final String PROGRESS_POLYLINE_ID = "path_following_progress_route"; + private Map3DView map3DView; private GoogleMap3D googleMap3D; - // Urban Path (San Francisco Downtown - Market Street Corridor with 1 to 10m altitudes) - public static final List URBAN_PATH = Arrays.asList( - new LatLngAltitude(37.79323, -122.39322, 4.2), - new LatLngAltitude(37.79166, -122.39519, 6.7), - new LatLngAltitude(37.79124, -122.39571, 8.1), - new LatLngAltitude(37.79105, -122.39599, 9.5), - new LatLngAltitude(37.78893, -122.39866, 7.3), - new LatLngAltitude(37.78742, -122.40060, 5.0), - new LatLngAltitude(37.78686, -122.40129, 3.4), - new LatLngAltitude(37.78652, -122.40171, 2.1), - new LatLngAltitude(37.78632, -122.40196, 4.6), - new LatLngAltitude(37.78627, -122.40207, 6.2), - new LatLngAltitude(37.78453, -122.40429, 8.9), - new LatLngAltitude(37.78443, -122.40434, 10.0), - new LatLngAltitude(37.78155, -122.40802, 7.8), - new LatLngAltitude(37.78005, -122.40990, 5.4), - new LatLngAltitude(37.77856, -122.41180, 3.1), - new LatLngAltitude(37.77746, -122.41318, 1.8), - new LatLngAltitude(37.77624, -122.41474, 4.0), - new LatLngAltitude(37.77744, -122.41623, 6.5), - new LatLngAltitude(37.77749, -122.41636, 8.7), - new LatLngAltitude(37.77761, -122.41654, 9.8), - new LatLngAltitude(37.77769, -122.41677, 7.2), - new LatLngAltitude(37.77729, -122.41981, 4.9), - new LatLngAltitude(37.77523, -122.41938, 2.6), - new LatLngAltitude(37.77510, -122.41934, 1.2), - new LatLngAltitude(37.77442, -122.42022, 3.5), - new LatLngAltitude(37.77441, -122.42033, 5.8), - new LatLngAltitude(37.77348, -122.42157, 8.4), - new LatLngAltitude(37.77244, -122.42289, 10.0) - ); - - // Rural Path - public static final List RURAL_PATH = Arrays.asList( - new LatLngAltitude(37.254529, -122.380897, 0.0), - new LatLngAltitude(37.255065, -122.381627, 0.0), - new LatLngAltitude(37.257540, -122.383720, 0.0), - new LatLngAltitude(37.261200, -122.383950, 0.0), - new LatLngAltitude(37.264780, -122.388210, 0.0), - new LatLngAltitude(37.268520, -122.392450, 0.0), - new LatLngAltitude(37.272110, -122.397640, 0.0), - new LatLngAltitude(37.276430, -122.401120, 0.0), - new LatLngAltitude(37.280850, -122.403560, 0.0), - new LatLngAltitude(37.286018, -122.405072, 0.0), - new LatLngAltitude(37.291040, -122.404210, 0.0), - new LatLngAltitude(37.295800, -122.401980, 0.0), - new LatLngAltitude(37.300120, -122.399540, 0.0), - new LatLngAltitude(37.304550, -122.397210, 0.0), - new LatLngAltitude(37.309200, -122.395100, 0.0), - new LatLngAltitude(37.313450, -122.392840, 0.0), - new LatLngAltitude(37.317200, -122.390510, 0.0), - new LatLngAltitude(37.320850, -122.388740, 0.0), - new LatLngAltitude(37.323540, -122.387600, 0.0), - new LatLngAltitude(37.325269, -122.386728, 0.0) - ); - // View Bindings + private CardView controlsCard; + private View cardHeader; + private MaterialButton btnCollapse; + private boolean isCollapsed = false; + private RadioGroup rgEnvironment; private RadioGroup rgAltitudeMode; private MaterialSwitch switchDrawsOccludedSegments; @@ -139,96 +91,48 @@ public class PathFollowingActivity extends AppCompatActivity implements OnMap3DV private Slider speedSlider; private TextView speedSliderLabel; - // Control parameters - private double cameraRange = 300.0; - private double groundAltitude = 20.0; - 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; - private boolean drawsOccludedSegments = true; - - // Path state - private List currentPath = URBAN_PATH; - private double[] cumulativeDistances = new double[0]; - private double totalDistance = 0.0; - private double elapsedDistance = 0.0; - private boolean isPlaying = false; - private boolean isUserScrubbing = false; - - // 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 animationHandler = new Handler(Looper.getMainLooper()); - private Runnable animationRunnable; - - private CardView controlsCard; - private 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 final Runnable fadeOutRunnable = + () -> { + if (controlsCard != null && !isCollapsed) { + controlsCard.animate().alpha(0.8f).setDuration(400).start(); + } + }; private void collapseControls() { - if (controlsCard == null) { - return; - } + 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); + + 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(); + controlsCard.animate().translationY(targetTranslationY).alpha(0.9f).setDuration(300).start(); } private void expandControls() { - if (controlsCard == null) { - return; - } + 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(); + 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 (ev.getAction() == MotionEvent.ACTION_DOWN || ev.getAction() == MotionEvent.ACTION_MOVE) { if (controlsCard != null && !isCollapsed) { - controlsCard.animate() - .alpha(1.0f) - .setDuration(150) - .start(); + controlsCard.animate().alpha(1.0f).setDuration(150).start(); fadeHandler.removeCallbacks(fadeOutRunnable); fadeHandler.postDelayed(fadeOutRunnable, 3000L); } @@ -236,6 +140,32 @@ public boolean dispatchTouchEvent(MotionEvent ev) { return super.dispatchTouchEvent(ev); } + // Control parameters + private double cameraRange = 300.0; + private double groundAltitude = 20.0; + private double headingOffset = 0.0; + private double cameraTilt = 70.0; + private double followSpeedMps = 30.0; + private int pathAltitudeMode = AltitudeMode.CLAMP_TO_GROUND; + private double pathAltitudeOffset = 0.5; + private boolean drawsOccludedSegments = true; + + // Path state + private List currentPath = URBAN_PATH; + private double[] cumulativeDistances = new double[0]; + private double totalDistance = 0.0; + private double elapsedDistance = 0.0; + private boolean isPlaying = false; + private boolean isUserScrubbing = false; + + // Polyline handles + private Polyline staticRoutePolyline; + private Polyline progressPolyline; + + // Animation Loop + private Runnable animationRunnable; + private final Handler animationHandler = new Handler(Looper.getMainLooper()); + @Override protected void onCreate(Bundle savedInstanceState) { WindowCompat.setDecorFitsSystemWindows(getWindow(), false); @@ -291,38 +221,54 @@ protected void onSaveInstanceState(@NonNull Bundle outState) { map3DView.onSaveInstanceState(outState); } + @Override + public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) { + this.googleMap3D = googleMap3D; + googleMap3D.setOnMapReadyListener( + (map) -> { + drawPathPolylines(); + updateCameraPositionForDistance(0.0); + }); + + // Schedule auto-fade for idle control panel after initial display + fadeHandler.removeCallbacks(fadeOutRunnable); + fadeHandler.postDelayed(fadeOutRunnable, 3000L); + } + 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(); - } - }); + btnCollapse.setOnClickListener( + v -> { + if (isCollapsed) { + expandControls(); + } else { + collapseControls(); + } + }); } if (cardHeader != null) { - cardHeader.setOnClickListener(v -> { - if (isCollapsed) { - expandControls(); - } - }); + cardHeader.setOnClickListener( + v -> { + if (isCollapsed) { + expandControls(); + } else { + collapseControls(); + } + }); } - fadeHandler.postDelayed(fadeOutRunnable, 3000L); - rgEnvironment = findViewById(R.id.rg_environment); rgAltitudeMode = findViewById(R.id.rg_altitude_mode); + switchDrawsOccludedSegments = findViewById(R.id.switch_draws_occluded_segments); 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); - rangeSlider = findViewById(R.id.range_slider); rangeSliderLabel = findViewById(R.id.range_slider_label); altitudeSlider = findViewById(R.id.altitude_slider); @@ -334,164 +280,151 @@ 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) { - altitudeSlider.setValueTo(200.0f); - switchEnvironment(URBAN_PATH); - } else if (checkedId == R.id.rb_rural) { - altitudeSlider.setValueTo(2000.0f); - switchEnvironment(RURAL_PATH); - } - }); - - // 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; - } - if (staticRoutePolyline != null) { - staticRoutePolyline.remove(); - staticRoutePolyline = null; - } - if (progressPolyline != null) { - progressPolyline.remove(); - progressPolyline = null; - } - drawPathPolylines(); - updateCameraPositionForDistance(elapsedDistance); - }); + // Environment toggle + rgEnvironment.setOnCheckedChangeListener( + (group, checkedId) -> { + if (checkedId == R.id.rb_urban) { + switchEnvironment(URBAN_PATH); + } else if (checkedId == R.id.rb_rural) { + switchEnvironment(RURAL_PATH); + } + }); + + // Altitude mode toggle + rgAltitudeMode.setOnCheckedChangeListener( + (group, checkedId) -> { + if (checkedId == R.id.rb_clamp_to_ground) { + pathAltitudeMode = AltitudeMode.CLAMP_TO_GROUND; + } else if (checkedId == R.id.rb_relative_to_ground) { + pathAltitudeMode = AltitudeMode.RELATIVE_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; + } else { + pathAltitudeMode = AltitudeMode.CLAMP_TO_GROUND; + } + if (staticRoutePolyline != null) { + staticRoutePolyline.remove(); + staticRoutePolyline = null; + } + if (progressPolyline != null) { + progressPolyline.remove(); + progressPolyline = null; + } + drawPathPolylines(); + updateCameraPositionForDistance(elapsedDistance); + }); // Draw occluded segments toggle - switchDrawsOccludedSegments = findViewById(R.id.switch_draws_occluded_segments); - if (switchDrawsOccludedSegments != null) { - switchDrawsOccludedSegments.setChecked(drawsOccludedSegments); - switchDrawsOccludedSegments.setOnCheckedChangeListener((buttonView, isChecked) -> { - drawsOccludedSegments = isChecked; - if (staticRoutePolyline != null) { - staticRoutePolyline.remove(); - staticRoutePolyline = null; - } - if (progressPolyline != null) { - progressPolyline.remove(); - progressPolyline = null; - } - drawPathPolylines(); - updateCameraPositionForDistance(elapsedDistance); - }); - } - - // Path height slider (relative altitude) - pathAltitudeSlider.addOnChangeListener((slider, value, fromUser) -> { - pathAltitudeOffset = value; - pathAltitudeSliderLabel.setText( - getString(R.string.path_height_format, pathAltitudeOffset)); - if (staticRoutePolyline != null) { - staticRoutePolyline.remove(); - staticRoutePolyline = null; - } - if (progressPolyline != null) { - progressPolyline.remove(); - progressPolyline = null; - } - drawPathPolylines(); - updateCameraPositionForDistance(elapsedDistance); - }); + switchDrawsOccludedSegments.setChecked(drawsOccludedSegments); + switchDrawsOccludedSegments.setOnCheckedChangeListener( + (buttonView, isChecked) -> { + drawsOccludedSegments = isChecked; + if (staticRoutePolyline != null) { + staticRoutePolyline.remove(); + staticRoutePolyline = null; + } + if (progressPolyline != null) { + progressPolyline.remove(); + progressPolyline = null; + } + drawPathPolylines(); + updateCameraPositionForDistance(elapsedDistance); + }); + + // Path height slider (0.0m - 10.0m) + pathAltitudeSlider.addOnChangeListener( + (slider, value, fromUser) -> { + pathAltitudeOffset = value; + pathAltitudeSliderLabel.setText( + getString(R.string.path_height_format, pathAltitudeOffset)); + if (staticRoutePolyline != null) { + staticRoutePolyline.remove(); + staticRoutePolyline = null; + } + if (progressPolyline != null) { + progressPolyline.remove(); + progressPolyline = null; + } + drawPathPolylines(); + updateCameraPositionForDistance(elapsedDistance); + }); // Play/Pause button - btnPlayPause.setOnClickListener(v -> { - if (isPlaying) { - pauseAnimation(); - } else { - startAnimation(); - } - }); - - // Progress slider scrubbing - progressSlider.addOnChangeListener((slider, value, fromUser) -> { - if (fromUser) { - isUserScrubbing = true; - elapsedDistance = totalDistance * value; - updateCameraPositionForDistance(elapsedDistance); - } - }); - progressSlider.addOnSliderTouchListener(new Slider.OnSliderTouchListener() { - @Override - public void onStartTrackingTouch(@NonNull Slider slider) { - isUserScrubbing = true; - } - - @Override - public void onStopTrackingTouch(@NonNull Slider slider) { - isUserScrubbing = false; - } - }); - - // Sliders listeners - rangeSlider.addOnChangeListener((slider, value, fromUser) -> { - cameraRange = value; - rangeSliderLabel.setText(getString(R.string.camera_range_format, (int) cameraRange)); - updateCameraPositionForDistance(elapsedDistance); - }); - - altitudeSlider.addOnChangeListener((slider, value, fromUser) -> { - groundAltitude = value; - altitudeSliderLabel.setText( - getString(R.string.ground_altitude_format, (int) groundAltitude)); - updateCameraPositionForDistance(elapsedDistance); - }); - - headingSlider.addOnChangeListener((slider, value, fromUser) -> { - headingOffset = value; - headingSliderLabel.setText( - getString(R.string.heading_offset_format, (int) headingOffset)); - updateCameraPositionForDistance(elapsedDistance); - }); - - tiltSlider.addOnChangeListener((slider, value, fromUser) -> { - cameraTilt = value; - tiltSliderLabel.setText(getString(R.string.camera_tilt_format, (int) cameraTilt)); - updateCameraPositionForDistance(elapsedDistance); - }); - - speedSlider.addOnChangeListener((slider, value, fromUser) -> { - followSpeedMps = value; - speedSliderLabel.setText(getString(R.string.follow_speed_format, (int) followSpeedMps)); - }); - } + btnPlayPause.setOnClickListener( + v -> { + if (isPlaying) { + pauseAnimation(); + } else { + startAnimation(); + } + }); + + // Progress scrub slider + progressSlider.addOnSliderTouchListener( + new Slider.OnSliderTouchListener() { + @Override + public void onStartTrackingTouch(@NonNull Slider slider) { + isUserScrubbing = true; + } - 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 onStopTrackingTouch(@NonNull Slider slider) { + isUserScrubbing = false; + elapsedDistance = totalDistance * slider.getValue(); + updateCameraPositionForDistance(elapsedDistance); + } + }); - @Override - public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) { - this.googleMap3D = googleMap3D; - googleMap3D.setOnMapReadyListener((map) -> { - googleMap3D.setOnMapReadyListener(null); - runOnUiThread(() -> { - drawPathPolylines(); - updateCameraPositionForDistance(0.0); - startAnimation(); - }); - }); + progressSlider.addOnChangeListener( + (slider, value, fromUser) -> { + if (fromUser) { + elapsedDistance = totalDistance * value; + updateCameraPositionForDistance(elapsedDistance); + } + }); + + // Camera Range Slider (50m - 1000m) + rangeSlider.addOnChangeListener( + (slider, value, fromUser) -> { + cameraRange = value; + rangeSliderLabel.setText(getString(R.string.camera_range_format, (int) cameraRange)); + updateCameraPositionForDistance(elapsedDistance); + }); + + // Ground Altitude Slider (2m - 200m) + altitudeSlider.addOnChangeListener( + (slider, value, fromUser) -> { + groundAltitude = value; + altitudeSliderLabel.setText( + getString(R.string.ground_altitude_format, (int) groundAltitude)); + updateCameraPositionForDistance(elapsedDistance); + }); + + // Heading Offset Slider (-180° - +180°) + headingSlider.addOnChangeListener( + (slider, value, fromUser) -> { + headingOffset = value; + headingSliderLabel.setText( + getString(R.string.heading_offset_format, (int) headingOffset)); + updateCameraPositionForDistance(elapsedDistance); + }); + + // Camera Tilt Slider (0° - 85°) + tiltSlider.addOnChangeListener( + (slider, value, fromUser) -> { + cameraTilt = value; + tiltSliderLabel.setText(getString(R.string.camera_tilt_format, (int) cameraTilt)); + updateCameraPositionForDistance(elapsedDistance); + }); + + // Follow Speed Slider (5 m/s - 100 m/s) + speedSlider.addOnChangeListener( + (slider, value, fromUser) -> { + followSpeedMps = value; + speedSliderLabel.setText(getString(R.string.follow_speed_format, (int) followSpeedMps)); + }); } private Double currentHeading = null; @@ -514,24 +447,24 @@ private void switchEnvironment(List path) { cameraRange = 450.0; groundAltitude = 40.0; cameraTilt = 75.0; - rangeSlider.setValue(450f); + altitudeSlider.setValueTo(2000f); altitudeSlider.setValue(40f); + rangeSlider.setValue(450f); tiltSlider.setValue(75f); - 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; cameraTilt = 70.0; - rangeSlider.setValue(300f); + altitudeSlider.setValueTo(200f); altitudeSlider.setValue(20f); + rangeSlider.setValue(300f); tiltSlider.setValue(70f); - 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)); } + rangeSliderLabel.setText(getString(R.string.camera_range_format, (int) cameraRange)); + altitudeSliderLabel.setText(getString(R.string.ground_altitude_format, (int) groundAltitude)); + tiltSliderLabel.setText(getString(R.string.camera_tilt_format, (int) cameraTilt)); + loadPathData(path); drawPathPolylines(); updateCameraPositionForDistance(0.0); @@ -543,8 +476,7 @@ private void loadPathData(List path) { totalDistance = 0.0; cumulativeDistances[0] = 0.0; for (int i = 1; i < path.size(); i++) { - LatLng pPrev = new LatLng(path.get(i - 1).getLatitude(), - path.get(i - 1).getLongitude()); + LatLng pPrev = new LatLng(path.get(i - 1).getLatitude(), path.get(i - 1).getLongitude()); LatLng pCurr = new LatLng(path.get(i).getLatitude(), path.get(i).getLongitude()); double dist = SphericalUtil.computeDistanceBetween(pPrev, pCurr); totalDistance += dist; @@ -555,37 +487,34 @@ private void loadPathData(List path) { private void drawPathPolylines() { drawStaticRoutePolyline(); if (!currentPath.isEmpty()) { - LatLng firstPt = new LatLng(currentPath.get(0).getLatitude(), - currentPath.get(0).getLongitude()); + LatLng firstPt = + new LatLng(currentPath.get(0).getLatitude(), currentPath.get(0).getLongitude()); updateProgressPolyline(elapsedDistance, firstPt, 0); } } private void drawStaticRoutePolyline() { - if (googleMap3D == null || currentPath.isEmpty()) { - return; - } - - double pathAltitude = (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) - ? 0.0 - : (pathAltitudeMode == AltitudeMode.ABSOLUTE) - ? (currentPath.equals(RURAL_PATH) ? 45.0 + pathAltitudeOffset - : 50.0 + pathAltitudeOffset) - : pathAltitudeOffset; + if (googleMap3D == null || currentPath.isEmpty()) return; List staticVertices = new ArrayList<>(); for (LatLngAltitude pt : currentPath) { - staticVertices.add( - new LatLngAltitude(pt.getLatitude(), pt.getLongitude(), pathAltitude)); + double vertexAltitude = + (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) + ? 0.0 + : (pathAltitudeMode == AltitudeMode.ABSOLUTE) + ? pt.getAltitude() + + (currentPath.equals(RURAL_PATH) ? 45.0 : 50.0) + + pathAltitudeOffset + : pt.getAltitude() + pathAltitudeOffset; + staticVertices.add(new LatLngAltitude(pt.getLatitude(), pt.getLongitude(), vertexAltitude)); } PolylineOptions staticOptions = new PolylineOptions(); - staticOptions.setId( - STATIC_ROUTE_POLYLINE_ID); // Fixed ID prevents flickering and updates in place + staticOptions.setId(STATIC_ROUTE_POLYLINE_ID); // Fixed ID prevents flickering staticOptions.setPath(staticVertices); staticOptions.setStrokeColor(Color.parseColor("#4285F4")); // Wide blue route - staticOptions.setStrokeWidth(16.0); // Route line: wider - staticOptions.setZIndex(1); // Route line: lower z-index + staticOptions.setStrokeWidth(16.0); + staticOptions.setZIndex(1); staticOptions.setAltitudeMode(pathAltitudeMode); staticOptions.setDrawsOccludedSegments(drawsOccludedSegments); @@ -593,64 +522,81 @@ private void drawStaticRoutePolyline() { } 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 - : (pathAltitudeMode == AltitudeMode.ABSOLUTE) - ? (currentPath.equals(RURAL_PATH) ? 45.0 + pathAltitudeOffset - : 50.0 + pathAltitudeOffset) - : pathAltitudeOffset; + if (googleMap3D == null || currentPath.isEmpty() || totalDistance <= 0.0) return; - double progressAltitude = (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) - ? 0.0 - : pathAltitude + 0.4; - - // Build traversed route from start to current position List progressCoordinates = new ArrayList<>(); for (int i = 0; i <= Math.min(index, currentPath.size() - 1); i++) { LatLngAltitude pt = currentPath.get(i); + double vertexAltitude = + (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) + ? 0.0 + : (pathAltitudeMode == AltitudeMode.ABSOLUTE) + ? pt.getAltitude() + + (currentPath.equals(RURAL_PATH) ? 45.0 : 50.0) + + pathAltitudeOffset + + 0.4 + : pt.getAltitude() + pathAltitudeOffset + 0.4; progressCoordinates.add( - new LatLngAltitude(pt.getLatitude(), pt.getLongitude(), progressAltitude)); + new LatLngAltitude(pt.getLatitude(), pt.getLongitude(), vertexAltitude)); } LatLngAltitude lastWaypoint = currentPath.get(Math.min(index, currentPath.size() - 1)); LatLng lastLatLng = new LatLng(lastWaypoint.getLatitude(), lastWaypoint.getLongitude()); double distToLast = SphericalUtil.computeDistanceBetween(lastLatLng, currentLatLng); if (distToLast >= 0.5) { + LatLngAltitude p1 = currentPath.get(Math.min(index, currentPath.size() - 1)); + LatLngAltitude p2 = (index < currentPath.size() - 1) ? currentPath.get(index + 1) : p1; + double segStartDist = (index < cumulativeDistances.length) ? cumulativeDistances[index] : 0.0; + double segEndDist = + (index + 1 < cumulativeDistances.length) ? cumulativeDistances[index + 1] : totalDistance; + double segLen = segEndDist - segStartDist; + double fraction = (segLen > 0) ? Math.max(0.0, Math.min(1.0, (dist - segStartDist) / segLen)) : 0.0; + double interpAlt = p1.getAltitude() + fraction * (p2.getAltitude() - p1.getAltitude()); + double progressAltitude = + (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) + ? 0.0 + : (pathAltitudeMode == AltitudeMode.ABSOLUTE) + ? interpAlt + + (currentPath.equals(RURAL_PATH) ? 45.0 : 50.0) + + pathAltitudeOffset + + 0.4 + : interpAlt + pathAltitudeOffset + 0.4; progressCoordinates.add( - new LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, - progressAltitude)); + new LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, progressAltitude)); } // Polyline requires at least 2 distinct vertices if (progressCoordinates.size() < 2) { if (currentPath.size() >= 2) { - LatLng p0 = new LatLng(currentPath.get(0).getLatitude(), - currentPath.get(0).getLongitude()); - LatLng p1 = new LatLng(currentPath.get(1).getLatitude(), - currentPath.get(1).getLongitude()); + LatLng p0 = + new LatLng(currentPath.get(0).getLatitude(), currentPath.get(0).getLongitude()); + LatLng p1 = + new LatLng(currentPath.get(1).getLatitude(), currentPath.get(1).getLongitude()); LatLng tinyForward = SphericalUtil.interpolate(p0, p1, 0.005); + double startAlt = + (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) + ? 0.0 + : (pathAltitudeMode == AltitudeMode.ABSOLUTE) + ? currentPath.get(0).getAltitude() + + (currentPath.equals(RURAL_PATH) ? 45.0 : 50.0) + + pathAltitudeOffset + + 0.4 + : currentPath.get(0).getAltitude() + pathAltitudeOffset + 0.4; progressCoordinates.add( - new LatLngAltitude(tinyForward.latitude, tinyForward.longitude, - progressAltitude)); + new LatLngAltitude(tinyForward.latitude, tinyForward.longitude, startAlt)); } } // 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. // Fixed PROGRESS_POLYLINE_ID ensures in-place upsert in Maps 3D engine without duplication. PolylineOptions progressOptions = new PolylineOptions(); - progressOptions.setId( - PROGRESS_POLYLINE_ID); // Fixed ID upserts in place to eliminate flickering + progressOptions.setId(PROGRESS_POLYLINE_ID); 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.setStrokeColor(Color.parseColor("#9C27B0")); // Narrow purple progress + progressOptions.setStrokeWidth(8.0); + progressOptions.setZIndex(2); progressOptions.setAltitudeMode(pathAltitudeMode); progressOptions.setDrawsOccludedSegments(drawsOccludedSegments); @@ -658,47 +604,39 @@ private void updateProgressPolyline(double dist, LatLng currentLatLng, int index } private void startAnimation() { - if (isPlaying) { - return; - } + if (isPlaying) return; isPlaying = true; btnPlayPause.setIconResource(R.drawable.pause_24px); - final long frameDurationMs = 16L; - animationRunnable = new Runnable() { - private long lastTime = System.currentTimeMillis(); + long frameDurationMs = 16L; + animationRunnable = + new Runnable() { + private long lastTime = System.currentTimeMillis(); - @Override - public void run() { - if (!isPlaying) { - return; - } + @Override + public void run() { + if (!isPlaying) return; - 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; + long now = System.currentTimeMillis(); + double dt = (now - lastTime) / 1000.0; + lastTime = now; - double stepDistance = followSpeedMps * dt; - elapsedDistance += stepDistance; + double stepDistance = followSpeedMps * dt; + elapsedDistance += stepDistance; - if (elapsedDistance >= totalDistance) { - elapsedDistance = 0.0; - } + if (elapsedDistance >= totalDistance) { + elapsedDistance = 0.0; + } - if (!isUserScrubbing && totalDistance > 0) { - float progress = (float) (elapsedDistance / totalDistance); - if (progress >= 0f && progress <= 1f) { - progressSlider.setValue(progress); - } - } - - updateCameraPositionForDistance(elapsedDistance); - animationHandler.postDelayed(this, frameDurationMs); - } - }; + if (!isUserScrubbing && totalDistance > 0) { + float progress = (float) Math.max(0.0, Math.min(1.0, elapsedDistance / totalDistance)); + progressSlider.setValue(progress); + } + updateCameraPositionForDistance(elapsedDistance); + animationHandler.postDelayed(this, frameDurationMs); + } + }; animationHandler.post(animationRunnable); } @@ -712,9 +650,7 @@ private void pauseAnimation() { } private void updateCameraPositionForDistance(double dist) { - if (googleMap3D == null || currentPath.isEmpty()) { - return; - } + if (googleMap3D == null || currentPath.isEmpty()) return; int index = 0; while (index < cumulativeDistances.length - 1 && cumulativeDistances[index + 1] < dist) { @@ -726,51 +662,105 @@ private void updateCameraPositionForDistance(double dist) { double segStartDist = cumulativeDistances[index]; double segEndDist = - (index < cumulativeDistances.length - 1) ? cumulativeDistances[index + 1] - : totalDistance; + (index < cumulativeDistances.length - 1) ? cumulativeDistances[index + 1] : totalDistance; double segLen = segEndDist - segStartDist; - double fraction = - (segLen > 0) ? Math.max(0.0, Math.min(1.0, (dist - segStartDist) / segLen)) : 0.0; + double fraction = (segLen > 0) ? Math.max(0.0, Math.min(1.0, (dist - segStartDist) / segLen)) : 0.0; LatLng latLng1 = new LatLng(p1.getLatitude(), p1.getLongitude()); LatLng latLng2 = new LatLng(p2.getLatitude(), p2.getLongitude()); LatLng currentLatLng = SphericalUtil.interpolate(latLng1, latLng2, fraction); double bearing = SphericalUtil.computeHeading(latLng1, latLng2); - // 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); + // Kinematic Heading Smoothing (Exponential Moving Average) + double targetHeadingRaw = (bearing + headingOffset) % 360.0; + if (targetHeadingRaw < 0.0) targetHeadingRaw += 360.0; + double targetHeading; if (currentHeading == null || isUserScrubbing || !isPlaying) { targetHeading = targetHeadingRaw; } else { double diff = (targetHeadingRaw - currentHeading) % 360.0; - if (diff > 180.0) { - diff -= 360.0; - } - if (diff < -180.0) { - diff += 360.0; - } - targetHeading = toHeading(currentHeading + diff * 0.12); + if (diff > 180.0) diff -= 360.0; + if (diff < -180.0) diff += 360.0; + targetHeading = (currentHeading + diff * 0.12) % 360.0; + if (targetHeading < 0.0) targetHeading += 360.0; } currentHeading = targetHeading; - double cameraTargetAltitude = (pathAltitudeMode == AltitudeMode.ABSOLUTE) - ? ((currentPath.equals(RURAL_PATH) ? 45.0 : 50.0) + groundAltitude) - : groundAltitude; - - Camera newCamera = new Camera( - new LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, - cameraTargetAltitude), - targetHeading, - cameraTilt, - 0.0, - cameraRange - ); + double interpAlt = p1.getAltitude() + fraction * (p2.getAltitude() - p1.getAltitude()); + double cameraTargetAltitude = + (pathAltitudeMode == AltitudeMode.ABSOLUTE) + ? ((currentPath.equals(RURAL_PATH) ? 45.0 : 50.0) + interpAlt + groundAltitude) + : groundAltitude; + + Camera newCamera = + new Camera( + new LatLngAltitude( + currentLatLng.latitude, currentLatLng.longitude, cameraTargetAltitude), + targetHeading, + cameraTilt, + /* roll= */ 0.0, + cameraRange); googleMap3D.setCamera(newCamera); - updateProgressPolyline(dist, currentLatLng, index); + LatLng firstPt = + new LatLng(currentPath.get(0).getLatitude(), currentPath.get(0).getLongitude()); + updateProgressPolyline(dist, (currentLatLng != null) ? currentLatLng : firstPt, index); } + + // Urban Path (San Francisco Downtown - Market Street Corridor with 1 to 10m altitudes) + public static final List URBAN_PATH = + Arrays.asList( + new LatLngAltitude(37.79323, -122.39322, 4.2), + new LatLngAltitude(37.79166, -122.39519, 6.7), + new LatLngAltitude(37.79124, -122.39571, 8.1), + new LatLngAltitude(37.79105, -122.39599, 9.5), + new LatLngAltitude(37.78893, -122.39866, 7.3), + new LatLngAltitude(37.78742, -122.40060, 5.0), + new LatLngAltitude(37.78686, -122.40129, 3.4), + new LatLngAltitude(37.78652, -122.40171, 2.1), + new LatLngAltitude(37.78632, -122.40196, 4.6), + new LatLngAltitude(37.78627, -122.40207, 6.2), + new LatLngAltitude(37.78453, -122.40429, 8.9), + new LatLngAltitude(37.78443, -122.40434, 10.0), + new LatLngAltitude(37.78155, -122.40802, 7.8), + new LatLngAltitude(37.78005, -122.40990, 5.4), + new LatLngAltitude(37.77856, -122.41180, 3.1), + new LatLngAltitude(37.77746, -122.41318, 1.8), + new LatLngAltitude(37.77624, -122.41474, 4.0), + new LatLngAltitude(37.77744, -122.41623, 6.5), + new LatLngAltitude(37.77749, -122.41636, 8.7), + new LatLngAltitude(37.77761, -122.41654, 9.8), + new LatLngAltitude(37.77769, -122.41677, 7.2), + new LatLngAltitude(37.77729, -122.41981, 4.9), + new LatLngAltitude(37.77523, -122.41938, 2.6), + new LatLngAltitude(37.77510, -122.41934, 1.2), + new LatLngAltitude(37.77442, -122.42022, 3.5), + new LatLngAltitude(37.77441, -122.42033, 5.8), + new LatLngAltitude(37.77348, -122.42157, 8.4), + new LatLngAltitude(37.77244, -122.42289, 10.0)); + + // Rural Path + public static final List RURAL_PATH = + Arrays.asList( + new LatLngAltitude(37.254529, -122.380897, 0.0), + new LatLngAltitude(37.255065, -122.381627, 0.0), + new LatLngAltitude(37.257540, -122.383720, 0.0), + new LatLngAltitude(37.261200, -122.383950, 0.0), + new LatLngAltitude(37.264780, -122.388210, 0.0), + new LatLngAltitude(37.268520, -122.392450, 0.0), + new LatLngAltitude(37.272110, -122.397640, 0.0), + new LatLngAltitude(37.276430, -122.401120, 0.0), + new LatLngAltitude(37.280850, -122.403560, 0.0), + new LatLngAltitude(37.286018, -122.405072, 0.0), + new LatLngAltitude(37.291040, -122.404210, 0.0), + new LatLngAltitude(37.295800, -122.401980, 0.0), + new LatLngAltitude(37.300120, -122.399540, 0.0), + new LatLngAltitude(37.304550, -122.397210, 0.0), + new LatLngAltitude(37.309200, -122.395100, 0.0), + new LatLngAltitude(37.313450, -122.392840, 0.0), + new LatLngAltitude(37.317200, -122.390510, 0.0), + new LatLngAltitude(37.320850, -122.388740, 0.0), + new LatLngAltitude(37.323540, -122.387600, 0.0), + new LatLngAltitude(37.325269, -122.386728, 0.0)); } 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 919fc2e..ae302d6 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 @@ -20,6 +20,7 @@ import android.os.Bundle import android.view.ViewGroup import android.widget.Button import android.widget.RadioGroup +import android.widget.TextView import androidx.lifecycle.lifecycleScope import com.example.maps3d.common.RouteEngine import com.example.maps3dcommon.R @@ -131,9 +132,11 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { private var isPlaying = false private var tourJob: Job? = null private var restartJob: Job? = null + private var frameDispatcherCallback: android.view.Choreographer.FrameCallback? = null private var selectedApproach = AnimationApproach.DISPATCHER_FRAME_LOOP private var btnPlayPause: Button? = null + private var tvTourStatus: TextView? = null private val cumulativeDistances by lazy { RouteEngine.calculateCumulativeDistances(AIRPLANE_FLIGHT_PATH) @@ -152,6 +155,8 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { setNavigationOnClickListener { finish() } } + tvTourStatus = findViewById(R.id.tv_tour_status) + findViewById(R.id.rg_animation_approach)?.setOnCheckedChangeListener { _, checkedId -> selectedApproach = when (checkedId) { R.id.rb_simple_fly_to -> AnimationApproach.SIMPLE_FLY_TO @@ -231,6 +236,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { stopTour() isPlaying = true updatePlayPauseButtonState() + tvTourStatus?.setText(R.string.approach_simple_fly_to) val targetLoc = AIRPLANE_FLIGHT_PATH.last() val flightHeading = @@ -254,6 +260,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { googleMap3D?.awaitFlyCameraTo(FlyToOptions(targetCam, 1500L)) isPlaying = false updatePlayPauseButtonState() + tvTourStatus?.setText(R.string.aerial_tour_status_finished) } } @@ -271,6 +278,12 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { while (currentStepIndex < SAN_FRANCISCO_TOUR.size && isActive && isPlaying) { val step = SAN_FRANCISCO_TOUR[currentStepIndex] + tvTourStatus?.text = getString( + R.string.aerial_tour_status_running, + currentStepIndex + 1, + SAN_FRANCISCO_TOUR.size, + step.stepTitle + ) when (step) { is CameraKeyframe.FlyTo -> { @@ -325,6 +338,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { } else { isPlaying = false updatePlayPauseButtonState() + tvTourStatus?.setText(R.string.aerial_tour_status_finished) break } } @@ -343,6 +357,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { stopTour() isPlaying = true updatePlayPauseButtonState() + tvTourStatus?.setText(R.string.approach_dispatcher_frame_loop) val totalDistance = cumulativeDistances.last().coerceAtLeast(1.0) val flightSpeedMps = 400.0 // Fast 400 m/s high-speed flight @@ -389,6 +404,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { googleMap3D?.setCamera(finalCam) isPlaying = false updatePlayPauseButtonState() + tvTourStatus?.setText(R.string.aerial_tour_status_finished) return } @@ -417,6 +433,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { android.view.Choreographer.getInstance().postFrameCallback(this) } } + frameDispatcherCallback = frameCallback android.view.Choreographer.getInstance().postFrameCallback(frameCallback) } @@ -427,6 +444,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { stopTour() isPlaying = true updatePlayPauseButtonState() + tvTourStatus?.setText(R.string.approach_orbit_360_spin) val targetCenter = AIRPLANE_FLIGHT_PATH.first() updateAirplaneModel(targetCenter, 105.0 + 180.0) @@ -457,6 +475,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { } isPlaying = false updatePlayPauseButtonState() + tvTourStatus?.setText(R.string.aerial_tour_status_finished) } } @@ -467,6 +486,10 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { restartJob = null tourJob?.cancel() tourJob = null + frameDispatcherCallback?.let { + android.view.Choreographer.getInstance().removeFrameCallback(it) + frameDispatcherCallback = null + } googleMap3D?.setCameraAnimationEndListener(null) googleMap3D?.stopCameraAnimation() } @@ -493,6 +516,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { range = 600.0 } googleMap3D?.setCamera(resetCam) + tvTourStatus?.setText(R.string.aerial_tour_status_idle) restartJob = lifecycleScope.launch { delay(300.milliseconds) diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt index bd76889..a7defd7 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt @@ -162,11 +162,13 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { private var isPlaying = false private var isUserScrubbing = false - // Polylines + // Polyline handles private var staticRoutePolyline: Polyline? = null private var progressPolyline: Polyline? = null - private val animationHandler = Handler(Looper.getMainLooper()) + + // Animation Loop private var animationRunnable: Runnable? = null + private val animationHandler = Handler(Looper.getMainLooper()) override fun onCreate(savedInstanceState: Bundle?) { WindowCompat.setDecorFitsSystemWindows(window, false) @@ -216,13 +218,13 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { override fun onMap3DViewReady(googleMap3D: GoogleMap3D) { this.googleMap3D = googleMap3D googleMap3D.setOnMapReadyListener { - googleMap3D.setOnMapReadyListener(null) - runOnUiThread { - drawPathPolylines() - updateCameraPositionForDistance(0.0) - startAnimation() - } + drawPathPolylines() + updateCameraPositionForDistance(0.0) } + + // Schedule auto-fade for idle control panel after initial display + fadeHandler.removeCallbacks(fadeOutRunnable) + fadeHandler.postDelayed(fadeOutRunnable, 3000L) } private fun initViews() { @@ -241,18 +243,18 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { cardHeader?.setOnClickListener { if (isCollapsed) { expandControls() + } else { + collapseControls() } } - fadeHandler.postDelayed(fadeOutRunnable, 3000L) - rgEnvironment = findViewById(R.id.rg_environment) rgAltitudeMode = findViewById(R.id.rg_altitude_mode) + switchDrawsOccludedSegments = findViewById(R.id.switch_draws_occluded_segments) 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) - rangeSlider = findViewById(R.id.range_slider) rangeSliderLabel = findViewById(R.id.range_slider_label) altitudeSlider = findViewById(R.id.altitude_slider) @@ -264,26 +266,18 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { speedSlider = findViewById(R.id.speed_slider) speedSliderLabel = findViewById(R.id.speed_slider_label) - updateControlLabels() - - // Radio group environment selection + // Environment toggle rgEnvironment.setOnCheckedChangeListener { _, checkedId -> when (checkedId) { - R.id.rb_urban -> { - altitudeSlider.valueTo = 200.0f - switchEnvironment(URBAN_PATH) - } - - R.id.rb_rural -> { - altitudeSlider.valueTo = 2000.0f - switchEnvironment(RURAL_PATH) - } + R.id.rb_urban -> switchEnvironment(URBAN_PATH) + R.id.rb_rural -> switchEnvironment(RURAL_PATH) } } - // Radio group altitude mode selection + // Altitude mode toggle rgAltitudeMode.setOnCheckedChangeListener { _, checkedId -> pathAltitudeMode = when (checkedId) { + R.id.rb_clamp_to_ground -> AltitudeMode.CLAMP_TO_GROUND R.id.rb_relative_to_ground -> AltitudeMode.RELATIVE_TO_GROUND R.id.rb_relative_to_mesh -> AltitudeMode.RELATIVE_TO_MESH R.id.rb_absolute -> AltitudeMode.ABSOLUTE @@ -298,7 +292,6 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { } // Draw occluded segments toggle - switchDrawsOccludedSegments = findViewById(R.id.switch_draws_occluded_segments) switchDrawsOccludedSegments.isChecked = drawsOccludedSegments switchDrawsOccludedSegments.setOnCheckedChangeListener { _, isChecked -> drawsOccludedSegments = isChecked @@ -310,7 +303,7 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { updateCameraPositionForDistance(elapsedDistance) } - // Path height slider (relative altitude) + // Path height slider (0.0m - 10.0m) pathAltitudeSlider.addOnChangeListener { _, value, _ -> pathAltitudeOffset = value.toDouble() pathAltitudeSliderLabel.text = @@ -332,14 +325,7 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { } } - // Progress slider scrubbing - progressSlider.addOnChangeListener { _, value, fromUser -> - if (fromUser) { - isUserScrubbing = true - elapsedDistance = totalDistance * value.toDouble() - updateCameraPositionForDistance(elapsedDistance) - } - } + // Progress scrub slider progressSlider.addOnSliderTouchListener(object : Slider.OnSliderTouchListener { override fun onStartTrackingTouch(slider: Slider) { isUserScrubbing = true @@ -347,16 +333,26 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { override fun onStopTrackingTouch(slider: Slider) { isUserScrubbing = false + elapsedDistance = totalDistance * slider.value.toDouble() + updateCameraPositionForDistance(elapsedDistance) } }) - // Sliders listeners + progressSlider.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + elapsedDistance = totalDistance * value.toDouble() + updateCameraPositionForDistance(elapsedDistance) + } + } + + // Camera Range Slider (50m - 1000m) rangeSlider.addOnChangeListener { _, value, _ -> cameraRange = value.toDouble() rangeSliderLabel.text = getString(R.string.camera_range_format, cameraRange.toInt()) updateCameraPositionForDistance(elapsedDistance) } + // Ground Altitude Slider (2m - 200m) altitudeSlider.addOnChangeListener { _, value, _ -> groundAltitude = value.toDouble() altitudeSliderLabel.text = @@ -364,39 +360,27 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { updateCameraPositionForDistance(elapsedDistance) } + // Heading Offset Slider (-180° - +180°) headingSlider.addOnChangeListener { _, value, _ -> headingOffset = value.toDouble() - headingSliderLabel.text = - getString(R.string.heading_offset_format, headingOffset.toInt()) + headingSliderLabel.text = getString(R.string.heading_offset_format, headingOffset.toInt()) updateCameraPositionForDistance(elapsedDistance) } + // Camera Tilt Slider (0° - 85°) tiltSlider.addOnChangeListener { _, value, _ -> cameraTilt = value.toDouble() tiltSliderLabel.text = getString(R.string.camera_tilt_format, cameraTilt.toInt()) updateCameraPositionForDistance(elapsedDistance) } + // Follow Speed Slider (5 m/s - 100 m/s) speedSlider.addOnChangeListener { _, value, _ -> followSpeedMps = value.toDouble() - speedSliderLabel.text = - getString(R.string.follow_speed_format, followSpeedMps.toInt()) + speedSliderLabel.text = getString(R.string.follow_speed_format, followSpeedMps.toInt()) } } - private fun updateControlLabels() { - pathAltitudeSliderLabel.text = - getString(R.string.path_height_format, pathAltitudeOffset) - rangeSliderLabel.text = getString(R.string.camera_range_format, cameraRange.toInt()) - altitudeSliderLabel.text = - getString(R.string.ground_altitude_format, groundAltitude.toInt()) - headingSliderLabel.text = - getString(R.string.heading_offset_format, headingOffset.toInt()) - tiltSliderLabel.text = getString(R.string.camera_tilt_format, cameraTilt.toInt()) - speedSliderLabel.text = - getString(R.string.follow_speed_format, followSpeedMps.toInt()) - } - private var currentHeading: Double? = null private fun switchEnvironment(path: List) { @@ -413,24 +397,24 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { cameraRange = 450.0 groundAltitude = 40.0 cameraTilt = 75.0 - rangeSlider.value = 450f + altitudeSlider.valueTo = 2000f altitudeSlider.value = 40f + rangeSlider.value = 450f tiltSlider.value = 75f - rangeSliderLabel.text = getString(R.string.camera_range_format, 450) - altitudeSliderLabel.text = getString(R.string.ground_altitude_format, 40) - tiltSliderLabel.text = getString(R.string.camera_tilt_format, 75) } else { cameraRange = 300.0 groundAltitude = 20.0 cameraTilt = 70.0 - rangeSlider.value = 300f + altitudeSlider.valueTo = 200f altitudeSlider.value = 20f + rangeSlider.value = 300f tiltSlider.value = 70f - rangeSliderLabel.text = getString(R.string.camera_range_format, 300) - altitudeSliderLabel.text = getString(R.string.ground_altitude_format, 20) - tiltSliderLabel.text = getString(R.string.camera_tilt_format, 70) } + rangeSliderLabel.text = getString(R.string.camera_range_format, cameraRange.toInt()) + altitudeSliderLabel.text = getString(R.string.ground_altitude_format, groundAltitude.toInt()) + tiltSliderLabel.text = getString(R.string.camera_tilt_format, cameraTilt.toInt()) + loadPathData(path) drawPathPolylines() updateCameraPositionForDistance(0.0) @@ -462,14 +446,13 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { val map = googleMap3D ?: return if (currentPath.isEmpty()) return - val pathAltitude = when (pathAltitudeMode) { - AltitudeMode.CLAMP_TO_GROUND -> 0.0 - AltitudeMode.ABSOLUTE -> if (currentPath == RURAL_PATH) 45.0 + pathAltitudeOffset else 50.0 + pathAltitudeOffset - else -> pathAltitudeOffset - } - val staticVertices = currentPath.map { pt -> - LatLngAltitude(pt.latitude, pt.longitude, pathAltitude) + val vertexAltitude = when (pathAltitudeMode) { + AltitudeMode.CLAMP_TO_GROUND -> 0.0 + AltitudeMode.ABSOLUTE -> pt.altitude + (if (currentPath == RURAL_PATH) 45.0 else 50.0) + pathAltitudeOffset + else -> pt.altitude + pathAltitudeOffset + } + LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude) } val staticOptions = PolylineOptions().apply { @@ -489,28 +472,33 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { val map = googleMap3D ?: return if (currentPath.isEmpty() || totalDistance <= 0.0) return - val pathAltitude = when (pathAltitudeMode) { - AltitudeMode.CLAMP_TO_GROUND -> 0.0 - AltitudeMode.ABSOLUTE -> if (currentPath == RURAL_PATH) 45.0 + pathAltitudeOffset else 50.0 + pathAltitudeOffset - else -> pathAltitudeOffset - } - - val progressAltitude = if (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) { - 0.0 - } else { - pathAltitude + 0.4 - } - val progressCoordinates = ArrayList() for (i in 0..index.coerceAtMost(currentPath.size - 1)) { val pt = currentPath[i] - progressCoordinates.add(LatLngAltitude(pt.latitude, pt.longitude, progressAltitude)) + val vertexAltitude = when (pathAltitudeMode) { + AltitudeMode.CLAMP_TO_GROUND -> 0.0 + AltitudeMode.ABSOLUTE -> pt.altitude + (if (currentPath == RURAL_PATH) 45.0 else 50.0) + pathAltitudeOffset + 0.4 + else -> pt.altitude + pathAltitudeOffset + 0.4 + } + progressCoordinates.add(LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude)) } val lastWaypoint = currentPath[index.coerceAtMost(currentPath.size - 1)] val lastLatLng = LatLng(lastWaypoint.latitude, lastWaypoint.longitude) val distToLast = SphericalUtil.computeDistanceBetween(lastLatLng, currentLatLng) if (distToLast >= 0.5) { + val p1 = currentPath[index.coerceAtMost(currentPath.size - 1)] + val p2 = if (index < currentPath.size - 1) currentPath[index + 1] else p1 + val segStartDist = cumulativeDistances.getOrElse(index) { 0.0 } + val segEndDist = cumulativeDistances.getOrElse(index + 1) { totalDistance } + val segLen = segEndDist - segStartDist + val fraction = if (segLen > 0) ((dist - segStartDist) / segLen).coerceIn(0.0, 1.0) else 0.0 + val interpAlt = p1.altitude + fraction * (p2.altitude - p1.altitude) + val progressAltitude = when (pathAltitudeMode) { + AltitudeMode.CLAMP_TO_GROUND -> 0.0 + AltitudeMode.ABSOLUTE -> interpAlt + (if (currentPath == RURAL_PATH) 45.0 else 50.0) + pathAltitudeOffset + 0.4 + else -> interpAlt + pathAltitudeOffset + 0.4 + } progressCoordinates.add( LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, progressAltitude) ) @@ -521,8 +509,13 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { val p0 = LatLng(currentPath[0].latitude, currentPath[0].longitude) val p1 = LatLng(currentPath[1].latitude, currentPath[1].longitude) val tinyForward = SphericalUtil.interpolate(p0, p1, 0.005) + val startAlt = when (pathAltitudeMode) { + AltitudeMode.CLAMP_TO_GROUND -> 0.0 + AltitudeMode.ABSOLUTE -> currentPath[0].altitude + (if (currentPath == RURAL_PATH) 45.0 else 50.0) + pathAltitudeOffset + 0.4 + else -> currentPath[0].altitude + pathAltitudeOffset + 0.4 + } progressCoordinates.add( - LatLngAltitude(tinyForward.latitude, tinyForward.longitude, progressAltitude) + LatLngAltitude(tinyForward.latitude, tinyForward.longitude, startAlt) ) } } @@ -629,9 +622,10 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { } currentHeading = targetHeading + val interpAlt = p1.altitude + fraction * (p2.altitude - p1.altitude) val cameraTargetAltitude = if (pathAltitudeMode == AltitudeMode.ABSOLUTE) { val baseAlt = if (currentPath == RURAL_PATH) 45.0 else 50.0 - baseAlt + groundAltitude + baseAlt + interpAlt + groundAltitude } else { groundAltitude } diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt index 6fd0d7e..e7b5d1d 100644 --- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt @@ -372,9 +372,17 @@ fun PathFollowingScreen() { } // Dynamic Camera Altitude + val p1Cam = currentPath.getOrElse(currentWaypointIndex.coerceAtMost(currentPath.size - 1)) { currentPath.first() } + val p2Cam = if (currentWaypointIndex < currentPath.size - 1) currentPath[currentWaypointIndex + 1] else p1Cam + val segStartDistCam = cumulativeDistances.getOrElse(currentWaypointIndex) { 0.0 } + val segEndDistCam = cumulativeDistances.getOrElse(currentWaypointIndex + 1) { totalDistance } + val segLenCam = segEndDistCam - segStartDistCam + val fractionCam = if (segLenCam > 0) ((elapsedDistance - segStartDistCam) / segLenCam).coerceIn(0.0, 1.0) else 0.0 + val interpAltCam = p1Cam.altitude + fractionCam * (p2Cam.altitude - p1Cam.altitude) + val cameraTargetAltitude = if (selectedAltitudeMode.mode == AltitudeMode.ABSOLUTE) { val baseAlt = if (currentPath == PathFollowingActivity.RURAL_PATH) 45.0 else 50.0 - baseAlt + groundAltitude + baseAlt + interpAltCam + groundAltitude } else { groundAltitude.toDouble() } @@ -404,13 +412,13 @@ fun PathFollowingScreen() { pathAltitudeOffset, drawsOccludedSegments, ) { - val pathAltitude = when (selectedAltitudeMode.mode) { - AltitudeMode.CLAMP_TO_GROUND -> 0.0 - AltitudeMode.ABSOLUTE -> if (currentPath == PathFollowingActivity.RURAL_PATH) 45.0 + pathAltitudeOffset else 50.0 + pathAltitudeOffset - else -> pathAltitudeOffset.toDouble() - } val staticVertices = currentPath.map { pt -> - LatLngAltitude(pt.latitude, pt.longitude, pathAltitude) + val vertexAltitude = when (selectedAltitudeMode.mode) { + AltitudeMode.CLAMP_TO_GROUND -> 0.0 + AltitudeMode.ABSOLUTE -> pt.altitude + (if (currentPath == PathFollowingActivity.RURAL_PATH) 45.0 else 50.0) + pathAltitudeOffset + else -> pt.altitude + pathAltitudeOffset.toDouble() + } + LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude) } PolylineConfig( key = "path_following_static_route", @@ -431,25 +439,32 @@ fun PathFollowingScreen() { pathAltitudeOffset, drawsOccludedSegments, totalDistance, + elapsedDistance, ) { if (currentPath.isEmpty() || totalDistance <= 0.0) return@remember null - val pathAltitude = when (selectedAltitudeMode.mode) { - AltitudeMode.CLAMP_TO_GROUND -> 0.0 - AltitudeMode.ABSOLUTE -> if (currentPath == PathFollowingActivity.RURAL_PATH) 45.0 + pathAltitudeOffset else 50.0 + pathAltitudeOffset - else -> pathAltitudeOffset.toDouble() - } - - val progressAltitude = if (selectedAltitudeMode.mode == AltitudeMode.CLAMP_TO_GROUND) { - 0.0 - } else { - pathAltitude + 0.4 - } - val progressCoordinates = ArrayList() for (i in 0..currentWaypointIndex.coerceAtMost(currentPath.size - 1)) { val pt = currentPath[i] - progressCoordinates.add(LatLngAltitude(pt.latitude, pt.longitude, progressAltitude)) + val vertexAltitude = when (selectedAltitudeMode.mode) { + AltitudeMode.CLAMP_TO_GROUND -> 0.0 + AltitudeMode.ABSOLUTE -> pt.altitude + (if (currentPath == PathFollowingActivity.RURAL_PATH) 45.0 else 50.0) + pathAltitudeOffset + 0.4 + else -> pt.altitude + pathAltitudeOffset.toDouble() + 0.4 + } + progressCoordinates.add(LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude)) + } + + val p1 = currentPath[currentWaypointIndex.coerceAtMost(currentPath.size - 1)] + val p2 = if (currentWaypointIndex < currentPath.size - 1) currentPath[currentWaypointIndex + 1] else p1 + val segStartDist = cumulativeDistances.getOrElse(currentWaypointIndex) { 0.0 } + val segEndDist = cumulativeDistances.getOrElse(currentWaypointIndex + 1) { totalDistance } + val segLen = segEndDist - segStartDist + val fraction = if (segLen > 0) ((elapsedDistance - segStartDist) / segLen).coerceIn(0.0, 1.0) else 0.0 + val interpAlt = p1.altitude + fraction * (p2.altitude - p1.altitude) + val progressAltitude = when (selectedAltitudeMode.mode) { + AltitudeMode.CLAMP_TO_GROUND -> 0.0 + AltitudeMode.ABSOLUTE -> interpAlt + (if (currentPath == PathFollowingActivity.RURAL_PATH) 45.0 else 50.0) + pathAltitudeOffset + 0.4 + else -> interpAlt + pathAltitudeOffset.toDouble() + 0.4 } val lastWaypoint = currentPath[currentWaypointIndex.coerceAtMost(currentPath.size - 1)] @@ -463,10 +478,15 @@ fun PathFollowingScreen() { if (progressCoordinates.size < 2 && currentPath.size >= 2) { val p0 = LatLng(currentPath[0].latitude, currentPath[0].longitude) - val p1 = LatLng(currentPath[1].latitude, currentPath[1].longitude) - val tinyForward = SphericalUtil.interpolate(p0, p1, 0.005) + val p1Lat = LatLng(currentPath[1].latitude, currentPath[1].longitude) + val tinyForward = SphericalUtil.interpolate(p0, p1Lat, 0.005) + val startAlt = when (selectedAltitudeMode.mode) { + AltitudeMode.CLAMP_TO_GROUND -> 0.0 + AltitudeMode.ABSOLUTE -> currentPath[0].altitude + (if (currentPath == PathFollowingActivity.RURAL_PATH) 45.0 else 50.0) + pathAltitudeOffset + 0.4 + else -> currentPath[0].altitude + pathAltitudeOffset.toDouble() + 0.4 + } progressCoordinates.add( - LatLngAltitude(tinyForward.latitude, tinyForward.longitude, progressAltitude), + LatLngAltitude(tinyForward.latitude, tinyForward.longitude, startAlt), ) } From e0ae1d3a2256b0157da004b531f23cd2fec2d7dc Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:53:22 -0600 Subject: [PATCH 2/6] refactor(maps3d): modularize path following & camera animation and extract shared datasets - Extract shared route and flight coordinate datasets to PathData and TourData in common module - Create PathEngine to encapsulate polyline generation, distance accumulation, kinematic heading smoothing, and altitude interpolation - Modularize PathFollowingActivity and AdvancedCameraAnimationActivity across Kotlin Views, Java Views, and Jetpack Compose - Connect ComposeDemos to common module with core library desugaring - Apply Spotless formatting across all modified files --- .../com/example/maps3d/common/PathData.kt | 93 ++ .../com/example/maps3d/common/PathEngine.kt | 233 ++++ .../com/example/maps3d/common/TourData.kt | 128 +++ .../AdvancedCameraAnimationActivity.java | 725 +++++-------- .../pathfollowing/PathFollowingActivity.java | 553 +++------- .../AdvancedCameraAnimationActivity.kt | 235 ++-- .../pathfollowing/PathFollowingActivity.kt | 536 +++------- .../ComposeDemos/app/build.gradle.kts | 4 + .../AdvancedCameraAnimationActivity.kt | 234 ++-- .../pathfollowing/PathFollowingActivity.kt | 999 +++++++----------- 10 files changed, 1637 insertions(+), 2103 deletions(-) create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathData.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/TourData.kt diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathData.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathData.kt new file mode 100644 index 0000000..a13fef6 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathData.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import com.google.android.gms.maps3d.model.LatLngAltitude + +/** + * Shared pre-baked route datasets for ground-level path following samples. + * + * Lifting coordinates into this shared repository decouples the raw geographic + * geometry from sample application code across Java Views, Kotlin Views, and Compose. + */ +object PathData { + + /** + * Urban Route: Downtown San Francisco (Market Street corridor). + * + * Features realistic per-waypoint elevation variations (1m to 10m) to demonstrate + * 3D altitude modes (Absolute, Relative to Ground, Relative to Mesh, Clamp to Ground). + */ + @JvmField + val URBAN_PATH: List = listOf( + LatLngAltitude(37.79323, -122.39322, 4.2), + LatLngAltitude(37.79166, -122.39519, 6.7), + LatLngAltitude(37.79124, -122.39571, 8.1), + LatLngAltitude(37.79105, -122.39599, 9.5), + LatLngAltitude(37.78893, -122.39866, 7.3), + LatLngAltitude(37.78742, -122.40060, 5.0), + LatLngAltitude(37.78686, -122.40129, 3.4), + LatLngAltitude(37.78652, -122.40171, 2.1), + LatLngAltitude(37.78632, -122.40196, 4.6), + LatLngAltitude(37.78627, -122.40207, 6.2), + LatLngAltitude(37.78453, -122.40429, 8.9), + LatLngAltitude(37.78443, -122.40434, 10.0), + LatLngAltitude(37.78155, -122.40802, 7.8), + LatLngAltitude(37.78005, -122.40990, 5.4), + LatLngAltitude(37.77856, -122.41180, 3.1), + LatLngAltitude(37.77746, -122.41318, 1.8), + LatLngAltitude(37.77624, -122.41474, 4.0), + LatLngAltitude(37.77744, -122.41623, 6.5), + LatLngAltitude(37.77749, -122.41636, 8.7), + LatLngAltitude(37.77761, -122.41654, 9.8), + LatLngAltitude(37.77769, -122.41677, 7.2), + LatLngAltitude(37.77729, -122.41981, 4.9), + LatLngAltitude(37.77523, -122.41938, 2.6), + LatLngAltitude(37.77510, -122.41934, 1.2), + LatLngAltitude(37.77442, -122.42022, 3.5), + LatLngAltitude(37.77441, -122.42033, 5.8), + LatLngAltitude(37.77348, -122.42157, 8.4), + LatLngAltitude(37.77244, -122.42289, 10.0) + ) + + /** + * Rural Route: Coastal highway and mountain switchbacks near Pescadero, CA. + */ + @JvmField + val RURAL_PATH: List = listOf( + LatLngAltitude(37.254529, -122.380897, 0.0), + LatLngAltitude(37.255065, -122.381627, 0.0), + LatLngAltitude(37.257540, -122.383720, 0.0), + LatLngAltitude(37.261200, -122.383950, 0.0), + LatLngAltitude(37.264780, -122.388210, 0.0), + LatLngAltitude(37.268520, -122.392450, 0.0), + LatLngAltitude(37.272110, -122.397640, 0.0), + LatLngAltitude(37.276430, -122.401120, 0.0), + LatLngAltitude(37.280850, -122.403560, 0.0), + LatLngAltitude(37.286018, -122.405072, 0.0), + LatLngAltitude(37.291040, -122.404210, 0.0), + LatLngAltitude(37.295800, -122.401980, 0.0), + LatLngAltitude(37.300120, -122.399540, 0.0), + LatLngAltitude(37.304550, -122.397210, 0.0), + LatLngAltitude(37.309200, -122.395100, 0.0), + LatLngAltitude(37.313450, -122.392840, 0.0), + LatLngAltitude(37.317200, -122.390510, 0.0), + LatLngAltitude(37.320850, -122.388740, 0.0), + LatLngAltitude(37.323540, -122.387600, 0.0), + LatLngAltitude(37.325269, -122.386728, 0.0) + ) +} diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt new file mode 100644 index 0000000..62c86ee --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt @@ -0,0 +1,233 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.LatLngAltitude +import com.google.maps.android.SphericalUtil +import java.util.ArrayList + +/** + * Result of interpolating a position and orientation along a 3D path at a specific distance. + * + * @property latLng The interpolated 2D geographic coordinate. + * @property waypointIndex The zero-based index of the segment start waypoint. + * @property bearing The raw compass bearing (degrees) of the current route segment. + * @property altitude The interpolated elevation in meters along the route segment. + */ +data class InterpolatedPathPoint( + @JvmField val latLng: LatLng, + @JvmField val waypointIndex: Int, + @JvmField val bearing: Double, + @JvmField val altitude: Double +) + +/** + * Math and geometry engine for ground-level 3D path following. + * + * Encapsulates segment search, distance accumulation, kinematic heading smoothing, + * and elevation interpolation across Kotlin Views, Java Views, and Compose. + */ +object PathEngine { + + const val STATIC_POLYLINE_ID = "path_following_static_route" + const val PROGRESS_POLYLINE_ID = "path_following_progress_route" + + /** + * Precomputes cumulative distances along a 3D path in meters. + */ + @JvmStatic + fun calculateCumulativeDistances(path: List): DoubleArray { + if (path.isEmpty()) return doubleArrayOf(0.0) + val cumulativeDistances = DoubleArray(path.size) + cumulativeDistances[0] = 0.0 + for (i in 1 until path.size) { + val pPrev = LatLng(path[i - 1].latitude, path[i - 1].longitude) + val pCurr = LatLng(path[i].latitude, path[i].longitude) + cumulativeDistances[i] = cumulativeDistances[i - 1] + SphericalUtil.computeDistanceBetween(pPrev, pCurr) + } + return cumulativeDistances + } + + /** + * Finds the interpolated geographic position, segment bearing, and elevation at a target distance. + */ + @JvmStatic + fun interpolatePoint( + path: List, + cumulativeDistances: DoubleArray, + distance: Double + ): InterpolatedPathPoint { + if (path.isEmpty()) { + return InterpolatedPathPoint(LatLng(0.0, 0.0), 0, 0.0, 0.0) + } + + val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0 + var index = 0 + while (index < cumulativeDistances.size - 1 && cumulativeDistances[index + 1] < distance) { + index++ + } + + val p1 = path[index] + val p2 = if (index < path.size - 1) path[index + 1] else p1 + + val segStartDist = cumulativeDistances.getOrElse(index) { 0.0 } + val segEndDist = cumulativeDistances.getOrElse(index + 1) { totalDistance } + val segLen = segEndDist - segStartDist + + val fraction = if (segLen > 0) ((distance - segStartDist) / segLen).coerceIn(0.0, 1.0) else 0.0 + val latLng1 = LatLng(p1.latitude, p1.longitude) + val latLng2 = LatLng(p2.latitude, p2.longitude) + val currentLatLng = SphericalUtil.interpolate(latLng1, latLng2, fraction) + val bearing = SphericalUtil.computeHeading(latLng1, latLng2) + val interpAlt = p1.altitude + fraction * (p2.altitude - p1.altitude) + + return InterpolatedPathPoint(currentLatLng, index, bearing, interpAlt) + } + + /** + * Applies an Exponential Moving Average (EMA) filter to camera heading to smooth + * abrupt turns around corners during real-time playback. + */ + @JvmStatic + fun smoothHeading( + targetHeading: Double, + currentHeading: Double?, + isUserScrubbing: Boolean, + isPlaying: Boolean, + smoothingFactor: Double = 0.12 + ): Double { + val normalizedTarget = (targetHeading % 360.0 + 360.0) % 360.0 + if (currentHeading == null || isUserScrubbing || !isPlaying) { + return normalizedTarget + } + + var diff = (normalizedTarget - currentHeading) % 360.0 + if (diff > 180.0) diff -= 360.0 + if (diff < -180.0) diff += 360.0 + return (currentHeading + diff * smoothingFactor + 360.0) % 360.0 + } + + /** + * Calculates camera target altitude based on the active altitude mode and route elevation. + */ + @JvmStatic + fun calculateCameraAltitude( + altitudeMode: Int, + baseAltitude: Double, + interpolatedAltitude: Double, + groundAltitude: Double + ): Double { + return if (altitudeMode == AltitudeMode.ABSOLUTE) { + baseAltitude + interpolatedAltitude + groundAltitude + } else { + groundAltitude + } + } + + /** + * Builds static route polyline vertices with altitude mode adjustments. + */ + @JvmStatic + fun buildStaticVertices( + path: List, + altitudeMode: Int, + baseAltitude: Double, + pathAltitudeOffset: Double + ): List { + return path.map { pt -> + val vertexAltitude = when (altitudeMode) { + AltitudeMode.CLAMP_TO_GROUND -> 0.0 + AltitudeMode.ABSOLUTE -> pt.altitude + baseAltitude + pathAltitudeOffset + else -> pt.altitude + pathAltitudeOffset + } + LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude) + } + } + + /** + * Builds progress polyline vertices up to the current progress distance with +0.4m depth bias. + */ + @JvmStatic + fun buildProgressVertices( + path: List, + cumulativeDistances: DoubleArray, + elapsedDistance: Double, + currentLatLng: LatLng, + waypointIndex: Int, + altitudeMode: Int, + baseAltitude: Double, + pathAltitudeOffset: Double + ): List { + if (path.isEmpty()) return emptyList() + + val progressCoordinates = ArrayList() + val clampedIndex = waypointIndex.coerceIn(0, path.size - 1) + + for (i in 0..clampedIndex) { + val pt = path[i] + val vertexAltitude = when (altitudeMode) { + AltitudeMode.CLAMP_TO_GROUND -> 0.0 + AltitudeMode.ABSOLUTE -> pt.altitude + baseAltitude + pathAltitudeOffset + 0.4 + else -> pt.altitude + pathAltitudeOffset + 0.4 + } + progressCoordinates.add(LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude)) + } + + val lastWaypoint = path[clampedIndex] + val lastLatLng = LatLng(lastWaypoint.latitude, lastWaypoint.longitude) + val distToLast = SphericalUtil.computeDistanceBetween(lastLatLng, currentLatLng) + + if (distToLast >= 0.5) { + val p1 = path[clampedIndex] + val p2 = if (clampedIndex < path.size - 1) path[clampedIndex + 1] else p1 + val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0 + val segStartDist = cumulativeDistances.getOrElse(clampedIndex) { 0.0 } + val segEndDist = cumulativeDistances.getOrElse(clampedIndex + 1) { totalDistance } + val segLen = segEndDist - segStartDist + val fraction = if (segLen > 0) ((elapsedDistance - segStartDist) / segLen).coerceIn(0.0, 1.0) else 0.0 + val interpAlt = p1.altitude + fraction * (p2.altitude - p1.altitude) + + val progressAltitude = when (altitudeMode) { + AltitudeMode.CLAMP_TO_GROUND -> 0.0 + AltitudeMode.ABSOLUTE -> interpAlt + baseAltitude + pathAltitudeOffset + 0.4 + else -> interpAlt + pathAltitudeOffset + 0.4 + } + progressCoordinates.add( + LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, progressAltitude) + ) + } + + // Polyline requires at least 2 distinct vertices + if (progressCoordinates.size < 2 && path.size >= 2) { + val p0 = LatLng(path[0].latitude, path[0].longitude) + val p1 = LatLng(path[1].latitude, path[1].longitude) + val tinyForward = SphericalUtil.interpolate(p0, p1, 0.005) + val startAlt = when (altitudeMode) { + AltitudeMode.CLAMP_TO_GROUND -> 0.0 + AltitudeMode.ABSOLUTE -> path[0].altitude + baseAltitude + pathAltitudeOffset + 0.4 + else -> path[0].altitude + pathAltitudeOffset + 0.4 + } + progressCoordinates.add( + LatLngAltitude(tinyForward.latitude, tinyForward.longitude, startAlt) + ) + } + + return progressCoordinates + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/TourData.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/TourData.kt new file mode 100644 index 0000000..30994d2 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/TourData.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import com.google.android.gms.maps.model.LatLng + +/** + * Keyframe representation for scripted camera tour stages. + */ +sealed class CameraKeyframe(val stepTitle: String, val stepDescription: String) { + + class FlyTo( + stepTitle: String, + stepDescription: String, + val targetCenter: LatLng, + val targetAltitude: Double, + val targetHeading: Double, + val targetTilt: Double, + val targetRange: Double, + val durationMs: Long + ) : CameraKeyframe(stepTitle, stepDescription) + + class DwellPause( + stepTitle: String, + stepDescription: String, + val durationMs: Long + ) : CameraKeyframe(stepTitle, stepDescription) + + class OrbitAround( + stepTitle: String, + stepDescription: String, + val center: LatLng, + val altitude: Double, + val range: Double, + val tilt: Double, + val startHeading: Double, + val endHeading: Double, + val durationMs: Long + ) : CameraKeyframe(stepTitle, stepDescription) +} + +/** + * Shared pre-baked flight route and tour keyframes for Advanced Camera Animation samples. + */ +object TourData { + + const val MODEL_ID = "airplane_model" + const val PLANE_URL = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/Airplane.glb" + + /** + * 4-Stage cinematic camera tour across San Francisco landmarks. + */ + @JvmField + val SAN_FRANCISCO_TOUR: List = listOf( + CameraKeyframe.FlyTo( + stepTitle = "1. Golden Gate Bridge Flight", + stepDescription = "3D Airplane flight over Golden Gate Bridge", + targetCenter = LatLng(37.8199, -122.4783), + targetAltitude = 200.0, + targetHeading = 105.0, + targetTilt = 65.0, + targetRange = 600.0, + durationMs = 2500L + ), + CameraKeyframe.DwellPause( + stepTitle = "2. Mid-Air Observation", + stepDescription = "Dwell pause observing 3D airplane over Golden Gate", + durationMs = 1500L + ), + CameraKeyframe.OrbitAround( + stepTitle = "3. Golden Gate 360° Orbit", + stepDescription = "360° orbital camera spin around flying airplane", + center = LatLng(37.8199, -122.4783), + altitude = 200.0, + range = 600.0, + tilt = 65.0, + startHeading = 105.0, + endHeading = 465.0, + durationMs = 4000L + ), + CameraKeyframe.FlyTo( + stepTitle = "4. Transit to Coit Tower", + stepDescription = "Airplane flight to Coit Tower Landmark", + targetCenter = LatLng(37.8024, -122.4058), + targetAltitude = 200.0, + targetHeading = 105.0, + targetTilt = 65.0, + targetRange = 600.0, + durationMs = 3000L + ) + ) + + /** + * 15 Fine-grained waypoints along the scenic flight corridor from Golden Gate Bridge to Coit Tower. + */ + @JvmField + val AIRPLANE_FLIGHT_PATH: List = listOf( + LatLng(37.8199, -122.4783), // 1. Golden Gate Bridge (Source) + LatLng(37.8188, -122.4735), // 2. Fort Point / Presidio Overlook + LatLng(37.8175, -122.4685), // 3. Crissy Field West + LatLng(37.8160, -122.4635), // 4. Crissy Field East + LatLng(37.8145, -122.4585), // 5. Marina Green West + LatLng(37.8130, -122.4530), // 6. Marina District Center + LatLng(37.8115, -122.4475), // 7. Fort Mason West + LatLng(37.8095, -122.4415), // 8. Fort Mason Great Meadow + LatLng(37.8075, -122.4350), // 9. Aquatic Park / Ghirardelli Square + LatLng(37.8060, -122.4285), // 10. Fisherman's Wharf West + LatLng(37.8050, -122.4220), // 11. Pier 39 / North Waterfront + LatLng(37.8040, -122.4160), // 12. Washington Square Park / North Beach + LatLng(37.8032, -122.4105), // 13. Telegraph Hill West Approach + LatLng(37.8026, -122.4075), // 14. Telegraph Hill Boulevard + LatLng(37.8024, -122.4058) // 15. Coit Tower Landmark (Destination) + ) +} 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 cf2df35..cf67638 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 @@ -28,6 +28,7 @@ import androidx.annotation.Nullable; import com.example.maps3d.common.PositionAndHeading; import com.example.maps3d.common.RouteEngine; +import com.example.maps3d.common.TourData; import com.example.maps3dcommon.R; import com.example.maps3djava.sampleactivity.SampleBaseActivity; import com.google.android.gms.maps.model.LatLng; @@ -52,51 +53,30 @@ enum AnimationApproach { ORBIT_360_SPIN } -interface CameraKeyframe { - +interface JavaCameraKeyframe { String getStepTitle(); - String getStepDescription(); - class FlyTo implements CameraKeyframe { - + class FlyTo implements JavaCameraKeyframe { private final String stepTitle; private final String stepDescription; private final Camera targetCamera; private final long durationMs; - public FlyTo( - String stepTitle, - String stepDescription, - Camera targetCamera, - long durationMs) { + public FlyTo(String stepTitle, String stepDescription, Camera targetCamera, long durationMs) { this.stepTitle = stepTitle; this.stepDescription = stepDescription; this.targetCamera = targetCamera; this.durationMs = durationMs; } - @Override - public String getStepTitle() { - return stepTitle; - } - - @Override - public String getStepDescription() { - return stepDescription; - } - - public Camera getTargetCamera() { - return targetCamera; - } - - public long getDurationMs() { - return durationMs; - } + @Override public String getStepTitle() { return stepTitle; } + @Override public String getStepDescription() { return stepDescription; } + public Camera getTargetCamera() { return targetCamera; } + public long getDurationMs() { return durationMs; } } - class DwellPause implements CameraKeyframe { - + class DwellPause implements JavaCameraKeyframe { private final String stepTitle; private final String stepDescription; private final long durationMs; @@ -107,23 +87,12 @@ public DwellPause(String stepTitle, String stepDescription, long durationMs) { this.durationMs = durationMs; } - @Override - public String getStepTitle() { - return stepTitle; - } - - @Override - public String getStepDescription() { - return stepDescription; - } - - public long getDurationMs() { - return durationMs; - } + @Override public String getStepTitle() { return stepTitle; } + @Override public String getStepDescription() { return stepDescription; } + public long getDurationMs() { return durationMs; } } - class OrbitAround implements CameraKeyframe { - + class OrbitAround implements JavaCameraKeyframe { private final String stepTitle; private final String stepDescription; private final LatLng center; @@ -135,15 +104,15 @@ class OrbitAround implements CameraKeyframe { private final long durationMs; public OrbitAround( - String stepTitle, - String stepDescription, - LatLng center, - double altitude, - double range, - double tilt, - double startHeading, - double endHeading, - long durationMs) { + String stepTitle, + String stepDescription, + LatLng center, + double altitude, + double range, + double tilt, + double startHeading, + double endHeading, + long durationMs) { this.stepTitle = stepTitle; this.stepDescription = stepDescription; this.center = center; @@ -155,46 +124,21 @@ public OrbitAround( this.durationMs = durationMs; } - @Override - public String getStepTitle() { - return stepTitle; - } - - @Override - public String getStepDescription() { - return stepDescription; - } - - public LatLng getCenter() { - return center; - } - - public double getAltitude() { - return altitude; - } - - public double getRange() { - return range; - } - - public double getTilt() { - return tilt; - } - - public double getStartHeading() { - return startHeading; - } - - public double getEndHeading() { - return endHeading; - } - - public long getDurationMs() { - return durationMs; - } + @Override public String getStepTitle() { return stepTitle; } + @Override public String getStepDescription() { return stepDescription; } + public LatLng getCenter() { return center; } + public double getAltitude() { return altitude; } + public double getRange() { return range; } + public double getTilt() { return tilt; } + public double getStartHeading() { return startHeading; } + public double getEndHeading() { return endHeading; } + public long getDurationMs() { return durationMs; } } } +/** + * Advanced Camera Animation demonstrating cinematic 3D camera controls and 3D airplane model tracking in Java. + */ public class AdvancedCameraAnimationActivity extends SampleBaseActivity { @NonNull @@ -206,15 +150,14 @@ public String getTAG() { @NonNull @Override public Camera getInitialCamera() { - LatLng firstLoc = AIRPLANE_FLIGHT_PATH.get(0); - double initialHeading = - SphericalUtil.computeHeading(firstLoc, AIRPLANE_FLIGHT_PATH.get(1)); + LatLng firstLoc = TourData.AIRPLANE_FLIGHT_PATH.get(0); + double initialHeading = SphericalUtil.computeHeading(firstLoc, TourData.AIRPLANE_FLIGHT_PATH.get(1)); return new Camera( - new LatLngAltitude(firstLoc.latitude, firstLoc.longitude, 200.0), - normalizeHeading(initialHeading), - /* tilt= */ 65.0, - /* roll= */ 0.0, - /* range= */ 600.0); + new LatLngAltitude(firstLoc.latitude, firstLoc.longitude, 200.0), + normalizeHeading(initialHeading), + /* tilt= */ 65.0, + /* roll= */ 0.0, + /* range= */ 600.0); } private Model airplaneModel; @@ -222,30 +165,29 @@ public Camera getInitialCamera() { private boolean isPlaying = false; private AnimationApproach selectedApproach = AnimationApproach.DISPATCHER_FRAME_LOOP; + private Button btnPlayPause; + private TextView tvTourStatus; + private final Handler handler = new Handler(Looper.getMainLooper()); private Choreographer.FrameCallback frameDispatcherCallback; private Choreographer.FrameCallback orbitCallback; private Choreographer.FrameCallback keyframeOrbitCallback; - private final Handler handler = new Handler(Looper.getMainLooper()); - private final double[] cumulativeDistances = - RouteEngine.calculateCumulativeDistances(AIRPLANE_FLIGHT_PATH); - - private Button btnPlayPause; - private TextView tvTourStatus; + private double[] cumulativeDistances; @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); + cumulativeDistances = RouteEngine.calculateCumulativeDistances(TourData.AIRPLANE_FLIGHT_PATH); + + ViewGroup mapContainer = findViewById(R.id.map_container); + if (mapContainer != null) { + getLayoutInflater().inflate(R.layout.control_panel_advanced_animation, mapContainer, true); } MaterialToolbar topBar = findViewById(R.id.top_bar); if (topBar != null) { - topBar.setTitle(R.string.feature_title_advanced_camera_animation); + topBar.setTitle("Advanced Camera Animation"); topBar.setNavigationOnClickListener(v -> finish()); } @@ -254,18 +196,18 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { RadioGroup rgApproach = findViewById(R.id.rg_animation_approach); if (rgApproach != null) { rgApproach.setOnCheckedChangeListener( - (group, checkedId) -> { - if (checkedId == R.id.rb_simple_fly_to) { - selectedApproach = AnimationApproach.SIMPLE_FLY_TO; - } else if (checkedId == R.id.rb_keyframe_tour) { - selectedApproach = AnimationApproach.KEYFRAME_TOUR; - } else if (checkedId == R.id.rb_dispatcher_frame_loop) { - selectedApproach = AnimationApproach.DISPATCHER_FRAME_LOOP; - } else { - selectedApproach = AnimationApproach.ORBIT_360_SPIN; - } - resetAndRestartTour(); - }); + (group, checkedId) -> { + if (checkedId == R.id.rb_simple_fly_to) { + selectedApproach = AnimationApproach.SIMPLE_FLY_TO; + } else if (checkedId == R.id.rb_keyframe_tour) { + selectedApproach = AnimationApproach.KEYFRAME_TOUR; + } else if (checkedId == R.id.rb_dispatcher_frame_loop) { + selectedApproach = AnimationApproach.DISPATCHER_FRAME_LOOP; + } else if (checkedId == R.id.rb_orbit_360_spin) { + selectedApproach = AnimationApproach.ORBIT_360_SPIN; + } + resetAndRestartTour(); + }); } Button btnReset = findViewById(R.id.btn_reset); @@ -276,60 +218,45 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { btnPlayPause = findViewById(R.id.btn_play_pause); if (btnPlayPause != null) { btnPlayPause.setOnClickListener( - v -> { - if (isPlaying) { - stopTour(); - } else { - startSelectedApproach(); - } - }); + v -> { + if (isPlaying) { + stopTour(); + } else { + startSelectedApproach(); + } + }); } } private void updatePlayPauseButtonState() { - runOnUiThread(() -> { - if (btnPlayPause != null) { - btnPlayPause.setText(isPlaying ? R.string.pause : R.string.play); - } - }); + if (btnPlayPause != null) { + btnPlayPause.setText(isPlaying ? "Pause" : "Play"); + } } @Override public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) { super.onMap3DViewReady(googleMap3D); - // Instantiate 3D Airplane Model on map at initial start position - LatLng startLoc = AIRPLANE_FLIGHT_PATH.get(0); - double initialHeading = - SphericalUtil.computeHeading(startLoc, AIRPLANE_FLIGHT_PATH.get(1)); + LatLng startLoc = TourData.AIRPLANE_FLIGHT_PATH.get(0); + double initialHeading = SphericalUtil.computeHeading(startLoc, TourData.AIRPLANE_FLIGHT_PATH.get(1)); updateAirplaneModel(startLoc, initialHeading + 180.0); - // Auto-start smooth flight animation after 1 second delay handler.postDelayed(this::startSelectedApproach, 1000L); } - /** - * Updates the 3D Airplane Model's position and orientation on the map. Note: Calling - * `map.addModel(opts)` continuously with the same `id` string is the recommended approach for - * dynamically updating a model's location. - * Remote URLs: Models should be hosted and loaded via external URL. - */ private void updateAirplaneModel(LatLng targetLatLng, double planeHeadingDeg) { - if (googleMap3D != null) { - ModelOptions opts = new ModelOptions(); - opts.setId(MODEL_ID); - opts.setPosition( - new LatLngAltitude(targetLatLng.latitude, targetLatLng.longitude, 200.0)); - opts.setAltitudeMode(AltitudeMode.ABSOLUTE); - opts.setOrientation( - new Orientation( - /* heading= */ normalizeHeading(planeHeadingDeg), - /* tilt= */ -90.0, - /* roll= */ 0.0)); - opts.setUrl(PLANE_URL); - opts.setScale(new Vector3D(0.08, 0.08, 0.08)); - airplaneModel = googleMap3D.addModel(opts); - } + if (googleMap3D == null) return; + + ModelOptions opts = new ModelOptions(); + opts.setId(TourData.MODEL_ID); + opts.setPosition(new LatLngAltitude(targetLatLng.latitude, targetLatLng.longitude, 200.0)); + opts.setAltitudeMode(AltitudeMode.ABSOLUTE); + opts.setOrientation(new Orientation(normalizeHeading(planeHeadingDeg), -90.0, 0.0)); + opts.setUrl(TourData.PLANE_URL); + opts.setScale(new Vector3D(0.08, 0.08, 0.08)); + + airplaneModel = googleMap3D.addModel(opts); } private void startSelectedApproach() { @@ -345,13 +272,14 @@ private void startSelectedApproach() { runFrameDispatcherLoop(); break; case ORBIT_360_SPIN: - default: run360OrbitSpin(); break; } } private void runSimpleFlyTo() { + if (googleMap3D == null) return; + stopTour(); isPlaying = true; updatePlayPauseButtonState(); @@ -359,38 +287,33 @@ private void runSimpleFlyTo() { tvTourStatus.setText(R.string.approach_simple_fly_to); } - LatLng targetLoc = AIRPLANE_FLIGHT_PATH.get(AIRPLANE_FLIGHT_PATH.size() - 1); - LatLng prevLoc = AIRPLANE_FLIGHT_PATH.get(AIRPLANE_FLIGHT_PATH.size() - 2); - double flightHeading = SphericalUtil.computeHeading(prevLoc, targetLoc); + LatLng targetLoc = TourData.AIRPLANE_FLIGHT_PATH.get(TourData.AIRPLANE_FLIGHT_PATH.size() - 1); + double flightHeading = + SphericalUtil.computeHeading( + TourData.AIRPLANE_FLIGHT_PATH.get(TourData.AIRPLANE_FLIGHT_PATH.size() - 2), targetLoc); updateAirplaneModel(targetLoc, flightHeading + 180.0); Camera targetCam = - new Camera( - new LatLngAltitude(targetLoc.latitude, targetLoc.longitude, 200.0), - normalizeHeading(flightHeading), - /* tilt= */ 65.0, - /* roll= */ 0.0, - /* range= */ 600.0); + new Camera( + new LatLngAltitude(targetLoc.latitude, targetLoc.longitude, 200.0), + normalizeHeading(flightHeading), + 65.0, + 0.0, + 600.0); - if (googleMap3D != null) { - googleMap3D.setCameraAnimationEndListener( + googleMap3D.setCameraAnimationEndListener( () -> { - if (googleMap3D != null) { - googleMap3D.setCameraAnimationEndListener(null); - } + googleMap3D.setCameraAnimationEndListener(null); isPlaying = false; updatePlayPauseButtonState(); if (tvTourStatus != null) { tvTourStatus.setText(R.string.aerial_tour_status_finished); } }); - googleMap3D.flyCameraTo(new FlyToOptions(targetCam, 1500L)); - } + + googleMap3D.flyCameraTo(new FlyToOptions(targetCam, 1500L)); } - /** - * Executes multi-step keyframe queue tour smoothly stage by stage. - */ private void startOrResumeTour() { stopTour(); isPlaying = true; @@ -403,113 +326,86 @@ private void executeKeyframeStep(int stepIndex) { if (!isPlaying || googleMap3D == null || stepIndex >= SAN_FRANCISCO_TOUR.size()) { isPlaying = false; updatePlayPauseButtonState(); - if (tvTourStatus != null && stepIndex >= SAN_FRANCISCO_TOUR.size()) { + if (tvTourStatus != null) { tvTourStatus.setText(R.string.aerial_tour_status_finished); } return; } - CameraKeyframe step = SAN_FRANCISCO_TOUR.get(stepIndex); + JavaCameraKeyframe step = SAN_FRANCISCO_TOUR.get(stepIndex); if (tvTourStatus != null) { - runOnUiThread(() -> tvTourStatus.setText( - getString( - R.string.aerial_tour_status_running, - stepIndex + 1, - SAN_FRANCISCO_TOUR.size(), - step.getStepTitle())) - ); + tvTourStatus.setText( + getString(R.string.aerial_tour_status_running, stepIndex + 1, SAN_FRANCISCO_TOUR.size(), step.getStepTitle())); } - if (step instanceof CameraKeyframe.FlyTo) { - CameraKeyframe.FlyTo flyTo = (CameraKeyframe.FlyTo) step; - Camera targetCam = flyTo.getTargetCamera(); - LatLng center = - new LatLng( - targetCam.getCenter().getLatitude(), targetCam.getCenter().getLongitude()); + if (step instanceof JavaCameraKeyframe.FlyTo) { + JavaCameraKeyframe.FlyTo flyStep = (JavaCameraKeyframe.FlyTo) step; + LatLng targetCenter = + new LatLng( + flyStep.getTargetCamera().getCenter().getLatitude(), + flyStep.getTargetCamera().getCenter().getLongitude()); + updateAirplaneModel(targetCenter, flyStep.getTargetCamera().getHeading() + 180.0); - updateAirplaneModel(center, targetCam.getHeading() + 180.0); googleMap3D.setCameraAnimationEndListener( - () -> { - googleMap3D.setCameraAnimationEndListener(null); - if (isPlaying) { - currentStepIndex = stepIndex + 1; + () -> { + googleMap3D.setCameraAnimationEndListener(null); + currentStepIndex++; executeKeyframeStep(currentStepIndex); - } - }); - googleMap3D.flyCameraTo(new FlyToOptions(targetCam, flyTo.getDurationMs())); - } else if (step instanceof CameraKeyframe.DwellPause) { - CameraKeyframe.DwellPause dwell = (CameraKeyframe.DwellPause) step; + }); + googleMap3D.flyCameraTo(new FlyToOptions(flyStep.getTargetCamera(), flyStep.getDurationMs())); + } else if (step instanceof JavaCameraKeyframe.DwellPause) { + JavaCameraKeyframe.DwellPause pauseStep = (JavaCameraKeyframe.DwellPause) step; handler.postDelayed( - () -> { - if (isPlaying) { - currentStepIndex = stepIndex + 1; + () -> { + currentStepIndex++; executeKeyframeStep(currentStepIndex); - } - }, - dwell.getDurationMs()); - } else if (step instanceof CameraKeyframe.OrbitAround) { - CameraKeyframe.OrbitAround orbit = (CameraKeyframe.OrbitAround) step; - long totalMs = orbit.getDurationMs(); + }, + pauseStep.getDurationMs()); + } else if (step instanceof JavaCameraKeyframe.OrbitAround) { + JavaCameraKeyframe.OrbitAround orbitStep = (JavaCameraKeyframe.OrbitAround) step; + long totalMs = orbitStep.getDurationMs(); keyframeOrbitCallback = - new Choreographer.FrameCallback() { - private long startTimeNanos = 0L; + new Choreographer.FrameCallback() { + private long startTimeNanos = 0L; - @Override - public void doFrame(long frameTimeNanos) { - if (!isPlaying || googleMap3D == null) { - return; - } + @Override + public void doFrame(long frameTimeNanos) { + if (!isPlaying || googleMap3D == null) return; - if (startTimeNanos == 0L) { - startTimeNanos = frameTimeNanos; - Choreographer.getInstance().postFrameCallback(this); - return; - } + if (startTimeNanos == 0L) { + startTimeNanos = frameTimeNanos; + Choreographer.getInstance().postFrameCallback(this); + return; + } - long elapsedMs = (frameTimeNanos - startTimeNanos) / 1_000_000L; - double t = Math.min(1.0, (double) elapsedMs / totalMs); - double orbitHeading = interpolateAngle(orbit.getStartHeading(), - orbit.getEndHeading(), t); + long elapsedMs = (frameTimeNanos - startTimeNanos) / 1_000_000L; + double t = Math.min(1.0, (double) elapsedMs / totalMs); + double orbitHeading = interpolateAngle(orbitStep.getStartHeading(), orbitStep.getEndHeading(), t); - Camera updatedCam = - new Camera( - new LatLngAltitude( - orbit.getCenter().latitude, - orbit.getCenter().longitude, - orbit.getAltitude()), - normalizeHeading(orbitHeading), - orbit.getTilt(), - /* roll= */ 0.0, - orbit.getRange()); - - updateAirplaneModel(orbit.getCenter(), orbitHeading + 180.0); - googleMap3D.setCamera(updatedCam); + Camera updatedCam = + new Camera( + new LatLngAltitude(orbitStep.getCenter().latitude, orbitStep.getCenter().longitude, orbitStep.getAltitude()), + normalizeHeading(orbitHeading), + orbitStep.getTilt(), + 0.0, + orbitStep.getRange()); - if (t >= 1.0) { - keyframeOrbitCallback = null; - if (isPlaying) { - currentStepIndex = stepIndex + 1; + updateAirplaneModel(orbitStep.getCenter(), orbitHeading + 180.0); + googleMap3D.setCamera(updatedCam); + + if (t >= 1.0) { + currentStepIndex++; executeKeyframeStep(currentStepIndex); + return; } - return; + Choreographer.getInstance().postFrameCallback(this); } - - Choreographer.getInstance().postFrameCallback(this); - } - }; + }; Choreographer.getInstance().postFrameCallback(keyframeOrbitCallback); } } - /** - * Frame Dispatcher Animation Loop. High-speed flight animation (400 m/s) stopping cleanly at - * destination. - * - * For the most visually uniform cinematic sweeping motion, we recommend using - * `Choreographer.FrameCallback` to sync our delta-time interpolation directly to the hardware - * display frames. - */ private void runFrameDispatcherLoop() { stopTour(); isPlaying = true; @@ -519,141 +415,125 @@ private void runFrameDispatcherLoop() { } double totalDistance = cumulativeDistances[cumulativeDistances.length - 1]; - double flightSpeedMps = 400.0; // Fast 400 m/s high-speed flight + double flightSpeedMps = 400.0; frameDispatcherCallback = - new Choreographer.FrameCallback() { - private long lastTimeNanos = 0L; - private double elapsedDistance = 0.0; - - @Override - public void doFrame(long frameTimeNanos) { - if (!isPlaying || googleMap3D == null) { - return; - } + new Choreographer.FrameCallback() { + private long lastTimeNanos = 0L; + private double elapsedDistance = 0.0; - if (lastTimeNanos == 0L) { - lastTimeNanos = frameTimeNanos; - Choreographer.getInstance().postFrameCallback(this); - return; - } + @Override + public void doFrame(long frameTimeNanos) { + if (!isPlaying || googleMap3D == null) return; - double dt = (frameTimeNanos - lastTimeNanos) / 1_000_000_000.0; - lastTimeNanos = frameTimeNanos; + if (lastTimeNanos == 0L) { + lastTimeNanos = frameTimeNanos; + Choreographer.getInstance().postFrameCallback(this); + return; + } - elapsedDistance += flightSpeedMps * dt; + double dt = (frameTimeNanos - lastTimeNanos) / 1_000_000_000.0; + lastTimeNanos = frameTimeNanos; + elapsedDistance += flightSpeedMps * dt; + + if (elapsedDistance >= totalDistance) { + elapsedDistance = totalDistance; + PositionAndHeading posAndHeading = + RouteEngine.calculatePositionAndHeading( + TourData.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); + googleMap3D.setCamera(finalCam); + isPlaying = false; + updatePlayPauseButtonState(); + if (tvTourStatus != null) { + tvTourStatus.setText(R.string.aerial_tour_status_finished); + } + return; + } - if (elapsedDistance >= totalDistance) { - elapsedDistance = totalDistance; PositionAndHeading posAndHeading = - RouteEngine.calculatePositionAndHeading( - AIRPLANE_FLIGHT_PATH, cumulativeDistances, elapsedDistance, 30.0); + RouteEngine.calculatePositionAndHeading( + TourData.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); - googleMap3D.setCamera(finalCam); - isPlaying = false; - updatePlayPauseButtonState(); - if (tvTourStatus != null) { - tvTourStatus.setText(R.string.aerial_tour_status_finished); - } - return; + Camera updatedCam = + new Camera( + new LatLngAltitude(posAndHeading.getPosition().latitude, posAndHeading.getPosition().longitude, 200.0), + normalizeHeading(posAndHeading.getHeading()), + 65.0, + 0.0, + 600.0); + googleMap3D.setCamera(updatedCam); + Choreographer.getInstance().postFrameCallback(this); } - - 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); - googleMap3D.setCamera(updatedCam); - - Choreographer.getInstance().postFrameCallback(this); - } - }; + }; Choreographer.getInstance().postFrameCallback(frameDispatcherCallback); } - /** - * Option 4: Continuous 360-degree orbital camera spin around landmark. - */ private void run360OrbitSpin() { - stopTour(); + stopTour() ; isPlaying = true; updatePlayPauseButtonState(); if (tvTourStatus != null) { tvTourStatus.setText(R.string.approach_orbit_360_spin); } - LatLng targetCenter = AIRPLANE_FLIGHT_PATH.get(0); + LatLng targetCenter = TourData.AIRPLANE_FLIGHT_PATH.get(0); updateAirplaneModel(targetCenter, 105.0 + 180.0); - long totalMs = 6000L; // 6 second smooth 360° spin + long totalMs = 6000L; double startHeading = 105.0; orbitCallback = - new Choreographer.FrameCallback() { - private long startTimeNanos = 0L; + new Choreographer.FrameCallback() { + private long startTimeNanos = 0L; - @Override - public void doFrame(long frameTimeNanos) { - if (!isPlaying || googleMap3D == null) { - return; - } + @Override + public void doFrame(long frameTimeNanos) { + if (!isPlaying || googleMap3D == null) return; - if (startTimeNanos == 0L) { - startTimeNanos = frameTimeNanos; - Choreographer.getInstance().postFrameCallback(this); - return; - } + if (startTimeNanos == 0L) { + startTimeNanos = frameTimeNanos; + Choreographer.getInstance().postFrameCallback(this); + return; + } - long elapsedMs = (frameTimeNanos - startTimeNanos) / 1_000_000L; - double t = Math.min(1.0, (double) elapsedMs / totalMs); - double headingDeg = (startHeading + t * 360.0) % 360.0; + long elapsedMs = (frameTimeNanos - startTimeNanos) / 1_000_000L; + double t = Math.min(1.0, (double) elapsedMs / totalMs); + 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); - googleMap3D.setCamera(currentCam); - - if (t >= 1.0) { - isPlaying = false; - updatePlayPauseButtonState(); - if (tvTourStatus != null) { - tvTourStatus.setText(R.string.aerial_tour_status_finished); + Camera currentCam = + new Camera( + new LatLngAltitude(targetCenter.latitude, targetCenter.longitude, 200.0), + normalizeHeading(headingDeg), + 65.0, + 0.0, + 600.0); + googleMap3D.setCamera(currentCam); + + if (t >= 1.0) { + isPlaying = false; + updatePlayPauseButtonState(); + if (tvTourStatus != null) { + tvTourStatus.setText(R.string.aerial_tour_status_finished); + } + return; } - return; - } - Choreographer.getInstance().postFrameCallback(this); - } - }; + Choreographer.getInstance().postFrameCallback(this); + } + }; Choreographer.getInstance().postFrameCallback(orbitCallback); } @@ -679,25 +559,21 @@ private void stopTour() { } } - /** - * Resets the camera and airplane model to the initial start location and restarts animation. - */ public void resetAndRestartTour() { stopTour(); currentStepIndex = 0; - LatLng startLoc = AIRPLANE_FLIGHT_PATH.get(0); - double initialHeading = - SphericalUtil.computeHeading(startLoc, AIRPLANE_FLIGHT_PATH.get(1)); + LatLng startLoc = TourData.AIRPLANE_FLIGHT_PATH.get(0); + double initialHeading = SphericalUtil.computeHeading(startLoc, TourData.AIRPLANE_FLIGHT_PATH.get(1)); updateAirplaneModel(startLoc, initialHeading + 180.0); Camera resetCam = - new Camera( - new LatLngAltitude(startLoc.latitude, startLoc.longitude, 200.0), - normalizeHeading(initialHeading), - /* tilt= */ 65.0, - /* roll= */ 0.0, - /* range= */ 600.0); + new Camera( + new LatLngAltitude(startLoc.latitude, startLoc.longitude, 200.0), + normalizeHeading(initialHeading), + /* tilt= */ 65.0, + /* roll= */ 0.0, + /* range= */ 600.0); if (googleMap3D != null) { googleMap3D.setCamera(resetCam); @@ -716,65 +592,40 @@ protected void onPause() { stopTour(); } - 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 List SAN_FRANCISCO_TOUR = - Arrays.asList( - new CameraKeyframe.FlyTo( - "1. Golden Gate Bridge Flight", - "3D Airplane flight over Golden Gate Bridge", - new Camera( - new LatLngAltitude(37.8199, -122.4783, 200.0), - /* heading= */ 105.0, - /* tilt= */ 65.0, - /* roll= */ 0.0, - /* range= */ 600.0), - 2500L), - new CameraKeyframe.DwellPause( - "2. Mid-Air Observation", "Dwell pause observing 3D airplane over Golden Gate", - 1500L), - new CameraKeyframe.OrbitAround( - "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 CameraKeyframe.FlyTo( - "4. Transit to Coit Tower", - "Airplane flight to Coit Tower Landmark", - new Camera( - new LatLngAltitude(37.8024, -122.4058, 200.0), - /* heading= */ 105.0, - /* tilt= */ 65.0, - /* roll= */ 0.0, - /* range= */ 600.0), - 3000L)); - - // 15 Fine-Grained Waypoints on the direct route from Golden Gate Bridge to Coit Tower - private 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) - ); + private static final List SAN_FRANCISCO_TOUR = + Arrays.asList( + new JavaCameraKeyframe.FlyTo( + "1. Golden Gate Bridge Flight", + "3D Airplane flight over Golden Gate Bridge", + new Camera( + new LatLngAltitude(37.8199, -122.4783, 200.0), + /* heading= */ 105.0, + /* tilt= */ 65.0, + /* roll= */ 0.0, + /* range= */ 600.0), + 2500L), + new JavaCameraKeyframe.DwellPause( + "2. Mid-Air Observation", "Dwell pause observing 3D airplane over Golden Gate", 1500L), + new JavaCameraKeyframe.OrbitAround( + "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 JavaCameraKeyframe.FlyTo( + "4. Transit to Coit Tower", + "Airplane flight to Coit Tower Landmark", + new Camera( + new LatLngAltitude(37.8024, -122.4058, 200.0), + /* heading= */ 105.0, + /* tilt= */ 65.0, + /* roll= */ 0.0, + /* range= */ 600.0), + 3000L)); private static double normalizeHeading(double headingDeg) { double normalized = headingDeg % 360.0; @@ -783,12 +634,8 @@ private static double normalizeHeading(double headingDeg) { 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; - } + if (diff > 180.0) diff -= 360.0; + if (diff < -180.0) diff += 360.0; return (start + diff * fraction + 360.0) % 360.0; } } 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 b3643c2..725806c 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 @@ -28,6 +28,9 @@ import androidx.appcompat.app.AppCompatActivity; import androidx.cardview.widget.CardView; import androidx.core.view.WindowCompat; +import com.example.maps3d.common.InterpolatedPathPoint; +import com.example.maps3d.common.PathData; +import com.example.maps3d.common.PathEngine; import com.example.maps3dcommon.R; import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps3d.GoogleMap3D; @@ -41,29 +44,20 @@ import com.google.android.material.button.MaterialButton; import com.google.android.material.materialswitch.MaterialSwitch; import com.google.android.material.slider.Slider; -import com.google.maps.android.SphericalUtil; -import java.util.ArrayList; -import java.util.Arrays; import java.util.List; /** * Advanced sample demonstrating ground-level path following in Java. * - * Features: - * - Urban vs Rural ground-level paths - * - Two-polyline architecture: wide blue base route (lower z-index) + narrow purple active progress route (higher z-index) - * - In-place polyline ID updates eliminating render flickering - * - Configurable altitude modes (Clamp to Ground default, Relative to Ground, Relative to Mesh, Absolute) - * - Dynamic path elevation slider to eliminate z-fighting - * - Occlusion visualization toggle (drawsOccludedSegments) rendering polylines through or behind 3D terrain and buildings - * - Explicit collapse dialog button and smooth slide-down controls - * - Real-time camera controls via sliders: Range, Ground Altitude, Heading Offset, Tilt, Follow Speed + * Key Concepts Demonstrated: + * 1. Dual-Polyline Architecture: Layered base route (lower z-index) and traversed progress route (higher z-index). + * 2. In-Place Polyline ID Updates: Stable IDs prevent render flickering during rapid real-time updates. + * 3. 3D Altitude Modes: Dynamic switching between Clamp to Ground, Relative, and Absolute elevation. + * 4. Occlusion Control: Toggling drawsOccludedSegments through terrain and buildings. + * 5. Kinematic Heading Smoothing: Exponential moving average low-pass filter to smooth camera cornering. */ public class PathFollowingActivity extends AppCompatActivity implements OnMap3DViewReadyCallback { - private static final String STATIC_ROUTE_POLYLINE_ID = "path_following_static_route"; - private static final String PROGRESS_POLYLINE_ID = "path_following_progress_route"; - private Map3DView map3DView; private GoogleMap3D googleMap3D; @@ -91,55 +85,6 @@ public class PathFollowingActivity extends AppCompatActivity implements OnMap3DV private Slider speedSlider; private TextView speedSliderLabel; - private final Handler fadeHandler = new Handler(Looper.getMainLooper()); - private final Runnable fadeOutRunnable = - () -> { - if (controlsCard != null && !isCollapsed) { - controlsCard.animate().alpha(0.8f).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); - } - // Control parameters private double cameraRange = 300.0; private double groundAltitude = 20.0; @@ -151,20 +96,32 @@ public boolean dispatchTouchEvent(MotionEvent ev) { private boolean drawsOccludedSegments = true; // Path state - private List currentPath = URBAN_PATH; + private List currentPath = PathData.URBAN_PATH; private double[] cumulativeDistances = new double[0]; private double totalDistance = 0.0; private double elapsedDistance = 0.0; private boolean isPlaying = false; private boolean isUserScrubbing = false; + private Double currentHeading = null; + + private double getBaseAltitude() { + return currentPath.equals(PathData.RURAL_PATH) ? 45.0 : 50.0; + } // Polyline handles private Polyline staticRoutePolyline; private Polyline progressPolyline; - // Animation Loop + // Animation & auto-fade handlers private Runnable animationRunnable; private final Handler animationHandler = new Handler(Looper.getMainLooper()); + private final Handler fadeHandler = new Handler(Looper.getMainLooper()); + private final Runnable fadeOutRunnable = + () -> { + if (controlsCard != null && !isCollapsed) { + controlsCard.animate().alpha(0.8f).setDuration(400).start(); + } + }; @Override protected void onCreate(Bundle savedInstanceState) { @@ -177,48 +134,7 @@ protected void onCreate(Bundle savedInstanceState) { map3DView.getMap3DViewAsync(this); initViews(); - loadPathData(URBAN_PATH); - } - - @Override - protected void onResume() { - super.onResume(); - map3DView.onResume(); - } - - @Override - protected void onPause() { - super.onPause(); - map3DView.onPause(); - pauseAnimation(); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - pauseAnimation(); - fadeHandler.removeCallbacks(fadeOutRunnable); - if (staticRoutePolyline != null) { - staticRoutePolyline.remove(); - staticRoutePolyline = null; - } - if (progressPolyline != null) { - progressPolyline.remove(); - progressPolyline = null; - } - map3DView.onDestroy(); - } - - @Override - public void onLowMemory() { - super.onLowMemory(); - map3DView.onLowMemory(); - } - - @Override - protected void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - map3DView.onSaveInstanceState(outState); + loadPath(PathData.URBAN_PATH); } @Override @@ -230,9 +146,7 @@ public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) { updateCameraPositionForDistance(0.0); }); - // Schedule auto-fade for idle control panel after initial display - fadeHandler.removeCallbacks(fadeOutRunnable); - fadeHandler.postDelayed(fadeOutRunnable, 3000L); + scheduleControlsFade(); } private void initViews() { @@ -241,25 +155,10 @@ private void initViews() { btnCollapse = findViewById(R.id.btn_collapse); if (btnCollapse != null) { - btnCollapse.setOnClickListener( - v -> { - if (isCollapsed) { - expandControls(); - } else { - collapseControls(); - } - }); + btnCollapse.setOnClickListener(v -> toggleControlsCard()); } - if (cardHeader != null) { - cardHeader.setOnClickListener( - v -> { - if (isCollapsed) { - expandControls(); - } else { - collapseControls(); - } - }); + cardHeader.setOnClickListener(v -> toggleControlsCard()); } rgEnvironment = findViewById(R.id.rg_environment); @@ -280,17 +179,19 @@ private void initViews() { speedSlider = findViewById(R.id.speed_slider); speedSliderLabel = findViewById(R.id.speed_slider_label); - // Environment toggle + setupEventListeners(); + } + + private void setupEventListeners() { rgEnvironment.setOnCheckedChangeListener( (group, checkedId) -> { if (checkedId == R.id.rb_urban) { - switchEnvironment(URBAN_PATH); + switchEnvironment(PathData.URBAN_PATH); } else if (checkedId == R.id.rb_rural) { - switchEnvironment(RURAL_PATH); + switchEnvironment(PathData.RURAL_PATH); } }); - // Altitude mode toggle rgAltitudeMode.setOnCheckedChangeListener( (group, checkedId) -> { if (checkedId == R.id.rb_clamp_to_ground) { @@ -304,54 +205,24 @@ private void initViews() { } else { pathAltitudeMode = AltitudeMode.CLAMP_TO_GROUND; } - if (staticRoutePolyline != null) { - staticRoutePolyline.remove(); - staticRoutePolyline = null; - } - if (progressPolyline != null) { - progressPolyline.remove(); - progressPolyline = null; - } - drawPathPolylines(); - updateCameraPositionForDistance(elapsedDistance); + redrawPolylinesAndCamera(); }); - // Draw occluded segments toggle switchDrawsOccludedSegments.setChecked(drawsOccludedSegments); switchDrawsOccludedSegments.setOnCheckedChangeListener( (buttonView, isChecked) -> { drawsOccludedSegments = isChecked; - if (staticRoutePolyline != null) { - staticRoutePolyline.remove(); - staticRoutePolyline = null; - } - if (progressPolyline != null) { - progressPolyline.remove(); - progressPolyline = null; - } - drawPathPolylines(); - updateCameraPositionForDistance(elapsedDistance); + redrawPolylinesAndCamera(); }); - // Path height slider (0.0m - 10.0m) pathAltitudeSlider.addOnChangeListener( (slider, value, fromUser) -> { pathAltitudeOffset = value; pathAltitudeSliderLabel.setText( getString(R.string.path_height_format, pathAltitudeOffset)); - if (staticRoutePolyline != null) { - staticRoutePolyline.remove(); - staticRoutePolyline = null; - } - if (progressPolyline != null) { - progressPolyline.remove(); - progressPolyline = null; - } - drawPathPolylines(); - updateCameraPositionForDistance(elapsedDistance); + redrawPolylinesAndCamera(); }); - // Play/Pause button btnPlayPause.setOnClickListener( v -> { if (isPlaying) { @@ -361,7 +232,6 @@ private void initViews() { } }); - // Progress scrub slider progressSlider.addOnSliderTouchListener( new Slider.OnSliderTouchListener() { @Override @@ -385,7 +255,6 @@ public void onStopTrackingTouch(@NonNull Slider slider) { } }); - // Camera Range Slider (50m - 1000m) rangeSlider.addOnChangeListener( (slider, value, fromUser) -> { cameraRange = value; @@ -393,7 +262,6 @@ public void onStopTrackingTouch(@NonNull Slider slider) { updateCameraPositionForDistance(elapsedDistance); }); - // Ground Altitude Slider (2m - 200m) altitudeSlider.addOnChangeListener( (slider, value, fromUser) -> { groundAltitude = value; @@ -402,7 +270,6 @@ public void onStopTrackingTouch(@NonNull Slider slider) { updateCameraPositionForDistance(elapsedDistance); }); - // Heading Offset Slider (-180° - +180°) headingSlider.addOnChangeListener( (slider, value, fromUser) -> { headingOffset = value; @@ -411,7 +278,6 @@ public void onStopTrackingTouch(@NonNull Slider slider) { updateCameraPositionForDistance(elapsedDistance); }); - // Camera Tilt Slider (0° - 85°) tiltSlider.addOnChangeListener( (slider, value, fromUser) -> { cameraTilt = value; @@ -419,7 +285,6 @@ public void onStopTrackingTouch(@NonNull Slider slider) { updateCameraPositionForDistance(elapsedDistance); }); - // Follow Speed Slider (5 m/s - 100 m/s) speedSlider.addOnChangeListener( (slider, value, fromUser) -> { followSpeedMps = value; @@ -427,23 +292,14 @@ public void onStopTrackingTouch(@NonNull Slider slider) { }); } - private Double currentHeading = null; - private void switchEnvironment(List path) { pauseAnimation(); currentHeading = null; elapsedDistance = 0.0; progressSlider.setValue(0f); - if (progressPolyline != null) { - progressPolyline.remove(); - progressPolyline = null; - } - if (staticRoutePolyline != null) { - staticRoutePolyline.remove(); - staticRoutePolyline = null; - } + clearPolylines(); - if (path.equals(RURAL_PATH)) { + if (path.equals(PathData.RURAL_PATH)) { cameraRange = 450.0; groundAltitude = 40.0; cameraTilt = 75.0; @@ -465,30 +321,21 @@ private void switchEnvironment(List path) { altitudeSliderLabel.setText(getString(R.string.ground_altitude_format, (int) groundAltitude)); tiltSliderLabel.setText(getString(R.string.camera_tilt_format, (int) cameraTilt)); - loadPathData(path); + loadPath(path); drawPathPolylines(); updateCameraPositionForDistance(0.0); } - private void loadPathData(List path) { + private void loadPath(List path) { currentPath = path; - cumulativeDistances = new double[path.size()]; - totalDistance = 0.0; - cumulativeDistances[0] = 0.0; - for (int i = 1; i < path.size(); i++) { - LatLng pPrev = new LatLng(path.get(i - 1).getLatitude(), path.get(i - 1).getLongitude()); - LatLng pCurr = new LatLng(path.get(i).getLatitude(), path.get(i).getLongitude()); - double dist = SphericalUtil.computeDistanceBetween(pPrev, pCurr); - totalDistance += dist; - cumulativeDistances[i] = totalDistance; - } + cumulativeDistances = PathEngine.calculateCumulativeDistances(path); + totalDistance = (cumulativeDistances.length > 0) ? cumulativeDistances[cumulativeDistances.length - 1] : 0.0; } private void drawPathPolylines() { drawStaticRoutePolyline(); if (!currentPath.isEmpty()) { - LatLng firstPt = - new LatLng(currentPath.get(0).getLatitude(), currentPath.get(0).getLongitude()); + LatLng firstPt = new LatLng(currentPath.get(0).getLatitude(), currentPath.get(0).getLongitude()); updateProgressPolyline(elapsedDistance, firstPt, 0); } } @@ -496,23 +343,13 @@ private void drawPathPolylines() { private void drawStaticRoutePolyline() { if (googleMap3D == null || currentPath.isEmpty()) return; - List staticVertices = new ArrayList<>(); - for (LatLngAltitude pt : currentPath) { - double vertexAltitude = - (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) - ? 0.0 - : (pathAltitudeMode == AltitudeMode.ABSOLUTE) - ? pt.getAltitude() - + (currentPath.equals(RURAL_PATH) ? 45.0 : 50.0) - + pathAltitudeOffset - : pt.getAltitude() + pathAltitudeOffset; - staticVertices.add(new LatLngAltitude(pt.getLatitude(), pt.getLongitude(), vertexAltitude)); - } + List staticVertices = + PathEngine.buildStaticVertices(currentPath, pathAltitudeMode, getBaseAltitude(), pathAltitudeOffset); PolylineOptions staticOptions = new PolylineOptions(); - staticOptions.setId(STATIC_ROUTE_POLYLINE_ID); // Fixed ID prevents flickering + staticOptions.setId(PathEngine.STATIC_POLYLINE_ID); // Fixed ID prevents flickering staticOptions.setPath(staticVertices); - staticOptions.setStrokeColor(Color.parseColor("#4285F4")); // Wide blue route + staticOptions.setStrokeColor(Color.parseColor("#4285F4")); // Wide blue route (16dp) staticOptions.setStrokeWidth(16.0); staticOptions.setZIndex(1); staticOptions.setAltitudeMode(pathAltitudeMode); @@ -524,77 +361,21 @@ private void drawStaticRoutePolyline() { private void updateProgressPolyline(double dist, LatLng currentLatLng, int index) { if (googleMap3D == null || currentPath.isEmpty() || totalDistance <= 0.0) return; - List progressCoordinates = new ArrayList<>(); - for (int i = 0; i <= Math.min(index, currentPath.size() - 1); i++) { - LatLngAltitude pt = currentPath.get(i); - double vertexAltitude = - (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) - ? 0.0 - : (pathAltitudeMode == AltitudeMode.ABSOLUTE) - ? pt.getAltitude() - + (currentPath.equals(RURAL_PATH) ? 45.0 : 50.0) - + pathAltitudeOffset - + 0.4 - : pt.getAltitude() + pathAltitudeOffset + 0.4; - progressCoordinates.add( - new LatLngAltitude(pt.getLatitude(), pt.getLongitude(), vertexAltitude)); - } - - LatLngAltitude lastWaypoint = currentPath.get(Math.min(index, currentPath.size() - 1)); - LatLng lastLatLng = new LatLng(lastWaypoint.getLatitude(), lastWaypoint.getLongitude()); - double distToLast = SphericalUtil.computeDistanceBetween(lastLatLng, currentLatLng); - if (distToLast >= 0.5) { - LatLngAltitude p1 = currentPath.get(Math.min(index, currentPath.size() - 1)); - LatLngAltitude p2 = (index < currentPath.size() - 1) ? currentPath.get(index + 1) : p1; - double segStartDist = (index < cumulativeDistances.length) ? cumulativeDistances[index] : 0.0; - double segEndDist = - (index + 1 < cumulativeDistances.length) ? cumulativeDistances[index + 1] : totalDistance; - double segLen = segEndDist - segStartDist; - double fraction = (segLen > 0) ? Math.max(0.0, Math.min(1.0, (dist - segStartDist) / segLen)) : 0.0; - double interpAlt = p1.getAltitude() + fraction * (p2.getAltitude() - p1.getAltitude()); - double progressAltitude = - (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) - ? 0.0 - : (pathAltitudeMode == AltitudeMode.ABSOLUTE) - ? interpAlt - + (currentPath.equals(RURAL_PATH) ? 45.0 : 50.0) - + pathAltitudeOffset - + 0.4 - : interpAlt + pathAltitudeOffset + 0.4; - progressCoordinates.add( - new LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, progressAltitude)); - } - - // Polyline requires at least 2 distinct vertices - if (progressCoordinates.size() < 2) { - if (currentPath.size() >= 2) { - LatLng p0 = - new LatLng(currentPath.get(0).getLatitude(), currentPath.get(0).getLongitude()); - LatLng p1 = - new LatLng(currentPath.get(1).getLatitude(), currentPath.get(1).getLongitude()); - LatLng tinyForward = SphericalUtil.interpolate(p0, p1, 0.005); - double startAlt = - (pathAltitudeMode == AltitudeMode.CLAMP_TO_GROUND) - ? 0.0 - : (pathAltitudeMode == AltitudeMode.ABSOLUTE) - ? currentPath.get(0).getAltitude() - + (currentPath.equals(RURAL_PATH) ? 45.0 : 50.0) - + pathAltitudeOffset - + 0.4 - : currentPath.get(0).getAltitude() + pathAltitudeOffset + 0.4; - progressCoordinates.add( - new LatLngAltitude(tinyForward.latitude, tinyForward.longitude, startAlt)); - } - } + List progressCoordinates = + PathEngine.buildProgressVertices( + currentPath, + cumulativeDistances, + dist, + currentLatLng, + index, + pathAltitudeMode, + getBaseAltitude(), + pathAltitudeOffset); - // Dual-Polyline Rendering Technique: - // 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. - // Fixed PROGRESS_POLYLINE_ID ensures in-place upsert in Maps 3D engine without duplication. PolylineOptions progressOptions = new PolylineOptions(); - progressOptions.setId(PROGRESS_POLYLINE_ID); + progressOptions.setId(PathEngine.PROGRESS_POLYLINE_ID); progressOptions.setPath(progressCoordinates); - progressOptions.setStrokeColor(Color.parseColor("#9C27B0")); // Narrow purple progress + progressOptions.setStrokeColor(Color.parseColor("#9C27B0")); // Narrow purple progress (8dp) progressOptions.setStrokeWidth(8.0); progressOptions.setZIndex(2); progressOptions.setAltitudeMode(pathAltitudeMode); @@ -603,6 +384,36 @@ private void updateProgressPolyline(double dist, LatLng currentLatLng, int index progressPolyline = googleMap3D.addPolyline(progressOptions); } + private void updateCameraPositionForDistance(double dist) { + if (googleMap3D == null || currentPath.isEmpty()) return; + + InterpolatedPathPoint point = PathEngine.interpolatePoint(currentPath, cumulativeDistances, dist); + double targetHeading = + PathEngine.smoothHeading( + point.bearing + headingOffset, + currentHeading, + isUserScrubbing, + isPlaying, + 0.12); + currentHeading = targetHeading; + + double cameraTargetAltitude = + PathEngine.calculateCameraAltitude( + pathAltitudeMode, getBaseAltitude(), point.altitude, groundAltitude); + + Camera newCamera = + new Camera( + new LatLngAltitude( + point.latLng.latitude, point.latLng.longitude, cameraTargetAltitude), + targetHeading, + cameraTilt, + /* roll= */ 0.0, + cameraRange); + + googleMap3D.setCamera(newCamera); + updateProgressPolyline(dist, point.latLng, point.waypointIndex); + } + private void startAnimation() { if (isPlaying) return; isPlaying = true; @@ -649,118 +460,102 @@ private void pauseAnimation() { } } - private void updateCameraPositionForDistance(double dist) { - if (googleMap3D == null || currentPath.isEmpty()) return; + private void redrawPolylinesAndCamera() { + clearPolylines(); + drawPathPolylines(); + updateCameraPositionForDistance(elapsedDistance); + } - int index = 0; - while (index < cumulativeDistances.length - 1 && cumulativeDistances[index + 1] < dist) { - index++; + private void clearPolylines() { + if (staticRoutePolyline != null) { + staticRoutePolyline.remove(); + staticRoutePolyline = null; } + if (progressPolyline != null) { + progressPolyline.remove(); + progressPolyline = null; + } + } - LatLngAltitude p1 = currentPath.get(index); - LatLngAltitude p2 = (index < currentPath.size() - 1) ? currentPath.get(index + 1) : p1; + private void toggleControlsCard() { + if (isCollapsed) expandControls(); else collapseControls(); + } - double segStartDist = cumulativeDistances[index]; - double segEndDist = - (index < cumulativeDistances.length - 1) ? cumulativeDistances[index + 1] : totalDistance; - double segLen = segEndDist - segStartDist; + 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)); + } - double fraction = (segLen > 0) ? Math.max(0.0, Math.min(1.0, (dist - segStartDist) / segLen)) : 0.0; - LatLng latLng1 = new LatLng(p1.getLatitude(), p1.getLongitude()); - LatLng latLng2 = new LatLng(p2.getLatitude(), p2.getLongitude()); - LatLng currentLatLng = SphericalUtil.interpolate(latLng1, latLng2, fraction); - double bearing = SphericalUtil.computeHeading(latLng1, latLng2); + 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(); + } - // Kinematic Heading Smoothing (Exponential Moving Average) - double targetHeadingRaw = (bearing + headingOffset) % 360.0; - if (targetHeadingRaw < 0.0) targetHeadingRaw += 360.0; + 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(); + scheduleControlsFade(); + } - double targetHeading; - if (currentHeading == null || isUserScrubbing || !isPlaying) { - targetHeading = targetHeadingRaw; - } else { - double diff = (targetHeadingRaw - currentHeading) % 360.0; - if (diff > 180.0) diff -= 360.0; - if (diff < -180.0) diff += 360.0; - targetHeading = (currentHeading + diff * 0.12) % 360.0; - if (targetHeading < 0.0) targetHeading += 360.0; + private void scheduleControlsFade() { + 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(); + scheduleControlsFade(); + } } - currentHeading = targetHeading; + return super.dispatchTouchEvent(ev); + } - double interpAlt = p1.getAltitude() + fraction * (p2.getAltitude() - p1.getAltitude()); - double cameraTargetAltitude = - (pathAltitudeMode == AltitudeMode.ABSOLUTE) - ? ((currentPath.equals(RURAL_PATH) ? 45.0 : 50.0) + interpAlt + groundAltitude) - : groundAltitude; + @Override + protected void onResume() { + super.onResume(); + map3DView.onResume(); + } - Camera newCamera = - new Camera( - new LatLngAltitude( - currentLatLng.latitude, currentLatLng.longitude, cameraTargetAltitude), - targetHeading, - cameraTilt, - /* roll= */ 0.0, - cameraRange); + @Override + protected void onPause() { + super.onPause(); + map3DView.onPause(); + pauseAnimation(); + } - googleMap3D.setCamera(newCamera); - LatLng firstPt = - new LatLng(currentPath.get(0).getLatitude(), currentPath.get(0).getLongitude()); - updateProgressPolyline(dist, (currentLatLng != null) ? currentLatLng : firstPt, index); + @Override + protected void onDestroy() { + super.onDestroy(); + pauseAnimation(); + fadeHandler.removeCallbacks(fadeOutRunnable); + clearPolylines(); + map3DView.onDestroy(); } - // Urban Path (San Francisco Downtown - Market Street Corridor with 1 to 10m altitudes) - public static final List URBAN_PATH = - Arrays.asList( - new LatLngAltitude(37.79323, -122.39322, 4.2), - new LatLngAltitude(37.79166, -122.39519, 6.7), - new LatLngAltitude(37.79124, -122.39571, 8.1), - new LatLngAltitude(37.79105, -122.39599, 9.5), - new LatLngAltitude(37.78893, -122.39866, 7.3), - new LatLngAltitude(37.78742, -122.40060, 5.0), - new LatLngAltitude(37.78686, -122.40129, 3.4), - new LatLngAltitude(37.78652, -122.40171, 2.1), - new LatLngAltitude(37.78632, -122.40196, 4.6), - new LatLngAltitude(37.78627, -122.40207, 6.2), - new LatLngAltitude(37.78453, -122.40429, 8.9), - new LatLngAltitude(37.78443, -122.40434, 10.0), - new LatLngAltitude(37.78155, -122.40802, 7.8), - new LatLngAltitude(37.78005, -122.40990, 5.4), - new LatLngAltitude(37.77856, -122.41180, 3.1), - new LatLngAltitude(37.77746, -122.41318, 1.8), - new LatLngAltitude(37.77624, -122.41474, 4.0), - new LatLngAltitude(37.77744, -122.41623, 6.5), - new LatLngAltitude(37.77749, -122.41636, 8.7), - new LatLngAltitude(37.77761, -122.41654, 9.8), - new LatLngAltitude(37.77769, -122.41677, 7.2), - new LatLngAltitude(37.77729, -122.41981, 4.9), - new LatLngAltitude(37.77523, -122.41938, 2.6), - new LatLngAltitude(37.77510, -122.41934, 1.2), - new LatLngAltitude(37.77442, -122.42022, 3.5), - new LatLngAltitude(37.77441, -122.42033, 5.8), - new LatLngAltitude(37.77348, -122.42157, 8.4), - new LatLngAltitude(37.77244, -122.42289, 10.0)); - - // Rural Path - public static final List RURAL_PATH = - Arrays.asList( - new LatLngAltitude(37.254529, -122.380897, 0.0), - new LatLngAltitude(37.255065, -122.381627, 0.0), - new LatLngAltitude(37.257540, -122.383720, 0.0), - new LatLngAltitude(37.261200, -122.383950, 0.0), - new LatLngAltitude(37.264780, -122.388210, 0.0), - new LatLngAltitude(37.268520, -122.392450, 0.0), - new LatLngAltitude(37.272110, -122.397640, 0.0), - new LatLngAltitude(37.276430, -122.401120, 0.0), - new LatLngAltitude(37.280850, -122.403560, 0.0), - new LatLngAltitude(37.286018, -122.405072, 0.0), - new LatLngAltitude(37.291040, -122.404210, 0.0), - new LatLngAltitude(37.295800, -122.401980, 0.0), - new LatLngAltitude(37.300120, -122.399540, 0.0), - new LatLngAltitude(37.304550, -122.397210, 0.0), - new LatLngAltitude(37.309200, -122.395100, 0.0), - new LatLngAltitude(37.313450, -122.392840, 0.0), - new LatLngAltitude(37.317200, -122.390510, 0.0), - new LatLngAltitude(37.320850, -122.388740, 0.0), - new LatLngAltitude(37.323540, -122.387600, 0.0), - new LatLngAltitude(37.325269, -122.386728, 0.0)); + @Override + public void onLowMemory() { + super.onLowMemory(); + map3DView.onLowMemory(); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + map3DView.onSaveInstanceState(outState); + } } 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 ae302d6..e11ac22 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 @@ -17,12 +17,15 @@ package com.example.maps3dkotlin.advancedcameraanimation import android.os.Bundle +import android.view.Choreographer import android.view.ViewGroup import android.widget.Button import android.widget.RadioGroup import android.widget.TextView import androidx.lifecycle.lifecycleScope +import com.example.maps3d.common.CameraKeyframe import com.example.maps3d.common.RouteEngine +import com.example.maps3d.common.TourData import com.example.maps3dcommon.R import com.example.maps3dkotlin.sampleactivity.SampleBaseActivity import com.google.android.gms.maps.model.LatLng @@ -66,55 +69,45 @@ private suspend fun GoogleMap3D.awaitFlyCameraTo(options: FlyToOptions) = } } -enum class AnimationApproach { - SIMPLE_FLY_TO, - KEYFRAME_TOUR, - DISPATCHER_FRAME_LOOP, - ORBIT_360_SPIN +/** + * Normalizes heading angles into standard compass range [0.0, 360.0). + */ +private fun normalizeHeading(deg: Double): Double = (deg % 360.0 + 360.0) % 360.0 + +/** + * Performs shortest-arc spherical angular interpolation between two headings. + */ +private fun interpolateAngle(start: Double, end: Double, fraction: Double): Double { + var diff = (end - start) % 360.0 + if (diff > 180.0) diff -= 360.0 + if (diff < -180.0) diff += 360.0 + return normalizeHeading(start + diff * fraction) } -sealed interface CameraKeyframe { - val stepTitle: String - val stepDescription: String - - data class FlyTo( - override val stepTitle: String, - override val stepDescription: String, - val targetCenter: LatLng, - val targetAltitude: Double, - val targetHeading: Double, - val targetTilt: Double, - val targetRange: Double, - val durationMs: Long = 2500L, - ) : CameraKeyframe - - data class DwellPause( - override val stepTitle: String, - override val stepDescription: String, - val durationMs: Long = 1500L, - ) : CameraKeyframe - - data class OrbitAround( - override val stepTitle: String, - override val stepDescription: String, - val center: LatLng, - val altitude: Double, - val range: Double, - val tilt: Double, - val startHeading: Double, - val endHeading: Double, - val durationMs: Long = 4000L, - ) : CameraKeyframe +enum class AnimationApproach(val title: String) { + SIMPLE_FLY_TO("1. SDK Simple flyTo (Native Transition)"), + KEYFRAME_TOUR("2. Scripted Keyframe Queue (Multi-Stage Tour)"), + DISPATCHER_FRAME_LOOP("3. High-Speed Flight (Display Sync Choreographer)"), + ORBIT_360_SPIN("4. Orbital Spin (Continuous 360° Rotation)"), } +/** + * Advanced Camera Animation demonstrating cinematic 3D camera controls and 3D airplane model tracking. + * + * Animation Approaches: + * 1. Simple flyTo: Native SDK camera transition. + * 2. Keyframe Queue Tour: Multi-stage sequenced tour (fly-to, dwell pause, 360° landmark orbit). + * 3. Frame Dispatcher Loop: High-speed flight synced directly to hardware display refresh rate via Choreographer. + * 4. 360° Orbital Spin: Continuous circular inspection around landmarks. + */ class AdvancedCameraAnimationActivity : SampleBaseActivity() { - override val TAG = "AdvancedCameraAnimation" + override val TAG: String = "AdvancedCameraAnimation" override val initialCamera: Camera get() { - val firstLoc = AIRPLANE_FLIGHT_PATH.first() - val initialHeading = SphericalUtil.computeHeading(firstLoc, AIRPLANE_FLIGHT_PATH[1]) + val firstLoc = TourData.AIRPLANE_FLIGHT_PATH.first() + val initialHeading = SphericalUtil.computeHeading(firstLoc, TourData.AIRPLANE_FLIGHT_PATH[1]) return camera { center = latLngAltitude { latitude = firstLoc.latitude @@ -132,14 +125,14 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { private var isPlaying = false private var tourJob: Job? = null private var restartJob: Job? = null - private var frameDispatcherCallback: android.view.Choreographer.FrameCallback? = null + private var frameDispatcherCallback: Choreographer.FrameCallback? = null private var selectedApproach = AnimationApproach.DISPATCHER_FRAME_LOOP private var btnPlayPause: Button? = null private var tvTourStatus: TextView? = null private val cumulativeDistances by lazy { - RouteEngine.calculateCumulativeDistances(AIRPLANE_FLIGHT_PATH) + RouteEngine.calculateCumulativeDistances(TourData.AIRPLANE_FLIGHT_PATH) } override fun onCreate(savedInstanceState: Bundle?) { @@ -174,11 +167,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { btnPlayPause = findViewById(R.id.btn_play_pause) btnPlayPause?.setOnClickListener { - if (isPlaying) { - stopTour() - } else { - startSelectedApproach() - } + if (isPlaying) stopTour() else startSelectedApproach() } } @@ -189,12 +178,12 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { override fun onMap3DViewReady(googleMap3D: GoogleMap3D) { super.onMap3DViewReady(googleMap3D) - // Instantiate 3D Airplane Model on map at initial start position - val startLoc = AIRPLANE_FLIGHT_PATH.first() - val initialHeading = SphericalUtil.computeHeading(startLoc, AIRPLANE_FLIGHT_PATH[1]) + // Position 3D Airplane Model at route origin + val startLoc = TourData.AIRPLANE_FLIGHT_PATH.first() + val initialHeading = SphericalUtil.computeHeading(startLoc, TourData.AIRPLANE_FLIGHT_PATH[1]) updateAirplaneModel(startLoc, initialHeading + 180.0) - // Auto-start smooth flight animation after 1 second delay + // Auto-start animation after initial map load restartJob = lifecycleScope.launch { delay(1000L.milliseconds) startSelectedApproach() @@ -202,20 +191,17 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { } /** - * Updates the 3D Airplane Model's position and orientation on the map. - * Note: Calling `map.addModel(opts)` continuously with the same `id` string - * is the recommended approach for dynamically updating a model's location. - * - * Remote URLs: Models should be hosted and loaded via external URL. + * Updates the 3D Airplane Model position and orientation on the map. + * In-place ID upserting avoids tearing or recreation overhead. */ private fun updateAirplaneModel(targetLatLng: LatLng, planeHeadingDeg: Double) { googleMap3D?.let { map -> val opts = ModelOptions().apply { - id = MODEL_ID + id = TourData.MODEL_ID position = LatLngAltitude(targetLatLng.latitude, targetLatLng.longitude, 200.0) altitudeMode = AltitudeMode.ABSOLUTE orientation = Orientation(normalizeHeading(planeHeadingDeg), -90.0, 0.0) - url = PLANE_URL + url = TourData.PLANE_URL scale = Vector3D(0.08, 0.08, 0.08) } airplaneModel = map.addModel(opts) @@ -232,18 +218,20 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { } } + /** + * Option 1: Simple flyTo animation directly to destination. + */ private fun runSimpleFlyTo() { stopTour() isPlaying = true updatePlayPauseButtonState() tvTourStatus?.setText(R.string.approach_simple_fly_to) - val targetLoc = AIRPLANE_FLIGHT_PATH.last() - val flightHeading = - SphericalUtil.computeHeading( - AIRPLANE_FLIGHT_PATH[AIRPLANE_FLIGHT_PATH.size - 2], - targetLoc - ) + val targetLoc = TourData.AIRPLANE_FLIGHT_PATH.last() + val flightHeading = SphericalUtil.computeHeading( + TourData.AIRPLANE_FLIGHT_PATH[TourData.AIRPLANE_FLIGHT_PATH.size - 2], + targetLoc + ) updateAirplaneModel(targetLoc, flightHeading + 180.0) val targetCam = camera { @@ -265,7 +253,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { } /** - * Executes multi-step keyframe queue tour smoothly stage by stage. + * Option 2: Executes multi-step keyframe queue tour smoothly stage by stage. */ private fun startOrResumeTour() { stopTour() @@ -276,12 +264,12 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { tourJob = lifecycleScope.launch(Dispatchers.Main) { val frameDurationMs = 16L - while (currentStepIndex < SAN_FRANCISCO_TOUR.size && isActive && isPlaying) { - val step = SAN_FRANCISCO_TOUR[currentStepIndex] + while (currentStepIndex < TourData.SAN_FRANCISCO_TOUR.size && isActive && isPlaying) { + val step = TourData.SAN_FRANCISCO_TOUR[currentStepIndex] tvTourStatus?.text = getString( R.string.aerial_tour_status_running, currentStepIndex + 1, - SAN_FRANCISCO_TOUR.size, + TourData.SAN_FRANCISCO_TOUR.size, step.stepTitle ) @@ -310,8 +298,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { for (frame in 0..totalFrames) { if (!isActive || !isPlaying) break val t = frame.toDouble() / totalFrames - val orbitHeading = - interpolateAngle(step.startHeading, step.endHeading, t) + val orbitHeading = interpolateAngle(step.startHeading, step.endHeading, t) val updatedCam = camera { center = latLngAltitude { @@ -333,7 +320,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { if (!isActive || !isPlaying) break - if (currentStepIndex < SAN_FRANCISCO_TOUR.size - 1) { + if (currentStepIndex < TourData.SAN_FRANCISCO_TOUR.size - 1) { currentStepIndex++ } else { isPlaying = false @@ -346,12 +333,8 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { } /** - * Frame Dispatcher Animation Loop. - * High-speed flight animation (400 m/s) stopping cleanly at destination. - * - * For the most visually uniform cinematic sweeping motion, we recommend using - * `Choreographer.FrameCallback` to sync our delta-time interpolation directly - * to the hardware display frames. + * Option 3: Frame Dispatcher Animation Loop. + * High-speed flight animation (400 m/s) synced to hardware display frames via Choreographer. */ private fun runFrameDispatcherLoop() { stopTour() @@ -360,9 +343,9 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { tvTourStatus?.setText(R.string.approach_dispatcher_frame_loop) val totalDistance = cumulativeDistances.last().coerceAtLeast(1.0) - val flightSpeedMps = 400.0 // Fast 400 m/s high-speed flight + val flightSpeedMps = 400.0 - val frameCallback = object : android.view.Choreographer.FrameCallback { + val frameCallback = object : Choreographer.FrameCallback { private var lastTimeNanos = 0L private var elapsedDistance = 0.0 @@ -371,7 +354,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { if (lastTimeNanos == 0L) { lastTimeNanos = frameTimeNanos - android.view.Choreographer.getInstance().postFrameCallback(this) + Choreographer.getInstance().postFrameCallback(this) return } @@ -383,7 +366,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { if (elapsedDistance >= totalDistance) { elapsedDistance = totalDistance val posAndHeading = RouteEngine.calculatePositionAndHeading( - AIRPLANE_FLIGHT_PATH, + TourData.AIRPLANE_FLIGHT_PATH, cumulativeDistances, elapsedDistance, 30.0 @@ -409,7 +392,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { } val posAndHeading = RouteEngine.calculatePositionAndHeading( - AIRPLANE_FLIGHT_PATH, + TourData.AIRPLANE_FLIGHT_PATH, cumulativeDistances, elapsedDistance, 30.0 @@ -430,11 +413,11 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { } googleMap3D?.setCamera(updatedCam) - android.view.Choreographer.getInstance().postFrameCallback(this) + Choreographer.getInstance().postFrameCallback(this) } } frameDispatcherCallback = frameCallback - android.view.Choreographer.getInstance().postFrameCallback(frameCallback) + Choreographer.getInstance().postFrameCallback(frameCallback) } /** @@ -446,7 +429,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { updatePlayPauseButtonState() tvTourStatus?.setText(R.string.approach_orbit_360_spin) - val targetCenter = AIRPLANE_FLIGHT_PATH.first() + val targetCenter = TourData.AIRPLANE_FLIGHT_PATH.first() updateAirplaneModel(targetCenter, 105.0 + 180.0) tourJob = lifecycleScope.launch(Dispatchers.Main) { @@ -487,7 +470,7 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { tourJob?.cancel() tourJob = null frameDispatcherCallback?.let { - android.view.Choreographer.getInstance().removeFrameCallback(it) + Choreographer.getInstance().removeFrameCallback(it) frameDispatcherCallback = null } googleMap3D?.setCameraAnimationEndListener(null) @@ -501,8 +484,8 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { stopTour() currentStepIndex = 0 - val startLoc = AIRPLANE_FLIGHT_PATH.first() - val initialHeading = SphericalUtil.computeHeading(startLoc, AIRPLANE_FLIGHT_PATH[1]) + val startLoc = TourData.AIRPLANE_FLIGHT_PATH.first() + val initialHeading = SphericalUtil.computeHeading(startLoc, TourData.AIRPLANE_FLIGHT_PATH[1]) updateAirplaneModel(startLoc, initialHeading + 180.0) val resetCam = camera { @@ -528,80 +511,4 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { super.onPause() stopTour() } - - companion object { - private const val MODEL_ID = "airplane_model" - private const val PLANE_URL = - "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/Airplane.glb" - - val SAN_FRANCISCO_TOUR = listOf( - CameraKeyframe.FlyTo( - stepTitle = "1. Golden Gate Bridge Flight", - stepDescription = "3D Airplane flight over Golden Gate Bridge", - targetCenter = LatLng(37.8199, -122.4783), - targetAltitude = 200.0, - targetHeading = 105.0, - targetTilt = 65.0, - targetRange = 600.0, - durationMs = 2500L - ), - CameraKeyframe.DwellPause( - stepTitle = "2. Mid-Air Observation", - stepDescription = "Dwell pause observing 3D airplane over Golden Gate", - durationMs = 1500L - ), - CameraKeyframe.OrbitAround( - stepTitle = "3. Golden Gate 360° Orbit", - stepDescription = "360° orbital camera spin around flying airplane", - center = LatLng(37.8199, -122.4783), - altitude = 200.0, - range = 600.0, - tilt = 65.0, - startHeading = 105.0, - endHeading = 465.0, - durationMs = 4000L - ), - CameraKeyframe.FlyTo( - stepTitle = "4. Transit to Coit Tower", - stepDescription = "Airplane flight to Coit Tower Landmark", - targetCenter = LatLng(37.8024, -122.4058), - targetAltitude = 200.0, - targetHeading = 105.0, - targetTilt = 65.0, - targetRange = 600.0, - durationMs = 3000L - ) - ) - - // 15 Fine-Grained Waypoints on the direct route from Golden Gate Bridge to Coit Tower - val AIRPLANE_FLIGHT_PATH = listOf( - LatLng(37.8199, -122.4783), // 1. Golden Gate Bridge (Source) - LatLng(37.8188, -122.4735), // 2. Fort Point / Presidio Overlook - LatLng(37.8175, -122.4685), // 3. Crissy Field West - LatLng(37.8160, -122.4635), // 4. Crissy Field East - LatLng(37.8145, -122.4585), // 5. Marina Green West - LatLng(37.8130, -122.4530), // 6. Marina District Center - LatLng(37.8115, -122.4475), // 7. Fort Mason West - LatLng(37.8100, -122.4420), // 8. Fort Mason Heights - LatLng(37.8085, -122.4365), // 9. Aquatic Park Cove - LatLng(37.8070, -122.4310), // 10. Fisherman's Wharf West - LatLng(37.8058, -122.4250), // 11. Fisherman's Wharf Center - LatLng(37.8048, -122.4195), // 12. Pier 39 Promenade - LatLng(37.8038, -122.4140), // 13. Embarcadero North - LatLng(37.8030, -122.4090), // 14. Telegraph Hill Slopes - LatLng(37.8024, -122.4058) // 15. Coit Tower (Destination) - ) - - private fun normalizeHeading(headingDeg: Double): Double { - val normalized = headingDeg % 360.0 - return if (normalized < 0.0) normalized + 360.0 else normalized - } - - private fun interpolateAngle(start: Double, end: Double, fraction: Double): Double { - var 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 - } - } } diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt index a7defd7..6dc501a 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt @@ -27,7 +27,8 @@ import androidx.appcompat.app.AppCompatActivity import androidx.cardview.widget.CardView import androidx.core.graphics.toColorInt import androidx.core.view.WindowCompat -import com.example.maps3d.common.toHeading +import com.example.maps3d.common.PathData +import com.example.maps3d.common.PathEngine import com.example.maps3dcommon.R import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps3d.GoogleMap3D @@ -42,27 +43,23 @@ import com.google.android.gms.maps3d.model.latLngAltitude import com.google.android.material.button.MaterialButton import com.google.android.material.materialswitch.MaterialSwitch import com.google.android.material.slider.Slider -import com.google.maps.android.SphericalUtil /** * Advanced sample demonstrating ground-level path following in Kotlin. * - * Features: - * - Urban vs Rural ground-level paths - * - Two-polyline architecture: wide blue base route (lower z-index) + narrow purple active progress route (higher z-index) - * - In-place polyline ID updates eliminating render flickering - * - Configurable altitude modes (Clamp to Ground default, Relative to Ground, Relative to Mesh, Absolute) - * - Dynamic path elevation slider to eliminate z-fighting - * - Occlusion visualization toggle (drawsOccludedSegments) rendering polylines through or behind 3D terrain and buildings - * - Explicit collapse dialog button and smooth slide-down controls - * - Real-time camera controls via sliders: Range, Ground Altitude, Heading Offset, Tilt, Follow Speed + * Key Concepts Demonstrated: + * 1. Dual-Polyline Architecture: Layered base route (lower z-index) and traversed progress route (higher z-index). + * 2. In-Place Polyline ID Updates: Stable IDs prevent render flickering during rapid real-time updates. + * 3. 3D Altitude Modes: Dynamic switching between Clamp to Ground, Relative, and Absolute elevation. + * 4. Occlusion Control: Toggling [PolylineOptions.setDrawsOccludedSegments] through terrain and buildings. + * 5. Kinematic Heading Smoothing: Exponential moving average low-pass filter to smooth camera cornering. */ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { private lateinit var map3DView: Map3DView private var googleMap3D: GoogleMap3D? = null - // View Bindings + // Control panel overlay bindings private var controlsCard: CardView? = null private var cardHeader: View? = null private var btnCollapse: MaterialButton? = null @@ -86,64 +83,6 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { private lateinit var speedSlider: Slider private lateinit var speedSliderLabel: TextView - private val fadeHandler = Handler(Looper.getMainLooper()) - private val fadeOutRunnable = Runnable { - if (controlsCard != null && !isCollapsed) { - controlsCard?.animate() - ?.alpha(0.8f) - ?.setDuration(400) - ?.start() - } - } - - private fun collapseControls() { - val card = controlsCard ?: return - isCollapsed = true - fadeHandler.removeCallbacks(fadeOutRunnable) - btnCollapse?.setIconResource(R.drawable.expand_less_24px) - btnCollapse?.contentDescription = getString(R.string.expand_controls) - - val headerHeight = if (cardHeader != null && cardHeader!!.height > 0) { - cardHeader!!.height - } else { - (48 * resources.displayMetrics.density).toInt() - } - val targetTranslationY = (card.height - headerHeight).coerceAtLeast(0).toFloat() - card.animate() - .translationY(targetTranslationY) - .alpha(0.9f) - .setDuration(300) - .start() - } - - private fun expandControls() { - val card = controlsCard ?: return - isCollapsed = false - btnCollapse?.setIconResource(R.drawable.expand_more_24px) - btnCollapse?.contentDescription = getString(R.string.collapse_controls) - card.animate() - .translationY(0f) - .alpha(1.0f) - .setDuration(250) - .start() - fadeHandler.removeCallbacks(fadeOutRunnable) - fadeHandler.postDelayed(fadeOutRunnable, 3000L) - } - - override fun dispatchTouchEvent(ev: MotionEvent): Boolean { - if (ev.action == MotionEvent.ACTION_DOWN || ev.action == 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) - } - // Control parameters private var cameraRange = 300.0 private var groundAltitude = 20.0 @@ -155,20 +94,30 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { private var drawsOccludedSegments: Boolean = true // Path state - private var currentPath: List = URBAN_PATH + private var currentPath: List = PathData.URBAN_PATH private var cumulativeDistances: DoubleArray = doubleArrayOf() private var totalDistance: Double = 0.0 private var elapsedDistance: Double = 0.0 private var isPlaying = false private var isUserScrubbing = false + private var currentHeading: Double? = null + + private val baseAltitude: Double + get() = if (currentPath == PathData.RURAL_PATH) 45.0 else 50.0 // Polyline handles private var staticRoutePolyline: Polyline? = null private var progressPolyline: Polyline? = null - // Animation Loop + // Animation & auto-fade handlers private var animationRunnable: Runnable? = null private val animationHandler = Handler(Looper.getMainLooper()) + private val fadeHandler = Handler(Looper.getMainLooper()) + private val fadeOutRunnable = Runnable { + if (controlsCard != null && !isCollapsed) { + controlsCard?.animate()?.alpha(0.8f)?.setDuration(400)?.start() + } + } override fun onCreate(savedInstanceState: Bundle?) { WindowCompat.setDecorFitsSystemWindows(window, false) @@ -180,39 +129,7 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { map3DView.getMap3DViewAsync(this) initViews() - loadPathData(URBAN_PATH) - } - - override fun onResume() { - super.onResume() - map3DView.onResume() - } - - override fun onPause() { - super.onPause() - map3DView.onPause() - pauseAnimation() - } - - override fun onDestroy() { - super.onDestroy() - pauseAnimation() - fadeHandler.removeCallbacks(fadeOutRunnable) - staticRoutePolyline?.remove() - staticRoutePolyline = null - progressPolyline?.remove() - progressPolyline = null - map3DView.onDestroy() - } - - override fun onLowMemory() { - super.onLowMemory() - map3DView.onLowMemory() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - map3DView.onSaveInstanceState(outState) + loadPath(PathData.URBAN_PATH) } override fun onMap3DViewReady(googleMap3D: GoogleMap3D) { @@ -222,9 +139,7 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { updateCameraPositionForDistance(0.0) } - // Schedule auto-fade for idle control panel after initial display - fadeHandler.removeCallbacks(fadeOutRunnable) - fadeHandler.postDelayed(fadeOutRunnable, 3000L) + scheduleControlsFade() } private fun initViews() { @@ -232,21 +147,8 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { cardHeader = findViewById(R.id.card_header) btnCollapse = findViewById(R.id.btn_collapse) - btnCollapse?.setOnClickListener { - if (isCollapsed) { - expandControls() - } else { - collapseControls() - } - } - - cardHeader?.setOnClickListener { - if (isCollapsed) { - expandControls() - } else { - collapseControls() - } - } + btnCollapse?.setOnClickListener { toggleControlsCard() } + cardHeader?.setOnClickListener { toggleControlsCard() } rgEnvironment = findViewById(R.id.rg_environment) rgAltitudeMode = findViewById(R.id.rg_altitude_mode) @@ -266,15 +168,17 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { speedSlider = findViewById(R.id.speed_slider) speedSliderLabel = findViewById(R.id.speed_slider_label) - // Environment toggle + setupEventListeners() + } + + private fun setupEventListeners() { rgEnvironment.setOnCheckedChangeListener { _, checkedId -> when (checkedId) { - R.id.rb_urban -> switchEnvironment(URBAN_PATH) - R.id.rb_rural -> switchEnvironment(RURAL_PATH) + R.id.rb_urban -> switchEnvironment(PathData.URBAN_PATH) + R.id.rb_rural -> switchEnvironment(PathData.RURAL_PATH) } } - // Altitude mode toggle rgAltitudeMode.setOnCheckedChangeListener { _, checkedId -> pathAltitudeMode = when (checkedId) { R.id.rb_clamp_to_ground -> AltitudeMode.CLAMP_TO_GROUND @@ -283,49 +187,25 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { R.id.rb_absolute -> AltitudeMode.ABSOLUTE else -> AltitudeMode.CLAMP_TO_GROUND } - staticRoutePolyline?.remove() - staticRoutePolyline = null - progressPolyline?.remove() - progressPolyline = null - drawPathPolylines() - updateCameraPositionForDistance(elapsedDistance) + redrawPolylinesAndCamera() } - // Draw occluded segments toggle switchDrawsOccludedSegments.isChecked = drawsOccludedSegments switchDrawsOccludedSegments.setOnCheckedChangeListener { _, isChecked -> drawsOccludedSegments = isChecked - staticRoutePolyline?.remove() - staticRoutePolyline = null - progressPolyline?.remove() - progressPolyline = null - drawPathPolylines() - updateCameraPositionForDistance(elapsedDistance) + redrawPolylinesAndCamera() } - // Path height slider (0.0m - 10.0m) pathAltitudeSlider.addOnChangeListener { _, value, _ -> pathAltitudeOffset = value.toDouble() - pathAltitudeSliderLabel.text = - getString(R.string.path_height_format, pathAltitudeOffset) - staticRoutePolyline?.remove() - staticRoutePolyline = null - progressPolyline?.remove() - progressPolyline = null - drawPathPolylines() - updateCameraPositionForDistance(elapsedDistance) + pathAltitudeSliderLabel.text = getString(R.string.path_height_format, pathAltitudeOffset) + redrawPolylinesAndCamera() } - // Play/Pause button btnPlayPause.setOnClickListener { - if (isPlaying) { - pauseAnimation() - } else { - startAnimation() - } + if (isPlaying) pauseAnimation() else startAnimation() } - // Progress scrub slider progressSlider.addOnSliderTouchListener(object : Slider.OnSliderTouchListener { override fun onStartTrackingTouch(slider: Slider) { isUserScrubbing = true @@ -345,55 +225,44 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { } } - // Camera Range Slider (50m - 1000m) rangeSlider.addOnChangeListener { _, value, _ -> cameraRange = value.toDouble() rangeSliderLabel.text = getString(R.string.camera_range_format, cameraRange.toInt()) updateCameraPositionForDistance(elapsedDistance) } - // Ground Altitude Slider (2m - 200m) altitudeSlider.addOnChangeListener { _, value, _ -> groundAltitude = value.toDouble() - altitudeSliderLabel.text = - getString(R.string.ground_altitude_format, groundAltitude.toInt()) + altitudeSliderLabel.text = getString(R.string.ground_altitude_format, groundAltitude.toInt()) updateCameraPositionForDistance(elapsedDistance) } - // Heading Offset Slider (-180° - +180°) headingSlider.addOnChangeListener { _, value, _ -> headingOffset = value.toDouble() headingSliderLabel.text = getString(R.string.heading_offset_format, headingOffset.toInt()) updateCameraPositionForDistance(elapsedDistance) } - // Camera Tilt Slider (0° - 85°) tiltSlider.addOnChangeListener { _, value, _ -> cameraTilt = value.toDouble() tiltSliderLabel.text = getString(R.string.camera_tilt_format, cameraTilt.toInt()) updateCameraPositionForDistance(elapsedDistance) } - // Follow Speed Slider (5 m/s - 100 m/s) speedSlider.addOnChangeListener { _, value, _ -> followSpeedMps = value.toDouble() speedSliderLabel.text = getString(R.string.follow_speed_format, followSpeedMps.toInt()) } } - private var currentHeading: Double? = null - private fun switchEnvironment(path: List) { pauseAnimation() currentHeading = null elapsedDistance = 0.0 progressSlider.value = 0f - progressPolyline?.remove() - progressPolyline = null - staticRoutePolyline?.remove() - staticRoutePolyline = null + clearPolylines() - if (path == RURAL_PATH) { + if (path == PathData.RURAL_PATH) { cameraRange = 450.0 groundAltitude = 40.0 cameraTilt = 75.0 @@ -415,23 +284,15 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { altitudeSliderLabel.text = getString(R.string.ground_altitude_format, groundAltitude.toInt()) tiltSliderLabel.text = getString(R.string.camera_tilt_format, cameraTilt.toInt()) - loadPathData(path) + loadPath(path) drawPathPolylines() updateCameraPositionForDistance(0.0) } - private fun loadPathData(path: List) { + private fun loadPath(path: List) { currentPath = path - cumulativeDistances = DoubleArray(path.size) - totalDistance = 0.0 - cumulativeDistances[0] = 0.0 - for (i in 1 until path.size) { - val pPrev = LatLng(path[i - 1].latitude, path[i - 1].longitude) - val pCurr = LatLng(path[i].latitude, path[i].longitude) - val dist = SphericalUtil.computeDistanceBetween(pPrev, pCurr) - totalDistance += dist - cumulativeDistances[i] = totalDistance - } + cumulativeDistances = PathEngine.calculateCumulativeDistances(path) + totalDistance = cumulativeDistances.lastOrNull() ?: 0.0 } private fun drawPathPolylines() { @@ -446,25 +307,16 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { val map = googleMap3D ?: return if (currentPath.isEmpty()) return - val staticVertices = currentPath.map { pt -> - val vertexAltitude = when (pathAltitudeMode) { - AltitudeMode.CLAMP_TO_GROUND -> 0.0 - AltitudeMode.ABSOLUTE -> pt.altitude + (if (currentPath == RURAL_PATH) 45.0 else 50.0) + pathAltitudeOffset - else -> pt.altitude + pathAltitudeOffset - } - LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude) - } - + val vertices = PathEngine.buildStaticVertices(currentPath, pathAltitudeMode, baseAltitude, pathAltitudeOffset) val staticOptions = PolylineOptions().apply { - id = STATIC_ROUTE_POLYLINE_ID - path = staticVertices - strokeColor = "#4285F4".toColorInt() // Wide blue route + id = PathEngine.STATIC_POLYLINE_ID + path = vertices + strokeColor = "#4285F4".toColorInt() // Wide blue route (16dp) strokeWidth = 16.0 zIndex = 1 altitudeMode = pathAltitudeMode drawsOccludedSegments = this@PathFollowingActivity.drawsOccludedSegments } - staticRoutePolyline = map.addPolyline(staticOptions) } @@ -472,72 +324,65 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { val map = googleMap3D ?: return if (currentPath.isEmpty() || totalDistance <= 0.0) return - val progressCoordinates = ArrayList() - for (i in 0..index.coerceAtMost(currentPath.size - 1)) { - val pt = currentPath[i] - val vertexAltitude = when (pathAltitudeMode) { - AltitudeMode.CLAMP_TO_GROUND -> 0.0 - AltitudeMode.ABSOLUTE -> pt.altitude + (if (currentPath == RURAL_PATH) 45.0 else 50.0) + pathAltitudeOffset + 0.4 - else -> pt.altitude + pathAltitudeOffset + 0.4 - } - progressCoordinates.add(LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude)) - } - - val lastWaypoint = currentPath[index.coerceAtMost(currentPath.size - 1)] - val lastLatLng = LatLng(lastWaypoint.latitude, lastWaypoint.longitude) - val distToLast = SphericalUtil.computeDistanceBetween(lastLatLng, currentLatLng) - if (distToLast >= 0.5) { - val p1 = currentPath[index.coerceAtMost(currentPath.size - 1)] - val p2 = if (index < currentPath.size - 1) currentPath[index + 1] else p1 - val segStartDist = cumulativeDistances.getOrElse(index) { 0.0 } - val segEndDist = cumulativeDistances.getOrElse(index + 1) { totalDistance } - val segLen = segEndDist - segStartDist - val fraction = if (segLen > 0) ((dist - segStartDist) / segLen).coerceIn(0.0, 1.0) else 0.0 - val interpAlt = p1.altitude + fraction * (p2.altitude - p1.altitude) - val progressAltitude = when (pathAltitudeMode) { - AltitudeMode.CLAMP_TO_GROUND -> 0.0 - AltitudeMode.ABSOLUTE -> interpAlt + (if (currentPath == RURAL_PATH) 45.0 else 50.0) + pathAltitudeOffset + 0.4 - else -> interpAlt + pathAltitudeOffset + 0.4 - } - progressCoordinates.add( - LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, progressAltitude) - ) - } - - if (progressCoordinates.size < 2) { - if (currentPath.size >= 2) { - val p0 = LatLng(currentPath[0].latitude, currentPath[0].longitude) - val p1 = LatLng(currentPath[1].latitude, currentPath[1].longitude) - val tinyForward = SphericalUtil.interpolate(p0, p1, 0.005) - val startAlt = when (pathAltitudeMode) { - AltitudeMode.CLAMP_TO_GROUND -> 0.0 - AltitudeMode.ABSOLUTE -> currentPath[0].altitude + (if (currentPath == RURAL_PATH) 45.0 else 50.0) + pathAltitudeOffset + 0.4 - else -> currentPath[0].altitude + pathAltitudeOffset + 0.4 - } - progressCoordinates.add( - LatLngAltitude(tinyForward.latitude, tinyForward.longitude, startAlt) - ) - } - } + val vertices = PathEngine.buildProgressVertices( + currentPath, + cumulativeDistances, + dist, + currentLatLng, + index, + pathAltitudeMode, + baseAltitude, + pathAltitudeOffset + ) - // 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. - // Fixed PROGRESS_POLYLINE_ID ensures in-place upsert in Maps 3D engine without duplication. + // In-place ID upsert updates the existing line in the 3D renderer without recreating objects val progressOptions = PolylineOptions().apply { - id = PROGRESS_POLYLINE_ID - path = progressCoordinates - strokeColor = "#9C27B0".toColorInt() // Narrow purple progress + id = PathEngine.PROGRESS_POLYLINE_ID + path = vertices + strokeColor = "#9C27B0".toColorInt() // Narrow purple progress (8dp) strokeWidth = 8.0 zIndex = 2 altitudeMode = pathAltitudeMode drawsOccludedSegments = this@PathFollowingActivity.drawsOccludedSegments } - progressPolyline = map.addPolyline(progressOptions) } + private fun updateCameraPositionForDistance(dist: Double) { + val map = googleMap3D ?: return + if (currentPath.isEmpty()) return + + val point = PathEngine.interpolatePoint(currentPath, cumulativeDistances, dist) + val targetHeading = PathEngine.smoothHeading( + point.bearing + headingOffset, + currentHeading, + isUserScrubbing, + isPlaying + ) + currentHeading = targetHeading + + val cameraTargetAltitude = PathEngine.calculateCameraAltitude( + pathAltitudeMode, + baseAltitude, + point.altitude, + groundAltitude + ) + + val newCamera = camera { + center = latLngAltitude { + latitude = point.latLng.latitude + longitude = point.latLng.longitude + altitude = cameraTargetAltitude + } + heading = targetHeading + tilt = cameraTilt + range = cameraRange + } + + map.setCamera(newCamera) + updateProgressPolyline(dist, point.latLng, point.waypointIndex) + } + private fun startAnimation() { if (isPlaying) return isPlaying = true @@ -551,21 +396,16 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { if (!isPlaying) return val 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. val dt = (now - lastTime) / 1000.0 lastTime = now - val stepDistance = followSpeedMps * dt - elapsedDistance += stepDistance - + elapsedDistance += followSpeedMps * dt if (elapsedDistance >= totalDistance) { elapsedDistance = 0.0 } if (!isUserScrubbing && totalDistance > 0) { - val progress = (elapsedDistance / totalDistance).toFloat().coerceIn(0f, 1f) - progressSlider.value = progress + progressSlider.value = (elapsedDistance / totalDistance).toFloat().coerceIn(0f, 1f) } updateCameraPositionForDistance(elapsedDistance) @@ -584,125 +424,89 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { } } - private fun updateCameraPositionForDistance(dist: Double) { - val map = googleMap3D ?: return - if (currentPath.isEmpty()) return + private fun redrawPolylinesAndCamera() { + clearPolylines() + drawPathPolylines() + updateCameraPositionForDistance(elapsedDistance) + } - var index = 0 - while (index < cumulativeDistances.size - 1 && cumulativeDistances[index + 1] < dist) { - index++ - } + private fun clearPolylines() { + staticRoutePolyline?.remove() + staticRoutePolyline = null + progressPolyline?.remove() + progressPolyline = null + } - val p1 = currentPath[index] - val p2 = if (index < currentPath.size - 1) currentPath[index + 1] else p1 - - val segStartDist = cumulativeDistances[index] - val segEndDist = - if (index < cumulativeDistances.size - 1) cumulativeDistances[index + 1] else totalDistance - val segLen = segEndDist - segStartDist - - val fraction = if (segLen > 0) ((dist - segStartDist) / segLen).coerceIn(0.0, 1.0) else 0.0 - val latLng1 = LatLng(p1.latitude, p1.longitude) - val latLng2 = LatLng(p2.latitude, p2.longitude) - val currentLatLng = SphericalUtil.interpolate(latLng1, latLng2, fraction) - val bearing = SphericalUtil.computeHeading(latLng1, latLng2) - - // 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. - val targetHeadingRaw = (bearing + headingOffset).toHeading() - val targetHeading = if (currentHeading == null || isUserScrubbing || !isPlaying) { - targetHeadingRaw - } else { - var diff = (targetHeadingRaw - currentHeading!!) % 360.0 - if (diff > 180.0) diff -= 360.0 - if (diff < -180.0) diff += 360.0 - (currentHeading!! + diff * 0.12).toHeading() - } - currentHeading = targetHeading + private fun toggleControlsCard() { + if (isCollapsed) expandControls() else collapseControls() + } - val interpAlt = p1.altitude + fraction * (p2.altitude - p1.altitude) - val cameraTargetAltitude = if (pathAltitudeMode == AltitudeMode.ABSOLUTE) { - val baseAlt = if (currentPath == RURAL_PATH) 45.0 else 50.0 - baseAlt + interpAlt + groundAltitude + private fun collapseControls() { + val card = controlsCard ?: return + isCollapsed = true + fadeHandler.removeCallbacks(fadeOutRunnable) + btnCollapse?.setIconResource(R.drawable.expand_less_24px) + btnCollapse?.contentDescription = getString(R.string.expand_controls) + + val headerHeight = if (cardHeader != null && cardHeader!!.height > 0) { + cardHeader!!.height } else { - groundAltitude + (48 * resources.displayMetrics.density).toInt() } + val targetTranslationY = (card.height - headerHeight).coerceAtLeast(0).toFloat() + card.animate().translationY(targetTranslationY).alpha(0.9f).setDuration(300).start() + } - val newCamera = camera { - center = latLngAltitude { - latitude = currentLatLng.latitude - longitude = currentLatLng.longitude - altitude = cameraTargetAltitude + private fun expandControls() { + val card = controlsCard ?: return + isCollapsed = false + btnCollapse?.setIconResource(R.drawable.expand_more_24px) + btnCollapse?.contentDescription = getString(R.string.collapse_controls) + card.animate().translationY(0f).alpha(1.0f).setDuration(250).start() + scheduleControlsFade() + } + + private fun scheduleControlsFade() { + fadeHandler.removeCallbacks(fadeOutRunnable) + fadeHandler.postDelayed(fadeOutRunnable, 3000L) + } + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + if (ev.action == MotionEvent.ACTION_DOWN || ev.action == MotionEvent.ACTION_MOVE) { + if (controlsCard != null && !isCollapsed) { + controlsCard?.animate()?.alpha(1.0f)?.setDuration(150)?.start() + scheduleControlsFade() } - heading = targetHeading - tilt = cameraTilt - range = cameraRange } + return super.dispatchTouchEvent(ev) + } - map.setCamera(newCamera) - updateProgressPolyline(dist, currentLatLng, index) + override fun onResume() { + super.onResume() + map3DView.onResume() } - companion object { - private const val STATIC_ROUTE_POLYLINE_ID = "path_following_static_route" - private const val PROGRESS_POLYLINE_ID = "path_following_progress_route" - - // Urban Path (San Francisco Downtown - Market Street Corridor with 1 to 10m altitudes) - val URBAN_PATH = listOf( - LatLngAltitude(37.79323, -122.39322, 4.2), - LatLngAltitude(37.79166, -122.39519, 6.7), - LatLngAltitude(37.79124, -122.39571, 8.1), - LatLngAltitude(37.79105, -122.39599, 9.5), - LatLngAltitude(37.78893, -122.39866, 7.3), - LatLngAltitude(37.78742, -122.40060, 5.0), - LatLngAltitude(37.78686, -122.40129, 3.4), - LatLngAltitude(37.78652, -122.40171, 2.1), - LatLngAltitude(37.78632, -122.40196, 4.6), - LatLngAltitude(37.78627, -122.40207, 6.2), - LatLngAltitude(37.78453, -122.40429, 8.9), - LatLngAltitude(37.78443, -122.40434, 10.0), - LatLngAltitude(37.78155, -122.40802, 7.8), - LatLngAltitude(37.78005, -122.40990, 5.4), - LatLngAltitude(37.77856, -122.41180, 3.1), - LatLngAltitude(37.77746, -122.41318, 1.8), - LatLngAltitude(37.77624, -122.41474, 4.0), - LatLngAltitude(37.77744, -122.41623, 6.5), - LatLngAltitude(37.77749, -122.41636, 8.7), - LatLngAltitude(37.77761, -122.41654, 9.8), - LatLngAltitude(37.77769, -122.41677, 7.2), - LatLngAltitude(37.77729, -122.41981, 4.9), - LatLngAltitude(37.77523, -122.41938, 2.6), - LatLngAltitude(37.77510, -122.41934, 1.2), - LatLngAltitude(37.77442, -122.42022, 3.5), - LatLngAltitude(37.77441, -122.42033, 5.8), - LatLngAltitude(37.77348, -122.42157, 8.4), - LatLngAltitude(37.77244, -122.42289, 10.0) - ) + override fun onPause() { + super.onPause() + map3DView.onPause() + pauseAnimation() + } - // Rural Path - val RURAL_PATH = listOf( - LatLngAltitude(37.254529, -122.380897, 0.0), - LatLngAltitude(37.255065, -122.381627, 0.0), - LatLngAltitude(37.257540, -122.383720, 0.0), - LatLngAltitude(37.261200, -122.383950, 0.0), - LatLngAltitude(37.264780, -122.388210, 0.0), - LatLngAltitude(37.268520, -122.392450, 0.0), - LatLngAltitude(37.272110, -122.397640, 0.0), - LatLngAltitude(37.276430, -122.401120, 0.0), - LatLngAltitude(37.280850, -122.403560, 0.0), - LatLngAltitude(37.286018, -122.405072, 0.0), - LatLngAltitude(37.291040, -122.404210, 0.0), - LatLngAltitude(37.295800, -122.401980, 0.0), - LatLngAltitude(37.300120, -122.399540, 0.0), - LatLngAltitude(37.304550, -122.397210, 0.0), - LatLngAltitude(37.309200, -122.395100, 0.0), - LatLngAltitude(37.313450, -122.392840, 0.0), - LatLngAltitude(37.317200, -122.390510, 0.0), - LatLngAltitude(37.320850, -122.388740, 0.0), - LatLngAltitude(37.323540, -122.387600, 0.0), - LatLngAltitude(37.325269, -122.386728, 0.0) - ) + override fun onDestroy() { + super.onDestroy() + pauseAnimation() + fadeHandler.removeCallbacks(fadeOutRunnable) + clearPolylines() + map3DView.onDestroy() + } + + override fun onLowMemory() { + super.onLowMemory() + map3DView.onLowMemory() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + map3DView.onSaveInstanceState(outState) } } diff --git a/Maps3DSamples/ComposeDemos/app/build.gradle.kts b/Maps3DSamples/ComposeDemos/app/build.gradle.kts index a78fba5..0aa1605 100644 --- a/Maps3DSamples/ComposeDemos/app/build.gradle.kts +++ b/Maps3DSamples/ComposeDemos/app/build.gradle.kts @@ -59,6 +59,7 @@ android { } } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } @@ -79,7 +80,10 @@ android { } dependencies { + coreLibraryDesugaring(libs.desugar.jdk.libs) + implementation(project(":maps3d-compose")) + implementation(project(":Maps3DSamples:ApiDemos:common")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/advancedcameraanimation/AdvancedCameraAnimationActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/advancedcameraanimation/AdvancedCameraAnimationActivity.kt index c5de808..63296d4 100644 --- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/advancedcameraanimation/AdvancedCameraAnimationActivity.kt +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/advancedcameraanimation/AdvancedCameraAnimationActivity.kt @@ -45,6 +45,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -52,13 +53,15 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import com.example.composedemos.routes.RouteEngine +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.example.maps3d.common.CameraKeyframe +import com.example.maps3d.common.RouteEngine +import com.example.maps3d.common.TourData import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps3d.GoogleMap3D import com.google.android.gms.maps3d.model.AltitudeMode @@ -103,40 +106,6 @@ enum class AnimationApproach(val title: String) { ORBIT_360_SPIN("4. 360° Orbit Spin (Continuous Orbital Camera)"), } -sealed interface CameraKeyframe { - val stepTitle: String - val stepDescription: String - - data class FlyTo( - override val stepTitle: String, - override val stepDescription: String, - val targetCenter: LatLng, - val targetAltitude: Double, - val targetHeading: Double, - val targetTilt: Double, - val targetRange: Double, - val durationMs: Long = 2500L, - ) : CameraKeyframe - - data class DwellPause( - override val stepTitle: String, - override val stepDescription: String, - val durationMs: Long = 1500L, - ) : CameraKeyframe - - data class OrbitAround( - override val stepTitle: String, - override val stepDescription: String, - val center: LatLng, - val altitude: Double, - val range: Double, - val tilt: Double, - val startHeading: Double, - val endHeading: Double, - val durationMs: Long = 4000L, - ) : CameraKeyframe -} - class AdvancedCameraAnimationActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -154,111 +123,51 @@ class AdvancedCameraAnimationActivity : ComponentActivity() { } companion object { - val SAN_FRANCISCO_TOUR = listOf( - CameraKeyframe.FlyTo( - stepTitle = "1. Golden Gate Bridge Flight", - stepDescription = "3D Airplane flight over Golden Gate Bridge", - targetCenter = LatLng(37.8199, -122.4783), - targetAltitude = 200.0, - targetHeading = 105.0, - targetTilt = 65.0, - targetRange = 600.0, - durationMs = 2500L, - ), - CameraKeyframe.DwellPause( - stepTitle = "2. Mid-Air Observation", - stepDescription = "Dwell pause observing 3D airplane over Golden Gate", - durationMs = 1500L, - ), - CameraKeyframe.OrbitAround( - stepTitle = "3. Golden Gate 360° Orbit", - stepDescription = "360° orbital camera spin around flying airplane", - center = LatLng(37.8199, -122.4783), - altitude = 200.0, - range = 600.0, - tilt = 65.0, - startHeading = 105.0, - endHeading = 465.0, - durationMs = 4000L, - ), - CameraKeyframe.FlyTo( - stepTitle = "4. Transit to Coit Tower", - stepDescription = "Airplane flight to Coit Tower Landmark", - targetCenter = LatLng(37.8024, -122.4058), - targetAltitude = 200.0, - targetHeading = 105.0, - targetTilt = 65.0, - targetRange = 600.0, - durationMs = 3000L, - ), - ) - - // 15 Fine-Grained Waypoints on the direct route from Golden Gate Bridge to Coit Tower - val AIRPLANE_FLIGHT_PATH = listOf( - LatLng(37.8199, -122.4783), // 1. Golden Gate Bridge (Source) - LatLng(37.8188, -122.4735), // 2. Fort Point / Presidio Overlook - LatLng(37.8175, -122.4685), // 3. Crissy Field West - LatLng(37.8160, -122.4635), // 4. Crissy Field East - LatLng(37.8145, -122.4585), // 5. Marina Green West - LatLng(37.8130, -122.4530), // 6. Marina District Center - LatLng(37.8115, -122.4475), // 7. Fort Mason West - LatLng(37.8100, -122.4420), // 8. Fort Mason Heights - LatLng(37.8085, -122.4365), // 9. Aquatic Park Cove - LatLng(37.8070, -122.4310), // 10. Fisherman's Wharf West - LatLng(37.8058, -122.4250), // 11. Fisherman's Wharf Center - LatLng(37.8048, -122.4195), // 12. Pier 39 Promenade - LatLng(37.8038, -122.4140), // 13. Embarcadero North - LatLng(37.8030, -122.4090), // 14. Telegraph Hill Slopes - LatLng(37.8024, -122.4058), // 15. Coit Tower (Destination) - ) + val SAN_FRANCISCO_TOUR = TourData.SAN_FRANCISCO_TOUR + val AIRPLANE_FLIGHT_PATH = TourData.AIRPLANE_FLIGHT_PATH } } @Composable fun AdvancedCameraAnimationScreen() { - val tourSequence = AdvancedCameraAnimationActivity.SAN_FRANCISCO_TOUR - val flightPath = AdvancedCameraAnimationActivity.AIRPLANE_FLIGHT_PATH - val scope = rememberCoroutineScope() + val tourSequence = TourData.SAN_FRANCISCO_TOUR + val flightPath = TourData.AIRPLANE_FLIGHT_PATH + val cumulativeDistances = remember(flightPath) { + RouteEngine.calculateCumulativeDistances(flightPath) + } - var mapInstance by remember { mutableStateOf(null) } var selectedApproach by remember { mutableStateOf(AnimationApproach.DISPATCHER_FRAME_LOOP) } + var currentStepIndex by remember { mutableIntStateOf(0) } + var isPlaying by remember { mutableStateOf(false) } - val initialStep = tourSequence.first() as CameraKeyframe.FlyTo - var currentCameraState by remember { - mutableStateOf( - camera { - center = latLngAltitude { - latitude = initialStep.targetCenter.latitude - longitude = initialStep.targetCenter.longitude - altitude = initialStep.targetAltitude - } - heading = 105.0 - tilt = initialStep.targetTilt - range = initialStep.targetRange - }, - ) - } + var mapInstance by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + var tourJob by remember { mutableStateOf(null) } + var restartJob by remember { mutableStateOf(null) } var planePosition by remember { + val start = flightPath.first() mutableStateOf( latLngAltitude { - latitude = flightPath.first().latitude - longitude = flightPath.first().longitude + latitude = start.latitude + longitude = start.longitude altitude = 200.0 }, ) } + var planeHeading by remember { - val rawH = SphericalUtil.computeHeading(flightPath.first(), flightPath[1]) - mutableStateOf(normalizeHeading(rawH + 180.0)) + val h = SphericalUtil.computeHeading(flightPath.first(), flightPath[1]) + mutableDoubleStateOf(normalizeHeading(h + 180.0)) } val activeModels = remember(planePosition, planeHeading) { listOf( ModelConfig( - key = "airplane_model", + key = TourData.MODEL_ID, position = planePosition, - url = "https://storage.googleapis.com/gmp-maps-demos/p3d-map/assets/Airplane.glb", + url = TourData.PLANE_URL, altitudeMode = AltitudeMode.ABSOLUTE, scale = ModelScale.Uniform(0.08f), heading = planeHeading, @@ -268,19 +177,27 @@ fun AdvancedCameraAnimationScreen() { ) } - var currentStepIndex by remember { mutableIntStateOf(0) } - var isPlaying by remember { mutableStateOf(false) } - var tourJob by remember { mutableStateOf(null) } - var restartJob by remember { mutableStateOf(null) } - - val cumulativeDistances: DoubleArray = remember(flightPath) { - RouteEngine.calculateCumulativeDistances(flightPath) + var currentCameraState by remember { + val firstLoc = flightPath.first() + val initialHeading = SphericalUtil.computeHeading(firstLoc, flightPath[1]) + mutableStateOf( + camera { + center = latLngAltitude { + latitude = firstLoc.latitude + longitude = firstLoc.longitude + altitude = 200.0 + } + heading = normalizeHeading(initialHeading) + tilt = 65.0 + range = 600.0 + }, + ) } val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { + if (event == Lifecycle.Event.ON_PAUSE) { isPlaying = false tourJob?.cancel() tourJob = null @@ -292,9 +209,6 @@ fun AdvancedCameraAnimationScreen() { lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) - tourJob?.cancel() - restartJob?.cancel() - mapInstance?.stopCameraAnimation() } } @@ -313,7 +227,11 @@ fun AdvancedCameraAnimationScreen() { isPlaying = true val targetLoc = flightPath.last() - val flightHeading = SphericalUtil.computeHeading(flightPath[flightPath.size - 2], targetLoc) + val flightHeading = SphericalUtil.computeHeading( + flightPath[flightPath.size - 2], + targetLoc, + ) + planePosition = latLngAltitude { latitude = targetLoc.latitude longitude = targetLoc.longitude @@ -331,6 +249,7 @@ fun AdvancedCameraAnimationScreen() { tilt = 65.0 range = 600.0 } + tourJob = scope.launch(Dispatchers.Main) { mapInstance?.awaitFlyCameraTo(FlyToOptions(targetCam, 1500L)) isPlaying = false @@ -344,9 +263,9 @@ fun AdvancedCameraAnimationScreen() { tourJob = scope.launch(Dispatchers.Main) { val frameDurationMs = 16L + while (currentStepIndex < tourSequence.size && isActive && isPlaying) { - val step = tourSequence[currentStepIndex] - when (step) { + when (val step = tourSequence[currentStepIndex]) { is CameraKeyframe.FlyTo -> { val targetCam = camera { center = latLngAltitude { @@ -358,12 +277,14 @@ fun AdvancedCameraAnimationScreen() { tilt = step.targetTilt range = step.targetRange } + planePosition = latLngAltitude { latitude = step.targetCenter.latitude longitude = step.targetCenter.longitude altitude = 200.0 } planeHeading = normalizeHeading(step.targetHeading + 180.0) + mapInstance?.awaitFlyCameraTo(FlyToOptions(targetCam, step.durationMs)) } @@ -372,8 +293,14 @@ fun AdvancedCameraAnimationScreen() { } is CameraKeyframe.OrbitAround -> { - val totalMs = step.durationMs + planePosition = latLngAltitude { + latitude = step.center.latitude + longitude = step.center.longitude + altitude = 200.0 + } val startTimeMs = SystemClock.uptimeMillis() + val totalMs = step.durationMs + while (isActive && isPlaying) { val elapsedMs = SystemClock.uptimeMillis() - startTimeMs if (elapsedMs > totalMs) break @@ -381,7 +308,8 @@ fun AdvancedCameraAnimationScreen() { val t = (elapsedMs.toDouble() / totalMs).coerceIn(0.0, 1.0) val orbitHeading = interpolateAngle(step.startHeading, step.endHeading, t) - val updatedCam = camera { + planeHeading = normalizeHeading(orbitHeading + 180.0) + currentCameraState = camera { center = latLngAltitude { latitude = step.center.latitude longitude = step.center.longitude @@ -391,15 +319,7 @@ fun AdvancedCameraAnimationScreen() { tilt = step.tilt range = step.range } - - planePosition = latLngAltitude { - latitude = step.center.latitude - longitude = step.center.longitude - altitude = 200.0 - } - planeHeading = normalizeHeading(orbitHeading + 180.0) - currentCameraState = updatedCam - delay(16L) // Keeps dispatch rhythm steady + delay(frameDurationMs.milliseconds) } } } @@ -420,34 +340,35 @@ fun AdvancedCameraAnimationScreen() { stopTour() isPlaying = true + val totalDist = cumulativeDistances.last().coerceAtLeast(1.0) + val flightSpeedMps = 400.0 + tourJob = scope.launch(Dispatchers.Main) { - val totalDistance = cumulativeDistances.last().coerceAtLeast(1.0) - val flightSpeedMps = 400.0 + var lastTimeMs = SystemClock.uptimeMillis() var elapsedDistance = 0.0 - var lastTime = System.currentTimeMillis() - while (isPlaying && isActive) { - val now = System.currentTimeMillis() - val dt = (now - lastTime) / 1000.0 - lastTime = now + while (isActive && isPlaying) { + val nowMs = SystemClock.uptimeMillis() + val dt = (nowMs - lastTimeMs) / 1000.0 + lastTimeMs = nowMs elapsedDistance += flightSpeedMps * dt - if (elapsedDistance >= totalDistance) { - elapsedDistance = totalDistance + if (elapsedDistance >= totalDist) { + elapsedDistance = totalDist val posAndHeading = RouteEngine.calculatePositionAndHeading( flightPath, cumulativeDistances, elapsedDistance, 30.0, ) - val planeH = posAndHeading.heading.toDouble() + 180.0 planePosition = latLngAltitude { latitude = posAndHeading.position.latitude longitude = posAndHeading.position.longitude altitude = 200.0 } - planeHeading = normalizeHeading(planeH) + planeHeading = normalizeHeading(posAndHeading.heading.toDouble() + 180.0) + currentCameraState = camera { center = latLngAltitude { latitude = posAndHeading.position.latitude @@ -468,13 +389,13 @@ fun AdvancedCameraAnimationScreen() { elapsedDistance, 30.0, ) - val planeH = posAndHeading.heading.toDouble() + 180.0 + planePosition = latLngAltitude { latitude = posAndHeading.position.latitude longitude = posAndHeading.position.longitude altitude = 200.0 } - planeHeading = normalizeHeading(planeH) + planeHeading = normalizeHeading(posAndHeading.heading.toDouble() + 180.0) currentCameraState = camera { center = latLngAltitude { @@ -486,6 +407,7 @@ fun AdvancedCameraAnimationScreen() { tilt = 65.0 range = 600.0 } + delay(16.milliseconds) } } diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt index e7b5d1d..3a64fc4 100644 --- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt @@ -17,107 +17,86 @@ package com.example.composedemos.pathfollowing import android.os.Bundle -import android.view.MotionEvent import androidx.activity.ComponentActivity -import androidx.activity.compose.LocalActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.RadioButton import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.withFrameNanos +import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.core.graphics.toColorInt -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner +import com.example.maps3d.common.PathData +import com.example.maps3d.common.PathEngine +import com.example.maps3dcommon.R import com.google.android.gms.maps.model.LatLng 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.camera import com.google.android.gms.maps3d.model.latLngAltitude -import com.google.maps.android.SphericalUtil import com.google.maps.android.compose3d.GoogleMap3D import com.google.maps.android.compose3d.PolylineConfig -import com.google.maps.android.compose3d.utils.toHeading -import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive -/** - * Advanced sample demonstrating ground-level path following in Jetpack Compose. - * - * Features: - * - Urban vs Rural ground-level paths - * - Two-polyline architecture: wide blue base route (lower z-index) + narrow purple active progress route (higher z-index) - * - Configurable altitude modes (Clamp to Ground default, Relative to Ground, Relative to Mesh, Absolute) - * - Dynamic path elevation slider to eliminate z-fighting - * - Occlusion visualization toggle (drawsOccludedSegments) rendering polylines through or behind 3D terrain and buildings - * - Explicit collapsible controls card with smooth auto-fade on idle - * - Real-time camera controls via sliders: Range, Ground Altitude, Heading Offset, Tilt, Follow Speed - */ class PathFollowingActivity : ComponentActivity() { - val touchEventFlow = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 1) - - override fun dispatchTouchEvent(ev: MotionEvent): Boolean { - if (ev.action == MotionEvent.ACTION_DOWN || ev.action == MotionEvent.ACTION_MOVE) { - touchEventFlow.tryEmit(Unit) - } - return super.dispatchTouchEvent(ev) - } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) enableEdgeToEdge() - - val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) - windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) - windowInsetsController.systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - + super.onCreate(savedInstanceState) setContent { MaterialTheme { Surface( @@ -131,299 +110,118 @@ class PathFollowingActivity : ComponentActivity() { } companion object { - // Urban Path (San Francisco Downtown - Market Street Corridor with 1 to 10m altitudes) - val URBAN_PATH = listOf( - LatLngAltitude(37.79323, -122.39322, 4.2), - LatLngAltitude(37.79166, -122.39519, 6.7), - LatLngAltitude(37.79124, -122.39571, 8.1), - LatLngAltitude(37.79105, -122.39599, 9.5), - LatLngAltitude(37.78893, -122.39866, 7.3), - LatLngAltitude(37.78742, -122.40060, 5.0), - LatLngAltitude(37.78686, -122.40129, 3.4), - LatLngAltitude(37.78652, -122.40171, 2.1), - LatLngAltitude(37.78632, -122.40196, 4.6), - LatLngAltitude(37.78627, -122.40207, 6.2), - LatLngAltitude(37.78453, -122.40429, 8.9), - LatLngAltitude(37.78443, -122.40434, 10.0), - LatLngAltitude(37.78155, -122.40802, 7.8), - LatLngAltitude(37.78005, -122.40990, 5.4), - LatLngAltitude(37.77856, -122.41180, 3.1), - LatLngAltitude(37.77746, -122.41318, 1.8), - LatLngAltitude(37.77624, -122.41474, 4.0), - LatLngAltitude(37.77744, -122.41623, 6.5), - LatLngAltitude(37.77749, -122.41636, 8.7), - LatLngAltitude(37.77761, -122.41654, 9.8), - LatLngAltitude(37.77769, -122.41677, 7.2), - LatLngAltitude(37.77729, -122.41981, 4.9), - LatLngAltitude(37.77523, -122.41938, 2.6), - LatLngAltitude(37.77510, -122.41934, 1.2), - LatLngAltitude(37.77442, -122.42022, 3.5), - LatLngAltitude(37.77441, -122.42033, 5.8), - LatLngAltitude(37.77348, -122.42157, 8.4), - LatLngAltitude(37.77244, -122.42289, 10.0), - ) - - // Rural Path (California Coastal Highway) - val RURAL_PATH = listOf( - LatLngAltitude(37.254529, -122.380897, 0.0), - LatLngAltitude(37.255065, -122.381627, 0.0), - LatLngAltitude(37.257540, -122.383720, 0.0), - LatLngAltitude(37.261200, -122.383950, 0.0), - LatLngAltitude(37.264780, -122.388210, 0.0), - LatLngAltitude(37.268520, -122.392450, 0.0), - LatLngAltitude(37.272110, -122.397640, 0.0), - LatLngAltitude(37.276430, -122.401120, 0.0), - LatLngAltitude(37.280850, -122.403560, 0.0), - LatLngAltitude(37.286018, -122.405072, 0.0), - LatLngAltitude(37.291040, -122.404210, 0.0), - LatLngAltitude(37.295800, -122.401980, 0.0), - LatLngAltitude(37.300120, -122.399540, 0.0), - LatLngAltitude(37.304550, -122.397210, 0.0), - LatLngAltitude(37.309200, -122.395100, 0.0), - LatLngAltitude(37.313450, -122.392840, 0.0), - LatLngAltitude(37.317200, -122.390510, 0.0), - LatLngAltitude(37.320850, -122.388740, 0.0), - LatLngAltitude(37.323540, -122.387600, 0.0), - LatLngAltitude(37.325269, -122.386728, 0.0), - ) + val URBAN_PATH: List = PathData.URBAN_PATH + val RURAL_PATH: List = PathData.RURAL_PATH } } -enum class EnvironmentType(val label: String) { - URBAN("Urban"), - RURAL("Rural"), -} - enum class AltitudeModeOption(val label: String, val mode: Int) { CLAMP_TO_GROUND("Clamp to Ground", AltitudeMode.CLAMP_TO_GROUND), - RELATIVE_TO_GROUND("Relative to Ground", AltitudeMode.RELATIVE_TO_GROUND), - RELATIVE_TO_MESH("Relative to Mesh", AltitudeMode.RELATIVE_TO_MESH), + RELATIVE_TO_GROUND("Rel to Ground", AltitudeMode.RELATIVE_TO_GROUND), + RELATIVE_TO_MESH("Rel to Mesh", AltitudeMode.RELATIVE_TO_MESH), ABSOLUTE("Absolute", AltitudeMode.ABSOLUTE), } +/** + * Main Path Following demo screen orchestrating 3D map rendering and interactive controls. + */ @Composable fun PathFollowingScreen() { var isMapSteady by remember { mutableStateOf(false) } - - // UX Fade and Collapse logic - val activity = LocalActivity.current as PathFollowingActivity - var showControls by remember { mutableStateOf(true) } - var isCollapsed by remember { mutableStateOf(false) } - - LaunchedEffect(Unit) { - activity.touchEventFlow.collect { - showControls = true - } - } - - LaunchedEffect(showControls, isCollapsed) { - if (showControls && !isCollapsed) { - kotlinx.coroutines.delay(3000.milliseconds) - showControls = false - } - } - - val controlsAlpha by animateFloatAsState( - targetValue = if (showControls || isCollapsed) 1f else 0.8f, - animationSpec = tween(400), - ) - - // Environment Selection State - var selectedEnv by remember { mutableStateOf(EnvironmentType.URBAN) } - var currentPath by remember { mutableStateOf(PathFollowingActivity.URBAN_PATH) } - - // Altitude Mode and Height State + var currentPath by remember { mutableStateOf(PathData.URBAN_PATH) } var selectedAltitudeMode by remember { mutableStateOf(AltitudeModeOption.CLAMP_TO_GROUND) } - var pathAltitudeOffset by remember { mutableFloatStateOf(0.5f) } var drawsOccludedSegments by remember { mutableStateOf(true) } + var pathAltitudeOffset by remember { mutableFloatStateOf(0.5f) } - // Path Calculations State - val pathCalculations = remember(currentPath) { - val cumulative = DoubleArray(currentPath.size) - var total = 0.0 - cumulative[0] = 0.0 - for (i in 1 until currentPath.size) { - val pPrev = LatLng(currentPath[i - 1].latitude, currentPath[i - 1].longitude) - val pCurr = LatLng(currentPath[i].latitude, currentPath[i].longitude) - val dist = SphericalUtil.computeDistanceBetween(pPrev, pCurr) - total += dist - cumulative[i] = total - } - Pair(cumulative, total) - } - val cumulativeDistances = pathCalculations.first - val totalDistance = pathCalculations.second - - // Animation Controls State - var isPlaying by remember { mutableStateOf(false) } - var progress by remember { mutableFloatStateOf(0.0f) } - var isUserScrubbing by remember { mutableStateOf(false) } - var elapsedDistance by remember { mutableDoubleStateOf(0.0) } - var currentWaypointIndex by remember { mutableIntStateOf(0) } - - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP || event == Lifecycle.Event.ON_DESTROY) { - isPlaying = false - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - // Camera Parameter Sliders State var cameraRange by remember { mutableFloatStateOf(300f) } var groundAltitude by remember { mutableFloatStateOf(20f) } var headingOffset by remember { mutableFloatStateOf(0f) } var cameraTilt by remember { mutableFloatStateOf(70f) } var followSpeedMps by remember { mutableFloatStateOf(30f) } - // Interpolated Position & Heading - var currentLatLng by remember(currentPath) { - val first = currentPath.first() - mutableStateOf(LatLng(first.latitude, first.longitude)) - } + var isPlaying by remember { mutableStateOf(false) } + var isUserScrubbing by remember { mutableStateOf(false) } + var progress by remember { mutableFloatStateOf(0f) } + var elapsedDistance by remember { mutableDoubleStateOf(0.0) } var currentHeading by remember { mutableStateOf(null) } - var targetHeading by remember { mutableDoubleStateOf(0.0) } - - // Helper to calculate position for given elapsed distance - fun updatePositionForDistance(dist: Double) { - if (currentPath.isEmpty()) return - var index = 0 - while (index < cumulativeDistances.size - 1 && cumulativeDistances[index + 1] < dist) { - index++ - } - currentWaypointIndex = index - - val p1 = currentPath[index] - val p2 = if (index < currentPath.size - 1) currentPath[index + 1] else p1 - - val segStartDist = cumulativeDistances[index] - val segEndDist = - if (index < cumulativeDistances.size - 1) cumulativeDistances[index + 1] else totalDistance - val segLen = segEndDist - segStartDist - - val fraction = if (segLen > 0) ((dist - segStartDist) / segLen).coerceIn(0.0, 1.0) else 0.0 - val latLng1 = LatLng(p1.latitude, p1.longitude) - val latLng2 = LatLng(p2.latitude, p2.longitude) - val interpolatedLatLng = SphericalUtil.interpolate(latLng1, latLng2, fraction) - currentLatLng = interpolatedLatLng - val bearing = SphericalUtil.computeHeading(latLng1, latLng2) - - // Kinematic Heading Smoothing (Exponential Moving Average) - val targetHeadingRaw = (bearing + headingOffset).toHeading() - val computedHeading = if (currentHeading == null || isUserScrubbing || !isPlaying) { - targetHeadingRaw - } else { - var diff = (targetHeadingRaw - currentHeading!!) % 360.0 - if (diff > 180.0) diff -= 360.0 - if (diff < -180.0) diff += 360.0 - (currentHeading!! + diff * 0.12).toHeading() - } - currentHeading = computedHeading - targetHeading = computedHeading - } - // Switch Environment logic - fun switchEnvironment(env: EnvironmentType) { - selectedEnv = env - isPlaying = false - currentHeading = null - progress = 0f - elapsedDistance = 0.0 - if (env == EnvironmentType.RURAL) { - currentPath = PathFollowingActivity.RURAL_PATH - cameraRange = 450f - groundAltitude = 40f - cameraTilt = 75f - } else { - currentPath = PathFollowingActivity.URBAN_PATH - cameraRange = 300f - groundAltitude = 20f - cameraTilt = 70f - } - updatePositionForDistance(0.0) + val cumulativeDistances = remember(currentPath) { + PathEngine.calculateCumulativeDistances(currentPath) } + val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0 + val baseAltitude = if (currentPath == PathData.RURAL_PATH) 45.0 else 50.0 + + // Frame animation loop with delta-time integration + LaunchedEffect(isPlaying, totalDistance, followSpeedMps) { + if (!isPlaying || totalDistance <= 0.0) return@LaunchedEffect + var lastTimeNanos = 0L + + while (isActive && isPlaying) { + withFrameMillis { frameTimeMillis -> + val nowNanos = frameTimeMillis * 1_000_000L + if (lastTimeNanos == 0L) { + lastTimeNanos = nowNanos + return@withFrameMillis + } + val dt = (nowNanos - lastTimeNanos) / 1_000_000_000.0 + lastTimeNanos = nowNanos - // Animation Loop synced to VSYNC - LaunchedEffect(isPlaying) { - if (!isPlaying) return@LaunchedEffect - var lastTimeNanos = withFrameNanos { it } - while (isPlaying) { - val nowNanos = withFrameNanos { it } - val dt = (nowNanos - lastTimeNanos) / 1_000_000_000.0 - lastTimeNanos = nowNanos - - val stepDistance = followSpeedMps * dt - elapsedDistance += stepDistance - - if (elapsedDistance >= totalDistance) { - elapsedDistance = 0.0 - } + val stepDistance = followSpeedMps * dt + elapsedDistance = (elapsedDistance + stepDistance) % totalDistance - if (!isUserScrubbing && totalDistance > 0) { - progress = (elapsedDistance / totalDistance).toFloat().coerceIn(0f, 1f) + if (!isUserScrubbing && totalDistance > 0) { + progress = (elapsedDistance / totalDistance).toFloat().coerceIn(0f, 1f) + } } - updatePositionForDistance(elapsedDistance) } } - // Dynamic Camera Altitude - val p1Cam = currentPath.getOrElse(currentWaypointIndex.coerceAtMost(currentPath.size - 1)) { currentPath.first() } - val p2Cam = if (currentWaypointIndex < currentPath.size - 1) currentPath[currentWaypointIndex + 1] else p1Cam - val segStartDistCam = cumulativeDistances.getOrElse(currentWaypointIndex) { 0.0 } - val segEndDistCam = cumulativeDistances.getOrElse(currentWaypointIndex + 1) { totalDistance } - val segLenCam = segEndDistCam - segStartDistCam - val fractionCam = if (segLenCam > 0) ((elapsedDistance - segStartDistCam) / segLenCam).coerceIn(0.0, 1.0) else 0.0 - val interpAltCam = p1Cam.altitude + fractionCam * (p2Cam.altitude - p1Cam.altitude) - - val cameraTargetAltitude = if (selectedAltitudeMode.mode == AltitudeMode.ABSOLUTE) { - val baseAlt = if (currentPath == PathFollowingActivity.RURAL_PATH) 45.0 else 50.0 - baseAlt + interpAltCam + groundAltitude - } else { - groundAltitude.toDouble() + // Interpolate camera and position + val interpPoint = remember(currentPath, cumulativeDistances, elapsedDistance) { + PathEngine.interpolatePoint(currentPath, cumulativeDistances, elapsedDistance) } - // Dynamic Camera State - val dynamicCamera = - remember(currentLatLng, targetHeading, cameraTilt, cameraRange, cameraTargetAltitude) { - camera { - center = latLngAltitude { - latitude = currentLatLng.latitude - longitude = currentLatLng.longitude - altitude = cameraTargetAltitude - } - heading = targetHeading - tilt = cameraTilt.toDouble() - range = cameraRange.toDouble() - roll = 0.0 - } - } + val targetHeading = remember(interpPoint.bearing, headingOffset, currentHeading, isUserScrubbing, isPlaying) { + val smoothed = PathEngine.smoothHeading( + interpPoint.bearing + headingOffset, + currentHeading, + isUserScrubbing, + isPlaying, + ) + currentHeading = smoothed + smoothed + } - // Dual-Polyline Rendering Technique: - // 1. Base Static Route Polyline: A wider (#4285F4 blue, 16dp) static path at ZIndex=1. - // 2. Traversed Progress Polyline: A narrower (#9C27B0 purple, 8dp) line at ZIndex=2. - val staticPolylineConfig = remember( - currentPath, - selectedAltitudeMode, - pathAltitudeOffset, - drawsOccludedSegments, - ) { - val staticVertices = currentPath.map { pt -> - val vertexAltitude = when (selectedAltitudeMode.mode) { - AltitudeMode.CLAMP_TO_GROUND -> 0.0 - AltitudeMode.ABSOLUTE -> pt.altitude + (if (currentPath == PathFollowingActivity.RURAL_PATH) 45.0 else 50.0) + pathAltitudeOffset - else -> pt.altitude + pathAltitudeOffset.toDouble() + val cameraTargetAltitude = remember(selectedAltitudeMode, baseAltitude, interpPoint.altitude, groundAltitude) { + PathEngine.calculateCameraAltitude( + selectedAltitudeMode.mode, + baseAltitude, + interpPoint.altitude, + groundAltitude.toDouble(), + ) + } + + val dynamicCamera = remember(interpPoint.latLng, targetHeading, cameraTilt, cameraRange, cameraTargetAltitude) { + camera { + center = latLngAltitude { + latitude = interpPoint.latLng.latitude + longitude = interpPoint.latLng.longitude + altitude = cameraTargetAltitude } - LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude) + heading = targetHeading + tilt = cameraTilt.toDouble() + range = cameraRange.toDouble() + roll = 0.0 } + } + + // Dual-Polyline Configurations + val staticPolylineConfig = remember(currentPath, selectedAltitudeMode, pathAltitudeOffset, drawsOccludedSegments, baseAltitude) { + val vertices = PathEngine.buildStaticVertices(currentPath, selectedAltitudeMode.mode, baseAltitude, pathAltitudeOffset.toDouble()) PolylineConfig( - key = "path_following_static_route", - points = staticVertices, - color = "#4285F4".toColorInt(), + key = PathEngine.STATIC_POLYLINE_ID, + points = vertices, + color = "#4285F4".toColorInt(), // Wide blue route (16dp) width = 16f, zIndex = 1, altitudeMode = selectedAltitudeMode.mode, @@ -432,68 +230,24 @@ fun PathFollowingScreen() { } val progressPolylineConfig = remember( - currentPath, - currentWaypointIndex, - currentLatLng, - selectedAltitudeMode, - pathAltitudeOffset, - drawsOccludedSegments, - totalDistance, - elapsedDistance, + currentPath, cumulativeDistances, elapsedDistance, interpPoint, + selectedAltitudeMode, pathAltitudeOffset, drawsOccludedSegments, baseAltitude, totalDistance, ) { if (currentPath.isEmpty() || totalDistance <= 0.0) return@remember null - - val progressCoordinates = ArrayList() - for (i in 0..currentWaypointIndex.coerceAtMost(currentPath.size - 1)) { - val pt = currentPath[i] - val vertexAltitude = when (selectedAltitudeMode.mode) { - AltitudeMode.CLAMP_TO_GROUND -> 0.0 - AltitudeMode.ABSOLUTE -> pt.altitude + (if (currentPath == PathFollowingActivity.RURAL_PATH) 45.0 else 50.0) + pathAltitudeOffset + 0.4 - else -> pt.altitude + pathAltitudeOffset.toDouble() + 0.4 - } - progressCoordinates.add(LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude)) - } - - val p1 = currentPath[currentWaypointIndex.coerceAtMost(currentPath.size - 1)] - val p2 = if (currentWaypointIndex < currentPath.size - 1) currentPath[currentWaypointIndex + 1] else p1 - val segStartDist = cumulativeDistances.getOrElse(currentWaypointIndex) { 0.0 } - val segEndDist = cumulativeDistances.getOrElse(currentWaypointIndex + 1) { totalDistance } - val segLen = segEndDist - segStartDist - val fraction = if (segLen > 0) ((elapsedDistance - segStartDist) / segLen).coerceIn(0.0, 1.0) else 0.0 - val interpAlt = p1.altitude + fraction * (p2.altitude - p1.altitude) - val progressAltitude = when (selectedAltitudeMode.mode) { - AltitudeMode.CLAMP_TO_GROUND -> 0.0 - AltitudeMode.ABSOLUTE -> interpAlt + (if (currentPath == PathFollowingActivity.RURAL_PATH) 45.0 else 50.0) + pathAltitudeOffset + 0.4 - else -> interpAlt + pathAltitudeOffset.toDouble() + 0.4 - } - - val lastWaypoint = currentPath[currentWaypointIndex.coerceAtMost(currentPath.size - 1)] - val lastLatLng = LatLng(lastWaypoint.latitude, lastWaypoint.longitude) - val distToLast = SphericalUtil.computeDistanceBetween(lastLatLng, currentLatLng) - if (distToLast >= 0.5) { - progressCoordinates.add( - LatLngAltitude(currentLatLng.latitude, currentLatLng.longitude, progressAltitude), - ) - } - - if (progressCoordinates.size < 2 && currentPath.size >= 2) { - val p0 = LatLng(currentPath[0].latitude, currentPath[0].longitude) - val p1Lat = LatLng(currentPath[1].latitude, currentPath[1].longitude) - val tinyForward = SphericalUtil.interpolate(p0, p1Lat, 0.005) - val startAlt = when (selectedAltitudeMode.mode) { - AltitudeMode.CLAMP_TO_GROUND -> 0.0 - AltitudeMode.ABSOLUTE -> currentPath[0].altitude + (if (currentPath == PathFollowingActivity.RURAL_PATH) 45.0 else 50.0) + pathAltitudeOffset + 0.4 - else -> currentPath[0].altitude + pathAltitudeOffset.toDouble() + 0.4 - } - progressCoordinates.add( - LatLngAltitude(tinyForward.latitude, tinyForward.longitude, startAlt), - ) - } - + val vertices = PathEngine.buildProgressVertices( + currentPath, + cumulativeDistances, + elapsedDistance, + interpPoint.latLng, + interpPoint.waypointIndex, + selectedAltitudeMode.mode, + baseAltitude, + pathAltitudeOffset.toDouble(), + ) PolylineConfig( - key = "path_following_progress_route", - points = progressCoordinates, - color = "#9C27B0".toColorInt(), + key = PathEngine.PROGRESS_POLYLINE_ID, + points = vertices, + color = "#9C27B0".toColorInt(), // Narrow purple progress (8dp) width = 8f, zIndex = 2, altitudeMode = selectedAltitudeMode.mode, @@ -505,286 +259,333 @@ fun PathFollowingScreen() { listOfNotNull(staticPolylineConfig, progressPolylineConfig) } + // Control card collapse and auto-fade state + var isCollapsed by remember { mutableStateOf(false) } + var isTouching by remember { mutableStateOf(false) } + var isCardIdle by remember { mutableStateOf(false) } + + LaunchedEffect(isTouching, isCollapsed) { + if (!isTouching && !isCollapsed) { + delay(3000L) + isCardIdle = true + } else { + isCardIdle = false + } + } + + val controlsAlpha by animateFloatAsState( + targetValue = if (isCardIdle && !isCollapsed) 0.8f else 1.0f, + animationSpec = tween(durationMillis = 300), + label = "controlsAlpha", + ) + Box( modifier = Modifier .fillMaxSize() .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, ) { - // 1. Full Screen 3D Map GoogleMap3D( camera = dynamicCamera, polylines = polylines, modifier = Modifier.fillMaxSize(), - onMapSteady = { - isMapSteady = true - }, + onMapSteady = { isMapSteady = true }, ) - // 2. Collapsible Control Panel Card at Bottom - Card( + PathFollowingControlCard( modifier = Modifier .align(Alignment.BottomCenter) .padding(16.dp) .fillMaxWidth() - .alpha(controlsAlpha), - ) { - Column( - modifier = Modifier.padding(16.dp), + .alpha(controlsAlpha) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + isTouching = true + tryAwaitRelease() + isTouching = false + }, + ) + }, + isCollapsed = isCollapsed, + onToggleCollapse = { isCollapsed = !isCollapsed }, + currentPath = currentPath, + onSwitchEnvironment = { newPath -> + isPlaying = false + currentHeading = null + elapsedDistance = 0.0 + progress = 0f + if (newPath == PathData.RURAL_PATH) { + cameraRange = 450f + groundAltitude = 40f + cameraTilt = 75f + } else { + cameraRange = 300f + groundAltitude = 20f + cameraTilt = 70f + } + currentPath = newPath + }, + selectedAltitudeMode = selectedAltitudeMode, + onAltitudeModeChange = { selectedAltitudeMode = it }, + drawsOccludedSegments = drawsOccludedSegments, + onDrawsOccludedChange = { drawsOccludedSegments = it }, + pathAltitudeOffset = pathAltitudeOffset, + onPathAltitudeOffsetChange = { pathAltitudeOffset = it }, + isPlaying = isPlaying, + onPlayPauseToggle = { isPlaying = !isPlaying }, + progress = progress, + onProgressChange = { + progress = it + elapsedDistance = totalDistance * it.toDouble() + }, + onProgressScrubbingChange = { isUserScrubbing = it }, + cameraRange = cameraRange, + onCameraRangeChange = { cameraRange = it }, + groundAltitude = groundAltitude, + onGroundAltitudeChange = { groundAltitude = it }, + headingOffset = headingOffset, + onHeadingOffsetChange = { headingOffset = it }, + cameraTilt = cameraTilt, + onCameraTiltChange = { cameraTilt = it }, + followSpeedMps = followSpeedMps, + onFollowSpeedChange = { followSpeedMps = it }, + ) + } +} + +/** + * Floating collapsible control panel for path following parameters. + */ +@Composable +private fun PathFollowingControlCard( + modifier: Modifier = Modifier, + isCollapsed: Boolean, + onToggleCollapse: () -> Unit, + currentPath: List, + onSwitchEnvironment: (List) -> Unit, + selectedAltitudeMode: AltitudeModeOption, + onAltitudeModeChange: (AltitudeModeOption) -> Unit, + drawsOccludedSegments: Boolean, + onDrawsOccludedChange: (Boolean) -> Unit, + pathAltitudeOffset: Float, + onPathAltitudeOffsetChange: (Float) -> Unit, + isPlaying: Boolean, + onPlayPauseToggle: () -> Unit, + progress: Float, + onProgressChange: (Float) -> Unit, + onProgressScrubbingChange: (Boolean) -> Unit, + cameraRange: Float, + onCameraRangeChange: (Float) -> Unit, + groundAltitude: Float, + onGroundAltitudeChange: (Float) -> Unit, + headingOffset: Float, + onHeadingOffsetChange: (Float) -> Unit, + cameraTilt: Float, + onCameraTiltChange: (Float) -> Unit, + followSpeedMps: Float, + onFollowSpeedChange: (Float) -> Unit, +) { + Card( + modifier = modifier.animateContentSize(), + elevation = CardDefaults.cardElevation(defaultElevation = 6.dp), + shape = RoundedCornerShape(16.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + // Header + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggleCollapse() }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - // Header with Title and Expand / Collapse Button - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { isCollapsed = !isCollapsed }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "Path Following Controls", - style = MaterialTheme.typography.titleMedium, + Text( + text = "Path Following Controls", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + IconButton(onClick = onToggleCollapse) { + Icon( + imageVector = if (isCollapsed) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isCollapsed) "Expand Controls" else "Collapse Controls", ) - IconButton( - onClick = { isCollapsed = !isCollapsed }, - modifier = Modifier.semantics { - contentDescription = - if (isCollapsed) "Expand Controls" else "Collapse Controls" - }, - ) { - Icon( - imageVector = if (isCollapsed) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = if (isCollapsed) "Expand Controls" else "Collapse Controls", - ) - } } + } - AnimatedVisibility(visible = !isCollapsed) { - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 340.dp) - .verticalScroll(rememberScrollState()), - ) { - // Environment Selector - Text( - text = "Path Environment:", - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(top = 4.dp), - ) + AnimatedVisibility(visible = !isCollapsed) { + Column( + modifier = Modifier + .fillMaxWidth() + .height(380.dp) + .verticalScroll(rememberScrollState()), + ) { + // Environment Selection + Text("Environment", style = MaterialTheme.typography.labelMedium) + Row(modifier = Modifier.fillMaxWidth()) { Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - EnvironmentType.entries.forEach { env -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .weight(1f) - .selectable( - selected = (selectedEnv == env), - onClick = { switchEnvironment(env) }, - role = Role.RadioButton, - ), - ) { - RadioButton( - selected = (selectedEnv == env), - onClick = null, - ) - Text( - text = env.label, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(start = 4.dp), - ) - } - } - } - - // Altitude Mode Selector - Text( - text = "Altitude Mode:", - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(top = 8.dp), - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp), + .selectable( + selected = currentPath == PathData.URBAN_PATH, + onClick = { onSwitchEnvironment(PathData.URBAN_PATH) }, + role = Role.RadioButton, + ) + .padding(end = 16.dp), ) { - AltitudeModeOption.entries.forEach { opt -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = (selectedAltitudeMode == opt), - onClick = { selectedAltitudeMode = opt }, - role = Role.RadioButton, - ), - ) { - RadioButton( - selected = (selectedAltitudeMode == opt), - onClick = null, - ) - Text( - text = opt.label, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(start = 4.dp), - ) - } - } + RadioButton( + selected = currentPath == PathData.URBAN_PATH, + onClick = null, + ) + Text("Urban (SF)", modifier = Modifier.padding(start = 4.dp)) } - // Path Height Slider (Relative Altitude) - Text( - text = "Path Height: ${"%.1f".format(pathAltitudeOffset)}m", - style = MaterialTheme.typography.labelMedium, - modifier = Modifier.padding(top = 4.dp), - ) - Slider( - value = pathAltitudeOffset, - onValueChange = { pathAltitudeOffset = it }, - valueRange = 0f..10f, - modifier = Modifier.semantics { - contentDescription = "Path Height Slider" - }, - ) - - // Occlusion Switch Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), + modifier = Modifier.selectable( + selected = currentPath == PathData.RURAL_PATH, + onClick = { onSwitchEnvironment(PathData.RURAL_PATH) }, + role = Role.RadioButton, + ), ) { - Text( - text = "Draw Occluded Segments", - style = MaterialTheme.typography.labelLarge, - ) - Switch( - checked = drawsOccludedSegments, - onCheckedChange = { drawsOccludedSegments = it }, - modifier = Modifier.semantics { - contentDescription = "Draw Occluded Segments Switch" - }, + RadioButton( + selected = currentPath == PathData.RURAL_PATH, + onClick = null, ) + Text("Rural", modifier = Modifier.padding(start = 4.dp)) } + } - // Play / Pause and Progress Slider Row - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - IconButton( - onClick = { isPlaying = !isPlaying }, - ) { - Icon( - imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, - contentDescription = if (isPlaying) "Pause animation" else "Play animation", - ) + Spacer(modifier = Modifier.height(8.dp)) + + // Altitude Mode Selection + Text("Altitude Mode", style = MaterialTheme.typography.labelMedium) + Column { + AltitudeModeOption.entries.chunked(2).forEach { rowOptions -> + Row(modifier = Modifier.fillMaxWidth()) { + rowOptions.forEach { opt -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .weight(1f) + .selectable( + selected = selectedAltitudeMode == opt, + onClick = { onAltitudeModeChange(opt) }, + role = Role.RadioButton, + ), + ) { + RadioButton( + selected = selectedAltitudeMode == opt, + onClick = null, + ) + Text(opt.label, style = MaterialTheme.typography.bodySmall) + } + } } - - Slider( - value = progress, - onValueChange = { newValue -> - isUserScrubbing = true - progress = newValue - elapsedDistance = totalDistance * newValue - updatePositionForDistance(elapsedDistance) - }, - onValueChangeFinished = { - isUserScrubbing = false - }, - modifier = Modifier - .weight(1f) - .semantics { contentDescription = "Path Progress Slider" }, - ) } + } - // Camera Range Slider - Text( - text = "Camera Range: ${cameraRange.toInt()}m", - style = MaterialTheme.typography.labelMedium, - ) - Slider( - value = cameraRange, - onValueChange = { - cameraRange = it - updatePositionForDistance(elapsedDistance) - }, - valueRange = 50f..1000f, - modifier = Modifier.semantics { - contentDescription = "Camera Range Slider" - }, - ) + Spacer(modifier = Modifier.height(8.dp)) - // Ground Altitude Slider - val maxAltitude = if (selectedEnv == EnvironmentType.RURAL) 2000f else 200f - Text( - text = "Ground Altitude: ${groundAltitude.toInt()}m", - style = MaterialTheme.typography.labelMedium, - ) - Slider( - value = groundAltitude.coerceIn(2f, maxAltitude), - onValueChange = { - groundAltitude = it - updatePositionForDistance(elapsedDistance) - }, - valueRange = 2f..maxAltitude, - modifier = Modifier.semantics { - contentDescription = "Ground Altitude Slider" - }, + // Occlusion toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Draw Occluded Segments", style = MaterialTheme.typography.labelMedium) + Switch( + checked = drawsOccludedSegments, + onCheckedChange = onDrawsOccludedChange, ) + } - // Heading Offset Slider - Text( - text = "Heading Offset: ${headingOffset.toInt()}°", - style = MaterialTheme.typography.labelMedium, - ) - Slider( - value = headingOffset, - onValueChange = { - headingOffset = it - updatePositionForDistance(elapsedDistance) - }, - valueRange = -180f..180f, - modifier = Modifier.semantics { - contentDescription = "Heading Offset Slider" - }, - ) + // Path Height Offset + Text( + stringResource(R.string.path_height_format, pathAltitudeOffset), + style = MaterialTheme.typography.labelSmall, + ) + Slider( + value = pathAltitudeOffset, + onValueChange = onPathAltitudeOffsetChange, + valueRange = 0f..10f, + ) - // Camera Tilt Slider - Text( - text = "Camera Tilt: ${cameraTilt.toInt()}°", - style = MaterialTheme.typography.labelMedium, - ) - Slider( - value = cameraTilt, - onValueChange = { - cameraTilt = it - updatePositionForDistance(elapsedDistance) - }, - valueRange = 0f..85f, - modifier = Modifier.semantics { - contentDescription = "Camera Tilt Slider" - }, - ) + // Play / Pause & Progress Scrubbing + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Button(onClick = onPlayPauseToggle) { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (isPlaying) "Pause" else "Play", + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(if (isPlaying) "Pause" else "Play") + } + + Spacer(modifier = Modifier.width(8.dp)) - // Follow Speed Slider - Text( - text = "Follow Speed: ${followSpeedMps.toInt()} m/s", - style = MaterialTheme.typography.labelMedium, - ) Slider( - value = followSpeedMps, - onValueChange = { followSpeedMps = it }, - valueRange = 5f..100f, - modifier = Modifier.semantics { - contentDescription = "Follow Speed Slider" - }, + value = progress, + onValueChange = onProgressChange, + onValueChangeFinished = { onProgressScrubbingChange(false) }, + modifier = Modifier.weight(1f), ) } + + Spacer(modifier = Modifier.height(8.dp)) + + // Camera Controls + Text( + stringResource(R.string.camera_range_format, cameraRange.toInt()), + style = MaterialTheme.typography.labelSmall, + ) + Slider( + value = cameraRange, + onValueChange = onCameraRangeChange, + valueRange = 50f..1000f, + ) + + Text( + stringResource(R.string.ground_altitude_format, groundAltitude.toInt()), + style = MaterialTheme.typography.labelSmall, + ) + Slider( + value = groundAltitude, + onValueChange = onGroundAltitudeChange, + valueRange = 2f..if (currentPath == PathData.RURAL_PATH) 2000f else 200f, + ) + + Text( + stringResource(R.string.heading_offset_format, headingOffset.toInt()), + style = MaterialTheme.typography.labelSmall, + ) + Slider( + value = headingOffset, + onValueChange = onHeadingOffsetChange, + valueRange = -180f..180f, + ) + + Text( + stringResource(R.string.camera_tilt_format, cameraTilt.toInt()), + style = MaterialTheme.typography.labelSmall, + ) + Slider( + value = cameraTilt, + onValueChange = onCameraTiltChange, + valueRange = 0f..85f, + ) + + Text( + stringResource(R.string.follow_speed_format, followSpeedMps.toInt()), + style = MaterialTheme.typography.labelSmall, + ) + Slider( + value = followSpeedMps, + onValueChange = onFollowSpeedChange, + valueRange = 5f..100f, + ) } } } From 288c3af71f4cb3ad8a815c0d4cb12c2e81bb5f63 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:15:23 -0600 Subject: [PATCH 3/6] refactor(maps3d): clean up Kotlin collections, add named/commented parameters, and verify lifecycles - Remove java.util.ArrayList import in PathEngine.kt in favor of mutableListOf - Remove redundant explicit type on TAG property in AdvancedCameraAnimationActivity.kt - Add named arguments for Kotlin function calls and inline parameter comments for Java calls - Verify lifecycle callback forwarding and add ON_PAUSE observer in Compose PathFollowingActivity --- .../com/example/maps3d/common/PathEngine.kt | 21 ++++++-- .../AdvancedCameraAnimationActivity.java | 38 +++++++++---- .../pathfollowing/PathFollowingActivity.java | 54 +++++++++++-------- .../AdvancedCameraAnimationActivity.kt | 15 ++++-- .../pathfollowing/PathFollowingActivity.kt | 45 +++++++++------- .../pathfollowing/PathFollowingActivity.kt | 28 ++++++++-- 6 files changed, 140 insertions(+), 61 deletions(-) diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt index 62c86ee..b1e4fed 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt @@ -20,7 +20,6 @@ import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps3d.model.AltitudeMode import com.google.android.gms.maps3d.model.LatLngAltitude import com.google.maps.android.SphericalUtil -import java.util.ArrayList /** * Result of interpolating a position and orientation along a 3D path at a specific distance. @@ -74,7 +73,12 @@ object PathEngine { distance: Double ): InterpolatedPathPoint { if (path.isEmpty()) { - return InterpolatedPathPoint(LatLng(0.0, 0.0), 0, 0.0, 0.0) + return InterpolatedPathPoint( + latLng = LatLng(0.0, 0.0), + waypointIndex = 0, + bearing = 0.0, + altitude = 0.0 + ) } val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0 @@ -97,7 +101,12 @@ object PathEngine { val bearing = SphericalUtil.computeHeading(latLng1, latLng2) val interpAlt = p1.altitude + fraction * (p2.altitude - p1.altitude) - return InterpolatedPathPoint(currentLatLng, index, bearing, interpAlt) + return InterpolatedPathPoint( + latLng = currentLatLng, + waypointIndex = index, + bearing = bearing, + altitude = interpAlt + ) } /** @@ -176,7 +185,7 @@ object PathEngine { ): List { if (path.isEmpty()) return emptyList() - val progressCoordinates = ArrayList() + val progressCoordinates = mutableListOf() val clampedIndex = waypointIndex.coerceIn(0, path.size - 1) for (i in 0..clampedIndex) { @@ -186,7 +195,9 @@ object PathEngine { AltitudeMode.ABSOLUTE -> pt.altitude + baseAltitude + pathAltitudeOffset + 0.4 else -> pt.altitude + pathAltitudeOffset + 0.4 } - progressCoordinates.add(LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude)) + progressCoordinates.add( + LatLngAltitude(pt.latitude, pt.longitude, vertexAltitude) + ) } val lastWaypoint = path[clampedIndex] 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 cf67638..966979b 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 @@ -153,8 +153,11 @@ public Camera getInitialCamera() { LatLng firstLoc = TourData.AIRPLANE_FLIGHT_PATH.get(0); double initialHeading = SphericalUtil.computeHeading(firstLoc, TourData.AIRPLANE_FLIGHT_PATH.get(1)); return new Camera( - new LatLngAltitude(firstLoc.latitude, firstLoc.longitude, 200.0), - normalizeHeading(initialHeading), + /* center= */ new LatLngAltitude( + /* latitude= */ firstLoc.latitude, + /* longitude= */ firstLoc.longitude, + /* altitude= */ 200.0), + /* heading= */ normalizeHeading(initialHeading), /* tilt= */ 65.0, /* roll= */ 0.0, /* range= */ 600.0); @@ -250,11 +253,19 @@ private void updateAirplaneModel(LatLng targetLatLng, double planeHeadingDeg) { ModelOptions opts = new ModelOptions(); opts.setId(TourData.MODEL_ID); - opts.setPosition(new LatLngAltitude(targetLatLng.latitude, targetLatLng.longitude, 200.0)); + opts.setPosition( + new LatLngAltitude( + /* latitude= */ targetLatLng.latitude, + /* longitude= */ targetLatLng.longitude, + /* altitude= */ 200.0)); opts.setAltitudeMode(AltitudeMode.ABSOLUTE); - opts.setOrientation(new Orientation(normalizeHeading(planeHeadingDeg), -90.0, 0.0)); + opts.setOrientation( + new Orientation( + /* heading= */ normalizeHeading(planeHeadingDeg), + /* tilt= */ -90.0, + /* roll= */ 0.0)); opts.setUrl(TourData.PLANE_URL); - opts.setScale(new Vector3D(0.08, 0.08, 0.08)); + opts.setScale(new Vector3D(/* x= */ 0.08, /* y= */ 0.08, /* z= */ 0.08)); airplaneModel = googleMap3D.addModel(opts); } @@ -311,7 +322,7 @@ private void runSimpleFlyTo() { } }); - googleMap3D.flyCameraTo(new FlyToOptions(targetCam, 1500L)); + googleMap3D.flyCameraTo(new FlyToOptions(/* camera= */ targetCam, /* durationMs= */ 1500L)); } private void startOrResumeTour() { @@ -352,7 +363,10 @@ private void executeKeyframeStep(int stepIndex) { currentStepIndex++; executeKeyframeStep(currentStepIndex); }); - googleMap3D.flyCameraTo(new FlyToOptions(flyStep.getTargetCamera(), flyStep.getDurationMs())); + googleMap3D.flyCameraTo( + new FlyToOptions( + /* camera= */ flyStep.getTargetCamera(), + /* durationMs= */ flyStep.getDurationMs())); } else if (step instanceof JavaCameraKeyframe.DwellPause) { JavaCameraKeyframe.DwellPause pauseStep = (JavaCameraKeyframe.DwellPause) step; handler.postDelayed( @@ -440,7 +454,10 @@ public void doFrame(long frameTimeNanos) { elapsedDistance = totalDistance; PositionAndHeading posAndHeading = RouteEngine.calculatePositionAndHeading( - TourData.AIRPLANE_FLIGHT_PATH, cumulativeDistances, elapsedDistance, 30.0); + /* route= */ TourData.AIRPLANE_FLIGHT_PATH, + /* cumulativeDistances= */ cumulativeDistances, + /* distance= */ elapsedDistance, + /* lookaheadDistance= */ 30.0); double planeHeading = posAndHeading.getHeading() + 180.0; updateAirplaneModel(posAndHeading.getPosition(), planeHeading); @@ -462,7 +479,10 @@ public void doFrame(long frameTimeNanos) { PositionAndHeading posAndHeading = RouteEngine.calculatePositionAndHeading( - TourData.AIRPLANE_FLIGHT_PATH, cumulativeDistances, elapsedDistance, 30.0); + /* route= */ TourData.AIRPLANE_FLIGHT_PATH, + /* cumulativeDistances= */ cumulativeDistances, + /* distance= */ elapsedDistance, + /* lookaheadDistance= */ 30.0); double planeHeading = posAndHeading.getHeading() + 180.0; updateAirplaneModel(posAndHeading.getPosition(), planeHeading); 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 725806c..73fee7f 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 @@ -344,7 +344,11 @@ private void drawStaticRoutePolyline() { if (googleMap3D == null || currentPath.isEmpty()) return; List staticVertices = - PathEngine.buildStaticVertices(currentPath, pathAltitudeMode, getBaseAltitude(), pathAltitudeOffset); + PathEngine.buildStaticVertices( + /* path= */ currentPath, + /* altitudeMode= */ pathAltitudeMode, + /* baseAltitude= */ getBaseAltitude(), + /* pathAltitudeOffset= */ pathAltitudeOffset); PolylineOptions staticOptions = new PolylineOptions(); staticOptions.setId(PathEngine.STATIC_POLYLINE_ID); // Fixed ID prevents flickering @@ -363,14 +367,14 @@ private void updateProgressPolyline(double dist, LatLng currentLatLng, int index List progressCoordinates = PathEngine.buildProgressVertices( - currentPath, - cumulativeDistances, - dist, - currentLatLng, - index, - pathAltitudeMode, - getBaseAltitude(), - pathAltitudeOffset); + /* path= */ currentPath, + /* cumulativeDistances= */ cumulativeDistances, + /* elapsedDistance= */ dist, + /* currentLatLng= */ currentLatLng, + /* waypointIndex= */ index, + /* altitudeMode= */ pathAltitudeMode, + /* baseAltitude= */ getBaseAltitude(), + /* pathAltitudeOffset= */ pathAltitudeOffset); PolylineOptions progressOptions = new PolylineOptions(); progressOptions.setId(PathEngine.PROGRESS_POLYLINE_ID); @@ -387,28 +391,36 @@ private void updateProgressPolyline(double dist, LatLng currentLatLng, int index private void updateCameraPositionForDistance(double dist) { if (googleMap3D == null || currentPath.isEmpty()) return; - InterpolatedPathPoint point = PathEngine.interpolatePoint(currentPath, cumulativeDistances, dist); + InterpolatedPathPoint point = PathEngine.interpolatePoint( + /* path= */ currentPath, + /* cumulativeDistances= */ cumulativeDistances, + /* distance= */ dist); double targetHeading = PathEngine.smoothHeading( - point.bearing + headingOffset, - currentHeading, - isUserScrubbing, - isPlaying, - 0.12); + /* targetHeading= */ point.bearing + headingOffset, + /* currentHeading= */ currentHeading, + /* isUserScrubbing= */ isUserScrubbing, + /* isPlaying= */ isPlaying, + /* smoothingFactor= */ 0.12); currentHeading = targetHeading; double cameraTargetAltitude = PathEngine.calculateCameraAltitude( - pathAltitudeMode, getBaseAltitude(), point.altitude, groundAltitude); + /* altitudeMode= */ pathAltitudeMode, + /* baseAltitude= */ getBaseAltitude(), + /* interpolatedAltitude= */ point.altitude, + /* groundAltitude= */ groundAltitude); Camera newCamera = new Camera( - new LatLngAltitude( - point.latLng.latitude, point.latLng.longitude, cameraTargetAltitude), - targetHeading, - cameraTilt, + /* center= */ new LatLngAltitude( + /* latitude= */ point.latLng.latitude, + /* longitude= */ point.latLng.longitude, + /* altitude= */ cameraTargetAltitude), + /* heading= */ targetHeading, + /* tilt= */ cameraTilt, /* roll= */ 0.0, - cameraRange); + /* range= */ cameraRange); googleMap3D.setCamera(newCamera); updateProgressPolyline(dist, point.latLng, point.waypointIndex); 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 e11ac22..0cee33f 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 @@ -102,7 +102,7 @@ enum class AnimationApproach(val title: String) { */ class AdvancedCameraAnimationActivity : SampleBaseActivity() { - override val TAG: String = "AdvancedCameraAnimation" + override val TAG = "AdvancedCameraAnimation" override val initialCamera: Camera get() { @@ -392,10 +392,10 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { } val posAndHeading = RouteEngine.calculatePositionAndHeading( - TourData.AIRPLANE_FLIGHT_PATH, - cumulativeDistances, - elapsedDistance, - 30.0 + route = TourData.AIRPLANE_FLIGHT_PATH, + cumulativeDistances = cumulativeDistances, + distance = elapsedDistance, + lookaheadDistance = 30.0 ) val planeHeading = posAndHeading.heading.toDouble() + 180.0 @@ -511,4 +511,9 @@ class AdvancedCameraAnimationActivity : SampleBaseActivity() { super.onPause() stopTour() } + + override fun onDestroy() { + super.onDestroy() + stopTour() + } } diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt index 6dc501a..023c1a4 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt @@ -307,7 +307,12 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { val map = googleMap3D ?: return if (currentPath.isEmpty()) return - val vertices = PathEngine.buildStaticVertices(currentPath, pathAltitudeMode, baseAltitude, pathAltitudeOffset) + val vertices = PathEngine.buildStaticVertices( + path = currentPath, + altitudeMode = pathAltitudeMode, + baseAltitude = baseAltitude, + pathAltitudeOffset = pathAltitudeOffset + ) val staticOptions = PolylineOptions().apply { id = PathEngine.STATIC_POLYLINE_ID path = vertices @@ -325,14 +330,14 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { if (currentPath.isEmpty() || totalDistance <= 0.0) return val vertices = PathEngine.buildProgressVertices( - currentPath, - cumulativeDistances, - dist, - currentLatLng, - index, - pathAltitudeMode, - baseAltitude, - pathAltitudeOffset + path = currentPath, + cumulativeDistances = cumulativeDistances, + elapsedDistance = dist, + currentLatLng = currentLatLng, + waypointIndex = index, + altitudeMode = pathAltitudeMode, + baseAltitude = baseAltitude, + pathAltitudeOffset = pathAltitudeOffset ) // In-place ID upsert updates the existing line in the 3D renderer without recreating objects @@ -352,20 +357,24 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { val map = googleMap3D ?: return if (currentPath.isEmpty()) return - val point = PathEngine.interpolatePoint(currentPath, cumulativeDistances, dist) + val point = PathEngine.interpolatePoint( + path = currentPath, + cumulativeDistances = cumulativeDistances, + distance = dist + ) val targetHeading = PathEngine.smoothHeading( - point.bearing + headingOffset, - currentHeading, - isUserScrubbing, - isPlaying + targetHeading = point.bearing + headingOffset, + currentHeading = currentHeading, + isUserScrubbing = isUserScrubbing, + isPlaying = isPlaying ) currentHeading = targetHeading val cameraTargetAltitude = PathEngine.calculateCameraAltitude( - pathAltitudeMode, - baseAltitude, - point.altitude, - groundAltitude + altitudeMode = pathAltitudeMode, + baseAltitude = baseAltitude, + interpolatedAltitude = point.altitude, + groundAltitude = groundAltitude ) val newCamera = camera { diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt index 3a64fc4..a213926 100644 --- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt @@ -58,6 +58,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableDoubleStateOf @@ -79,6 +80,9 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.core.graphics.toColorInt +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import com.example.maps3d.common.PathData import com.example.maps3d.common.PathEngine import com.example.maps3dcommon.R @@ -146,11 +150,24 @@ fun PathFollowingScreen() { var currentHeading by remember { mutableStateOf(null) } val cumulativeDistances = remember(currentPath) { - PathEngine.calculateCumulativeDistances(currentPath) + PathEngine.calculateCumulativeDistances(path = currentPath) } val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0 val baseAltitude = if (currentPath == PathData.RURAL_PATH) 45.0 else 50.0 + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE) { + isPlaying = false + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + // Frame animation loop with delta-time integration LaunchedEffect(isPlaying, totalDistance, followSpeedMps) { if (!isPlaying || totalDistance <= 0.0) return@LaunchedEffect @@ -178,7 +195,7 @@ fun PathFollowingScreen() { // Interpolate camera and position val interpPoint = remember(currentPath, cumulativeDistances, elapsedDistance) { - PathEngine.interpolatePoint(currentPath, cumulativeDistances, elapsedDistance) + PathEngine.interpolatePoint(path = currentPath, cumulativeDistances = cumulativeDistances, distance = elapsedDistance) } val targetHeading = remember(interpPoint.bearing, headingOffset, currentHeading, isUserScrubbing, isPlaying) { @@ -217,7 +234,12 @@ fun PathFollowingScreen() { // Dual-Polyline Configurations val staticPolylineConfig = remember(currentPath, selectedAltitudeMode, pathAltitudeOffset, drawsOccludedSegments, baseAltitude) { - val vertices = PathEngine.buildStaticVertices(currentPath, selectedAltitudeMode.mode, baseAltitude, pathAltitudeOffset.toDouble()) + val vertices = PathEngine.buildStaticVertices( + path = currentPath, + altitudeMode = selectedAltitudeMode.mode, + baseAltitude = baseAltitude, + pathAltitudeOffset = pathAltitudeOffset.toDouble(), + ) PolylineConfig( key = PathEngine.STATIC_POLYLINE_ID, points = vertices, From 4ff5b3cc8e0f8ff3b3523ba6839819e0b0b0f008 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:42:08 -0600 Subject: [PATCH 4/6] fix(path-following): smooth playback with VSYNC Choreographer and lookahead heading --- .../com/example/maps3d/common/PathEngine.kt | 61 +++++++++++++++++-- .../pathfollowing/PathFollowingActivity.java | 34 ++++++----- .../pathfollowing/PathFollowingActivity.kt | 36 ++++++----- 3 files changed, 95 insertions(+), 36 deletions(-) diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt index b1e4fed..050dbe8 100644 --- a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathEngine.kt @@ -26,7 +26,7 @@ import com.google.maps.android.SphericalUtil * * @property latLng The interpolated 2D geographic coordinate. * @property waypointIndex The zero-based index of the segment start waypoint. - * @property bearing The raw compass bearing (degrees) of the current route segment. + * @property bearing The forward-looking tangent compass bearing (degrees). * @property altitude The interpolated elevation in meters along the route segment. */ data class InterpolatedPathPoint( @@ -64,13 +64,47 @@ object PathEngine { } /** - * Finds the interpolated geographic position, segment bearing, and elevation at a target distance. + * Helper to interpolate 2D LatLng position at any distance along the route. */ @JvmStatic - fun interpolatePoint( + fun getInterpolatedLatLng( path: List, cumulativeDistances: DoubleArray, distance: Double + ): LatLng { + if (path.isEmpty()) return LatLng(0.0, 0.0) + val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0 + if (distance <= 0.0) return LatLng(path.first().latitude, path.first().longitude) + if (distance >= totalDistance) return LatLng(path.last().latitude, path.last().longitude) + + var index = 0 + while (index < cumulativeDistances.size - 1 && cumulativeDistances[index + 1] < distance) { + index++ + } + + val p1 = path[index] + val p2 = if (index < path.size - 1) path[index + 1] else p1 + val d1 = cumulativeDistances[index] + val d2 = cumulativeDistances.getOrElse(index + 1) { totalDistance } + val segLen = d2 - d1 + val fraction = if (segLen > 0) ((distance - d1) / segLen).coerceIn(0.0, 1.0) else 0.0 + + val latLng1 = LatLng(p1.latitude, p1.longitude) + val latLng2 = LatLng(p2.latitude, p2.longitude) + return SphericalUtil.interpolate(latLng1, latLng2, fraction) + } + + /** + * Finds the interpolated geographic position, smooth forward lookahead bearing, + * and elevation at a target distance. + */ + @JvmStatic + @JvmOverloads + fun interpolatePoint( + path: List, + cumulativeDistances: DoubleArray, + distance: Double, + lookaheadDistance: Double = 25.0 ): InterpolatedPathPoint { if (path.isEmpty()) { return InterpolatedPathPoint( @@ -98,13 +132,30 @@ object PathEngine { val latLng1 = LatLng(p1.latitude, p1.longitude) val latLng2 = LatLng(p2.latitude, p2.longitude) val currentLatLng = SphericalUtil.interpolate(latLng1, latLng2, fraction) - val bearing = SphericalUtil.computeHeading(latLng1, latLng2) val interpAlt = p1.altitude + fraction * (p2.altitude - p1.altitude) + // Smooth forward lookahead tangent heading calculation + val targetLookaheadDist = (distance + lookaheadDistance).coerceAtMost(totalDistance) + val lookaheadPos = getInterpolatedLatLng(path, cumulativeDistances, targetLookaheadDist) + + val bearing = if (targetLookaheadDist > distance && currentLatLng != lookaheadPos) { + SphericalUtil.computeHeading(currentLatLng, lookaheadPos) + } else if (distance > 1.0) { + val prevPos = getInterpolatedLatLng(path, cumulativeDistances, distance - 1.0) + SphericalUtil.computeHeading(prevPos, currentLatLng) + } else if (path.size >= 2) { + SphericalUtil.computeHeading( + LatLng(path[0].latitude, path[0].longitude), + LatLng(path[1].latitude, path[1].longitude) + ) + } else { + 0.0 + } + return InterpolatedPathPoint( latLng = currentLatLng, waypointIndex = index, - bearing = bearing, + bearing = (bearing + 360.0) % 360.0, altitude = interpAlt ) } 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 73fee7f..c8dfffb 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 @@ -20,6 +20,7 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.view.Choreographer; import android.view.MotionEvent; import android.view.View; import android.widget.RadioGroup; @@ -113,8 +114,7 @@ private double getBaseAltitude() { private Polyline progressPolyline; // Animation & auto-fade handlers - private Runnable animationRunnable; - private final Handler animationHandler = new Handler(Looper.getMainLooper()); + private Choreographer.FrameCallback frameCallback; private final Handler fadeHandler = new Handler(Looper.getMainLooper()); private final Runnable fadeOutRunnable = () -> { @@ -431,18 +431,22 @@ private void startAnimation() { isPlaying = true; btnPlayPause.setIconResource(R.drawable.pause_24px); - long frameDurationMs = 16L; - animationRunnable = - new Runnable() { - private long lastTime = System.currentTimeMillis(); + frameCallback = + new Choreographer.FrameCallback() { + private long lastTimeNanos = 0L; @Override - public void run() { + public void doFrame(long frameTimeNanos) { if (!isPlaying) return; - long now = System.currentTimeMillis(); - double dt = (now - lastTime) / 1000.0; - lastTime = now; + if (lastTimeNanos == 0L) { + lastTimeNanos = frameTimeNanos; + Choreographer.getInstance().postFrameCallback(this); + return; + } + + double dt = (frameTimeNanos - lastTimeNanos) / 1_000_000_000.0; + lastTimeNanos = frameTimeNanos; double stepDistance = followSpeedMps * dt; elapsedDistance += stepDistance; @@ -457,18 +461,18 @@ public void run() { } updateCameraPositionForDistance(elapsedDistance); - animationHandler.postDelayed(this, frameDurationMs); + Choreographer.getInstance().postFrameCallback(this); } }; - animationHandler.post(animationRunnable); + Choreographer.getInstance().postFrameCallback(frameCallback); } private void pauseAnimation() { isPlaying = false; btnPlayPause.setIconResource(R.drawable.play_arrow_24px); - if (animationRunnable != null) { - animationHandler.removeCallbacks(animationRunnable); - animationRunnable = null; + if (frameCallback != null) { + Choreographer.getInstance().removeFrameCallback(frameCallback); + frameCallback = null; } } diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt index 023c1a4..2ae9c41 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt @@ -19,6 +19,7 @@ package com.example.maps3dkotlin.pathfollowing import android.os.Bundle import android.os.Handler import android.os.Looper +import android.view.Choreographer import android.view.MotionEvent import android.view.View import android.widget.RadioGroup @@ -110,8 +111,7 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { private var progressPolyline: Polyline? = null // Animation & auto-fade handlers - private var animationRunnable: Runnable? = null - private val animationHandler = Handler(Looper.getMainLooper()) + private var frameCallback: Choreographer.FrameCallback? = null private val fadeHandler = Handler(Looper.getMainLooper()) private val fadeOutRunnable = Runnable { if (controlsCard != null && !isCollapsed) { @@ -397,16 +397,19 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { isPlaying = true btnPlayPause.setIconResource(R.drawable.pause_24px) - val frameDurationMs = 16L - animationRunnable = object : Runnable { - private var lastTime = System.currentTimeMillis() - - override fun run() { + var lastTimeNanos = 0L + frameCallback = object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { if (!isPlaying) return - val now = System.currentTimeMillis() - val dt = (now - lastTime) / 1000.0 - lastTime = now + if (lastTimeNanos == 0L) { + lastTimeNanos = frameTimeNanos + Choreographer.getInstance().postFrameCallback(this) + return + } + + val dt = (frameTimeNanos - lastTimeNanos) / 1_000_000_000.0 + lastTimeNanos = frameTimeNanos elapsedDistance += followSpeedMps * dt if (elapsedDistance >= totalDistance) { @@ -414,22 +417,23 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { } if (!isUserScrubbing && totalDistance > 0) { - progressSlider.value = (elapsedDistance / totalDistance).toFloat().coerceIn(0f, 1f) + val progressRatio = (elapsedDistance / totalDistance).toFloat().coerceIn(0f, 1f) + progressSlider.value = progressRatio } updateCameraPositionForDistance(elapsedDistance) - animationHandler.postDelayed(this, frameDurationMs) + Choreographer.getInstance().postFrameCallback(this) } } - animationHandler.post(animationRunnable!!) + Choreographer.getInstance().postFrameCallback(frameCallback!!) } private fun pauseAnimation() { isPlaying = false btnPlayPause.setIconResource(R.drawable.play_arrow_24px) - animationRunnable?.let { - animationHandler.removeCallbacks(it) - animationRunnable = null + frameCallback?.let { + Choreographer.getInstance().removeFrameCallback(it) + frameCallback = null } } From 7dd3aab69c779589d2e7293fbb9cc91e7ca5785d Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:49:16 -0600 Subject: [PATCH 5/6] refactor(path-following): introduce PathPlaybackController, ViewModel, and unit tests - Separate path following math and physics into framework-independent PathPlaybackController - Add comprehensive JVM Unit Tests (PathPlaybackControllerTest) - Introduce PathFollowingViewModel exposing reactive StateFlow/LiveData - Refactor Kotlin Views, Java Views, and Jetpack Compose to be pure presentation layers --- .../ApiDemos/common/build.gradle.kts | 2 + .../maps3d/common/PathFollowingViewModel.kt | 115 ++++ .../maps3d/common/PathPlaybackController.kt | 383 ++++++++++++ .../common/PathPlaybackControllerTest.kt | 135 ++++ .../pathfollowing/PathFollowingActivity.java | 556 ++++++----------- .../pathfollowing/PathFollowingActivity.kt | 506 ++++++--------- .../pathfollowing/PathFollowingActivity.kt | 586 +++++++----------- gradle/libs.versions.toml | 1 + 8 files changed, 1228 insertions(+), 1056 deletions(-) create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathFollowingViewModel.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathPlaybackController.kt create mode 100644 Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathPlaybackControllerTest.kt diff --git a/Maps3DSamples/ApiDemos/common/build.gradle.kts b/Maps3DSamples/ApiDemos/common/build.gradle.kts index 163eed9..8f4664e 100644 --- a/Maps3DSamples/ApiDemos/common/build.gradle.kts +++ b/Maps3DSamples/ApiDemos/common/build.gradle.kts @@ -61,7 +61,9 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) + api(libs.androidx.lifecycle.viewmodel.ktx) testImplementation(libs.junit) + testImplementation(libs.google.truth) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) implementation(platform(libs.androidx.compose.bom)) diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathFollowingViewModel.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathFollowingViewModel.kt new file mode 100644 index 0000000..89f9f2d --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathFollowingViewModel.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import com.google.android.gms.maps3d.model.LatLngAltitude +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * ViewModel managing reactive UI state for 3D Path Following samples across + * Kotlin Views, Java Views, and Jetpack Compose. + * + * Delegates all calculation, physics, and interpolation to [PathPlaybackController]. + */ +class PathFollowingViewModel( + initialRoute: List = PathData.URBAN_PATH +) : ViewModel() { + + private val controller = PathPlaybackController(initialRoute) + private val _uiState = MutableStateFlow(controller.getState()) + + /** + * Observable reactive state for Kotlin Coroutine Flow collectors and Compose. + */ + val uiState: StateFlow = _uiState.asStateFlow() + + /** + * Observable LiveData bridge for Java View consumers. + */ + val liveData by lazy { _uiState.asLiveData() } + + val currentState: PathPlaybackState + get() = _uiState.value + + fun advance(deltaTimeSeconds: Double) { + _uiState.value = controller.advance(deltaTimeSeconds) + } + + fun seekToRatio(ratio: Float) { + _uiState.value = controller.seekToRatio(ratio) + } + + fun seekToDistance(distanceMeters: Double) { + _uiState.value = controller.seekToDistance(distanceMeters) + } + + fun setScrubbing(isScrubbing: Boolean) { + _uiState.value = controller.setScrubbing(isScrubbing) + } + + fun setPlaying(isPlaying: Boolean) { + _uiState.value = controller.setPlaying(isPlaying) + } + + fun togglePlayPause() { + _uiState.value = controller.togglePlayPause() + } + + fun setRoute(newRoute: List, applyDefaults: Boolean = true) { + _uiState.value = controller.setRoute(newRoute, applyDefaults) + } + + fun setAltitudeMode(mode: Int) { + _uiState.value = controller.setAltitudeMode(mode) + } + + fun setDrawsOccludedSegments(drawsOccluded: Boolean) { + _uiState.value = controller.setDrawsOccludedSegments(drawsOccluded) + } + + fun setPathAltitudeOffset(offset: Double) { + _uiState.value = controller.setPathAltitudeOffset(offset) + } + + fun setCameraRange(range: Double) { + _uiState.value = controller.setCameraRange(range) + } + + fun setGroundAltitude(altitude: Double) { + _uiState.value = controller.setGroundAltitude(altitude) + } + + fun setHeadingOffset(offset: Double) { + _uiState.value = controller.setHeadingOffset(offset) + } + + fun setCameraTilt(tilt: Double) { + _uiState.value = controller.setCameraTilt(tilt) + } + + fun setFollowSpeed(speedMps: Double) { + _uiState.value = controller.setFollowSpeed(speedMps) + } + + fun reset() { + _uiState.value = controller.reset() + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathPlaybackController.kt b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathPlaybackController.kt new file mode 100644 index 0000000..f477617 --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/main/java/com/example/maps3d/common/PathPlaybackController.kt @@ -0,0 +1,383 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.LatLngAltitude + +/** + * Immutable state representation of the path following engine and camera position. + */ +data class PathPlaybackState( + val route: List = PathData.URBAN_PATH, + val cumulativeDistances: DoubleArray = doubleArrayOf(0.0), + val totalDistance: Double = 0.0, + val elapsedDistance: Double = 0.0, + val progressRatio: Float = 0.0f, + val isPlaying: Boolean = false, + val isScrubbing: Boolean = false, + val followSpeedMps: Double = 30.0, + val cameraRange: Double = 300.0, + val groundAltitude: Double = 20.0, + val headingOffset: Double = 0.0, + val cameraTilt: Double = 70.0, + val altitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND, + val pathAltitudeOffset: Double = 0.5, + val drawsOccludedSegments: Boolean = true, + val currentPosition: LatLng = LatLng(0.0, 0.0), + val currentAltitude: Double = 0.0, + val currentHeading: Double = 0.0, + val staticPolylineVertices: List = emptyList(), + val progressPolylineVertices: List = emptyList() +) { + val baseAltitude: Double + get() = if (route == PathData.RURAL_PATH) 45.0 else 50.0 + + val cameraTargetAltitude: Double + get() = PathEngine.calculateCameraAltitude( + altitudeMode = altitudeMode, + baseAltitude = baseAltitude, + interpolatedAltitude = currentAltitude, + groundAltitude = groundAltitude + ) + + val effectiveHeading: Double + get() = (currentHeading + headingOffset + 360.0) % 360.0 + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PathPlaybackState) return false + + return route == other.route && + cumulativeDistances.contentEquals(other.cumulativeDistances) && + totalDistance == other.totalDistance && + elapsedDistance == other.elapsedDistance && + progressRatio == other.progressRatio && + isPlaying == other.isPlaying && + isScrubbing == other.isScrubbing && + followSpeedMps == other.followSpeedMps && + cameraRange == other.cameraRange && + groundAltitude == other.groundAltitude && + headingOffset == other.headingOffset && + cameraTilt == other.cameraTilt && + altitudeMode == other.altitudeMode && + pathAltitudeOffset == other.pathAltitudeOffset && + drawsOccludedSegments == other.drawsOccludedSegments && + currentPosition == other.currentPosition && + currentAltitude == other.currentAltitude && + currentHeading == other.currentHeading && + staticPolylineVertices == other.staticPolylineVertices && + progressPolylineVertices == other.progressPolylineVertices + } + + override fun hashCode(): Int { + var result = route.hashCode() + result = 31 * result + cumulativeDistances.contentHashCode() + result = 31 * result + totalDistance.hashCode() + result = 31 * result + elapsedDistance.hashCode() + result = 31 * result + progressRatio.hashCode() + result = 31 * result + isPlaying.hashCode() + result = 31 * result + isScrubbing.hashCode() + result = 31 * result + followSpeedMps.hashCode() + result = 31 * result + cameraRange.hashCode() + result = 31 * result + groundAltitude.hashCode() + result = 31 * result + headingOffset.hashCode() + result = 31 * result + cameraTilt.hashCode() + result = 31 * result + altitudeMode.hashCode() + result = 31 * result + pathAltitudeOffset.hashCode() + result = 31 * result + drawsOccludedSegments.hashCode() + result = 31 * result + currentPosition.hashCode() + result = 31 * result + currentAltitude.hashCode() + result = 31 * result + currentHeading.hashCode() + result = 31 * result + staticPolylineVertices.hashCode() + result = 31 * result + progressPolylineVertices.hashCode() + return result + } +} + +/** + * Framework-independent controller for 3D path following playback and camera interpolation. + * + * Encapsulates all domain math, distance tracking, kinematic heading smoothing, and polyline + * vertex generation. Has zero dependencies on Android Views, UI widgets, or GoogleMap3D rendering + * classes, making it 100% unit-testable on the JVM. + */ +class PathPlaybackController( + initialRoute: List = PathData.URBAN_PATH +) { + private var state: PathPlaybackState + + init { + val cumDist = PathEngine.calculateCumulativeDistances(initialRoute) + val totalDist = cumDist.lastOrNull() ?: 0.0 + val baseAlt = if (initialRoute == PathData.RURAL_PATH) 45.0 else 50.0 + val staticVertices = PathEngine.buildStaticVertices( + path = initialRoute, + altitudeMode = AltitudeMode.CLAMP_TO_GROUND, + baseAltitude = baseAlt, + pathAltitudeOffset = 0.5 + ) + val point = PathEngine.interpolatePoint( + path = initialRoute, + cumulativeDistances = cumDist, + distance = 0.0 + ) + val progressVertices = PathEngine.buildProgressVertices( + path = initialRoute, + cumulativeDistances = cumDist, + elapsedDistance = 0.0, + currentLatLng = point.latLng, + waypointIndex = point.waypointIndex, + altitudeMode = AltitudeMode.CLAMP_TO_GROUND, + baseAltitude = baseAlt, + pathAltitudeOffset = 0.5 + ) + + state = PathPlaybackState( + route = initialRoute, + cumulativeDistances = cumDist, + totalDistance = totalDist, + elapsedDistance = 0.0, + progressRatio = 0f, + isPlaying = false, + isScrubbing = false, + currentPosition = point.latLng, + currentAltitude = point.altitude, + currentHeading = point.bearing, + staticPolylineVertices = staticVertices, + progressPolylineVertices = progressVertices + ) + } + + fun getState(): PathPlaybackState = state + + /** + * Advances playback by a specific time delta (in seconds). + */ + fun advance(deltaTimeSeconds: Double): PathPlaybackState { + if (!state.isPlaying || state.totalDistance <= 0.0) return state + + val stepDist = state.followSpeedMps * deltaTimeSeconds + var newDist = state.elapsedDistance + stepDist + if (newDist >= state.totalDistance) { + newDist %= state.totalDistance + } + + return updateDistanceAndRecompute(newDistance = newDist, updateProgressRatio = !state.isScrubbing) + } + + /** + * Seeks to a specific normalized progress ratio [0.0, 1.0]. + */ + fun seekToRatio(ratio: Float): PathPlaybackState { + val clampedRatio = ratio.coerceIn(0f, 1f) + val targetDist = state.totalDistance * clampedRatio.toDouble() + state = state.copy(progressRatio = clampedRatio) + return updateDistanceAndRecompute(newDistance = targetDist, updateProgressRatio = false) + } + + /** + * Seeks to a specific distance along the route in meters. + */ + fun seekToDistance(distanceMeters: Double): PathPlaybackState { + val targetDist = distanceMeters.coerceIn(0.0, state.totalDistance) + val ratio = if (state.totalDistance > 0.0) (targetDist / state.totalDistance).toFloat().coerceIn(0f, 1f) else 0f + state = state.copy(progressRatio = ratio) + return updateDistanceAndRecompute(newDistance = targetDist, updateProgressRatio = false) + } + + fun setScrubbing(isScrubbing: Boolean): PathPlaybackState { + state = state.copy(isScrubbing = isScrubbing) + return state + } + + fun setPlaying(isPlaying: Boolean): PathPlaybackState { + state = state.copy(isPlaying = isPlaying) + return state + } + + fun togglePlayPause(): PathPlaybackState { + state = state.copy(isPlaying = !state.isPlaying) + return state + } + + fun setRoute(newRoute: List, applyDefaults: Boolean = true): PathPlaybackState { + val cumDist = PathEngine.calculateCumulativeDistances(newRoute) + val totalDist = cumDist.lastOrNull() ?: 0.0 + val isRural = newRoute == PathData.RURAL_PATH + + val range = if (applyDefaults) (if (isRural) 450.0 else 300.0) else state.cameraRange + val groundAlt = if (applyDefaults) (if (isRural) 40.0 else 20.0) else state.groundAltitude + val tilt = if (applyDefaults) (if (isRural) 75.0 else 70.0) else state.cameraTilt + val baseAlt = if (isRural) 45.0 else 50.0 + + val point = PathEngine.interpolatePoint( + path = newRoute, + cumulativeDistances = cumDist, + distance = 0.0 + ) + + val staticVertices = PathEngine.buildStaticVertices( + path = newRoute, + altitudeMode = state.altitudeMode, + baseAltitude = baseAlt, + pathAltitudeOffset = state.pathAltitudeOffset + ) + + val progressVertices = PathEngine.buildProgressVertices( + path = newRoute, + cumulativeDistances = cumDist, + elapsedDistance = 0.0, + currentLatLng = point.latLng, + waypointIndex = point.waypointIndex, + altitudeMode = state.altitudeMode, + baseAltitude = baseAlt, + pathAltitudeOffset = state.pathAltitudeOffset + ) + + state = state.copy( + route = newRoute, + cumulativeDistances = cumDist, + totalDistance = totalDist, + elapsedDistance = 0.0, + progressRatio = 0f, + isPlaying = false, + cameraRange = range, + groundAltitude = groundAlt, + cameraTilt = tilt, + currentPosition = point.latLng, + currentAltitude = point.altitude, + currentHeading = point.bearing, + staticPolylineVertices = staticVertices, + progressPolylineVertices = progressVertices + ) + return state + } + + fun setAltitudeMode(mode: Int): PathPlaybackState { + state = state.copy(altitudeMode = mode) + return recomputeVerticesAndAltitude() + } + + fun setDrawsOccludedSegments(drawsOccluded: Boolean): PathPlaybackState { + state = state.copy(drawsOccludedSegments = drawsOccluded) + return state + } + + fun setPathAltitudeOffset(offset: Double): PathPlaybackState { + state = state.copy(pathAltitudeOffset = offset) + return recomputeVerticesAndAltitude() + } + + fun setCameraRange(range: Double): PathPlaybackState { + state = state.copy(cameraRange = range) + return state + } + + fun setGroundAltitude(altitude: Double): PathPlaybackState { + state = state.copy(groundAltitude = altitude) + return state + } + + fun setHeadingOffset(offset: Double): PathPlaybackState { + state = state.copy(headingOffset = offset) + return state + } + + fun setCameraTilt(tilt: Double): PathPlaybackState { + state = state.copy(cameraTilt = tilt) + return state + } + + fun setFollowSpeed(speedMps: Double): PathPlaybackState { + state = state.copy(followSpeedMps = speedMps) + return state + } + + fun reset(): PathPlaybackState { + return seekToDistance(0.0).copy(isPlaying = false) + } + + private fun updateDistanceAndRecompute(newDistance: Double, updateProgressRatio: Boolean): PathPlaybackState { + val point = PathEngine.interpolatePoint( + path = state.route, + cumulativeDistances = state.cumulativeDistances, + distance = newDistance + ) + + val smoothedHeading = PathEngine.smoothHeading( + targetHeading = point.bearing, + currentHeading = state.currentHeading, + isUserScrubbing = state.isScrubbing, + isPlaying = state.isPlaying + ) + + val progressVertices = PathEngine.buildProgressVertices( + path = state.route, + cumulativeDistances = state.cumulativeDistances, + elapsedDistance = newDistance, + currentLatLng = point.latLng, + waypointIndex = point.waypointIndex, + altitudeMode = state.altitudeMode, + baseAltitude = state.baseAltitude, + pathAltitudeOffset = state.pathAltitudeOffset + ) + + val ratio = if (updateProgressRatio && state.totalDistance > 0.0) { + (newDistance / state.totalDistance).toFloat().coerceIn(0f, 1f) + } else { + state.progressRatio + } + + state = state.copy( + elapsedDistance = newDistance, + progressRatio = ratio, + currentPosition = point.latLng, + currentAltitude = point.altitude, + currentHeading = smoothedHeading, + progressPolylineVertices = progressVertices + ) + return state + } + + private fun recomputeVerticesAndAltitude(): PathPlaybackState { + val staticVertices = PathEngine.buildStaticVertices( + path = state.route, + altitudeMode = state.altitudeMode, + baseAltitude = state.baseAltitude, + pathAltitudeOffset = state.pathAltitudeOffset + ) + + val progressVertices = PathEngine.buildProgressVertices( + path = state.route, + cumulativeDistances = state.cumulativeDistances, + elapsedDistance = state.elapsedDistance, + currentLatLng = state.currentPosition, + waypointIndex = 0, + altitudeMode = state.altitudeMode, + baseAltitude = state.baseAltitude, + pathAltitudeOffset = state.pathAltitudeOffset + ) + + state = state.copy( + staticPolylineVertices = staticVertices, + progressPolylineVertices = progressVertices + ) + return state + } +} diff --git a/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathPlaybackControllerTest.kt b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathPlaybackControllerTest.kt new file mode 100644 index 0000000..e369bec --- /dev/null +++ b/Maps3DSamples/ApiDemos/common/src/test/java/com/example/maps3d/common/PathPlaybackControllerTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.maps3d.common + +import com.google.android.gms.maps3d.model.AltitudeMode +import com.google.android.gms.maps3d.model.LatLngAltitude +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test + +class PathPlaybackControllerTest { + + private lateinit var controller: PathPlaybackController + + @Before + fun setUp() { + controller = PathPlaybackController(PathData.URBAN_PATH) + } + + @Test + fun initialState_hasCorrectDefaultsAndCalculatesDistances() { + val state = controller.getState() + + assertThat(state.route).isEqualTo(PathData.URBAN_PATH) + assertThat(state.totalDistance).isGreaterThan(3000.0) + assertThat(state.elapsedDistance).isEqualTo(0.0) + assertThat(state.progressRatio).isEqualTo(0.0f) + assertThat(state.isPlaying).isFalse() + assertThat(state.isScrubbing).isFalse() + assertThat(state.altitudeMode).isEqualTo(AltitudeMode.CLAMP_TO_GROUND) + assertThat(state.staticPolylineVertices).isNotEmpty() + assertThat(state.progressPolylineVertices).isNotEmpty() + } + + @Test + fun togglePlayPause_updatesPlayingState() { + val playingState = controller.togglePlayPause() + assertThat(playingState.isPlaying).isTrue() + + val pausedState = controller.togglePlayPause() + assertThat(pausedState.isPlaying).isFalse() + } + + @Test + fun advance_movesDistanceProportionalToSpeedAndDeltaTime() { + controller.setPlaying(true) + controller.setFollowSpeed(50.0) // 50 m/s + + val updatedState = controller.advance(deltaTimeSeconds = 2.0) // 100 meters forward + + assertThat(updatedState.elapsedDistance).isEqualTo(100.0) + assertThat(updatedState.progressRatio).isGreaterThan(0.0f) + assertThat(updatedState.currentPosition.latitude).isNotEqualTo(0.0) + } + + @Test + fun advance_wrapsAroundAtEndOfRoute() { + controller.setPlaying(true) + controller.setFollowSpeed(1000.0) + + val totalDist = controller.getState().totalDistance + val steps = (totalDist / 1000.0) + 1.0 + + val wrappedState = controller.advance(deltaTimeSeconds = steps) + assertThat(wrappedState.elapsedDistance).isLessThan(totalDist) + } + + @Test + fun seekToRatio_calculatesCorrectDistanceAndProgress() { + val targetRatio = 0.5f + val state = controller.seekToRatio(targetRatio) + + assertThat(state.progressRatio).isEqualTo(targetRatio) + assertThat(state.elapsedDistance).isWithin(1.0).of(state.totalDistance * 0.5) + } + + @Test + fun seekToDistance_clampsToBounds() { + val belowZero = controller.seekToDistance(-50.0) + assertThat(belowZero.elapsedDistance).isEqualTo(0.0) + + val beyondEnd = controller.seekToDistance(controller.getState().totalDistance + 5000.0) + assertThat(beyondEnd.elapsedDistance).isEqualTo(controller.getState().totalDistance) + } + + @Test + fun setAltitudeMode_updatesVerticesAndCalculatesCorrectCameraElevation() { + val absoluteState = controller.setAltitudeMode(AltitudeMode.ABSOLUTE) + + assertThat(absoluteState.altitudeMode).isEqualTo(AltitudeMode.ABSOLUTE) + // Camera target elevation in ABSOLUTE mode includes base altitude + route elevation + ground altitude + assertThat(absoluteState.cameraTargetAltitude).isGreaterThan(50.0) + } + + @Test + fun setRoute_switchesToRuralAndUpdatesDefaults() { + val ruralState = controller.setRoute(PathData.RURAL_PATH, applyDefaults = true) + + assertThat(ruralState.route).isEqualTo(PathData.RURAL_PATH) + assertThat(ruralState.cameraRange).isEqualTo(450.0) + assertThat(ruralState.groundAltitude).isEqualTo(40.0) + assertThat(ruralState.cameraTilt).isEqualTo(75.0) + assertThat(ruralState.elapsedDistance).isEqualTo(0.0) + } + + @Test + fun smoothHeading_computesContinuousLookaheadBearing() { + val p0 = controller.seekToDistance(0.0) + val p1 = controller.seekToDistance(100.0) + + assertThat(p0.currentHeading).isGreaterThan(0.0) + assertThat(p1.currentHeading).isGreaterThan(0.0) + assertThat(p0.currentHeading).isLessThan(360.0) + } + + @Test + fun progressPolyline_containsAtLeastTwoVerticesForRendering() { + val zeroState = controller.seekToDistance(0.0) + assertThat(zeroState.progressPolylineVertices.size).isAtLeast(2) + } +} 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 c8dfffb..3205694 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 @@ -28,12 +28,12 @@ import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.cardview.widget.CardView; -import androidx.core.view.WindowCompat; -import com.example.maps3d.common.InterpolatedPathPoint; +import androidx.lifecycle.ViewModelProvider; import com.example.maps3d.common.PathData; import com.example.maps3d.common.PathEngine; +import com.example.maps3d.common.PathFollowingViewModel; +import com.example.maps3d.common.PathPlaybackState; import com.example.maps3dcommon.R; -import com.google.android.gms.maps.model.LatLng; import com.google.android.gms.maps3d.GoogleMap3D; import com.google.android.gms.maps3d.Map3DView; import com.google.android.gms.maps3d.OnMap3DViewReadyCallback; @@ -45,27 +45,29 @@ import com.google.android.material.button.MaterialButton; import com.google.android.material.materialswitch.MaterialSwitch; import com.google.android.material.slider.Slider; -import java.util.List; /** - * Advanced sample demonstrating ground-level path following in Java. + * Demonstrates 3D Path Following using an MVVM architecture with [PathFollowingViewModel]. * - * Key Concepts Demonstrated: - * 1. Dual-Polyline Architecture: Layered base route (lower z-index) and traversed progress route (higher z-index). - * 2. In-Place Polyline ID Updates: Stable IDs prevent render flickering during rapid real-time updates. - * 3. 3D Altitude Modes: Dynamic switching between Clamp to Ground, Relative, and Absolute elevation. - * 4. Occlusion Control: Toggling drawsOccludedSegments through terrain and buildings. - * 5. Kinematic Heading Smoothing: Exponential moving average low-pass filter to smooth camera cornering. + * The Activity acts as a pure presentation layer: it observes [PathPlaybackState] from the ViewModel + * and renders camera positions and dual polylines into [GoogleMap3D]. */ public class PathFollowingActivity extends AppCompatActivity implements OnMap3DViewReadyCallback { + private PathFollowingViewModel viewModel; + + // 3D Map View private Map3DView map3DView; private GoogleMap3D googleMap3D; - // View Bindings + // Polylines + private Polyline staticRoutePolyline; + private Polyline progressPolyline; + + // Control panel overlay bindings private CardView controlsCard; - private View cardHeader; private MaterialButton btnCollapse; + private View controlsScroll; private boolean isCollapsed = false; private RadioGroup rgEnvironment; @@ -86,81 +88,37 @@ public class PathFollowingActivity extends AppCompatActivity implements OnMap3DV private Slider speedSlider; private TextView speedSliderLabel; - // Control parameters - private double cameraRange = 300.0; - private double groundAltitude = 20.0; - private double headingOffset = 0.0; - private double cameraTilt = 70.0; - private double followSpeedMps = 30.0; - private int pathAltitudeMode = AltitudeMode.CLAMP_TO_GROUND; - private double pathAltitudeOffset = 0.5; - private boolean drawsOccludedSegments = true; - - // Path state - private List currentPath = PathData.URBAN_PATH; - private double[] cumulativeDistances = new double[0]; - private double totalDistance = 0.0; - private double elapsedDistance = 0.0; - private boolean isPlaying = false; - private boolean isUserScrubbing = false; - private Double currentHeading = null; - - private double getBaseAltitude() { - return currentPath.equals(PathData.RURAL_PATH) ? 45.0 : 50.0; - } - - // Polyline handles - private Polyline staticRoutePolyline; - private Polyline progressPolyline; - - // Animation & auto-fade handlers + // Auto-fade & VSYNC Choreographer private Choreographer.FrameCallback frameCallback; private final Handler fadeHandler = new Handler(Looper.getMainLooper()); - private final Runnable fadeOutRunnable = - () -> { - if (controlsCard != null && !isCollapsed) { - controlsCard.animate().alpha(0.8f).setDuration(400).start(); - } - }; @Override protected void onCreate(Bundle savedInstanceState) { - WindowCompat.setDecorFitsSystemWindows(getWindow(), false); super.onCreate(savedInstanceState); setContentView(R.layout.activity_path_following); - map3DView = findViewById(R.id.map3dView); + viewModel = new ViewModelProvider(this).get(PathFollowingViewModel.class); + + bindViews(); + setupControlListeners(); + setupTouchAutoFade(); + observeViewModel(); + map3DView.onCreate(savedInstanceState); map3DView.getMap3DViewAsync(this); - - initViews(); - loadPath(PathData.URBAN_PATH); } @Override - public void onMap3DViewReady(@NonNull GoogleMap3D googleMap3D) { - this.googleMap3D = googleMap3D; - googleMap3D.setOnMapReadyListener( - (map) -> { - drawPathPolylines(); - updateCameraPositionForDistance(0.0); - }); - - scheduleControlsFade(); + public void onMap3DViewReady(@NonNull GoogleMap3D map) { + this.googleMap3D = map; + render(viewModel.getCurrentState()); } - private void initViews() { + private void bindViews() { + map3DView = findViewById(R.id.map3dView); controlsCard = findViewById(R.id.controls_card); - cardHeader = findViewById(R.id.card_header); btnCollapse = findViewById(R.id.btn_collapse); - - if (btnCollapse != null) { - btnCollapse.setOnClickListener(v -> toggleControlsCard()); - } - if (cardHeader != null) { - cardHeader.setOnClickListener(v -> toggleControlsCard()); - } - + controlsScroll = findViewById(R.id.controls_scroll); rgEnvironment = findViewById(R.id.rg_environment); rgAltitudeMode = findViewById(R.id.rg_altitude_mode); switchDrawsOccludedSegments = findViewById(R.id.switch_draws_occluded_segments); @@ -178,57 +136,27 @@ private void initViews() { tiltSliderLabel = findViewById(R.id.tilt_slider_label); speedSlider = findViewById(R.id.speed_slider); speedSliderLabel = findViewById(R.id.speed_slider_label); - - setupEventListeners(); } - private void setupEventListeners() { - rgEnvironment.setOnCheckedChangeListener( - (group, checkedId) -> { - if (checkedId == R.id.rb_urban) { - switchEnvironment(PathData.URBAN_PATH); - } else if (checkedId == R.id.rb_rural) { - switchEnvironment(PathData.RURAL_PATH); - } - }); - - rgAltitudeMode.setOnCheckedChangeListener( - (group, checkedId) -> { - if (checkedId == R.id.rb_clamp_to_ground) { - pathAltitudeMode = AltitudeMode.CLAMP_TO_GROUND; - } else if (checkedId == R.id.rb_relative_to_ground) { - pathAltitudeMode = AltitudeMode.RELATIVE_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; - } else { - pathAltitudeMode = AltitudeMode.CLAMP_TO_GROUND; - } - redrawPolylinesAndCamera(); - }); + private void setupControlListeners() { + btnPlayPause.setOnClickListener(v -> viewModel.togglePlayPause()); - switchDrawsOccludedSegments.setChecked(drawsOccludedSegments); - switchDrawsOccludedSegments.setOnCheckedChangeListener( - (buttonView, isChecked) -> { - drawsOccludedSegments = isChecked; - redrawPolylinesAndCamera(); - }); + if (btnCollapse != null) { + btnCollapse.setOnClickListener( + v -> { + isCollapsed = !isCollapsed; + if (controlsScroll != null) { + controlsScroll.setVisibility(isCollapsed ? View.GONE : View.VISIBLE); + } + btnCollapse.setIconResource( + isCollapsed ? R.drawable.expand_less_24px : R.drawable.expand_more_24px); + }); + } - pathAltitudeSlider.addOnChangeListener( + progressSlider.addOnChangeListener( (slider, value, fromUser) -> { - pathAltitudeOffset = value; - pathAltitudeSliderLabel.setText( - getString(R.string.path_height_format, pathAltitudeOffset)); - redrawPolylinesAndCamera(); - }); - - btnPlayPause.setOnClickListener( - v -> { - if (isPlaying) { - pauseAnimation(); - } else { - startAnimation(); + if (fromUser) { + viewModel.seekToRatio(value); } }); @@ -236,309 +164,206 @@ private void setupEventListeners() { new Slider.OnSliderTouchListener() { @Override public void onStartTrackingTouch(@NonNull Slider slider) { - isUserScrubbing = true; + viewModel.setScrubbing(true); } @Override public void onStopTrackingTouch(@NonNull Slider slider) { - isUserScrubbing = false; - elapsedDistance = totalDistance * slider.getValue(); - updateCameraPositionForDistance(elapsedDistance); + viewModel.setScrubbing(false); + viewModel.seekToRatio(slider.getValue()); } }); - progressSlider.addOnChangeListener( - (slider, value, fromUser) -> { - if (fromUser) { - elapsedDistance = totalDistance * value; - updateCameraPositionForDistance(elapsedDistance); + rgAltitudeMode.setOnCheckedChangeListener( + (group, checkedId) -> { + int mode = AltitudeMode.CLAMP_TO_GROUND; + if (checkedId == R.id.rb_relative_to_ground) { + mode = AltitudeMode.RELATIVE_TO_GROUND; + } else if (checkedId == R.id.rb_relative_to_mesh) { + mode = AltitudeMode.RELATIVE_TO_MESH; + } else if (checkedId == R.id.rb_absolute) { + mode = AltitudeMode.ABSOLUTE; } + viewModel.setAltitudeMode(mode); + }); + + switchDrawsOccludedSegments.setOnCheckedChangeListener( + (buttonView, isChecked) -> viewModel.setDrawsOccludedSegments(isChecked)); + + pathAltitudeSlider.addOnChangeListener( + (slider, value, fromUser) -> { + if (fromUser) viewModel.setPathAltitudeOffset(value); + pathAltitudeSliderLabel.setText(getString(R.string.path_height_format, value)); }); rangeSlider.addOnChangeListener( (slider, value, fromUser) -> { - cameraRange = value; - rangeSliderLabel.setText(getString(R.string.camera_range_format, (int) cameraRange)); - updateCameraPositionForDistance(elapsedDistance); + if (fromUser) viewModel.setCameraRange(value); + rangeSliderLabel.setText( + getString(R.string.camera_range_format, (int) value)); }); altitudeSlider.addOnChangeListener( (slider, value, fromUser) -> { - groundAltitude = value; + if (fromUser) viewModel.setGroundAltitude(value); altitudeSliderLabel.setText( - getString(R.string.ground_altitude_format, (int) groundAltitude)); - updateCameraPositionForDistance(elapsedDistance); + getString(R.string.ground_altitude_format, (int) value)); }); headingSlider.addOnChangeListener( (slider, value, fromUser) -> { - headingOffset = value; + if (fromUser) viewModel.setHeadingOffset(value); headingSliderLabel.setText( - getString(R.string.heading_offset_format, (int) headingOffset)); - updateCameraPositionForDistance(elapsedDistance); + getString(R.string.heading_offset_format, (int) value)); }); tiltSlider.addOnChangeListener( (slider, value, fromUser) -> { - cameraTilt = value; - tiltSliderLabel.setText(getString(R.string.camera_tilt_format, (int) cameraTilt)); - updateCameraPositionForDistance(elapsedDistance); + if (fromUser) viewModel.setCameraTilt(value); + tiltSliderLabel.setText( + getString(R.string.camera_tilt_format, (int) value)); }); speedSlider.addOnChangeListener( (slider, value, fromUser) -> { - followSpeedMps = value; - speedSliderLabel.setText(getString(R.string.follow_speed_format, (int) followSpeedMps)); + if (fromUser) viewModel.setFollowSpeed(value); + speedSliderLabel.setText(getString(R.string.follow_speed_format, (int) value)); }); - } - - private void switchEnvironment(List path) { - pauseAnimation(); - currentHeading = null; - elapsedDistance = 0.0; - progressSlider.setValue(0f); - clearPolylines(); - - if (path.equals(PathData.RURAL_PATH)) { - cameraRange = 450.0; - groundAltitude = 40.0; - cameraTilt = 75.0; - altitudeSlider.setValueTo(2000f); - altitudeSlider.setValue(40f); - rangeSlider.setValue(450f); - tiltSlider.setValue(75f); - } else { - cameraRange = 300.0; - groundAltitude = 20.0; - cameraTilt = 70.0; - altitudeSlider.setValueTo(200f); - altitudeSlider.setValue(20f); - rangeSlider.setValue(300f); - tiltSlider.setValue(70f); - } - rangeSliderLabel.setText(getString(R.string.camera_range_format, (int) cameraRange)); - altitudeSliderLabel.setText(getString(R.string.ground_altitude_format, (int) groundAltitude)); - tiltSliderLabel.setText(getString(R.string.camera_tilt_format, (int) cameraTilt)); - - loadPath(path); - drawPathPolylines(); - updateCameraPositionForDistance(0.0); + rgEnvironment.setOnCheckedChangeListener( + (group, checkedId) -> { + if (checkedId == R.id.rb_urban) { + viewModel.setRoute(PathData.URBAN_PATH, /* applyDefaults= */ true); + pathAltitudeSlider.setValueTo(20.0f); + altitudeSlider.setValueTo(100.0f); + } else if (checkedId == R.id.rb_rural) { + viewModel.setRoute(PathData.RURAL_PATH, /* applyDefaults= */ true); + pathAltitudeSlider.setValueTo(200.0f); + altitudeSlider.setValueTo(200.0f); + } + }); } - private void loadPath(List path) { - currentPath = path; - cumulativeDistances = PathEngine.calculateCumulativeDistances(path); - totalDistance = (cumulativeDistances.length > 0) ? cumulativeDistances[cumulativeDistances.length - 1] : 0.0; + private void observeViewModel() { + viewModel.getLiveData().observe(this, this::handleStateUpdate); } - private void drawPathPolylines() { - drawStaticRoutePolyline(); - if (!currentPath.isEmpty()) { - LatLng firstPt = new LatLng(currentPath.get(0).getLatitude(), currentPath.get(0).getLongitude()); - updateProgressPolyline(elapsedDistance, firstPt, 0); - } + private void handleStateUpdate(PathPlaybackState state) { + render(state); + manageAnimationTicker(state.isPlaying()); } - private void drawStaticRoutePolyline() { - if (googleMap3D == null || currentPath.isEmpty()) return; + private void render(PathPlaybackState state) { + if (googleMap3D == null || state == null) return; - List staticVertices = - PathEngine.buildStaticVertices( - /* path= */ currentPath, - /* altitudeMode= */ pathAltitudeMode, - /* baseAltitude= */ getBaseAltitude(), - /* pathAltitudeOffset= */ pathAltitudeOffset); + // Update Camera + Camera newCamera = + new Camera( + /* center= */ new LatLngAltitude( + /* latitude= */ state.getCurrentPosition().latitude, + /* longitude= */ state.getCurrentPosition().longitude, + /* altitude= */ state.getCameraTargetAltitude()), + /* heading= */ state.getEffectiveHeading(), + /* tilt= */ state.getCameraTilt(), + /* roll= */ 0.0, + /* range= */ state.getCameraRange()); + googleMap3D.setCamera(newCamera); + // Update Static Base Route Polyline PolylineOptions staticOptions = new PolylineOptions(); - staticOptions.setId(PathEngine.STATIC_POLYLINE_ID); // Fixed ID prevents flickering - staticOptions.setPath(staticVertices); - staticOptions.setStrokeColor(Color.parseColor("#4285F4")); // Wide blue route (16dp) + staticOptions.setId(PathEngine.STATIC_POLYLINE_ID); + staticOptions.setPath(state.getStaticPolylineVertices()); + staticOptions.setStrokeColor(Color.parseColor("#4285F4")); staticOptions.setStrokeWidth(16.0); staticOptions.setZIndex(1); - staticOptions.setAltitudeMode(pathAltitudeMode); - staticOptions.setDrawsOccludedSegments(drawsOccludedSegments); - + staticOptions.setAltitudeMode(state.getAltitudeMode()); + staticOptions.setDrawsOccludedSegments(state.getDrawsOccludedSegments()); staticRoutePolyline = googleMap3D.addPolyline(staticOptions); - } - - private void updateProgressPolyline(double dist, LatLng currentLatLng, int index) { - if (googleMap3D == null || currentPath.isEmpty() || totalDistance <= 0.0) return; - - List progressCoordinates = - PathEngine.buildProgressVertices( - /* path= */ currentPath, - /* cumulativeDistances= */ cumulativeDistances, - /* elapsedDistance= */ dist, - /* currentLatLng= */ currentLatLng, - /* waypointIndex= */ index, - /* altitudeMode= */ pathAltitudeMode, - /* baseAltitude= */ getBaseAltitude(), - /* pathAltitudeOffset= */ pathAltitudeOffset); + // Update Progress Polyline PolylineOptions progressOptions = new PolylineOptions(); progressOptions.setId(PathEngine.PROGRESS_POLYLINE_ID); - progressOptions.setPath(progressCoordinates); - progressOptions.setStrokeColor(Color.parseColor("#9C27B0")); // Narrow purple progress (8dp) + progressOptions.setPath(state.getProgressPolylineVertices()); + progressOptions.setStrokeColor(Color.parseColor("#9C27B0")); progressOptions.setStrokeWidth(8.0); progressOptions.setZIndex(2); - progressOptions.setAltitudeMode(pathAltitudeMode); - progressOptions.setDrawsOccludedSegments(drawsOccludedSegments); - + progressOptions.setAltitudeMode(state.getAltitudeMode()); + progressOptions.setDrawsOccludedSegments(state.getDrawsOccludedSegments()); progressPolyline = googleMap3D.addPolyline(progressOptions); - } - - private void updateCameraPositionForDistance(double dist) { - if (googleMap3D == null || currentPath.isEmpty()) return; - - InterpolatedPathPoint point = PathEngine.interpolatePoint( - /* path= */ currentPath, - /* cumulativeDistances= */ cumulativeDistances, - /* distance= */ dist); - double targetHeading = - PathEngine.smoothHeading( - /* targetHeading= */ point.bearing + headingOffset, - /* currentHeading= */ currentHeading, - /* isUserScrubbing= */ isUserScrubbing, - /* isPlaying= */ isPlaying, - /* smoothingFactor= */ 0.12); - currentHeading = targetHeading; - - double cameraTargetAltitude = - PathEngine.calculateCameraAltitude( - /* altitudeMode= */ pathAltitudeMode, - /* baseAltitude= */ getBaseAltitude(), - /* interpolatedAltitude= */ point.altitude, - /* groundAltitude= */ groundAltitude); - - Camera newCamera = - new Camera( - /* center= */ new LatLngAltitude( - /* latitude= */ point.latLng.latitude, - /* longitude= */ point.latLng.longitude, - /* altitude= */ cameraTargetAltitude), - /* heading= */ targetHeading, - /* tilt= */ cameraTilt, - /* roll= */ 0.0, - /* range= */ cameraRange); - - googleMap3D.setCamera(newCamera); - updateProgressPolyline(dist, point.latLng, point.waypointIndex); - } - - private void startAnimation() { - if (isPlaying) return; - isPlaying = true; - btnPlayPause.setIconResource(R.drawable.pause_24px); - - frameCallback = - new Choreographer.FrameCallback() { - private long lastTimeNanos = 0L; - - @Override - public void doFrame(long frameTimeNanos) { - if (!isPlaying) return; - - if (lastTimeNanos == 0L) { - lastTimeNanos = frameTimeNanos; - Choreographer.getInstance().postFrameCallback(this); - return; - } - - double dt = (frameTimeNanos - lastTimeNanos) / 1_000_000_000.0; - lastTimeNanos = frameTimeNanos; - - double stepDistance = followSpeedMps * dt; - elapsedDistance += stepDistance; - - if (elapsedDistance >= totalDistance) { - elapsedDistance = 0.0; - } - - if (!isUserScrubbing && totalDistance > 0) { - float progress = (float) Math.max(0.0, Math.min(1.0, elapsedDistance / totalDistance)); - progressSlider.setValue(progress); - } - updateCameraPositionForDistance(elapsedDistance); - Choreographer.getInstance().postFrameCallback(this); - } - }; - Choreographer.getInstance().postFrameCallback(frameCallback); - } + // Update Play/Pause Button Icon + btnPlayPause.setIconResource( + state.isPlaying() ? R.drawable.pause_24px : R.drawable.play_arrow_24px); - private void pauseAnimation() { - isPlaying = false; - btnPlayPause.setIconResource(R.drawable.play_arrow_24px); - if (frameCallback != null) { - Choreographer.getInstance().removeFrameCallback(frameCallback); - frameCallback = null; + // Update Progress Slider without fighting touch scrubbing + if (!state.isScrubbing()) { + progressSlider.setValue(state.getProgressRatio()); } } - private void redrawPolylinesAndCamera() { - clearPolylines(); - drawPathPolylines(); - updateCameraPositionForDistance(elapsedDistance); - } - - private void clearPolylines() { - if (staticRoutePolyline != null) { - staticRoutePolyline.remove(); - staticRoutePolyline = null; - } - if (progressPolyline != null) { - progressPolyline.remove(); - progressPolyline = null; - } - } - - private void toggleControlsCard() { - if (isCollapsed) expandControls(); else collapseControls(); - } - - 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)); + private void manageAnimationTicker(boolean isPlaying) { + if (isPlaying) { + if (frameCallback == null) { + frameCallback = + new Choreographer.FrameCallback() { + private long lastTimeNanos = 0L; + + @Override + public void doFrame(long frameTimeNanos) { + if (!viewModel.getCurrentState().isPlaying()) return; + + if (lastTimeNanos == 0L) { + lastTimeNanos = frameTimeNanos; + Choreographer.getInstance().postFrameCallback(this); + return; + } + + double dt = (frameTimeNanos - lastTimeNanos) / 1_000_000_000.0; + lastTimeNanos = frameTimeNanos; + + viewModel.advance(dt); + Choreographer.getInstance().postFrameCallback(this); + } + }; + Choreographer.getInstance().postFrameCallback(frameCallback); + } + } else { + if (frameCallback != null) { + Choreographer.getInstance().removeFrameCallback(frameCallback); + frameCallback = null; + } } - - 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)); + private void setupTouchAutoFade() { + if (controlsCard != null) { + controlsCard.setOnTouchListener( + (v, event) -> { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + fadeHandler.removeCallbacksAndMessages(null); + controlsCard.setAlpha(1.0f); + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + scheduleControlFade(); + break; + } + return false; + }); } - controlsCard.animate().translationY(0f).alpha(1.0f).setDuration(250).start(); - scheduleControlsFade(); - } - - private void scheduleControlsFade() { - fadeHandler.removeCallbacks(fadeOutRunnable); - fadeHandler.postDelayed(fadeOutRunnable, 3000L); + scheduleControlFade(); } - @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(); - scheduleControlsFade(); - } - } - return super.dispatchTouchEvent(ev); + private void scheduleControlFade() { + fadeHandler.removeCallbacksAndMessages(null); + fadeHandler.postDelayed( + () -> { + if (controlsCard != null && !isCollapsed) { + controlsCard.animate().alpha(0.80f).setDuration(400L).start(); + } + }, + 5000L); } @Override @@ -550,16 +375,21 @@ protected void onResume() { @Override protected void onPause() { super.onPause(); + viewModel.setPlaying(false); map3DView.onPause(); - pauseAnimation(); } @Override protected void onDestroy() { super.onDestroy(); - pauseAnimation(); - fadeHandler.removeCallbacks(fadeOutRunnable); - clearPolylines(); + viewModel.setPlaying(false); + if (frameCallback != null) { + Choreographer.getInstance().removeFrameCallback(frameCallback); + frameCallback = null; + } + fadeHandler.removeCallbacksAndMessages(null); + staticRoutePolyline = null; + progressPolyline = null; map3DView.onDestroy(); } diff --git a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt index 2ae9c41..d1c77ee 100644 --- a/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt +++ b/Maps3DSamples/ApiDemos/kotlin-app/src/main/java/com/example/maps3dkotlin/pathfollowing/PathFollowingActivity.kt @@ -24,46 +24,53 @@ import android.view.MotionEvent import android.view.View import android.widget.RadioGroup import android.widget.TextView +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.cardview.widget.CardView import androidx.core.graphics.toColorInt -import androidx.core.view.WindowCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.example.maps3d.common.PathData import com.example.maps3d.common.PathEngine +import com.example.maps3d.common.PathFollowingViewModel +import com.example.maps3d.common.PathPlaybackState import com.example.maps3dcommon.R -import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps3d.GoogleMap3D import com.google.android.gms.maps3d.Map3DView 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.Polyline import com.google.android.gms.maps3d.model.PolylineOptions -import com.google.android.gms.maps3d.model.camera -import com.google.android.gms.maps3d.model.latLngAltitude import com.google.android.material.button.MaterialButton import com.google.android.material.materialswitch.MaterialSwitch import com.google.android.material.slider.Slider +import kotlinx.coroutines.launch /** - * Advanced sample demonstrating ground-level path following in Kotlin. + * Demonstrates 3D Path Following using an MVI / MVVM architecture with [PathFollowingViewModel]. * - * Key Concepts Demonstrated: - * 1. Dual-Polyline Architecture: Layered base route (lower z-index) and traversed progress route (higher z-index). - * 2. In-Place Polyline ID Updates: Stable IDs prevent render flickering during rapid real-time updates. - * 3. 3D Altitude Modes: Dynamic switching between Clamp to Ground, Relative, and Absolute elevation. - * 4. Occlusion Control: Toggling [PolylineOptions.setDrawsOccludedSegments] through terrain and buildings. - * 5. Kinematic Heading Smoothing: Exponential moving average low-pass filter to smooth camera cornering. + * The Activity acts as a pure presentation layer: it observes [PathPlaybackState] from the ViewModel + * and renders camera positions and dual polylines into [GoogleMap3D]. */ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { + private val viewModel: PathFollowingViewModel by viewModels() + + // 3D Map View private lateinit var map3DView: Map3DView private var googleMap3D: GoogleMap3D? = null + // Polylines + private var staticRoutePolyline: Polyline? = null + private var progressPolyline: Polyline? = null + // Control panel overlay bindings private var controlsCard: CardView? = null - private var cardHeader: View? = null private var btnCollapse: MaterialButton? = null + private var controlsScroll: View? = null private var isCollapsed = false private lateinit var rgEnvironment: RadioGroup @@ -84,72 +91,33 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { private lateinit var speedSlider: Slider private lateinit var speedSliderLabel: TextView - // Control parameters - private var cameraRange = 300.0 - private var groundAltitude = 20.0 - private var headingOffset = 0.0 - private var cameraTilt = 70.0 - private var followSpeedMps = 30.0 - private var pathAltitudeMode: Int = AltitudeMode.CLAMP_TO_GROUND - private var pathAltitudeOffset: Double = 0.5 - private var drawsOccludedSegments: Boolean = true - - // Path state - private var currentPath: List = PathData.URBAN_PATH - private var cumulativeDistances: DoubleArray = doubleArrayOf() - private var totalDistance: Double = 0.0 - private var elapsedDistance: Double = 0.0 - private var isPlaying = false - private var isUserScrubbing = false - private var currentHeading: Double? = null - - private val baseAltitude: Double - get() = if (currentPath == PathData.RURAL_PATH) 45.0 else 50.0 - - // Polyline handles - private var staticRoutePolyline: Polyline? = null - private var progressPolyline: Polyline? = null - - // Animation & auto-fade handlers + // Auto-fade & VSYNC Choreographer private var frameCallback: Choreographer.FrameCallback? = null private val fadeHandler = Handler(Looper.getMainLooper()) - private val fadeOutRunnable = Runnable { - if (controlsCard != null && !isCollapsed) { - controlsCard?.animate()?.alpha(0.8f)?.setDuration(400)?.start() - } - } override fun onCreate(savedInstanceState: Bundle?) { - WindowCompat.setDecorFitsSystemWindows(window, false) super.onCreate(savedInstanceState) setContentView(R.layout.activity_path_following) - map3DView = findViewById(R.id.map3dView) + bindViews() + setupControlListeners() + setupTouchAutoFade() + observeViewModelState() + map3DView.onCreate(savedInstanceState) map3DView.getMap3DViewAsync(this) - - initViews() - loadPath(PathData.URBAN_PATH) } - override fun onMap3DViewReady(googleMap3D: GoogleMap3D) { - this.googleMap3D = googleMap3D - googleMap3D.setOnMapReadyListener { - drawPathPolylines() - updateCameraPositionForDistance(0.0) - } - - scheduleControlsFade() + override fun onMap3DViewReady(map: GoogleMap3D) { + googleMap3D = map + render(viewModel.currentState) } - private fun initViews() { + private fun bindViews() { + map3DView = findViewById(R.id.map3dView) controlsCard = findViewById(R.id.controls_card) - cardHeader = findViewById(R.id.card_header) btnCollapse = findViewById(R.id.btn_collapse) - - btnCollapse?.setOnClickListener { toggleControlsCard() } - cardHeader?.setOnClickListener { toggleControlsCard() } - + controlsScroll = findViewById(R.id.controls_scroll) rgEnvironment = findViewById(R.id.rg_environment) rgAltitudeMode = findViewById(R.id.rg_altitude_mode) switchDrawsOccludedSegments = findViewById(R.id.switch_draws_occluded_segments) @@ -167,331 +135,216 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { tiltSliderLabel = findViewById(R.id.tilt_slider_label) speedSlider = findViewById(R.id.speed_slider) speedSliderLabel = findViewById(R.id.speed_slider_label) - - setupEventListeners() } - private fun setupEventListeners() { - rgEnvironment.setOnCheckedChangeListener { _, checkedId -> - when (checkedId) { - R.id.rb_urban -> switchEnvironment(PathData.URBAN_PATH) - R.id.rb_rural -> switchEnvironment(PathData.RURAL_PATH) - } - } - - rgAltitudeMode.setOnCheckedChangeListener { _, checkedId -> - pathAltitudeMode = when (checkedId) { - R.id.rb_clamp_to_ground -> AltitudeMode.CLAMP_TO_GROUND - R.id.rb_relative_to_ground -> AltitudeMode.RELATIVE_TO_GROUND - R.id.rb_relative_to_mesh -> AltitudeMode.RELATIVE_TO_MESH - R.id.rb_absolute -> AltitudeMode.ABSOLUTE - else -> AltitudeMode.CLAMP_TO_GROUND - } - redrawPolylinesAndCamera() - } - - switchDrawsOccludedSegments.isChecked = drawsOccludedSegments - switchDrawsOccludedSegments.setOnCheckedChangeListener { _, isChecked -> - drawsOccludedSegments = isChecked - redrawPolylinesAndCamera() + private fun setupControlListeners() { + btnPlayPause.setOnClickListener { + viewModel.togglePlayPause() } - pathAltitudeSlider.addOnChangeListener { _, value, _ -> - pathAltitudeOffset = value.toDouble() - pathAltitudeSliderLabel.text = getString(R.string.path_height_format, pathAltitudeOffset) - redrawPolylinesAndCamera() + btnCollapse?.setOnClickListener { + isCollapsed = !isCollapsed + controlsScroll?.visibility = if (isCollapsed) View.GONE else View.VISIBLE + btnCollapse?.setIconResource( + if (isCollapsed) R.drawable.expand_less_24px else R.drawable.expand_more_24px + ) } - btnPlayPause.setOnClickListener { - if (isPlaying) pauseAnimation() else startAnimation() + progressSlider.addOnChangeListener { _, value, fromUser -> + if (fromUser) { + viewModel.seekToRatio(value) + } } progressSlider.addOnSliderTouchListener(object : Slider.OnSliderTouchListener { override fun onStartTrackingTouch(slider: Slider) { - isUserScrubbing = true + viewModel.setScrubbing(true) } override fun onStopTrackingTouch(slider: Slider) { - isUserScrubbing = false - elapsedDistance = totalDistance * slider.value.toDouble() - updateCameraPositionForDistance(elapsedDistance) + viewModel.setScrubbing(false) + viewModel.seekToRatio(slider.value) } }) - progressSlider.addOnChangeListener { _, value, fromUser -> - if (fromUser) { - elapsedDistance = totalDistance * value.toDouble() - updateCameraPositionForDistance(elapsedDistance) + rgAltitudeMode.setOnCheckedChangeListener { _, checkedId -> + val mode = when (checkedId) { + R.id.rb_clamp_to_ground -> AltitudeMode.CLAMP_TO_GROUND + R.id.rb_relative_to_ground -> AltitudeMode.RELATIVE_TO_GROUND + R.id.rb_relative_to_mesh -> AltitudeMode.RELATIVE_TO_MESH + R.id.rb_absolute -> AltitudeMode.ABSOLUTE + else -> AltitudeMode.CLAMP_TO_GROUND } + viewModel.setAltitudeMode(mode) } - rangeSlider.addOnChangeListener { _, value, _ -> - cameraRange = value.toDouble() - rangeSliderLabel.text = getString(R.string.camera_range_format, cameraRange.toInt()) - updateCameraPositionForDistance(elapsedDistance) + switchDrawsOccludedSegments.setOnCheckedChangeListener { _, isChecked -> + viewModel.setDrawsOccludedSegments(isChecked) } - altitudeSlider.addOnChangeListener { _, value, _ -> - groundAltitude = value.toDouble() - altitudeSliderLabel.text = getString(R.string.ground_altitude_format, groundAltitude.toInt()) - updateCameraPositionForDistance(elapsedDistance) + pathAltitudeSlider.addOnChangeListener { _, value, fromUser -> + if (fromUser) viewModel.setPathAltitudeOffset(value.toDouble()) + pathAltitudeSliderLabel.text = getString(R.string.path_height_format, value) } - headingSlider.addOnChangeListener { _, value, _ -> - headingOffset = value.toDouble() - headingSliderLabel.text = getString(R.string.heading_offset_format, headingOffset.toInt()) - updateCameraPositionForDistance(elapsedDistance) + rangeSlider.addOnChangeListener { _, value, fromUser -> + if (fromUser) viewModel.setCameraRange(value.toDouble()) + rangeSliderLabel.text = getString(R.string.camera_range_format, value.toInt()) } - tiltSlider.addOnChangeListener { _, value, _ -> - cameraTilt = value.toDouble() - tiltSliderLabel.text = getString(R.string.camera_tilt_format, cameraTilt.toInt()) - updateCameraPositionForDistance(elapsedDistance) + altitudeSlider.addOnChangeListener { _, value, fromUser -> + if (fromUser) viewModel.setGroundAltitude(value.toDouble()) + altitudeSliderLabel.text = getString(R.string.ground_altitude_format, value.toInt()) } - speedSlider.addOnChangeListener { _, value, _ -> - followSpeedMps = value.toDouble() - speedSliderLabel.text = getString(R.string.follow_speed_format, followSpeedMps.toInt()) + headingSlider.addOnChangeListener { _, value, fromUser -> + if (fromUser) viewModel.setHeadingOffset(value.toDouble()) + headingSliderLabel.text = getString(R.string.heading_offset_format, value.toInt()) } - } - private fun switchEnvironment(path: List) { - pauseAnimation() - currentHeading = null - elapsedDistance = 0.0 - progressSlider.value = 0f - clearPolylines() - - if (path == PathData.RURAL_PATH) { - cameraRange = 450.0 - groundAltitude = 40.0 - cameraTilt = 75.0 - altitudeSlider.valueTo = 2000f - altitudeSlider.value = 40f - rangeSlider.value = 450f - tiltSlider.value = 75f - } else { - cameraRange = 300.0 - groundAltitude = 20.0 - cameraTilt = 70.0 - altitudeSlider.valueTo = 200f - altitudeSlider.value = 20f - rangeSlider.value = 300f - tiltSlider.value = 70f + tiltSlider.addOnChangeListener { _, value, fromUser -> + if (fromUser) viewModel.setCameraTilt(value.toDouble()) + tiltSliderLabel.text = getString(R.string.camera_tilt_format, value.toInt()) } - rangeSliderLabel.text = getString(R.string.camera_range_format, cameraRange.toInt()) - altitudeSliderLabel.text = getString(R.string.ground_altitude_format, groundAltitude.toInt()) - tiltSliderLabel.text = getString(R.string.camera_tilt_format, cameraTilt.toInt()) - - loadPath(path) - drawPathPolylines() - updateCameraPositionForDistance(0.0) - } + speedSlider.addOnChangeListener { _, value, fromUser -> + if (fromUser) viewModel.setFollowSpeed(value.toDouble()) + speedSliderLabel.text = getString(R.string.follow_speed_format, value.toInt()) + } - private fun loadPath(path: List) { - currentPath = path - cumulativeDistances = PathEngine.calculateCumulativeDistances(path) - totalDistance = cumulativeDistances.lastOrNull() ?: 0.0 + rgEnvironment.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.rb_urban -> { + viewModel.setRoute(PathData.URBAN_PATH) + pathAltitudeSlider.valueTo = 20.0f + altitudeSlider.valueTo = 100.0f + } + R.id.rb_rural -> { + viewModel.setRoute(PathData.RURAL_PATH) + pathAltitudeSlider.valueTo = 200.0f + altitudeSlider.valueTo = 200.0f + } + } + } } - private fun drawPathPolylines() { - drawStaticRoutePolyline() - if (currentPath.isNotEmpty()) { - val firstPt = LatLng(currentPath[0].latitude, currentPath[0].longitude) - updateProgressPolyline(elapsedDistance, firstPt, 0) + private fun observeViewModelState() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> + render(state) + manageAnimationTicker(state.isPlaying) + } + } } } - private fun drawStaticRoutePolyline() { + private fun render(state: PathPlaybackState) { val map = googleMap3D ?: return - if (currentPath.isEmpty()) return - val vertices = PathEngine.buildStaticVertices( - path = currentPath, - altitudeMode = pathAltitudeMode, - baseAltitude = baseAltitude, - pathAltitudeOffset = pathAltitudeOffset + // Update Camera Position & Orientation + val newCamera = Camera( + /* center = */ LatLngAltitude( + state.currentPosition.latitude, + state.currentPosition.longitude, + state.cameraTargetAltitude + ), + /* heading = */ state.effectiveHeading, + /* tilt = */ state.cameraTilt, + /* roll = */ 0.0, + /* range = */ state.cameraRange ) + map.setCamera(newCamera) + + // Update Static Base Route Polyline val staticOptions = PolylineOptions().apply { id = PathEngine.STATIC_POLYLINE_ID - path = vertices - strokeColor = "#4285F4".toColorInt() // Wide blue route (16dp) + path = state.staticPolylineVertices + strokeColor = "#4285F4".toColorInt() strokeWidth = 16.0 zIndex = 1 - altitudeMode = pathAltitudeMode - drawsOccludedSegments = this@PathFollowingActivity.drawsOccludedSegments + altitudeMode = state.altitudeMode + drawsOccludedSegments = state.drawsOccludedSegments } staticRoutePolyline = map.addPolyline(staticOptions) - } - - private fun updateProgressPolyline(dist: Double, currentLatLng: LatLng, index: Int) { - val map = googleMap3D ?: return - if (currentPath.isEmpty() || totalDistance <= 0.0) return - - val vertices = PathEngine.buildProgressVertices( - path = currentPath, - cumulativeDistances = cumulativeDistances, - elapsedDistance = dist, - currentLatLng = currentLatLng, - waypointIndex = index, - altitudeMode = pathAltitudeMode, - baseAltitude = baseAltitude, - pathAltitudeOffset = pathAltitudeOffset - ) - // In-place ID upsert updates the existing line in the 3D renderer without recreating objects + // Update Progress Polyline val progressOptions = PolylineOptions().apply { id = PathEngine.PROGRESS_POLYLINE_ID - path = vertices - strokeColor = "#9C27B0".toColorInt() // Narrow purple progress (8dp) + path = state.progressPolylineVertices + strokeColor = "#9C27B0".toColorInt() strokeWidth = 8.0 zIndex = 2 - altitudeMode = pathAltitudeMode - drawsOccludedSegments = this@PathFollowingActivity.drawsOccludedSegments + altitudeMode = state.altitudeMode + drawsOccludedSegments = state.drawsOccludedSegments } progressPolyline = map.addPolyline(progressOptions) - } - - private fun updateCameraPositionForDistance(dist: Double) { - val map = googleMap3D ?: return - if (currentPath.isEmpty()) return - val point = PathEngine.interpolatePoint( - path = currentPath, - cumulativeDistances = cumulativeDistances, - distance = dist - ) - val targetHeading = PathEngine.smoothHeading( - targetHeading = point.bearing + headingOffset, - currentHeading = currentHeading, - isUserScrubbing = isUserScrubbing, - isPlaying = isPlaying + // Update Play/Pause Button Icon + btnPlayPause.setIconResource( + if (state.isPlaying) R.drawable.pause_24px else R.drawable.play_arrow_24px ) - currentHeading = targetHeading - val cameraTargetAltitude = PathEngine.calculateCameraAltitude( - altitudeMode = pathAltitudeMode, - baseAltitude = baseAltitude, - interpolatedAltitude = point.altitude, - groundAltitude = groundAltitude - ) - - val newCamera = camera { - center = latLngAltitude { - latitude = point.latLng.latitude - longitude = point.latLng.longitude - altitude = cameraTargetAltitude - } - heading = targetHeading - tilt = cameraTilt - range = cameraRange + // Update Progress Slider without fighting touch scrubbing + if (!state.isScrubbing) { + progressSlider.value = state.progressRatio } - - map.setCamera(newCamera) - updateProgressPolyline(dist, point.latLng, point.waypointIndex) } - private fun startAnimation() { - if (isPlaying) return - isPlaying = true - btnPlayPause.setIconResource(R.drawable.pause_24px) - - var lastTimeNanos = 0L - frameCallback = object : Choreographer.FrameCallback { - override fun doFrame(frameTimeNanos: Long) { - if (!isPlaying) return - - if (lastTimeNanos == 0L) { - lastTimeNanos = frameTimeNanos - Choreographer.getInstance().postFrameCallback(this) - return - } - - val dt = (frameTimeNanos - lastTimeNanos) / 1_000_000_000.0 - lastTimeNanos = frameTimeNanos - - elapsedDistance += followSpeedMps * dt - if (elapsedDistance >= totalDistance) { - elapsedDistance = 0.0 + private fun manageAnimationTicker(isPlaying: Boolean) { + if (isPlaying) { + if (frameCallback == null) { + var lastTimeNanos = 0L + frameCallback = object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + if (!viewModel.currentState.isPlaying) return + + if (lastTimeNanos == 0L) { + lastTimeNanos = frameTimeNanos + Choreographer.getInstance().postFrameCallback(this) + return + } + + val dt = (frameTimeNanos - lastTimeNanos) / 1_000_000_000.0 + lastTimeNanos = frameTimeNanos + + viewModel.advance(dt) + Choreographer.getInstance().postFrameCallback(this) + } } - - if (!isUserScrubbing && totalDistance > 0) { - val progressRatio = (elapsedDistance / totalDistance).toFloat().coerceIn(0f, 1f) - progressSlider.value = progressRatio - } - - updateCameraPositionForDistance(elapsedDistance) - Choreographer.getInstance().postFrameCallback(this) + Choreographer.getInstance().postFrameCallback(frameCallback!!) } - } - Choreographer.getInstance().postFrameCallback(frameCallback!!) - } - - private fun pauseAnimation() { - isPlaying = false - btnPlayPause.setIconResource(R.drawable.play_arrow_24px) - frameCallback?.let { - Choreographer.getInstance().removeFrameCallback(it) - frameCallback = null - } - } - - private fun redrawPolylinesAndCamera() { - clearPolylines() - drawPathPolylines() - updateCameraPositionForDistance(elapsedDistance) - } - - private fun clearPolylines() { - staticRoutePolyline?.remove() - staticRoutePolyline = null - progressPolyline?.remove() - progressPolyline = null - } - - private fun toggleControlsCard() { - if (isCollapsed) expandControls() else collapseControls() - } - - private fun collapseControls() { - val card = controlsCard ?: return - isCollapsed = true - fadeHandler.removeCallbacks(fadeOutRunnable) - btnCollapse?.setIconResource(R.drawable.expand_less_24px) - btnCollapse?.contentDescription = getString(R.string.expand_controls) - - val headerHeight = if (cardHeader != null && cardHeader!!.height > 0) { - cardHeader!!.height } else { - (48 * resources.displayMetrics.density).toInt() + frameCallback?.let { + Choreographer.getInstance().removeFrameCallback(it) + frameCallback = null + } } - val targetTranslationY = (card.height - headerHeight).coerceAtLeast(0).toFloat() - card.animate().translationY(targetTranslationY).alpha(0.9f).setDuration(300).start() - } - - private fun expandControls() { - val card = controlsCard ?: return - isCollapsed = false - btnCollapse?.setIconResource(R.drawable.expand_more_24px) - btnCollapse?.contentDescription = getString(R.string.collapse_controls) - card.animate().translationY(0f).alpha(1.0f).setDuration(250).start() - scheduleControlsFade() } - private fun scheduleControlsFade() { - fadeHandler.removeCallbacks(fadeOutRunnable) - fadeHandler.postDelayed(fadeOutRunnable, 3000L) + private fun setupTouchAutoFade() { + controlsCard?.setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + fadeHandler.removeCallbacksAndMessages(null) + controlsCard?.alpha = 1.0f + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + scheduleControlFade() + } + } + false + } + scheduleControlFade() } - override fun dispatchTouchEvent(ev: MotionEvent): Boolean { - if (ev.action == MotionEvent.ACTION_DOWN || ev.action == MotionEvent.ACTION_MOVE) { - if (controlsCard != null && !isCollapsed) { - controlsCard?.animate()?.alpha(1.0f)?.setDuration(150)?.start() - scheduleControlsFade() + private fun scheduleControlFade() { + fadeHandler.removeCallbacksAndMessages(null) + fadeHandler.postDelayed({ + if (!isCollapsed) { + controlsCard?.animate()?.alpha(0.80f)?.setDuration(400L)?.start() } - } - return super.dispatchTouchEvent(ev) + }, 5000L) } override fun onResume() { @@ -501,15 +354,20 @@ class PathFollowingActivity : AppCompatActivity(), OnMap3DViewReadyCallback { override fun onPause() { super.onPause() + viewModel.setPlaying(false) map3DView.onPause() - pauseAnimation() } override fun onDestroy() { super.onDestroy() - pauseAnimation() - fadeHandler.removeCallbacks(fadeOutRunnable) - clearPolylines() + viewModel.setPlaying(false) + frameCallback?.let { + Choreographer.getInstance().removeFrameCallback(it) + frameCallback = null + } + fadeHandler.removeCallbacksAndMessages(null) + staticRoutePolyline = null + progressPolyline = null map3DView.onDestroy() } diff --git a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt index a213926..7dce46c 100644 --- a/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt +++ b/Maps3DSamples/ComposeDemos/app/src/main/java/com/example/composedemos/pathfollowing/PathFollowingActivity.kt @@ -16,16 +16,16 @@ package com.example.composedemos.pathfollowing +import android.graphics.Color import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -35,9 +35,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -45,14 +44,14 @@ import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch @@ -61,62 +60,49 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableDoubleStateOf -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.core.graphics.toColorInt import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.example.maps3d.common.PathData import com.example.maps3d.common.PathEngine -import com.example.maps3dcommon.R -import com.google.android.gms.maps.model.LatLng +import com.example.maps3d.common.PathFollowingViewModel +import com.example.maps3d.common.PathPlaybackState 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.camera import com.google.android.gms.maps3d.model.latLngAltitude import com.google.maps.android.compose3d.GoogleMap3D import com.google.maps.android.compose3d.PolylineConfig -import kotlinx.coroutines.delay import kotlinx.coroutines.isActive class PathFollowingActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() super.onCreate(savedInstanceState) + enableEdgeToEdge() setContent { MaterialTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - PathFollowingScreen() + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + color = MaterialTheme.colorScheme.background, + ) { + PathFollowingScreen() + } } } } } - - companion object { - val URBAN_PATH: List = PathData.URBAN_PATH - val RURAL_PATH: List = PathData.RURAL_PATH - } } enum class AltitudeModeOption(val label: String, val mode: Int) { @@ -127,39 +113,21 @@ enum class AltitudeModeOption(val label: String, val mode: Int) { } /** - * Main Path Following demo screen orchestrating 3D map rendering and interactive controls. + * Main Path Following demo screen orchestrating 3D map rendering and interactive controls + * driven by [PathFollowingViewModel]. */ @Composable -fun PathFollowingScreen() { +fun PathFollowingScreen( + viewModel: PathFollowingViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() var isMapSteady by remember { mutableStateOf(false) } - var currentPath by remember { mutableStateOf(PathData.URBAN_PATH) } - var selectedAltitudeMode by remember { mutableStateOf(AltitudeModeOption.CLAMP_TO_GROUND) } - var drawsOccludedSegments by remember { mutableStateOf(true) } - var pathAltitudeOffset by remember { mutableFloatStateOf(0.5f) } - - var cameraRange by remember { mutableFloatStateOf(300f) } - var groundAltitude by remember { mutableFloatStateOf(20f) } - var headingOffset by remember { mutableFloatStateOf(0f) } - var cameraTilt by remember { mutableFloatStateOf(70f) } - var followSpeedMps by remember { mutableFloatStateOf(30f) } - - var isPlaying by remember { mutableStateOf(false) } - var isUserScrubbing by remember { mutableStateOf(false) } - var progress by remember { mutableFloatStateOf(0f) } - var elapsedDistance by remember { mutableDoubleStateOf(0.0) } - var currentHeading by remember { mutableStateOf(null) } - - val cumulativeDistances = remember(currentPath) { - PathEngine.calculateCumulativeDistances(path = currentPath) - } - val totalDistance = cumulativeDistances.lastOrNull() ?: 0.0 - val baseAltitude = if (currentPath == PathData.RURAL_PATH) 45.0 else 50.0 val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE) { - isPlaying = false + viewModel.setPlaying(false) } } lifecycleOwner.lifecycle.addObserver(observer) @@ -168,12 +136,12 @@ fun PathFollowingScreen() { } } - // Frame animation loop with delta-time integration - LaunchedEffect(isPlaying, totalDistance, followSpeedMps) { - if (!isPlaying || totalDistance <= 0.0) return@LaunchedEffect + // Hardware VSYNC-synced frame loop using withFrameMillis + LaunchedEffect(state.isPlaying) { + if (!state.isPlaying) return@LaunchedEffect var lastTimeNanos = 0L - while (isActive && isPlaying) { + while (isActive && state.isPlaying) { withFrameMillis { frameTimeMillis -> val nowNanos = frameTimeMillis * 1_000_000L if (lastTimeNanos == 0L) { @@ -183,431 +151,311 @@ fun PathFollowingScreen() { val dt = (nowNanos - lastTimeNanos) / 1_000_000_000.0 lastTimeNanos = nowNanos - val stepDistance = followSpeedMps * dt - elapsedDistance = (elapsedDistance + stepDistance) % totalDistance - - if (!isUserScrubbing && totalDistance > 0) { - progress = (elapsedDistance / totalDistance).toFloat().coerceIn(0f, 1f) - } + viewModel.advance(dt) } } } - // Interpolate camera and position - val interpPoint = remember(currentPath, cumulativeDistances, elapsedDistance) { - PathEngine.interpolatePoint(path = currentPath, cumulativeDistances = cumulativeDistances, distance = elapsedDistance) - } - - val targetHeading = remember(interpPoint.bearing, headingOffset, currentHeading, isUserScrubbing, isPlaying) { - val smoothed = PathEngine.smoothHeading( - interpPoint.bearing + headingOffset, - currentHeading, - isUserScrubbing, - isPlaying, - ) - currentHeading = smoothed - smoothed - } - - val cameraTargetAltitude = remember(selectedAltitudeMode, baseAltitude, interpPoint.altitude, groundAltitude) { - PathEngine.calculateCameraAltitude( - selectedAltitudeMode.mode, - baseAltitude, - interpPoint.altitude, - groundAltitude.toDouble(), - ) - } - - val dynamicCamera = remember(interpPoint.latLng, targetHeading, cameraTilt, cameraRange, cameraTargetAltitude) { + val dynamicCamera = remember(state.currentPosition, state.effectiveHeading, state.cameraTilt, state.cameraRange, state.cameraTargetAltitude) { camera { center = latLngAltitude { - latitude = interpPoint.latLng.latitude - longitude = interpPoint.latLng.longitude - altitude = cameraTargetAltitude + latitude = state.currentPosition.latitude + longitude = state.currentPosition.longitude + altitude = state.cameraTargetAltitude } - heading = targetHeading - tilt = cameraTilt.toDouble() - range = cameraRange.toDouble() + heading = state.effectiveHeading + tilt = state.cameraTilt + range = state.cameraRange roll = 0.0 } } - // Dual-Polyline Configurations - val staticPolylineConfig = remember(currentPath, selectedAltitudeMode, pathAltitudeOffset, drawsOccludedSegments, baseAltitude) { - val vertices = PathEngine.buildStaticVertices( - path = currentPath, - altitudeMode = selectedAltitudeMode.mode, - baseAltitude = baseAltitude, - pathAltitudeOffset = pathAltitudeOffset.toDouble(), - ) + val staticPolylineConfig = remember(state.staticPolylineVertices, state.altitudeMode, state.drawsOccludedSegments) { PolylineConfig( key = PathEngine.STATIC_POLYLINE_ID, - points = vertices, - color = "#4285F4".toColorInt(), // Wide blue route (16dp) + points = state.staticPolylineVertices, width = 16f, + color = Color.parseColor("#4285F4"), + altitudeMode = state.altitudeMode, + drawsOccludedSegments = state.drawsOccludedSegments, zIndex = 1, - altitudeMode = selectedAltitudeMode.mode, - drawsOccludedSegments = drawsOccludedSegments, ) } - val progressPolylineConfig = remember( - currentPath, cumulativeDistances, elapsedDistance, interpPoint, - selectedAltitudeMode, pathAltitudeOffset, drawsOccludedSegments, baseAltitude, totalDistance, - ) { - if (currentPath.isEmpty() || totalDistance <= 0.0) return@remember null - val vertices = PathEngine.buildProgressVertices( - currentPath, - cumulativeDistances, - elapsedDistance, - interpPoint.latLng, - interpPoint.waypointIndex, - selectedAltitudeMode.mode, - baseAltitude, - pathAltitudeOffset.toDouble(), - ) + val progressPolylineConfig = remember(state.progressPolylineVertices, state.altitudeMode, state.drawsOccludedSegments) { PolylineConfig( key = PathEngine.PROGRESS_POLYLINE_ID, - points = vertices, - color = "#9C27B0".toColorInt(), // Narrow purple progress (8dp) + points = state.progressPolylineVertices, width = 8f, + color = Color.parseColor("#9C27B0"), + altitudeMode = state.altitudeMode, + drawsOccludedSegments = state.drawsOccludedSegments, zIndex = 2, - altitudeMode = selectedAltitudeMode.mode, - drawsOccludedSegments = drawsOccludedSegments, ) } - val polylines = remember(staticPolylineConfig, progressPolylineConfig) { - listOfNotNull(staticPolylineConfig, progressPolylineConfig) - } - - // Control card collapse and auto-fade state - var isCollapsed by remember { mutableStateOf(false) } - var isTouching by remember { mutableStateOf(false) } - var isCardIdle by remember { mutableStateOf(false) } - - LaunchedEffect(isTouching, isCollapsed) { - if (!isTouching && !isCollapsed) { - delay(3000L) - isCardIdle = true - } else { - isCardIdle = false - } - } - - val controlsAlpha by animateFloatAsState( - targetValue = if (isCardIdle && !isCollapsed) 0.8f else 1.0f, - animationSpec = tween(durationMillis = 300), - label = "controlsAlpha", - ) - - Box( - modifier = Modifier - .fillMaxSize() - .semantics { contentDescription = if (isMapSteady) "MapSteady" else "MapLoading" }, - ) { + Box(modifier = Modifier.fillMaxSize()) { GoogleMap3D( - camera = dynamicCamera, - polylines = polylines, modifier = Modifier.fillMaxSize(), + camera = dynamicCamera, + mapMode = Map3DMode.HYBRID, + polylines = listOf(staticPolylineConfig, progressPolylineConfig), onMapSteady = { isMapSteady = true }, ) PathFollowingControlCard( + state = state, modifier = Modifier .align(Alignment.BottomCenter) - .padding(16.dp) .fillMaxWidth() - .alpha(controlsAlpha) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - isTouching = true - tryAwaitRelease() - isTouching = false - }, - ) - }, - isCollapsed = isCollapsed, - onToggleCollapse = { isCollapsed = !isCollapsed }, - currentPath = currentPath, - onSwitchEnvironment = { newPath -> - isPlaying = false - currentHeading = null - elapsedDistance = 0.0 - progress = 0f - if (newPath == PathData.RURAL_PATH) { - cameraRange = 450f - groundAltitude = 40f - cameraTilt = 75f - } else { - cameraRange = 300f - groundAltitude = 20f - cameraTilt = 70f - } - currentPath = newPath - }, - selectedAltitudeMode = selectedAltitudeMode, - onAltitudeModeChange = { selectedAltitudeMode = it }, - drawsOccludedSegments = drawsOccludedSegments, - onDrawsOccludedChange = { drawsOccludedSegments = it }, - pathAltitudeOffset = pathAltitudeOffset, - onPathAltitudeOffsetChange = { pathAltitudeOffset = it }, - isPlaying = isPlaying, - onPlayPauseToggle = { isPlaying = !isPlaying }, - progress = progress, - onProgressChange = { - progress = it - elapsedDistance = totalDistance * it.toDouble() + .padding(16.dp), + onTogglePlay = { viewModel.togglePlayPause() }, + onSeekRatio = { viewModel.seekToRatio(it) }, + onScrubbingChange = { viewModel.setScrubbing(it) }, + onAltitudeModeChange = { viewModel.setAltitudeMode(it.mode) }, + onOcclusionChange = { viewModel.setDrawsOccludedSegments(it) }, + onPathAltitudeOffsetChange = { viewModel.setPathAltitudeOffset(it.toDouble()) }, + onCameraRangeChange = { viewModel.setCameraRange(it.toDouble()) }, + onGroundAltitudeChange = { viewModel.setGroundAltitude(it.toDouble()) }, + onHeadingOffsetChange = { viewModel.setHeadingOffset(it.toDouble()) }, + onCameraTiltChange = { viewModel.setCameraTilt(it.toDouble()) }, + onSpeedChange = { viewModel.setFollowSpeed(it.toDouble()) }, + onEnvironmentChange = { isUrban -> + viewModel.setRoute(if (isUrban) PathData.URBAN_PATH else PathData.RURAL_PATH) }, - onProgressScrubbingChange = { isUserScrubbing = it }, - cameraRange = cameraRange, - onCameraRangeChange = { cameraRange = it }, - groundAltitude = groundAltitude, - onGroundAltitudeChange = { groundAltitude = it }, - headingOffset = headingOffset, - onHeadingOffsetChange = { headingOffset = it }, - cameraTilt = cameraTilt, - onCameraTiltChange = { cameraTilt = it }, - followSpeedMps = followSpeedMps, - onFollowSpeedChange = { followSpeedMps = it }, ) } } /** - * Floating collapsible control panel for path following parameters. + * Collapsible floating control card exposing all path following adjustments. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun PathFollowingControlCard( + state: PathPlaybackState, modifier: Modifier = Modifier, - isCollapsed: Boolean, - onToggleCollapse: () -> Unit, - currentPath: List, - onSwitchEnvironment: (List) -> Unit, - selectedAltitudeMode: AltitudeModeOption, + onTogglePlay: () -> Unit, + onSeekRatio: (Float) -> Unit, + onScrubbingChange: (Boolean) -> Unit, onAltitudeModeChange: (AltitudeModeOption) -> Unit, - drawsOccludedSegments: Boolean, - onDrawsOccludedChange: (Boolean) -> Unit, - pathAltitudeOffset: Float, + onOcclusionChange: (Boolean) -> Unit, onPathAltitudeOffsetChange: (Float) -> Unit, - isPlaying: Boolean, - onPlayPauseToggle: () -> Unit, - progress: Float, - onProgressChange: (Float) -> Unit, - onProgressScrubbingChange: (Boolean) -> Unit, - cameraRange: Float, onCameraRangeChange: (Float) -> Unit, - groundAltitude: Float, onGroundAltitudeChange: (Float) -> Unit, - headingOffset: Float, onHeadingOffsetChange: (Float) -> Unit, - cameraTilt: Float, onCameraTiltChange: (Float) -> Unit, - followSpeedMps: Float, - onFollowSpeedChange: (Float) -> Unit, + onSpeedChange: (Float) -> Unit, + onEnvironmentChange: (Boolean) -> Unit, ) { + var isCollapsed by remember { mutableStateOf(false) } + var isInteracting by remember { mutableStateOf(false) } + + val alpha by animateFloatAsState( + targetValue = if (isInteracting || isCollapsed) 1.0f else 0.85f, + animationSpec = tween(durationMillis = 300), + label = "controlCardAlpha", + ) + Card( - modifier = modifier.animateContentSize(), - elevation = CardDefaults.cardElevation(defaultElevation = 6.dp), + modifier = modifier, shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = alpha), + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), ) { - Column(modifier = Modifier.padding(16.dp)) { - // Header + Column( + modifier = Modifier + .padding(16.dp) + .verticalScroll(rememberScrollState()), + ) { + // Header: Title, Play/Pause, and Collapse Toggle Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onToggleCollapse() }, - verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { Text( text = "Path Following Controls", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, ) - IconButton(onClick = onToggleCollapse) { - Icon( - imageVector = if (isCollapsed) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = if (isCollapsed) "Expand Controls" else "Collapse Controls", - ) + Row { + IconButton(onClick = onTogglePlay) { + Icon( + imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (state.isPlaying) "Pause" else "Play", + ) + } + IconButton(onClick = { isCollapsed = !isCollapsed }) { + Icon( + imageVector = if (isCollapsed) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isCollapsed) "Expand" else "Collapse", + ) + } } } - AnimatedVisibility(visible = !isCollapsed) { - Column( - modifier = Modifier - .fillMaxWidth() - .height(380.dp) - .verticalScroll(rememberScrollState()), - ) { - // Environment Selection - Text("Environment", style = MaterialTheme.typography.labelMedium) - Row(modifier = Modifier.fillMaxWidth()) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .selectable( - selected = currentPath == PathData.URBAN_PATH, - onClick = { onSwitchEnvironment(PathData.URBAN_PATH) }, - role = Role.RadioButton, - ) - .padding(end = 16.dp), - ) { - RadioButton( - selected = currentPath == PathData.URBAN_PATH, - onClick = null, - ) - Text("Urban (SF)", modifier = Modifier.padding(start = 4.dp)) - } + // Progress Slider + val progressInteractionSource = remember { MutableInteractionSource() } + val isDragged by progressInteractionSource.collectIsDraggedAsState() + LaunchedEffect(isDragged) { + onScrubbingChange(isDragged) + } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.selectable( - selected = currentPath == PathData.RURAL_PATH, - onClick = { onSwitchEnvironment(PathData.RURAL_PATH) }, - role = Role.RadioButton, - ), - ) { - RadioButton( - selected = currentPath == PathData.RURAL_PATH, - onClick = null, - ) - Text("Rural", modifier = Modifier.padding(start = 4.dp)) - } - } + Slider( + value = state.progressRatio, + onValueChange = { onSeekRatio(it) }, + interactionSource = progressInteractionSource, + modifier = Modifier.fillMaxWidth(), + ) + AnimatedVisibility(visible = !isCollapsed) { + Column { Spacer(modifier = Modifier.height(8.dp)) // Altitude Mode Selection - Text("Altitude Mode", style = MaterialTheme.typography.labelMedium) - Column { - AltitudeModeOption.entries.chunked(2).forEach { rowOptions -> - Row(modifier = Modifier.fillMaxWidth()) { - rowOptions.forEach { opt -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .weight(1f) - .selectable( - selected = selectedAltitudeMode == opt, - onClick = { onAltitudeModeChange(opt) }, - role = Role.RadioButton, - ), - ) { - RadioButton( - selected = selectedAltitudeMode == opt, - onClick = null, - ) - Text(opt.label, style = MaterialTheme.typography.bodySmall) - } - } + Text( + text = "Altitude Mode", + style = MaterialTheme.typography.labelMedium, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + AltitudeModeOption.entries.forEach { option -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = state.altitudeMode == option.mode, + onClick = { onAltitudeModeChange(option) }, + ) + Text( + text = option.label, + style = MaterialTheme.typography.bodySmall, + ) } } } Spacer(modifier = Modifier.height(8.dp)) - // Occlusion toggle + // Occlusion Handling Switch Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Text("Draw Occluded Segments", style = MaterialTheme.typography.labelMedium) + Text( + text = "Draw Occluded Segments", + style = MaterialTheme.typography.bodyMedium, + ) Switch( - checked = drawsOccludedSegments, - onCheckedChange = onDrawsOccludedChange, + checked = state.drawsOccludedSegments, + onCheckedChange = onOcclusionChange, ) } - // Path Height Offset + Spacer(modifier = Modifier.height(8.dp)) + + // Path Altitude Offset Slider + val maxOffset = if (state.route == PathData.RURAL_PATH) 200f else 20f Text( - stringResource(R.string.path_height_format, pathAltitudeOffset), - style = MaterialTheme.typography.labelSmall, + text = "Path Elevation Offset: %.1f m".format(state.pathAltitudeOffset), + style = MaterialTheme.typography.bodySmall, ) Slider( - value = pathAltitudeOffset, + value = state.pathAltitudeOffset.toFloat(), onValueChange = onPathAltitudeOffsetChange, - valueRange = 0f..10f, + valueRange = 0f..maxOffset, ) - // Play / Pause & Progress Scrubbing - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Button(onClick = onPlayPauseToggle) { - Icon( - imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, - contentDescription = if (isPlaying) "Pause" else "Play", - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(if (isPlaying) "Pause" else "Play") - } - - Spacer(modifier = Modifier.width(8.dp)) - - Slider( - value = progress, - onValueChange = onProgressChange, - onValueChangeFinished = { onProgressScrubbingChange(false) }, - modifier = Modifier.weight(1f), - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - // Camera Controls + // Camera Range Slider Text( - stringResource(R.string.camera_range_format, cameraRange.toInt()), - style = MaterialTheme.typography.labelSmall, + text = "Camera Distance: %d m".format(state.cameraRange.toInt()), + style = MaterialTheme.typography.bodySmall, ) Slider( - value = cameraRange, + value = state.cameraRange.toFloat(), onValueChange = onCameraRangeChange, valueRange = 50f..1000f, ) + // Ground Altitude Slider + val maxGroundAlt = if (state.route == PathData.RURAL_PATH) 200f else 100f Text( - stringResource(R.string.ground_altitude_format, groundAltitude.toInt()), - style = MaterialTheme.typography.labelSmall, + text = "Camera Altitude: %d m".format(state.groundAltitude.toInt()), + style = MaterialTheme.typography.bodySmall, ) Slider( - value = groundAltitude, + value = state.groundAltitude.toFloat(), onValueChange = onGroundAltitudeChange, - valueRange = 2f..if (currentPath == PathData.RURAL_PATH) 2000f else 200f, + valueRange = 5f..maxGroundAlt, ) + // Heading Offset Slider Text( - stringResource(R.string.heading_offset_format, headingOffset.toInt()), - style = MaterialTheme.typography.labelSmall, + text = "Heading Offset: %d°".format(state.headingOffset.toInt()), + style = MaterialTheme.typography.bodySmall, ) Slider( - value = headingOffset, + value = state.headingOffset.toFloat(), onValueChange = onHeadingOffsetChange, valueRange = -180f..180f, ) + // Camera Tilt Slider Text( - stringResource(R.string.camera_tilt_format, cameraTilt.toInt()), - style = MaterialTheme.typography.labelSmall, + text = "Camera Tilt: %d°".format(state.cameraTilt.toInt()), + style = MaterialTheme.typography.bodySmall, ) Slider( - value = cameraTilt, + value = state.cameraTilt.toFloat(), onValueChange = onCameraTiltChange, valueRange = 0f..85f, ) + // Speed Slider Text( - stringResource(R.string.follow_speed_format, followSpeedMps.toInt()), - style = MaterialTheme.typography.labelSmall, + text = "Follow Speed: %d m/s".format(state.followSpeedMps.toInt()), + style = MaterialTheme.typography.bodySmall, ) Slider( - value = followSpeedMps, - onValueChange = onFollowSpeedChange, - valueRange = 5f..100f, + value = state.followSpeedMps.toFloat(), + onValueChange = onSpeedChange, + valueRange = 5f..120f, + ) + + // Environment Route Selection + Text( + text = "Environment", + style = MaterialTheme.typography.labelMedium, ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = state.route == PathData.URBAN_PATH, + onClick = { onEnvironmentChange(true) }, + ) + Text( + text = "Urban (SF)", + style = MaterialTheme.typography.bodySmall, + ) + } + Spacer(modifier = Modifier.size(16.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = state.route == PathData.RURAL_PATH, + onClick = { onEnvironmentChange(false) }, + ) + Text( + text = "Rural", + style = MaterialTheme.typography.bodySmall, + ) + } + } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a4e94c8..14f3a9f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,6 +47,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activityCompose" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } From fe895ed4c2454f4b65a179e91534fed31c086668 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:53:32 -0600 Subject: [PATCH 6/6] feat(ui): position Play/Pause button and progress bar permanently at top of control dialog - Move Play/Pause button and progress slider above scrollable content so they remain visible when dialog is collapsed - Ensure title, collapse toggle, play/pause, and progress bar are always accessible --- .../res/layout/activity_path_following.xml | 62 ++++++++++--------- 1 file changed, 34 insertions(+), 28 deletions(-) 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 f2b6923..1350dff 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 @@ -80,6 +80,39 @@ /> + + + + + + + + - - - - - - - +