Skip to content
Open
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
b6a6c8f
feat: create repo-root catalog, basic Compose map sample, and establi…
dkhawk Apr 3, 2026
43e4f03
feat: initialize ComposeDemos module and reorganize catalog
dkhawk Apr 23, 2026
00652cd
feat: add Polylines sample data, tests, and screenshot
dkhawk Apr 23, 2026
c71f07a
docs: use HTML img tags for catalog thumbnails
dkhawk Apr 23, 2026
70a5638
feat: implement Popovers sample and visual test
dkhawk Apr 23, 2026
c8b6286
feat: apply immersive mode and update catalog thumbnails
dkhawk Apr 23, 2026
9a5ab2e
feat: implement Polygons, Models, and Markers samples
dkhawk Apr 23, 2026
ab7f9fe
fix: correct spelling of Visualization to American English
dkhawk Apr 23, 2026
c9eed08
feat: implement Camera Restrictions sample and visual test
dkhawk Apr 23, 2026
f8e55c8
style: run spotlessApply to format code
dkhawk Apr 23, 2026
bd0cdb9
style: clean up fully qualified names and format code
dkhawk Apr 23, 2026
72765dc
feat: implement missing compose samples and fix library bugs
dkhawk Apr 23, 2026
5cd4041
chore: add default API key placeholders to local.defaults.properties
dkhawk Apr 23, 2026
c570c08
feat: implement Place Details sample with custom theme and fragment i…
dkhawk Apr 23, 2026
7263871
feat: update initial camera for Place Details sample
dkhawk Apr 23, 2026
44e0e60
feat: make Flatirons the first item in Place Details sample
dkhawk Apr 23, 2026
5fb5ebe
chore: remove old screenshot
dkhawk Apr 23, 2026
214ce81
feat: add correct place details screenshot for Flatirons
dkhawk Apr 23, 2026
6b04da0
chore: remove old screenshot again
dkhawk Apr 23, 2026
a1238f4
feat: verify Place Details with strict prompt and longer delay
dkhawk Apr 23, 2026
36bae9c
feat: add swipe gesture to Place Details visual test
dkhawk Apr 23, 2026
a3cad79
feat: implement Place Details sample with custom theme and clean MVVM…
dkhawk Apr 23, 2026
6b4f9cc
docs: add description column to Compose catalog README
dkhawk Apr 24, 2026
29e4408
feat(samples): align Java and Kotlin camera controls and improve cata…
dkhawk Apr 27, 2026
587139d
feat(samples): implement Map Interactions and add visual tests for Ja…
dkhawk May 20, 2026
51c6652
Merge branch 'origin/main' into feat/catalogs-for-java-and-kotlin-views
dkhawk May 20, 2026
c3c3c57
refactor(samples): implement robust isInitialized delayed fallback pa…
dkhawk May 20, 2026
23caccf
test(api-demos): stabilize visual testing suite and fix rendering gli…
dkhawk May 20, 2026
0559073
test(api-demos): update activity copyright headers to 2026
dkhawk May 20, 2026
cc7600f
fix(api-demos): resolve hardcoded string lint warnings in map interac…
dkhawk May 20, 2026
2724522
Merge branch 'feat/catalogs-for-java-and-kotlin-views' into feat/fill…
dkhawk May 21, 2026
a6ccc95
feat: implement view-based Kotlin RoutesActivity and shared RouteEngi…
dkhawk May 21, 2026
7619143
feat: implement view-based Java RoutesActivity and RouteRepository
dkhawk May 21, 2026
33be374
docs: update Kotlin and Java views catalogs to mark Routes API as Done
dkhawk May 21, 2026
8c622b6
feat: implement Marker Styling and Marker Collisions in Compose Marke…
dkhawk May 21, 2026
d6a7a2f
feat(visual-testing): implement view-based Kotlin/Java RoutesVisualTe…
dkhawk May 22, 2026
1ebef64
fix(routes): implement robust offline fallback route and fix blank sc…
dkhawk May 22, 2026
4338fee
fix(routes): forward lifecycle methods and re-bind map3DView in Java …
dkhawk May 22, 2026
619344f
fix(routes): resolve background single-thread deadlock in Java Routes…
dkhawk May 22, 2026
f255708
chore(debug): add verbose tick logs to RoutesActivity in Java and Kotlin
dkhawk May 22, 2026
a30036a
fix(routes): call addModel on every tick to properly update model pos…
dkhawk May 22, 2026
befafd3
fix(routes): fix lookahead heading calculation in Compose RoutesActiv…
dkhawk May 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions Maps3DSamples/ApiDemos/catalog_automation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import subprocess
import os
import sys
import re

