diff --git a/android/src/main/java/com/icegps/orx/ContoursManager.kt b/android/src/main/java/com/icegps/orx/ContoursManager.kt index 31bf2dd4..4019713b 100644 --- a/android/src/main/java/com/icegps/orx/ContoursManager.kt +++ b/android/src/main/java/com/icegps/orx/ContoursManager.kt @@ -28,8 +28,11 @@ import com.mapbox.maps.extension.style.sources.addSource import com.mapbox.maps.extension.style.sources.generated.geoJsonSource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.openrndr.extra.shapes.splines.CatmullRomChain2 @@ -54,7 +57,7 @@ class ContoursManager( private var contourSize: Int = 6 private var heightRange: ClosedFloatingPointRange = 0.0..100.0 private var cellSize: Double? = 10.0 - private val simplePalette = SimplePalette( + val simplePalette = SimplePalette( range = 0.0..100.0 ) @@ -103,18 +106,21 @@ class ContoursManager( } private var isGridVisible: Boolean = true - private var gridModel: GridModel? = null + private var _gridModel = MutableStateFlow(null) + val gridModel = _gridModel.asStateFlow() fun setGridVisible(visible: Boolean) { if (visible != isGridVisible) { isGridVisible = visible if (visible) { - if (gridModel != null) mapView.displayGridModel( - grid = gridModel!!, - sourceId = gridSourceId, - layerId = gridLayerId, - palette = simplePalette::palette - ) + _gridModel.value?.let { gridModel -> + mapView.displayGridModel( + grid = gridModel, + sourceId = gridSourceId, + layerId = gridLayerId, + palette = simplePalette::palette + ) + } } else { mapView.mapboxMap.getStyle { style -> try { @@ -149,12 +155,15 @@ class ContoursManager( } } + private var job: Job? = null + fun refresh() { val points = points if (points.size <= 3) { context.toast("points size ${points.size}") return } + job?.cancel() scope.launch { mapView.mapboxMap.getStyle { style -> val step = heightRange.endInclusive / contourSize @@ -175,7 +184,7 @@ class ContoursManager( delaunator = triangulation, cellSize = cellSize, ) - this@ContoursManager.gridModel = gridModel + this@ContoursManager._gridModel.value = gridModel if (isGridVisible) mapView.displayGridModel( grid = gridModel, sourceId = gridSourceId, @@ -183,7 +192,7 @@ class ContoursManager( palette = simplePalette::palette ) } - scope.launch(Dispatchers.Default) { + job = scope.launch(Dispatchers.Default) { val lineFeatures = mutableListOf>() val features = zip.mapIndexed { index, range -> async { @@ -264,10 +273,10 @@ class ContoursManager( style.addSource(source) val layer = lineLayer(layerId, sourceId) { - lineColor(Expression.Companion.toColor(Expression.Companion.get("color"))) // 从属性获取颜色 + lineColor(Expression.toColor(Expression.Companion.get("color"))) // 从属性获取颜色 lineWidth(1.0) - lineCap(LineCap.Companion.ROUND) - lineJoin(LineJoin.Companion.ROUND) + lineCap(LineCap.ROUND) + lineJoin(LineJoin.ROUND) lineOpacity(0.8) } style.addLayer(layer) @@ -288,7 +297,7 @@ class ContoursManager( style.addSource(source) val layer = fillLayer(layerId, sourceId) { - fillColor(Expression.Companion.toColor(Expression.Companion.get("color"))) // 从属性获取颜色 + fillColor(Expression.Companion.toColor(Expression.get("color"))) // 从属性获取颜色 fillOpacity(0.5) fillAntialias(true) } diff --git a/android/src/main/java/com/icegps/orx/ControllableArrow.kt b/android/src/main/java/com/icegps/orx/ControllableArrow.kt new file mode 100644 index 00000000..952ac154 --- /dev/null +++ b/android/src/main/java/com/icegps/orx/ControllableArrow.kt @@ -0,0 +1,197 @@ +package com.icegps.orx + +import com.icegps.math.geometry.Angle +import com.icegps.math.geometry.Vector2D +import com.icegps.orx.ktx.toMapboxPoint +import com.mapbox.geojson.Feature +import com.mapbox.geojson.FeatureCollection +import com.mapbox.geojson.LineString +import com.mapbox.geojson.Point +import com.mapbox.geojson.Polygon +import com.mapbox.maps.MapView +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.generated.LineLayer +import com.mapbox.maps.extension.style.layers.properties.generated.LineCap +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 +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin + +/** + * 设置趋势箭头图层 + */ +fun setupTrendLayer( + style: Style, + trendSourceId: String, + trendLayerId: String, + features: List +) { + val trendSource = geoJsonSource(trendSourceId) { + featureCollection(FeatureCollection.fromFeatures(features)) + } + + try { + style.removeStyleLayer(trendLayerId) + } catch (_: Exception) { + } + + try { + style.removeStyleLayer("$trendLayerId-head") + } catch (_: Exception) { + } + + if (style.styleSourceExists(trendSourceId)) { + style.removeStyleSource(trendSourceId) + } + + style.addSource(trendSource) + + val lineLayer = LineLayer(trendLayerId, trendSourceId).apply { + lineColor(Expression.toColor(Expression.get("color"))) + lineWidth(4.0) + lineCap(LineCap.ROUND) + lineJoin(LineJoin.ROUND) + } + style.addLayer(lineLayer) + + val headLayer = FillLayer("$trendLayerId-head", trendSourceId).apply { + fillColor(Expression.toColor(Expression.get("color"))) + } + style.addLayer(headLayer) +} + +fun MapView.displayControllableArrow( + grid: GridModel, + sourceId: String = "controllable-source-id-0", + layerId: String = "controllable-layer-id-0", + arrowScale: Double = 0.4, + angle: Angle, + onHeadArrowChange: (List) -> Unit +) { + mapboxMap.getStyle { style -> + val centerX = (grid.minX + grid.maxX) / 2 + val centerY = (grid.minY + grid.maxY) / 2 + + val regionWidth = grid.maxX - grid.minX + val regionHeight = grid.maxY - grid.minY + val arrowLength = min(regionWidth, regionHeight) * arrowScale * 1.0 + + val arrowDirectionRad = angle.radians + val endX = centerX + sin(arrowDirectionRad) * arrowLength + val endY = centerY + cos(arrowDirectionRad) * arrowLength + + val arrowLine = LineString.fromLngLats( + listOf( + Vector2D(centerX, centerY), + Vector2D(endX, endY) + ).map { it.toMapboxPoint() } + ) + + val arrowFeature = Feature.fromGeometry(arrowLine) + arrowFeature.addStringProperty("color", "#0000FF") + arrowFeature.addStringProperty("type", "overall-trend") + + // 创建箭头头部 + val headSize = arrowLength * 0.2 + val leftRad = arrowDirectionRad + Math.PI * 0.8 + val rightRad = arrowDirectionRad - Math.PI * 0.8 + + val leftX = endX + sin(leftRad) * headSize + val leftY = endY + cos(leftRad) * headSize + val rightX = endX + sin(rightRad) * headSize + val rightY = endY + cos(rightRad) * headSize + + val headRing = listOf( + Vector2D(endX, endY), + Vector2D(leftX, leftY), + Vector2D(rightX, rightY), + Vector2D(endX, endY) + ).map { it.toMapboxPoint() } + onHeadArrowChange(headRing) + val headPolygon = Polygon.fromLngLats(listOf(headRing)) + val headFeature = Feature.fromGeometry(headPolygon) + headFeature.addStringProperty("color", "#0000FF") + headFeature.addStringProperty("type", "overall-trend") + + val features = listOf(arrowFeature, headFeature) + + // 设置图层 + setupTrendLayer(style, sourceId, layerId, features) + } +} + +fun calculateArrowData( + grid: GridModel, + angle: Angle, + arrowScale: Double = 0.4 +): ArrowData { + val centerX = (grid.minX + grid.maxX) / 2 + val centerY = (grid.minY + grid.maxY) / 2 + + val regionWidth = grid.maxX - grid.minX + val regionHeight = grid.maxY - grid.minY + val arrowLength = min(regionWidth, regionHeight) * arrowScale * 1.0 + + val arrowDirectionRad = angle.radians + val endX = centerX + sin(arrowDirectionRad) * arrowLength + val endY = centerY + cos(arrowDirectionRad) * arrowLength + + val arrowLine = listOf( + Vector2D(centerX, centerY), + Vector2D(endX, endY) + ) + + // 创建箭头头部 + val headSize = arrowLength * 0.2 + val leftRad = arrowDirectionRad + Math.PI * 0.8 + val rightRad = arrowDirectionRad - Math.PI * 0.8 + + val leftX = endX + sin(leftRad) * headSize + val leftY = endY + cos(leftRad) * headSize + val rightX = endX + sin(rightRad) * headSize + val rightY = endY + cos(rightRad) * headSize + + val headRing = listOf( + Vector2D(endX, endY), + Vector2D(leftX, leftY), + Vector2D(rightX, rightY), + Vector2D(endX, endY) + ) + return ArrowData( + arrowLine = arrowLine, + headRing = headRing + ) +} + +data class ArrowData( + val arrowLine: List, + val headRing: List +) + +fun MapView.displayControllableArrow( + sourceId: String = "controllable-source-id-0", + layerId: String = "controllable-layer-id-0", + arrowData: ArrowData +) { + mapboxMap.getStyle { style -> + val (arrowLine, headRing) = arrowData + val arrowFeature = Feature.fromGeometry(LineString.fromLngLats(arrowLine.map { it.toMapboxPoint() })) + arrowFeature.addStringProperty("color", "#0000FF") + arrowFeature.addStringProperty("type", "overall-trend") + + val headPolygon = Polygon.fromLngLats(listOf(headRing.map { it.toMapboxPoint() })) + val headFeature = Feature.fromGeometry(headPolygon) + headFeature.addStringProperty("color", "#0000FF") + headFeature.addStringProperty("type", "overall-trend") + + val features = listOf(arrowFeature, headFeature) + + // 设置图层 + setupTrendLayer(style, sourceId, layerId, features) + } +} \ No newline at end of file diff --git a/android/src/main/java/com/icegps/orx/DisplaySlopeResult.kt b/android/src/main/java/com/icegps/orx/DisplaySlopeResult.kt new file mode 100644 index 00000000..3f21e13d --- /dev/null +++ b/android/src/main/java/com/icegps/orx/DisplaySlopeResult.kt @@ -0,0 +1,144 @@ +package com.icegps.orx + +import android.util.Log +import com.icegps.math.geometry.Vector2D +import com.icegps.orx.ktx.toMapboxPoint +import com.mapbox.geojson.Feature +import com.mapbox.geojson.FeatureCollection +import com.mapbox.geojson.Polygon +import com.mapbox.maps.MapView +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.generated.LineLayer +import com.mapbox.maps.extension.style.sources.addSource +import com.mapbox.maps.extension.style.sources.generated.geoJsonSource + +/** + * @author tabidachinokaze + * @date 2025/11/26 + */ +/** + * 绘制斜坡设计结果 + */ +fun MapView.displaySlopeResult( + originalGrid: GridModel, + slopeResult: SlopeResult, + sourceId: String = "slope-result", + layerId: String = "slope-layer", + palette: (Double?) -> String, + showDesignHeight: Boolean +) { + val elevationList = mutableListOf() + mapboxMap.getStyle { style -> + val features = mutableListOf() + val designGrid = slopeResult.designSurface + + // 对比测试,将绘制到原来图形的左边 + // val minX = originalGrid.minX * 2 - originalGrid.maxX + val minX = originalGrid.minX + val maxY = originalGrid.maxY + + val cellSize = originalGrid.cellSize + + for (r in 0 until originalGrid.rows) { + for (c in 0 until originalGrid.cols) { + val originalElev = originalGrid.getValue(r, c) ?: continue + val designElev = designGrid.getValue(r, c) ?: continue + elevationList.add(designElev) + + // 计算填挖高度 + val heightDiff = designElev - originalElev + + // 计算栅格边界 + val x0 = minX + c * cellSize + val y0 = maxY - r * cellSize + val x1 = x0 + cellSize + val y1 = y0 - cellSize + + // 1. 创建多边形要素(背景色) + val ring = listOf( + Vector2D(x0, y0), + Vector2D(x1, y0), + Vector2D(x1, y1), + Vector2D(x0, y1), + Vector2D(x0, y0) + ).map { it.toMapboxPoint() } + val poly = Polygon.fromLngLats(listOf(ring)) + val feature = Feature.fromGeometry(poly) + + if (showDesignHeight) { + // 显示设计高度,测试坡向是否正确,和高度是否计算正确 + feature.addStringProperty("color", palette(designElev)) + } else { + // 显示高差 + feature.addStringProperty("color", palette(heightDiff)) + } + // 显示原始高度 + // feature.addStringProperty("color", palette(originalElev)) + features.add(feature) + } + } + + Log.d("displayGridWithDirectionArrows", "对比区域的土方量计算: ${elevationList.sum()}, 平均值:${elevationList.average()}") + + // 设置图层 + setupEarthworkLayer(style, sourceId, layerId, features) + } +} + +/** + * 完整的土方工程图层设置 - 修正版 + */ +private fun setupEarthworkLayer( + style: Style, + sourceId: String, + layerId: String, + features: List, +) { + // 创建数据源 + val source = geoJsonSource(sourceId) { + featureCollection(FeatureCollection.fromFeatures(features)) + } + + // 清理旧图层 + try { + style.removeStyleLayer(layerId) + } catch (_: Exception) { + } + try { + style.removeStyleLayer("$layerId-arrow") + } catch (_: Exception) { + } + try { + style.removeStyleLayer("$layerId-outline") + } catch (_: Exception) { + } + try { + style.removeStyleLayer("$layerId-text") + } catch (_: Exception) { + } + + if (style.styleSourceExists(sourceId)) { + style.removeStyleSource(sourceId) + } + + // 添加数据源 + style.addSource(source) + + // 主填充图层 + val fillLayer = FillLayer(layerId, sourceId).apply { + fillColor(Expression.toColor(Expression.get("color"))) + fillOpacity(0.7) + } + style.addLayer(fillLayer) + + // 边框图层 + val outlineLayer = LineLayer("$layerId-outline", sourceId).apply { + lineColor("#333333") + lineWidth(1.0) + lineOpacity(0.5) + } + style.addLayer(outlineLayer) +} \ No newline at end of file diff --git a/android/src/main/java/com/icegps/orx/EarthworkManager.kt b/android/src/main/java/com/icegps/orx/EarthworkManager.kt new file mode 100644 index 00000000..7dc5cd00 --- /dev/null +++ b/android/src/main/java/com/icegps/orx/EarthworkManager.kt @@ -0,0 +1,438 @@ +package com.icegps.orx + +import android.graphics.PointF +import android.util.Log +import com.icegps.common.helper.GeoHelper +import com.icegps.math.geometry.Angle +import com.icegps.math.geometry.Vector2D +import com.icegps.math.geometry.degrees +import com.icegps.shared.ktx.TAG +import com.mapbox.android.gestures.MoveGestureDetector +import com.mapbox.geojson.Point +import com.mapbox.maps.MapView +import com.mapbox.maps.ScreenCoordinate +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.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.sin + +/** + * @author tabidachinokaze + * @date 2025/11/26 + */ +object SlopeCalculator { + fun calculateSlope( + grid: GridModel, + slopeDirection: Double, + slopePercentage: Double, + baseHeightOffset: Double = 0.0 + ): SlopeResult { + val centerX = (grid.minX + grid.maxX) / 2 + val centerY = (grid.minY + grid.maxY) / 2 + + val elevations = grid.cells.filterNotNull() + val baseElevation = elevations.average() + baseHeightOffset + + val basePoint = Triple(centerX, centerY, baseElevation) + + val earthworkResult = EarthworkCalculator.calculateForSlopeDesign( + grid = grid, + basePoint = basePoint, + slope = slopePercentage, + aspect = slopeDirection + ) + + return SlopeResult( + slopeDirection = slopeDirection, + slopePercentage = slopePercentage, + baseHeightOffset = baseHeightOffset, + baseElevation = baseElevation, + earthworkResult = earthworkResult, + designSurface = generateSlopeDesignGrid( + grid = grid, + basePoint = basePoint, + slopePercentage = slopePercentage, + slopeDirection = slopeDirection + ) + ) + } + + + /** + * 生成斜坡设计面网格(用于可视化) + */ + private fun generateSlopeDesignGrid( + grid: GridModel, + basePoint: Triple, + slopePercentage: Double, + slopeDirection: Double + ): GridModel { + val designCells = Array(grid.rows * grid.cols) { null } + val (baseX, baseY, baseElev) = basePoint + val slopeRatio = slopePercentage / 100.0 + + for (r in 0 until grid.rows) { + for (c in 0 until grid.cols) { + if (grid.getValue(r, c) != null) { + val cellX = grid.minX + (c + 0.5) * (grid.maxX - grid.minX) / grid.cols + val cellY = grid.minY + (r + 0.5) * (grid.maxY - grid.minY) / grid.rows + + val designElev = calculateSlopeElevation( + pointX = cellX, + pointY = cellY, + baseX = baseX, + baseY = baseY, + baseElev = baseElev, + slopeRatio = slopeRatio, + slopeDirection = slopeDirection + ) + designCells[r * grid.cols + c] = designElev + } + } + } + return GridModel( + minX = grid.minX, + maxX = grid.maxX, + minY = grid.minY, + maxY = grid.maxY, + rows = grid.rows, + cols = grid.cols, + cellSize = grid.cellSize, + cells = designCells + ) + } + + /** + * 斜坡高程计算 + */ + fun calculateSlopeElevation( + pointX: Double, + pointY: Double, + baseX: Double, + baseY: Double, + baseElev: Double, + slopeRatio: Double, + slopeDirection: Double + ): Double { + val dx = (pointX - baseX) * cos(Math.toRadians(baseY)) + val dy = (pointY - baseY) + + val slopeRad = (slopeDirection.degrees - 90.degrees).normalized.radians + + val projection = dx * cos(slopeRad) + dy * sin(slopeRad) + val heightDiff = projection * slopeRatio + + return baseElev + heightDiff + } +} + +/** + * 斜面设计 + * + * @property slopeDirection 坡向 (度) + * @property slopePercentage 坡度 (%) + * @property baseHeightOffset 基准面高度偏移 (m) + * @property baseElevation 基准点高程 (m) + * @property earthworkResult 土方量结果 + * @property designSurface 设计面网格(用于可视化) + */ +data class SlopeResult( + val slopeDirection: Double, + val slopePercentage: Double, + val baseHeightOffset: Double, + val baseElevation: Double, + val earthworkResult: EarthworkResult, + val designSurface: GridModel +) + +object EarthworkCalculator { + /** + * @param grid 栅格网模型 + * @param designElevation 设计高程 + */ + fun calculateForFlatDesign( + grid: GridModel, + designElevation: Double + ): EarthworkResult { + var cutVolume = 0.0 + var fillVolume = 0.0 + var cutArea = 0.0 + var fillArea = 0.0 + val cellArea = grid.cellSize * grid.cellSize + + for (r in 0 until grid.rows) { + for (c in 0 until grid.cols) { + val originalElev = grid.getValue(r, c) ?: continue + + val heightDiff = designElevation - originalElev + + val volume = heightDiff * cellArea + + if (volume > 0) { + fillVolume += volume + fillArea += cellArea + } else if (volume < 0) { + cutVolume += abs(volume) + cutArea += cellArea + } + } + } + + return EarthworkResult( + cutVolume = cutVolume, + fillVolume = fillVolume, + netVolume = fillVolume - cutVolume, + cutArea = cutArea, + fillArea = fillArea, + totalArea = cutArea + fillArea + ) + } + + /** + * 计算斜面设计的土方量 + */ + fun calculateForSlopeDesign( + grid: GridModel, + basePoint: Triple, + slope: Double, + aspect: Double + ): EarthworkResult { + var cutVolume = 0.0 + var fillVolume = 0.0 + var cutArea = 0.0 + var fillArea = 0.0 + val cellArea = grid.cellSize * grid.cellSize + + val (baseX, baseY, baseElev) = basePoint + val slopeRatio = slope / 100.0 + + for (r in 0 until grid.rows) { + for (c in 0 until grid.cols) { + val originalElev = grid.getValue(r, c) ?: continue + + val cellX = grid.minX + (c + 0.5) * (grid.maxX - grid.minX) / grid.cols + val cellY = grid.minY + (r + 0.5) * (grid.maxY - grid.minY) / grid.rows + + val designElev = SlopeCalculator.calculateSlopeElevation( + pointX = cellX, + pointY = cellY, + baseX = baseX, + baseY = baseY, + baseElev = baseElev, + slopeRatio = slopeRatio, + slopeDirection = aspect + ) + + val heightElev = designElev - originalElev + val volume = heightElev * cellArea + + if (volume > 0) { + fillVolume += volume + fillArea += cellArea + } else if (volume < 0) { + cutVolume += abs(volume) + cutArea += cellArea + } + } + } + + return EarthworkResult( + cutVolume = cutVolume, + fillVolume = fillVolume, + netVolume = fillVolume - cutVolume, + cutArea = cutArea, + fillArea = fillArea, + totalArea = cutArea + fillArea + ) + } +} + +/** + * 土方量计算结果 + * @property cutVolume 挖方量 (m³) + * @property fillVolume 填方量 (m³) + * @property netVolume 净土方量 (m³) + * @property cutArea 挖方面积 (m²) + * @property fillArea 填方面积 (m²) + * @property totalArea 总面积 (m²) + */ +data class EarthworkResult( + val cutVolume: Double, + val fillVolume: Double, + val netVolume: Double, + val cutArea: Double, + val fillArea: Double, + val totalArea: Double +) { + override fun toString(): String { + return buildString { + appendLine("EarthworkResult") + appendLine("挖方: ${"%.1f".format(cutVolume)} m³") + appendLine("填方: ${"%.1f".format(fillVolume)} m³") + appendLine("净土方: ${"%.1f".format(netVolume)} m³") + appendLine("挖方面积: ${"%.1f".format(cutArea)} m²") + appendLine("填方面积: ${"%.1f".format(fillArea)} m²") + appendLine("总面积:${"%.1f".format(totalArea)} m²") + } + } +} + +class EarthworkManager( + private val mapView: MapView, + private val scope: CoroutineScope +) { + private val arrowSourceId: String = "controllable-source-id-0" + private val arrowLayerId: String = "controllable-layer-id-0" + private var listener: OnMoveListener? = null + + private var gridModel = MutableStateFlow(null) + private val arrowHead = MutableStateFlow(emptyList()) + private var arrowCenter = MutableStateFlow(Vector2D(0.0, 0.0)) + private var arrowEnd = MutableStateFlow(Vector2D(0.0, 1.0)) + private var _slopeDirection = MutableStateFlow(0.degrees) + val slopeDirection = _slopeDirection.asStateFlow() + private val _slopePercentage = MutableStateFlow(90.0) + val slopePercentage = _slopePercentage.asStateFlow() + private val _baseHeightOffset = MutableStateFlow(0.0) + val baseHeightOffset = _baseHeightOffset.asStateFlow() + + init { + combine( + arrowCenter, + arrowEnd, + gridModel + ) { center, arrow, gridModel -> + gridModel?.let { gridModel -> + // _slopeDirection.value = angle + displayControllableArrow(gridModel, getSlopeDirection(arrow, center)) + } + }.launchIn(scope) + combine( + _slopeDirection, + gridModel + ) { slopeDirection, gridModel -> + gridModel?.let { + displayControllableArrow(it, slopeDirection) + } + }.launchIn(scope) + } + + 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 displayControllableArrow(gridModel: GridModel, slopeDirection: Angle) { + val arrowData = calculateArrowData( + grid = gridModel, + angle = slopeDirection, + ) + arrowHead.value = arrowData.headRing + mapView.displayControllableArrow( + sourceId = arrowSourceId, + layerId = arrowLayerId, + arrowData = arrowData, + ) + } + + 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 + } + + fun setupOnMoveListener() { + listener = object : OnMoveListener { + private var beginning: Boolean = false + private var isDragging: Boolean = false + 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) { + isDragging = true + } + if (isDragging) { + arrowEnd.value = point + } + return isDragging + } + + override fun onMoveBegin(detector: MoveGestureDetector) { + Log.d(TAG, "onMoveBegin: $detector") + beginning = true + } + + override fun onMoveEnd(detector: MoveGestureDetector) { + Log.d(TAG, "onMoveEnd: $detector") + val point = getCoordinate(detector.focalPoint) + val arrow = point.toVector2D() + if (beginning && isDragging) { + arrowEnd.value = arrow + val center = arrowCenter.value + _slopeDirection.value = getSlopeDirection(arrow, center) + } + Log.d( + TAG, + buildString { + appendLine("onMoveEnd: ") + appendLine("${point.longitude()}, ${point.latitude()}") + } + ) + isDragging = false + beginning = false + } + }.also(mapView.mapboxMap::addOnMoveListener) + } + + fun updateGridModel(gridModel: GridModel) { + this.gridModel.value = gridModel + calculateArrowCenter(gridModel) + } + + private fun calculateArrowCenter(gridModel: GridModel) { + val centerX = (gridModel.minX + gridModel.maxX) / 2 + val centerY = (gridModel.minY + gridModel.maxY) / 2 + arrowCenter.value = Vector2D(centerX, centerY) + } + + fun updateSlopeDirection(angle: Angle) { + _slopeDirection.value = angle + } + + fun updateSlopePercentage(value: Double) { + _slopePercentage.value = value + } + + fun updateDesignHeight(value: Double) { + _baseHeightOffset.value = value + } +} \ No newline at end of file diff --git a/android/src/main/java/com/icegps/orx/GridModel.kt b/android/src/main/java/com/icegps/orx/GridModel.kt index fa43df98..538c6932 100644 --- a/android/src/main/java/com/icegps/orx/GridModel.kt +++ b/android/src/main/java/com/icegps/orx/GridModel.kt @@ -19,7 +19,14 @@ data class GridModel( val cols: Int, val cellSize: Double, val cells: Array -) +) { + fun getValue(row: Int, col: Int): Double? { + if (row !in 0..= cols) { + return null + } + return cells[row * cols + col] + } +} fun triangulationToGrid( delaunator: DelaunayTriangulation3D, diff --git a/android/src/main/java/com/icegps/orx/MainActivity.kt b/android/src/main/java/com/icegps/orx/MainActivity.kt index 90eefe64..91ee4fa1 100644 --- a/android/src/main/java/com/icegps/orx/MainActivity.kt +++ b/android/src/main/java/com/icegps/orx/MainActivity.kt @@ -10,12 +10,16 @@ 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.math.geometry.degrees import com.icegps.orx.databinding.ActivityMainBinding import com.icegps.shared.model.GeoPoint 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.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -26,6 +30,7 @@ class MainActivity : AppCompatActivity() { ViewModelProvider(this)[MainViewModel::class.java] } private lateinit var contoursManager: ContoursManager + private lateinit var earthworkManager: EarthworkManager init { initGeoHelper() @@ -36,6 +41,7 @@ class MainActivity : AppCompatActivity() { enableEdgeToEdge() binding = ActivityMainBinding.inflate(layoutInflater) mapView = binding.mapView + earthworkManager = EarthworkManager(mapView, lifecycleScope) setContentView(binding.root) ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) @@ -52,27 +58,17 @@ class MainActivity : AppCompatActivity() { .build() ) - val points = coordinateGenerate1() + val points = coordinateGenerate() - val polygonTest = PolygonTest(mapView) - polygonTest.clear() - val innerPoints = points.map { it[0] } - val outerPoints = points.map { it[1] } - if (false) polygonTest.update( - outer = outerPoints, - inner = innerPoints, - other = points.map { it[2] } - ) // divider contoursManager = ContoursManager( context = this, mapView = mapView, scope = lifecycleScope ) - val points2 = points.flatten() contoursManager.updateContourSize(6) - contoursManager.updatePoints(points2) - val height = points2.map { it.z } + contoursManager.updatePoints(points) + val height = points.map { it.z } val min = height.min() val max = height.max() contoursManager.updateHeightRange((min / 2)..max) @@ -125,6 +121,7 @@ class MainActivity : AppCompatActivity() { override fun onStopTrackingTouch(slider: Slider) { contoursManager.updateCellSize(slider.value.toDouble()) + contoursManager.refresh() } } ) @@ -135,13 +132,78 @@ class MainActivity : AppCompatActivity() { binding.clearPoints.setOnClickListener { viewModel.clearPoints() } + binding.slopeDirection.addOnSliderTouchListener( + object : Slider.OnSliderTouchListener { + override fun onStartTrackingTouch(slider: Slider) { + } + + override fun onStopTrackingTouch(slider: Slider) { + earthworkManager.updateSlopeDirection(slider.value.degrees) + } + } + ) + binding.slopePercentage.addOnSliderTouchListener( + object : Slider.OnSliderTouchListener { + override fun onStartTrackingTouch(slider: Slider) { + } + + override fun onStopTrackingTouch(slider: Slider) { + earthworkManager.updateSlopePercentage(slider.value.toDouble()) + } + } + ) + binding.designHeight.addOnSliderTouchListener( + object : Slider.OnSliderTouchListener { + override fun onStartTrackingTouch(slider: Slider) { + } + + override fun onStopTrackingTouch(slider: Slider) { + earthworkManager.updateDesignHeight(slider.value.toDouble()) + } + } + ) + binding.switchDesignSurface.setOnCheckedChangeListener { button, isChecked -> + showDesignHeight.value = isChecked + } + earthworkManager.setupOnMoveListener() initData() } + private val showDesignHeight = MutableStateFlow(false) + private fun initData() { viewModel.points.onEach { contoursManager.updatePoints(it) contoursManager.updateHeightRange() + contoursManager.refresh() + }.launchIn(lifecycleScope) + contoursManager.gridModel.filterNotNull().onEach { + earthworkManager.updateGridModel(it) + }.launchIn(lifecycleScope) + earthworkManager.slopeDirection.onEach { + binding.slopeDirection.value = it.degrees.toFloat() + }.launchIn(lifecycleScope) + combine( + earthworkManager.slopeDirection, + earthworkManager.slopePercentage, + earthworkManager.baseHeightOffset, + contoursManager.gridModel, + showDesignHeight + ) { slopeDirection, slopePercentage, baseHeightOffset, gridModel, showDesignHeight -> + gridModel?.let { gridModel -> + val slopeResult: SlopeResult = SlopeCalculator.calculateSlope( + grid = gridModel, + slopeDirection = slopeDirection.degrees, + slopePercentage = slopePercentage, + baseHeightOffset = baseHeightOffset + ) + mapView.displaySlopeResult( + originalGrid = gridModel, + slopeResult = slopeResult, + palette = contoursManager.simplePalette::palette, + showDesignHeight = showDesignHeight + ) + } }.launchIn(lifecycleScope) } } diff --git a/android/src/main/java/com/icegps/orx/RayCastingAlgorithm.kt b/android/src/main/java/com/icegps/orx/RayCastingAlgorithm.kt new file mode 100644 index 00000000..2fff7006 --- /dev/null +++ b/android/src/main/java/com/icegps/orx/RayCastingAlgorithm.kt @@ -0,0 +1,44 @@ +package com.icegps.orx + +import com.icegps.math.geometry.Vector2D +import com.icegps.math.geometry.Vector3D +import com.icegps.math.geometry.toVector2D + +/** + * @author tabidachinokaze + * @date 2025/11/26 + */ +object RayCastingAlgorithm { + /** + * 使用射线法判断点是否在多边形内 + * @param point 测试点 + * @param polygon 多边形顶点列表 + * @return true如果在多边形内 + */ + fun isPointInPolygon(point: Vector2D, polygon: List): Boolean { + if (polygon.size < 3) return false + + val x = point.x + val y = point.y + var inside = false + + var j = polygon.size - 1 + for (i in polygon.indices) { + val xi = polygon[i].x + val yi = polygon[i].y + val xj = polygon[j].x + val yj = polygon[j].y + + val intersect = ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi) + + if (intersect) inside = !inside + j = i + } + + return inside + } + + fun isPointInPolygon(point: Vector3D, polygon: List): Boolean { + return isPointInPolygon(point.toVector2D(), polygon.map { it.toVector2D() }) + } +} \ No newline at end of file diff --git a/android/src/main/java/com/icegps/orx/ktx/Vector2D.kt b/android/src/main/java/com/icegps/orx/ktx/Vector2D.kt new file mode 100644 index 00000000..7761afe7 --- /dev/null +++ b/android/src/main/java/com/icegps/orx/ktx/Vector2D.kt @@ -0,0 +1,15 @@ +package com.icegps.orx.ktx + +import com.icegps.common.helper.GeoHelper +import com.icegps.math.geometry.Vector2D +import com.mapbox.geojson.Point + +/** + * @author tabidachinokaze + * @date 2025/11/26 + */ +fun Vector2D.toMapboxPoint(): Point { + val geoHelper = GeoHelper.getSharedInstance() + val wgs84 = geoHelper.enuToWGS84Object(GeoHelper.ENU(x = x, y = y)) + return Point.fromLngLat(wgs84.lon, wgs84.lat) +} \ No newline at end of file diff --git a/android/src/main/java/com/icegps/orx/triangulation/DelaunayTriangulation3D.kt b/android/src/main/java/com/icegps/orx/triangulation/DelaunayTriangulation3D.kt index 76274160..02cd6b68 100644 --- a/android/src/main/java/com/icegps/orx/triangulation/DelaunayTriangulation3D.kt +++ b/android/src/main/java/com/icegps/orx/triangulation/DelaunayTriangulation3D.kt @@ -8,7 +8,7 @@ import org.openrndr.shape.path3D * Kotlin/OPENRNDR idiomatic interface to `Delaunay` */ class DelaunayTriangulation3D(val points: List) { - val delaunay: Delaunay = Delaunay.Companion.from(points.map { it.xy }) + val delaunay: Delaunay = Delaunay.from(points.map { it.xy }) fun neighbors(pointIndex: Int): Sequence { return delaunay.neighbors(pointIndex) diff --git a/android/src/main/res/layout-port/activity_main.xml b/android/src/main/res/layout-port/activity_main.xml index c2990eef..c43e10f7 100644 --- a/android/src/main/res/layout-port/activity_main.xml +++ b/android/src/main/res/layout-port/activity_main.xml @@ -112,6 +112,72 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="清除所有点" /> + + + + + + + + + + + + + + + + + + + + + + + \ 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 1561e159..9e1ef564 100644 --- a/android/src/main/res/layout/activity_main.xml +++ b/android/src/main/res/layout/activity_main.xml @@ -103,6 +103,72 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="清除所有点" /> + + + + + + + + + + + + + + + + + + + + + + +