Visualize hidden infrastructure in its real-world location using augmented reality.

Use case
You can use AR to “x-ray” the ground to see pipes, wiring, or other infrastructure that isn’t otherwise visible. For example, you could use this feature to trace the flow of water through a building to help identify the source of a leak.
How to use the sample
When you open the sample, you’ll see a map centered on your current location. Tap on the map to draw pipes around your location. After drawing the pipes, input an elevation offset value to place the drawn infrastructure above or below ground. When you are ready, tap the camera button to view the infrastructure you drew in AR.
How it works
- Draw pipes on the map. See the “Create and edit geometries” sample to learn how to use the geometry editor for creating graphics.
- Add a
WorldScaleSceneViewcomposable to the augmented reality screen, available in the ArcGIS Maps SDK for Kotlin toolkit.- The component is available both in
World trackingandGeospatial trackingmodes. Geospatial tracking uses street view data to calibrate augmented reality positioning and is available with an ARCORE API key.
- The component is available both in
- Pass a
SceneViewinto the world scale scene view and set the base surface background grid to not be visible and the base surface opacity to 0.0. - Create an
ArcGISTiledElevationSourceand add it to the scene’s base surface. Set the navigation constraint to unconstrained to allow going underground if needed. - Configure a graphics overlay and renderer for showing the drawn pipes. This sample uses a
SolidStrokeSymbolLayerwith aMultilayerPolylineSymbolto draw the pipes.
Relevant API
- GeometryEditor
- GraphicsOverlay
- MultilayerPolylineSymbol
- SolidStrokeSymbolLayer
- Surface
- WorldScaleSceneView
About the data
This sample uses Esri’s world elevation service to ensure that the infrastructure you create is accurately placed beneath the ground.
Real-scale AR relies on having data in real-world locations near the user. It isn’t practical to provide pre-made data like other ArcGIS Maps SDKs for Native Apps samples, so you must draw your own nearby sample “pipe infrastructure” prior to starting the AR experience.
Additional information
You may notice that pipes you draw underground appear to float more than you would expect. That floating is a normal result of the parallax effect that looks unnatural because you’re not used to being able to see underground/obscured objects. Compare the behavior of underground pipes with equivalent pipes drawn above the surface - the behavior is the same, but probably feels more natural above ground because you see similar scenes day-to-day (e.g. utility wires).
This sample requires a device that is compatible with ARCore.
Unlike other scene samples, there’s no need for a basemap while navigating, because context is provided by the camera feed showing the real environment. The base surface’s opacity is set to zero to prevent it from interfering with the AR experience.
This sample uses the WorldScaleSceneView toolkit component. For information about setting up the toolkit, as well as code for the underlying component, visit the toolkit docs.
Note that apps using ARCore must comply with ARCore’s user privacy requirements. See this page for more information.
Tags
augmented reality, full-scale, infrastructure, lines, mixed reality, pipes, real-scale, underground, visualization, visualize, world-scale
Sample Code
/* Copyright 2025 Esri * * 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.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure
import android.Manifestimport android.os.Bundleimport android.widget.Toastimport androidx.activity.ComponentActivityimport androidx.activity.compose.setContentimport androidx.activity.enableEdgeToEdgeimport androidx.activity.result.contract.ActivityResultContractsimport androidx.compose.runtime.Composableimport androidx.navigation.compose.rememberNavControllerimport com.arcgismaps.ApiKeyimport com.arcgismaps.ArcGISEnvironmentimport com.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.components.SharedRepositoryimport com.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.navigation.AugmentRealityToShowHiddenInfrastructureNavGraphimport com.esri.arcgismaps.sample.sampleslib.theme.SampleAppTheme
class MainActivity : ComponentActivity() {
private var isLocationPermissionGranted = false
private val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted -> if (isGranted) { isLocationPermissionGranted = true } else { Toast.makeText(this, "Location permission is required to run this sample!", Toast.LENGTH_SHORT).show() }
enableEdgeToEdge() setContent { SampleAppTheme { AugmentRealityToNavigateRoute(isLocationPermissionGranted) } } }
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // authentication with an API key or named user is // required to access basemaps and other location services ArcGISEnvironment.apiKey = ApiKey.create(BuildConfig.ACCESS_TOKEN) ArcGISEnvironment.applicationContext = applicationContext
val hasNonDefaultAPIKey = BuildConfig.GOOGLE_API_KEY != "DEFAULT_GOOGLE_API_KEY"
SharedRepository.updateHasNonDefaultAPIKey(hasNonDefaultAPIKey) SharedRepository.pipeInfoList.clear()
requestLocationPermission() }
private fun requestLocationPermission() { requestPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }
}
@Composablefun AugmentRealityToNavigateRoute(isLocationPermissionGranted: Boolean) { val navController = rememberNavController() AugmentRealityToShowHiddenInfrastructureNavGraph( navController = navController, isLocationPermissionGranted = isLocationPermissionGranted, )}/* Copyright 2025 Esri * * 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.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.components
import android.app.Applicationimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.setValueimport androidx.lifecycle.AndroidViewModelimport androidx.lifecycle.viewModelScopeimport com.arcgismaps.Colorimport com.arcgismaps.geometry.GeodeticCurveTypeimport com.arcgismaps.geometry.GeometryEngineimport com.arcgismaps.geometry.LinearUnitimport com.arcgismaps.geometry.Pointimport com.arcgismaps.geometry.Polylineimport com.arcgismaps.geometry.PolylineBuilderimport com.arcgismaps.geometry.SpatialReferenceimport com.arcgismaps.mapping.ArcGISSceneimport com.arcgismaps.mapping.ElevationSourceimport com.arcgismaps.mapping.NavigationConstraintimport com.arcgismaps.mapping.symbology.MultilayerPolylineSymbolimport com.arcgismaps.mapping.symbology.SimpleLineSymbolimport com.arcgismaps.mapping.symbology.SimpleLineSymbolStyleimport com.arcgismaps.mapping.symbology.SolidStrokeSymbolLayerimport com.arcgismaps.mapping.symbology.StrokeSymbolLayerLineStyle3Dimport com.arcgismaps.mapping.view.Graphicimport com.arcgismaps.mapping.view.GraphicsOverlayimport com.arcgismaps.mapping.view.SurfacePlacementimport com.arcgismaps.toolkit.ar.WorldScaleSceneViewProxyimport com.arcgismaps.toolkit.ar.WorldScaleVpsAvailabilityimport kotlinx.coroutines.launch
class AugmentedRealityViewModel(app: Application) : AndroidViewModel(app) {
val worldScaleSceneViewProxy = WorldScaleSceneViewProxy()
var isVpsAvailable by mutableStateOf(false)
// Graphics overlay for the 3D pipes val pipeGraphicsOverlay = GraphicsOverlay().apply { sceneProperties.surfacePlacement = SurfacePlacement.Absolute }
// Graphics overlay for the shadow of pipes underground val pipeShadowGraphicsOverlay = GraphicsOverlay().apply { opacity = 0.6f }
// Graphics overlay for the leaders val leaderGraphicsOverlay = GraphicsOverlay().apply { sceneProperties.surfacePlacement = SurfacePlacement.Absolute }
// Create a scene with an elevation source and grid and surface hidden val arcGISScene = ArcGISScene().apply { baseSurface.apply { elevationSources.add(ElevationSource.fromTerrain3dService()) backgroundGrid.isVisible = false opacity = 0.0f navigationConstraint = NavigationConstraint.None } }
// Define a red 3D stroke symbol to show the pipe private val pipeStrokeSymbol = SolidStrokeSymbolLayer( width = 0.3, color = Color.red, lineStyle3D = StrokeSymbolLayerLineStyle3D.Tube ) val pipeSymbol = MultilayerPolylineSymbol(listOf(pipeStrokeSymbol))
// Define a red 2D stroke symbol to show the pipe shadow private val pipeShadowSymbol = SimpleLineSymbol( style = SimpleLineSymbolStyle.Solid, color = Color.yellow, width = 0.3f )
val leaderSymbol = SimpleLineSymbol( style = SimpleLineSymbolStyle.Dash, color = Color.red, width = 0.1f )
init { // For each pipe in the shared repository SharedRepository.pipeInfoList.forEach { viewModelScope.launch { // Densify the polyline to ensure it has enough points for elevation sampling val densifiedPolyline = GeometryEngine.densifyGeodeticOrNull( geometry = it.polyline, maxSegmentLength = 1.0, lengthUnit = LinearUnit.meters, curveType = GeodeticCurveType.Geodesic ) as Polyline // Add Z values to the polyline using the base surface elevation and elevation offset val densifiedPolylineWithZ = addZValues(densifiedPolyline, it.elevationOffset) // Add the 3D pipe to the pipe graphics overlay pipeGraphicsOverlay.graphics.add(Graphic(densifiedPolylineWithZ, pipeSymbol)) // Only add the shadow if the pipe is underground if (it.elevationOffset < 0) { // Add the 2D pipe shadow to the shadow graphics overlay pipeShadowGraphicsOverlay.graphics.add(Graphic(it.polyline, pipeShadowSymbol)) // Get the original polyline with Z values val originalPolylineWithZ = addZValues(it.polyline, it.elevationOffset) // Add leader lines connecting pipe vertices to shadow vertices addLeaderLines(originalPolylineWithZ, it.elevationOffset) } } } }
/** * Adds Z values to the polyline by getting the elevation from the base surface. */ private suspend fun addZValues(polyline: Polyline, elevationOffset: Float): Polyline { // Create a new polyline builder to construct the polyline with Z values val polylineBuilder = PolylineBuilder(SpatialReference(3857)) // For each point in each part of the densified polyline polyline.parts.forEach { part -> part.points.forEach { point -> arcGISScene.baseSurface.elevationSources.first().load().onSuccess { arcGISScene.baseSurface.getElevation(point).let { elevationResult -> // Get the elevation at the point elevationResult.getOrNull()?.let { elevation -> // Add the point with the elevation offset to the polyline builder polylineBuilder.addPoint( GeometryEngine.createWithZ( point, elevation + elevationOffset ) ) } } } } } return polylineBuilder.toGeometry() }
/** * Adds leader lines from the pipe vertices to the shadow vertices. */ private fun addLeaderLines(pipePolyline: Polyline, elevationOffset: Float) { // For each point in each part of the densified polyline pipePolyline.parts.forEach { part -> part.points.forEach { point -> // Create a line from the 3D pipe vertex to a pont offset by the elevation offset val offsetPoint = GeometryEngine.createWithZ( point, point.z?.minus(elevationOffset) ) val leaderLine = Polyline(listOf(point, offsetPoint)) leaderGraphicsOverlay.graphics.add(Graphic(leaderLine, leaderSymbol)) } } }
/** * Checks if the current viewpoint camera location is within the VPS availability area. */ fun onCurrentViewpointCameraChanged(location: Point) { viewModelScope.launch { worldScaleSceneViewProxy.checkVpsAvailability(location.y, location.x).onSuccess { isVpsAvailable = it == WorldScaleVpsAvailability.Available } } }}/* Copyright 2025 Esri * * 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.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.components
import android.app.Applicationimport androidx.compose.runtime.MutableStateimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableFloatStateOfimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.setValueimport androidx.lifecycle.AndroidViewModelimport androidx.lifecycle.viewModelScopeimport com.arcgismaps.Colorimport com.arcgismaps.geometry.GeometryTypeimport com.arcgismaps.geometry.Polylineimport com.arcgismaps.mapping.ArcGISMapimport com.arcgismaps.mapping.BasemapStyleimport com.arcgismaps.mapping.symbology.SimpleLineSymbolimport com.arcgismaps.mapping.symbology.SimpleLineSymbolStyleimport com.arcgismaps.mapping.view.Graphicimport com.arcgismaps.mapping.view.GraphicsOverlayimport com.arcgismaps.mapping.view.LocationDisplayimport com.arcgismaps.mapping.view.geometryeditor.GeometryEditorimport kotlinx.coroutines.launch
class MapViewModel(app: Application) : AndroidViewModel(app) {
val arcGISMap = ArcGISMap(BasemapStyle.ArcGISImagery) val graphicsOverlay = GraphicsOverlay() val geometryEditor = GeometryEditor()
private var _statusText by mutableStateOf("Tap on map or use current location to create start point") val statusText get() = _statusText
private var _showElevationDialog by mutableStateOf(false) val showElevationDialog get() = _showElevationDialog
private var _elevationInput by mutableFloatStateOf(0f) val elevationInput get() = _elevationInput
var graphic = Graphic()
var polyline: Polyline? = null
private val _isGeometryBeingEdited = mutableStateOf(false) val isGeometryBeingEdited: MutableState<Boolean> = _isGeometryBeingEdited
init { startPolylineEditing() }
/** * Initialize the location display. */ fun initialize(locationDisplay: LocationDisplay) { with(viewModelScope) { launch { locationDisplay.dataSource.start() } launch { geometryEditor.geometry.collect { (it as? Polyline)?.let { polyline -> _isGeometryBeingEdited.value = geometryEditor.isStarted.value && (polyline.parts.firstOrNull()?.points?.toList()?.size ?: 0) > 1 } } } } }
/** * Starts the GeometryEditor for creating a polyline. */ fun startPolylineEditing() { geometryEditor.start(GeometryType.Polyline) _statusText = "Polyline editing started. Tap to add points." }
/** * Completes the polyline and adds it as a graphic to the map. */ fun completePolyline() { polyline = geometryEditor.stop() as? Polyline if (polyline != null) { graphic = Graphic( polyline, symbol = SimpleLineSymbol(style = SimpleLineSymbolStyle.Solid, color = Color.red, width = 2f) ) _showElevationDialog = true graphicsOverlay.graphics.add(graphic)
_statusText = "Polyline completed. Tap again to continue adding polylines or proceed to rendering in AR." } else { _statusText = "No geometry created. Try again." } }
/** * Adds the pipe information to the shared repository and resets the UI state. */ fun onElevationConfirmed(elevation: Float) { polyline?.let { pipelineGeometry -> _elevationInput = elevation _showElevationDialog = false _isGeometryBeingEdited.value = false SharedRepository.pipeInfoList.add(PipeInfo(pipelineGeometry, _elevationInput)) } }}
data class PipeInfo( val polyline: Polyline, val elevationOffset: Float)/* Copyright 2025 Esri * * 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.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.components
import androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.setValue
/** * Shared repository to hold the route result generated in the route view model and passed to the augmented reality view * model. */object SharedRepository {
private var _pipeInfoList: MutableList<PipeInfo> = mutableListOf() val pipeInfoList get() = _pipeInfoList
private var _hasNonDefaultAPIKey by mutableStateOf(false) val hasNonDefaultAPIKey: Boolean get() = _hasNonDefaultAPIKey
fun updateHasNonDefaultAPIKey(hasNonDefaultAPIKey: Boolean) { _hasNonDefaultAPIKey = hasNonDefaultAPIKey }}/* Copyright 2025 Esri * * 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.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.navigation
import androidx.compose.runtime.Composableimport androidx.compose.ui.Modifierimport androidx.compose.ui.res.stringResourceimport androidx.navigation.NavHostControllerimport androidx.navigation.compose.NavHostimport androidx.navigation.compose.composableimport com.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.Rimport com.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.screens.AugmentedRealityScreenimport com.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.screens.MapScreen
@Composablefun AugmentRealityToShowHiddenInfrastructureNavGraph( navController: NavHostController, modifier: Modifier = Modifier, isLocationPermissionGranted: Boolean) { NavHost( navController = navController, startDestination = "route_screen", modifier = modifier ) { composable("route_screen") { MapScreen( sampleName = stringResource(R.string.augment_reality_to_show_hidden_infrastructure_app_name), locationPermissionGranted = isLocationPermissionGranted, onNavigateToARScreen = { navController.navigate("ar_screen") } )
} composable("ar_screen") { AugmentedRealityScreen( sampleName = stringResource(R.string.augment_reality_to_show_hidden_infrastructure_app_name) ) } }}/* Copyright 2025 Esri * * 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.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.screens
import android.content.Contextimport androidx.compose.foundation.backgroundimport androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Boximport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.Rowimport androidx.compose.foundation.layout.Spacerimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.fillMaxWidthimport androidx.compose.foundation.layout.heightimport androidx.compose.foundation.layout.paddingimport androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.filled.Doneimport androidx.compose.material.icons.filled.MoreVertimport androidx.compose.material.icons.filled.Settingsimport androidx.compose.material3.Buttonimport androidx.compose.material3.Cardimport androidx.compose.material3.CircularProgressIndicatorimport androidx.compose.material3.DropdownMenuimport androidx.compose.material3.DropdownMenuItemimport androidx.compose.material3.FloatingActionButtonimport androidx.compose.material3.Iconimport androidx.compose.material3.IconButtonimport androidx.compose.material3.Scaffoldimport androidx.compose.material3.Textimport androidx.compose.material3.TextButtonimport androidx.compose.runtime.Composableimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.rememberimport androidx.compose.runtime.saveable.rememberSaveableimport androidx.compose.runtime.setValueimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.graphics.Colorimport androidx.compose.ui.platform.LocalContextimport androidx.compose.ui.res.painterResourceimport androidx.compose.ui.text.LinkAnnotationimport androidx.compose.ui.text.SpanStyleimport androidx.compose.ui.text.TextLinkStylesimport androidx.compose.ui.text.buildAnnotatedStringimport androidx.compose.ui.text.withLinkimport androidx.compose.ui.unit.dpimport androidx.compose.ui.window.Dialogimport androidx.core.content.editimport androidx.lifecycle.compose.collectAsStateWithLifecycleimport androidx.lifecycle.viewmodel.compose.viewModelimport com.arcgismaps.LoadStatusimport com.arcgismaps.toolkit.ar.WorldScaleSceneViewimport com.arcgismaps.toolkit.ar.WorldScaleSceneViewStatusimport com.arcgismaps.toolkit.ar.WorldScaleTrackingModeimport com.arcgismaps.toolkit.ar.rememberWorldScaleSceneViewStatusimport com.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.Rimport com.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.components.AugmentedRealityViewModelimport com.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.components.SharedRepositoryimport com.esri.arcgismaps.sample.sampleslib.components.SampleTopAppBar
private const val KEY_PREF_ACCEPTED_PRIVACY_INFO = "ACCEPTED_PRIVACY_INFO"
@Composablefun AugmentedRealityScreen(sampleName: String) { val augmentedRealityViewModel: AugmentedRealityViewModel = viewModel()
var displayCalibrationView by remember { mutableStateOf(false) } var initializationStatus by rememberWorldScaleSceneViewStatus()
// Initialize the world scale tracking mode based on whether a google API key is provided val initialWorldScaleTrackingMode = when { SharedRepository.hasNonDefaultAPIKey -> { WorldScaleTrackingMode.Geospatial() } else -> { WorldScaleTrackingMode.World() } }
var trackingMode by remember { mutableStateOf(initialWorldScaleTrackingMode) }
var showDropdownMenu by remember { mutableStateOf(false) } var isPipeShadowVisible by remember { mutableStateOf(true) } var isLeaderVisible by remember { mutableStateOf(true) }
val sharedPreferences = LocalContext.current.getSharedPreferences("", Context.MODE_PRIVATE) var acceptedPrivacyInfo by rememberSaveable { mutableStateOf( sharedPreferences.getBoolean( KEY_PREF_ACCEPTED_PRIVACY_INFO, false ) ) } var showPrivacyInfo by rememberSaveable { mutableStateOf(!acceptedPrivacyInfo) }
Scaffold( topBar = { SampleTopAppBar(title = sampleName, actions = { var actionsExpanded by remember { mutableStateOf(false) } IconButton(onClick = { actionsExpanded = !actionsExpanded }) { Icon(Icons.Default.MoreVert, "More") } DropdownMenu( expanded = actionsExpanded, onDismissRequest = { actionsExpanded = false }) { DropdownMenuItem(text = { Text("World tracking") }, onClick = { trackingMode = WorldScaleTrackingMode.World() actionsExpanded = false }) DropdownMenuItem(text = { Text("Geospatial tracking") }, onClick = { trackingMode = WorldScaleTrackingMode.Geospatial() actionsExpanded = false }) } }) }, content = { if (showPrivacyInfo) { PrivacyInfoDialog( hasCurrentlyAccepted = acceptedPrivacyInfo, onUserResponse = { accepted -> acceptedPrivacyInfo = accepted sharedPreferences.edit { putBoolean(KEY_PREF_ACCEPTED_PRIVACY_INFO, accepted) } showPrivacyInfo = false } ) } else if (!acceptedPrivacyInfo) { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Text(text = "Privacy Info not accepted") Button(onClick = { showPrivacyInfo = true }) { Text(text = "Show Privacy Info") } } } else { Box( modifier = Modifier .fillMaxSize() .padding(it), ) { WorldScaleSceneView( modifier = Modifier.fillMaxSize(), arcGISScene = augmentedRealityViewModel.arcGISScene, graphicsOverlays = listOf( augmentedRealityViewModel.pipeGraphicsOverlay, augmentedRealityViewModel.pipeShadowGraphicsOverlay, augmentedRealityViewModel.leaderGraphicsOverlay ), onCurrentViewpointCameraChanged = { camera -> if (camera.location.x != 0.0 && camera.location.y != 0.0) { augmentedRealityViewModel.onCurrentViewpointCameraChanged(camera.location) } }, worldScaleSceneViewProxy = augmentedRealityViewModel.worldScaleSceneViewProxy, worldScaleTrackingMode = trackingMode, onInitializationStatusChanged = { status -> initializationStatus = status }) { Box(modifier = Modifier.fillMaxSize()) { if (trackingMode is WorldScaleTrackingMode.World) { if (displayCalibrationView) { CalibrationView( onDismiss = { displayCalibrationView = false }, modifier = Modifier.align(Alignment.BottomCenter), ) } } } } WorldScaleSceneViewStatusHandler( initializationStatus = initializationStatus, trackingMode = trackingMode, arcGISSceneLoadStatus = augmentedRealityViewModel.arcGISScene.loadStatus.collectAsStateWithLifecycle().value ) if (trackingMode is WorldScaleTrackingMode.Geospatial) { Box( modifier = Modifier .fillMaxWidth() .background(Color.Gray.copy(alpha = 0.5f)) .padding(8.dp), contentAlignment = Alignment.Center ) { Text( text = if (augmentedRealityViewModel.isVpsAvailable) { "VPS available" } else { "VPS unavailable" }, color = Color.White ) } } } } }, floatingActionButton = { if (!displayCalibrationView) { FloatingActionButtonOptions( showDropdownMenu = showDropdownMenu, isPipeShadowVisible = isPipeShadowVisible, isLeaderVisible = isLeaderVisible, trackingMode = trackingMode, onToggleDropdownMenu = { showDropdownMenu = !showDropdownMenu }, onTogglePipeShadowVisibility = { isPipeShadowVisible = !isPipeShadowVisible augmentedRealityViewModel.pipeShadowGraphicsOverlay.isVisible = isPipeShadowVisible }, onToggleLeaderVisibility = { isLeaderVisible = !isLeaderVisible augmentedRealityViewModel.leaderGraphicsOverlay.isVisible = isLeaderVisible }, onShowCalibrationView = { displayCalibrationView = true } ) } } )}
@Composableprivate fun PrivacyInfoDialog( hasCurrentlyAccepted: Boolean, onUserResponse: (accepted: Boolean) -> Unit) { Dialog(onDismissRequest = { onUserResponse(hasCurrentlyAccepted) }) { Card { Column( modifier = Modifier.padding(16.dp) ) { LegalTextArCore() Spacer(Modifier.height(16.dp)) LegalTextGeospatial() Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { TextButton(onClick = { onUserResponse(false) }) { Text(text = "Decline") }
TextButton(onClick = { onUserResponse(true) }) { Text(text = "Accept") } } } } }}
@Composableprivate fun WorldScaleSceneViewStatusHandler( initializationStatus: WorldScaleSceneViewStatus, trackingMode: WorldScaleTrackingMode, arcGISSceneLoadStatus: LoadStatus) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { when (initializationStatus) { is WorldScaleSceneViewStatus.Initializing -> { TextWithScrim( if (trackingMode is WorldScaleTrackingMode.Geospatial) { "Initializing AR in geospatial mode..." } else { "Initializing AR in world mode..." } ) }
is WorldScaleSceneViewStatus.Initialized -> { when (arcGISSceneLoadStatus) { is LoadStatus.Loading, LoadStatus.NotLoaded -> { CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) }
is LoadStatus.FailedToLoad -> { TextWithScrim("Failed to load world scale AR scene: " + arcGISSceneLoadStatus.error) }
else -> {} } }
is WorldScaleSceneViewStatus.FailedToInitialize -> { TextWithScrim( text = "World scale AR failed to initialize: " + (initializationStatus.error.message ?: initializationStatus.error) ) } } }}
@Composableprivate fun FloatingActionButtonOptions( showDropdownMenu: Boolean, isPipeShadowVisible: Boolean, isLeaderVisible: Boolean, trackingMode: WorldScaleTrackingMode, onToggleDropdownMenu: () -> Unit, onTogglePipeShadowVisibility: () -> Unit, onToggleLeaderVisibility: () -> Unit, onShowCalibrationView: () -> Unit) { Column { FloatingActionButton( modifier = Modifier.padding(bottom = 18.dp), onClick = onToggleDropdownMenu ) { Icon(Icons.Default.Settings, contentDescription = "Toggle visibility of shadows and leaders") } DropdownMenu( expanded = showDropdownMenu, onDismissRequest = onToggleDropdownMenu ) { DropdownMenuItem( text = { Text("Shadows") }, leadingIcon = { if (isPipeShadowVisible) { Icon(Icons.Default.Done, contentDescription = "Visible") } }, onClick = onTogglePipeShadowVisibility ) DropdownMenuItem( text = { Text("Leaders") }, leadingIcon = { if (isLeaderVisible) { Icon(Icons.Default.Done, contentDescription = "Visible") } }, onClick = onToggleLeaderVisibility ) } if (trackingMode is WorldScaleTrackingMode.World) { FloatingActionButton( modifier = Modifier .align(Alignment.End) .padding(bottom = 16.dp), onClick = onShowCalibrationView ) { Icon( painter = painterResource(R.drawable.baseline_straighten_24), contentDescription = "Show calibration view" ) } } }}
/** * Displays the provided [text] on top of a half-transparent gray background. */@Composableprivate fun TextWithScrim(text: String) { Column( modifier = Modifier .background(Color.Gray.copy(alpha = 0.5f)) .fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Text(text = text) }}
/** * Displays the required privacy information for use of ARCore */@Composableprivate fun LegalTextArCore() { val textLinkStyle = TextLinkStyles(style = SpanStyle(color = Color.Blue)) Text(text = buildAnnotatedString { append("This application runs on ") withLink( LinkAnnotation.Url( "https://play.google.com/store/apps/details?id=com.google.ar.core", textLinkStyle ) ) { append("Google Play Services for AR") } append(" (ARCore), which is provided by Google and governed by the ") withLink( LinkAnnotation.Url( "https://policies.google.com/privacy", textLinkStyle ) ) { append("Google Privacy Policy.") } })}
/** * Displays the required privacy information for use of the Geospatial API */@Composableprivate fun LegalTextGeospatial() { Text(text = buildAnnotatedString { append("To power this session, Google will process sensor data (e.g., camera and location).") appendLine() withLink( LinkAnnotation.Url( "https://support.google.com/ar?p=how-google-play-services-for-ar-handles-your-data", TextLinkStyles(style = SpanStyle(color = Color.Blue)) ) ) { append("Learn more") } })}/* Copyright 2025 Esri * * 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.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.screens
import androidx.compose.foundation.backgroundimport androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Boximport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.Rowimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.paddingimport androidx.compose.foundation.shape.RoundedCornerShapeimport androidx.compose.material3.AlertDialogimport androidx.compose.material3.Buttonimport androidx.compose.material3.Iconimport androidx.compose.material3.Scaffoldimport androidx.compose.material3.Sliderimport androidx.compose.material3.Textimport androidx.compose.material3.TextButtonimport androidx.compose.runtime.Composableimport androidx.compose.runtime.LaunchedEffectimport androidx.compose.runtime.collectAsStateimport androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableFloatStateOfimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.rememberimport androidx.compose.runtime.setValueimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.draw.clipimport androidx.compose.ui.graphics.Colorimport androidx.compose.ui.res.painterResourceimport androidx.compose.ui.unit.dpimport androidx.lifecycle.viewmodel.compose.viewModelimport com.arcgismaps.location.LocationDisplayAutoPanModeimport com.arcgismaps.toolkit.geoviewcompose.MapViewimport com.arcgismaps.toolkit.geoviewcompose.rememberLocationDisplayimport com.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.Rimport com.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.components.MapViewModelimport com.esri.arcgismaps.sample.augmentrealitytoshowhiddeninfrastructure.components.SharedRepositoryimport com.esri.arcgismaps.sample.sampleslib.components.SampleTopAppBarimport kotlin.math.roundToInt
/** * Main screen layout for the sample app */@Composablefun MapScreen(sampleName: String, locationPermissionGranted: Boolean, onNavigateToARScreen: () -> Unit) {
val mapViewModel: MapViewModel = viewModel()
// Initialize the location display with auto pan mode set to recenter val locationDisplay = rememberLocationDisplay().apply { setAutoPanMode(LocationDisplayAutoPanMode.Recenter) } var isViewmodelInitialized by remember { mutableStateOf(false) } LaunchedEffect(isViewmodelInitialized) { if (!isViewmodelInitialized && locationPermissionGranted) { mapViewModel.initialize(locationDisplay) isViewmodelInitialized = true } }
val isGeometryBeingEdited by remember { mutableStateOf(mapViewModel.isGeometryBeingEdited) } val canUndo by mapViewModel.geometryEditor.canUndo.collectAsState() val showElevationDialog = mapViewModel.showElevationDialog val pipeInfoList = SharedRepository.pipeInfoList
Scaffold( topBar = { SampleTopAppBar(title = sampleName) }, content = { Box( modifier = Modifier .fillMaxSize() .padding(it), ) { MapView( modifier = Modifier.fillMaxSize(), arcGISMap = mapViewModel.arcGISMap, locationDisplay = locationDisplay, geometryEditor = mapViewModel.geometryEditor, graphicsOverlays = listOf(mapViewModel.graphicsOverlay), onSingleTapConfirmed = { if (!mapViewModel.geometryEditor.isStarted.value) { mapViewModel.startPolylineEditing() } } ) if (mapViewModel.statusText != "") { Box( modifier = Modifier .align(Alignment.TopCenter) .padding(8.dp) .clip(RoundedCornerShape(10.dp)) .background(Color.Black.copy(alpha = 0.8f)) .padding(16.dp) ) { Text( text = mapViewModel.statusText, color = Color.White ) } } Box( modifier = Modifier .fillMaxSize() .padding(bottom = 24.dp), contentAlignment = Alignment.BottomCenter ) { Column( horizontalAlignment = Alignment.CenterHorizontally ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { // Allow undoing of last vertex while editing geometry if (canUndo) { Button (onClick = { mapViewModel.geometryEditor.undo() }) { Icon( painter = painterResource(R.drawable.baseline_undo_24), contentDescription = "Close button" ) } } // Complete polyline once enough vertices have been added if (isGeometryBeingEdited.value) { Button(onClick = { mapViewModel.completePolyline() }) { Text("Complete polyline") } } // Clear all polyline graphics and start over if (mapViewModel.graphicsOverlay.graphics.isNotEmpty()) { Button(onClick = { mapViewModel.graphicsOverlay.graphics.clear() mapViewModel.startPolylineEditing() SharedRepository.pipeInfoList.clear() }) { Text("Clear polylines") } } } if (pipeInfoList.isNotEmpty()) { Button( onClick = onNavigateToARScreen, modifier = Modifier.padding(top = 8.dp) ) { Text("Show hidden infrastructure in AR") } } } } } } )
if (showElevationDialog) { var elevationInput by remember { mutableFloatStateOf(mapViewModel.elevationInput) } AlertDialog( onDismissRequest = { }, title = { Text("Elevation offset") }, text = { Column { Text("Enter a pipe elevation offset in meters between -10 and 10.") Slider( value = elevationInput, onValueChange = { elevationInput = it }, valueRange = -10f..10f, steps = 19 ) Text(modifier = Modifier.align(Alignment.End), text = "${elevationInput.roundToInt()} m") } }, confirmButton = { TextButton(onClick = { mapViewModel.onElevationConfirmed(elevationInput) }) { Text("Confirm") } }, ) }}