def run_command(cmd, cwd=None):
print(f"Running: {cmd}")
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, cwd=cwd)
if result.returncode != 0:
print(f"Command failed with exit code {result.returncode}")
print(result.stderr)
return False, result.stdout
return True, result.stdout

def main():
if len(sys.argv) < 3:
print("Usage: python3 catalog_automation.py <java|kotlin> <TestClassName>")
sys.exit(1)

app_type = sys.argv[1]
test_class = sys.argv[2]

if app_type not in ["java", "kotlin"]:
print("Invalid app type. Use 'java' or 'kotlin'.")
sys.exit(1)

# Workspace root relative to this script
script_dir = os.path.dirname(os.path.abspath(__file__))
workspace_root = os.path.abspath(os.path.join(script_dir, "../.."))

package_mapping = {
"java": "com.example.maps3djava",
"kotlin": "com.example.maps3dkotlin"
}
package = package_mapping[app_type]

module_mapping = {
"java": ":Maps3DSamples:ApiDemos:java-app",
"kotlin": ":Maps3DSamples:ApiDemos:kotlin-app"
}
module = module_mapping[app_type]

# 1. Install and Run Test
print(f"Installing {app_type} app...")
success, _ = run_command(f"./gradlew {module}:installDebug", cwd=workspace_root)
if not success: sys.exit(1)

print(f"Installing {app_type} test app...")
success, _ = run_command(f"./gradlew {module}:installDebugAndroidTest", cwd=workspace_root)
if not success: sys.exit(1)

print("Running test...")
cmd = f"adb shell am instrument -w -e class {package}.{test_class} {package}.test/androidx.test.runner.AndroidJUnitRunner"
success, output = run_command(cmd, cwd=workspace_root)
if not success:
print("Test failed.")
sys.exit(1)

print("Test passed. Pulling screenshot...")

# 2. Pull Screenshot
filename_mapping = {
"HelloMapVisualTest": "hello_map_screenshot.png",
"PolylinesVisualTest": "polylines_screenshot.png",
"MapInteractionsVisualTest": "map_interactions_screenshot.png",
"PopoversVisualTest": "popovers_screenshot.png",
"CameraControlsVisualTest": "camera_controls_screenshot.png",
"PolygonsVisualTest": "polygons_screenshot.png",
"ModelsVisualTest": "models_screenshot.png",
"MarkersVisualTest": "markers_screenshot.png",
}

filename = filename_mapping.get(test_class, f"{test_class.lower()}_screenshot.png")
local_path = filename

# Use run-as to read the file from the app's data directory and pipe it to a local file
cat_cmd = f"adb shell run-as {package} cat files/{filename}"
print(f"Running: {cat_cmd}")
result = subprocess.run(cat_cmd, shell=True, capture_output=True, text=False)
if result.returncode != 0:
print(f"Failed to read screenshot via run-as. Error: {result.stderr.decode('utf-8')}")
sys.exit(1)

with open(local_path, "wb") as f:
f.write(result.stdout)
print(f"Pulled screenshot to {local_path}")

# 3. Scale Image using sips (macOS built-in)
dim_cmd = f"sips -g pixelWidth -g pixelHeight {local_path}"
success, dim_output = run_command(dim_cmd)
if not success:
print("Failed to get image dimensions.")
sys.exit(1)

try:
width = int(re.search(r"pixelWidth: (\d+)", dim_output).group(1))
height = int(re.search(r"pixelHeight: (\d+)", dim_output).group(1))
except AttributeError:
print(f"Failed to parse dimensions from output: {dim_output}")
sys.exit(1)

new_width = int(width * 0.5)
new_height = int(height * 0.5)

