diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 0dcae33c..cca9fe47 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -57,6 +57,10 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.ktx) implementation(project(":icegps-common")) implementation(project(":icegps-shared")) + implementation(project(":orx-marching-squares")) + implementation(project(":orx-palette")) { + exclude(group = "org.openrndr", module = "openrndr-draw") + } testImplementation(libs.junit) androidTestImplementation(libs.ext.junit) diff --git a/android/src/main/java/com/icegps/orx/ContoursManager.kt b/android/src/main/java/com/icegps/orx/ContoursManager.kt new file mode 100644 index 00000000..99d61312 --- /dev/null +++ b/android/src/main/java/com/icegps/orx/ContoursManager.kt @@ -0,0 +1,401 @@ +package com.icegps.orx + +import ColorBrewer2Type +import android.content.Context +import android.util.Log +import colorBrewer2Palettes +import com.icegps.math.geometry.Vector3D +import com.icegps.orx.ktx.area +import com.icegps.orx.ktx.toColorInt +import com.icegps.orx.ktx.toMapboxPoint +import com.icegps.orx.ktx.toast +import com.icegps.shared.ktx.TAG +import com.mapbox.geojson.Feature +import com.mapbox.geojson.FeatureCollection +import com.mapbox.geojson.LineString +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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import com.icegps.orx.triangulation.DelaunayTriangulation3D +import com.icegps.orx.triangulation.Triangle3D +import org.openrndr.math.Vector2 +import org.openrndr.math.Vector3 +import org.openrndr.shape.Rectangle +import org.openrndr.shape.ShapeContour +import kotlin.math.max + +class ContoursManager( + private val context: Context, + private val mapView: MapView, + private val scope: CoroutineScope +) { + private val sourceId: String = "contours-source-id-10" + private val layerId: String = "contours-layer-id-10" + private val fillSourceId: String = "contours-fill-source-id-10" + private val fillLayerId: String = "contours-fill-layer-id-10" + private val gridSourceId: String = "grid-polygon-source-id" + private val gridLayerId: String = "grid-polygon-layer-id" + + private var contourSize: Int = 6 + private var heightRange: ClosedFloatingPointRange = 0.0..100.0 + private var cellSize: Double? = 10.0 + private val simplePalette = SimplePalette( + range = 0.0..100.0 + ) + + private var colors = colorBrewer2Palettes( + numberOfColors = contourSize, + paletteType = ColorBrewer2Type.Any + ).first().colors.reversed() + + private var points: List = emptyList() + + private val polylineManager = PolylineManager(mapView) + + fun updateContourSize(contourSize: Int) { + this.contourSize = contourSize + colors = colorBrewer2Palettes( + numberOfColors = contourSize, + paletteType = ColorBrewer2Type.Any + ).first().colors.reversed() + } + + fun updateCellSize(value: Double) { + cellSize = value + } + + fun updatePoints( + points: List, + ) { + this.points = points + } + + fun updateHeightRange( + heightRange: ClosedFloatingPointRange? = null + ) { + if (heightRange == null) { + if (points.isEmpty()) { + return + } + val height = points.map { it.z } + val range = height.min()..height.max() + this.heightRange = range + simplePalette.setRange(range) + } else { + this.heightRange = heightRange + simplePalette.setRange(heightRange) + } + } + + private var isGridVisible: Boolean = true + private var gridModel: GridModel? = null + + 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 + ) + } else { + mapView.mapboxMap.getStyle { style -> + try { + style.removeStyleLayer(gridLayerId) + } catch (_: Exception) { + } + + if (style.styleSourceExists(gridSourceId)) { + style.removeStyleSource(gridSourceId) + } + } + } + } + } + + private var triangles: List = listOf() + private var isTriangleVisible: Boolean = true + + fun setTriangleVisible(visible: Boolean) { + if (visible != isTriangleVisible) { + isTriangleVisible = visible + if (visible) { + polylineManager.update( + triangles.map { + listOf(it.x1, it.x2, it.x3) + .map { Vector3D(it.x, it.y, it.z) } + } + ) + } else { + polylineManager.clearContours() + } + } + } + + fun refresh() { + val points = points + if (points.size <= 3) { + context.toast("points size ${points.size}") + return + } + 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 points = points.map { Vector3(it.x, it.y, it.z) } + val area = points.area + val triangulation = DelaunayTriangulation3D(points) + val triangles: MutableList = triangulation.triangles() + val cellSize: Double = if (cellSize == null || cellSize!! < 0.1) { + (max(triangulation.points.area.width, triangulation.points.area.height) / 50) + } else { + cellSize!! + } + scope.launch { + val gridModel = triangulationToGrid( + delaunator = triangulation, + cellSize = cellSize, + ) + this@ContoursManager.gridModel = gridModel + if (isGridVisible) mapView.displayGridModel( + grid = gridModel, + sourceId = gridSourceId, + layerId = gridLayerId, + palette = simplePalette::palette + ) + } + scope.launch(Dispatchers.Default) { + val lineFeatures = mutableListOf>() + val features = zip.mapIndexed { index, range -> + async { + val contours = findContours( + triangles = triangles, + range = range, + area = area, + cellSize = cellSize + ) + val color = colors[index].toColorInt() + lineFeatures.add(contoursToLineFeatures(contours, color).flatten()) + contoursToPolygonFeatures(contours, color) + } + }.awaitAll() + withContext(Dispatchers.Main) { + if (false) setupLineLayer( + style = style, + sourceId = sourceId, + layerId = layerId, + features = lineFeatures.flatten() + ) + setupFillLayer( + style = style, + sourceId = fillSourceId, + layerId = fillLayerId, + features = features.filterNotNull(), + ) + Log.d(TAG, "refresh: 刷新完成") + } + } + } + } + } + + fun findContours( + triangles: MutableList, + range: ClosedFloatingPointRange, + area: Rectangle, + cellSize: Double + ): List { + return org.openrndr.extra.marchingsquares.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 { + Log.d(TAG, "findContours: ${v} -> ${it}") + } + }, + area = area, + cellSize = cellSize, + ) + } + + private fun setupLineLayer( + 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 = lineLayer(layerId, sourceId) { + lineColor(Expression.Companion.toColor(Expression.Companion.get("color"))) // 从属性获取颜色 + lineWidth(1.0) + lineCap(LineCap.Companion.ROUND) + lineJoin(LineJoin.Companion.ROUND) + lineOpacity(0.8) + } + style.addLayer(layer) + } + + 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.Companion.toColor(Expression.Companion.get("color"))) // 从属性获取颜色 + fillOpacity(0.5) + fillAntialias(true) + } + style.addLayer(layer) + } + + fun contoursToLineFeatures(contours: List, color: Int): List> { + return contours.drop(1).map { contour -> + contour.segments.map { segment -> + LineString.fromLngLats(listOf(segment.start.toMapboxPoint(), segment.end.toMapboxPoint())) + }.map { lineString -> + Feature.fromGeometry(lineString).apply { + // 将颜色Int转换为十六进制字符串 + addStringProperty("color", color.toHexColorString()) + } + } + } + } + + fun contoursToPolygonFeatures(contours: List, color: Int): 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 { points -> points.map { it.toMapboxPoint() } } + + if (lists.isEmpty()) { + Log.w(TAG, "contoursToPolygonFeatures: 没有有效的轮廓数据") + return null + } + + val polygon = Polygon.fromLngLats(lists) + return Feature.fromGeometry(polygon).apply { + // 将颜色Int转换为十六进制字符串 + addStringProperty("color", color.toHexColorString()) + } + } + + fun Int.toHexColorString(): String { + return String.format("#%06X", 0xFFFFFF and this) + } + + fun clearContours() { + mapView.mapboxMap.getStyle { style -> + try { + style.removeStyleLayer(layerId) + } catch (_: Exception) { + } + try { + style.removeStyleSource(sourceId) + } catch (_: Exception) { + } + } + } +} + +fun isPointInTriangle3D(point: Vector2, triangle: List): Boolean { + require(triangle.size == 3) { "三角形必须有3个顶点" } + + val (v1, v2, v3) = triangle + + // 计算重心坐标 + val denominator = (v2.y - v3.y) * (v1.x - v3.x) + (v3.x - v2.x) * (v1.y - v3.y) + if (denominator == 0.0) return false // 退化三角形 + + val alpha = ((v2.y - v3.y) * (point.x - v3.x) + (v3.x - v2.x) * (point.y - v3.y)) / denominator + val beta = ((v3.y - v1.y) * (point.x - v3.x) + (v1.x - v3.x) * (point.y - v3.y)) / denominator + val gamma = 1.0 - alpha - beta + + // 点在三角形内当且仅当所有重心坐标都在[0,1]范围内 + return alpha >= 0 && beta >= 0 && gamma >= 0 && + alpha <= 1 && beta <= 1 && gamma <= 1 +} + +/** + * 使用重心坐标计算点在三角形上的高度 + * + * @param point 二维点 (x, y) + * @param triangle 三角形的三个顶点 + * @return 三维点 (x, y, z) + */ +fun interpolateHeight(point: Vector2, triangle: List): Vector3 { + /** + * 计算点在三角形中的重心坐标 + */ + fun calculateBarycentricCoordinates( + point: Vector2, + v1: Vector3, + v2: Vector3, + v3: Vector3 + ): Triple { + val denom = (v2.y - v3.y) * (v1.x - v3.x) + (v3.x - v2.x) * (v1.y - v3.y) + + val alpha = ((v2.y - v3.y) * (point.x - v3.x) + (v3.x - v2.x) * (point.y - v3.y)) / denom + val beta = ((v3.y - v1.y) * (point.x - v3.x) + (v1.x - v3.x) * (point.y - v3.y)) / denom + val gamma = 1.0 - alpha - beta + + return Triple(alpha, beta, gamma) + } + + require(triangle.size == 3) { "三角形必须有3个顶点" } + + val (v1, v2, v3) = triangle + + // 计算重心坐标 + val (alpha, beta, gamma) = calculateBarycentricCoordinates(point, v1, v2, v3) + + // 使用重心坐标插值z值 + val z = alpha * v1.z + beta * v2.z + gamma * v3.z + + return Vector3(point.x, point.y, z) +} diff --git a/android/src/main/java/com/icegps/orx/CoordinateGenerator.kt b/android/src/main/java/com/icegps/orx/CoordinateGenerator.kt new file mode 100644 index 00000000..e4cae564 --- /dev/null +++ b/android/src/main/java/com/icegps/orx/CoordinateGenerator.kt @@ -0,0 +1,53 @@ +package com.icegps.orx + +import com.icegps.math.geometry.Angle +import com.icegps.math.geometry.Vector3D +import com.icegps.math.geometry.degrees +import kotlin.math.cos +import kotlin.math.sin +import kotlin.random.Random + +/** + * @author tabidachinokaze + * @date 2025/11/25 + */ +fun coordinateGenerate(): List { + val minX = -20.0 + val maxX = 20.0 + val minY = -20.0 + val maxY = 20.0 + val minZ = -20.0 + val maxZ = 20.0 + val x: () -> Double = { Random.nextDouble(minX, maxX) } + val y: () -> Double = { Random.nextDouble(minY, maxY) } + val z: () -> Double = { Random.nextDouble(minZ, maxZ) } + val dPoints = (0..60).map { + Vector3D(x(), y(), z()) + } + return dPoints +} + +fun coordinateGenerate1(): List> { + /** + * 绕 Z 轴旋转指定角度(弧度) + */ + fun Vector3D.rotateAroundZ(angle: Angle): Vector3D { + val cosAngle = cos(angle.radians) + val sinAngle = sin(angle.radians) + + return Vector3D( + x = x * cosAngle - y * sinAngle, + y = x * sinAngle + y * cosAngle, + z = z + ) + } + + val center = Vector3D() + val direction = Vector3D(0.0, 1.0, -1.0) + return (0..360).step(10).map { + val nowDirection = direction.rotateAroundZ(it.degrees) + listOf(2, 6, 10).map { + center + nowDirection * it + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/icegps/orx/GridDisplay.kt b/android/src/main/java/com/icegps/orx/GridDisplay.kt new file mode 100644 index 00000000..1e1257d1 --- /dev/null +++ b/android/src/main/java/com/icegps/orx/GridDisplay.kt @@ -0,0 +1,83 @@ +package com.icegps.orx + +import com.icegps.common.helper.GeoHelper +import com.icegps.math.geometry.Vector2D +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.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.sources.addSource +import com.mapbox.maps.extension.style.sources.generated.geoJsonSource + +/** + * @author tabidachinokaze + * @date 2025/11/25 + */ +fun MapView.displayGridModel( + grid: GridModel, + sourceId: String, + layerId: String, + palette: (Double?) -> String, +) { + val geoHelper = GeoHelper.getSharedInstance() + mapboxMap.getStyle { style -> + val polygonFeatures = mutableListOf() + + val minX = grid.minX + val maxY = grid.maxY + val cellSize = grid.cellSize + + for (r in 0 until grid.rows) { + for (c in 0 until grid.cols) { + val idx = r * grid.cols + c + val v = grid.cells[idx] ?: continue + + val x0 = minX + c * cellSize + val y0 = maxY - r * cellSize + val x1 = x0 + cellSize + val y1 = y0 - cellSize + + val ring = listOf( + Vector2D(x0, y0), + Vector2D(x1, y0), + Vector2D(x1, y1), + Vector2D(x0, y1), + Vector2D(x0, y0), + ).map { + geoHelper.enuToWGS84Object(GeoHelper.ENU(it.x, it.y)) + }.map { + Point.fromLngLat(it.lon, it.lat) + } + val poly = Polygon.fromLngLats(listOf(ring)) + val polyFeature = Feature.fromGeometry(poly) + polyFeature.addStringProperty("color", palette(v)) + polyFeature.addNumberProperty("value", v ?: -9999.0) + polygonFeatures.add(polyFeature) + } + } + + try { + style.removeStyleLayer(layerId) + } catch (_: Exception) { + } + + if (style.styleSourceExists(sourceId)) { + style.removeStyleSource(sourceId) + } + + val polygonSource = geoJsonSource(sourceId) { + featureCollection(FeatureCollection.fromFeatures(polygonFeatures)) + } + style.addSource(polygonSource) + + val fillLayer = FillLayer(layerId, sourceId).apply { + fillColor(Expression.toColor(Expression.get("color"))) + fillOpacity(0.5) + } + style.addLayer(fillLayer) + } +} diff --git a/android/src/main/java/com/icegps/orx/GridModel.kt b/android/src/main/java/com/icegps/orx/GridModel.kt new file mode 100644 index 00000000..fa43df98 --- /dev/null +++ b/android/src/main/java/com/icegps/orx/GridModel.kt @@ -0,0 +1,132 @@ +package com.icegps.orx + +import com.icegps.math.geometry.Vector2D +import com.icegps.orx.triangulation.DelaunayTriangulation3D +import org.openrndr.math.Vector3 +import kotlin.math.absoluteValue +import kotlin.math.ceil + +/** + * @author tabidachinokaze + * @date 2025/11/25 + */ +data class GridModel( + val minX: Double, + val maxX: Double, + val minY: Double, + val maxY: Double, + val rows: Int, + val cols: Int, + val cellSize: Double, + val cells: Array +) + +fun triangulationToGrid( + delaunator: DelaunayTriangulation3D, + cellSize: Double = 50.0, + maxSidePixels: Int = 5000 +): GridModel { + fun pointInTriangle(pt: Vector2D, a: Vector3, b: Vector3, c: Vector3): Boolean { + val v0x = c.x - a.x + val v0y = c.y - a.y + val v1x = b.x - a.x + val v1y = b.y - a.y + val v2x = pt.x - a.x + val v2y = pt.y - a.y + + val dot00 = v0x * v0x + v0y * v0y + val dot01 = v0x * v1x + v0y * v1y + val dot02 = v0x * v2x + v0y * v2y + val dot11 = v1x * v1x + v1y * v1y + val dot12 = v1x * v2x + v1y * v2y + + val denom = dot00 * dot11 - dot01 * dot01 + if (denom == 0.0) return false + val invDenom = 1.0 / denom + val u = (dot11 * dot02 - dot01 * dot12) * invDenom + val v = (dot00 * dot12 - dot01 * dot02) * invDenom + return u >= 0 && v >= 0 && u + v <= 1 + } + + fun barycentricInterpolateLegacy(pt: Vector2D, a: Vector3, b: Vector3, c: Vector3, values: DoubleArray): Double { + val area = { p1: Vector2D, p2: Vector3, p3: Vector3 -> + ((p2.x - p1.x) * (p3.y - p1.y) - (p3.x - p1.x) * (p2.y - p1.y)).absoluteValue / 2.0 + } + val area2 = { p1: Vector3, p2: Vector3, p3: Vector3 -> + ((p2.x - p1.x) * (p3.y - p1.y) - (p3.x - p1.x) * (p2.y - p1.y)).absoluteValue / 2.0 + } + val areaTotal = area2(a, b, c) + if (areaTotal == 0.0) return values[0] + val wA = area(pt, b, c) / areaTotal + val wB = area(pt, c, a) / areaTotal + val wC = area(pt, a, b) / areaTotal + return values[0] * wA + values[1] * wB + values[2] * wC + } + + + 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 + + var cols = ceil(width / cellSize).toInt() + var rows = ceil(height / cellSize).toInt() + + // 防止过大 + if (cols > maxSidePixels) cols = maxSidePixels + if (rows > maxSidePixels) rows = maxSidePixels + + val cells = Array(rows * cols) { null } + + + val triangles = delaunator.triangles() + + for (ti in 0 until triangles.size) { + val (a, b, c) = triangles[ti] + + val tminX = minOf(a.x, b.x, c.x) + val tmaxX = maxOf(a.x, b.x, c.x) + val tminY = minOf(a.y, b.y, c.y) + val tmaxY = maxOf(a.y, b.y, c.y) + + val colMin = ((tminX - minX) / cellSize).toInt().coerceIn(0, cols - 1) + val colMax = ((tmaxX - minX) / cellSize).toInt().coerceIn(0, cols - 1) + val rowMin = ((maxY - tmaxY) / cellSize).toInt().coerceIn(0, rows - 1) + val rowMax = ((maxY - tminY) / cellSize).toInt().coerceIn(0, rows - 1) + + val triVertexVals = doubleArrayOf(a.z, b.z, c.z) + + for (r in rowMin..rowMax) { + for (cIdx in colMin..colMax) { + val centerX = minX + (cIdx + 0.5) * cellSize + val centerY = maxY - (r + 0.5) * cellSize + val pt = Vector2D(centerX, centerY) + if (pointInTriangle(pt, a, b, c)) { + val idx = r * cols + cIdx + val valInterp = barycentricInterpolateLegacy(pt, a, b, c, triVertexVals) + cells[idx] = valInterp + } + } + } + } + + val grid = GridModel( + minX = minX, + minY = minY, + maxX = maxX, + maxY = maxY, + rows = rows, + cols = cols, + cellSize = cellSize, + cells = cells + ) + return grid +} diff --git a/android/src/main/java/com/icegps/orx/MainActivity.kt b/android/src/main/java/com/icegps/orx/MainActivity.kt index 68f5d909..90eefe64 100644 --- a/android/src/main/java/com/icegps/orx/MainActivity.kt +++ b/android/src/main/java/com/icegps/orx/MainActivity.kt @@ -1,56 +1,31 @@ package com.icegps.orx -import android.graphics.Color import android.os.Bundle -import android.util.Log import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.ViewModelProvider +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.Angle -import com.icegps.math.geometry.Line3D -import com.icegps.math.geometry.Vector3D -import com.icegps.math.geometry.degrees import com.icegps.orx.databinding.ActivityMainBinding -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.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.CameraOptions import com.mapbox.maps.MapView -import com.mapbox.maps.Style -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 kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.catch +import com.mapbox.maps.plugin.gestures.addOnMapClickListener import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update -import org.openrndr.extra.triangulation.DelaunayTriangulation -import org.openrndr.math.Vector2 -import org.openrndr.math.YPolarity -import kotlin.math.cos -import kotlin.math.sin class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var mapView: MapView + private val viewModel: MainViewModel by lazy { + ViewModelProvider(this)[MainViewModel::class.java] + } + private lateinit var contoursManager: ContoursManager init { initGeoHelper() @@ -78,15 +53,96 @@ class MainActivity : AppCompatActivity() { ) val points = coordinateGenerate1() + val polygonTest = PolygonTest(mapView) polygonTest.clear() val innerPoints = points.map { it[0] } val outerPoints = points.map { it[1] } - polygonTest.update( + 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 } + 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() + + binding.sliderTargetHeight.addOnSliderTouchListener( + object : Slider.OnSliderTouchListener { + override fun onStartTrackingTouch(p0: Slider) { + } + + override fun onStopTrackingTouch(p0: Slider) { + val present = p0.value / p0.valueTo + // val targetHeight = ((valueRange.endInclusive - valueRange.start) * present) + valueRange.start + + // val contours = findContours(triangles, targetHeight) + // contoursTest.clearContours() + // if (false) contoursTest.updateContours(contours) + } + } + ) + + binding.heightRange.addOnSliderTouchListener( + object : RangeSlider.OnSliderTouchListener { + override fun onStartTrackingTouch(slider: RangeSlider) { + } + + override fun onStopTrackingTouch(slider: RangeSlider) { + contoursManager.updateHeightRange((slider.values.min().toDouble() - 1.0)..(slider.values.max().toDouble() + 1.0)) + contoursManager.refresh() + } + } + ) + + binding.switchGrid.setOnCheckedChangeListener { _, isChecked -> + contoursManager.setGridVisible(isChecked) + } + binding.switchTriangle.setOnCheckedChangeListener { _, isChecked -> + contoursManager.setTriangleVisible(isChecked) + } + binding.update.setOnClickListener { + contoursManager.refresh() + } + binding.cellSize.addOnSliderTouchListener( + object : Slider.OnSliderTouchListener { + override fun onStartTrackingTouch(slider: Slider) { + } + + override fun onStopTrackingTouch(slider: Slider) { + contoursManager.updateCellSize(slider.value.toDouble()) + } + } + ) + mapView.mapboxMap.addOnMapClickListener { + viewModel.addPoint(it) + true + } + binding.clearPoints.setOnClickListener { + viewModel.clearPoints() + } + initData() + } + + private fun initData() { + viewModel.points.onEach { + contoursManager.updatePoints(it) + contoursManager.updateHeightRange() + }.launchIn(lifecycleScope) } } @@ -100,195 +156,3 @@ fun initGeoHelper(base: GeoPoint = home) { hgt = base.altitude ) } - -fun fromPoints( - points: List, - closed: Boolean, - polarity: YPolarity = YPolarity.CW_NEGATIVE_Y -) = if (points.isEmpty()) { - emptyList() -} else { - if (!closed) { - (0 until points.size - 1).map { - Line3D( - points[it], - points[it + 1] - ) - } - } else { - val d = (points.last() - points.first()).length - val usePoints = if (d > 1E-6) points else points.dropLast(1) - (usePoints.indices).map { - Line3D( - usePoints[it], - usePoints[(it + 1) % usePoints.size] - ) - } - } -} - -fun coordinateGenerate1(): List> { - /** - * 绕 Z 轴旋转指定角度(弧度) - */ - fun Vector3D.rotateAroundZ(angle: Angle): Vector3D { - val cosAngle = cos(angle.radians) - val sinAngle = sin(angle.radians) - - return Vector3D( - x = x * cosAngle - y * sinAngle, - y = x * sinAngle + y * cosAngle, - z = z - ) - } - - val center = Vector3D() - val direction = Vector3D(0.0, 1.0, -1.0) - return (0..360).step(10).map { - val nowDirection = direction.rotateAroundZ(it.degrees) - listOf(2, 6, 10).map { - center + nowDirection * it - } - } -} - -class PolygonTest( - private val mapView: MapView -) { - private val geoHelper = GeoHelper.getSharedInstance() - - private val contourSourceId = "contour-source-id-0" - private val contourLayerId = "contour-layer-id-0" - - private val fillSourceId = "fill-source-id-0" - private val fillLayerId = "fill-layer-id-0" - - fun Vector3D.toMapboxPoint(): Point { - return geoHelper.enuToWGS84Object(GeoHelper.ENU(x, y, z)).run { - Point.fromLngLat(lon, lat, hgt) - } - } - - fun update( - outer: List, - inner: List, - other: List - ) { - val lineFeatures = mutableListOf() - val fillFeatures = mutableListOf() - - val outerPoints = outer.map { it.toMapboxPoint() } - val innerPoints = inner.map { it.toMapboxPoint() } - val otherPoints = other.map { it.toMapboxPoint() } - val outerLine = LineString.fromLngLats(outerPoints) - Feature.fromGeometry(outerLine).also { - lineFeatures.add(it) - } - val innerLine = LineString.fromLngLats(innerPoints) - Feature.fromGeometry(innerLine).also { - lineFeatures.add(it) - } - - //val polygon = Polygon.fromOuterInner(outerLine, innerLine) - val polygon = Polygon.fromLngLats(listOf(outerPoints, otherPoints, innerPoints)) - - mapView.mapboxMap.getStyle { style -> - setupLineLayer( - style = style, - sourceId = contourSourceId, - layerId = contourLayerId, - features = lineFeatures - ) - setupFillLayer( - style = style, - sourceId = fillSourceId, - layerId = fillLayerId, - features = listOf(Feature.fromGeometry(polygon)) - ) - } - } - - private fun setupLineLayer( - 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 = lineLayer(layerId, sourceId) { - lineColor(Color.RED) - lineWidth(2.0) - lineCap(LineCap.ROUND) - lineJoin(LineJoin.ROUND) - lineOpacity(0.8) - } - style.addLayer(layer) - } - - 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(fillLayerId, fillSourceId) { - fillColor(Color.YELLOW) - fillOpacity(0.3) - fillAntialias(true) - } - style.addLayer(layer) - } - - fun clear() { - - } -} - -class MainViewModel : ViewModel() { - private val geoHelper = GeoHelper.getSharedInstance() - private val openElevation: OpenElevationApi = OpenElevation(SharedHttpClient(SharedJson())) - - private val _points = MutableStateFlow>(emptyList()) - - init { - _points.map { - openElevation.lookup(it.map { GeoPoint(it.longitude(), it.latitude(), it.altitude()) }) - }.catch { - Log.e(TAG, "高程请求失败", it) - }.map { - it.map { - val enu = - geoHelper.wgs84ToENU(lon = it.longitude, lat = it.latitude, hgt = it.altitude) - Vector2(enu.x, enu.y) - } - }.onEach { - val triangulation = DelaunayTriangulation(it) - triangulation.triangles().map { - it.contour - } - }.launchIn(viewModelScope) - } - - fun addPoint(point: Point) { - _points.update { - it.toMutableList().apply { - add(point) - } - } - } -} \ No newline at end of file diff --git a/android/src/main/java/com/icegps/orx/MainViewModel.kt b/android/src/main/java/com/icegps/orx/MainViewModel.kt new file mode 100644 index 00000000..ccfba229 --- /dev/null +++ b/android/src/main/java/com/icegps/orx/MainViewModel.kt @@ -0,0 +1,59 @@ +package com.icegps.orx + +import android.app.Application +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.orx.ktx.toast +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.mapbox.geojson.Point +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +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 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()) }) + }.catch { + Log.e(TAG, "高程请求失败", it) + context.toast("高程请求失败") + }.map { + it.map { + val enu = geoHelper.wgs84ToENU(lon = it.longitude, lat = it.latitude, hgt = it.altitude) + Vector3D(enu.x, enu.y, enu.z) + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Companion.Eagerly, + initialValue = emptyList() + ) + + fun addPoint(point: Point) { + context.toast("${point.longitude()}, ${point.latitude()}") + _points.update { + it.toMutableList().apply { + add(point) + } + } + } + + fun clearPoints() { + _points.value = emptyList() + } +} \ No newline at end of file diff --git a/android/src/main/java/com/icegps/orx/PolygonTest.kt b/android/src/main/java/com/icegps/orx/PolygonTest.kt new file mode 100644 index 00000000..6b1622a0 --- /dev/null +++ b/android/src/main/java/com/icegps/orx/PolygonTest.kt @@ -0,0 +1,123 @@ +package com.icegps.orx + +import android.graphics.Color +import com.icegps.common.helper.GeoHelper +import com.icegps.math.geometry.Vector3D +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.Polygon +import com.mapbox.maps.MapView +import com.mapbox.maps.Style +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 + +class PolygonTest( + private val mapView: MapView +) { + private val geoHelper = GeoHelper.Companion.getSharedInstance() + + private val contourSourceId = "contour-source-id-0" + private val contourLayerId = "contour-layer-id-0" + + private val fillSourceId = "fill-source-id-0" + private val fillLayerId = "fill-layer-id-0" + + fun update( + outer: List, + inner: List, + other: List + ) { + val lineFeatures = mutableListOf() + val fillFeatures = mutableListOf() + + val outerPoints = outer.map { it.toMapboxPoint() } + val innerPoints = inner.map { it.toMapboxPoint() } + val otherPoints = other.map { it.toMapboxPoint() } + val outerLine = LineString.fromLngLats(outerPoints) + Feature.fromGeometry(outerLine).also { + lineFeatures.add(it) + } + val innerLine = LineString.fromLngLats(innerPoints) + Feature.fromGeometry(innerLine).also { + lineFeatures.add(it) + } + Feature.fromGeometry(LineString.fromLngLats(otherPoints)).also { + lineFeatures.add(it) + } + + //val polygon = Polygon.fromOuterInner(outerLine, innerLine) + val polygon = Polygon.fromLngLats(listOf(outerPoints, otherPoints, innerPoints)) + + mapView.mapboxMap.getStyle { style -> + if (false) setupLineLayer( + style = style, + sourceId = contourSourceId, + layerId = contourLayerId, + features = lineFeatures + ) + setupFillLayer( + style = style, + sourceId = fillSourceId, + layerId = fillLayerId, + features = listOf(Feature.fromGeometry(polygon)) + ) + } + } + + private fun setupLineLayer( + 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 = lineLayer(layerId, sourceId) { + lineColor(Color.RED) + lineWidth(2.0) + lineCap(LineCap.Companion.ROUND) + lineJoin(LineJoin.Companion.ROUND) + lineOpacity(0.8) + } + style.addLayer(layer) + } + + 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(fillLayerId, fillSourceId) { + fillColor(Color.YELLOW) + fillOpacity(0.3) + fillAntialias(true) + } + style.addLayer(layer) + } + + fun clear() { + + } +} \ No newline at end of file diff --git a/android/src/main/java/com/icegps/orx/PolylineManager.kt b/android/src/main/java/com/icegps/orx/PolylineManager.kt new file mode 100644 index 00000000..aaad304b --- /dev/null +++ b/android/src/main/java/com/icegps/orx/PolylineManager.kt @@ -0,0 +1,123 @@ +package com.icegps.orx + +import android.graphics.Color +import com.icegps.math.geometry.Line3D +import com.icegps.math.geometry.Vector3D +import com.icegps.orx.ktx.toMapboxPoint +import com.mapbox.geojson.Feature +import com.mapbox.geojson.FeatureCollection +import com.mapbox.geojson.LineString +import com.mapbox.maps.MapView +import com.mapbox.maps.Style +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.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 org.openrndr.math.YPolarity + +class PolylineManager( + private val mapView: MapView +) { + private val sourceId: String = "polyline-source-id-0" + private val layerId: String = "polyline-layer-id-0" + + fun update( + points: List> + ) { + val lineStrings: List> = points.map { + val lines = fromPoints(it, true) + lines.map { + LineString.fromLngLats(listOf(it.a.toMapboxPoint(), it.b.toMapboxPoint())) + } + }.map { + it.map { Feature.fromGeometry(it) } + } + + mapView.mapboxMap.getStyle { style -> + setupLineLayer( + style = style, + sourceId = sourceId, + layerId = layerId, + features = lineStrings.flatten() + ) + } + } + + fun updateFeatures( + features: List + ) { + mapView.mapboxMap.getStyle { style -> + setupLineLayer( + style = style, + sourceId = sourceId, + layerId = layerId, + features = features + ) + } + } + + private fun setupLineLayer( + 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 = lineLayer(layerId, sourceId) { + lineColor(Color.RED) + lineWidth(2.0) + lineCap(LineCap.Companion.ROUND) + lineJoin(LineJoin.Companion.ROUND) + lineOpacity(0.8) + } + style.addLayer(layer) + } + + fun clearContours() { + mapView.mapboxMap.getStyle { style -> + try { + style.removeStyleLayer(layerId) + } catch (_: Exception) { + } + try { + style.removeStyleSource(sourceId) + } catch (_: Exception) { + } + } + } +} + +fun fromPoints( + points: List, + closed: Boolean, + polarity: YPolarity = YPolarity.CW_NEGATIVE_Y +) = if (points.isEmpty()) { + emptyList() +} else { + if (!closed) { + (0 until points.size - 1).map { + Line3D( + points[it], + points[it + 1] + ) + } + } else { + val d = (points.last() - points.first()).length + val usePoints = if (d > 1E-6) points else points.dropLast(1) + (usePoints.indices).map { + Line3D( + usePoints[it], + usePoints[(it + 1) % usePoints.size] + ) + } + } +} diff --git a/android/src/main/java/com/icegps/orx/SimplePalette.kt b/android/src/main/java/com/icegps/orx/SimplePalette.kt new file mode 100644 index 00000000..2b1f55c0 --- /dev/null +++ b/android/src/main/java/com/icegps/orx/SimplePalette.kt @@ -0,0 +1,123 @@ +package com.icegps.orx + +import android.util.Log + +/** + * @author tabidachinokaze + * @date 2025/11/25 + */ +class SimplePalette( + private var range: ClosedFloatingPointRange +) { + fun setRange(range: ClosedFloatingPointRange) { + this.range = range + } + + private val colors: Map + + init { + colors = generateTerrainColorMap() + } + + fun palette(value: Double?): String { + if (value == null) return "#00000000" + val minH = range.start + val maxH = range.endInclusive + val normalized = ((value - minH) / (maxH - minH)).coerceIn(0.0, 1.0) + return colors[(normalized * 255).toInt()] ?: "#00000000" + } + + fun palette1(value: Double?): String { + return if (value == null) "#00000000" else { + // 假设您已经知道高度范围,或者动态计算 + val minH = range.start + val maxH = range.endInclusive + val normalized = ((value - minH) / (maxH - minH)).coerceIn(0.0, 1.0) + val alpha = (normalized * 255).toInt() + String.format("#%02X%02X%02X", alpha, 0, 0) + }.also { + Log.d("simplePalette", "$value -> $it") + } + } + + fun generateGrayscaleColorMap2(): MutableMap { + val colorMap = mutableMapOf() + + // 定义关键灰度点 + val black = Color(0, 0, 0) // 低地势 - 黑色 + val darkGray = Color(64, 64, 64) // 过渡 + val midGray = Color(128, 128, 128) // 中间 + val lightGray = Color(192, 192, 192) // 过渡 + val white = Color(255, 255, 255) // 高地势 - 白色 + + for (i in 0..255) { + val position = i / 255.0 + + val color = when { + position < 0.25 -> interpolateColor(black, darkGray, position / 0.25) + position < 0.5 -> interpolateColor(darkGray, midGray, (position - 0.25) / 0.25) + position < 0.75 -> interpolateColor(midGray, lightGray, (position - 0.5) / 0.25) + else -> interpolateColor(lightGray, white, (position - 0.75) / 0.25) + } + colorMap[i] = color.toHex() + } + + return colorMap + } + + fun generateGrayscaleColorMap(): MutableMap { + val colorMap = mutableMapOf() + + for (i in 0..255) { + // 从黑色到白色的线性渐变 + val grayValue = i + val color = Color(grayValue, grayValue, grayValue) + colorMap[i] = color.toHex() + } + + return colorMap + } + + fun generateTerrainColorMap(): MutableMap { + val colorMap = mutableMapOf() + + // 定义关键颜色点 + val blue = Color(0, 0, 255) // 低地势 - 蓝色 + val cyan = Color(0, 255, 255) // 中间过渡 + val green = Color(0, 255, 0) // 中间过渡 + val yellow = Color(255, 255, 0) // 中间过渡 + val red = Color(255, 0, 0) // 高地势 - 红色 + + for (i in 0..255) { + val position = i / 255.0 + + val color = when { + position < 0.25 -> interpolateColor(blue, cyan, position / 0.25) + position < 0.5 -> interpolateColor(cyan, green, (position - 0.25) / 0.25) + position < 0.75 -> interpolateColor(green, yellow, (position - 0.5) / 0.25) + else -> interpolateColor(yellow, red, (position - 0.75) / 0.25) + } + colorMap[i] = color.toHex() + } + + return colorMap + } + + fun interpolateColor(start: Color, end: Color, fraction: Double): Color { + val r = (start.red + (end.red - start.red) * fraction).toInt() + val g = (start.green + (end.green - start.green) * fraction).toInt() + val b = (start.blue + (end.blue - start.blue) * fraction).toInt() + return Color(r, g, b) + } + + // Color类简化实现 + class Color(val red: Int, val green: Int, val blue: Int) { + fun toArgb(): Int { + return (0xFF shl 24) or (red shl 16) or (green shl 8) or blue + } + + fun toHex(): String { + return String.format("#%06X", toArgb() and 0xFFFFFF) + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/icegps/orx/ktx/ColorRGBa.kt b/android/src/main/java/com/icegps/orx/ktx/ColorRGBa.kt new file mode 100644 index 00000000..098e04a3 --- /dev/null +++ b/android/src/main/java/com/icegps/orx/ktx/ColorRGBa.kt @@ -0,0 +1,23 @@ +package com.icegps.orx.ktx + +import org.openrndr.color.ColorRGBa + +/** + * @author tabidachinokaze + * @date 2025/11/25 + */ +fun ColorRGBa.toColorInt(): Int { + val clampedR = r.coerceIn(0.0, 1.0) + val clampedG = g.coerceIn(0.0, 1.0) + val clampedB = b.coerceIn(0.0, 1.0) + val clampedAlpha = alpha.coerceIn(0.0, 1.0) + + return ((clampedAlpha * 255).toInt() shl 24) or + ((clampedR * 255).toInt() shl 16) or + ((clampedG * 255).toInt() shl 8) or + ((clampedB * 255).toInt()) +} + +fun ColorRGBa.toColorHex(): String { + return String.format("#%06X", 0xFFFFFF and toColorInt()) +} diff --git a/android/src/main/java/com/icegps/orx/ktx/Context.kt b/android/src/main/java/com/icegps/orx/ktx/Context.kt new file mode 100644 index 00000000..8dc5c8d3 --- /dev/null +++ b/android/src/main/java/com/icegps/orx/ktx/Context.kt @@ -0,0 +1,12 @@ +package com.icegps.orx.ktx + +import android.content.Context +import android.widget.Toast + +/** + * @author tabidachinokaze + * @date 2025/11/25 + */ +fun Context.toast(text: String, duration: Int = Toast.LENGTH_SHORT) { + Toast.makeText(this, text, duration).show() +} \ No newline at end of file diff --git a/android/src/main/java/com/icegps/orx/ktx/Vector2.kt b/android/src/main/java/com/icegps/orx/ktx/Vector2.kt new file mode 100644 index 00000000..bc87dc4b --- /dev/null +++ b/android/src/main/java/com/icegps/orx/ktx/Vector2.kt @@ -0,0 +1,22 @@ +package com.icegps.orx.ktx + +import com.icegps.common.helper.GeoHelper +import com.mapbox.geojson.Point +import org.openrndr.math.Vector2 + +fun Vector2.niceStr(): String { + return "[$x, $y, 0.0]".format(this) +} + +fun List.niceStr(): String { + return joinToString(", ", "[", "]") { + it.niceStr() + } +} + +fun Vector2.toMapboxPoint(): Point { + val geoHelper = GeoHelper.getSharedInstance() + return geoHelper.enuToWGS84Object(GeoHelper.ENU(x, y)).run { + Point.fromLngLat(lon, lat, hgt) + } +} \ No newline at end of file diff --git a/android/src/main/java/com/icegps/orx/ktx/Vector3.kt b/android/src/main/java/com/icegps/orx/ktx/Vector3.kt new file mode 100644 index 00000000..79263ffd --- /dev/null +++ b/android/src/main/java/com/icegps/orx/ktx/Vector3.kt @@ -0,0 +1,22 @@ +package com.icegps.orx.ktx + +import org.openrndr.math.Vector3 + +fun Vector3.niceStr(): String { + return "[$x, $y, $z]".format(this) +} + +fun List.niceStr(): String { + return joinToString(", ", "[", "]") { + it.niceStr() + } +} + +val List.area: org.openrndr.shape.Rectangle + get() { + val minX = minOf { it.x } + val maxX = maxOf { it.x } + val minY = minOf { it.y } + val maxY = maxOf { it.y } + return org.openrndr.shape.Rectangle(x = minX, y = minY, width = maxX - minX, height = maxY - minY) + } diff --git a/android/src/main/java/com/icegps/orx/ktx/Vector3D.kt b/android/src/main/java/com/icegps/orx/ktx/Vector3D.kt new file mode 100644 index 00000000..9dd4b0f4 --- /dev/null +++ b/android/src/main/java/com/icegps/orx/ktx/Vector3D.kt @@ -0,0 +1,32 @@ +package com.icegps.orx.ktx + +import com.icegps.common.helper.GeoHelper +import com.icegps.math.geometry.Rectangle +import com.icegps.math.geometry.Vector3D +import com.mapbox.geojson.Point + +fun Vector3D.niceStr(): String { + return "[$x, $y, $z]".format(this) +} + +fun List.niceStr(): String { + return joinToString(", ", "[", "]") { + it.niceStr() + } +} + +fun Vector3D.toMapboxPoint(): Point { + val geoHelper = GeoHelper.getSharedInstance() + return geoHelper.enuToWGS84Object(GeoHelper.ENU(x, y, z)).run { + Point.fromLngLat(lon, lat, hgt) + } +} + +val List.area: Rectangle + get() { + val minX = minOf { it.x } + val maxX = maxOf { it.x } + val minY = minOf { it.y } + val maxY = maxOf { it.y } + return Rectangle(x = minX, y = minY, width = maxX - minX, height = maxY - minY) + } diff --git a/android/src/main/java/com/icegps/orx/triangulation/DelaunayTriangulation3D.kt b/android/src/main/java/com/icegps/orx/triangulation/DelaunayTriangulation3D.kt new file mode 100644 index 00000000..76274160 --- /dev/null +++ b/android/src/main/java/com/icegps/orx/triangulation/DelaunayTriangulation3D.kt @@ -0,0 +1,91 @@ +package com.icegps.orx.triangulation + +import org.openrndr.extra.triangulation.Delaunay +import org.openrndr.math.Vector3 +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 }) + + fun neighbors(pointIndex: Int): Sequence { + return delaunay.neighbors(pointIndex) + } + + fun neighborPoints(pointIndex: Int): List { + return neighbors(pointIndex).map { points[it] }.toList() + } + + fun triangleIndices(): List { + val list = mutableListOf() + for (i in delaunay.triangles.indices step 3) { + list.add( + intArrayOf( + delaunay.triangles[i], + delaunay.triangles[i + 1], + delaunay.triangles[i + 2] + ) + ) + } + return list + } + + fun triangles(filterPredicate: (Int, Int, Int) -> Boolean = { _, _, _ -> true }): MutableList { + val list = mutableListOf() + + for (i in delaunay.triangles.indices step 3) { + val t0 = delaunay.triangles[i] + val t1 = delaunay.triangles[i + 1] + val t2 = delaunay.triangles[i + 2] + + // originally they are defined *counterclockwise* + if (filterPredicate(t2, t1, t0)) { + val p1 = points[t0] + val p2 = points[t1] + val p3 = points[t2] + list.add(Triangle3D(p1, p2, p3)) + } + } + return list + } + + // Inner edges of the delaunay triangulation (without hull) + fun halfedges() = path3D { + for (i in delaunay.halfedges.indices) { + val j = delaunay.halfedges[i] + + if (j < i) continue + val ti = delaunay.triangles[i] + val tj = delaunay.triangles[j] + + moveTo(points[ti]) + lineTo(points[tj]) + } + } + + fun hull() = path3D { + for (h in delaunay.hull) { + moveOrLineTo(points[h]) + } + close() + } + + fun nearest(query: Vector3): Int = delaunay.find(query.x, query.y) + + fun nearestPoint(query: Vector3): Vector3 = points[nearest(query)] +} + +/** + * Computes the Delaunay triangulation for the list of 2D points. + * + * The Delaunay triangulation is a triangulation of a set of points such that + * no point is inside the circumcircle of any triangle. It maximizes the minimum + * angle of all the angles in the triangles, avoiding skinny triangles. + * + * @return A DelaunayTriangulation object representing the triangulation of the given points. + */ +fun List.delaunayTriangulation(): DelaunayTriangulation3D { + return DelaunayTriangulation3D(this) +} diff --git a/android/src/main/java/com/icegps/orx/triangulation/Triangle3D.kt b/android/src/main/java/com/icegps/orx/triangulation/Triangle3D.kt new file mode 100644 index 00000000..0b840eea --- /dev/null +++ b/android/src/main/java/com/icegps/orx/triangulation/Triangle3D.kt @@ -0,0 +1,24 @@ +package com.icegps.orx.triangulation + +import org.openrndr.math.Vector3 +import org.openrndr.shape.BezierSegment +import org.openrndr.shape.Path +import org.openrndr.shape.Path3D + +/** + * @author tabidachinokaze + * @date 2025/11/24 + */ +data class Triangle3D( + val x1: Vector3, + val x2: Vector3, + val x3: Vector3, +) : Path { + val path = Path3D.fromPoints(points = listOf(x1, x2, x3), closed = true) + override fun sub(t0: Double, t1: Double): Path = path.sub(t0, t1) + + override val closed: Boolean get() = path.closed + override val empty: Boolean get() = path.empty + override val infinity: Vector3 get() = path.infinity + override val segments: List> get() = path.segments +} \ 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 b02beaa1..1561e159 100644 --- a/android/src/main/res/layout/activity_main.xml +++ b/android/src/main/res/layout/activity_main.xml @@ -4,11 +4,110 @@ android:id="@+id/main" android:layout_width="match_parent" android:layout_height="match_parent" - android:orientation="vertical" + android:orientation="horizontal" tools:context=".MainActivity"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +