Compare commits

..

7 commits

Author SHA1 Message Date
86a2dc3786 Fixed reversing 2025-09-24 17:49:30 +03:00
6e47b724e8 Removed unused reports 2025-09-24 17:31:53 +03:00
32cfa000c7 Moved everything out of loop 2025-09-24 17:16:14 +03:00
c7b22f012b Optimized heights parsing 2025-09-24 16:20:10 +03:00
71f012dc6b Changed multiple heights parsing 2025-09-23 16:39:53 +03:00
470535cac8 Height on distance 2025-09-23 12:51:23 +03:00
af7429fad7 Deducing height on the move 2025-09-22 12:36:51 +03:00
6 changed files with 249 additions and 88 deletions

View file

@ -4,7 +4,7 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-08-26T11:06:03.805342749Z">
<DropdownSelection timestamp="2025-09-22T09:08:24.101225364Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/secondbeam/.config/.android/avd/Virtual_Pixel_3.avd" />

View file

@ -1,9 +1,10 @@
package com.mirenkov.ktheightmap
import android.content.Context
import android.util.Log
import java.io.DataInputStream
import java.io.FileInputStream
import kotlin.math.max
import kotlin.math.min
private data class HeightInfo(
val minLon: Float,
@ -16,46 +17,116 @@ private data class HeightInfo(
class KhmParser {
companion object {
private var header: HeightInfo? = null
const val HEIGHT_FILE: String = "height.khm"
fun load(filePath: String, ctx: Context) {
if (ctx.getFileStreamPath(HEIGHT_FILE).exists()) {
Log.i(TAG, "height.khm already exists")
return
}
val inp = FileInputStream(filePath)
ctx.openFileOutput(HEIGHT_FILE, Context.MODE_PRIVATE).use {
it.write(inp.readBytes())
Log.i(TAG, "Copied %s to height.khm".format(filePath))
}
}
private fun getOffset(header: HeightInfo, x: Int, y: Int): Int {
Log.i(TAG, "Offset for (%d, %d) = %d".format(x, y, y * header.height + x))
return (x * header.width + y) * 2
}
private fun readHeader(dis: DataInputStream): HeightInfo {
return with(dis) {
if (header == null) {
header = with(dis) {
val minLon = readFloat()
val minLat = readFloat()
val lonPerValue = readFloat()
val latPerValue = readFloat()
val width = readInt()
val height = readInt()
Log.i(TAG, "Header: %.6f, %.6f, %.6f, %.6f, %d, %d".format(minLon, minLat, lonPerValue, latPerValue, width, height))
HeightInfo(minLon, minLat, lonPerValue, latPerValue, width, height)
}
} else {
dis.skipBytes(4 * 6)
}
return header!!
}
private fun inBounds(header: HeightInfo, lon: Float, lat: Float): Boolean {
val maxLon = header.minLon + (header.lonPerValue * header.width)
val maxLat = header.minLat + (header.latPerValue * header.height)
val miLon = min(header.minLon, maxLon)
val maLon = max(header.minLon, maxLon)
val miLat = min(header.minLat, maxLat)
val maLat = max(header.minLat, maxLat)
return lon > miLon && lat > miLat && lon < maLon && lat < maLat
}
fun getHeight(lon: Float, lat: Float, ctx: Context): UShort {
val dis = DataInputStream(ctx.openFileInput(HEIGHT_FILE))
dis.use {
val header = readHeader(dis)
if (!inBounds(header, lon, lat))
return 0u
val x = ((lon - header.minLon) / header.lonPerValue).toInt()
val y = ((lat - header.minLat) / header.latPerValue).toInt()
val offset = getOffset(header, x, y)
dis.skipBytes(offset)
return dis.readUnsignedShort().toUShort()
val height = dis.readUnsignedShort().toUShort()
return height
}
}
@OptIn(ExperimentalUnsignedTypes::class)
fun getHeightsMul(ctx: Context, coords: Array<Pair<Float, Float>>): Pair<UShortArray, Array<Pair<Float, Float>>> {
if (coords.isEmpty()) return Pair(ushortArrayOf(), coords)
val dis = DataInputStream(ctx.openFileInput(HEIGHT_FILE))
dis.use {
val header = readHeader(dis)
val reversed = coords[0].first < coords[coords.size - 1].first
val glist: MutableMap<Pair<Float, Float>, Int> = mutableMapOf()
for (coord in coords) {
val x = ((coord.first - header.minLon) / header.lonPerValue).toInt()
val y = ((coord.second - header.minLat) / header.latPerValue).toInt()
val offset = getOffset(header, x, y)
glist.put(coord, offset)
}
val sortedGlist = glist.toList().sortedBy { (_, v) -> v }.toMap()
val glistKeys = sortedGlist.keys.toTypedArray()
val cutOffsets = IntArray(glist.size) {-1}
if (inBounds(header, coords[0].first, coords[0].second)) {
val key = glistKeys[0]
cutOffsets[0] = sortedGlist.getOrDefault(key, 0)
}
for (i in 1 until sortedGlist.size) {
if (inBounds(header, coords[i].first, coords[i].second) && cutOffsets[i-1] >= 0) {
val prevKey = glistKeys[i-1]
val key = glistKeys[i]
cutOffsets[i] = sortedGlist.getOrDefault(key, 0) - sortedGlist.getOrDefault(prevKey, 0) - 2
} else break
}
return Pair(
UShortArray(coords.size) { i ->
if (cutOffsets[i] > 0) {
dis.skipBytes(cutOffsets[i])
dis.readUnsignedShort().toUShort()
} else 0u },
if (reversed) coords else coords.reversed().toTypedArray())
}
}
fun getLonPerValue(): Float {
return header?.lonPerValue ?: 0F
}
fun getLatPerValue(): Float {
return header?.latPerValue ?: 0F
}
}
}

View file

@ -3,7 +3,6 @@ package com.mirenkov.ktheightmap
import android.app.Application
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@ -32,7 +31,6 @@ import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -43,7 +41,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.toUpperCase
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
@ -122,12 +119,13 @@ fun Main(vm: TileViewModel = viewModel()) {
}
}
@Suppress("VariableNeverRead", "AssignedValueIsNeverRead")
@Composable
fun ToolButton(viewModel: TileViewModel) {
val context = LocalContext.current
var expanded by rememberSaveable{ mutableStateOf(false) }
var debug by rememberSaveable{ viewModel.debug }
var logRequested by remember{ viewModel.logRequested }
var pointRequested by remember{ viewModel.pointRequested }
val dialogShown = rememberSaveable{ mutableStateOf(false) }
FloatingActionButton(
onClick = {
@ -147,9 +145,9 @@ fun ToolButton(viewModel: TileViewModel) {
}
)
DropdownMenuItem(
text = { Text("Log") },
text = { Text("Place point") },
onClick = {
logRequested = true
pointRequested = true
expanded = false
}
)
@ -175,7 +173,6 @@ fun ToolButton(viewModel: TileViewModel) {
@Suppress("VariableNeverRead", "AssignedValueIsNeverRead")
@Composable
fun SetLocationDialog(vm: TileViewModel, dialogShown: MutableState<Boolean>) {
val ctx = LocalContext.current
var showLocationDialog by dialogShown
if (showLocationDialog) {
var latitudeText by remember { vm.latitudeText }
@ -210,12 +207,8 @@ fun SetLocationDialog(vm: TileViewModel, dialogShown: MutableState<Boolean>) {
confirmButton = { TextButton(onClick = {
val latitude = latitudeText.text.toDoubleOrNull() ?: 0.0
val longitude = longitudeText.text.toDoubleOrNull() ?: 0.0
Log.i(TAG, "Lat: %.6f, Lon: %.6f".format(latitude, longitude))
Log.i(TAG, "X = %.6f".format(SphereMercator.mercateLon(longitude, vm.scale.floatValue.toInt(), -TILE_SIZE.toDouble())))
Log.i(TAG, "Y = %.6f".format(SphereMercator.mercateLat(latitude, vm.scale.floatValue.toInt(), -TILE_SIZE.toDouble())))
offsetX = SphereMercator.mercateLon(longitude, vm.scale.floatValue.toInt(), -vm.halvedOffsetX!!.toDouble() - TILE_SIZE).toFloat()
offsetY = SphereMercator.mercateLat(latitude, vm.scale.floatValue.toInt(), -vm.halvedOffsetY!!.toDouble() - TILE_SIZE).toFloat()
Log.i(TAG, "%d".format(KhmParser.getHeight(longitude.toFloat(), latitude.toFloat(), ctx).toInt()))
}) {
Text("Confirm".uppercase())
} },

View file

@ -1,11 +1,12 @@
package com.mirenkov.ktheightmap
import android.util.Log
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@ -16,6 +17,7 @@ import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@ -24,7 +26,11 @@ import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import kotlin.math.absoluteValue
import kotlin.math.floor
import kotlin.math.pow
import kotlin.math.sqrt
@OptIn(ExperimentalUnsignedTypes::class)
@Composable
fun MapCanvas(
viewModel: TileViewModel,
@ -33,18 +39,30 @@ fun MapCanvas(
tileContainer: TileContainer,
modifier: Modifier = Modifier
) {
val ctx = LocalContext.current
var offsetX by rememberSaveable { viewModel.mapOffsetX }
var offsetY by rememberSaveable { viewModel.mapOffsetY }
val scale by rememberSaveable { viewModel.scale }
val textMeasurer = rememberTextMeasurer()
val debug by rememberSaveable { viewModel.debug }
var logRequested by rememberSaveable { viewModel.logRequested }
var pointRequested by rememberSaveable { viewModel.pointRequested }
var pointLat by rememberSaveable { viewModel.rememberedPointLat }
var pointLon by rememberSaveable { viewModel.rememberedPointLon }
var invokeHeightCalc by remember { mutableStateOf(false) }
Canvas(
modifier = modifier.fillMaxSize()
.pointerInput(Unit) {
detectDragGestures { _, distance ->
detectDragGestures (
onDragEnd = {
invokeHeightCalc = true
},
onDragCancel = {
invokeHeightCalc = true
}
) { _, distance ->
offsetX -= distance.x
offsetY -= distance.y
invokeHeightCalc = false
}
}
) {
@ -55,16 +73,6 @@ fun MapCanvas(
val halvedX = viewModel.halvedOffsetX!!
val halvedY = viewModel.halvedOffsetY!!
drawRect(
color = backColor,
size = size
)
if (logRequested) {
Log.i(TAG, "Offset: %.6f, %.6f".format(offsetX, offsetY))
logRequested = false
}
val oldLevel = tileContainer.getLevel()
val level = scale.toInt()
val levelDiff = level - oldLevel
@ -84,12 +92,27 @@ fun MapCanvas(
offsetY += halvedY + TILE_SIZE
}
}
val tileOffsetX = (offsetX / TILE_SIZE).toInt()
val tileOffsetY = (offsetY / TILE_SIZE).toInt()
val centerTileX = (1 + (offsetX + halvedX) / TILE_SIZE).toDouble()
val centerTileY = (1 + (offsetY + halvedY) / TILE_SIZE).toDouble()
val lon = SphereMercator.mercateX(centerTileX, level).toFloat()
val lat = SphereMercator.mercateY(centerTileY, level).toFloat()
if (pointRequested) {
if (pointLat == 0F) {
pointLat = lat
pointLon = lon
} else {
pointLat = 0F
pointLon = 0F
}
pointRequested = false
}
val tileOffsetX = (offsetX / TILE_SIZE).toInt()
val tileOffsetY = (offsetY / TILE_SIZE).toInt()
val strippedOffsetX = offsetX % TILE_SIZE
val strippedOffsetY = offsetY % TILE_SIZE
@ -108,6 +131,13 @@ fun MapCanvas(
val latLonSize = Size(216F, 96F + additionalSize)
val latLonOffset = Offset(16F, 16F)
// Background
drawRect(
color = backColor,
size = size
)
// Tiles
for (cellX in 0 .. gridWidth + 2) {
val tileX = tileOffsetX + cellX
val localOffsetX = TILE_SIZE * (cellX - 1)
@ -119,6 +149,7 @@ fun MapCanvas(
val bitmap = tile.toBitmap()
val totalOffset = Offset(localOffsetX, localOffsetY) - offset
// Tile
bitmap?.let {
val imageBitmap = bitmap.asImageBitmap()
drawImage(
@ -127,6 +158,7 @@ fun MapCanvas(
)
}
// Debug grid
if (debug) {
drawRect(
color = gridColor,
@ -150,7 +182,63 @@ fun MapCanvas(
size = Size(TILE_SIZE, TILE_SIZE)
)
}
}
}
// Placed point and line to center
if (pointLat != 0F) {
val pointOffsetX = SphereMercator.mercateLon(pointLon.toDouble(), level, -TILE_SIZE.toDouble()).toFloat() - offsetX
val pointOffsetY = SphereMercator.mercateLat(pointLat.toDouble(), level, -TILE_SIZE.toDouble()).toFloat() - offsetY
drawRect(
color = gridColor,
size = Size(8F, 8F),
topLeft = Offset(pointOffsetX - 4, pointOffsetY - 4)
)
drawLine(
color = gridColor,
start = Offset(pointOffsetX, pointOffsetY),
end = Offset(halvedX, halvedY)
)
val startHeight = KhmParser.getHeight(pointLon, pointLat, ctx)
if (pointOffsetX >= 0 && pointOffsetY >= 0 && pointOffsetX < size.width && pointOffsetY < size.height)
drawText(
textMeasurer = textMeasurer,
text = buildAnnotatedString { withStyle(SpanStyle(color = Color.White)) {
append("${startHeight}m")
} },
topLeft = Offset(pointOffsetX, pointOffsetY - 32)
)
if (invokeHeightCalc) {
val latPV = KhmParser.getLatPerValue()
val lonPV = KhmParser.getLonPerValue()
val valueDistance = sqrt(latPV.pow(2) + lonPV.pow(2))
val latDiff = lat - pointLat
val lonDiff = lon - pointLon
val distance = sqrt((latDiff).pow(2) + (lonDiff).pow(2))
val valuesCount = floor(distance / valueDistance).toInt()
val array: Array<Pair<Float, Float>> = Array(valuesCount) { step ->
val interCoef = 1F - step.toFloat() / valuesCount
Pair(lon - lonDiff * interCoef, lat - latDiff * interCoef)
}
val heightPair = KhmParser.getHeightsMul(ctx, array)
val heights = heightPair.first
val coords = heightPair.second
for (step in 0 until coords.size) {
val stepLat = coords[step].second
val stepLon = coords[step].first
val stepOffsetX = SphereMercator.mercateLon(stepLon.toDouble(), level, -TILE_SIZE.toDouble()).toFloat() - offsetX
val stepOffsetY = SphereMercator.mercateLat(stepLat.toDouble(), level, -TILE_SIZE.toDouble()).toFloat() - offsetY
if (stepOffsetX >= 0 && stepOffsetY >= 0 && stepOffsetX < size.width && stepOffsetY < size.height)
drawRect(
color = if (heights[step] > startHeight) Color.Red else Color.Green,
size = Size(8F, 8F),
topLeft = Offset(stepOffsetX - 4, stepOffsetY - 4)
)
}
}
}
// Cursor path
val path = Path()
path.moveTo(halvedX - crossRadius, halvedY)
path.lineTo(halvedX + crossRadius, halvedY)
@ -158,26 +246,27 @@ fun MapCanvas(
path.lineTo(halvedX, halvedY + crossRadius)
path.close()
// Cursor
drawPath(
path,
Color.White,
style = Stroke(width = 6F)
)
// Info box
drawRect(
color = backColor,
size = latLonSize,
topLeft = latLonOffset
)
// Info box content
drawText(
textMeasurer = textMeasurer,
text = buildAnnotatedString {
withStyle(ParagraphStyle(textAlign = TextAlign.Center)) {
withStyle(SpanStyle(color = gridColor)) {
val lon = SphereMercator.mercateX(centerTileX, level)
val lat = SphereMercator.mercateY(centerTileY, level)
append("%.6f\n%.6f".format(lon, lat))
append("%.6f\n%.6f".format(lat, lon))
if (debug) {
append("\n%.0f\n%.0f".format(offsetX, offsetY))
}
@ -187,7 +276,16 @@ fun MapCanvas(
size = latLonSize,
topLeft = latLonOffset
)
}
}
// Height under cursor
drawText(
textMeasurer = textMeasurer,
text = buildAnnotatedString {
withStyle(SpanStyle(color = Color.White)) {
append("${KhmParser.getHeight(lon, lat, ctx)}m")
}
},
topLeft = Offset(halvedX + crossRadius, halvedY + crossRadius)
)
}
}

View file

@ -89,10 +89,7 @@ fun SettingsMain(vm: TileViewModel, launcher: ActivityResultLauncher<DocumentFil
val t = getTile(it.x, it.y, it.level)
if (t == null) {
pushTile(it)
Log.i(TAG, "pushed to db: %d, %d, %d".format(it.level, it.x, it.y))
}
else
Log.i(TAG, "found in db: %d, %d, %d".format(t.level, t.x, t.y))
}
}
} },
@ -124,7 +121,6 @@ suspend fun processZip(filePath: String?): List<Tile> = coroutineScope {
if (entry == null)
break
}
Log.i(TAG, "got list")
}
}
return@coroutineScope list

View file

@ -10,7 +10,7 @@ class TileViewModel(application: Application): ViewModel() {
val repository: TileRepository
var debug = mutableStateOf(false)
var logRequested = mutableStateOf(false)
var pointRequested = mutableStateOf(false)
var latitudeText = mutableStateOf(TextFieldValue(""))
var longitudeText = mutableStateOf(TextFieldValue(""))
@ -22,6 +22,9 @@ class TileViewModel(application: Application): ViewModel() {
var halvedOffsetX: Float? = null
var halvedOffsetY: Float? = null
var rememberedPointLon = mutableFloatStateOf(0F)
var rememberedPointLat = mutableFloatStateOf(0F)
init {
val tileDb = TileDB.getInstance(application)
val tileDao = tileDb.tileDao()