diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index d92b7ef..f508ebc 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,9 +1,9 @@ - - + + + + + + + + + diff --git a/android/src/main/java/com/icegps/geotools/ContoursManager.kt b/android/src/main/java/com/icegps/geotools/ContoursManager.kt index 541c54b..1d52fe9 100644 --- a/android/src/main/java/com/icegps/geotools/ContoursManager.kt +++ b/android/src/main/java/com/icegps/geotools/ContoursManager.kt @@ -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 = 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 = 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 + ) { + updateSlopeData { + it.copy(targetHeight = block(heightRange)) + } + } + + private fun getContours( + heightRange: ClosedFloatingPointRange, + targetHeightRange: ClosedFloatingPointRange + ): List> { + 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> { + 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>() 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) diff --git a/android/src/main/java/com/icegps/geotools/EarthworkManager.kt b/android/src/main/java/com/icegps/geotools/EarthworkManager.kt index 0ed2eb6..0ce944a 100644 --- a/android/src/main/java/com/icegps/geotools/EarthworkManager.kt +++ b/android/src/main/java/com/icegps/geotools/EarthworkManager.kt @@ -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 = 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 + ): 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)} m²") } } + + 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 diff --git a/android/src/main/java/com/icegps/geotools/GridModel.kt b/android/src/main/java/com/icegps/geotools/GridModel.kt index 300ad35..442ea37 100644 --- a/android/src/main/java/com/icegps/geotools/GridModel.kt +++ b/android/src/main/java/com/icegps/geotools/GridModel.kt @@ -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..= 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, 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(rows * cols) { null } - - val triangles = delaunator.triangles() - for (ti in 0 until triangles.size) { val (a, b, c) = triangles[ti] diff --git a/android/src/main/java/com/icegps/geotools/MainActivity.kt b/android/src/main/java/com/icegps/geotools/MainActivity.kt index 2cc6abd..2fa9abb 100644 --- a/android/src/main/java/com/icegps/geotools/MainActivity.kt +++ b/android/src/main/java/com/icegps/geotools/MainActivity.kt @@ -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() diff --git a/android/src/main/java/com/icegps/geotools/MainViewModel.kt b/android/src/main/java/com/icegps/geotools/MainViewModel.kt index c33ff66..dba2662 100644 --- a/android/src/main/java/com/icegps/geotools/MainViewModel.kt +++ b/android/src/main/java/com/icegps/geotools/MainViewModel.kt @@ -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>(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) { + _points.value = points.map { + Point.fromLngLat(it.longitude, it.latitude, it.altitude) + } + } + fun clearPoints() { _points.value = emptyList() } diff --git a/android/src/main/java/com/icegps/geotools/MapActivity.kt b/android/src/main/java/com/icegps/geotools/MapActivity.kt new file mode 100644 index 0000000..f736965 --- /dev/null +++ b/android/src/main/java/com/icegps/geotools/MapActivity.kt @@ -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) -> 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("目标栅格") +} \ No newline at end of file diff --git a/android/src/main/java/com/icegps/geotools/PathGenerator.kt b/android/src/main/java/com/icegps/geotools/PathGenerator.kt new file mode 100644 index 0000000..0f19466 --- /dev/null +++ b/android/src/main/java/com/icegps/geotools/PathGenerator.kt @@ -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> { + 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() + val lat = mutableListOf() + + 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> { + 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() + val pathLat = mutableListOf() + + 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, lat: List): Pair, List> { + 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 { + 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 = 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.interpolate(x: List, 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, + ) +} diff --git a/android/src/main/java/com/icegps/geotools/TerrainManager.kt b/android/src/main/java/com/icegps/geotools/TerrainManager.kt new file mode 100644 index 0000000..009b64b --- /dev/null +++ b/android/src/main/java/com/icegps/geotools/TerrainManager.kt @@ -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 + /** + * 土方量数据 + */ + val earthworkResult: StateFlow + /** + * 坐标点集合 + */ + val points: StateFlow> + /** + * 等高线 + */ + val contoursLines: StateFlow>> + fun updateTerrainData(block: (TerrainData) -> TerrainData) + fun updateTargetHeight(block: (ClosedFloatingPointRange) -> Double) + fun addPoint(point: Vector3D) + fun setPoints(points: List) + fun clearPoints() + fun updateHeightRange(range: ClosedFloatingPointRange? = null) + fun update() +} + +class TerrainManagerImpl( + private val scope: CoroutineScope +) : TerrainManager { + private val _terrainData = MutableStateFlow(TerrainData.Empty) + override val terrainData: StateFlow = _terrainData.asStateFlow() + private val _earthworkResult = MutableStateFlow(EarthworkResult.Empty) + override val earthworkResult: StateFlow = _earthworkResult.asStateFlow() + private val _points = MutableStateFlow>(emptyList()) + override val points: StateFlow> = _points.asStateFlow() + private val _contoursLines = MutableStateFlow>>(emptyList()) + override val contoursLines: StateFlow>> = _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) { + 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) { + _points.value = points + } + + override fun clearPoints() { + _points.value = emptyList() + } + + override fun updateHeightRange(range: ClosedFloatingPointRange?) { + 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(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, + area: Rectangle, + cellSize: Double, + triangles: List, + targetHeightRange: ClosedFloatingPointRange + ) = coroutineScope { + val contoursLines: List> = 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, + range: ClosedFloatingPointRange, + area: Rectangle, + cellSize: Double + ): List { + 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, + targetHeightRange: ClosedFloatingPointRange + ): List> { + 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, + val gridModel: GridModel?, + val designGrid: GridModel?, + val triangles: List, + 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 + /** + * 等高线颜色 + */ + val colors: StateFlow> + + 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) + fun updateGrid(gridModel: GridModel) + fun updateContour(contoursLines: List>, 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.asStateFlow() + private val _colors = MutableStateFlow(ContourPalette.CONTOUR_COLORS) + override val colors: StateFlow> = _colors.asStateFlow() + private val markerPosition = MutableStateFlow(Vector2D.ZERO) + private val arrowHead = MutableStateFlow(emptyList()) + 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 + ) { + 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, 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) { + 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>, 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" + } +} diff --git a/android/src/main/java/com/icegps/geotools/TriangleDisplay.kt b/android/src/main/java/com/icegps/geotools/TriangleDisplay.kt new file mode 100644 index 0000000..3a091e4 --- /dev/null +++ b/android/src/main/java/com/icegps/geotools/TriangleDisplay.kt @@ -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, + 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) + } +} \ No newline at end of file diff --git a/android/src/main/java/com/icegps/geotools/ktx/GeoPoint.kt b/android/src/main/java/com/icegps/geotools/ktx/GeoPoint.kt new file mode 100644 index 0000000..670d412 --- /dev/null +++ b/android/src/main/java/com/icegps/geotools/ktx/GeoPoint.kt @@ -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.niceStr(): String { + return joinToString(", ", "[", "]") { + it.niceStr() + } +} \ No newline at end of file diff --git a/android/src/main/java/com/icegps/geotools/ktx/GridModel.kt b/android/src/main/java/com/icegps/geotools/ktx/GridModel.kt new file mode 100644 index 0000000..2f0f544 --- /dev/null +++ b/android/src/main/java/com/icegps/geotools/ktx/GridModel.kt @@ -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 +} diff --git a/android/src/main/java/com/icegps/geotools/ktx/Point.kt b/android/src/main/java/com/icegps/geotools/ktx/Point.kt new file mode 100644 index 0000000..bb21c76 --- /dev/null +++ b/android/src/main/java/com/icegps/geotools/ktx/Point.kt @@ -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) +} \ No newline at end of file diff --git a/android/src/main/res/drawable/ic_marker_red.xml b/android/src/main/res/drawable/ic_marker_red.xml new file mode 100644 index 0000000..28f1619 --- /dev/null +++ b/android/src/main/res/drawable/ic_marker_red.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/layout-port/activity_main.xml b/android/src/main/res/layout-port/activity_main.xml index fb2cf36..10ad5b0 100644 --- a/android/src/main/res/layout-port/activity_main.xml +++ b/android/src/main/res/layout-port/activity_main.xml @@ -7,11 +7,78 @@ android:orientation="vertical" tools:context=".MainActivity"> - + android:layout_weight="3"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/layout/activity_main.xml b/android/src/main/res/layout/activity_main.xml index da30c62..2f516b5 100644 --- a/android/src/main/res/layout/activity_main.xml +++ b/android/src/main/res/layout/activity_main.xml @@ -163,6 +163,12 @@ android:valueTo="100" /> + + + + - + android:layout_weight="3"> + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/layout/activity_map.xml b/android/src/main/res/layout/activity_map.xml new file mode 100644 index 0000000..f5db7c0 --- /dev/null +++ b/android/src/main/res/layout/activity_map.xml @@ -0,0 +1,304 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/src/test/java/com/icegps/geotools/StepTest.kt b/android/src/test/java/com/icegps/geotools/StepTest.kt new file mode 100644 index 0000000..b3f98dd --- /dev/null +++ b/android/src/test/java/com/icegps/geotools/StepTest.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/android/src/test/java/com/icegps/geotools/TriangulationToGridTest.kt b/android/src/test/java/com/icegps/geotools/TriangulationToGridTest.kt index 0ec66f5..6298387 100644 --- a/android/src/test/java/com/icegps/geotools/TriangulationToGridTest.kt +++ b/android/src/test/java/com/icegps/geotools/TriangulationToGridTest.kt @@ -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()}") } \ No newline at end of file