实现等高线的绘制

This commit is contained in:
2025-11-25 19:43:51 +08:00
parent de15029b2b
commit 816e954ed8
31 changed files with 3553 additions and 257 deletions

View File

@@ -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)

View File

@@ -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<Double> = 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<Vector3D> = 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<Vector3D>,
) {
this.points = points
}
fun updateHeightRange(
heightRange: ClosedFloatingPointRange<Double>? = 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<Triangle3D> = 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<Triangle3D> = 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<List<Feature>>()
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<Triangle3D>,
range: ClosedFloatingPointRange<Double>,
area: Rectangle,
cellSize: Double
): List<ShapeContour> {
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<Feature>
) {
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<Feature>
) {
style.removeStyleLayer(layerId)
style.removeStyleSource(sourceId)
val source = geoJsonSource(sourceId) {
featureCollection(FeatureCollection.fromFeatures(features))
}
style.addSource(source)
val layer = fillLayer(layerId, sourceId) {
fillColor(Expression.Companion.toColor(Expression.Companion.get("color"))) // 从属性获取颜色
fillOpacity(0.5)
fillAntialias(true)
}
style.addLayer(layer)
}
fun contoursToLineFeatures(contours: List<ShapeContour>, color: Int): List<List<Feature>> {
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<ShapeContour>, 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<Vector3>): 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>): Vector3 {
/**
* 计算点在三角形中的重心坐标
*/
fun calculateBarycentricCoordinates(
point: Vector2,
v1: Vector3,
v2: Vector3,
v3: Vector3
): Triple<Double, Double, Double> {
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)
}

View File

@@ -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<Vector3D> {
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<List<Vector3D>> {
/**
* 绕 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
}
}
}

View File

@@ -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<Feature>()
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)
}
}

View File

@@ -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<Double?>
)
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<Double?>(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
}

View File

@@ -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<Vector3D>,
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<List<Vector3D>> {
/**
* 绕 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<Vector3D>,
inner: List<Vector3D>,
other: List<Vector3D>
) {
val lineFeatures = mutableListOf<Feature>()
val fillFeatures = mutableListOf<Feature>()
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<Feature>
) {
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<Feature>
) {
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<List<Point>>(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)
}
}
}
}

View File

@@ -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<List<Point>>(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()
}
}

View File

@@ -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<Vector3D>,
inner: List<Vector3D>,
other: List<Vector3D>
) {
val lineFeatures = mutableListOf<Feature>()
val fillFeatures = mutableListOf<Feature>()
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<Feature>
) {
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<Feature>
) {
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() {
}
}

View File

@@ -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<List<Vector3D>>
) {
val lineStrings: List<List<Feature>> = 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<Feature>
) {
mapView.mapboxMap.getStyle { style ->
setupLineLayer(
style = style,
sourceId = sourceId,
layerId = layerId,
features = features
)
}
}
private fun setupLineLayer(
style: Style,
sourceId: String,
layerId: String,
features: List<Feature>
) {
style.removeStyleLayer(layerId)
style.removeStyleSource(sourceId)
val source = geoJsonSource(sourceId) {
featureCollection(FeatureCollection.fromFeatures(features))
}
style.addSource(source)
val layer = 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<Vector3D>,
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]
)
}
}
}

View File

@@ -0,0 +1,123 @@
package com.icegps.orx
import android.util.Log
/**
* @author tabidachinokaze
* @date 2025/11/25
*/
class SimplePalette(
private var range: ClosedFloatingPointRange<Double>
) {
fun setRange(range: ClosedFloatingPointRange<Double>) {
this.range = range
}
private val colors: Map<Int, String>
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<Int, String> {
val colorMap = mutableMapOf<Int, String>()
// 定义关键灰度点
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<Int, String> {
val colorMap = mutableMapOf<Int, String>()
for (i in 0..255) {
// 从黑色到白色的线性渐变
val grayValue = i
val color = Color(grayValue, grayValue, grayValue)
colorMap[i] = color.toHex()
}
return colorMap
}
fun generateTerrainColorMap(): MutableMap<Int, String> {
val colorMap = mutableMapOf<Int, String>()
// 定义关键颜色点
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)
}
}
}

View File

@@ -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())
}

View File

@@ -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()
}

View File

@@ -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<Vector2>.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)
}
}

View File

@@ -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<Vector3>.niceStr(): String {
return joinToString(", ", "[", "]") {
it.niceStr()
}
}
val List<Vector3>.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)
}

View File

@@ -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<Vector3D>.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<Vector3D>.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)
}

View File

@@ -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<Vector3>) {
val delaunay: Delaunay = Delaunay.Companion.from(points.map { it.xy })
fun neighbors(pointIndex: Int): Sequence<Int> {
return delaunay.neighbors(pointIndex)
}
fun neighborPoints(pointIndex: Int): List<Vector3> {
return neighbors(pointIndex).map { points[it] }.toList()
}
fun triangleIndices(): List<IntArray> {
val list = mutableListOf<IntArray>()
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<Triangle3D> {
val list = mutableListOf<Triangle3D>()
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<Vector3>.delaunayTriangulation(): DelaunayTriangulation3D {
return DelaunayTriangulation3D(this)
}

View File

@@ -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<Vector3> {
val path = Path3D.fromPoints(points = listOf(x1, x2, x3), closed = true)
override fun sub(t0: Double, t1: Double): Path<Vector3> = 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<BezierSegment<Vector3>> get() = path.segments
}

View File

@@ -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">
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:paddingTop="32dp">
<com.google.android.material.slider.Slider
android:id="@+id/slider_target_height"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:value="0"
android:valueFrom="0"
android:valueTo="100" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="栅格大小:" />
<com.google.android.material.slider.Slider
android:id="@+id/cell_size"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:value="1"
android:valueFrom="1"
android:valueTo="100" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="高度范围:" />
<com.google.android.material.slider.RangeSlider
android:id="@+id/height_range"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:valueFrom="0"
android:valueTo="100" />
</LinearLayout>
<Switch
android:id="@+id/switch_grid"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:switchPadding="16dp"
android:text="栅格网" />
<Switch
android:id="@+id/switch_triangle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:switchPadding="16dp"
android:text="三角网" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="坐标数量:" />
<TextView
android:id="@+id/point_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<Button
android:id="@+id/update"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="刷新界面" />
<Button
android:id="@+id/clear_points"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="清除所有点" />
</LinearLayout>
<com.mapbox.maps.MapView
android:id="@+id/map_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3" />
</LinearLayout>