From 3740a93029857acd44769f16baa3832cd8e44814 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:11:07 -0700 Subject: [PATCH 01/11] fix: center coverage bands on computed grid samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Band rectangles now extend half a cell beyond their outermost sample centers, and iteration and merging include every row and column. A regression test checks that a uniform 2×2 grid merges into one closed rectangle with the expected half-cell bounds. Fixes #7752 --- .../feature/coverage/CoverageContours.kt | 19 +++++++++--------- .../feature/coverage/CoverageContoursTest.kt | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) 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/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.kt index b93bbb5e6a8..da2862ac890 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,7 @@ package org.meshtastic.feature.coverage import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue class CoverageContoursTest { @@ -48,4 +49,23 @@ 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) + } } From 495f780c3470c0cc176f569735d0cf40e0d534ae Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:05:34 -0500 Subject: [PATCH 02/11] fix(coverage): reject grids under two samples a side `lonAt` and `latAt` divide by `width - 1` and `height - 1`, and the bands now reach every row and column, so a one-wide grid would emit non-finite coordinates. --- .../meshtastic/feature/coverage/CoverageGrid.kt | 2 ++ .../feature/coverage/CoverageContoursTest.kt | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) 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/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/CoverageContoursTest.kt index da2862ac890..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 @@ -18,6 +18,7 @@ package org.meshtastic.feature.coverage import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue class CoverageContoursTest { @@ -68,4 +69,20 @@ class CoverageContoursTest { 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 }, + ) + } + } } From 4856033acdcc04a7b3564b1aabc5b475e34026d4 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:06:02 -0500 Subject: [PATCH 03/11] fix(coverage): start the bands at the receiver sensitivity The style used fixed -130..-80 dBm that the form never changed, so LongFast lost its -139..-130 edge and a -120 dBm receiver was drawn hearing where it cannot. The dead `minDbm`/`maxDbm` params go with it. fixes #7756 --- .../feature/coverage/SitePlannerEstimate.kt | 12 +++++-- .../coverage/SitePlannerEstimateTest.kt | 33 +++++++++++++++++++ .../map/component/SitePlannerParams.kt | 4 --- .../feature/map/component/SitePlannerSheet.kt | 2 -- 4 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimateTest.kt 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..1f8d8568a2d 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 @@ -83,14 +83,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/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..965deb15fb3 --- /dev/null +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimateTest.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.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/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..ddc96dce0a5 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 @@ -489,7 +489,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, ) From 8b971b1195bb8ebd90457867577bc231d9dfe3de Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:07:12 -0500 Subject: [PATCH 04/11] fix(coverage): keep one Default worker free during an estimate The sweep queued every radial on `Dispatchers.Default` with no limit, and the mesh service shares it, so incoming radio data waited behind the estimate. Radials and tile decodes now run on a view of Default one worker short. fixes #7754 --- feature/coverage/build.gradle.kts | 1 - .../coverage/CoverageCompute.android.kt | 19 +++++++++++ .../feature/coverage/CoverageCompute.kt | 32 +++++++++++++++++++ .../feature/coverage/MapterhornElevation.kt | 4 +-- .../feature/coverage/PolarCoverage.kt | 6 ++-- .../feature/coverage/SitePlannerEstimate.kt | 17 ++++------ .../feature/coverage/PolarCoverageTest.kt | 21 ++++++++++++ .../feature/coverage/CoverageCompute.ios.kt | 21 ++++++++++++ .../feature/coverage/CoverageCompute.jvm.kt | 19 +++++++++++ 9 files changed, 122 insertions(+), 18 deletions(-) create mode 100644 feature/coverage/src/androidMain/kotlin/org/meshtastic/feature/coverage/CoverageCompute.android.kt create mode 100644 feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/CoverageCompute.kt create mode 100644 feature/coverage/src/iosMain/kotlin/org/meshtastic/feature/coverage/CoverageCompute.ios.kt create mode 100644 feature/coverage/src/jvmMain/kotlin/org/meshtastic/feature/coverage/CoverageCompute.jvm.kt diff --git a/feature/coverage/build.gradle.kts b/feature/coverage/build.gradle.kts index 40bf9aa44cb..cb5844d9fcb 100644 --- a/feature/coverage/build.gradle.kts +++ b/feature/coverage/build.gradle.kts @@ -36,7 +36,6 @@ kotlin { implementation(projects.feature.map) implementation(projects.feature.mapTerrain) implementation(projects.core.common) - implementation(projects.core.di) implementation(libs.okio) implementation(libs.ktor.client.core) } 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/MapterhornElevation.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt index 9c9c6b32b59..ff32b4f5b85 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 @@ -18,7 +18,6 @@ package org.meshtastic.feature.coverage import io.ktor.client.HttpClient import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -64,8 +63,7 @@ class MapterhornElevation( // 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) - @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) // 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. 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/SitePlannerEstimate.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt index 1f8d8568a2d..65354625b9a 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 @@ -22,8 +22,6 @@ import kotlinx.coroutines.CoroutineDispatcher 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 @@ -47,17 +45,14 @@ suspend fun estimateCoverageGeoJson( } } -/** [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. */ @Composable -fun rememberCoverageEstimate(): suspend (SitePlannerParams) -> String { - val dispatchers: CoroutineDispatchers = koinInject() - return remember(dispatchers) { - val store = TerrainTileStore(coverageFileSystem(), coverageTerrainDirectory()) - val estimate: suspend (SitePlannerParams) -> String = { params -> - estimateCoverageGeoJson(params, store, dispatchers.default) - } - estimate +fun rememberCoverageEstimate(): suspend (SitePlannerParams) -> String = remember { + val store = TerrainTileStore(coverageFileSystem(), coverageTerrainDirectory()) + val estimate: suspend (SitePlannerParams) -> String = { params -> + estimateCoverageGeoJson(params, store, coverageComputeDispatcher) } + estimate } /** 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/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() From 59bd4e012fb636ff3de06f8859d2600280aca4e0 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:11:45 -0500 Subject: [PATCH 05/11] fix(coverage): estimate from downloaded region terrain when offline The estimate read only its own cache and the network, so a site inside a downloaded region failed offline. Region terrain stores are now read after the cache and before the network, and a region downloaded below the estimate zoom stands in with its coarser parent tile. Stand-ins are dropped from the shared cache when the estimate ends, so a later online run downloads the real tile. fixes #7753 --- .../app/map/component/SitePlannerSlot.kt | 3 +- .../kotlin/org/meshtastic/app/map/MapView.kt | 7 +- .../desktop/map/DesktopSitePlannerSlot.kt | 3 +- feature/coverage/build.gradle.kts | 9 ++- .../feature/coverage/MapterhornElevation.kt | 55 ++++++++++++- .../feature/coverage/MapterhornTiles.kt | 18 ++++- .../feature/coverage/SitePlannerEstimate.kt | 33 +++++--- .../feature/coverage/TerrainCache.kt | 7 ++ .../feature/coverage/OfflineTerrainTest.kt | 80 +++++++++++++++++++ 9 files changed, 195 insertions(+), 20 deletions(-) create mode 100644 feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/OfflineTerrainTest.kt 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..c7689b20701 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 @@ -27,6 +27,7 @@ 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 @@ -50,7 +51,7 @@ fun SitePlannerSlot(session: SitePlannerSession) { SitePlannerHost( initialParams = subject.toSitePlannerParams(channelSet), - estimate = rememberCoverageEstimate(), + estimate = rememberCoverageEstimate(listOf(OfflineTerrainRepository.default.tileStore)), 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..b09ef417c75 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -1234,9 +1234,14 @@ 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 { mapViewModel.terrainStoreForRegion(it.id) } + } SitePlannerHost( initialParams = launch.initialParams, - estimate = rememberCoverageEstimate(), + estimate = rememberCoverageEstimate(offlineTerrain), onDismiss = { sitePlannerLaunch = null }, onImport = { name, geoJson, latitude, longitude -> mapViewModel.addGeoJsonLayer(name, geoJson) 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..584c518fc9f 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/map/DesktopSitePlannerSlot.kt @@ -28,6 +28,7 @@ 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 @@ -44,7 +45,7 @@ fun DesktopSitePlannerSlot(session: SitePlannerSession) { SitePlannerHost( initialParams = subject.toSitePlannerParams(channelSet), - estimate = rememberCoverageEstimate(), + estimate = rememberCoverageEstimate(listOf(OfflineTerrainRepository.default.tileStore)), 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 cb5844d9fcb..3e58accffba 100644 --- a/feature/coverage/build.gradle.kts +++ b/feature/coverage/build.gradle.kts @@ -34,7 +34,8 @@ 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(libs.okio) implementation(libs.ktor.client.core) @@ -43,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/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/MapterhornElevation.kt index ff32b4f5b85..b631804fcf2 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,12 +17,15 @@ package org.meshtastic.feature.coverage import io.ktor.client.HttpClient +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope 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 @@ -55,16 +58,23 @@ 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) 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. @Volatile private var cache: TerrainCache? = null @@ -138,12 +148,38 @@ class MapterhornElevation( // 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. + @Suppress("TooGenericExceptionCaught") // whatever the download threw is rethrown when no region holds the ground private suspend fun decode(x: Int, y: Int): ElevationTile? { - val cached = tiles.bytes(zoom, x, y) ?: return null + val cached = + try { + tiles.bytes(zoom, x, y) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Offline, or the endpoint failed. A region downloaded to a shallower zoom still holds this ground. + return fromCoarserRegionTile(x, y) ?: throw e + } ?: 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. */ + private suspend fun fromCoarserRegionTile(x: Int, y: Int): ElevationTile? { + for (levels in 1..zoom) { + val bytes = tiles.offlineBytes(zoom - levels, x shr levels, y shr levels) ?: continue + val parent = runCatching { decodeTerrariumTile(bytes) }.getOrNull() ?: continue + standInLock.withLock { standIns += tileKey(x, y) } + return parent.descendant(levels, x, y) + } + return null + } + + /** Drop every stand-in tile from the shared cache. Call once the estimate that needed them is done. */ + 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. */ @@ -167,6 +203,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..a7b042233b6 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,38 @@ 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(zoom, x, y) return stored ?: download(zoom, x, y)?.also { writeLocal(source, tile, it) } } + /** One tile from the downloaded regions alone, or null when none of them holds it. */ + suspend fun offlineBytes(zoom: Int, x: Int, y: Int): ByteArray? { + if (offline.isEmpty()) return null + val tile = TileIndex(zoom, x, y) + val source = sourceFor(zoom) + return withContext(ioDispatcher) { + offline.firstNotNullOfOrNull { region -> runCatching { region.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/SitePlannerEstimate.kt b/feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimate.kt index 65354625b9a..8d2a1549e6e 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,6 +19,7 @@ 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 @@ -30,30 +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' own stores, 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 -> - source.prefetch(site) - LocalCoverage(source).sweepGrid(site).toGeoJson(params.toCoverageStyle()) + MapterhornElevation(bounds = site.coverageBounds(), store = store, offline = offlineTerrain).use { source -> + try { + source.prefetch(site) + LocalCoverage(source).sweepGrid(site).toGeoJson(params.toCoverageStyle()) + } finally { + withContext(NonCancellable) { source.forgetStandIns() } + } } } -/** [estimateCoverageGeoJson] bound to the coverage 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 = remember { - val store = TerrainTileStore(coverageFileSystem(), coverageTerrainDirectory()) - val estimate: suspend (SitePlannerParams) -> String = { params -> - estimateCoverageGeoJson(params, store, coverageComputeDispatcher) +fun rememberCoverageEstimate(offlineTerrain: List = emptyList()): suspend (SitePlannerParams) -> String = + remember(offlineTerrain) { + val store = TerrainTileStore(coverageFileSystem(), coverageTerrainDirectory()) + val estimate: suspend (SitePlannerParams) -> String = { params -> + estimateCoverageGeoJson(params, store, coverageComputeDispatcher, offlineTerrain) + } + estimate } - estimate -} /** * Where coverage terrain is cached between launches. It's kept apart from downloaded offline map regions so a coverage 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/OfflineTerrainTest.kt b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/OfflineTerrainTest.kt new file mode 100644 index 00000000000..daeddb625d8 --- /dev/null +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/OfflineTerrainTest.kt @@ -0,0 +1,80 @@ +/* + * 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 region = TerrainTileStore(FakeFileSystem(), "/region".toPath()) + + @Test + fun aRegionTileIsReadWithoutTheNetwork() = runTest { + val bytes = byteArrayOf(1, 2, 3) + region.writeTile(TerrainSource.GLOBAL, TileIndex(12, 655, 1430), 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 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) + } +} From b2f73cd13d3d9fcb892a6ed298879bb582df2bef Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:16:08 -0500 Subject: [PATCH 06/11] fix(coverage): credit Mapterhorn for coverage estimates The overlay is built from Mapterhorn terrain, but the credit only showed while hillshade or contours drew. The Google map now credits it whenever a coverage layer is visible, and the Site Planner form carries the credit on every flavor. MapLibre has no map-side credit for it: `GeoJsonOptions` takes no attribution. fixes #7755 --- .skills/compose-ui/strings-index.txt | 1 + .../src/google/kotlin/org/meshtastic/app/map/MapView.kt | 5 +++-- .../src/commonMain/composeResources/values/strings.xml | 1 + .../meshtastic/feature/map/component/SitePlannerSheet.kt | 6 ++++++ 4 files changed, 11 insertions(+), 2 deletions(-) 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/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index b09ef417c75..9adf98f1ced 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -1027,12 +1027,13 @@ 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 = - attributionCoveringRegion?.hasTerrain == true && (terrainHillshadeEnabled || terrainContoursEnabled) + attributionCoveringRegion?.hasTerrain == true && (terrainHillshadeEnabled || terrainContoursEnabled) || + mapLayers.any { it.layerType == LayerType.COVERAGE && it.isVisible } val attributionText = remember(currentRasterBasemap, enabledOverlayIds, offlineRegionActive, terrainActive) { val baseAttribution = 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/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 ddc96dce0a5..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, + ) } } From 6cf4ab70a360c2fe123f10f1dee4927f68156843 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:24:49 -0500 Subject: [PATCH 07/11] fix(coverage): floor stand-in terrain at z6 and drop it after cancel Every region holds the planet tile, so an unbounded walk gave any site anywhere a stand-in a pixel wide instead of failing. The tile scope is now canceled before stand-ins are forgotten, so a decode finishing in between cannot leave one in the shared cache. --- .../feature/coverage/MapterhornElevation.kt | 13 ++++++++++--- .../feature/coverage/SitePlannerEstimate.kt | 14 +++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) 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 b631804fcf2..08fc23b0b4e 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 @@ -163,9 +163,13 @@ class MapterhornElevation( ?: 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. */ + /** + * 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) { + for (levels in 1..(zoom - MIN_ZOOM)) { val bytes = tiles.offlineBytes(zoom - levels, x shr levels, y shr levels) ?: continue val parent = runCatching { decodeTerrariumTile(bytes) }.getOrNull() ?: continue standInLock.withLock { standIns += tileKey(x, y) } @@ -174,7 +178,10 @@ class MapterhornElevation( return null } - /** Drop every stand-in tile from the shared cache. Call once the estimate that needed them is done. */ + /** + * 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) 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 8d2a1549e6e..122376c3c72 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 @@ -44,13 +44,13 @@ suspend fun estimateCoverageGeoJson( ): 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, offline = offlineTerrain).use { source -> - try { - source.prefetch(site) - LocalCoverage(source).sweepGrid(site).toGeoJson(params.toCoverageStyle()) - } finally { - withContext(NonCancellable) { source.forgetStandIns() } - } + 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() } } } From ac6587d2194988e54316bfbe01993424b156db36 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:24:49 -0500 Subject: [PATCH 08/11] fix(coverage): split the attribution condition for detekt --- .../src/google/kotlin/org/meshtastic/app/map/MapView.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 9adf98f1ced..081427865d7 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -1031,9 +1031,10 @@ fun MapView( val attributionCoveringRegion = cameraPositionState.projection?.visibleRegion?.latLngBounds?.let { mapViewModel.offlineRegionCovering(it) } val offlineRegionActive = offlineOverlayEnabled && attributionCoveringRegion != null - val terrainActive = - attributionCoveringRegion?.hasTerrain == true && (terrainHillshadeEnabled || terrainContoursEnabled) || - mapLayers.any { it.layerType == LayerType.COVERAGE && it.isVisible } + 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 = From 7144d8064d7ea87795069b571cf938cefc6b3416 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:49:27 -0500 Subject: [PATCH 09/11] fix(coverage): fall back to region terrain when a fresh retry fails too The corrupt-tile retry now sits inside the same fallback, and decode and the ancestor walk are reshaped for detekt. --- .../feature/coverage/MapterhornElevation.kt | 39 +++++++++++-------- .../feature/coverage/SitePlannerEstimate.kt | 19 ++++----- .../feature/coverage/OfflineTerrainTest.kt | 8 +++- .../coverage/SitePlannerEstimateTest.kt | 5 ++- 4 files changed, 42 insertions(+), 29 deletions(-) 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 08fc23b0b4e..367e2082eb6 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 @@ -146,19 +146,20 @@ 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. - @Suppress("TooGenericExceptionCaught") // whatever the download threw is rethrown when no region holds the ground - private suspend fun decode(x: Int, y: Int): ElevationTile? { - val cached = - try { - tiles.bytes(zoom, x, y) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - // Offline, or the endpoint failed. A region downloaded to a shallower zoom still holds this ground. - return fromCoarserRegionTile(x, y) ?: throw e - } ?: return null + 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) } } @@ -170,10 +171,14 @@ class MapterhornElevation( */ private suspend fun fromCoarserRegionTile(x: Int, y: Int): ElevationTile? { for (levels in 1..(zoom - MIN_ZOOM)) { - val bytes = tiles.offlineBytes(zoom - levels, x shr levels, y shr levels) ?: continue - val parent = runCatching { decodeTerrariumTile(bytes) }.getOrNull() ?: continue - standInLock.withLock { standIns += tileKey(x, y) } - return parent.descendant(levels, x, y) + val parent = + tiles.offlineBytes(zoom - levels, x shr levels, y shr levels)?.let { + runCatching { decodeTerrariumTile(it) }.getOrNull() + } + if (parent != null) { + standInLock.withLock { standIns += tileKey(x, y) } + return parent.descendant(levels, x, y) + } } return null } @@ -213,8 +218,8 @@ 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. + * 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 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 122376c3c72..cb40e3d7961 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 @@ -31,8 +31,8 @@ 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' own stores, so an estimate - * inside a region works without a connection. + * Terrain the network can't supply is read from [offlineTerrain], the downloaded map regions' own stores, 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. */ @@ -59,14 +59,15 @@ suspend fun estimateCoverageGeoJson( * [offlineTerrain] when offline. */ @Composable -fun rememberCoverageEstimate(offlineTerrain: List = emptyList()): suspend (SitePlannerParams) -> String = - remember(offlineTerrain) { - val store = TerrainTileStore(coverageFileSystem(), coverageTerrainDirectory()) - val estimate: suspend (SitePlannerParams) -> String = { params -> - estimateCoverageGeoJson(params, store, coverageComputeDispatcher, offlineTerrain) - } - estimate +fun rememberCoverageEstimate( + offlineTerrain: List = emptyList(), +): suspend (SitePlannerParams) -> String = remember(offlineTerrain) { + val store = TerrainTileStore(coverageFileSystem(), coverageTerrainDirectory()) + val estimate: suspend (SitePlannerParams) -> String = { params -> + 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 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 index daeddb625d8..0296b2f85e7 100644 --- a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/OfflineTerrainTest.kt +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/OfflineTerrainTest.kt @@ -35,7 +35,13 @@ import kotlin.test.assertNull class OfflineTerrainTest { private var requests = 0 - private val notFound = HttpClient(MockEngine { requests++; respondError(HttpStatusCode.NotFound) }) + private val notFound = + HttpClient( + MockEngine { + requests++ + respondError(HttpStatusCode.NotFound) + }, + ) private val region = TerrainTileStore(FakeFileSystem(), "/region".toPath()) @Test 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 index 965deb15fb3..1fdc59aba42 100644 --- a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimateTest.kt +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/SitePlannerEstimateTest.kt @@ -24,8 +24,9 @@ class SitePlannerEstimateTest { @Test fun bandsStartAtTheReceiverSensitivity() { - val style = SitePlannerParams(name = "Test", latitude = 0.0, longitude = 0.0, rxSensitivityDbm = -139.0) - .toCoverageStyle() + 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) From c524499c2178b6ac96db254048533e70be8b130a Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:02:53 -0500 Subject: [PATCH 10/11] style(coverage): spotless --- .../feature/coverage/MapterhornElevation.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 367e2082eb6..bd78650b981 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 @@ -148,13 +148,14 @@ class MapterhornElevation( // 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 - } + 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. From 4d14ad6aa7cf33eb57452d8e53d1c430e6b13195 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:27:42 -0500 Subject: [PATCH 11/11] fix(coverage): only take region terrain for ground the region covers A region keeps every z6-z12 tile it touches, and a z6 tile spans hundreds of km, so a site far outside the region still got stand-in terrain offline. Each region now comes with its bounds, and a tile is read from it only for ground inside them. --- .../app/map/component/SitePlannerSlot.kt | 11 ++++- .../kotlin/org/meshtastic/app/map/MapView.kt | 11 ++++- .../desktop/map/DesktopSitePlannerSlot.kt | 11 ++++- .../feature/coverage/MapterhornElevation.kt | 5 ++- .../feature/coverage/MapterhornTiles.kt | 20 ++++++---- .../feature/coverage/RegionTerrain.kt | 40 +++++++++++++++++++ .../feature/coverage/SitePlannerEstimate.kt | 21 +++++----- .../feature/coverage/OfflineTerrainTest.kt | 22 +++++++++- 8 files changed, 115 insertions(+), 26 deletions(-) create mode 100644 feature/coverage/src/commonMain/kotlin/org/meshtastic/feature/coverage/RegionTerrain.kt 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 c7689b20701..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,11 +17,14 @@ 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 @@ -49,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(listOf(OfflineTerrainRepository.default.tileStore)), + 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 081427865d7..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 @@ -1239,7 +1241,14 @@ fun MapView( val offlineRegions by mapViewModel.offlineRegions.collectAsStateWithLifecycle() val offlineTerrain = remember(offlineRegions) { - offlineRegions.filter { it.hasTerrain }.map { mapViewModel.terrainStoreForRegion(it.id) } + 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, 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 584c518fc9f..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,11 +17,14 @@ 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 @@ -43,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(listOf(OfflineTerrainRepository.default.tileStore)), + estimate = rememberCoverageEstimate(offlineTerrain), onDismiss = session.onDismiss, onImport = { name, geoJson, latitude, longitude -> layersManager.addGeoJsonLayer(name, geoJson) 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 bd78650b981..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 @@ -31,6 +31,7 @@ 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 @@ -59,7 +60,7 @@ class MapterhornElevation( zoom: Int = DEFAULT_ZOOM, http: HttpClient? = null, /** Downloaded map regions' terrain, used when the network can't supply a tile. */ - offline: List = emptyList(), + offline: List = emptyList(), ) : ElevationSource, AutoCloseable { @@ -173,7 +174,7 @@ class MapterhornElevation( private suspend fun fromCoarserRegionTile(x: Int, y: Int): ElevationTile? { for (levels in 1..(zoom - MIN_ZOOM)) { val parent = - tiles.offlineBytes(zoom - levels, x shr levels, y shr levels)?.let { + tiles.offlineBytes(TileIndex(zoom - levels, x shr levels, y shr levels), TileIndex(zoom, x, y))?.let { runCatching { decodeTerrariumTile(it) }.getOrNull() } if (parent != null) { 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 a7b042233b6..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 @@ -46,7 +46,7 @@ import org.meshtastic.feature.map.terrain.TileIndex internal class MapterhornTiles( private val http: HttpClient, private val store: TerrainTileStore?, - private val offline: List = emptyList(), + private val offline: List = emptyList(), concurrency: Int = DEFAULT_CONCURRENCY, ) { private val gate = Semaphore(concurrency) @@ -58,17 +58,21 @@ internal class MapterhornTiles( 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) ?: offlineBytes(zoom, x, y) + val stored = if (fresh) null else readLocal(source, tile) ?: offlineBytes(tile, tile) return stored ?: download(zoom, x, y)?.also { writeLocal(source, tile, it) } } - /** One tile from the downloaded regions alone, or null when none of them holds it. */ - suspend fun offlineBytes(zoom: Int, x: Int, y: Int): ByteArray? { - if (offline.isEmpty()) return null - val tile = TileIndex(zoom, x, y) - val source = sourceFor(zoom) + /** + * [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) { - offline.firstNotNullOfOrNull { region -> runCatching { region.readTile(source, tile) }.getOrNull() } + holders.firstNotNullOfOrNull { region -> runCatching { region.store.readTile(source, tile) }.getOrNull() } } } 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 cb40e3d7961..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 @@ -31,8 +31,8 @@ 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' own stores, so an - * estimate inside a region works without a connection. + * 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. */ @@ -40,7 +40,7 @@ suspend fun estimateCoverageGeoJson( params: SitePlannerParams, store: TerrainTileStore, dispatcher: CoroutineDispatcher, - offlineTerrain: List = emptyList(), + 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. @@ -59,15 +59,14 @@ suspend fun estimateCoverageGeoJson( * [offlineTerrain] when offline. */ @Composable -fun rememberCoverageEstimate( - offlineTerrain: List = emptyList(), -): suspend (SitePlannerParams) -> String = remember(offlineTerrain) { - val store = TerrainTileStore(coverageFileSystem(), coverageTerrainDirectory()) - val estimate: suspend (SitePlannerParams) -> String = { params -> - estimateCoverageGeoJson(params, store, coverageComputeDispatcher, offlineTerrain) +fun rememberCoverageEstimate(offlineTerrain: List = emptyList()): suspend (SitePlannerParams) -> String = + remember(offlineTerrain) { + val store = TerrainTileStore(coverageFileSystem(), coverageTerrainDirectory()) + val estimate: suspend (SitePlannerParams) -> String = { params -> + estimateCoverageGeoJson(params, store, coverageComputeDispatcher, offlineTerrain) + } + estimate } - estimate -} /** * Where coverage terrain is cached between launches. It's kept apart from downloaded offline map regions so a coverage 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 index 0296b2f85e7..731c248ba84 100644 --- a/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/OfflineTerrainTest.kt +++ b/feature/coverage/src/commonTest/kotlin/org/meshtastic/feature/coverage/OfflineTerrainTest.kt @@ -42,12 +42,16 @@ class OfflineTerrainTest { respondError(HttpStatusCode.NotFound) }, ) - private val region = TerrainTileStore(FakeFileSystem(), "/region".toPath()) + 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) - region.writeTile(TerrainSource.GLOBAL, TileIndex(12, 655, 1430), bytes) + store.writeTile(TerrainSource.GLOBAL, tile, bytes) val read = MapterhornTiles(notFound, store = null, offline = listOf(region)).bytes(12, 655, 1430) @@ -63,6 +67,20 @@ class OfflineTerrainTest { 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() })