print(f"Scaling from {width}x{height} to {new_width}x{new_height}")
scale_cmd = f"sips -z {new_height} {new_width} {local_path}"
success, _ = run_command(scale_cmd)
if not success:
print("Failed to scale image.")
sys.exit(1)

# 4. Move to Source
app_dir_mapping = {
"java": "java-app",
"kotlin": "kotlin-app"
}
app_dir = app_dir_mapping[app_type]
target_dir = f"{workspace_root}/Maps3DSamples/ApiDemos/{app_dir}/screenshots"
os.makedirs(target_dir, exist_ok=True)

target_path = f"{target_dir}/{local_path}"
os.rename(local_path, target_path)
print(f"Screenshot saved to {target_path}")

# 5. Update Catalog
catalog_path = f"{workspace_root}/Maps3DSamples/ApiDemos/{app_dir}/README.md"

mapping = {
"HelloMapVisualTest": "Basic Map",
"PolylinesVisualTest": "Polylines",
"MapInteractionsVisualTest": "Map Interactions",
"PopoversVisualTest": "Popovers",
"CameraControlsVisualTest": "Camera Controls",
"PolygonsVisualTest": "Polygons",
"ModelsVisualTest": "Models",
"MarkersVisualTest": "Markers",
}

feature_name = mapping.get(test_class)
if feature_name:
if not os.path.exists(catalog_path):
print(f"Catalog file not found: {catalog_path}")
sys.exit(1)

with open(catalog_path, "r") as f:
catalog_content = f.read()

image_link = f'<img src="screenshots/{local_path}" alt="Screenshot" width="121"/>'

lines = catalog_content.split("\n")
updated = False
for i, line in enumerate(lines):
if f"| **{feature_name}** |" in line:
parts = line.split("|")
if len(parts) >= 5:
parts[4] = f" {image_link} "
lines[i] = "|".join(parts)
updated = True
break

if updated:
catalog_content = "\n".join(lines)
with open(catalog_path, "w") as f:
f.write(catalog_content)
print(f"Updated Catalog README.md for {feature_name}")
else:
print(f"Feature {feature_name} not found in catalog.")
else:
print(f"No mapping found for {test_class} in catalog.")

