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"