diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 4015a3c5fff..7e586220fe5 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -391,6 +391,17 @@ device_metrics_log
device_metrics_numeric_value
device_metrics_percent_value
device_metrics_voltage_value
+device_model_capture
+device_model_capture_failed
+device_model_capture_saved
+device_model_load_failed
+device_model_rest_face_down
+device_model_rest_face_up
+device_model_rest_left_side
+device_model_rest_right_side
+device_model_rest_upright
+device_model_view
+device_model_view_in_space
device_sleeping
device_storage_ui_title
device_theme_language
diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index 0e4ed76cf46..f6f1bce6bed 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -270,6 +270,10 @@ dependencies {
googleImplementation(libs.location.services)
googleImplementation(libs.play.services.maps)
+ // ARCore for the device model viewer; proprietary, so google only. Same exclusions as feature/node's sceneview.
+ googleImplementation("io.github.sceneview:arsceneview:${libs.versions.sceneview.get()}") {
+ exclude(group = "org.jetbrains.kotlin", module = "kotlin-android-extensions-runtime")
+ }
googleImplementation(libs.maps.compose)
googleImplementation(libs.maps.compose.utils)
googleImplementation(libs.maps.compose.widgets)
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/node/DeviceModelArViewer.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/node/DeviceModelArViewer.kt
new file mode 100644
index 00000000000..f89456e2a5f
--- /dev/null
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/node/DeviceModelArViewer.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.node
+
+import org.meshtastic.core.ui.util.DeviceModelArViewer
+
+/** ARCore is proprietary, so the fdroid flavor has no AR viewer and the action never shows. */
+val deviceModelArViewer: DeviceModelArViewer? = null
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/node/ArCapture.kt b/androidApp/src/google/kotlin/org/meshtastic/app/node/ArCapture.kt
new file mode 100644
index 00000000000..a75aa8da276
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/node/ArCapture.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.node
+
+import android.content.ContentValues
+import android.content.Context
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.provider.MediaStore
+import android.view.PixelCopy
+import android.view.SurfaceView
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.RequiresApi
+import androidx.core.view.children
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import kotlin.coroutines.resume
+
+private const val PICTURES_RELATIVE_PATH = "Pictures/Meshtastic"
+private const val JPEG_QUALITY = 92
+
+/**
+ * Copies the AR view's GL surface into Pictures/Meshtastic: the camera feed and the model, none of the Compose controls
+ * drawn over it. The scene renders every camera frame, so the surface is never stale. Null when nothing was saved.
+ */
+@RequiresApi(Build.VERSION_CODES.Q)
+internal suspend fun captureArScene(root: View, title: String, io: CoroutineDispatcher): Uri? {
+ val surface = root.findSurfaceView()?.takeIf { it.width > 0 && it.height > 0 } ?: return null
+ val bitmap = Bitmap.createBitmap(surface.width, surface.height, Bitmap.Config.ARGB_8888)
+ val copied = suspendCancellableCoroutine { continuation ->
+ PixelCopy.request(
+ surface,
+ bitmap,
+ { result -> continuation.resume(result == PixelCopy.SUCCESS) },
+ Handler(Looper.getMainLooper()),
+ )
+ }
+ return if (copied) withContext(io) { saveToPictures(root.context, bitmap, title) } else null
+}
+
+private fun View.findSurfaceView(): SurfaceView? = when (this) {
+ is SurfaceView -> this
+ is ViewGroup -> children.firstNotNullOfOrNull { it.findSurfaceView() }
+ else -> null
+}
+
+@RequiresApi(Build.VERSION_CODES.Q)
+private fun saveToPictures(context: Context, bitmap: Bitmap, title: String): Uri? {
+ val resolver = context.contentResolver
+ val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
+ val name = "${title.replace(Regex("[^A-Za-z0-9]+"), "-").trim('-')}-$stamp.jpg"
+ val pending =
+ ContentValues().apply {
+ put(MediaStore.Images.Media.DISPLAY_NAME, name)
+ put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
+ put(MediaStore.Images.Media.RELATIVE_PATH, PICTURES_RELATIVE_PATH)
+ put(MediaStore.Images.Media.IS_PENDING, 1)
+ }
+ val uri =
+ runCatching {
+ resolver.insert(MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), pending)
+ }
+ .getOrNull() ?: return null
+ val written = runCatching {
+ resolver.openOutputStream(uri)?.use { bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, it) } == true &&
+ resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null) > 0
+ }
+ .getOrDefault(false)
+ // A row left pending would sit hidden in the gallery, so a failed write removes it.
+ if (!written) runCatching { resolver.delete(uri, null, null) }
+ return uri.takeIf { written }
+}
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/node/DeviceModelArViewer.kt b/androidApp/src/google/kotlin/org/meshtastic/app/node/DeviceModelArViewer.kt
new file mode 100644
index 00000000000..a9c5e238b99
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/node/DeviceModelArViewer.kt
@@ -0,0 +1,422 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.node
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.pm.ActivityInfo
+import android.content.pm.PackageManager
+import android.os.Build
+import android.widget.Toast
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
+import androidx.compose.foundation.horizontalScroll
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import com.google.ar.core.Anchor
+import com.google.ar.core.ArCoreApk
+import com.google.ar.core.Config
+import com.google.ar.core.Frame
+import com.google.ar.core.Plane
+import com.google.ar.core.Session
+import com.google.ar.core.TrackingFailureReason
+import com.google.ar.core.TrackingState
+import io.github.sceneview.ar.ARCoachingOverlay
+import io.github.sceneview.ar.ARSceneView
+import io.github.sceneview.ar.PlacementReticleVisual
+import io.github.sceneview.ar.ReticlePhase
+import io.github.sceneview.ar.arcore.rememberDetectedPlanes
+import io.github.sceneview.ar.placementHit
+import io.github.sceneview.ar.rememberArGuidanceState
+import io.github.sceneview.ar.reticlePhaseFor
+import io.github.sceneview.ar.scene.SceneUnderstanding
+import io.github.sceneview.math.Position
+import io.github.sceneview.math.Rotation
+import io.github.sceneview.model.model
+import io.github.sceneview.rememberEngine
+import io.github.sceneview.rememberMaterialLoader
+import io.github.sceneview.rememberModelLoader
+import io.github.sceneview.rememberOnGestureListener
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.koinInject
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.close
+import org.meshtastic.core.resources.device_model_capture
+import org.meshtastic.core.resources.device_model_capture_failed
+import org.meshtastic.core.resources.device_model_capture_saved
+import org.meshtastic.core.resources.device_model_rest_face_down
+import org.meshtastic.core.resources.device_model_rest_face_up
+import org.meshtastic.core.resources.device_model_rest_left_side
+import org.meshtastic.core.resources.device_model_rest_right_side
+import org.meshtastic.core.resources.device_model_rest_upright
+import org.meshtastic.core.resources.info
+import org.meshtastic.core.ui.icon.Close
+import org.meshtastic.core.ui.icon.Info
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.PhotoCamera
+import org.meshtastic.core.ui.util.DeviceModelArViewer
+import org.meshtastic.feature.node.model.GlbBounds
+import org.meshtastic.feature.node.model.RestPose
+import java.nio.ByteBuffer
+
+private const val SCRIM_ALPHA = 0.4f
+private const val YAW_DEGREES_PER_PX = 0.4f
+
+val deviceModelArViewer: DeviceModelArViewer? = GoogleDeviceModelArViewer
+
+/**
+ * The google flavor's AR viewer. A phone ARCore is still checking, or that only lacks the ARCore app, counts as
+ * available: the viewer's own overlay offers the install.
+ */
+private object GoogleDeviceModelArViewer : DeviceModelArViewer {
+ @Composable
+ override fun isAvailable(): Boolean {
+ val context = LocalContext.current
+ return remember(context) { !ArCoreApk.getInstance().checkAvailability(context).isUnsupported }
+ }
+
+ @Composable
+ override fun Content(glb: ByteArray, title: String, notice: String?, onDismiss: () -> Unit) =
+ DeviceModelAr(glb, title, notice, onDismiss)
+}
+
+/**
+ * Tap a surface to stand the device on it at real size: the models are in meters and ARCore works in meters, so nothing
+ * is scaled. A later tap moves it rather than adding another, and a twist turns it.
+ */
+@Composable
+private fun DeviceModelAr(glb: ByteArray, title: String, notice: String?, onDismiss: () -> Unit) {
+ BackHandler(onBack = onDismiss)
+ LockOrientationWhileShown()
+ // Asked here rather than left to the AR view, whose own request never appears from inside a dialog.
+ val context = LocalContext.current
+ var cameraGranted by remember {
+ mutableStateOf(
+ ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED,
+ )
+ }
+ val requestCamera =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ if (granted) cameraGranted = true else onDismiss()
+ }
+ var asked by rememberSaveable { mutableStateOf(false) }
+ SideEffect {
+ if (!cameraGranted && !asked) {
+ asked = true
+ requestCamera.launch(Manifest.permission.CAMERA)
+ }
+ }
+ Box(modifier = Modifier.fillMaxSize()) {
+ if (cameraGranted) DeviceModelArScene(glb, onCapture = rememberArCapture(title))
+ ArHeader(title, notice, onDismiss)
+ }
+}
+
+/**
+ * ARSceneView rather than SceneView's PlacementScene, which as of 4.56.0 does not pass depth occlusion through. This
+ * keeps its tap-to-place, reticle, coaching and single-shadow-receiver behavior, built from the same public pieces.
+ */
+@Composable
+@Suppress("LongMethod")
+private fun DeviceModelArScene(glb: ByteArray, onCapture: (() -> Unit)?) {
+ val engine = rememberEngine()
+ val modelLoader = rememberModelLoader(engine)
+ val materialLoader = rememberMaterialLoader(engine)
+ val haptic = LocalHapticFeedback.current
+ var anchor by remember { mutableStateOf(null) }
+ // Saved across a rotation; the anchor cannot be, since the activity's new ARCore session starts unplaced.
+ var yawDegrees by rememberSaveable { mutableFloatStateOf(0f) }
+ var pose by rememberSaveable { mutableStateOf(RestPose.Upright) }
+ val bounds = remember(glb) { GlbBounds.parse(glb) }
+ var latestFrame by remember { mutableStateOf(null) }
+ var session by remember { mutableStateOf(null) }
+ var viewport by remember { mutableStateOf(IntSize.Zero) }
+ var reticlePhase by remember { mutableStateOf(ReticlePhase.SEARCHING) }
+ var cameraReady by remember { mutableStateOf(false) }
+ var isTracking by remember { mutableStateOf(false) }
+ var surfaceFound by remember { mutableStateOf(false) }
+ var trackingFailure by remember { mutableStateOf(null) }
+ val placed = anchor != null
+ Box(modifier = Modifier.fillMaxSize()) {
+ ARSceneView(
+ modifier = Modifier.fillMaxSize().onSizeChanged { viewport = it },
+ engine = engine,
+ modelLoader = modelLoader,
+ materialLoader = materialLoader,
+ instantPlacementMode = Config.InstantPlacementMode.LOCAL_Y_UP,
+ sessionConfiguration = { arSession, config ->
+ // Real objects in front of the model hide it; phones without depth just skip it.
+ if (arSession.isDepthModeSupported(Config.DepthMode.AUTOMATIC)) {
+ config.depthMode = Config.DepthMode.AUTOMATIC
+ }
+ },
+ // The grid shows surfaces until the device stands on one, then gets out of the way.
+ sceneUnderstanding = SceneUnderstanding(occlusion = true, planeVisualization = !placed),
+ onSessionUpdated = { updated, frame ->
+ latestFrame = frame
+ if (session !== updated) session = updated
+ cameraReady = true
+ isTracking = frame.camera.trackingState == TrackingState.TRACKING
+ if (!surfaceFound) {
+ surfaceFound =
+ updated.getAllTrackables(Plane::class.java).any { it.trackingState == TrackingState.TRACKING }
+ }
+ },
+ onTrackingFailureChanged = { trackingFailure = it },
+ onGestureListener =
+ rememberOnGestureListener(
+ onSingleTapConfirmed = { event, node ->
+ // A tap on the model is a gesture on it, not a new placement.
+ val frame = latestFrame
+ if (node == null && frame != null) {
+ placementHit(frame, event.x, event.y, instantPlacement = true)?.let { hit ->
+ anchor?.detach()
+ anchor = hit.createAnchor()
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ }
+ }
+ },
+ // A one-finger drag anywhere turns it: a twist on the node itself needs both fingers on a few
+ // centimeters of device. GestureDetector's distance is previous minus current, so negate it.
+ onScroll = { _, _, _, distance -> yawDegrees -= distance.x * YAW_DEGREES_PER_PX },
+ ),
+ ) {
+ if (!placed && isTracking && viewport != IntSize.Zero) {
+ PlacementReticle(
+ xPx = viewport.width / 2f,
+ yPx = viewport.height / 2f,
+ onHitResultChanged = { hit -> reticlePhase = reticlePhaseFor(hasHit = hit != null) },
+ ) {
+ PlacementReticleVisual(materialLoader = materialLoader, phase = reticlePhase)
+ }
+ }
+ // The plane grid carries its own shadow receiver while it shows; one per plane once it is hidden.
+ if (placed) {
+ val planes by rememberDetectedPlanes(session)
+ planes.forEach { plane -> key(plane) { ShadowReceiverPlane(plane = plane) } }
+ }
+ anchor?.let { placedAnchor ->
+ key(placedAnchor) {
+ val instance = remember(modelLoader) { modelLoader.createModelInstance(ByteBuffer.wrap(glb)) }
+ DisposableEffect(instance) { onDispose { modelLoader.destroyModel(instance.model) } }
+ AnchorNode(anchor = placedAnchor) {
+ // Moving is a new tap and scaling would break real size, so the drag's yaw and the rest pose
+ // are the only edits. The yaw wraps the pose so it always turns about the surface's up.
+ val offset = bounds?.let(pose::offset) ?: floatArrayOf(0f, 0f, 0f)
+ Node(rotation = Rotation(y = yawDegrees)) {
+ ModelNode(
+ modelInstance = instance,
+ position = Position(offset[0], offset[1], offset[2]),
+ rotation = Rotation(x = pose.pitchDegrees, z = pose.rollDegrees),
+ )
+ }
+ }
+ }
+ }
+ }
+ val guidance =
+ rememberArGuidanceState(
+ cameraReady = cameraReady,
+ isTracking = isTracking,
+ surfaceFound = surfaceFound,
+ trackingFailureReason = trackingFailure,
+ )
+ ARCoachingOverlay(guidance)
+ Column(
+ modifier = Modifier.align(Alignment.BottomCenter).safeDrawingPadding().padding(bottom = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ if (placed && bounds != null) RestPoseChips(selected = pose, onSelect = { pose = it })
+ onCapture?.let { ShutterButton(it) }
+ }
+ }
+}
+
+/** Which face of the device lies on the surface. */
+@Composable
+private fun RestPoseChips(selected: RestPose, onSelect: (RestPose) -> Unit, modifier: Modifier = Modifier) {
+ val colors =
+ FilterChipDefaults.filterChipColors(
+ labelColor = Color.White,
+ selectedContainerColor = Color.White.copy(alpha = SCRIM_ALPHA),
+ selectedLabelColor = Color.White,
+ )
+ Row(
+ modifier =
+ modifier
+ .clip(RoundedCornerShape(24.dp))
+ .background(Color.Black.copy(alpha = SCRIM_ALPHA))
+ .horizontalScroll(rememberScrollState())
+ .padding(horizontal = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ RestPose.entries.forEach { pose ->
+ FilterChip(
+ selected = pose == selected,
+ onClick = { onSelect(pose) },
+ label = { Text(stringResource(pose.label)) },
+ colors = colors,
+ )
+ }
+ }
+}
+
+private val RestPose.label: StringResource
+ get() =
+ when (this) {
+ RestPose.Upright -> Res.string.device_model_rest_upright
+ RestPose.FaceUp -> Res.string.device_model_rest_face_up
+ RestPose.FaceDown -> Res.string.device_model_rest_face_down
+ RestPose.LeftSide -> Res.string.device_model_rest_left_side
+ RestPose.RightSide -> Res.string.device_model_rest_right_side
+ }
+
+/** Title bar over the camera; the model's notice opens under it, clear of the controls at the bottom. */
+@Composable
+private fun ArHeader(title: String, notice: String?, onDismiss: () -> Unit) {
+ var noticeShown by rememberSaveable { mutableStateOf(false) }
+ Column(
+ modifier =
+ Modifier.fillMaxWidth().background(Color.Black.copy(alpha = SCRIM_ALPHA)).safeDrawingPadding().padding(4.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = onDismiss) {
+ Icon(MeshtasticIcons.Close, contentDescription = stringResource(Res.string.close), tint = Color.White)
+ }
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleLarge,
+ color = Color.White,
+ modifier = Modifier.padding(start = 8.dp).weight(1f),
+ )
+ if (notice != null) {
+ IconButton(onClick = { noticeShown = !noticeShown }) {
+ Icon(MeshtasticIcons.Info, contentDescription = stringResource(Res.string.info), tint = Color.White)
+ }
+ }
+ }
+ AnimatedVisibility(visible = noticeShown && notice != null) {
+ Text(
+ text = notice.orEmpty(),
+ style = MaterialTheme.typography.labelSmall,
+ color = Color.White,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
+ )
+ }
+ }
+}
+
+/** A camera-app shutter: the one control you reach for with a thumb, so it sits bottom center. */
+@Composable
+private fun ShutterButton(onClick: () -> Unit) {
+ FilledIconButton(
+ onClick = onClick,
+ modifier = Modifier.size(72.dp),
+ colors = IconButtonDefaults.filledIconButtonColors(containerColor = Color.White, contentColor = Color.Black),
+ ) {
+ Icon(MeshtasticIcons.PhotoCamera, contentDescription = stringResource(Res.string.device_model_capture))
+ }
+}
+
+/** Saves the AR view to Pictures and says so; Android 10 and later only, where that needs no storage permission. */
+@Composable
+private fun rememberArCapture(title: String): (() -> Unit)? {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null
+ val view = LocalView.current
+ val scope = rememberCoroutineScope()
+ val dispatchers = koinInject()
+ val haptic = LocalHapticFeedback.current
+ val saved = stringResource(Res.string.device_model_capture_saved)
+ val failed = stringResource(Res.string.device_model_capture_failed)
+ return {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ scope.launch {
+ val uri = captureArScene(view.rootView, title, dispatchers.io)
+ Toast.makeText(view.context, if (uri != null) saved else failed, Toast.LENGTH_SHORT).show()
+ }
+ }
+}
+
+/**
+ * Holds the activity at its current orientation while AR is up. A rotation recreates the activity, and the new ARCore
+ * session starts without the planes it found or the device placed on one; turning the phone here moves the camera.
+ */
+@Composable
+private fun LockOrientationWhileShown() {
+ val context = LocalContext.current
+ DisposableEffect(context) {
+ val activity = context.findActivity()
+ val previous = activity?.requestedOrientation
+ activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LOCKED
+ onDispose { if (activity != null && previous != null) activity.requestedOrientation = previous }
+ }
+}
+
+private tailrec fun Context.findActivity(): Activity? = when (this) {
+ is Activity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+}
diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index 1fa0ade959f..edeba531d34 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -28,6 +28,18 @@
android:name="android.hardware.location.gps"
android:required="false" />
+
+
+
+
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index 827e1125935..b2e77b4f15e 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -57,6 +57,7 @@ import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.map.getMapViewProvider
import org.meshtastic.app.node.component.InlineMap
+import org.meshtastic.app.node.deviceModelArViewer
import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
@@ -86,6 +87,7 @@ import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
+import org.meshtastic.core.ui.util.LocalDeviceModelArViewer
import org.meshtastic.core.ui.util.LocalDiscoveryMapProvider
import org.meshtastic.core.ui.util.LocalEventBranding
import org.meshtastic.core.ui.util.LocalInlineMapProvider
@@ -268,6 +270,7 @@ class MainActivity : AppCompatActivity() {
packageManager.hasSystemFeature(PackageManager.FEATURE_NFC_HOST_CARD_EMULATION),
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(),
+ LocalDeviceModelArViewer provides deviceModelArViewer,
LocalSitePlannerAvailable provides true,
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalNodeTrackMapProvider provides
diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/SceneViewManifestTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/SceneViewManifestTest.kt
new file mode 100644
index 00000000000..18d24a22f98
--- /dev/null
+++ b/androidApp/src/test/kotlin/org/meshtastic/app/SceneViewManifestTest.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app
+
+import android.app.Application
+import android.content.Context
+import android.content.pm.FeatureInfo
+import android.content.pm.PackageManager
+import androidx.test.core.app.ApplicationProvider
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+
+/**
+ * The SceneView AAR's manifest marks GLES 3.0 as a required feature and requests VIBRATE. The 3D viewer is optional, so
+ * the app manifest neutralizes both; these assertions read the merged manifest and fail if either override is dropped.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [34])
+class SceneViewManifestTest {
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+
+ @Test
+ fun `gles 3 is declared but not required`() {
+ val features =
+ context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_CONFIGURATIONS).reqFeatures
+ val gles = assertNotNull(features?.firstOrNull { it.reqGlEsVersion == GLES_3_0 }, "no GLES 3.0 uses-feature")
+ assertFalse(
+ gles.flags and FeatureInfo.FLAG_REQUIRED != 0,
+ "GLES 3.0 is required; the viewer must stay optional",
+ )
+ }
+
+ @Test
+ fun `vibrate is not requested`() {
+ val permissions =
+ context.packageManager
+ .getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
+ .requestedPermissions
+ assertFalse(permissions.orEmpty().contains(android.Manifest.permission.VIBRATE))
+ }
+
+ private companion object {
+ const val GLES_3_0 = 0x00030000
+ }
+}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceModelRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceModelRepositoryImpl.kt
new file mode 100644
index 00000000000..2260efb729d
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceModelRepositoryImpl.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.repository
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.koin.core.annotation.Single
+import org.meshtastic.core.network.DeviceModelRemoteDataSource
+import org.meshtastic.core.repository.DeviceModelRepository
+
+/**
+ * Memoizes per image name for the process lifetime: a model is a few hundred KB to a few MB and the hero re-asks on
+ * every node detail open. The flasher's answer is cached either way; a failed request is not, so a later open retries.
+ */
+@Single
+class DeviceModelRepositoryImpl(private val remote: DeviceModelRemoteDataSource) : DeviceModelRepository {
+ private val lock = Mutex()
+ private val existing = mutableSetOf()
+ private val absent = mutableSetOf()
+ private val models = mutableMapOf()
+
+ override suspend fun hasModel(imageFile: String): Boolean {
+ val cached = lock.withLock {
+ when (imageFile) {
+ in existing -> true
+ in absent -> false
+ else -> null
+ }
+ }
+ val answer =
+ cached
+ ?: remote.exists(imageFile)?.also { exists ->
+ lock.withLock { (if (exists) existing else absent) += imageFile }
+ }
+ return answer == true
+ }
+
+ override suspend fun getModel(imageFile: String): ByteArray? = lock.withLock { models[imageFile] }
+ ?: remote.fetch(imageFile)?.also { bytes ->
+ lock.withLock {
+ models[imageFile] = bytes
+ existing += imageFile
+ }
+ }
+}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceHardware.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceHardware.kt
index 16965496c9e..37f78d01fb8 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceHardware.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceHardware.kt
@@ -66,3 +66,10 @@ data class DeviceHardware(
val isRp2040Arc: Boolean
get() = architecture.startsWith("rp2040", ignoreCase = true)
}
+
+/**
+ * The illustration the apps show for this hardware: the cased variant when the registry lists one (index 1), else the
+ * bare board. The flasher's 3D model, when there is one, is named after the same file.
+ */
+val DeviceHardware.heroImageFile: String?
+ get() = images?.getOrNull(1) ?: images?.getOrNull(0)
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/DeviceModelRemoteDataSource.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/DeviceModelRemoteDataSource.kt
new file mode 100644
index 00000000000..2a9caa87e23
--- /dev/null
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/DeviceModelRemoteDataSource.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.network
+
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import io.ktor.client.request.head
+import io.ktor.client.statement.HttpResponse
+import io.ktor.client.statement.readRawBytes
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+import io.ktor.http.isSuccess
+import kotlinx.coroutines.withContext
+import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.safeCatching
+import org.meshtastic.core.di.CoroutineDispatchers
+
+/** Fetches the flasher's GLB device models; the URL is the illustration's with the extension swapped. */
+@Single
+class DeviceModelRemoteDataSource(private val client: HttpClient, private val dispatchers: CoroutineDispatchers) {
+
+ /**
+ * True when the model is served, false when the flasher answered without one, null when it could not be asked. The
+ * flasher is a single-page app that answers a missing path with 200 and its HTML shell, so only the GLB content
+ * type counts.
+ */
+ suspend fun exists(imageFile: String): Boolean? = withContext(dispatchers.io) {
+ safeCatching { client.head(modelUrl(imageFile)).isGlb() }.getOrNull()
+ }
+
+ /** The model bytes, or null when absent or the fetch failed. */
+ suspend fun fetch(imageFile: String): ByteArray? = withContext(dispatchers.io) {
+ safeCatching {
+ val response = client.get(modelUrl(imageFile))
+ if (response.isGlb()) response.readRawBytes() else null
+ }
+ .getOrNull()
+ }
+
+ private fun HttpResponse.isGlb(): Boolean =
+ status.isSuccess() && contentType()?.withoutParameters() == GLB_CONTENT_TYPE
+
+ companion object {
+ private val GLB_CONTENT_TYPE = ContentType("model", "gltf-binary")
+
+ fun modelUrl(imageFile: String): String =
+ "${HttpClientDefaults.DEVICE_MODEL_BASE_URL}/${imageFile.substringBeforeLast('.')}.glb"
+ }
+}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt
index 6e1e2a0bb90..38790a5f4e4 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt
@@ -66,6 +66,9 @@ object HttpClientDefaults {
* those fall back to the release zip named by the API's `zip_url`, which is a GitHub release asset and unaffected.
*/
const val RELEASE_BASE_URL = "https://release.meshtastic.org"
+
+ /** The flasher's device models, `.glb` beside the `img/devices/.svg` illustrations the apps show. */
+ const val DEVICE_MODEL_BASE_URL = "https://flasher.meshtastic.org/models/devices"
}
/**
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/DeviceModelRemoteDataSourceTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/DeviceModelRemoteDataSourceTest.kt
new file mode 100644
index 00000000000..366ac36c9c3
--- /dev/null
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/DeviceModelRemoteDataSourceTest.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.network
+
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.http.HttpHeaders
+import io.ktor.http.headersOf
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.runTest
+import kotlinx.io.IOException
+import org.meshtastic.core.di.CoroutineDispatchers
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class DeviceModelRemoteDataSourceTest {
+ private val dispatchers =
+ CoroutineDispatchers(io = Dispatchers.Default, main = Dispatchers.Default, default = Dispatchers.Default)
+
+ @Test
+ fun modelUrlSwapsTheIllustrationExtension() {
+ assertEquals(
+ "https://flasher.meshtastic.org/models/devices/rak4631_case.glb",
+ DeviceModelRemoteDataSource.modelUrl("rak4631_case.svg"),
+ )
+ }
+
+ @Test
+ fun aServedGlbExistsAndIsFetched() = runTest {
+ val glb = byteArrayOf(0x67, 0x6C, 0x54, 0x46)
+ withSource(respondWith = glb, contentType = "model/gltf-binary") { source ->
+ assertEquals(true, source.exists("tracker-t1000-e.svg"))
+ assertContentEquals(glb, source.fetch("tracker-t1000-e.svg"))
+ }
+ }
+
+ @Test
+ fun theFlashersHtmlShellForAMissingModelIsNotAModel() = runTest {
+ withSource(
+ respondWith = "".encodeToByteArray(),
+ contentType = "text/html;charset=utf-8",
+ ) { source ->
+ assertEquals(false, source.exists("t-deck.svg"))
+ assertNull(source.fetch("t-deck.svg"))
+ }
+ }
+
+ @Test
+ fun aFailedRequestIsUnknownNotAbsent() = runTest {
+ val client = HttpClient(MockEngine { throw IOException("offline") })
+ try {
+ val source = DeviceModelRemoteDataSource(client, dispatchers)
+ assertNull(source.exists("t-deck.svg"))
+ assertNull(source.fetch("t-deck.svg"))
+ } finally {
+ client.close()
+ }
+ }
+
+ private suspend fun withSource(
+ respondWith: ByteArray,
+ contentType: String,
+ block: suspend (DeviceModelRemoteDataSource) -> Unit,
+ ) {
+ val client =
+ HttpClient(MockEngine { respond(respondWith, headers = headersOf(HttpHeaders.ContentType, contentType)) })
+ try {
+ block(DeviceModelRemoteDataSource(client, dispatchers))
+ } finally {
+ client.close()
+ }
+ }
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DeviceModelRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DeviceModelRepository.kt
new file mode 100644
index 00000000000..219f293b57e
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DeviceModelRepository.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.repository
+
+/**
+ * The flasher's real-size 3D device models (`/models/devices/.glb`), one per device illustration. A hardware
+ * entry's image file names its model by extension swap, and only some illustrations have one.
+ */
+interface DeviceModelRepository {
+ /** Whether a model exists for [imageFile] (an entry of `DeviceHardware.images`). False on any network failure. */
+ suspend fun hasModel(imageFile: String): Boolean
+
+ /** The GLB bytes for [imageFile], or null when there is no model or it could not be fetched. */
+ suspend fun getModel(imageFile: String): ByteArray?
+}
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_photo_camera.xml b/core/resources/src/commonMain/composeResources/drawable/ic_photo_camera.xml
new file mode 100644
index 00000000000..dcbda4fc875
--- /dev/null
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_photo_camera.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_view_in_ar.xml b/core/resources/src/commonMain/composeResources/drawable/ic_view_in_ar.xml
new file mode 100644
index 00000000000..d2d37ed3317
--- /dev/null
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_view_in_ar.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/core/resources/src/commonMain/composeResources/files/environments/studio_small_09.hdr b/core/resources/src/commonMain/composeResources/files/environments/studio_small_09.hdr
new file mode 100644
index 00000000000..e9a2d2272ce
Binary files /dev/null and b/core/resources/src/commonMain/composeResources/files/environments/studio_small_09.hdr differ
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index f13aa213829..b2d90bbd962 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -424,6 +424,17 @@
%1$s
%1$s: %2$s%%
%1$s: %2$s V
+ Take a picture
+ Couldn't save the picture
+ Saved to Pictures
+ Couldn't load the 3D model
+ Face down
+ Face up
+ Left side
+ Right side
+ Upright
+ View 3D model
+ View in your space
Device sleeping
Device Storage & UI (Read-Only)
Theme: %1$s, Language: %2$s
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt
index fb9634e63d9..2449adfb9f0 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Hardware.kt
@@ -29,16 +29,26 @@ import org.meshtastic.core.resources.ic_display_settings
import org.meshtastic.core.resources.ic_memory
import org.meshtastic.core.resources.ic_mesh_radio
import org.meshtastic.core.resources.ic_nfc
+import org.meshtastic.core.resources.ic_photo_camera
import org.meshtastic.core.resources.ic_settings_input_antenna
import org.meshtastic.core.resources.ic_speaker_phone
import org.meshtastic.core.resources.ic_terminal
import org.meshtastic.core.resources.ic_usb
import org.meshtastic.core.resources.ic_usb_off
+import org.meshtastic.core.resources.ic_view_in_ar
import org.meshtastic.core.resources.ic_wifi
/** The maker hardware mark from meshtastic/design#160: a wrench, Material Symbols "build". */
val MeshtasticIcons.Wrench: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_build)
+
+/** Material Symbols "view_in_ar": the entry to a device's 3D model. */
+val MeshtasticIcons.ViewInAr: ImageVector
+ @Composable get() = vectorResource(Res.drawable.ic_view_in_ar)
+
+/** Material Symbols "photo_camera": capture the AR view. */
+val MeshtasticIcons.PhotoCamera: ImageVector
+ @Composable get() = vectorResource(Res.drawable.ic_photo_camera)
val MeshtasticIcons.BluetoothConnected: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_bluetooth_connected)
val MeshtasticIcons.BluetoothSearching: ImageVector
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalDeviceModelArViewer.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalDeviceModelArViewer.kt
new file mode 100644
index 00000000000..a4b9783de61
--- /dev/null
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalDeviceModelArViewer.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.staticCompositionLocalOf
+
+/** Null where there is no AR runtime at all: desktop and the fdroid flavor. */
+@Suppress("CompositionLocalAllowlist") // A host-provided capability, like the barcode and NFC providers.
+val LocalDeviceModelArViewer = staticCompositionLocalOf { null }
+
+/** Places a device's real-size GLB model in the camera view. */
+interface DeviceModelArViewer {
+ /** False on a phone the AR runtime has ruled out. Asked when a model opens, never at app start. */
+ @Composable fun isAvailable(): Boolean
+
+ /** [notice] is the model's own copyright line, shown with it. */
+ @Composable fun Content(glb: ByteArray, title: String, notice: String?, onDismiss: () -> Unit)
+}
diff --git a/desktopApp/proguard-rules.pro b/desktopApp/proguard-rules.pro
index 0ad61b70f19..66f0e4c3f22 100644
--- a/desktopApp/proguard-rules.pro
+++ b/desktopApp/proguard-rules.pro
@@ -92,6 +92,12 @@
-keep class org.maplibre.nativeffi.** { *; }
-keep interface org.maplibre.nativeffi.** { *; }
+# ---- Filament's Panama FFI bindings - the 3D device model viewer ------------
+# Same jextract shape as MapLibre above: each `Fila*Callback$Function` upcall
+# resolves "apply" by name, so the shrinker would strip it.
+-keep class io.github.erkko68.filament.ffm.** { *; }
+-keep interface io.github.erkko68.filament.ffm.** { *; }
+
# ---- LWJGL - the map bridge's Vulkan, OpenGL and Direct3D bindings -----------
# LWJGL picks its memory backend at by walking a list of class names
# (`MemoryBackendUnsafe*`, `MemoryBackendFFM*`) through Class.forName, and the
diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts
index 050b1b94dfa..c9f4612d3f1 100644
--- a/feature/node/build.gradle.kts
+++ b/feature/node/build.gradle.kts
@@ -36,10 +36,18 @@ kotlin {
implementation(projects.core.ui)
implementation(projects.core.di)
+ implementation(libs.kotlinx.serialization.json)
implementation(libs.markdown.renderer)
implementation(libs.markdown.renderer.m3)
implementation(libs.vico.compose)
implementation(libs.vico.compose.m3)
+ // The desktop renderer's POM drags a mac-only Compose runtime onto every OS's classpath. The
+ // string form is the one overload that takes an exclude; the version still comes from the catalog.
+ implementation("io.github.sceneview:sceneview-compose:${libs.versions.sceneview.get()}") {
+ exclude(group = "org.jetbrains.compose.desktop", module = "desktop-jvm-macos-arm64")
+ // Fuel 2.3.1 (the Android renderer's URL loader, unused here) ships the pre-Parcelize runtime.
+ exclude(group = "org.jetbrains.kotlin", module = "kotlin-android-extensions-runtime")
+ }
// JetBrains Material 3 Adaptive (multiplatform ListDetailPaneScaffold)
implementation(libs.jetbrains.compose.material3.adaptive)
@@ -48,7 +56,13 @@ kotlin {
implementation(libs.jetbrains.compose.material3.adaptive.navigation3)
}
- androidMain.dependencies { implementation(libs.markdown.renderer.android) }
+ androidMain.dependencies {
+ implementation(libs.markdown.renderer.android)
+ // The Android renderer under sceneview-compose, which only ships it at runtime scope.
+ implementation("io.github.sceneview:sceneview:${libs.versions.sceneview.get()}") {
+ exclude(group = "org.jetbrains.kotlin", module = "kotlin-android-extensions-runtime")
+ }
+ }
// Compose UI tests live in jvmTest, not commonTest: this module enables android host tests, and the
// androidHostTest stubs leave Build.FINGERPRINT null, which the Compose Robolectric idling strategy NPEs on.
diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/component/SceneViewerPlatform.android.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/component/SceneViewerPlatform.android.kt
new file mode 100644
index 00000000000..aa9b7892c47
--- /dev/null
+++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/component/SceneViewerPlatform.android.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.component
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.produceState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Modifier
+import com.google.android.filament.Engine
+import io.github.sceneview.SceneView
+import io.github.sceneview.createDefaultCameraManipulator
+import io.github.sceneview.environment.Environment
+import io.github.sceneview.gesture.CameraGestureDetector
+import io.github.sceneview.loaders.EnvironmentLoader
+import io.github.sceneview.loaders.ModelLoader
+import io.github.sceneview.math.Position
+import io.github.sceneview.model.model
+import io.github.sceneview.rememberEngine
+import io.github.sceneview.rememberEnvironmentLoader
+import io.github.sceneview.rememberModelLoader
+import org.meshtastic.core.common.util.safeCatching
+import org.meshtastic.feature.node.model.GlbBounds
+import java.nio.ByteBuffer
+
+/**
+ * SceneView directly rather than the multiplatform SceneViewer, whose Android HDR path swaps out and frees its
+ * placeholder environment while the scene still renders it, a native crash. The probe loads before the scene exists, so
+ * nothing is swapped. SceneView centers the model on the origin, so the orbit targets the origin.
+ */
+@Composable
+internal actual fun DeviceModelScene(
+ glb: ByteArray,
+ bounds: GlbBounds,
+ onError: () -> Unit,
+ modifier: Modifier,
+ visible: Boolean,
+) {
+ val engine = rememberEngine()
+ val modelLoader = rememberModelLoader(engine)
+ val environmentLoader = rememberEnvironmentLoader(engine)
+ val currentOnError by rememberUpdatedState(onError)
+ val environment by
+ produceState(null, environmentLoader) {
+ value =
+ safeCatching { environmentLoader.loadHDREnvironment(STUDIO_LIGHT_PROBE, createSkybox = false) }
+ .getOrNull()
+ .also { if (it == null) currentOnError() }
+ }
+ // Captured, not read through the delegate: the null effect's dispose runs after the state already holds the load.
+ val loadedEnvironment = environment
+ DisposableEffect(loadedEnvironment) { onDispose { loadedEnvironment?.let(environmentLoader::destroyEnvironment) } }
+ val cameraManipulator =
+ remember(bounds) {
+ createDefaultCameraManipulator(eyePosition = orbitEye(bounds), targetPosition = Position(0f))
+ }
+ // Hidden, only the scene goes: the engine, its compiled materials, the probe and the camera stay for when it shows
+ // again.
+ if (visible && loadedEnvironment != null) {
+ OrbitScene(glb, engine, modelLoader, environmentLoader, loadedEnvironment, cameraManipulator, onError, modifier)
+ }
+}
+
+/**
+ * The model instance does not survive its scene's teardown, so it is rebuilt from the bytes each time, which is cheap.
+ */
+@Composable
+@Suppress("LongParameterList")
+private fun OrbitScene(
+ glb: ByteArray,
+ engine: Engine,
+ modelLoader: ModelLoader,
+ environmentLoader: EnvironmentLoader,
+ environment: Environment,
+ cameraManipulator: CameraGestureDetector.CameraManipulator,
+ onError: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val currentOnError by rememberUpdatedState(onError)
+ val instance =
+ remember(modelLoader, glb) {
+ runCatching { modelLoader.createModelInstance(ByteBuffer.wrap(glb)) }.getOrNull()
+ }
+ DisposableEffect(instance) { onDispose { instance?.let { modelLoader.destroyModel(it.model) } } }
+ SideEffect { if (instance == null) currentOnError() }
+ if (instance == null) return
+ SceneView(
+ modifier = modifier,
+ engine = engine,
+ modelLoader = modelLoader,
+ environmentLoader = environmentLoader,
+ environment = environment,
+ isOpaque = false,
+ cameraManipulator = cameraManipulator,
+ ) {
+ ModelNode(modelInstance = instance)
+ }
+}
+
+private fun orbitEye(bounds: GlbBounds): Position {
+ val eye = orbitEyeOffset(bounds)
+ return Position(eye[0], eye[1], eye[2])
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt
index 7f54afca207..0b385f552ab 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceDetailsSection.kt
@@ -17,6 +17,7 @@
package org.meshtastic.feature.node.component
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -39,6 +40,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
@@ -47,8 +49,10 @@ import coil3.request.ImageRequest
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.HardwareSupportTier
+import org.meshtastic.core.model.heroImageFile
import org.meshtastic.core.model.supportTier
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.device_model_view
import org.meshtastic.core.resources.ic_unverified
import org.meshtastic.core.resources.img_hw_unknown
import org.meshtastic.core.resources.supported
@@ -56,6 +60,7 @@ import org.meshtastic.core.resources.supported_by_community
import org.meshtastic.core.resources.supported_by_maker
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Verified
+import org.meshtastic.core.ui.icon.ViewInAr
import org.meshtastic.core.ui.icon.Wrench
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@@ -71,12 +76,13 @@ internal fun DeviceHeroSection(
deviceHardware: DeviceHardware,
reportedTarget: String?,
modifier: Modifier = Modifier,
+ onOpenDeviceModel: (() -> Unit)? = null,
) {
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- DeviceAvatar(bgColor, deviceHardware, size = 80)
+ DeviceAvatar(bgColor, deviceHardware, size = 80, onOpenDeviceModel = onOpenDeviceModel)
Spacer(Modifier.width(16.dp))
@@ -94,16 +100,44 @@ internal fun DeviceHeroSection(
}
}
+/** The illustration, and when a 3D model exists a tap target for it with a corner badge saying so. */
@Composable
-private fun DeviceAvatar(bgColor: Long, deviceHardware: DeviceHardware, size: Int = 64) {
- Box(
- modifier =
- Modifier.size(size.dp)
- .clip(CircleShape)
- .background(color = Color(bgColor).copy(alpha = .5f), shape = CircleShape),
- contentAlignment = Alignment.Center,
- ) {
- DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize())
+private fun DeviceAvatar(
+ bgColor: Long,
+ deviceHardware: DeviceHardware,
+ size: Int = 64,
+ onOpenDeviceModel: (() -> Unit)? = null,
+) {
+ val label = stringResource(Res.string.device_model_view)
+ Box(modifier = Modifier.size(size.dp)) {
+ Box(
+ modifier =
+ Modifier.fillMaxSize()
+ .clip(CircleShape)
+ .background(color = Color(bgColor).copy(alpha = .5f), shape = CircleShape)
+ .then(
+ if (onOpenDeviceModel != null) {
+ Modifier.clickable(onClickLabel = label, role = Role.Button, onClick = onOpenDeviceModel)
+ } else {
+ Modifier
+ },
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize())
+ }
+ if (onOpenDeviceModel != null) {
+ Icon(
+ imageVector = MeshtasticIcons.ViewInAr,
+ contentDescription = null,
+ tint = colorScheme.onPrimaryContainer,
+ modifier =
+ Modifier.align(Alignment.BottomEnd)
+ .size(24.dp)
+ .background(colorScheme.primaryContainer, CircleShape)
+ .padding(4.dp),
+ )
+ }
}
}
@@ -149,7 +183,7 @@ private fun SupportStatusBadge(tier: HardwareSupportTier) {
@Composable
internal fun DeviceHardwareImage(deviceHardware: DeviceHardware, modifier: Modifier = Modifier) {
- val hwImg = deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0) ?: "unknown.svg"
+ val hwImg = deviceHardware.heroImageFile ?: "unknown.svg"
val imageUrl = "https://flasher.meshtastic.org/img/devices/$hwImg"
val fallbackPainter = org.jetbrains.compose.resources.painterResource(Res.drawable.img_hw_unknown)
AsyncImage(
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceModelDialog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceModelDialog.kt
new file mode 100644
index 00000000000..e10331e300d
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceModelDialog.kt
@@ -0,0 +1,199 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
+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.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.viewmodel.koinViewModel
+import org.meshtastic.core.model.DeviceHardware
+import org.meshtastic.core.model.heroImageFile
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.close
+import org.meshtastic.core.resources.device_model_load_failed
+import org.meshtastic.core.resources.device_model_view_in_space
+import org.meshtastic.core.resources.info
+import org.meshtastic.core.ui.icon.Close
+import org.meshtastic.core.ui.icon.Info
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.ViewInAr
+import org.meshtastic.core.ui.util.LocalDeviceModelArViewer
+
+/** The hardware's real-size 3D model, orbitable, over the whole window; the illustration when it cannot be shown. */
+@Composable
+fun DeviceModelDialog(deviceHardware: DeviceHardware, onDismiss: () -> Unit, modifier: Modifier = Modifier) {
+ val viewModel = koinViewModel()
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val imageFile = deviceHardware.heroImageFile
+ // Once per open: a failed load clears the view model's key, so a later open retries.
+ DisposableEffect(imageFile) {
+ if (imageFile != null) viewModel.load(imageFile) else viewModel.markFailed()
+ onDispose {}
+ }
+
+ val arViewer = LocalDeviceModelArViewer.current?.takeIf { it.isAvailable() }
+ var inAr by rememberSaveable { mutableStateOf(false) }
+ val arModel = (state as? DeviceModelState.Ready)?.takeIf { inAr && arViewer != null }
+
+ Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
+ // The orbit view stays composed under AR with its surface dropped, so coming back does not reload the model.
+ Box(modifier = modifier.fillMaxSize()) {
+ OrbitContent(
+ deviceHardware = deviceHardware,
+ state = state,
+ onError = viewModel::markFailed,
+ onDismiss = onDismiss,
+ onOpenAr = if (arViewer != null) ({ inAr = true }) else null,
+ visible = arModel == null,
+ )
+ arModel?.let { arViewer?.Content(it.glb, deviceHardware.displayName, it.copyright) { inAr = false } }
+ }
+ }
+}
+
+@Composable
+private fun OrbitContent(
+ deviceHardware: DeviceHardware,
+ state: DeviceModelState,
+ onError: () -> Unit,
+ onDismiss: () -> Unit,
+ onOpenAr: (() -> Unit)?,
+ visible: Boolean,
+) {
+ val ready = state as? DeviceModelState.Ready
+ Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
+ Column(modifier = Modifier.fillMaxSize().safeDrawingPadding()) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = onDismiss) {
+ Icon(MeshtasticIcons.Close, contentDescription = stringResource(Res.string.close))
+ }
+ Text(
+ text = deviceHardware.displayName,
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(start = 8.dp).weight(1f),
+ )
+ if (onOpenAr != null && ready != null) {
+ IconButton(onClick = onOpenAr) {
+ Icon(
+ MeshtasticIcons.ViewInAr,
+ contentDescription = stringResource(Res.string.device_model_view_in_space),
+ )
+ }
+ }
+ }
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ when (val current = state) {
+ DeviceModelState.Loading -> CircularProgressIndicator()
+
+ DeviceModelState.Failed -> ModelFallback(deviceHardware)
+
+ is DeviceModelState.Ready ->
+ Box(modifier = Modifier.fillMaxSize()) {
+ DeviceModelScene(
+ glb = current.glb,
+ bounds = current.bounds,
+ onError = onError,
+ modifier = Modifier.fillMaxSize(),
+ visible = visible,
+ )
+ current.copyright?.let {
+ ModelNotice(it, Modifier.align(Alignment.BottomEnd).padding(16.dp))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * The model file's own copyright notice behind an info button, shut until tapped, as the maps carry their credit. The
+ * text is the owner's, so it is never translated or cut.
+ */
+@Composable
+fun ModelNotice(
+ notice: String,
+ modifier: Modifier = Modifier,
+ contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
+ containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
+) {
+ var expanded by rememberSaveable { mutableStateOf(false) }
+ // The toggle is last, so the notice opens inboard of it from the trailing corner, in RTL too.
+ Row(
+ modifier = modifier.clip(RoundedCornerShape(24.dp)).background(containerColor),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AnimatedVisibility(visible = expanded, modifier = Modifier.weight(1f, fill = false)) {
+ Text(
+ text = notice,
+ style = MaterialTheme.typography.labelSmall,
+ color = contentColor,
+ modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp),
+ )
+ }
+ IconButton(onClick = { expanded = !expanded }) {
+ Icon(MeshtasticIcons.Info, contentDescription = stringResource(Res.string.info), tint = contentColor)
+ }
+ }
+}
+
+@Composable
+private fun ModelFallback(deviceHardware: DeviceHardware) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ DeviceHardwareImage(deviceHardware, Modifier.size(240.dp))
+ Spacer(Modifier.height(16.dp))
+ Text(
+ text = stringResource(Res.string.device_model_load_failed),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceModelViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceModelViewModel.kt
new file mode 100644
index 00000000000..1a1ca789e34
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceModelViewModel.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.component
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.core.repository.DeviceModelRepository
+import org.meshtastic.feature.node.model.GlbBounds
+
+sealed interface DeviceModelState {
+ data object Loading : DeviceModelState
+
+ data object Failed : DeviceModelState
+
+ /** [copyright] is the model's own notice, shown wherever the model is. */
+ data class Ready(val imageFile: String, val glb: ByteArray, val bounds: GlbBounds, val copyright: String?) :
+ DeviceModelState {
+ override fun equals(other: Any?): Boolean =
+ this === other || (other is Ready && imageFile == other.imageFile && bounds == other.bounds)
+
+ override fun hashCode(): Int = 31 * imageFile.hashCode() + bounds.hashCode()
+ }
+}
+
+/** Fetches a device's GLB and the bounds the viewer frames it with; one model at a time, the last request wins. */
+@KoinViewModel
+class DeviceModelViewModel(private val repository: DeviceModelRepository) : ViewModel() {
+ private val _state = MutableStateFlow(DeviceModelState.Loading)
+ val state: StateFlow = _state.asStateFlow()
+
+ private var loading: Job? = null
+ private var loadedImageFile: String? = null
+
+ fun load(imageFile: String) {
+ if (loadedImageFile == imageFile) return
+ loadedImageFile = imageFile
+ loading?.cancel()
+ _state.value = DeviceModelState.Loading
+ loading = viewModelScope.launch {
+ val glb = repository.getModel(imageFile)
+ val bounds = glb?.let(GlbBounds::parse)
+ _state.value =
+ if (glb != null && bounds != null) {
+ DeviceModelState.Ready(imageFile, glb, bounds, GlbBounds.copyright(glb))
+ } else {
+ // Lets the next open retry rather than replay the failure.
+ loadedImageFile = null
+ DeviceModelState.Failed
+ }
+ }
+ }
+
+ /** The renderer rejected a model the parser accepted: show the illustration instead. */
+ fun markFailed() {
+ _state.value = DeviceModelState.Failed
+ }
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
index 1edb22398a1..fc678a0a8e5 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
@@ -121,6 +121,8 @@ fun NodeDetailsSection(
reportedTarget: String? = null,
isLocal: Boolean = false,
onRequestUserInfo: (() -> Unit)? = null,
+ hasDeviceModel: Boolean = false,
+ onOpenDeviceModel: ((DeviceHardware) -> Unit)? = null,
) {
SectionCard(title = Res.string.details, modifier = modifier) {
Column(modifier = Modifier.animateContentSize()) {
@@ -133,6 +135,8 @@ fun NodeDetailsSection(
bgColor = node.colors.second.toLong(),
deviceHardware = deviceHardware,
reportedTarget = reportedTarget,
+ onOpenDeviceModel =
+ onOpenDeviceModel?.takeIf { hasDeviceModel }?.let { open -> { open(deviceHardware) } },
)
SectionDivider()
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/SceneViewerPlatform.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/SceneViewerPlatform.kt
new file mode 100644
index 00000000000..2864e932449
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/SceneViewerPlatform.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.component
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import dev.romainguy.kotlin.math.Float3
+import io.github.sceneview.compose.EnvironmentSource
+import io.github.sceneview.compose.Lighting
+import io.github.sceneview.compose.ModelSource
+import io.github.sceneview.compose.SceneViewer
+import io.github.sceneview.compose.rememberCameraState
+import org.meshtastic.feature.node.model.GlbBounds
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+/** A CC0 studio light probe (Poly Haven, Studio Small 09), at the path both renderers resolve a bundled file by. */
+internal const val STUDIO_LIGHT_PROBE =
+ "composeResources/org.meshtastic.core.resources/files/environments/studio_small_09.hdr"
+
+private const val CAMERA_DISTANCE_PER_EXTENT = 2.2f
+private const val CAMERA_AZIMUTH_DEGREES = 30f
+private const val CAMERA_ELEVATION_DEGREES = 20f
+private const val SUN_LUX = 40_000f
+private const val DEGREES_TO_RADIANS = (PI / 180.0).toFloat()
+
+/** The camera's offset from the model's center: the same three-quarter view on every platform. */
+internal fun orbitEyeOffset(bounds: GlbBounds): FloatArray {
+ val distance = bounds.extent * CAMERA_DISTANCE_PER_EXTENT
+ val azimuth = CAMERA_AZIMUTH_DEGREES * DEGREES_TO_RADIANS
+ val elevation = CAMERA_ELEVATION_DEGREES * DEGREES_TO_RADIANS
+ val horizontal = distance * cos(elevation)
+ return floatArrayOf(horizontal * sin(azimuth), distance * sin(elevation), horizontal * cos(azimuth))
+}
+
+/** The orbitable model, lit by the studio probe over a transparent backdrop so the theme shows behind it. */
+@Composable
+internal expect fun DeviceModelScene(
+ glb: ByteArray,
+ bounds: GlbBounds,
+ onError: () -> Unit,
+ modifier: Modifier = Modifier,
+ visible: Boolean = true,
+)
+
+/**
+ * The multiplatform SceneViewer, which places the model at its authored coordinates on desktop: the orbit target is the
+ * bounds center and the distance a multiple of the longest side.
+ */
+@Composable
+internal fun SceneViewerModelScene(
+ glb: ByteArray,
+ bounds: GlbBounds,
+ onError: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val center = bounds.center
+ val camera =
+ rememberCameraState(
+ target = Float3(center[0], center[1], center[2]),
+ distance = bounds.extent * CAMERA_DISTANCE_PER_EXTENT,
+ azimuth = CAMERA_AZIMUTH_DEGREES,
+ elevation = CAMERA_ELEVATION_DEGREES,
+ )
+ SceneViewer(
+ model = ModelSource.Bytes(glb),
+ modifier = modifier,
+ camera = camera,
+ lighting = Lighting(intensity = SUN_LUX),
+ environment = EnvironmentSource.Hdr(STUDIO_LIGHT_PROBE, showSkybox = false),
+ onError = { onError() },
+ )
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt
index 0bb67c73df8..fd0f9d897df 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt
@@ -110,6 +110,8 @@ fun NodeDetailList(
onRequestUserInfo = {
onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestUserInfo(node)))
},
+ hasDeviceModel = uiState.metricsState.hasDeviceModel,
+ onOpenDeviceModel = { onAction(NodeDetailAction.OpenDeviceModel(it)) },
)
}
if (uiState.metricsState.deviceLinks.isNotEmpty()) {
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
index c5ba3964370..760f0325706 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
@@ -30,6 +30,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -47,6 +48,7 @@ import org.meshtastic.core.ui.util.ActiveWhileStarted
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.component.CompassSheetContent
+import org.meshtastic.feature.node.component.DeviceModelDialog
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.model.NodeDetailAction
@@ -96,6 +98,8 @@ private fun NodeDetailScaffold(
compassViewModel: CompassViewModel? = null,
) {
var activeOverlay by remember { mutableStateOf(null) }
+ // Saved, unlike the overlays: a rotation must not close the model, which the user may be turning the phone for.
+ var deviceModelOpen by rememberSaveable { mutableStateOf(false) }
val actualCompassViewModel = compassViewModel
val compassUiState by
actualCompassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
@@ -128,6 +132,8 @@ private fun NodeDetailScaffold(
is NodeDetailAction.OpenCompass ->
activeOverlay = NodeDetailOverlay.Compass(action.node.num, action.displayUnits)
+ is NodeDetailAction.OpenDeviceModel -> deviceModelOpen = true
+
else ->
handleNodeAction(
action = action,
@@ -156,6 +162,9 @@ private fun NodeDetailScaffold(
) {
viewModel.handleNodeMenuAction(NodeMenuAction.RequestPosition(it))
}
+ uiState.metricsState.deviceHardware
+ ?.takeIf { deviceModelOpen }
+ ?.let { DeviceModelDialog(it, onDismiss = { deviceModelOpen = false }) }
}
@OptIn(ExperimentalMaterial3Api::class)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt
index 31a1e526cf7..8de43017f82 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt
@@ -22,8 +22,8 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.transformLatest
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.LocaleUnitsProvider
import org.meshtastic.core.common.util.MeasurementSystem
@@ -34,10 +34,12 @@ import org.meshtastic.core.model.FirmwareRelease
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.heroImageFile
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.model.util.isDirectSignal
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.DeviceLinkRepository
+import org.meshtastic.core.repository.DeviceModelRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
@@ -67,6 +69,7 @@ constructor(
private val radioConfigRepository: RadioConfigRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val deviceLinkRepository: DeviceLinkRepository,
+ private val deviceModelRepository: DeviceModelRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val nodeRequestActions: NodeRequestActions,
private val localeUnitsProvider: LocaleUnitsProvider,
@@ -123,8 +126,8 @@ constructor(
IdentityGroup(ourNode, myInfo, profile)
}
- // 3. Device Hardware (+ msh.to links) — non-blocking Flow derived from stable (hwModel, pioEnv) key.
- val hardwareAndLinksFlow: Flow>> =
+ // 3. Device Hardware (+ msh.to links, 3D model) — non-blocking Flow derived from stable (hwModel, pioEnv) key.
+ val hardwareAndLinksFlow: Flow =
combine(nodeFlow, identityFlow) { node, identity ->
val isLocal = node.num == identity.ourNode?.num
val pioEnv = if (isLocal) identity.myInfo?.pioEnv else null
@@ -133,12 +136,16 @@ constructor(
.distinctUntilChanged()
.flatMapLatest { key -> deviceHardwareRepository.observeDeviceHardware(key.hwModel, key.target) }
.onStart { emit(null) }
- .mapLatest { hw ->
+ .transformLatest { hw ->
val links =
hw?.platformioTarget
?.takeIf { it.isNotBlank() }
?.let { deviceLinkRepository.getLinksForTarget(it) } ?: emptyList()
- hw to links
+ // The model check is a network round trip, so the hardware card never waits on it.
+ emit(HardwareGroup(hw, links, hasModel = false))
+ if (hw?.heroImageFile?.let { deviceModelRepository.hasModel(it) } == true) {
+ emit(HardwareGroup(hw, links, hasModel = true))
+ }
}
// 4. Metadata & Request Timestamps
@@ -194,9 +201,7 @@ constructor(
@Suppress("UNCHECKED_CAST")
val requests = args[REQUESTS_INDEX] as Pair, List>
- @Suppress("UNCHECKED_CAST")
- val hardwareAndLinks = args[HARDWARE_INDEX] as Pair>
- val (hw, deviceLinks) = hardwareAndLinks
+ val (hw, deviceLinks, hasDeviceModel) = args[HARDWARE_INDEX] as HardwareGroup
val (trReqs, niReqs) = requests
val isLocal = node.num == identity.ourNode?.num
@@ -212,6 +217,7 @@ constructor(
isLocal = isLocal,
deviceHardware = hw,
deviceLinks = deviceLinks,
+ hasDeviceModel = hasDeviceModel,
reportedTarget = pioEnv,
isManaged = identity.profile.config?.security?.is_managed ?: false,
isFahrenheit = temperatureUnit == TemperatureUnit.FAHRENHEIT,
@@ -273,6 +279,8 @@ constructor(
private data class HardwareKey(val hwModel: Int, val target: String?)
+ private data class HardwareGroup(val hardware: DeviceHardware?, val links: List, val hasModel: Boolean)
+
private data class LogsGroup(
val telemetry: List,
val packets: List,
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/GlbBounds.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/GlbBounds.kt
new file mode 100644
index 00000000000..11b9320cf84
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/GlbBounds.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.model
+
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.float
+import kotlinx.serialization.json.int
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+
+/** Axis-aligned bounds of a model in its own units (meters for the flasher's models), Y up. */
+data class GlbBounds(val min: FloatArray, val max: FloatArray) {
+ val center: FloatArray
+ get() = FloatArray(AXES) { (min[it] + max[it]) / 2f }
+
+ val extent: Float
+ get() = (0 until AXES).maxOf { max[it] - min[it] }
+
+ override fun equals(other: Any?): Boolean =
+ this === other || (other is GlbBounds && min.contentEquals(other.min) && max.contentEquals(other.max))
+
+ override fun hashCode(): Int = 31 * min.contentHashCode() + max.contentHashCode()
+
+ companion object {
+ private const val AXES = 3
+ private const val HEADER_BYTES = 12
+ private const val CHUNK_TYPE_OFFSET = 4
+ private const val CHUNK_HEADER_BYTES = 8
+ private const val GLB_MAGIC = 0x46546C67 // "glTF"
+ private const val JSON_CHUNK = 0x4E4F534A // "JSON"
+ private const val INT_BYTES = 4
+ private const val BYTE_BITS = 8
+ private const val BYTE_MASK = 0xFF
+ private val json = Json { ignoreUnknownKeys = true }
+
+ /**
+ * The union of every mesh primitive's `POSITION` accessor bounds, which glTF requires each accessor to carry.
+ * Node transforms are ignored: the flasher's models bake theirs into the vertices. Null for anything that is
+ * not a GLB with at least one positioned primitive.
+ */
+ fun parse(glb: ByteArray): GlbBounds? = jsonChunk(glb)?.let { chunk ->
+ runCatching { boundsOf(json.parseToJsonElement(chunk).jsonObject) }.getOrNull()
+ }
+
+ /** The notice the model carries in glTF's `asset.copyright`, or null when it has none. */
+ fun copyright(glb: ByteArray): String? = jsonChunk(glb)?.let { chunk ->
+ runCatching {
+ json
+ .parseToJsonElement(chunk)
+ .jsonObject["asset"]
+ ?.jsonObject
+ ?.get("copyright")
+ ?.jsonPrimitive
+ ?.content
+ }
+ .getOrNull()
+ ?.takeIf { it.isNotBlank() }
+ }
+
+ private fun jsonChunk(glb: ByteArray): String? {
+ val start = HEADER_BYTES + CHUNK_HEADER_BYTES
+ if (glb.size < start) return null
+ val length = glb.readIntLe(HEADER_BYTES)
+ val valid =
+ glb.readIntLe(0) == GLB_MAGIC &&
+ glb.readIntLe(HEADER_BYTES + CHUNK_TYPE_OFFSET) == JSON_CHUNK &&
+ length >= 0 &&
+ length <= glb.size - start
+ return if (valid) glb.decodeToString(start, start + length) else null
+ }
+
+ private fun boundsOf(root: JsonObject): GlbBounds? {
+ val accessors = root["accessors"]?.jsonArray.orEmpty()
+ val positions =
+ root["meshes"]?.jsonArray.orEmpty().flatMap { mesh ->
+ mesh.jsonObject["primitives"]?.jsonArray.orEmpty().mapNotNull { primitive ->
+ primitive.jsonObject["attributes"]
+ ?.jsonObject
+ ?.get("POSITION")
+ ?.jsonPrimitive
+ ?.int
+ ?.let { accessors[it].jsonObject }
+ ?.takeIf { "min" in it && "max" in it }
+ }
+ }
+ if (positions.isEmpty()) return null
+ fun axis(key: String, axis: Int) = positions.map { it.getValue(key).jsonArray[axis].jsonPrimitive.float }
+ return GlbBounds(
+ min = FloatArray(AXES) { axis("min", it).min() },
+ max = FloatArray(AXES) { axis("max", it).max() },
+ )
+ }
+
+ private fun ByteArray.readIntLe(offset: Int): Int = (0 until INT_BYTES).fold(0) { acc, i ->
+ acc or ((this[offset + i].toInt() and BYTE_MASK) shl (BYTE_BITS * i))
+ }
+ }
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt
index 931ca6526ec..dcd62eed046 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt
@@ -51,6 +51,8 @@ data class MetricsState(
val deviceHardware: DeviceHardware? = null,
/** msh.to vendor/marketplace links for this device's hardware, region-filtered and sorted (vendor first). */
val deviceLinks: List = emptyList(),
+ /** Whether the flasher serves a 3D model for [deviceHardware]'s hero illustration. */
+ val hasDeviceModel: Boolean = false,
val firmwareEdition: FirmwareEdition? = null,
val latestStableFirmware: FirmwareRelease = FirmwareRelease(),
val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(),
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt
index 523ecc9d8b1..ffd6f56366e 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt
@@ -17,6 +17,7 @@
package org.meshtastic.feature.node.model
import org.meshtastic.core.common.util.MeasurementSystem
+import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.Node
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.node.component.NodeMenuAction
@@ -36,6 +37,9 @@ sealed interface NodeDetailAction {
data object ShareContact : NodeDetailAction
+ /** Open the real-size 3D model of the node's hardware. */
+ data class OpenDeviceModel(val deviceHardware: DeviceHardware) : NodeDetailAction
+
// Opens the compass sheet scoped to a target node and the user’s preferred units.
data class OpenCompass(val node: Node, val displayUnits: MeasurementSystem) : NodeDetailAction
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/RestPose.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/RestPose.kt
new file mode 100644
index 00000000000..546d370d37d
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/RestPose.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.model
+
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+/**
+ * Which face of a model rests on the surface it is placed on. The flasher's models are authored Y up with the front
+ * facing +Z, so a pitch about X lays the device on its back or front and a roll about Z on a side.
+ */
+enum class RestPose(val pitchDegrees: Float, val rollDegrees: Float) {
+ Upright(0f, 0f),
+ FaceUp(-QUARTER_TURN, 0f),
+ FaceDown(QUARTER_TURN, 0f),
+ LeftSide(0f, QUARTER_TURN),
+ RightSide(0f, -QUARTER_TURN),
+ ;
+
+ /** The model's bounds once rotated into this pose, before any offset. */
+ fun rotatedBounds(bounds: GlbBounds): GlbBounds {
+ val ends = listOf(bounds.min, bounds.max)
+ val corners = ends.flatMap { x -> ends.flatMap { y -> ends.map { z -> rotate(x[0], y[1], z[2]) } } }
+ return GlbBounds(
+ min = FloatArray(AXES) { axis -> corners.minOf { it[axis] } },
+ max = FloatArray(AXES) { axis -> corners.maxOf { it[axis] } },
+ )
+ }
+
+ /** Where to put the rotated model so its lowest face touches y = 0 and its footprint centers on the origin. */
+ fun offset(bounds: GlbBounds): FloatArray {
+ val rotated = rotatedBounds(bounds)
+ return floatArrayOf(-rotated.center[0], -rotated.min[1], -rotated.center[2])
+ }
+
+ private fun rotate(x: Float, y: Float, z: Float): FloatArray {
+ val pitch = pitchDegrees * DEGREES_TO_RADIANS
+ val roll = rollDegrees * DEGREES_TO_RADIANS
+ // Pitch about X, then roll about Z; a pose uses one or the other.
+ val y1 = y * cos(pitch) - z * sin(pitch)
+ val z1 = y * sin(pitch) + z * cos(pitch)
+ return floatArrayOf(x * cos(roll) - y1 * sin(roll), x * sin(roll) + y1 * cos(roll), z1)
+ }
+}
+
+private const val QUARTER_TURN = 90f
+private const val AXES = 3
+private const val DEGREES_TO_RADIANS = (PI / 180.0).toFloat()
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/TracerouteHistoryUseCaseTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/TracerouteHistoryUseCaseTest.kt
index 07d61bd907b..aad87c0599d 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/TracerouteHistoryUseCaseTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/TracerouteHistoryUseCaseTest.kt
@@ -76,6 +76,7 @@ class TracerouteHistoryUseCaseTest {
radioConfigRepository = FakeRadioConfigRepository(),
deviceHardwareRepository = FakeDeviceHardwareRepository(),
deviceLinkRepository = mock(MockMode.autofill),
+ deviceModelRepository = mock(MockMode.autofill),
firmwareReleaseRepository = FakeFirmwareReleaseRepository(),
nodeRequestActions = nodeRequestActions,
localeUnitsProvider = FakeLocaleUnitsProvider(),
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/GlbBoundsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/GlbBoundsTest.kt
new file mode 100644
index 00000000000..0dd59752fa8
--- /dev/null
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/GlbBoundsTest.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.model
+
+import kotlin.test.Test
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+class GlbBoundsTest {
+
+ @Test
+ fun unionsEveryPrimitivesPositionAccessor() {
+ val glb =
+ glb(
+ """
+ {"accessors":[
+ {"min":[-0.0275,0,-0.00325],"max":[0.0275,0.0858,0.00325]},
+ {"min":[0.01,0.08,-0.009],"max":[0.026,0.0935,0.009]},
+ {"min":[-9,-9,-9],"max":[9,9,9]}
+ ],
+ "meshes":[
+ {"primitives":[{"attributes":{"POSITION":0,"NORMAL":2}}]},
+ {"primitives":[{"attributes":{"POSITION":1}}]}
+ ]}
+ """,
+ )
+
+ val bounds = assertNotNull(GlbBounds.parse(glb))
+
+ assertContentEquals(floatArrayOf(-0.0275f, 0f, -0.009f), bounds.min)
+ assertContentEquals(floatArrayOf(0.0275f, 0.0935f, 0.009f), bounds.max)
+ assertEquals(0.0935f, bounds.extent)
+ assertContentEquals(floatArrayOf(0f, 0.04675f, 0f), bounds.center)
+ }
+
+ @Test
+ fun readsTheModelsOwnCopyrightNotice() {
+ val glb = glb("""{"asset":{"version":"2.0","copyright":"Copyright © 2026 Meshtastic LLC."}}""")
+
+ assertEquals("Copyright © 2026 Meshtastic LLC.", GlbBounds.copyright(glb))
+ }
+
+ @Test
+ fun noCopyrightWhenTheModelCarriesNone() {
+ assertNull(GlbBounds.copyright(glb("""{"asset":{"version":"2.0"}}""")))
+ assertNull(GlbBounds.copyright("not a model".encodeToByteArray()))
+ }
+
+ @Test
+ fun rejectsNonGlbBytes() {
+ assertNull(GlbBounds.parse("not a model".encodeToByteArray()))
+ assertNull(GlbBounds.parse(ByteArray(4)))
+ }
+
+ @Test
+ fun rejectsJsonChunkLengthPastTheEnd() {
+ val model = glb("""{"asset":{"version":"2.0"}}""")
+ for (length in listOf(model.size, Int.MAX_VALUE - 10, Int.MAX_VALUE)) {
+ val bad = model.copyOf().apply { putIntLe(12, length) }
+ assertNull(GlbBounds.parse(bad))
+ assertNull(GlbBounds.copyright(bad))
+ }
+ }
+
+ @Test
+ fun rejectsModelWithoutPositions() {
+ assertNull(GlbBounds.parse(glb("""{"accessors":[],"meshes":[{"primitives":[{"attributes":{}}]}]}""")))
+ }
+
+ private fun glb(json: String): ByteArray {
+ val body = json.trimIndent().encodeToByteArray()
+ val header = ByteArray(20)
+ header.putIntLe(0, 0x46546C67)
+ header.putIntLe(4, 2)
+ header.putIntLe(8, header.size + body.size)
+ header.putIntLe(12, body.size)
+ header.putIntLe(16, 0x4E4F534A)
+ return header + body
+ }
+
+ private fun ByteArray.putIntLe(offset: Int, value: Int) {
+ for (i in 0 until 4) this[offset + i] = (value shr (8 * i)).toByte()
+ }
+}
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/RestPoseTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/RestPoseTest.kt
new file mode 100644
index 00000000000..fd7f42f61d5
--- /dev/null
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/model/RestPoseTest.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.model
+
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+class RestPoseTest {
+ // The WisMesh Pocket: 52 mm wide, 138 mm tall with its antenna, 32 mm deep, front facing +Z.
+ private val pocket = GlbBounds(floatArrayOf(-0.026f, 0f, -0.015f), floatArrayOf(0.026f, 0.1382f, 0.017f))
+
+ @Test
+ fun everyPoseRestsOnTheSurfaceCenteredOnTheAnchor() {
+ RestPose.entries.forEach { pose ->
+ val placed = placed(pose)
+ assertNear(0f, placed.min[1], "$pose bottom")
+ assertNear(0f, placed.center[0], "$pose x center")
+ assertNear(0f, placed.center[2], "$pose z center")
+ }
+ }
+
+ @Test fun uprightStandsAtFullHeight() = assertNear(0.1382f, placed(RestPose.Upright).max[1], "height")
+
+ @Test
+ fun faceUpAndFaceDownLieAsDeepAsTheCase() {
+ assertNear(0.032f, placed(RestPose.FaceUp).max[1], "face up height")
+ assertNear(0.032f, placed(RestPose.FaceDown).max[1], "face down height")
+ }
+
+ @Test
+ fun aSideRestsAsWideAsTheCase() {
+ assertNear(0.052f, placed(RestPose.LeftSide).max[1], "left height")
+ assertNear(0.052f, placed(RestPose.RightSide).max[1], "right height")
+ }
+
+ @Test
+ fun faceUpTurnsTheScreenToTheSkyAndFaceDownToTheSurface() {
+ val screen = GlbBounds(floatArrayOf(-0.015f, 0.032f, 0.0158f), floatArrayOf(0.015f, 0.047f, 0.016f))
+ val up = RestPose.FaceUp
+ val down = RestPose.FaceDown
+ assertTrue(up.rotatedBounds(screen).center[1] > up.rotatedBounds(pocket).center[1], "face up")
+ assertTrue(down.rotatedBounds(screen).center[1] < down.rotatedBounds(pocket).center[1], "face down")
+ }
+
+ private fun placed(pose: RestPose): GlbBounds {
+ val rotated = pose.rotatedBounds(pocket)
+ val offset = pose.offset(pocket)
+ return GlbBounds(
+ FloatArray(3) { rotated.min[it] + offset[it] },
+ FloatArray(3) { rotated.max[it] + offset[it] },
+ )
+ }
+
+ private fun assertNear(expected: Float, actual: Float, what: String) =
+ assertTrue(abs(expected - actual) < 1e-5f, "$what: expected $expected, was $actual")
+}
diff --git a/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/component/SceneViewerPlatform.ios.kt b/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/component/SceneViewerPlatform.ios.kt
new file mode 100644
index 00000000000..a215eb830b4
--- /dev/null
+++ b/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/component/SceneViewerPlatform.ios.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.component
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import org.meshtastic.feature.node.model.GlbBounds
+
+@Composable
+internal actual fun DeviceModelScene(
+ glb: ByteArray,
+ bounds: GlbBounds,
+ onError: () -> Unit,
+ modifier: Modifier,
+ visible: Boolean,
+) {
+ if (visible) SceneViewerModelScene(glb, bounds, onError, modifier)
+}
diff --git a/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/component/SceneViewerPlatform.jvm.kt b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/component/SceneViewerPlatform.jvm.kt
new file mode 100644
index 00000000000..a215eb830b4
--- /dev/null
+++ b/feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/component/SceneViewerPlatform.jvm.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.component
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import org.meshtastic.feature.node.model.GlbBounds
+
+@Composable
+internal actual fun DeviceModelScene(
+ glb: ByteArray,
+ bounds: GlbBounds,
+ onError: () -> Unit,
+ modifier: Modifier,
+ visible: Boolean,
+) {
+ if (visible) SceneViewerModelScene(glb, bounds, onError, modifier)
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c7b97034e0b..680cd0840ca 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -101,6 +101,7 @@ markdownRenderer = "0.46.0"
okio = "3.18.2"
uri-kmp = "0.0.21"
spotless = "8.10.4"
+sceneview = "4.56.0"
vico = "3.3.1"
kable = "0.45.0"
mqttastic = "0.9.0"