diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 74245d1cd8a..3b969f7da73 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1914,6 +1914,7 @@ site_planner_section_receiver site_planner_section_simulation site_planner_section_transmitter site_planner_subtitle +site_planner_terrain_credit site_planner_tx_power_watts site_planner_use_current_location site_planner_use_map_center diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/SitePlannerSlot.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/SitePlannerSlot.kt index 6a04879a5e0..4e151f3e942 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/SitePlannerSlot.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/component/SitePlannerSlot.kt @@ -17,16 +17,20 @@ package org.meshtastic.app.map.component import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel import org.maplibre.spatialk.geojson.Position import org.meshtastic.app.map.MapViewModel +import org.meshtastic.feature.coverage.RegionTerrain import org.meshtastic.feature.coverage.rememberCoverageEstimate import org.meshtastic.feature.map.SharedMapViewModel import org.meshtastic.feature.map.component.SitePlannerHost import org.meshtastic.feature.map.component.toSitePlannerParams import org.meshtastic.feature.map.maplibre.SitePlannerSession +import org.meshtastic.feature.map.maplibre.terrain.OfflineTerrainRepository /** * Runs the Site Planner for the F-Droid map. The estimate becomes a GeoJSON map layer and the map moves to the @@ -48,9 +52,15 @@ fun SitePlannerSlot(session: SitePlannerSession) { // A deep link names the node to plan for; a toolbar launch plans for whatever we are connected to. val subject = session.nodeNum?.let { num -> nodes.firstOrNull { it.num == num } } ?: ourNode + val terrain = OfflineTerrainRepository.default + LaunchedEffect(terrain) { terrain.refresh() } + val terrainRegion by terrain.region.collectAsStateWithLifecycle() + val offlineTerrain = + remember(terrainRegion) { listOfNotNull(terrainRegion?.let { RegionTerrain(terrain.tileStore, it.bounds) }) } + SitePlannerHost( initialParams = subject.toSitePlannerParams(channelSet), - estimate = rememberCoverageEstimate(), + estimate = rememberCoverageEstimate(offlineTerrain), onDismiss = session.onDismiss, onImport = { name, geoJson, latitude, longitude -> mapViewModel.addGeoJsonLayer(name, geoJson) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index 9d365f206fc..809374d2b55 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -193,6 +193,7 @@ import org.meshtastic.core.ui.util.formatAgo import org.meshtastic.core.ui.util.formatPositionTime import org.meshtastic.core.ui.util.rememberLocationPermissionState import org.meshtastic.core.ui.util.showToast +import org.meshtastic.feature.coverage.RegionTerrain import org.meshtastic.feature.coverage.rememberCoverageEstimate import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState import org.meshtastic.feature.map.MapBounds @@ -225,6 +226,7 @@ import org.meshtastic.feature.map.layers.MapLayerItem import org.meshtastic.feature.map.layers.opacityOf import org.meshtastic.feature.map.layers.toPickedMapFile import org.meshtastic.feature.map.mergeStationaryRuns +import org.meshtastic.feature.map.terrain.GeoBounds import org.meshtastic.feature.map.terrain.MapterhornEndpoints import org.meshtastic.feature.map.tiles.mapAttributionText import org.meshtastic.feature.map.tracerouteNodeSelection @@ -1027,12 +1029,14 @@ fun MapView( // alone: an on-disk-only attribution is invisible to anyone who never opens the offline-region manager // sheet, which is exactly the gap this mirrors from the sibling iOS app's own terrain layer. The offline // terrain layer's own credit (Mapterhorn) is folded in the same way, whenever hillshade or contours are - // actually rendering. + // actually rendering, or a coverage estimate built from that terrain is shown. val attributionCoveringRegion = cameraPositionState.projection?.visibleRegion?.latLngBounds?.let { mapViewModel.offlineRegionCovering(it) } val offlineRegionActive = offlineOverlayEnabled && attributionCoveringRegion != null - val terrainActive = + val terrainDrawn = attributionCoveringRegion?.hasTerrain == true && (terrainHillshadeEnabled || terrainContoursEnabled) + val coverageShown = mapLayers.any { it.layerType == LayerType.COVERAGE && it.isVisible } + val terrainActive = terrainDrawn || coverageShown val attributionText = remember(currentRasterBasemap, enabledOverlayIds, offlineRegionActive, terrainActive) { val baseAttribution = @@ -1234,9 +1238,21 @@ fun MapView( // Route launches retain the selected node; manual map launches continue following our connected node. val onUseNodeLocation: (() -> Pair)? = launch.nodeLocation(ourNodeInfo)?.let { location -> { location } } + val offlineRegions by mapViewModel.offlineRegions.collectAsStateWithLifecycle() + val offlineTerrain = + remember(offlineRegions) { + offlineRegions + .filter { it.hasTerrain } + .map { region -> + RegionTerrain( + store = mapViewModel.terrainStoreForRegion(region.id), + bounds = GeoBounds(region.southLat, region.westLon, region.northLat, region.eastLon), + ) + } + } SitePlannerHost( initialParams = launch.initialParams, - estimate = rememberCoverageEstimate(), + estimate = rememberCoverageEstimate(offlineTerrain), onDismiss = { sitePlannerLaunch = null }, onImport = { name, geoJson, latitude, longitude -> mapViewModel.addGeoJsonLayer(name, geoJson) diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 25600364b9f..cf6eded0fd8 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1980,6 +1980,7 @@ Simulation Options Site / Transmitter Estimate RF coverage for a transmitter + Terrain data © Mapterhorn TX power (W) Use current location Use map center diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt index 040d8cbb5a8..b583b7db737 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt @@ -17,17 +17,21 @@ package org.meshtastic.desktop.map import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.maplibre.spatialk.geojson.Position +import org.meshtastic.feature.coverage.RegionTerrain import org.meshtastic.feature.coverage.rememberCoverageEstimate import org.meshtastic.feature.map.SharedMapViewModel import org.meshtastic.feature.map.component.SitePlannerHost import org.meshtastic.feature.map.component.toSitePlannerParams import org.meshtastic.feature.map.layers.MapLayersManager import org.meshtastic.feature.map.maplibre.SitePlannerSession +import org.meshtastic.feature.map.maplibre.terrain.OfflineTerrainRepository /** Runs the Site Planner for the desktop map; the estimate becomes a GeoJSON map layer and the map moves to it. */ @Composable @@ -42,9 +46,15 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) { // A deep link names the node to plan for; a toolbar launch plans for whatever we are connected to. val subject = session.nodeNum?.let { num -> nodes.firstOrNull { it.num == num } } ?: ourNode + val terrain = OfflineTerrainRepository.default + LaunchedEffect(terrain) { terrain.refresh() } + val terrainRegion by terrain.region.collectAsStateWithLifecycle() + val offlineTerrain = + remember(terrainRegion) { listOfNotNull(terrainRegion?.let { RegionTerrain(terrain.tileStore, it.bounds) }) } + SitePlannerHost( initialParams = subject.toSitePlannerParams(channelSet), - estimate = rememberCoverageEstimate(), + estimate = rememberCoverageEstimate(offlineTerrain), onDismiss = session.onDismiss, onImport = { name, geoJson, latitude, longitude -> layersManager.addGeoJsonLayer(name, geoJson) diff --git a/feature/coverage/build.gradle.kts b/feature/coverage/build.gradle.kts index 40bf9aa44cb..3e58accffba 100644 --- a/feature/coverage/build.gradle.kts +++ b/feature/coverage/build.gradle.kts @@ -34,9 +34,9 @@ kotlin { implementation(libs.kp1812) implementation(libs.kotlinx.coroutines.core) implementation(projects.feature.map) - implementation(projects.feature.mapTerrain) + // api: the estimate's public signatures take a TerrainTileStore. + api(projects.feature.mapTerrain) implementation(projects.core.common) - implementation(projects.core.di) implementation(libs.okio) implementation(libs.ktor.client.core) } @@ -44,7 +44,11 @@ kotlin { jvmMain.dependencies { implementation(libs.ktor.client.java) } androidMain.dependencies { implementation(libs.ktor.client.okhttp) } - commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) } + commonTest.dependencies { + implementation(libs.kotlinx.coroutines.test) + implementation(libs.ktor.client.mock) + implementation(libs.okio.fakefilesystem) + } jvmTest.dependencies { implementation(libs.kotlinx.coroutines.test) } } } diff --git a/feature/coverage/src/androidMain/kotlin/org/meshtastic/feature/coverage/CoverageCompute.android.kt b/feature/coverage/src/androidMain/kotlin/org/meshtastic/feature/coverage/CoverageCompute.android.kt new file mode 100644 index 00000000000..1be039e7a24 --- /dev/null +++ b/feature/coverage/src/androidMain/kotlin/org/meshtastic/feature/coverage/CoverageCompute.android.kt @@ -0,0 +1,19 @@ +/* + * 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.coverage + +internal actual fun processorCount(): Int = Runtime.getRuntime().availableProcessors() diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageCompute.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageCompute.kt new file mode 100644 index 00000000000..ff1cb2b2cb1 --- /dev/null +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageCompute.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.coverage + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +/** + * Where a coverage estimate's CPU work runs: [Dispatchers.Default] with one worker held back. The mesh service shares + * Default, and a sweep queues hundreds of radials, so an unlimited one would leave incoming radio data waiting on it. + */ +@Suppress("InjectDispatcher") // the sweep and tile decode are built outside DI +internal val coverageComputeDispatcher: CoroutineDispatcher by lazy { + Dispatchers.Default.limitedParallelism(maxOf(1, processorCount() - 1)) +} + +/** CPU cores available to the process; common code has no API for it. */ +internal expect fun processorCount(): Int diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt index e8dffe8b5b2..0a43c2bb5b3 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageContours.kt @@ -38,21 +38,21 @@ internal fun CoverageGrid.bands(ranges: List>): // Greedily merge horizontal runs of in-band cells into rectangles: far fewer polygons than // one per cell, and the map renders a handful of rings rather than thousands. val taken = BooleanArray(width * height) - for (y in 0 until height - 1) { + for (y in 0 until height) { var x = 0 - while (x < width - 1) { + while (x < width) { if (taken[y * width + x] || !inBand(x, y, range)) { x++ continue } var runEnd = x - while (runEnd + 1 < width - 1 && !taken[y * width + runEnd + 1] && inBand(runEnd + 1, y, range)) { + while (runEnd + 1 < width && !taken[y * width + runEnd + 1] && inBand(runEnd + 1, y, range)) { runEnd++ } // Extend downwards while the whole run stays in band. var runBottom = y while ( - runBottom + 1 < height - 1 && + runBottom + 1 < height && (x..runEnd).all { inBand(it, runBottom + 1, range) } && (x..runEnd).none { taken[(runBottom + 1) * width + it] } ) { @@ -63,11 +63,11 @@ internal fun CoverageGrid.bands(ranges: List>): } rings.add( listOf( - lonAt(x.toDouble()) to latAt(y.toDouble()), - lonAt(runEnd + 1.0) to latAt(y.toDouble()), - lonAt(runEnd + 1.0) to latAt(runBottom + 1.0), - lonAt(x.toDouble()) to latAt(runBottom + 1.0), - lonAt(x.toDouble()) to latAt(y.toDouble()), + lonAt(x - HALF_CELL) to latAt(y - HALF_CELL), + lonAt(runEnd + HALF_CELL) to latAt(y - HALF_CELL), + lonAt(runEnd + HALF_CELL) to latAt(runBottom + HALF_CELL), + lonAt(x - HALF_CELL) to latAt(runBottom + HALF_CELL), + lonAt(x - HALF_CELL) to latAt(y - HALF_CELL), ), ) x = runEnd + 1 @@ -142,6 +142,7 @@ $features """ } +private const val HALF_CELL = 0.5 private const val DEFAULT_BANDS = 6 private const val EMPTY_FEATURE_COLLECTION = """{"type":"FeatureCollection","properties":{"generator":"meshtastic-kp1812"},"features":[]}""" diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt index a84118ab97a..13edd62ccc4 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageGrid.kt @@ -36,6 +36,8 @@ class CoverageGrid( val dbm: DoubleArray, ) { init { + // lonAt and latAt divide by width - 1 and height - 1. + require(width >= 2 && height >= 2) { "grid must be at least 2x2, got $width x $height" } require(dbm.size == width * height) { "dbm must be width*height, got ${dbm.size} for $width x $height" } } diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt index 9c9c6b32b59..c17438d9c39 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt @@ -17,18 +17,21 @@ package org.meshtastic.feature.coverage import io.ktor.client.HttpClient +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.meshtastic.feature.map.terrain.ElevationTile import org.meshtastic.feature.map.terrain.GeoBounds import org.meshtastic.feature.map.terrain.MapterhornEndpoints import org.meshtastic.feature.map.terrain.TerrainTileMath import org.meshtastic.feature.map.terrain.TerrainTileStore +import org.meshtastic.feature.map.terrain.TileIndex import org.meshtastic.feature.map.terrain.decodeTerrariumTile import kotlin.concurrent.Volatile import kotlin.math.PI @@ -56,16 +59,22 @@ class MapterhornElevation( store: TerrainTileStore? = null, zoom: Int = DEFAULT_ZOOM, http: HttpClient? = null, + /** Downloaded map regions' terrain, used when the network can't supply a tile. */ + offline: List = emptyList(), ) : ElevationSource, AutoCloseable { private val zoom = zoomFitting(zoom.coerceAtMost(MapterhornEndpoints.TILES_MAX_ZOOM), bounds) // Shared, and never closed here: a caller's client is theirs, and the shared one outlives us. - private val tiles = MapterhornTiles(http ?: SharedTerrain.http, store) + private val tiles = MapterhornTiles(http ?: SharedTerrain.http, store, offline) - @Suppress("InjectDispatcher") // tile decoding is CPU work, and callers build this outside DI - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val scope = CoroutineScope(SupervisorJob() + coverageComputeDispatcher) + + // Tiles cut from a coarser region tile. Good enough for this estimate, but the cache is shared, so they are + // dropped by forgetStandIns() rather than served to a later estimate that could download the real one. + private val standInLock = Mutex() + private val standIns = mutableSetOf() // Decoded terrain outlives this instance - see TerrainCache. Memoised after the first suspend // acquisition; two callers racing here both get the same cache, so the race is harmless. @@ -138,14 +147,53 @@ class MapterhornElevation( private suspend fun awaitTile(key: Long, x: Int, y: Int): ElevationTile? = cache().getOrFetch(key) { scope.async { decode(x, y) } } + // Offline, or the endpoint failed: a region downloaded to a shallower zoom may still hold this ground. + @Suppress("TooGenericExceptionCaught") // whatever the fetch threw is rethrown when no region holds the ground + private suspend fun decode(x: Int, y: Int): ElevationTile? = + try { + fetchAndDecode(x, y) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + fromCoarserRegionTile(x, y) ?: throw e + } + // Null means the endpoint has no tile there, so it must never stand in for a failed decode: a corrupt cached // tile gets one fresh download, and bytes that still don't decode fail the estimate. - private suspend fun decode(x: Int, y: Int): ElevationTile? { + private suspend fun fetchAndDecode(x: Int, y: Int): ElevationTile? { val cached = tiles.bytes(zoom, x, y) ?: return null return runCatching { decodeTerrariumTile(cached) }.getOrNull() ?: tiles.bytes(zoom, x, y, fresh = true)?.let { decodeTerrariumTile(it) } } + /** + * The nearest ancestor a downloaded region holds, cut down to this tile's quarter of it at each level. Stops at + * [MIN_ZOOM]: every region holds the planet's lowest zooms, so without a floor any site anywhere would estimate + * over terrain a few pixels wide instead of failing. + */ + private suspend fun fromCoarserRegionTile(x: Int, y: Int): ElevationTile? { + for (levels in 1..(zoom - MIN_ZOOM)) { + val parent = + tiles.offlineBytes(TileIndex(zoom - levels, x shr levels, y shr levels), TileIndex(zoom, x, y))?.let { + runCatching { decodeTerrariumTile(it) }.getOrNull() + } + if (parent != null) { + standInLock.withLock { standIns += tileKey(x, y) } + return parent.descendant(levels, x, y) + } + } + return null + } + + /** + * Drop every stand-in tile from the shared cache. Call after [close], once no decode can still complete and add + * one. + */ + suspend fun forgetStandIns() { + val keys = standInLock.withLock { standIns.toList().also { standIns.clear() } } + cache?.forget(keys) + } + override fun close() = scope.cancel() /** One tile a prefetch still has to fetch. */ @@ -169,6 +217,21 @@ class MapterhornElevation( /** The cache is keyed by endpoint, so a second terrain source wouldn't collide with this one. */ private const val ARCHIVE = "tiles.mapterhorn.com" +/** + * The part of this tile that covers its descendant [levels] zooms down at tile [x], [y]. + * + * Not resampled: a sampler scales its fractional position by the tile's own size, so a 128-pixel quarter reads the same + * ground as a 512-pixel tile would, at the coarser spacing it actually has. + */ +internal fun ElevationTile.descendant(levels: Int, x: Int, y: Int): ElevationTile { + val n = 1 shl levels + val w = (width / n).coerceAtLeast(1) + val h = (height / n).coerceAtLeast(1) + val left = (x and (n - 1)) * width / n + val top = (y and (n - 1)) * height / n + return ElevationTile(w, h, FloatArray(w * h) { i -> elevationAt(left + i % w, top + i / w) }) +} + /** Pack a tile's coordinates into one cache key. */ private fun tileKey(x: Int, y: Int): Long = (x.toLong() shl TILE_KEY_SHIFT) or y.toLong() diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt index 3960c06b9d5..92e8b7c8ba2 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornTiles.kt @@ -40,26 +40,42 @@ import org.meshtastic.feature.map.terrain.TileIndex * many we choose to have in flight. * * Anything fetched is written back to [store] when there is one, so the next run (or the next launch) pays nothing for - * the same ground. + * the same ground. [offline] are downloaded map regions' terrain, read after [store] and before the network but never + * written: they belong to their regions. */ internal class MapterhornTiles( private val http: HttpClient, private val store: TerrainTileStore?, + private val offline: List = emptyList(), concurrency: Int = DEFAULT_CONCURRENCY, ) { private val gate = Semaphore(concurrency) /** * Raw Terrarium WebP for one tile, or null where the endpoint has no data (ocean, out of range). [fresh] skips the - * stored copy and overwrites it, for a stored tile that didn't decode. + * stored copies and overwrites the cached one, for a stored tile that didn't decode. */ suspend fun bytes(zoom: Int, x: Int, y: Int, fresh: Boolean = false): ByteArray? { val tile = TileIndex(zoom, x, y) val source = sourceFor(zoom) - val stored = if (fresh) null else readLocal(source, tile) + val stored = if (fresh) null else readLocal(source, tile) ?: offlineBytes(tile, tile) return stored ?: download(zoom, x, y)?.also { writeLocal(source, tile, it) } } + /** + * [tile] from a downloaded region whose bounds reach [needed], or null when none does. A region keeps every + * low-zoom tile it touches, and one of those spans far more ground than the region, so a tile is only taken from a + * region for ground that region was downloaded for. + */ + suspend fun offlineBytes(tile: TileIndex, needed: TileIndex): ByteArray? { + val holders = offline.filter { it.bounds.intersects(needed.bounds()) } + if (holders.isEmpty()) return null + val source = sourceFor(tile.zoom) + return withContext(ioDispatcher) { + holders.firstNotNullOfOrNull { region -> runCatching { region.store.readTile(source, tile) }.getOrNull() } + } + } + /** * Only 404 means "no tile here" (ocean, or past the endpoint's deepest zoom), and only 404 is allowed to return * null, because null is cached as flat ground for the rest of the process. A throttled or failed request that diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt index a0d9dc90be9..52657bb37a3 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/PolarCoverage.kt @@ -16,7 +16,7 @@ */ package org.meshtastic.feature.coverage -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -67,6 +67,7 @@ suspend fun LocalCoverage.sweepPolar( radials: Int = DEFAULT_RADIALS, rings: Int = DEFAULT_RINGS, profileStepKm: Double = DEFAULT_PROFILE_STEP_KM, + dispatcher: CoroutineDispatcher = coverageComputeDispatcher, ): PolarCoverage { require(radials >= MIN_RADIALS) { "radials must be >= $MIN_RADIALS, got $radials" } require(rings >= MIN_RINGS) { "rings must be >= $MIN_RINGS, got $rings" } @@ -84,11 +85,10 @@ suspend fun LocalCoverage.sweepPolar( // scales with cores. Each writes its own disjoint slice of `out`, so there is nothing to guard. // The dispatcher is explicit: a caller on runBlocking or the main thread would otherwise hand // every radial to that one confined thread and the sweep would stay serial. - @Suppress("InjectDispatcher") coroutineScope { (0 until radials) .map { b -> - async(Dispatchers.Default) { + async(dispatcher) { val bearing = FULL_CIRCLE * b / radials // One walk per bearing: every ring on this radial is a prefix of this profile. val lats = DoubleArray(profilePoints) diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/RegionTerrain.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/RegionTerrain.kt new file mode 100644 index 00000000000..e58e316d95d --- /dev/null +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/RegionTerrain.kt @@ -0,0 +1,40 @@ +/* + * 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.coverage + +import org.meshtastic.feature.map.terrain.GeoBounds +import org.meshtastic.feature.map.terrain.TerrainTileMath +import org.meshtastic.feature.map.terrain.TerrainTileStore +import org.meshtastic.feature.map.terrain.TileIndex + +/** A downloaded map region's terrain: its tiles, and the ground it was downloaded for. */ +class RegionTerrain(val store: TerrainTileStore, val bounds: GeoBounds) + +/** The ground this tile covers. */ +internal fun TileIndex.bounds(): GeoBounds { + val northwest = TerrainTileMath.lonLatAt(zoom, x.toDouble(), y.toDouble()) + val southeast = TerrainTileMath.lonLatAt(zoom, x + 1.0, y + 1.0) + return GeoBounds( + south = southeast.latitude, + west = northwest.longitude, + north = northwest.latitude, + east = southeast.longitude, + ) +} + +internal fun GeoBounds.intersects(other: GeoBounds): Boolean = + west < other.east && other.west < east && south < other.north && other.south < north diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt index ab35acbec46..1051681022a 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt @@ -19,11 +19,10 @@ package org.meshtastic.feature.coverage import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import okio.FileSystem import okio.Path -import org.koin.compose.koinInject -import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.feature.map.component.SitePlannerParams import org.meshtastic.feature.map.terrain.TerrainTileStore import kotlin.math.log10 @@ -32,33 +31,42 @@ import kotlin.math.log10 * Computes a Site Planner estimate on this device: Mapterhorn terrain for the coverage disc, cached under [store], then * the ITU-R P.1812 sweep, returned as the styled GeoJSON iso-bands a map layer takes. * + * Terrain the network can't supply is read from [offlineTerrain], the downloaded map regions, so an estimate inside a + * region works without a connection. + * * Throws when terrain can't be fetched or a value is outside what P.1812 accepts. Cancellation propagates. */ suspend fun estimateCoverageGeoJson( params: SitePlannerParams, store: TerrainTileStore, dispatcher: CoroutineDispatcher, + offlineTerrain: List = emptyList(), ): String = withContext(dispatcher) { val site = params.toSite() // The bounds let the zoom drop for a wide disc, so its tiles fit the decoded cache. - MapterhornElevation(bounds = site.coverageBounds(), store = store).use { source -> + val source = MapterhornElevation(bounds = site.coverageBounds(), store = store, offline = offlineTerrain) + try { source.prefetch(site) LocalCoverage(source).sweepGrid(site).toGeoJson(params.toCoverageStyle()) + } finally { + source.close() + withContext(NonCancellable) { source.forgetStandIns() } } } -/** [estimateCoverageGeoJson] bound to the app's compute dispatcher and its coverage terrain cache. */ +/** + * [estimateCoverageGeoJson] bound to the coverage compute dispatcher and its coverage terrain cache, falling back to + * [offlineTerrain] when offline. + */ @Composable -fun rememberCoverageEstimate(): suspend (SitePlannerParams) -> String { - val dispatchers: CoroutineDispatchers = koinInject() - return remember(dispatchers) { +fun rememberCoverageEstimate(offlineTerrain: List = emptyList()): suspend (SitePlannerParams) -> String = + remember(offlineTerrain) { val store = TerrainTileStore(coverageFileSystem(), coverageTerrainDirectory()) val estimate: suspend (SitePlannerParams) -> String = { params -> - estimateCoverageGeoJson(params, store, dispatchers.default) + estimateCoverageGeoJson(params, store, coverageComputeDispatcher, offlineTerrain) } estimate } -} /** * Where coverage terrain is cached between launches. It's kept apart from downloaded offline map regions so a coverage @@ -83,14 +91,20 @@ internal fun SitePlannerParams.toSite(): Site = Site( radiusKm = maxRangeKm, ) -/** The form's Display section: palette, the dBm range the ramp spans, and overlay transparency. */ +/** + * The form's Display section: palette and overlay transparency. The ramp starts at the receiver's sensitivity, so the + * outermost band is where a node can hear the site at all. + */ internal fun SitePlannerParams.toCoverageStyle(): CoverageStyle = CoverageStyle.fromTransparency( palette = colorScale, - minDbm = minDbm, - maxDbm = maxDbm, + minDbm = rxSensitivityDbm, + maxDbm = rxSensitivityDbm + BAND_SPAN_DB, transparencyPercent = overlayTransparency, ) +/** The dB the six bands span above the sensitivity floor. */ +private const val BAND_SPAN_DB = 50.0 + internal fun wattsToDbm(watts: Double): Double { require(watts > 0.0) { "transmit power must be positive, got $watts W" } return DBM_PER_DECADE * log10(watts * MILLIWATTS_PER_WATT) diff --git a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt index 98cbe8883b2..db1ce05cd02 100644 --- a/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt +++ b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/TerrainCache.kt @@ -98,6 +98,13 @@ internal class TerrainCache(private val capacity: Int = DEFAULT_CAPACITY) { } } + /** Drop [keys], so the next reader decodes them afresh. */ + suspend fun forget(keys: Collection) = lock.withLock { + if (keys.isEmpty()) return@withLock + resolved = resolved - keys.toSet() + keys.forEach { inFlight.remove(it) } + } + /** Record tiles fetched in bulk, as a prefetch does. */ suspend fun publish(entries: List>) = lock.withLock { val merged = LinkedHashMap(resolved) diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.kt index b93bbb5e6a8..d2587a45959 100644 --- a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.kt +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.kt @@ -17,6 +17,8 @@ package org.meshtastic.feature.coverage import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue class CoverageContoursTest { @@ -48,4 +50,39 @@ class CoverageContoursTest { assertTrue("\"name\": \"Tom's \\\"Hill\\\" \\\\ relay\"" in geoJson, geoJson.take(300)) } + + @Test + fun mergedBandExtendsHalfACellBeyondEverySample() { + val grid = + CoverageGrid( + site = Site("Test", 10.0, 26.0, 915.0, 30.0, -130.0), + width = 2, + height = 2, + north = 12.0, + south = 8.0, + east = 32.0, + west = 20.0, + dbm = DoubleArray(4) { -90.0 }, + ) + + val ring = grid.bands(listOf(-130.0..-80.0)).single().rings.single() + + assertEquals(listOf(14.0 to 14.0, 38.0 to 14.0, 38.0 to 6.0, 14.0 to 6.0, 14.0 to 14.0), ring) + } + + @Test + fun aGridOneSampleWideIsRejected() { + assertFailsWith { + CoverageGrid( + site = Site("Test", 10.0, 26.0, 915.0, 30.0, -130.0), + width = 1, + height = 3, + north = 12.0, + south = 8.0, + east = 26.0, + west = 26.0, + dbm = DoubleArray(3) { -90.0 }, + ) + } + } } diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/OfflineTerrainTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/OfflineTerrainTest.kt new file mode 100644 index 00000000000..731c248ba84 --- /dev/null +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/OfflineTerrainTest.kt @@ -0,0 +1,104 @@ +/* + * 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.coverage + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respondError +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.test.runTest +import okio.Path.Companion.toPath +import okio.fakefilesystem.FakeFileSystem +import org.meshtastic.feature.map.terrain.ElevationTile +import org.meshtastic.feature.map.terrain.TerrainSource +import org.meshtastic.feature.map.terrain.TerrainTileStore +import org.meshtastic.feature.map.terrain.TileIndex +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class OfflineTerrainTest { + + private var requests = 0 + private val notFound = + HttpClient( + MockEngine { + requests++ + respondError(HttpStatusCode.NotFound) + }, + ) + private val store = TerrainTileStore(FakeFileSystem(), "/region".toPath()) + + // A region exactly one z12 tile in size. + private val tile = TileIndex(12, 655, 1430) + private val region = RegionTerrain(store, tile.bounds()) + + @Test + fun aRegionTileIsReadWithoutTheNetwork() = runTest { + val bytes = byteArrayOf(1, 2, 3) + store.writeTile(TerrainSource.GLOBAL, tile, bytes) + + val read = MapterhornTiles(notFound, store = null, offline = listOf(region)).bytes(12, 655, 1430) + + assertContentEquals(bytes, read) + assertEquals(0, requests) + } + + @Test + fun aTileNoRegionHoldsStillGoesToTheNetwork() = runTest { + val read = MapterhornTiles(notFound, store = null, offline = listOf(region)).bytes(12, 655, 1430) + + assertNull(read) + assertEquals(1, requests) + } + + @Test + fun aRegionsCoarseTileIsOnlyUsedForGroundTheRegionCovers() = runTest { + val parent = TileIndex(6, 655 shr 6, 1430 shr 6) + store.writeTile(TerrainSource.GLOBAL, parent, byteArrayOf(9)) + val tiles = MapterhornTiles(notFound, store = null, offline = listOf(region)) + + val inside = tiles.offlineBytes(parent, needed = tile) + // Same z6 parent, but a z12 tile across it from the region. + val outside = tiles.offlineBytes(parent, needed = TileIndex(12, (655 shr 6 shl 6) + 63, 1430)) + + assertContentEquals(byteArrayOf(9), inside) + assertNull(outside) + } + + @Test + fun aDescendantIsItsQuarterOfTheParent() { + val parent = ElevationTile(4, 4, FloatArray(16) { it.toFloat() }) + + // Tile (3, 2) one zoom down is the parent's top-right quarter. + val child = parent.descendant(levels = 1, x = 3, y = 2) + + assertEquals(2, child.width) + assertEquals(2, child.height) + assertContentEquals(floatArrayOf(2f, 3f, 6f, 7f), child.elevations) + } + + @Test + fun aDescendantFinerThanThePixelsIsOnePixel() { + val parent = ElevationTile(2, 2, floatArrayOf(10f, 20f, 30f, 40f)) + + val child = parent.descendant(levels = 3, x = 7, y = 0) + + assertContentEquals(floatArrayOf(20f), child.elevations) + } +} diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt index f569c4d45cb..74afd0a8a7f 100644 --- a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/PolarCoverageTest.kt @@ -16,7 +16,11 @@ */ package org.meshtastic.feature.coverage +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest +import kotlin.coroutines.CoroutineContext import kotlin.math.abs import kotlin.test.Test import kotlin.test.assertEquals @@ -53,6 +57,23 @@ class PolarCoverageTest { assertTrue(distances.last() <= site().radiusKm + 1e-9, "outermost ring ${distances.last()} exceeds the radius") } + @Test + fun radialsRunOnTheGivenDispatcher() = runTest { + val inner = StandardTestDispatcher(testScheduler) + var dispatched = 0 + val counting = + object : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { + dispatched++ + inner.dispatch(context, block) + } + } + + LocalCoverage(flat).sweepPolar(site(), radials = 8, rings = 4, dispatcher = counting) + + assertTrue(dispatched >= 8, "only $dispatched dispatches for 8 radials") + } + @Test fun overFlatGroundStrengthDependsOnRangeAlone() = runTest { // No terrain means no bearing can differ from any other; anything that does is the sweep's diff --git a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimateTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimateTest.kt new file mode 100644 index 00000000000..1fdc59aba42 --- /dev/null +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimateTest.kt @@ -0,0 +1,34 @@ +/* + * 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.coverage + +import org.meshtastic.feature.map.component.SitePlannerParams +import kotlin.test.Test +import kotlin.test.assertEquals + +class SitePlannerEstimateTest { + + @Test + fun bandsStartAtTheReceiverSensitivity() { + val style = + SitePlannerParams(name = "Test", latitude = 0.0, longitude = 0.0, rxSensitivityDbm = -139.0) + .toCoverageStyle() + + assertEquals(-139.0, style.minDbm) + assertEquals(-89.0, style.maxDbm) + } +} diff --git a/feature/coverage/src/iosMain/kotlin/org/meshtastic/feature/coverage/CoverageCompute.ios.kt b/feature/coverage/src/iosMain/kotlin/org/meshtastic/feature/coverage/CoverageCompute.ios.kt new file mode 100644 index 00000000000..9b789e81324 --- /dev/null +++ b/feature/coverage/src/iosMain/kotlin/org/meshtastic/feature/coverage/CoverageCompute.ios.kt @@ -0,0 +1,21 @@ +/* + * 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.coverage + +import platform.Foundation.NSProcessInfo + +internal actual fun processorCount(): Int = NSProcessInfo.processInfo.activeProcessorCount.toInt() diff --git a/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageCompute.jvm.kt b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageCompute.jvm.kt new file mode 100644 index 00000000000..1be039e7a24 --- /dev/null +++ b/feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageCompute.jvm.kt @@ -0,0 +1,19 @@ +/* + * 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.coverage + +internal actual fun processorCount(): Int = Runtime.getRuntime().availableProcessors() diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt index 8a087704f87..c9c6312a46a 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerParams.kt @@ -29,8 +29,6 @@ data class SitePlannerParams( val rxSensitivityDbm: Double = DEFAULT_RX_SENSITIVITY_DBM, val rxHeightMeters: Double = DEFAULT_RX_HEIGHT_METERS, val maxRangeKm: Double = DEFAULT_MAX_RANGE_KM, - val minDbm: Double = DEFAULT_MIN_DBM, - val maxDbm: Double = DEFAULT_MAX_DBM, val overlayTransparency: Int = DEFAULT_OVERLAY_TRANSPARENCY, ) { companion object { @@ -43,8 +41,6 @@ data class SitePlannerParams( const val DEFAULT_RX_SENSITIVITY_DBM: Double = -130.0 const val DEFAULT_RX_HEIGHT_METERS: Double = 1.0 const val DEFAULT_MAX_RANGE_KM: Double = 30.0 - const val DEFAULT_MIN_DBM: Double = -130.0 - const val DEFAULT_MAX_DBM: Double = -80.0 const val DEFAULT_OVERLAY_TRANSPARENCY: Int = 50 // ITU-R P.1812 is defined from 30 MHz to 6 GHz; the model rejects anything outside it. diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt index dab7ee1f315..443bcff7c98 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerSheet.kt @@ -97,6 +97,7 @@ import org.meshtastic.core.resources.site_planner_section_receiver import org.meshtastic.core.resources.site_planner_section_simulation import org.meshtastic.core.resources.site_planner_section_transmitter import org.meshtastic.core.resources.site_planner_subtitle +import org.meshtastic.core.resources.site_planner_terrain_credit import org.meshtastic.core.resources.site_planner_tx_power_watts import org.meshtastic.core.resources.site_planner_use_current_location import org.meshtastic.core.resources.site_planner_use_map_center @@ -264,6 +265,11 @@ private fun SiteFormContent( ) { Text(stringResource(Res.string.site_planner_estimate)) } + Text( + text = stringResource(Res.string.site_planner_terrain_credit), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } @@ -489,7 +495,5 @@ private fun buildSubmitParams(state: SiteFormState, initial: SitePlannerParams): rxSensitivityDbm = NumberFormatter.parseDecimalOrNull(state.rxSensitivity) ?: initial.rxSensitivityDbm, rxHeightMeters = NumberFormatter.parseDecimalOrNull(state.rxHeight) ?: initial.rxHeightMeters, maxRangeKm = NumberFormatter.parseDecimalOrNull(state.maxRange) ?: initial.maxRangeKm, - minDbm = initial.minDbm, - maxDbm = initial.maxDbm, overlayTransparency = initial.overlayTransparency, )