Use augmented reality (AR) to pin a scene to a table or desk for easy exploration.

Use case
Tabletop scenes allow you to use your device to interact with scenes as if they are 3D-printed model models sitting on your desk. You could use this to virtually explore a proposed development without needing to create a physical model.
How to use the sample
Once the scene loads, you’ll see a feed from the camera. Wait for ArCore to highlight a plane and tap on one to place the scene. With the scene placed, you can move the camera around the scene to explore.
NOTE: Google Play Services for AR must be installed to run this app and the application code will prompt the user to install it if it is not already installed.
How it works
- Create a
TableTopSceneViewwitharcGISSceneAnchor,translationFactor,clippingDistance, and anarcGISScene. - For this sample, the
arcGISSceneAnchorsets coordinates at the center of the buildings in theArcGSISceneLayerto give the impression that the scene is centered on the location the user tapped. - Set the
translationFactorsuch that the user can view the entire scene by moving the device around it. The translation factor defines how far the virtual camera moves through the scene when the physical camera moves. -A good formula for determining translation factor to use in a tabletop map experience is translationFactor = sceneWidth / tableTopWidth. The scene width is the width/length of the scene content you wish to display in meters. The tabletop width is the length of the area on the physical surface that you want the scene content to fill. For simplicity, the sample assumes a scene width of 800 meters. - Set the
clippingDistanceto clip the scene to the area you want to show.
Relevant API
- ArcGISScene
- TableTopSceneView
About the data
This sample uses the Philadelphia Mobile Scene Package. It was chosen because it is a compact scene ideal for tabletop use. Note that tabletop mapping experiences work best with small, focused scenes. The small, focused area with basemap tiles defines a clear boundary for the scene.
Additional information
This sample requires a device that is compatible with ARCore on Android.
This sample uses the TableTopSceneView toolkit component. For information about setting up the toolkit, as well as code for the underlying component, visit the toolkit docs.
Tags
augmented reality, drop, mixed reality, model, pin, place, table-top, tabletop
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.augmentrealitytoshowtabletopscene
import android.content.Intentimport android.os.Bundleimport com.esri.arcgismaps.sample.sampleslib.DownloaderActivity
class DownloadActivity : DownloaderActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) downloadAndStartSample( Intent(this, MainActivity::class.java), // get the app name of the sample getString(R.string.augment_reality_to_show_tabletop_scene_app_name), listOf( // ArcGIS Portal item containing the Philadelphia mobile scene package. "https://www.arcgis.com/home/item.html?id=7dd2f97bb007466ea939160d0de96a9d" ) ) }}/* 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.augmentrealitytoshowtabletopscene
import android.os.Bundleimport androidx.activity.ComponentActivityimport androidx.activity.compose.setContentimport androidx.activity.enableEdgeToEdgeimport androidx.activity.viewModelsimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Surfaceimport androidx.compose.runtime.Composableimport com.arcgismaps.ApiKeyimport com.arcgismaps.ArcGISEnvironmentimport com.esri.arcgismaps.sample.augmentrealitytoshowtabletopscene.components.AugmentRealityToShowTabletopSceneViewModelimport com.esri.arcgismaps.sample.augmentrealitytoshowtabletopscene.screens.DisplaySceneInTabletopARScreenimport com.esri.arcgismaps.sample.sampleslib.theme.SampleAppThemeimport com.google.ar.core.ArCoreApkimport com.google.ar.core.exceptions.UnavailableDeviceNotCompatibleExceptionimport com.google.ar.core.exceptions.UnavailableUserDeclinedInstallationException
class MainActivity : ComponentActivity() {
private val sceneViewModel: AugmentRealityToShowTabletopSceneViewModel by viewModels()
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)
enableEdgeToEdge() setContent { SampleAppTheme { DisplaySceneInTabletopARApp() } } }
override fun onResume() { super.onResume() checkARCoreAvailability() }
/** * Checks if ARCore is supported and handles install/update flow if required. */ private fun checkARCoreAvailability() { val context = this try { ArCoreApk.getInstance().checkAvailabilityAsync(context) { availability -> when (availability) { ArCoreApk.Availability.UNSUPPORTED_DEVICE_NOT_CAPABLE -> { sceneViewModel.messageDialogVM.showMessageDialog( title = "AR Not Supported", description = "This device does not support AR." ) }
ArCoreApk.Availability.SUPPORTED_NOT_INSTALLED, ArCoreApk.Availability.SUPPORTED_APK_TOO_OLD -> { try { val installStatus = ArCoreApk.getInstance().requestInstall(this, true) if (installStatus == ArCoreApk.InstallStatus.INSTALL_REQUESTED) { // Installation requested, wait for next resume } } catch (e: UnavailableUserDeclinedInstallationException) { sceneViewModel.messageDialogVM.showMessageDialog( title = "AR Installation Declined", description = "User declined to install ARCore." ) } catch (e: UnavailableDeviceNotCompatibleException) { sceneViewModel.messageDialogVM.showMessageDialog( title = "AR Not Compatible", description = "This device is not compatible with ARCore." ) } catch (e: Exception) { sceneViewModel.messageDialogVM.showMessageDialog( title = "Installation Error", description = e.localizedMessage ?: "An unknown error occurred." ) } }
ArCoreApk.Availability.SUPPORTED_INSTALLED -> { // ARCore is ready, proceed to use AR features. }
ArCoreApk.Availability.UNKNOWN_CHECKING, ArCoreApk.Availability.UNKNOWN_ERROR, ArCoreApk.Availability.UNKNOWN_TIMED_OUT -> { sceneViewModel.messageDialogVM.showMessageDialog( title = "AR Check Error", description = "Unable to determine ARCore availability. Please try again." ) } } } } catch (e: Exception) { sceneViewModel.messageDialogVM.showMessageDialog( "Error checking AR availability", e.localizedMessage ?: "An error occurred." ) } }
@Composable private fun DisplaySceneInTabletopARApp() { Surface(color = MaterialTheme.colorScheme.background) { DisplaySceneInTabletopARScreen( sampleName = getString(R.string.augment_reality_to_show_tabletop_scene_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.augmentrealitytoshowtabletopscene.screens
import androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.fillMaxSizeimport androidx.compose.foundation.layout.paddingimport androidx.compose.material3.CircularProgressIndicatorimport androidx.compose.material3.Scaffoldimport androidx.compose.material3.Textimport androidx.compose.runtime.Composableimport androidx.compose.runtime.getValueimport androidx.compose.runtime.setValueimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.res.stringResourceimport androidx.lifecycle.compose.collectAsStateWithLifecycleimport androidx.lifecycle.viewmodel.compose.viewModelimport com.arcgismaps.LoadStatusimport com.arcgismaps.geometry.Pointimport com.arcgismaps.geometry.SpatialReferenceimport com.arcgismaps.toolkit.ar.TableTopSceneViewimport com.arcgismaps.toolkit.ar.TableTopSceneViewStatusimport com.arcgismaps.toolkit.ar.rememberTableTopSceneViewStatusimport com.esri.arcgismaps.sample.augmentrealitytoshowtabletopscene.Rimport com.esri.arcgismaps.sample.augmentrealitytoshowtabletopscene.components.AugmentRealityToShowTabletopSceneViewModelimport com.esri.arcgismaps.sample.sampleslib.components.SampleTopAppBar
/** * Main screen layout for the sample app */@Composablefun DisplaySceneInTabletopARScreen(sampleName: String) { val sceneViewModel: AugmentRealityToShowTabletopSceneViewModel = viewModel() var initializationStatus: TableTopSceneViewStatus by rememberTableTopSceneViewStatus() Scaffold(topBar = { SampleTopAppBar(title = sampleName) }, content = { paddingValues -> sceneViewModel.scene?.let { // Scene view that provides an augmented reality table top experience TableTopSceneView( arcGISScene = it, arcGISSceneAnchor = Point( x = -75.16996728256345, y = 39.95787000283599, spatialReference = SpatialReference.wgs84() ), translationFactor = 800.0, modifier = Modifier .fillMaxSize() .padding(paddingValues), clippingDistance = 800.0, onInitializationStatusChanged = { statusChanged -> initializationStatus = statusChanged }, ) } })
// Show an overlay with instructions or progress indicator based on the initialization status when (val status = initializationStatus) { is TableTopSceneViewStatus.Initializing -> ShowInstruction(text = stringResource(R.string.initializing_overlay)) is TableTopSceneViewStatus.DetectingPlanes -> ShowInstruction(text = stringResource(R.string.detect_planes_overlay)) is TableTopSceneViewStatus.Initialized -> { sceneViewModel.scene?.let { when (val sceneLoadStatus = it.loadStatus.collectAsStateWithLifecycle().value) { // Tell the user to tap the screen if the scene has not started loading is LoadStatus.NotLoaded -> ShowInstruction(text = stringResource(R.string.tap_scene_overlay)) // The scene may take a while to load, so show a progress indicator is LoadStatus.Loading -> { Column( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { CircularProgressIndicator() } } // Show an error message if the scene failed to load is LoadStatus.FailedToLoad -> ShowInstruction( text = stringResource( R.string.failed_to_load_scene, sceneLoadStatus.error ) ) is LoadStatus.Loaded -> {} // Do nothing } } } is TableTopSceneViewStatus.FailedToInitialize -> { ShowInstruction( text = stringResource(R.string.failed_to_initialize_overlay, status.error.message ?: status.error) ) } }}
/** * Displays the provided [text] in the middle of the screen. */@Composablefun ShowInstruction(text: String) { Column( modifier = Modifier .fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally ) { Text(text = text) }}/* 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.augmentrealitytoshowtabletopscene.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.mapping.ArcGISSceneimport com.arcgismaps.mapping.MobileScenePackageimport com.arcgismaps.mapping.NavigationConstraintimport com.esri.arcgismaps.sample.augmentrealitytoshowtabletopscene.Rimport com.esri.arcgismaps.sample.sampleslib.components.MessageDialogViewModelimport kotlinx.coroutines.launchimport java.io.File
class AugmentRealityToShowTabletopSceneViewModel(application: Application) : AndroidViewModel(application) {
private val provisionPath: String by lazy { application.getExternalFilesDir(null)?.path.toString() + File.separator + application.getString(R.string.augment_reality_to_show_tabletop_scene_app_name) }
// Get the folder path containing the mobile scene package (.mspk) file private val filePath = "$provisionPath/philadelphia.mspk"
// Create a mobile scene package private val scenePackage = MobileScenePackage(filePath)
// Create a mutable state variable to hold the scene. Later loaded from the scene package var scene: ArcGISScene? by mutableStateOf(null)
// Create a message dialog view model for handling error messages val messageDialogVM = MessageDialogViewModel()
init { viewModelScope.launch { // Load the mobile scene package scenePackage.load().onSuccess { // Get the first scene from the package scene = scenePackage.scenes.first().apply { // Set the navigation constraint to allow you to look at the scene from below baseSurface.navigationConstraint = NavigationConstraint.None } }.onFailure { messageDialogVM.showMessageDialog( "Error loading mobile scene package", it.message.toString() ) } } }}