feat: 添加当前位置Marker,显示实时设计高度,完善地形图代码
Some checks failed
Build and generate screenshots / generate_screenshots (push) Has been cancelled
Release API docs / release_apidocs (push) Has been cancelled

This commit is contained in:
2025-11-28 22:40:10 +08:00
parent 3ec494ca69
commit af2257b467
19 changed files with 2458 additions and 107 deletions

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Include this permission to grab user's general location -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- Include only if your app benefits from precise location access. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <!-- Include only if your app benefits from precise location access. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
@@ -11,6 +11,15 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Orx">
<activity
android:name=".MapActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
android:exported="true">

View File

@@ -1,19 +1,18 @@
package com.icegps.geotools
import ColorBrewer2Type
import android.content.Context
import android.util.Log
import colorBrewer2Palettes
import com.icegps.math.geometry.Rectangle
import com.icegps.math.geometry.Vector2D
import com.icegps.math.geometry.Vector3D
import androidx.core.graphics.toColorInt
import com.icegps.geotools.catmullrom.CatmullRomChain2
import com.icegps.geotools.ktx.area
import com.icegps.geotools.ktx.toColorInt
import com.icegps.geotools.ktx.optimalHeight
import com.icegps.geotools.ktx.toMapboxPoint
import com.icegps.geotools.ktx.toast
import com.icegps.geotools.marchingsquares.ShapeContour
import com.icegps.geotools.marchingsquares.findContours
import com.icegps.math.geometry.Rectangle
import com.icegps.math.geometry.Vector2D
import com.icegps.math.geometry.Vector3D
import com.icegps.shared.ktx.TAG
import com.icegps.triangulation.DelaunayTriangulation
import com.icegps.triangulation.Triangle
@@ -38,6 +37,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.max
@@ -54,17 +54,18 @@ class ContoursManager(
private val gridSourceId: String = "grid-polygon-source-id"
private val gridLayerId: String = "grid-polygon-layer-id"
private var contourSize: Int = 6
private var contourSize: Int = 13
private var heightRange: ClosedFloatingPointRange<Double> = 0.0..100.0
private var cellSize: Double? = 10.0
val simplePalette = SimplePalette(
range = 0.0..100.0
)
val mySimplePalette = SimplePalette(0.0..13.0)
private var colors = colorBrewer2Palettes(
numberOfColors = contourSize,
paletteType = ColorBrewer2Type.Any
).first().colors.reversed()
val CONTOUR_COLORS = arrayOf(
"#CC0000", "#FF0000", "#FF3333", "#FF6666", "#FF9999", "#FFCCCC",
"#8CED8C", "#B3D9FF", "#80BFFF", "#4D94FF", "#2673FF", "#004CFF", "#1517EC"
).reversed()
private var points: List<Vector3D> = emptyList()
@@ -72,10 +73,6 @@ class ContoursManager(
fun updateContourSize(contourSize: Int) {
this.contourSize = contourSize
colors = colorBrewer2Palettes(
numberOfColors = contourSize,
paletteType = ColorBrewer2Type.Any
).first().colors.reversed()
}
fun updateCellSize(value: Double) {
@@ -142,21 +139,72 @@ class ContoursManager(
fun setTriangleVisible(visible: Boolean) {
if (visible != isTriangleVisible) {
isTriangleVisible = visible
val sourceId = "triangle-source-id"
val layerId = "triangle-layer-id"
if (visible) {
polylineManager.update(
/*polylineManager.update(
triangles.map {
listOf(it.x1, it.x2, it.x3)
.map { Vector3D(it.x, it.y, it.z) }
}
)*/
mapView.displayTriangle(
triangles = triangles,
sourceId = sourceId,
layerId = layerId
)
} else {
polylineManager.clearContours()
// polylineManager.clearContours()
mapView.mapboxMap.getStyle { style ->
style.removeStyleLayer(layerId)
style.removeStyleSource(sourceId)
}
}
}
}
private var job: Job? = null
private val _slopeData = MutableStateFlow(SlopeData())
val slopeData = _slopeData.asStateFlow()
data class SlopeData(
val slopeDirection: Double = 0.0,
val slopePercentage: Double = 0.0,
val targetHeight: Double = 0.0,
val optimalHeight: Double = 0.0
)
fun updateSlopeData(block: (SlopeData) -> SlopeData) {
_slopeData.update(block)
}
fun updateTargetHeight(
block: (ClosedFloatingPointRange<Double>) -> Double
) {
updateSlopeData {
it.copy(targetHeight = block(heightRange))
}
}
private fun getContours(
heightRange: ClosedFloatingPointRange<Double>,
targetHeightRange: ClosedFloatingPointRange<Double>
): List<ClosedFloatingPointRange<Double>> {
val frontHalfHeight = targetHeightRange.start - heightRange.start
val backHalfHeight = heightRange.endInclusive - targetHeightRange.endInclusive
val frontHalfStep = frontHalfHeight / 6
val backHalfStep = backHalfHeight / 6
val frontHalfZip = (0..6).map { index ->
heightRange.start + index * frontHalfStep
}.zipWithNext { a, b -> a..b }
val backHalfZip = (0..6).map { index ->
targetHeightRange.endInclusive + index * backHalfStep
}.zipWithNext { a, b -> a..b }
return frontHalfZip + listOf(targetHeightRange) + backHalfZip
}
fun refresh() {
val points = points
if (points.size <= 3) {
@@ -166,24 +214,34 @@ class ContoursManager(
job?.cancel()
scope.launch {
mapView.mapboxMap.getStyle { style ->
val step = heightRange.endInclusive / contourSize
val zip = (0..contourSize).map { index ->
heightRange.start + index * step
}.zipWithNext { a, b -> a..b }
val area = points.area
val triangulation = DelaunayTriangulation(points)
val triangles = triangulation.triangles()
triangles = triangulation.triangles()
val cellSize: Double = if (cellSize == null || cellSize!! < 0.1) {
(max(triangulation.points.area.width, triangulation.points.area.height) / 50)
} else {
cellSize!!
}
// 生成栅格网
val gridModel = triangulationToGrid(
area = area,
triangles = triangles,
cellSize = cellSize,
)
this@ContoursManager._gridModel.value = gridModel
val optimalHeight = gridModel.optimalHeight()
// 根据目标高度生成坡面
val (slopeDirection, slopePercentage, targetHeight, _) = _slopeData.value
val height = if (targetHeight <= 0.0) optimalHeight else targetHeight
val slopeResult = SlopeCalculator.calculateSlopeByTargetHeight(
grid = gridModel,
slopeDirection = slopeDirection,
slopePercentage = slopePercentage,
targetHeight = height
)
scope.launch {
val gridModel = triangulationToGrid(
delaunator = triangulation,
cellSize = cellSize,
)
this@ContoursManager._gridModel.value = gridModel
if (isGridVisible) mapView.displayGridModel(
grid = gridModel,
sourceId = gridSourceId,
@@ -192,6 +250,19 @@ class ContoursManager(
)
}
job = scope.launch(Dispatchers.Default) {
// 由目标高度计算等级
fun simple(): List<ClosedFloatingPointRange<Double>> {
val step = (heightRange.endInclusive - heightRange.start) / contourSize
val zip = (0..contourSize).map { index ->
heightRange.start + index * step
}.zipWithNext { a, b -> a..b }
return zip
}
// val zip = simple()
val zip = getContours(
heightRange = heightRange,
height..height
)
val lineFeatures = mutableListOf<List<Feature>>()
val features = zip.mapIndexed { index, range ->
async {
@@ -201,13 +272,15 @@ class ContoursManager(
area = area,
cellSize = cellSize
)
val color = colors[index].toColorInt()
// val color = colors[index].toColorInt()
// val color = mySimplePalette.palette(index.toDouble()).toColorInt()
val color = CONTOUR_COLORS[index].toColorInt()
lineFeatures.add(contoursToLineFeatures(contours, color).flatten())
contoursToPolygonFeatures(contours, color)
}
}.awaitAll()
withContext(Dispatchers.Main) {
if (false) setupLineLayer(
if (true) setupLineLayer(
style = style,
sourceId = sourceId,
layerId = layerId,
@@ -296,8 +369,8 @@ class ContoursManager(
style.addSource(source)
val layer = fillLayer(layerId, sourceId) {
fillColor(Expression.Companion.toColor(Expression.get("color"))) // 从属性获取颜色
fillOpacity(0.5)
fillColor(Expression.toColor(Expression.get("color"))) // 从属性获取颜色
fillOpacity(1.0)
fillAntialias(true)
}
style.addLayer(layer)

View File

@@ -2,7 +2,7 @@ package com.icegps.geotools
import android.graphics.PointF
import android.util.Log
import com.icegps.common.helper.GeoHelper
import com.icegps.geotools.ktx.toVector2D
import com.icegps.math.geometry.Angle
import com.icegps.math.geometry.Vector2D
import com.icegps.math.geometry.degrees
@@ -40,7 +40,7 @@ object SlopeCalculator {
val elevations = grid.cells.filterNotNull()
val baseElevation = elevations.average() + baseHeightOffset
val basePoint = Triple(centerX, centerY, baseElevation)
val basePoint: Triple<Double, Double, Double> = Triple(centerX, centerY, baseElevation)
val earthworkResult = EarthworkCalculator.calculateForSlopeDesign(
grid = grid,
@@ -64,6 +64,56 @@ object SlopeCalculator {
)
}
fun calculateSlope(
grid: GridModel,
slopeDirection: Double,
slopePercentage: Double,
basePoint: Triple<Double, Double, Double>
): SlopeResult {
val elevations = grid.cells.filterNotNull()
val baseHeightOffset = basePoint.third - elevations.average()
val earthworkResult = EarthworkCalculator.calculateForSlopeDesign(
grid = grid,
basePoint = basePoint,
slope = slopePercentage,
aspect = slopeDirection
)
return SlopeResult(
slopeDirection = slopeDirection,
slopePercentage = slopePercentage,
baseHeightOffset = baseHeightOffset,
baseElevation = basePoint.third,
earthworkResult = earthworkResult,
designSurface = generateSlopeDesignGrid(
grid = grid,
basePoint = basePoint,
slopePercentage = slopePercentage,
slopeDirection = slopeDirection
)
)
}
fun calculateSlopeByTargetHeight(
grid: GridModel,
slopeDirection: Double,
slopePercentage: Double,
targetHeight: Double = 0.0
): SlopeResult {
val centerX = (grid.minX + grid.maxX) / 2
val centerY = (grid.minY + grid.maxY) / 2
val basePoint = Triple(centerX, centerY, targetHeight)
return calculateSlope(
grid = grid,
slopeDirection = slopeDirection,
slopePercentage = slopePercentage,
basePoint = basePoint
)
}
/**
* 生成斜坡设计面网格(用于可视化)
@@ -282,6 +332,17 @@ data class EarthworkResult(
appendLine("总面积:${"%.1f".format(totalArea)}")
}
}
companion object {
val Empty = EarthworkResult(
cutVolume = 0.0,
fillVolume = 0.0,
netVolume = 0.0,
cutArea = 0.0,
fillArea = 0.0,
totalArea = 0.0
)
}
}
class EarthworkManager(
@@ -347,12 +408,6 @@ class EarthworkManager(
)
}
fun Point.toVector2D(): Vector2D {
val geoHelper = GeoHelper.getSharedInstance()
val enu = geoHelper.wgs84ToENU(lon = longitude(), lat = latitude(), hgt = 0.0)
return Vector2D(enu.x, enu.y)
}
fun removeOnMoveListener() {
listener?.let(mapView.mapboxMap::removeOnMoveListener)
listener = null

View File

@@ -1,8 +1,9 @@
package com.icegps.geotools
import com.icegps.math.geometry.Rectangle
import com.icegps.math.geometry.Vector2D
import com.icegps.math.geometry.Vector3D
import com.icegps.triangulation.DelaunayTriangulation
import com.icegps.triangulation.Triangle
import kotlin.math.absoluteValue
import kotlin.math.ceil
@@ -28,8 +29,21 @@ data class GridModel(
}
}
fun GridModel.getHeightAt(point: Vector2D): Double? {
if (point.x !in minX..<maxX || point.y < minY || point.y >= maxY) {
return null
}
val col = ((point.x - minX) / cellSize).toInt()
val row = ((maxY - point.y) / cellSize).toInt()
val index = row * cols + col
return cells.getOrNull(index)
}
fun triangulationToGrid(
delaunator: DelaunayTriangulation,
area: Rectangle,
triangles: List<Triangle>,
cellSize: Double = 50.0,
maxSidePixels: Int = 5000
): GridModel {
@@ -70,19 +84,13 @@ fun triangulationToGrid(
return values[0] * wA + values[1] * wB + values[2] * wC
}
val minX = area.x
val maxX = area.x + area.width
val minY = area.y
val maxY = area.y + area.height
val pts = delaunator.points
require(pts.isNotEmpty()) { "points empty" }
val x = pts.map { it.x }
val y = pts.map { it.y }
val minX = x.min()
val maxX = x.max()
val minY = y.min()
val maxY = y.max()
val width = maxX - minX
val height = maxY - minY
val width = area.width
val height = area.height
var cols = ceil(width / cellSize).toInt()
var rows = ceil(height / cellSize).toInt()
@@ -93,9 +101,6 @@ fun triangulationToGrid(
val cells = Array<Double?>(rows * cols) { null }
val triangles = delaunator.triangles()
for (ti in 0 until triangles.size) {
val (a, b, c) = triangles[ti]

View File

@@ -5,6 +5,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import com.google.android.material.slider.RangeSlider
@@ -20,6 +21,7 @@ import com.mapbox.maps.MapView
import com.mapbox.maps.plugin.gestures.addOnMapClickListener
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -36,6 +38,10 @@ class MainActivity : AppCompatActivity() {
private lateinit var contoursManager: ContoursManager
private lateinit var earthworkManager: EarthworkManager
private val geoHelper = GeoHelper.getSharedInstance()
private lateinit var terrainManager: TerrainManager
private lateinit var terrainUiController: TerrainUiController
init {
initGeoHelper()
}
@@ -45,6 +51,13 @@ class MainActivity : AppCompatActivity() {
enableEdgeToEdge()
binding = ActivityMainBinding.inflate(layoutInflater)
mapView = binding.mapView
terrainManager = TerrainManagerImpl(scope = lifecycleScope)
terrainUiController = TerrainUiControllerImpl(
context = this,
mapView = mapView,
scope = lifecycleScope,
terrainManager = terrainManager,
)
earthworkManager = EarthworkManager(mapView, lifecycleScope)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
@@ -62,7 +75,18 @@ class MainActivity : AppCompatActivity() {
.build()
)
val points = coordinateGenerate()
// val points = coordinateGenerate()
val pathGenerator = PathGenerator(
centerLon = home.longitude,
centerLat = home.latitude,
widthMeters = 10 * 10.0,
heightMeters = 20 * 10.0,
minHeight = 30.0,
maxHeight = 80.0,
)
val originPoints = pathGenerator.generatePath()
viewModel.addPoints(originPoints)
// divider
contoursManager = ContoursManager(
@@ -70,16 +94,7 @@ class MainActivity : AppCompatActivity() {
mapView = mapView,
scope = lifecycleScope
)
contoursManager.updateContourSize(6)
contoursManager.updatePoints(points)
val height = points.map { it.z }
val min = height.min()
val max = height.max()
contoursManager.updateHeightRange((min / 2)..max)
binding.heightRange.values = listOf(min.toFloat() / 2, max.toFloat())
binding.heightRange.valueFrom = min.toFloat()
binding.heightRange.valueTo = max.toFloat()
contoursManager.refresh()
contoursManager.updateContourSize(13)
binding.sliderTargetHeight.addOnSliderTouchListener(
object : Slider.OnSliderTouchListener {
@@ -142,7 +157,8 @@ class MainActivity : AppCompatActivity() {
}
override fun onStopTrackingTouch(slider: Slider) {
earthworkManager.updateSlopeDirection(slider.value.degrees)
// earthworkManager.updateSlopeDirection(slider.value.degrees)
terrainManager.updateTerrainData { it.copy(slopeDirection = slider.value.degrees) }
}
}
)
@@ -152,38 +168,67 @@ class MainActivity : AppCompatActivity() {
}
override fun onStopTrackingTouch(slider: Slider) {
earthworkManager.updateSlopePercentage(slider.value.toDouble())
// earthworkManager.updateSlopePercentage(slider.value.toDouble())
terrainManager.updateTerrainData { it.copy(slopePercentage = slider.value.toDouble()) }
}
}
)
fun Slider.getRatioValue(): Float {
val length = valueTo - valueFrom
val position = value - valueFrom
return position / length
}
binding.designHeight.addOnSliderTouchListener(
object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {
}
override fun onStopTrackingTouch(slider: Slider) {
earthworkManager.updateDesignHeight(slider.value.toDouble())
// earthworkManager.updateDesignHeight(slider.value.toDouble())
/*contoursManager.updateTargetHeight {
(it.endInclusive - it.start) * slider.getRatioValue()
}
contoursManager.refresh()*/
terrainManager.updateTargetHeight {
(it.endInclusive - it.start) * slider.getRatioValue()
}
}
}
)
binding.switchDesignSurface.setOnCheckedChangeListener { button, isChecked ->
binding.switchDesignSurface.setOnCheckedChangeListener { _, isChecked ->
showDesignHeight.value = isChecked
terrainUiController
}
earthworkManager.setupOnMoveListener()
binding.switchSlopeResult.setOnCheckedChangeListener { _, isChecked ->
slopeResultVisible.value = isChecked
}
binding.switchCatmullRom.setOnCheckedChangeListener { _, isChecked ->
contoursManager.setCatmullRom(isChecked)
contoursManager.refresh()
terrainUiController.enabledCatmullRom(isChecked)
}
binding.targetHeight.doAfterTextChanged {
}
initData()
}
private val showDesignHeight = MutableStateFlow(false)
private val slopeResultVisible = MutableStateFlow(false)
private val targetHeight = MutableStateFlow(0.0)
@OptIn(ExperimentalUuidApi::class)
private fun initData() {
viewModel.points.onEach {
viewModel.points.filter { it.size > 3 }.onEach {
contoursManager.updatePoints(it)
val height = it.map { it.z }
val min = height.min()
val max = height.max()
contoursManager.updateHeightRange()
binding.heightRange.values = listOf(min.toFloat(), max.toFloat())
binding.heightRange.valueFrom = min.toFloat()
binding.heightRange.valueTo = max.toFloat()
contoursManager.refresh()
}.launchIn(lifecycleScope)
contoursManager.gridModel.filterNotNull().onEach {
@@ -197,7 +242,7 @@ class MainActivity : AppCompatActivity() {
combine(
earthworkManager.slopeDirection,
earthworkManager.slopePercentage,
earthworkManager.baseHeightOffset,
targetHeight,
contoursManager.gridModel,
showDesignHeight,
slopeResultVisible
@@ -210,7 +255,7 @@ class MainActivity : AppCompatActivity() {
p5 = it[4] as Boolean,
p6 = it[5] as Boolean
)
}.map { (slopeDirection, slopePercentage, baseHeightOffset, gridModel, showDesignHeight, slopeResultVisible) ->
}.map { (slopeDirection, slopePercentage, targetHeight, gridModel, showDesignHeight, slopeResultVisible) ->
if (!slopeResultVisible) {
mapView.mapboxMap.getStyle { style ->
style.removeStyleLayer(slopeResultLayerId)
@@ -218,12 +263,19 @@ class MainActivity : AppCompatActivity() {
style.removeStyleSource(slopeResultSourceId)
}
} else gridModel?.let { gridModel ->
val slopeResult: SlopeResult = SlopeCalculator.calculateSlope(
/*val slopeResult: SlopeResult = SlopeCalculator.calculateSlope(
grid = gridModel,
slopeDirection = slopeDirection.degrees,
slopePercentage = slopePercentage,
baseHeightOffset = baseHeightOffset
)*/
val slopeResult = SlopeCalculator.calculateSlopeByTargetHeight(
grid = gridModel,
slopeDirection = slopeDirection.degrees,
slopePercentage = slopePercentage,
targetHeight = targetHeight
)
slopeResult.earthworkResult.cutArea
mapView.displaySlopeResult(
originalGrid = gridModel,
slopeResult = slopeResult,
@@ -253,7 +305,7 @@ data class Params6<
val p6: P6,
)
val home = GeoPoint(114.476060, 22.771073, 30.897)
val home = GeoPoint(longitude = 114.476060, latitude = 22.771073, altitude = 30.897)
fun initGeoHelper(base: GeoPoint = home) {
val geoHelper = GeoHelper.getSharedInstance()

View File

@@ -5,14 +5,15 @@ import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.icegps.common.helper.GeoHelper
import com.icegps.math.geometry.Vector3D
import com.icegps.geotools.ktx.toast
import com.icegps.math.geometry.Vector3D
import com.icegps.shared.SharedHttpClient
import com.icegps.shared.SharedJson
import com.icegps.shared.api.OpenElevation
import com.icegps.shared.api.OpenElevationApi
import com.icegps.shared.ktx.TAG
import com.icegps.shared.model.GeoPoint
import com.icegps.shared.model.IGeoPoint
import com.mapbox.geojson.Point
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -24,12 +25,28 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
class MainViewModel(private val context: Application) : AndroidViewModel(context) {
private val geoHelper = GeoHelper.Companion.getSharedInstance()
private val geoHelper = GeoHelper.getSharedInstance()
private val openElevation: OpenElevationApi = OpenElevation(SharedHttpClient(SharedJson()))
private val _points = MutableStateFlow<List<Point>>(emptyList())
val points = _points.filter { it.size > 3 }.debounce(1000).map {
openElevation.lookup(it.map { GeoPoint(it.longitude(), it.latitude(), it.altitude()) })
it.filter { it.altitude().isNaN() }.let {
if (it.isEmpty()) it.map {
GeoPoint(
longitude = it.longitude(),
latitude = it.latitude(),
altitude = it.altitude()
)
} else {
openElevation.lookup(it.map { GeoPoint(longitude = it.longitude(), latitude = it.latitude(), altitude = it.altitude()) })
}
} + it.filter { !it.altitude().isNaN() }.map {
GeoPoint(
longitude = it.longitude(),
latitude = it.latitude(),
altitude = it.altitude()
)
}
}.catch {
Log.e(TAG, "高程请求失败", it)
context.toast("高程请求失败")
@@ -40,7 +57,7 @@ class MainViewModel(private val context: Application) : AndroidViewModel(context
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Companion.Eagerly,
started = SharingStarted.Eagerly,
initialValue = emptyList()
)
@@ -53,6 +70,12 @@ class MainViewModel(private val context: Application) : AndroidViewModel(context
}
}
fun addPoints(points: List<IGeoPoint>) {
_points.value = points.map {
Point.fromLngLat(it.longitude, it.latitude, it.altitude)
}
}
fun clearPoints() {
_points.value = emptyList()
}

View File

@@ -0,0 +1,228 @@
package com.icegps.geotools
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.slider.RangeSlider
import com.google.android.material.slider.Slider
import com.icegps.common.helper.GeoHelper
import com.icegps.geotools.databinding.ActivityMapBinding
import com.icegps.math.geometry.degrees
import com.icegps.shared.ktx.TAG
import com.mapbox.geojson.Point
import com.mapbox.maps.CameraOptions
import com.mapbox.maps.MapView
import com.mapbox.maps.plugin.gestures.addOnMapClickListener
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
class MapActivity : AppCompatActivity() {
private lateinit var binding: ActivityMapBinding
private lateinit var mapView: MapView
private lateinit var terrainManager: TerrainManager
private lateinit var terrainUiController: TerrainUiController
private val pathGenerator = PathGenerator(home)
private val geoHelper = GeoHelper.getSharedInstance()
init {
initGeoHelper(home)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
binding = ActivityMapBinding.inflate(layoutInflater)
mapView = binding.mapView
terrainManager = TerrainManagerImpl(scope = lifecycleScope)
terrainUiController = TerrainUiControllerImpl(
context = this,
mapView = mapView,
scope = lifecycleScope,
terrainManager = terrainManager,
)
setContentView(binding.root)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets
}
initView()
initData()
}
private fun initView() {
setupMap()
setupCheckbox()
setupSlider()
binding.spinnerGridType.apply {
adapter = ArrayAdapter(
this@MapActivity,
android.R.layout.simple_spinner_item,
GridType.entries.map { it.text }
).apply {
this.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
}
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
terrainUiController.updateGridType(GridType.entries[position])
}
override fun onNothingSelected(parent: AdapterView<*>?) {
}
}
}
}
fun Slider.onValueChange(block: Slider.(Double) -> Unit) {
addOnSliderTouchListener(
object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {
}
override fun onStopTrackingTouch(slider: Slider) {
block(slider, slider.value.toDouble())
}
}
)
}
fun RangeSlider.onValueChange(block: RangeSlider.(ClosedFloatingPointRange<Double>) -> Unit) {
addOnSliderTouchListener(
object : RangeSlider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: RangeSlider) {
}
override fun onStopTrackingTouch(slider: RangeSlider) {
block(slider, slider.values.run { min().toDouble()..max().toDouble() })
}
}
)
}
fun setupSlider() {
binding.sliderCellSize.onValueChange { value ->
terrainManager.updateTerrainData { it.copy(cellSize = value) }
terrainManager.update()
}
binding.sliderSlopeDirection.onValueChange { value ->
terrainManager.updateTerrainData { it.copy(slopeDirection = value.degrees) }
}
binding.sliderSlopePercentage.onValueChange { value ->
terrainManager.updateTerrainData { it.copy(slopePercentage = value) }
}
binding.sliderTargetHeight.onValueChange { value ->
terrainManager.updateTerrainData { it.copy(targetHeight = value) }
terrainManager.update()
}
binding.sliderHeightRange.onValueChange { range ->
terrainManager.updateTerrainData { it.copy(heightRange = range) }
terrainManager.update()
}
}
private fun setupCheckbox() {
with(terrainUiController.terrainUiState.value) {
binding.checkboxTriangle.isChecked = isTriangleVisible
binding.checkboxGrid.isChecked = isGridVisible
binding.checkboxContourLine.isChecked = isContourVisible
binding.checkboxContourLineLabel.isChecked = isContourLabelingVisible
binding.checkboxGridHeightDiff.isChecked = isGridHeightDiffVisible
binding.checkboxControllableArrow.isChecked = isControllableArrowVisible
}
binding.checkboxTriangle.setOnCheckedChangeListener { _, isChecked ->
terrainUiController.setTriangleVisible(isChecked)
}
binding.checkboxGrid.setOnCheckedChangeListener { _, isChecked ->
terrainUiController.setGridVisible(isChecked)
}
binding.checkboxContourLine.setOnCheckedChangeListener { _, isChecked ->
terrainUiController.setContourVisible(isChecked)
}
binding.checkboxContourLineLabel.setOnCheckedChangeListener { _, isChecked ->
terrainUiController.setContourLabelingVisible(isChecked)
}
binding.checkboxGridHeightDiff.setOnCheckedChangeListener { _, isChecked ->
terrainUiController.setGridHeightDiffVisible(isChecked)
}
binding.checkboxControllableArrow.setOnCheckedChangeListener { _, isChecked ->
terrainUiController.setControllableArrowVisible(isChecked)
}
}
private fun setupMap() {
mapView.mapboxMap.setCamera(
CameraOptions.Builder()
.center(Point.fromLngLat(home.longitude, home.latitude))
.pitch(0.0)
.zoom(18.0)
.bearing(0.0)
.build()
)
mapView.mapboxMap.addOnMapClickListener { point ->
// point.toVector2D()
// terrainManager.addPoint()
true
}
}
private fun initData() {
setupMapData()
terrainManager.points.filter { it.size > 3 }.map {
val heightList = it.map { it.z }
val min = heightList.min().toFloat()
val max = heightList.max().toFloat()
Log.d(TAG, "initData: $min, $max")
binding.sliderHeightRange.apply {
values = listOf(min, max)
valueFrom = min
valueTo = max
}
binding.sliderTargetHeight.apply {
if (value !in min..max) {
value = heightList.average().toFloat()
}
valueFrom = min
valueTo = max
}
}.launchIn(lifecycleScope)
terrainManager.terrainData.onEach {
binding.tvSlopePercentage.text = "坡度:%.2f".format(it.slopePercentage)
binding.tvCurrentHeight.text = "当前高度:%.2f m".format(it.currentHeight)
binding.tvTargetHeight.text = "目标高度:%.2f m".format(it.targetHeight ?: it.optimalHeight)
binding.tvOptimalHeight.text = "最佳高度:%.2f m".format(it.optimalHeight)
}.launchIn(lifecycleScope)
terrainManager.earthworkResult.onEach {
binding.tvArea.text = "包围面积:%.2f m²".format(it.totalArea)
binding.tvCutVolume.text = "挖土方量:%.2f m³".format(it.cutVolume)
binding.tvFillVolume.text = "填土方量:%.2f m³".format(it.fillVolume)
}.launchIn(lifecycleScope)
}
private fun setupMapData() {
val points = pathGenerator.generatePath().map {
geoHelper.wgs84ToENU(lon = it.longitude, lat = it.latitude, hgt = it.altitude)
}.map {
Vector3D(it.x, it.y, it.z)
}
terrainManager.setPoints(points)
terrainManager.updateHeightRange()
terrainManager.update()
}
}
enum class GridType(
val text: String
) {
Origin("原始栅格"), Design("目标栅格")
}

View File

@@ -0,0 +1,214 @@
package com.icegps.geotools
import com.icegps.shared.model.GeoPoint
import com.icegps.shared.model.GeodeticCoordinate
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.random.Random
/**
* 路径生成器
*
* @param centerLon 区域中心经度
* @param centerLat 区域中心纬度
* @param widthMeters 区域宽度(米)
* @param heightMeters 区域高度(米)
* @param minHeight 最低高度(米)
* @param maxHeight 最高高度(米)
* @param numZigzagLines Zigzag 路径的线条数
* @param cornerRadiusRatio 圆角半径与较短边长的比例
* @param pointSpacingMeters 路径点之间的间隔(米)
*/
class PathGenerator(
private val centerLon: Double,
private val centerLat: Double,
private val widthMeters: Double,
private val heightMeters: Double,
private val minHeight: Double,
private val maxHeight: Double,
private val numZigzagLines: Int = 10,
private val cornerRadiusRatio: Double = 0.1,
private val pointSpacingMeters: Double = 5.0
) {
private val metersPerDegreeLat = 111000.0
private val metersPerDegreeLon = cos(centerLat.toRadians()) * 111000.0
private val width = widthMeters / metersPerDegreeLon
private val height = heightMeters / metersPerDegreeLat
private val cornerRadius = minOf(width, height) * cornerRadiusRatio
private val pointSpacing = pointSpacingMeters / metersPerDegreeLat
/**
* 创建带圆角的矩形边界
*/
private fun createRoundedRectangle(numPoints: Int = 100): Pair<List<Double>, List<Double>> {
val t = (0 until numPoints).map { it * 2 * PI / numPoints }
val corners = listOf(
Pair(centerLon + width / 2 - cornerRadius, centerLat + height / 2 - cornerRadius),
Pair(centerLon - width / 2 + cornerRadius, centerLat + height / 2 - cornerRadius),
Pair(centerLon - width / 2 + cornerRadius, centerLat - height / 2 + cornerRadius),
Pair(centerLon + width / 2 - cornerRadius, centerLat - height / 2 + cornerRadius)
)
val lon = mutableListOf<Double>()
val lat = mutableListOf<Double>()
corners.forEachIndexed { i, (x, y) ->
val quarter = numPoints / 4
lon.addAll((i * quarter until (i + 1) * quarter).map { x + cornerRadius * cos(t[it]) })
lat.addAll((i * quarter until (i + 1) * quarter).map { y + cornerRadius * sin(t[it]) })
}
lon.add(lon[0])
lat.add(lat[0])
return Pair(lon, lat)
}
/**
* 创建对角线 zigzag 路径
*/
private fun createDiagonalZigzagPath(startLon: Double, startLat: Double): Pair<List<Double>, List<Double>> {
val width = this.width
val height = this.height
val numLines = this.numZigzagLines
val lonStart = startLon
val lonEnd = if (startLon > centerLon) startLon - width else startLon + width
val latStart = startLat
val latEnd = if (startLat < centerLat) startLat + height else startLat - height
val latStep = abs(latEnd - latStart) / (numLines)
val pathLon = mutableListOf<Double>()
val pathLat = mutableListOf<Double>()
for (i in 0 until numLines) {
if (i % 2 == 0) {
pathLon.add(lonStart)
pathLon.add(lonEnd)
} else {
pathLon.add(lonEnd)
pathLon.add(lonStart)
}
val currentLat = if (latStart < latEnd) latStart + i * latStep else latStart - i * latStep
val nextLat = if (latStart < latEnd) latStart + (i + 1) * latStep else latStart - (i + 1) * latStep
pathLat.add(currentLat)
pathLat.add(nextLat)
}
val minLon = centerLon - width / 2
val maxLon = centerLon + width / 2
val minLat = centerLat - height / 2
val maxLat = centerLat + height / 2
pathLon.replaceAll { it.coerceIn(minLon, maxLon) }
pathLat.replaceAll { it.coerceIn(minLat, maxLat) }
return Pair(pathLon, pathLat)
}
/**
* 根据经纬度位置计算高度
*/
private fun calculateHeight(lon: Double, lat: Double): Double {
val normalizedPosition = (lon - (centerLon - width / 2)) / width
return minHeight + (maxHeight - minHeight) * normalizedPosition
}
/**
* 对给定的点进行插值,使路径更平滑
*/
private fun interpolatePoints(lon: List<Double>, lat: List<Double>): Pair<List<Double>, List<Double>> {
val dist = mutableListOf(0.0)
for (i in 1 until lon.size) {
dist.add(dist.last() + sqrt((lon[i] - lon[i - 1]).pow(2) + (lat[i] - lat[i - 1]).pow(2)))
}
val numPoints = (dist.last() / pointSpacing).toInt()
val newDist = (0 until numPoints).map { it * dist.last() / (numPoints - 1) }
val newLon = newDist.map { d -> lon.interpolate(dist, d) }
val newLat = newDist.map { d -> lat.interpolate(dist, d) }
return Pair(newLon, newLat)
}
/**
* 生成完整的路径,返回 GeodeticCoordinate 列表
*/
fun generatePath(): List<GeodeticCoordinate> {
val (boundaryLon, boundaryLat) = createRoundedRectangle()
val (interpolatedBoundaryLon, interpolatedBoundaryLat) = interpolatePoints(boundaryLon, boundaryLat)
val startLon = interpolatedBoundaryLon.first()
val startLat = interpolatedBoundaryLat.first()
val (pathLon, pathLat) = createDiagonalZigzagPath(startLon, startLat)
val (interpolatedPathLon, interpolatedPathLat) = interpolatePoints(pathLon, pathLat)
val allLon = interpolatedBoundaryLon + interpolatedPathLon
val allLat = interpolatedBoundaryLat + interpolatedPathLat
return allLon.zip(allLat).map { (lon, lat) ->
GeodeticCoordinate(lat, lon, calculateHeight(lon, lat))
}
}
/**
* 生成路径并以 Flow 形式发送 GeodeticCoordinate
*/
fun generatePathFlow(): Flow<GeodeticCoordinate> = flow {
val (boundaryLon, boundaryLat) = createRoundedRectangle()
val (interpolatedBoundaryLon, interpolatedBoundaryLat) = interpolatePoints(boundaryLon, boundaryLat)
val random = Random(1)
// 发送边界点
interpolatedBoundaryLon.zip(interpolatedBoundaryLat).forEach { (lon, lat) ->
emit(GeodeticCoordinate(lat, lon, calculateHeight(lon, lat)))
// emit(GeodeticCoordinate(lat, lon, 0.0))
// emit(GeodeticCoordinate(lat, lon, random.nextDouble(0.001, 0.002)))
}
val startLon = interpolatedBoundaryLon.first()
val startLat = interpolatedBoundaryLat.first()
val (pathLon, pathLat) = createDiagonalZigzagPath(startLon, startLat)
val (interpolatedPathLon, interpolatedPathLat) = interpolatePoints(pathLon, pathLat)
// 发送内部路径点
interpolatedPathLon.zip(interpolatedPathLat).forEach { (lon, lat) ->
emit(GeodeticCoordinate(lat, lon, calculateHeight(lon, lat)))
// emit(GeodeticCoordinate(lat, lon, 0.0))
// emit(GeodeticCoordinate(lat, lon, random.nextDouble(0.001, 0.002)))
}
}
private fun List<Double>.interpolate(x: List<Double>, xi: Double): Double {
val i = x.binarySearch(xi).let { if (it < 0) -(it + 1) - 1 else it }
if (i == x.lastIndex) return this.last()
val t = (xi - x[i]) / (x[i + 1] - x[i])
return this[i] * (1 - t) + this[i + 1] * t
}
private fun Double.toRadians() = this * PI / 180
}
fun PathGenerator(
center: GeoPoint = GeoPoint(longitude = 114.24357285, latitude = 22.70470464, altitude = 0.0)
): PathGenerator {
return PathGenerator(
centerLon = center.longitude,
centerLat = center.latitude,
widthMeters = 50.0,
heightMeters = 100.0,
minHeight = 100.0,
maxHeight = 102.0,
numZigzagLines = 10,
pointSpacingMeters = 5.0,
)
}

View File

@@ -0,0 +1,994 @@
package com.icegps.geotools
import android.content.Context
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.PointF
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.util.Log
import androidx.annotation.DrawableRes
import androidx.appcompat.content.res.AppCompatResources
import com.icegps.geotools.catmullrom.CatmullRomChain2
import com.icegps.geotools.ktx.area
import com.icegps.geotools.ktx.optimalHeight
import com.icegps.geotools.ktx.toMapboxPoint
import com.icegps.geotools.ktx.toVector2D
import com.icegps.geotools.ktx.toast
import com.icegps.geotools.marchingsquares.ShapeContour
import com.icegps.geotools.marchingsquares.findContours
import com.icegps.math.geometry.Angle
import com.icegps.math.geometry.Rectangle
import com.icegps.math.geometry.degrees
import com.icegps.shared.ktx.TAG
import com.icegps.shared.model.GeoPoint
import com.icegps.triangulation.DelaunayTriangulation
import com.icegps.triangulation.Triangle
import com.mapbox.android.gestures.MoveGestureDetector
import com.mapbox.geojson.Feature
import com.mapbox.geojson.FeatureCollection
import com.mapbox.geojson.Point
import com.mapbox.geojson.Polygon
import com.mapbox.maps.MapView
import com.mapbox.maps.ScreenCoordinate
import com.mapbox.maps.Style
import com.mapbox.maps.extension.style.expressions.generated.Expression
import com.mapbox.maps.extension.style.layers.addLayer
import com.mapbox.maps.extension.style.layers.generated.fillLayer
import com.mapbox.maps.extension.style.layers.properties.generated.IconAnchor
import com.mapbox.maps.extension.style.layers.properties.generated.TextAnchor
import com.mapbox.maps.extension.style.sources.addSource
import com.mapbox.maps.extension.style.sources.generated.geoJsonSource
import com.mapbox.maps.plugin.annotation.Annotation
import com.mapbox.maps.plugin.annotation.annotations
import com.mapbox.maps.plugin.annotation.generated.OnPointAnnotationDragListener
import com.mapbox.maps.plugin.annotation.generated.PointAnnotation
import com.mapbox.maps.plugin.annotation.generated.PointAnnotationManager
import com.mapbox.maps.plugin.annotation.generated.PointAnnotationOptions
import com.mapbox.maps.plugin.annotation.generated.createPointAnnotationManager
import com.mapbox.maps.plugin.gestures.OnMoveListener
import com.mapbox.maps.plugin.gestures.addOnMoveListener
import com.mapbox.maps.plugin.gestures.removeOnMoveListener
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
// typealias GeoPoint = com.mapbox.geojson.Point
typealias GeoPoint = GeoPoint
typealias Vector2D = com.icegps.math.geometry.Vector2D
typealias Vector3D = com.icegps.math.geometry.Vector3D
/**
* 地形图数据管理
*
* @author tabidachinokaze
* @date 2025/11/27
*/
interface TerrainManager {
/**
* 地形图数据
*/
val terrainData: StateFlow<TerrainData>
/**
* 土方量数据
*/
val earthworkResult: StateFlow<EarthworkResult>
/**
* 坐标点集合
*/
val points: StateFlow<List<Vector3D>>
/**
* 等高线
*/
val contoursLines: StateFlow<List<List<ShapeContour>>>
fun updateTerrainData(block: (TerrainData) -> TerrainData)
fun updateTargetHeight(block: (ClosedFloatingPointRange<Double>) -> Double)
fun addPoint(point: Vector3D)
fun setPoints(points: List<Vector3D>)
fun clearPoints()
fun updateHeightRange(range: ClosedFloatingPointRange<Double>? = null)
fun update()
}
class TerrainManagerImpl(
private val scope: CoroutineScope
) : TerrainManager {
private val _terrainData = MutableStateFlow(TerrainData.Empty)
override val terrainData: StateFlow<TerrainData> = _terrainData.asStateFlow()
private val _earthworkResult = MutableStateFlow(EarthworkResult.Empty)
override val earthworkResult: StateFlow<EarthworkResult> = _earthworkResult.asStateFlow()
private val _points = MutableStateFlow<List<Vector3D>>(emptyList())
override val points: StateFlow<List<Vector3D>> = _points.asStateFlow()
private val _contoursLines = MutableStateFlow<List<List<ShapeContour>>>(emptyList())
override val contoursLines: StateFlow<List<List<ShapeContour>>> = _contoursLines.asStateFlow()
data class Params4<
out P1,
out P2,
out P3,
out P4,
>(
val p1: P1,
val p2: P2,
val p3: P3,
val p4: P4,
)
init {
terrainData.map {
val gridModel = it.gridModel
val slopeDirection = it.slopeDirection
val slopePercentage = it.slopePercentage
val targetHeight = it.targetHeight ?: it.optimalHeight
gridModel?.let {
Params4(
p1 = gridModel,
p2 = slopeDirection,
p3 = slopePercentage,
p4 = targetHeight,
)
}
}.filterNotNull().distinctUntilChanged().map { (gridModel, slopeDirection, slopePercentage, targetHeight) ->
SlopeCalculator.calculateSlopeByTargetHeight(
grid = gridModel,
slopeDirection = slopeDirection.degrees,
slopePercentage = slopePercentage,
targetHeight = targetHeight
)
}.onEach { result ->
_terrainData.update { it.copy(designGrid = result.designSurface) }
_earthworkResult.value = result.earthworkResult
}.launchIn(scope)
}
override fun updateTerrainData(block: (TerrainData) -> TerrainData) {
_terrainData.update(block)
}
private var updateTargetHeightJob: Job? = null
override fun updateTargetHeight(block: (ClosedFloatingPointRange<Double>) -> Double) {
updateTargetHeightJob?.cancel()
updateTargetHeightJob = scope.launch(Dispatchers.IO) {
val heightRange = terrainData.value.heightRange
val targetHeight = block(heightRange)
updateTerrainData { it.copy(targetHeight = targetHeight) }
val area = points.value.area
val cellSize = terrainData.value.cellSize
val triangles = terrainData.value.triangles
updateContoursLines(
heightRange = heightRange,
area = area,
cellSize = cellSize,
triangles = triangles,
targetHeightRange = targetHeight..targetHeight
)
}
}
override fun addPoint(point: Vector3D) {
_points.update {
it.toMutableList().apply { add(point) }
}
}
override fun setPoints(points: List<Vector3D>) {
_points.value = points
}
override fun clearPoints() {
_points.value = emptyList()
}
override fun updateHeightRange(range: ClosedFloatingPointRange<Double>?) {
val points = _points.value
if (range == null && points.isNotEmpty()) {
val heightList = points.map { it.z }
updateTerrainData { it.copy(heightRange = heightList.min()..heightList.max()) }
}
if (range != null) {
updateTerrainData { it.copy(heightRange = range) }
}
}
/*private val _gridModel = MutableStateFlow<GridModel?>(null)
val triangles = _points.map {
DelaunayTriangulation(it).triangles()
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyList()
)
val gridModel = combine(
triangles,
_terrainData.map { it.cellSize }.distinctUntilChanged()
) { triangles, cellSize ->
}*/
private var updateJob: Job? = null
// points, cellSize, heightRange, targetHeight
override fun update() {
updateJob?.cancel()
updateJob = scope.launch(Dispatchers.IO) {
val points = _points.value
val area = points.area
val triangles = DelaunayTriangulation(points).triangles()
val cellSize = terrainData.value.cellSize
val gridModel = triangulationToGrid(
area = area,
triangles = triangles,
cellSize = cellSize,
)
val optimalHeight = gridModel.optimalHeight()
val heightRange = terrainData.value.heightRange
val targetHeight = terrainData.value.targetHeight ?: optimalHeight
Log.d(
TAG,
buildString {
appendLine("update: ")
appendLine("points size = ${points.size}")
appendLine("area = $area")
appendLine("triangles size = ${triangles.size}")
appendLine("cellSize = $cellSize")
appendLine("gridModel = $gridModel")
appendLine("optimalHeight = $optimalHeight")
appendLine("heightRange = $heightRange")
appendLine("targetHeight = $targetHeight")
}
)
updateContoursLines(
heightRange = heightRange,
area = area,
cellSize = cellSize,
triangles = triangles,
targetHeightRange = targetHeight..targetHeight
)
updateTerrainData {
it.copy(triangles = triangles, gridModel = gridModel, optimalHeight = optimalHeight)
}
}
}
suspend fun updateContoursLines(
heightRange: ClosedFloatingPointRange<Double>,
area: Rectangle,
cellSize: Double,
triangles: List<Triangle>,
targetHeightRange: ClosedFloatingPointRange<Double>
) = coroutineScope {
val contoursLines: List<List<ShapeContour>> = getContoursLines(
heightRange = heightRange,
targetHeightRange = targetHeightRange
).map { range ->
async {
findContours(
triangles = triangles,
range = range,
area = area,
cellSize = cellSize
)
}
}.awaitAll()
_contoursLines.value = contoursLines
}
private fun findContours(
triangles: List<Triangle>,
range: ClosedFloatingPointRange<Double>,
area: Rectangle,
cellSize: Double
): List<ShapeContour> {
return findContours(
f = { v ->
val triangle = triangles.firstOrNull { triangle ->
isPointInTriangle3D(v, listOf(triangle.x1, triangle.x2, triangle.x3))
}
(triangle?.let { triangle ->
val interpolate = interpolateHeight(
point = v,
triangle = listOf(
triangle.x1,
triangle.x2,
triangle.x3,
)
)
if (interpolate.z in range) -1.0
else 1.0
} ?: 1.0).also {
if (false) Log.d(TAG, "findContours: ${v} -> ${it}")
}
},
area = area,
cellSize = cellSize,
)
}
private fun getContoursLines(
heightRange: ClosedFloatingPointRange<Double>,
targetHeightRange: ClosedFloatingPointRange<Double>
): List<ClosedFloatingPointRange<Double>> {
val frontHalfHeight = targetHeightRange.start - heightRange.start
val backHalfHeight = heightRange.endInclusive - targetHeightRange.endInclusive
val frontHalfStep = frontHalfHeight / 6
val backHalfStep = backHalfHeight / 6
val frontHalfZip = (0..6).map { index ->
heightRange.start + index * frontHalfStep
}.zipWithNext { a, b -> a..b }
val backHalfZip = (0..6).map { index ->
targetHeightRange.endInclusive + index * backHalfStep
}.zipWithNext { a, b -> a..b }
return frontHalfZip + listOf(targetHeightRange) + backHalfZip
}
}
/**
* @property slopeDirection 坡向
* @property slopePercentage 坡度0~100
* @property currentHeight 当前高度
* @property targetHeight 目标高度
* @property cellSize 栅格大小
* @property heightRange 高度范围
* @property gridModel 原始栅格数据
* @property designGrid 目标栅格数据
* @property triangles 三角网数据
* @property optimalHeight 最佳高度
*/
data class TerrainData(
val slopeDirection: Angle,
val slopePercentage: Double,
val currentHeight: Double?,
val targetHeight: Double?,
val cellSize: Double,
val heightRange: ClosedFloatingPointRange<Double>,
val gridModel: GridModel?,
val designGrid: GridModel?,
val triangles: List<Triangle>,
val optimalHeight: Double,
) {
companion object {
val Empty = TerrainData(
slopeDirection = 0.degrees,
slopePercentage = 0.0,
currentHeight = null,
targetHeight = null,
cellSize = 10.0,
heightRange = 0.0..100.0,
gridModel = null,
designGrid = null,
triangles = emptyList(),
optimalHeight = 0.0,
)
}
}
/**
* @property isTriangleVisible 三角网图层
* @property isGridVisible 栅格图层
* @property isContourVisible 等高线图层
* @property isContourLabelingVisible 等高线标注图层
* @property isGridHeightDiffVisible 高差数字图层
* @property isControllableArrowVisible 地形趋势箭头图层
* @property useCatmullRom 是否启用 CatmullRom
* @property gridType 栅格类型
*/
data class TerrainUiState(
val isTriangleVisible: Boolean,
val isGridVisible: Boolean,
val isContourVisible: Boolean,
val isContourLabelingVisible: Boolean,
val isGridHeightDiffVisible: Boolean,
val isControllableArrowVisible: Boolean,
val useCatmullRom: Boolean,
val gridType: GridType
) {
companion object {
val Empty = TerrainUiState(
isTriangleVisible = true,
isGridVisible = true,
isContourVisible = true,
isContourLabelingVisible = true,
isGridHeightDiffVisible = true,
isControllableArrowVisible = true,
useCatmullRom = true,
gridType = GridType.Origin
)
}
}
/**
* 地形图 UI 控制器
*
* @author tabidachinokaze
* @date 2025/11/27
*/
interface TerrainUiController {
/**
* 地形图 UI 状态
*/
val terrainUiState: StateFlow<TerrainUiState>
/**
* 等高线颜色
*/
val colors: StateFlow<List<String>>
fun setTriangleVisible(visible: Boolean)
fun setGridVisible(visible: Boolean)
fun setContourVisible(visible: Boolean)
fun setContourLabelingVisible(visible: Boolean)
fun setGridHeightDiffVisible(visible: Boolean)
fun setControllableArrowVisible(visible: Boolean)
fun enabledCatmullRom(enabled: Boolean)
fun updateTriangle(triangles: List<Triangle>)
fun updateGrid(gridModel: GridModel)
fun updateContour(contoursLines: List<List<ShapeContour>>, useCatmullRom: Boolean)
fun updateGridHeightDiff()
fun updateControllableArrow()
fun updateGridType(type: GridType)
}
interface TerrainPalette {
fun getColor(index: Int): String
}
class ContourPalette() : TerrainPalette {
override fun getColor(index: Int): String {
return CONTOUR_COLORS[index]
}
companion object {
val CONTOUR_COLORS = arrayOf(
"#CC0000", "#FF0000", "#FF3333", "#FF6666", "#FF9999", "#FFCCCC",
"#8CED8C", "#B3D9FF", "#80BFFF", "#4D94FF", "#2673FF", "#004CFF", "#1517EC"
).reversed()
}
}
class DraggableMarker(
private val context: Context,
private val mapView: MapView,
private val onPositionChange: (Point) -> Unit,
private val setHeight: DraggableMarker.(PointAnnotation) -> Unit
) {
private var marker: PointAnnotation? = null
private var annotationManager: PointAnnotationManager? = null
private fun drawableToBitmap(drawable: Drawable): Bitmap {
if (drawable is BitmapDrawable) {
return drawable.bitmap
}
val bitmap = Bitmap.createBitmap(
drawable.intrinsicWidth,
drawable.intrinsicHeight,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
return bitmap
}
fun addMarker(point: Point) {
mapView.mapboxMap.getStyle { style ->
// 从资源加载图标
val drawable = AppCompatResources.getDrawable(mapView.context, R.drawable.ic_marker_red)
style.addImage("marker-icon", drawableToBitmap(drawable!!))
// 创建标记管理器
annotationManager = mapView.annotations.createPointAnnotationManager()
// 创建可拖动标记
marker = annotationManager?.create(
PointAnnotationOptions()
.withPoint(point)
.withIconImage("marker-icon")
.withIconAnchor(IconAnchor.BOTTOM) // 图标底部对准位置
.withDraggable(true) // 启用拖动
.withTextField("") // 设置坐标文本
.withTextColor("#FFFFFF") // 白色文字
.withTextHaloColor("#000000") // 黑色文字背景
.withTextHaloWidth(1.0)
.withTextSize(12.0)
.withTextAnchor(TextAnchor.TOP) // 文本在标记上方
)
// 设置拖动监听
annotationManager?.addDragListener(
object : OnPointAnnotationDragListener {
override fun onAnnotationDrag(annotation: Annotation<*>) {
//marker?.point?.let { onPositionChange(it) }
if (annotation is PointAnnotation) {
setHeight(this@DraggableMarker, annotation)
}
}
override fun onAnnotationDragFinished(annotation: Annotation<*>) {
if (annotation is PointAnnotation) {
onPositionChange(annotation.point)
}
}
override fun onAnnotationDragStarted(annotation: Annotation<*>) {
//bringMarkerToTop()
}
}
)
}
}
fun updateMarkerText(annotation: PointAnnotation) {
annotation.textField = getCoordinateText(annotation.point)
annotationManager?.update(annotation)
}
fun updateMarkerText(annotation: PointAnnotation, text: String) {
annotation.textField = text
annotationManager?.update(annotation)
}
private fun getCoordinateText(point: Point): String {
return String.format("(%.4f, %.4f)", point.longitude(), point.latitude())
}
private fun bringMarkerToTop() {
val currentPosition = getMarkerPosition()
if (currentPosition != null) {
// 移除并重新添加标记,确保在最上层
removeMarker()
addMarker(currentPosition)
}
}
fun getMarkerPosition(): Point? {
return marker?.point
}
fun removeMarker() {
marker?.let {
annotationManager?.let {
mapView.annotations.removeAnnotationManager(annotationManager = it)
}
}
}
}
class TerrainUiControllerImpl(
private val context: Context,
private val mapView: MapView,
private val scope: CoroutineScope,
private val terrainManager: TerrainManager,
) : TerrainUiController {
private val _terrainUiState = MutableStateFlow(TerrainUiState.Empty)
override val terrainUiState: StateFlow<TerrainUiState> = _terrainUiState.asStateFlow()
private val _colors = MutableStateFlow(ContourPalette.CONTOUR_COLORS)
override val colors: StateFlow<List<String>> = _colors.asStateFlow()
private val markerPosition = MutableStateFlow<Vector2D>(Vector2D.ZERO)
private val arrowHead = MutableStateFlow(emptyList<Vector2D>())
private val arrowCenter = MutableStateFlow(Vector2D(0.0, 0.0))
private val arrowEnd = MutableStateFlow(Vector2D(0.0, 1.0))
private val gridPalette = SimplePalette(0.0..100.0)
private var listener: OnMoveListener? = null
private val draggableMarker = DraggableMarker(
context, mapView,
onPositionChange = {
Log.d(TAG, "draggableMarker: ${it.longitude()}, ${it.latitude()}")
markerPosition.value = it.toVector2D()
},
setHeight = { annotation ->
scope.launch {
val heightAt = terrainManager.terrainData.value.designGrid?.getHeightAt(annotation.point.toVector2D())
heightAt?.let {
this@DraggableMarker.updateMarkerText(annotation, "%.2f m".format(it))
}
}
}
)
init {
setupControllableArrow()
setupOnMoveListener()
terrainManager.terrainData.onEach {
it.slopeDirection
it.slopePercentage
}.launchIn(scope)
terrainManager.terrainData.onEach {
it.gridModel
}.launchIn(scope)
combine(
terrainManager.terrainData.map { it.designGrid }.distinctUntilChanged(),
markerPosition
) { designGrid, markerPosition ->
// [0][2][4][6]
// cellSize = 2
//
designGrid?.let { designGrid ->
val heightAt = designGrid.getHeightAt(markerPosition)
terrainManager.updateTerrainData { it.copy(currentHeight = heightAt) }
}
}.launchIn(scope)
combine(
terrainManager.terrainData.map { data ->
data.gridModel?.let { origin ->
data.designGrid?.let { design ->
origin to design
}
}
}.filterNotNull().distinctUntilChanged(),
terrainUiState.map { it.isGridVisible to it.gridType }.distinctUntilChanged()
) { a, b ->
val (origin, design) = a
val (isGridVisible, gridType) = b
if (isGridVisible) {
when (gridType) {
GridType.Origin -> updateGrid(origin)
GridType.Design -> updateGrid(design)
}
} else {
removeLayer(GRID_LAYER_ID, GRID_SOURCE_ID)
}
}.launchIn(scope)
combine(
terrainManager.contoursLines,
terrainUiState.map {
it.isContourVisible to it.useCatmullRom
}.distinctUntilChanged()
) { contoursLines, pair ->
val (isContourVisible, useCatmullRom) = pair
if (isContourVisible) {
updateContour(contoursLines, useCatmullRom)
} else {
removeLayer(layerId = CONTOUR_LAYER_ID, sourceId = CONTOUR_SOURCE_ID)
}
}.launchIn(scope)
terrainManager.terrainData.map { it.heightRange }.distinctUntilChanged().onEach {
gridPalette.setRange(it)
}.launchIn(scope)
combine(
terrainManager.terrainData.map { it.triangles }.distinctUntilChanged(),
terrainUiState.map { it.isTriangleVisible }.distinctUntilChanged()
) { triangles, isTriangleVisible ->
if (isTriangleVisible) {
updateTriangle(triangles)
} else {
removeLayer(layerId = TRIANGLE_LAYER_ID, sourceId = TRIANGLE_SOURCE_ID)
}
}.launchIn(scope)
terrainManager.points.filter { it.isNotEmpty() }.take(1).onEach {
delay(3000)
draggableMarker.addMarker(it.first().toMapboxPoint())
}.launchIn(scope)
}
object BitmapUtils {
/**
* 从资源获取 Bitmap
*/
fun getBitmapFromResource(resources: Resources, @DrawableRes resId: Int): Bitmap {
return BitmapFactory.decodeResource(resources, resId)
}
/**
* 调整 Bitmap 大小
*/
fun resizeBitmap(bitmap: Bitmap, width: Int, height: Int): Bitmap {
return Bitmap.createScaledBitmap(bitmap, width, height, false)
}
}
private fun loadCustomIcons(style: Style) {
// 方法1从资源加载
val resourceIcon = BitmapUtils.getBitmapFromResource(mapView.context.resources, R.drawable.ic_marker_red)
style.addImage("custom-marker", resourceIcon)
}
fun setupControllableArrow() {
combine(
arrowCenter,
arrowEnd,
terrainManager.terrainData.map { it.gridModel }.filterNotNull().distinctUntilChanged(),
terrainUiState.map { it.isControllableArrowVisible }.distinctUntilChanged()
) { center, arrow, gridModel, isControllableArrowVisible ->
if (isControllableArrowVisible) {
displayControllableArrow(gridModel, getSlopeDirection(arrow, center))
}
}.launchIn(scope)
val isControllableArrowVisibleFlow = terrainUiState.map { it.isControllableArrowVisible }.distinctUntilChanged()
terrainManager.terrainData.map {
it.gridModel?.let { gridModel ->
gridModel to it.slopeDirection
}
}.filterNotNull().distinctUntilChanged().combine(isControllableArrowVisibleFlow) { pair, isControllableArrowVisible ->
val (gridModel, slopeDirection) = pair
if (isControllableArrowVisible) {
displayControllableArrow(
gridModel = gridModel,
slopeDirection = slopeDirection
)
}
}.launchIn(scope)
}
fun removeOnMoveListener() {
listener?.let(mapView.mapboxMap::removeOnMoveListener)
listener = null
}
fun setupOnMoveListener() {
removeOnMoveListener()
listener = object : OnMoveListener {
private var beginning: Boolean = false
private var isArrowDragging: Boolean = false
private var isMarkerDragging: Boolean = false
private val tolerance = 1.0
private fun getCoordinate(focalPoint: PointF): Point {
return mapView.mapboxMap.coordinateForPixel(ScreenCoordinate(focalPoint.x.toDouble(), focalPoint.y.toDouble()))
}
override fun onMove(detector: MoveGestureDetector): Boolean {
val focalPoint = detector.focalPoint
val point = mapView.mapboxMap
.coordinateForPixel(ScreenCoordinate(focalPoint.x.toDouble(), focalPoint.y.toDouble()))
.toVector2D()
val isPointInPolygon = RayCastingAlgorithm.isPointInPolygon(
point = point,
polygon = arrowHead.value
)
if (isPointInPolygon) {
isArrowDragging = true
}
if (isArrowDragging) {
arrowEnd.value = point
}
if (point.distanceTo(markerPosition.value) < tolerance) {
isMarkerDragging = true
}
if (isMarkerDragging) {
markerPosition.value = point
}
return isArrowDragging || isMarkerDragging
}
override fun onMoveBegin(detector: MoveGestureDetector) {
Log.d(TAG, "onMoveBegin: $detector")
beginning = true
}
override fun onMoveEnd(detector: MoveGestureDetector) {
Log.d(TAG, "onMoveEnd: $detector")
if (beginning && isArrowDragging) {
val arrow = getCoordinate(detector.focalPoint).toVector2D()
arrowEnd.value = arrow
val center = arrowCenter.value
terrainManager.updateTerrainData { it.copy(slopeDirection = getSlopeDirection(arrow, center)) }
}
if (beginning && isMarkerDragging) {
val point = getCoordinate(detector.focalPoint).toVector2D()
markerPosition.value = point
}
beginning = false
isArrowDragging = false
isMarkerDragging = false
}
}.also(mapView.mapboxMap::addOnMoveListener)
}
private fun displayControllableArrow(gridModel: GridModel, slopeDirection: Angle) {
val arrowData = calculateArrowData(
grid = gridModel,
angle = slopeDirection,
)
arrowHead.value = arrowData.headRing
mapView.displayControllableArrow(
sourceId = CONTROLLABLE_ARROW_SOURCE_ID,
layerId = CONTROLLABLE_ARROW_LAYER_ID,
arrowData = arrowData,
)
}
private fun getSlopeDirection(
arrow: Vector2D,
center: Vector2D
): Angle {
val direction = (arrow - center)
val atan2 = Angle.atan2(direction.x, direction.y, Vector2D.UP)
val angle = atan2.normalized
return angle
}
private fun removeLayer(
layerId: String,
sourceId: String
) {
mapView.mapboxMap.removeStyleLayer(layerId)
mapView.mapboxMap.removeStyleSource(sourceId)
}
override fun setTriangleVisible(visible: Boolean) {
_terrainUiState.update { it.copy(isTriangleVisible = visible) }
}
override fun setGridVisible(visible: Boolean) {
_terrainUiState.update { it.copy(isGridVisible = visible) }
}
override fun setContourVisible(visible: Boolean) {
_terrainUiState.update { it.copy(isContourVisible = visible) }
}
private fun setupFillLayer(
style: Style,
sourceId: String,
layerId: String,
features: List<Feature>
) {
style.removeStyleLayer(layerId)
style.removeStyleSource(sourceId)
val source = geoJsonSource(sourceId) {
featureCollection(FeatureCollection.fromFeatures(features))
}
style.addSource(source)
val layer = fillLayer(layerId, sourceId) {
fillColor(Expression.toColor(Expression.get("color"))) // 从属性获取颜色
fillOpacity(1.0)
fillAntialias(true)
}
style.addLayer(layer)
}
private fun contoursToPolygonFeatures(contours: List<ShapeContour>, color: String, useCatmullRom: Boolean): Feature? {
val lists = contours.drop(0).filter { it.segments.isNotEmpty() }.map { contour ->
val start = contour.segments[0].start
listOf(start) + contour.segments.map { it.end }
}.map {
if (!useCatmullRom) return@map it
val cmr = CatmullRomChain2(it, 1.0, loop = true)
val contour = ShapeContour.fromPoints(cmr.positions(200), true)
val start = contour.segments[0].start
listOf(start) + contour.segments.map { it.end }
}.map { points -> points.map { it.toMapboxPoint() } }
if (lists.isEmpty()) {
Log.w(TAG, "contoursToPolygonFeatures: 没有有效的轮廓数据")
return null
}
val polygon = Polygon.fromLngLats(lists)
return Feature.fromGeometry(polygon).apply {
addStringProperty("color", color)
}
}
override fun setContourLabelingVisible(visible: Boolean) {
context.toast("Not yet implemented")
}
override fun setGridHeightDiffVisible(visible: Boolean) {
context.toast("Not yet implemented")
}
override fun setControllableArrowVisible(visible: Boolean) {
if (visible != terrainUiState.value.isControllableArrowVisible) {
_terrainUiState.update { it.copy(isControllableArrowVisible = visible) }
if (visible) {
with(terrainManager.terrainData.value) {
gridModel?.let { gridModel ->
displayControllableArrow(
gridModel = gridModel,
slopeDirection = slopeDirection
)
}
}
} else {
mapView.mapboxMap.removeStyleLayer("$CONTROLLABLE_ARROW_LAYER_ID-head")
removeLayer(
layerId = CONTROLLABLE_ARROW_LAYER_ID,
sourceId = CONTROLLABLE_ARROW_SOURCE_ID
)
}
}
}
override fun enabledCatmullRom(enabled: Boolean) {
_terrainUiState.update {
it.copy(useCatmullRom = enabled)
}
}
override fun updateTriangle(triangles: List<Triangle>) {
mapView.displayTriangle(
triangles = triangles,
sourceId = TRIANGLE_SOURCE_ID,
layerId = TRIANGLE_LAYER_ID
)
}
override fun updateGrid(gridModel: GridModel) {
mapView.displayGridModel(
grid = gridModel,
sourceId = GRID_SOURCE_ID,
layerId = GRID_LAYER_ID,
palette = gridPalette::palette
)
}
override fun updateContour(contoursLines: List<List<ShapeContour>>, useCatmullRom: Boolean) {
val features = contoursLines.mapIndexed { index, contours ->
contoursToPolygonFeatures(
contours = contours,
color = colors.value[index],
useCatmullRom = useCatmullRom
)
}
mapView.mapboxMap.getStyle { style ->
setupFillLayer(
style = style,
sourceId = CONTOUR_SOURCE_ID,
layerId = CONTOUR_LAYER_ID,
features = features.filterNotNull()
)
}
}
override fun updateGridHeightDiff() {
TODO("Not yet implemented")
}
override fun updateControllableArrow() {
TODO("Not yet implemented")
}
override fun updateGridType(type: GridType) {
_terrainUiState.update { it.copy(gridType = type) }
}
companion object {
const val TRIANGLE_LAYER_ID = "triangle_layer_id"
const val GRID_LAYER_ID = "grid_layer_id"
const val CONTOUR_LAYER_ID = "contour_layer_id"
const val CONTOUR_LABELING_LAYER_ID = "contour_labeling_layer_id"
const val GRID_HEIGHT_DIFF_LAYER_ID = "grid_height_diff_layer_id"
const val CONTROLLABLE_ARROW_LAYER_ID = "controllable_arrow_layer_id"
const val TRIANGLE_SOURCE_ID = "triangle_source_id"
const val GRID_SOURCE_ID = "grid_source_id"
const val CONTOUR_SOURCE_ID = "contour_source_id"
const val CONTOUR_LABELING_SOURCE_ID = "contour_labeling_source_id"
const val GRID_HEIGHT_DIFF_SOURCE_ID = "grid_height_diff_source_id"
const val CONTROLLABLE_ARROW_SOURCE_ID = "controllable_arrow_source_id"
}
}

View File

@@ -0,0 +1,54 @@
package com.icegps.geotools
import com.icegps.geotools.ktx.toMapboxPoint
import com.icegps.triangulation.Triangle
import com.mapbox.geojson.Feature
import com.mapbox.geojson.FeatureCollection
import com.mapbox.geojson.LineString
import com.mapbox.maps.MapView
import com.mapbox.maps.extension.style.layers.addLayer
import com.mapbox.maps.extension.style.layers.generated.lineLayer
import com.mapbox.maps.extension.style.layers.properties.generated.LineJoin
import com.mapbox.maps.extension.style.sources.addSource
import com.mapbox.maps.extension.style.sources.generated.geoJsonSource
/**
* @author tabidachinokaze
* @date 2025/11/28
*/
fun MapView.displayTriangle(
triangles: List<Triangle>,
sourceId: String,
layerId: String
) {
val features = triangles.map {
listOf(it.x1, it.x2, it.x3)
}.map {
fromPoints(points = it, closed = true)
}.map {
it.map { line ->
val lineA = line.a.toMapboxPoint()
val lineB = line.b.toMapboxPoint()
LineString.fromLngLats(listOf(lineA, lineB))
}.map {
Feature.fromGeometry(it)
}
}.flatten()
mapboxMap.getStyle { style ->
style.removeStyleLayer(layerId)
style.removeStyleSource(sourceId)
val source = geoJsonSource(sourceId) {
featureCollection(FeatureCollection.fromFeatures(features))
}
style.addSource(source)
val layer = lineLayer(layerId, sourceId) {
lineWidth(1.5)
lineJoin(LineJoin.ROUND)
lineOpacity(1.0)
lineColor("#ff0000")
}
style.addLayer(layer)
}
}

View File

@@ -0,0 +1,17 @@
package com.icegps.geotools.ktx
import com.icegps.shared.model.GeodeticCoordinate
/**
* @author tabidachinokaze
* @date 2025/11/27
*/
fun GeodeticCoordinate.niceStr(): String {
return "[$longitude, $latitude, $altitude]".format(this)
}
fun List<GeodeticCoordinate>.niceStr(): String {
return joinToString(", ", "[", "]") {
it.niceStr()
}
}

View File

@@ -0,0 +1,49 @@
package com.icegps.geotools.ktx
import com.icegps.geotools.GridModel
/**
* @author tabidachinokaze
* @date 2025/11/27
*/
fun GridModel.volume(targetHeight: Double): Double {
val cellArea = cellSize * cellSize
var volume = 0.0
for (r in 0 until rows) {
for (c in 0 until cols) {
getValue(r, c) ?: continue
volume += cellArea * targetHeight
}
}
return volume
}
fun GridModel.averageHeight(): Double {
return cells.filterNotNull().average()
}
fun GridModel.optimalHeight(): Double {
val cellArea = cellSize * cellSize
var volume = 0.0
var count = 0
for (r in 0 until rows) {
for (c in 0 until cols) {
val height = getValue(r, c) ?: continue
volume += height * cellArea
count++
}
}
return volume / count / cellArea
}
fun GridModel.volumeSum(): Double {
val cellArea = cellSize * cellSize
var volume = 0.0
for (r in 0 until rows) {
for (c in 0 until cols) {
val height = getValue(r, c) ?: continue
volume += cellArea * height
}
}
return volume
}

View File

@@ -0,0 +1,15 @@
package com.icegps.geotools.ktx
import com.icegps.common.helper.GeoHelper
import com.icegps.math.geometry.Vector2D
import com.mapbox.geojson.Point
/**
* @author tabidachinokaze
* @date 2025/11/28
*/
fun Point.toVector2D(): Vector2D {
val geoHelper = GeoHelper.getSharedInstance()
val enu = geoHelper.wgs84ToENU(lon = longitude(), lat = latitude(), hgt = 0.0)
return Vector2D(enu.x, enu.y)
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M512,85.3c-164.9,0 -298.7,133.7 -298.7,298.7 0,164.9 298.7,554.7 298.7,554.7s298.7,-389.7 298.7,-554.7c0,-164.9 -133.7,-298.7 -298.7,-298.7zM512,533.3a149.3,149.3 0,1 1,0 -298.7,149.3 149.3,0 0,1 0,298.7z"
android:fillColor="#FF3D00"/>
</vector>

View File

@@ -7,11 +7,78 @@
android:orientation="vertical"
tools:context=".MainActivity">
<com.mapbox.maps.MapView
android:id="@+id/map_view"
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="3" />
android:layout_weight="3">
<com.mapbox.maps.MapView
android:id="@+id/map_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#4D000000"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tv_slope_percentage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="坡度0.00°" />
<TextView
android:id="@+id/tv_area"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="包围面积114514.0 m²" />
<TextView
android:id="@+id/tv_current_height"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="当前高度114514.0 m" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical">
<TextView
android:id="@+id/tv_target_height"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="目标高度114514.0 m" />
<ImageButton
android:id="@+id/target_height_button"
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@drawable/ic_launcher_foreground" />
</LinearLayout>
<TextView
android:id="@+id/tv_optimal_height"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="最佳高度114514.0" />
<TextView
android:id="@+id/tv_cut_volume"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="挖土方量114514.0 m³" />
<TextView
android:id="@+id/tv_fill_volume"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="填土方量114514.0 m³" />
</LinearLayout>
</FrameLayout>
<ScrollView
android:layout_width="match_parent"
@@ -172,6 +239,11 @@
android:valueTo="100" />
</LinearLayout>
<EditText
android:id="@+id/target_height"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<Switch
android:id="@+id/switch_design_surface"
android:layout_width="wrap_content"
@@ -185,6 +257,13 @@
android:layout_height="wrap_content"
android:switchPadding="16dp"
android:text="显示计算的坡面" />
<Switch
android:id="@+id/switch_catmull_rom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:switchPadding="16dp"
android:text="使用 CatmullRom 创建平滑曲线" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -163,6 +163,12 @@
android:valueTo="100" />
</LinearLayout>
<EditText
android:id="@+id/target_height"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:lines="1" />
<Switch
android:id="@+id/switch_design_surface"
android:layout_width="wrap_content"
@@ -176,11 +182,85 @@
android:layout_height="wrap_content"
android:switchPadding="16dp"
android:text="显示计算的坡面" />
<Switch
android:id="@+id/switch_catmull_rom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:switchPadding="16dp"
android:text="使用 CatmullRom 创建平滑曲线" />
</LinearLayout>
<com.mapbox.maps.MapView
android:id="@+id/map_view"
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3" />
android:layout_weight="3">
<com.mapbox.maps.MapView
android:id="@+id/map_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/tv_slope_percentage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="坡度0.00°" />
<TextView
android:id="@+id/tv_area"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="包围面积114514.0 m²" />
<TextView
android:id="@+id/tv_current_height"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="当前高度114514.0 m" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical">
<TextView
android:id="@+id/tv_target_height"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="目标高度114514.0 m" />
<ImageButton
android:id="@+id/target_height_button"
android:layout_width="16dp"
android:layout_height="16dp"
android:src="@drawable/ic_launcher_foreground" />
</LinearLayout>
<TextView
android:id="@+id/tv_optimal_height"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="最佳高度114514.0" />
<TextView
android:id="@+id/tv_cut_volume"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="挖土方量114514.0 m³" />
<TextView
android:id="@+id/tv_fill_volume"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="填土方量114514.0 m³" />
</LinearLayout>
</FrameLayout>
</LinearLayout>

View File

@@ -0,0 +1,304 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MapActivity">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="5">
<com.mapbox.maps.MapView
android:id="@+id/map_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#4D000000"
android:orientation="vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="32dp">
<TextView
android:id="@+id/tv_slope_percentage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black"
tools:text="坡度0.00°" />
<TextView
android:id="@+id/tv_area"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black"
tools:text="包围面积114514.0 m²" />
<TextView
android:id="@+id/tv_current_height"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black"
tools:text="当前高度114514.0 m" />
<TextView
android:id="@+id/tv_target_height"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="目标高度114514.0 m"
android:textColor="@color/black" />
<TextView
android:id="@+id/tv_optimal_height"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black"
tools:text="最佳高度114514.0" />
<TextView
android:id="@+id/tv_cut_volume"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black"
tools:text="挖土方量114514.0 m³" />
<TextView
android:id="@+id/tv_fill_volume"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black"
tools:text="填土方量114514.0 m³" />
</LinearLayout>
</FrameLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="2">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="三角网图层" />
<CheckBox
android:id="@+id/checkbox_triangle"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="栅格图层" />
<Spinner
android:id="@+id/spinner_grid_type"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<CheckBox
android:id="@+id/checkbox_grid"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="等高线图层" />
<CheckBox
android:id="@+id/checkbox_contour_line"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="等高线标注图层" />
<CheckBox
android:id="@+id/checkbox_contour_line_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="高差数字图层" />
<CheckBox
android:id="@+id/checkbox_grid_height_diff"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="地形趋势箭头图层" />
<CheckBox
android:id="@+id/checkbox_controllable_arrow"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="栅格大小" />
<com.google.android.material.slider.Slider
android:id="@+id/slider_cell_size"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:value="1"
android:valueFrom="1"
android:valueTo="100" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="坡向" />
<com.google.android.material.slider.Slider
android:id="@+id/slider_slope_direction"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:value="0"
android:valueFrom="0"
android:valueTo="360" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="坡度" />
<com.google.android.material.slider.Slider
android:id="@+id/slider_slope_percentage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:value="0"
android:valueFrom="0"
android:valueTo="200" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="目标高度" />
<com.google.android.material.slider.Slider
android:id="@+id/slider_target_height"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:value="0"
android:valueFrom="0"
android:valueTo="100" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="高度范围" />
<com.google.android.material.slider.RangeSlider
android:id="@+id/slider_height_range"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:valueFrom="-100"
android:valueTo="100" />
</LinearLayout>
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -0,0 +1,62 @@
package com.icegps.geotools
import org.junit.Test
/**
* @author tabidachinokaze
* @date 2025/11/27
*/
class StepTest {
@Test
fun testStep() {
val contourSize = 6
val heightRange = 30.0..80.0
val step = (heightRange.endInclusive - heightRange.start) / contourSize
val zip = (0..contourSize).map { index ->
heightRange.start + index * step
}.zipWithNext { a, b -> a..b }
zip.forEach {
println(it)
}
}
@Test
fun testStepWithTargetHeight() {
val targetHeight = 50.0
val heightRange = 30.0..80.0
val targetHeightRange = (targetHeight - 1..targetHeight + 1)
val frontHalfHeight = targetHeightRange.start - heightRange.start
val backHalfHeight = heightRange.endInclusive - targetHeightRange.endInclusive
val frontHalfStep = frontHalfHeight / 6
val backHalfStep = backHalfHeight / 6
val frontHalfZip = (0..6).map { index ->
heightRange.start + index * frontHalfStep
}.zipWithNext { a, b -> a..b }
val backHalfZip = (0..6).map { index ->
targetHeightRange.endInclusive + index * backHalfStep
}.zipWithNext { a, b -> a..b }
(frontHalfZip + listOf(targetHeightRange) + backHalfZip).forEach {
println(it)
}
}
fun getContours(targetHeight: Double) {
val targetHeightRange = (targetHeight - 1..targetHeight + 1)
val heightRange = 30.0..80.0
val frontHalfHeight = targetHeightRange.start - heightRange.start
val backHalfHeight = heightRange.endInclusive - targetHeightRange.endInclusive
val frontHalfStep = frontHalfHeight / 6
val backHalfStep = backHalfHeight / 6
val frontHalfZip = (0..6).map { index ->
heightRange.start + index * frontHalfStep
}.zipWithNext { a, b -> a..b }
val backHalfZip = (0..6).map { index ->
targetHeightRange.endInclusive + index * backHalfStep
}.zipWithNext { a, b -> a..b }
(frontHalfZip + listOf(targetHeightRange) + backHalfZip).forEach {
println(it)
}
}
}

View File

@@ -1,7 +1,13 @@
package com.icegps.geotools
import com.icegps.common.helper.GeoHelper
import com.icegps.geotools.ktx.area
import com.icegps.geotools.ktx.averageHeight
import com.icegps.geotools.ktx.niceStr
import com.icegps.geotools.ktx.optimalHeight
import com.icegps.geotools.ktx.volume
import com.icegps.geotools.ktx.volumeSum
import com.icegps.math.geometry.Rectangle
import com.icegps.math.geometry.Vector3D
import com.icegps.triangulation.delaunayTriangulation
import org.junit.Test
@@ -14,34 +20,63 @@ import kotlin.math.max
class TriangulationToGridTest {
@Test
fun testTriangulationToGrid() {
val points = listOf(
/*val points = listOf(
Vector3D(-10.0, 10.0, 0.0),
Vector3D(10.0, 10.0, 10.0),
Vector3D(-10.0, -10.0, 20.0),
Vector3D(10.0, -10.0, 30.0),
)*/
val pathGenerator = PathGenerator(
centerLon = 114.24357285,
centerLat = 22.70470464,
widthMeters = 50.0,
heightMeters = 100.0,
minHeight = 100.0,
maxHeight = 102.0,
numZigzagLines = 10,
pointSpacingMeters = 5.0,
)
points.map {
val geoHelper = GeoHelper.getSharedInstance()
val points = pathGenerator.generatePath().map {
geoHelper.wgs84ToENU(lon = it.longitude, lat = it.latitude, hgt = it.altitude).copy(z = it.altitude)
}.map { Vector3D(it.x, it.y, it.z) }
if (false) points.map {
it / 8
}.niceStr().let(::println)
val area = points.area
val cellSize = max(area.x + area.width, area.y + area.height) / 10
println("area: ${area}")
val cellSize = max(area.x + area.width, area.y + area.height) / 8
val triangulation = points.delaunayTriangulation()
val triangles = triangulation.triangles()
val grid = triangulationToGrid(
delaunator = triangulation,
area = area,
triangles = triangles,
cellSize = cellSize,
)
grid.string().let(::println)
val slopeResult = SlopeCalculator.calculateSlope(
/*val slopeResult = SlopeCalculator.calculateSlope(
grid = grid,
slopeDirection = 0.0,
slopePercentage = 100.0,
slopePercentage = 0.0,
baseHeightOffset = 0.0
)*/
val targetHeight = 100.0
val slopeResult = SlopeCalculator.calculateSlopeByTargetHeight(
grid = grid,
slopeDirection = 0.0,
slopePercentage = 0.0,
targetHeight = targetHeight
)
slopeResult.designSurface.string().let(::println)
println("原来的 Volume: ${grid.volumeSum()}")
println("做坡的 Volume: ${slopeResult.designSurface.volumeSum()}")
println("目标高度:${targetHeight}")
println("原来的土方量: ${grid.volumeSum()}")
println("目标土方量: ${slopeResult.designSurface.volumeSum()}")
println(slopeResult.earthworkResult)
println("最佳高度:${grid.optimalHeight()}")
println("栅格平均高度:${grid.averageHeight()}")
println("目标高度是 $targetHeight 的挖填土方量:${grid.volume(targetHeight)}")
}
}
@@ -55,17 +90,11 @@ fun GridModel.string() = buildString {
}
}
fun GridModel.volumeSum(): Double {
var volume = 0.0
for (r in 0 until rows) {
for (c in 0 until cols) {
val height = getValue(r, c) ?: continue
volume += height
}
}
return volume
}
fun Double.format(): String {
return "%.1f".format(this)
}
fun Rectangle.string(): String = buildString {
appendLine("x = ${x.format()}, y = ${y.format()}")
appendLine("width = ${width.format()}, height = ${height.format()}")
}