if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions Maps3DSamples/ApiDemos/common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,5 @@ dependencies {

api(libs.play.services.base) // "com.google.android.gms:play-services-base:18.10.0"
api(libs.play.services.maps3d) // "com.google.android.gms:play-services-maps3d:0.2.0"
api(libs.maps.utils.ktx)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/*
* 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 kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.floor
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt

/**
* Represents the result of interpolating a position along a 3D map route.
*
* @property position The interpolated LatLng coordinate of the target object.
* @property heading The calculated heading (bearing) in degrees, clockwise from North.
*/
data class PositionAndHeading(
val position: LatLng,
val heading: Float
)

/**
* A shared, highly-optimized math and physics engine designed to calculate real-time positions
* and orientations for objects (like cars or drones) moving along complex geographic coordinates.
*
* Accessible to both Kotlin and Java View-based sample modules.
*/
object RouteEngine {

/**
* Precomputes a cumulative distance array in meters for a given list of coordinates.
*
* @param route A list of [LatLng] coordinates representing the route.
* @return A DoubleArray where each index holds the total distance from index 0 to that index.
*/
@JvmStatic
fun calculateCumulativeDistances(route: List<LatLng>): DoubleArray {
if (route.isEmpty()) return doubleArrayOf(0.0)

val cumulativeDistances = DoubleArray(route.size)
cumulativeDistances[0] = 0.0
for (i in 1 until route.size) {
cumulativeDistances[i] = cumulativeDistances[i - 1] + haversineDistance(route[i - 1], route[i])
}
return cumulativeDistances
}

/**
* Calculates the absolute coordinate at a specific distance along the pre-computed route path.
*
* @param distance The target distance in meters to locate.
* @param route The list of coordinates.
* @param cumulativeDistances The pre-computed cumulative distances corresponding to the route.
* @return The interpolated [LatLng] coordinate.
*/
@JvmStatic
fun getInterpolatedPoint(
distance: Double,
route: List<LatLng>,
cumulativeDistances: DoubleArray
): LatLng {
if (distance <= 0.0) return route.first()
if (distance >= cumulativeDistances.last()) return route.last()

var idx = cumulativeDistances.binarySearch(distance)
if (idx < 0) {
idx = -(idx + 1) - 1
}
idx = idx.coerceIn(0, cumulativeDistances.size - 2)

val p1 = route[idx]
val p2 = route[idx + 1]
val d1 = cumulativeDistances[idx]
val d2 = cumulativeDistances[idx + 1]

val fraction = (distance - d1) / (d2 - d1)
if (fraction <= 0.0) return p1
if (fraction >= 1.0) return p2

val lat = p1.latitude + (p2.latitude - p1.latitude) * fraction
val lng = p1.longitude + (p2.longitude - p1.longitude) * fraction
return LatLng(lat, lng)
}

/**
* Computes both the geographic position and the rotational heading of a vehicle at a
* given distance along the route.
*
* @param route The list of route coordinates.
* @param cumulativeDistances The pre-computed cumulative distances corresponding to the route.
* @param distance The target distance in meters along the route.
* @param lookaheadDistance The forward-looking distance in meters used to predict heading.
* @return The calculated [PositionAndHeading] structure.
*/
@JvmStatic
@JvmOverloads
fun calculatePositionAndHeading(
route: List<LatLng>,
cumulativeDistances: DoubleArray,
distance: Double,
lookaheadDistance: Double = 30.0
): PositionAndHeading {
val targetPos = getInterpolatedPoint(distance, route, cumulativeDistances)
val lookaheadPos = getInterpolatedPoint(distance + lookaheadDistance, route, cumulativeDistances)

val heading = if (targetPos == lookaheadPos && distance > 0.0) {
val prevPos = getInterpolatedPoint(distance - 1.0, route, cumulativeDistances)
calculateHeading(prevPos, targetPos).toFloat()
} else {
calculateHeading(targetPos, lookaheadPos).toFloat()
}

return PositionAndHeading(targetPos, heading)
}

/**
* Calculates the distance in meters between two [LatLng] points using the Haversine formula.
*/
@JvmStatic
fun haversineDistance(p1: LatLng, p2: LatLng): Double {
val r = 6371000.0 // Earth radius in meters
val lat1 = Math.toRadians(p1.latitude)
val lon1 = Math.toRadians(p1.longitude)
val lat2 = Math.toRadians(p2.latitude)
val lon2 = Math.toRadians(p2.longitude)

val dLat = lat2 - lat1
val dLon = lon2 - lon1

val a = sin(dLat / 2).pow(2.0) +
cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2.0)
val c = 2 * atan2(sqrt(a), sqrt(1 - a))

return r * c
}

/**
* Calculates the bearing (heading) from one LatLng coordinate to another in degrees.
*/
@JvmStatic
fun calculateHeading(from: LatLng, to: LatLng): Double {
val lat1 = Math.toRadians(from.latitude)
val lon1 = Math.toRadians(from.longitude)
val lat2 = Math.toRadians(to.latitude)
val lon2 = Math.toRadians(to.longitude)

val dLon = lon2 - lon1
val y = sin(dLon) * cos(lat2)
val x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)

val bearing = Math.toDegrees(atan2(y, x))
return (bearing + 360.0) % 360.0
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M6,19h4V5H6v14zm8,-14v14h4V5h-4z"/>
</vector>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M8,5v14l11,-7z"/>
</vector>
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@
android:id="@+id/map3dView"
map3d:mapId="bcce776b92de1336e22c569f"
map3d:mode="hybrid"
map3d:centerLat="40.748392"
map3d:centerLng="-73.986060"
map3d:centerAlt="175"
map3d:heading="26"
map3d:tilt="67"
map3d:range="4000"
map3d:centerLat="38.743498"
map3d:centerLng="-109.499307"
map3d:centerAlt="1467"
map3d:heading="151"
map3d:tilt="68"
map3d:range="250"
map3d:roll="0"
map3d:minAltitude="0"
map3d:maxAltitude="1000000"
Expand Down
Loading
Loading