Compare commits
22 Commits
8990a6cf64
...
terrain
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c90073363 | |||
| 2525d30c80 | |||
| 0d15c60606 | |||
| ac86ab3976 | |||
| 816e954ed8 | |||
| de15029b2b | |||
| a1a9a9e0e4 | |||
| f81eee8716 | |||
|
|
3ba0395c16 | ||
|
|
10888b0e83 | ||
|
|
6024e62af0 | ||
|
|
4af2ed3fed | ||
|
|
522627ca51 | ||
|
|
72368deb85 | ||
|
|
7ad88da049 | ||
|
|
b24586288d | ||
|
|
9d68b75c5d | ||
|
|
ce123dfabd | ||
|
|
c0832197cd | ||
|
|
e21683640d | ||
|
|
97752e9cf1 | ||
|
|
987c6dafba |
1
.gitignore
vendored
@@ -10,3 +10,4 @@ gradle.properties
|
||||
/ShaderError.glsl
|
||||
/.kotlin
|
||||
/.lwjgl
|
||||
/local.properties
|
||||
|
||||
1
android/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
62
android/build.gradle.kts
Normal file
@@ -0,0 +1,62 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.icegps.orx"
|
||||
compileSdk {
|
||||
version = release(36)
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.icegps.orx"
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions.jvmTarget = JvmTarget.JVM_17
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.mapbox.maps)
|
||||
implementation(project(":math"))
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(project(":icegps-common"))
|
||||
implementation(project(":icegps-shared"))
|
||||
implementation(project(":icegps-triangulation"))
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.ext.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
}
|
||||
21
android/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.icegps.orx
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.icegps.orx", appContext.packageName)
|
||||
}
|
||||
}
|
||||
25
android/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Include this permission to grab user's general location -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<!-- Include only if your app benefits from precise location access. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Orx">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
427
android/src/main/java/com/icegps/orx/ContoursManager.kt
Normal file
@@ -0,0 +1,427 @@
|
||||
package com.icegps.orx
|
||||
|
||||
import ColorBrewer2Type
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import colorBrewer2Palettes
|
||||
import com.icegps.math.geometry.Rectangle
|
||||
import com.icegps.math.geometry.Vector2D
|
||||
import com.icegps.math.geometry.Vector3D
|
||||
import com.icegps.orx.catmullrom.CatmullRomChain2
|
||||
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.orx.marchingsquares.ShapeContour
|
||||
import com.icegps.orx.marchingsquares.findContours
|
||||
import com.icegps.shared.ktx.TAG
|
||||
import com.icegps.triangulation.DelaunayTriangulation
|
||||
import com.icegps.triangulation.Triangle
|
||||
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.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import 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
|
||||
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 = MutableStateFlow<GridModel?>(null)
|
||||
val gridModel = _gridModel.asStateFlow()
|
||||
|
||||
fun setGridVisible(visible: Boolean) {
|
||||
if (visible != isGridVisible) {
|
||||
isGridVisible = visible
|
||||
if (visible) {
|
||||
_gridModel.value?.let { gridModel ->
|
||||
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<Triangle> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
fun refresh() {
|
||||
val points = points
|
||||
if (points.size <= 3) {
|
||||
context.toast("points size ${points.size}")
|
||||
return
|
||||
}
|
||||
job?.cancel()
|
||||
scope.launch {
|
||||
mapView.mapboxMap.getStyle { style ->
|
||||
val step = heightRange.endInclusive / contourSize
|
||||
val zip = (0..contourSize).map { index ->
|
||||
heightRange.start + index * step
|
||||
}.zipWithNext { a, b -> a..b }
|
||||
val area = points.area
|
||||
val triangulation = DelaunayTriangulation(points)
|
||||
val triangles = triangulation.triangles()
|
||||
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.value = gridModel
|
||||
if (isGridVisible) mapView.displayGridModel(
|
||||
grid = gridModel,
|
||||
sourceId = gridSourceId,
|
||||
layerId = gridLayerId,
|
||||
palette = simplePalette::palette
|
||||
)
|
||||
}
|
||||
job = 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: List<Triangle>,
|
||||
range: ClosedFloatingPointRange<Double>,
|
||||
area: Rectangle,
|
||||
cellSize: Double
|
||||
): List<ShapeContour> {
|
||||
return findContours(
|
||||
f = { v ->
|
||||
val triangle = triangles.firstOrNull { triangle ->
|
||||
isPointInTriangle3D(v, listOf(triangle.x1, triangle.x2, triangle.x3))
|
||||
}
|
||||
(triangle?.let { triangle ->
|
||||
val interpolate = interpolateHeight(
|
||||
point = v,
|
||||
triangle = listOf(
|
||||
triangle.x1,
|
||||
triangle.x2,
|
||||
triangle.x3,
|
||||
)
|
||||
)
|
||||
if (interpolate.z in range) -1.0
|
||||
else 1.0
|
||||
} ?: 1.0).also {
|
||||
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.toColor(Expression.Companion.get("color"))) // 从属性获取颜色
|
||||
lineWidth(1.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(layerId, sourceId) {
|
||||
fillColor(Expression.Companion.toColor(Expression.get("color"))) // 从属性获取颜色
|
||||
fillOpacity(0.5)
|
||||
fillAntialias(true)
|
||||
}
|
||||
style.addLayer(layer)
|
||||
}
|
||||
|
||||
private var useCatmullRom: Boolean = true
|
||||
|
||||
fun setCatmullRom(enabled: Boolean) {
|
||||
useCatmullRom = enabled
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!useCatmullRom) return@map it
|
||||
val cmr = CatmullRomChain2(it, 1.0, loop = true)
|
||||
val contour = ShapeContour.fromPoints(cmr.positions(200), true)
|
||||
val start = contour.segments[0].start
|
||||
listOf(start) + contour.segments.map { it.end }
|
||||
}.map { points -> points.map { it.toMapboxPoint() } }
|
||||
|
||||
if (lists.isEmpty()) {
|
||||
Log.w(TAG, "contoursToPolygonFeatures: 没有有效的轮廓数据")
|
||||
return null
|
||||
}
|
||||
|
||||
val polygon = Polygon.fromLngLats(lists)
|
||||
return Feature.fromGeometry(polygon).apply {
|
||||
// 将颜色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: Vector2D, triangle: List<Vector3D>): 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: Vector2D, triangle: List<Vector3D>): Vector3D {
|
||||
/**
|
||||
* 计算点在三角形中的重心坐标
|
||||
*/
|
||||
fun calculateBarycentricCoordinates(
|
||||
point: Vector2D,
|
||||
v1: Vector3D,
|
||||
v2: Vector3D,
|
||||
v3: Vector3D
|
||||
): 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 Vector3D(point.x, point.y, z)
|
||||
}
|
||||
197
android/src/main/java/com/icegps/orx/ControllableArrow.kt
Normal file
@@ -0,0 +1,197 @@
|
||||
package com.icegps.orx
|
||||
|
||||
import com.icegps.math.geometry.Angle
|
||||
import com.icegps.math.geometry.Vector2D
|
||||
import com.icegps.orx.ktx.toMapboxPoint
|
||||
import com.mapbox.geojson.Feature
|
||||
import com.mapbox.geojson.FeatureCollection
|
||||
import com.mapbox.geojson.LineString
|
||||
import com.mapbox.geojson.Point
|
||||
import com.mapbox.geojson.Polygon
|
||||
import com.mapbox.maps.MapView
|
||||
import com.mapbox.maps.Style
|
||||
import com.mapbox.maps.extension.style.expressions.generated.Expression
|
||||
import com.mapbox.maps.extension.style.layers.addLayer
|
||||
import com.mapbox.maps.extension.style.layers.generated.FillLayer
|
||||
import com.mapbox.maps.extension.style.layers.generated.LineLayer
|
||||
import com.mapbox.maps.extension.style.layers.properties.generated.LineCap
|
||||
import com.mapbox.maps.extension.style.layers.properties.generated.LineJoin
|
||||
import com.mapbox.maps.extension.style.sources.addSource
|
||||
import com.mapbox.maps.extension.style.sources.generated.geoJsonSource
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* 设置趋势箭头图层
|
||||
*/
|
||||
fun setupTrendLayer(
|
||||
style: Style,
|
||||
trendSourceId: String,
|
||||
trendLayerId: String,
|
||||
features: List<Feature>
|
||||
) {
|
||||
val trendSource = geoJsonSource(trendSourceId) {
|
||||
featureCollection(FeatureCollection.fromFeatures(features))
|
||||
}
|
||||
|
||||
try {
|
||||
style.removeStyleLayer(trendLayerId)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
try {
|
||||
style.removeStyleLayer("$trendLayerId-head")
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
if (style.styleSourceExists(trendSourceId)) {
|
||||
style.removeStyleSource(trendSourceId)
|
||||
}
|
||||
|
||||
style.addSource(trendSource)
|
||||
|
||||
val lineLayer = LineLayer(trendLayerId, trendSourceId).apply {
|
||||
lineColor(Expression.toColor(Expression.get("color")))
|
||||
lineWidth(4.0)
|
||||
lineCap(LineCap.ROUND)
|
||||
lineJoin(LineJoin.ROUND)
|
||||
}
|
||||
style.addLayer(lineLayer)
|
||||
|
||||
val headLayer = FillLayer("$trendLayerId-head", trendSourceId).apply {
|
||||
fillColor(Expression.toColor(Expression.get("color")))
|
||||
}
|
||||
style.addLayer(headLayer)
|
||||
}
|
||||
|
||||
fun MapView.displayControllableArrow(
|
||||
grid: GridModel,
|
||||
sourceId: String = "controllable-source-id-0",
|
||||
layerId: String = "controllable-layer-id-0",
|
||||
arrowScale: Double = 0.4,
|
||||
angle: Angle,
|
||||
onHeadArrowChange: (List<Point>) -> Unit
|
||||
) {
|
||||
mapboxMap.getStyle { style ->
|
||||
val centerX = (grid.minX + grid.maxX) / 2
|
||||
val centerY = (grid.minY + grid.maxY) / 2
|
||||
|
||||
val regionWidth = grid.maxX - grid.minX
|
||||
val regionHeight = grid.maxY - grid.minY
|
||||
val arrowLength = min(regionWidth, regionHeight) * arrowScale * 1.0
|
||||
|
||||
val arrowDirectionRad = angle.radians
|
||||
val endX = centerX + sin(arrowDirectionRad) * arrowLength
|
||||
val endY = centerY + cos(arrowDirectionRad) * arrowLength
|
||||
|
||||
val arrowLine = LineString.fromLngLats(
|
||||
listOf(
|
||||
Vector2D(centerX, centerY),
|
||||
Vector2D(endX, endY)
|
||||
).map { it.toMapboxPoint() }
|
||||
)
|
||||
|
||||
val arrowFeature = Feature.fromGeometry(arrowLine)
|
||||
arrowFeature.addStringProperty("color", "#0000FF")
|
||||
arrowFeature.addStringProperty("type", "overall-trend")
|
||||
|
||||
// 创建箭头头部
|
||||
val headSize = arrowLength * 0.2
|
||||
val leftRad = arrowDirectionRad + Math.PI * 0.8
|
||||
val rightRad = arrowDirectionRad - Math.PI * 0.8
|
||||
|
||||
val leftX = endX + sin(leftRad) * headSize
|
||||
val leftY = endY + cos(leftRad) * headSize
|
||||
val rightX = endX + sin(rightRad) * headSize
|
||||
val rightY = endY + cos(rightRad) * headSize
|
||||
|
||||
val headRing = listOf(
|
||||
Vector2D(endX, endY),
|
||||
Vector2D(leftX, leftY),
|
||||
Vector2D(rightX, rightY),
|
||||
Vector2D(endX, endY)
|
||||
).map { it.toMapboxPoint() }
|
||||
onHeadArrowChange(headRing)
|
||||
val headPolygon = Polygon.fromLngLats(listOf(headRing))
|
||||
val headFeature = Feature.fromGeometry(headPolygon)
|
||||
headFeature.addStringProperty("color", "#0000FF")
|
||||
headFeature.addStringProperty("type", "overall-trend")
|
||||
|
||||
val features = listOf(arrowFeature, headFeature)
|
||||
|
||||
// 设置图层
|
||||
setupTrendLayer(style, sourceId, layerId, features)
|
||||
}
|
||||
}
|
||||
|
||||
fun calculateArrowData(
|
||||
grid: GridModel,
|
||||
angle: Angle,
|
||||
arrowScale: Double = 0.4
|
||||
): ArrowData {
|
||||
val centerX = (grid.minX + grid.maxX) / 2
|
||||
val centerY = (grid.minY + grid.maxY) / 2
|
||||
|
||||
val regionWidth = grid.maxX - grid.minX
|
||||
val regionHeight = grid.maxY - grid.minY
|
||||
val arrowLength = min(regionWidth, regionHeight) * arrowScale * 1.0
|
||||
|
||||
val arrowDirectionRad = angle.radians
|
||||
val endX = centerX + sin(arrowDirectionRad) * arrowLength
|
||||
val endY = centerY + cos(arrowDirectionRad) * arrowLength
|
||||
|
||||
val arrowLine = listOf(
|
||||
Vector2D(centerX, centerY),
|
||||
Vector2D(endX, endY)
|
||||
)
|
||||
|
||||
// 创建箭头头部
|
||||
val headSize = arrowLength * 0.2
|
||||
val leftRad = arrowDirectionRad + Math.PI * 0.8
|
||||
val rightRad = arrowDirectionRad - Math.PI * 0.8
|
||||
|
||||
val leftX = endX + sin(leftRad) * headSize
|
||||
val leftY = endY + cos(leftRad) * headSize
|
||||
val rightX = endX + sin(rightRad) * headSize
|
||||
val rightY = endY + cos(rightRad) * headSize
|
||||
|
||||
val headRing = listOf(
|
||||
Vector2D(endX, endY),
|
||||
Vector2D(leftX, leftY),
|
||||
Vector2D(rightX, rightY),
|
||||
Vector2D(endX, endY)
|
||||
)
|
||||
return ArrowData(
|
||||
arrowLine = arrowLine,
|
||||
headRing = headRing
|
||||
)
|
||||
}
|
||||
|
||||
data class ArrowData(
|
||||
val arrowLine: List<Vector2D>,
|
||||
val headRing: List<Vector2D>
|
||||
)
|
||||
|
||||
fun MapView.displayControllableArrow(
|
||||
sourceId: String = "controllable-source-id-0",
|
||||
layerId: String = "controllable-layer-id-0",
|
||||
arrowData: ArrowData
|
||||
) {
|
||||
mapboxMap.getStyle { style ->
|
||||
val (arrowLine, headRing) = arrowData
|
||||
val arrowFeature = Feature.fromGeometry(LineString.fromLngLats(arrowLine.map { it.toMapboxPoint() }))
|
||||
arrowFeature.addStringProperty("color", "#0000FF")
|
||||
arrowFeature.addStringProperty("type", "overall-trend")
|
||||
|
||||
val headPolygon = Polygon.fromLngLats(listOf(headRing.map { it.toMapboxPoint() }))
|
||||
val headFeature = Feature.fromGeometry(headPolygon)
|
||||
headFeature.addStringProperty("color", "#0000FF")
|
||||
headFeature.addStringProperty("type", "overall-trend")
|
||||
|
||||
val features = listOf(arrowFeature, headFeature)
|
||||
|
||||
// 设置图层
|
||||
setupTrendLayer(style, sourceId, layerId, features)
|
||||
}
|
||||
}
|
||||
53
android/src/main/java/com/icegps/orx/CoordinateGenerator.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
144
android/src/main/java/com/icegps/orx/DisplaySlopeResult.kt
Normal file
@@ -0,0 +1,144 @@
|
||||
package com.icegps.orx
|
||||
|
||||
import android.util.Log
|
||||
import com.icegps.math.geometry.Vector2D
|
||||
import com.icegps.orx.ktx.toMapboxPoint
|
||||
import com.mapbox.geojson.Feature
|
||||
import com.mapbox.geojson.FeatureCollection
|
||||
import com.mapbox.geojson.Polygon
|
||||
import com.mapbox.maps.MapView
|
||||
import com.mapbox.maps.Style
|
||||
import com.mapbox.maps.extension.style.expressions.generated.Expression
|
||||
import com.mapbox.maps.extension.style.layers.addLayer
|
||||
import com.mapbox.maps.extension.style.layers.generated.FillLayer
|
||||
import com.mapbox.maps.extension.style.layers.generated.LineLayer
|
||||
import com.mapbox.maps.extension.style.sources.addSource
|
||||
import com.mapbox.maps.extension.style.sources.generated.geoJsonSource
|
||||
|
||||
/**
|
||||
* @author tabidachinokaze
|
||||
* @date 2025/11/26
|
||||
*/
|
||||
/**
|
||||
* 绘制斜坡设计结果
|
||||
*/
|
||||
fun MapView.displaySlopeResult(
|
||||
originalGrid: GridModel,
|
||||
slopeResult: SlopeResult,
|
||||
sourceId: String = "slope-result",
|
||||
layerId: String = "slope-layer",
|
||||
palette: (Double?) -> String,
|
||||
showDesignHeight: Boolean
|
||||
) {
|
||||
val elevationList = mutableListOf<Double>()
|
||||
mapboxMap.getStyle { style ->
|
||||
val features = mutableListOf<Feature>()
|
||||
val designGrid = slopeResult.designSurface
|
||||
|
||||
// 对比测试,将绘制到原来图形的左边
|
||||
// val minX = originalGrid.minX * 2 - originalGrid.maxX
|
||||
val minX = originalGrid.minX
|
||||
val maxY = originalGrid.maxY
|
||||
|
||||
val cellSize = originalGrid.cellSize
|
||||
|
||||
for (r in 0 until originalGrid.rows) {
|
||||
for (c in 0 until originalGrid.cols) {
|
||||
val originalElev = originalGrid.getValue(r, c) ?: continue
|
||||
val designElev = designGrid.getValue(r, c) ?: continue
|
||||
elevationList.add(designElev)
|
||||
|
||||
// 计算填挖高度
|
||||
val heightDiff = designElev - originalElev
|
||||
|
||||
// 计算栅格边界
|
||||
val x0 = minX + c * cellSize
|
||||
val y0 = maxY - r * cellSize
|
||||
val x1 = x0 + cellSize
|
||||
val y1 = y0 - cellSize
|
||||
|
||||
// 1. 创建多边形要素(背景色)
|
||||
val ring = listOf(
|
||||
Vector2D(x0, y0),
|
||||
Vector2D(x1, y0),
|
||||
Vector2D(x1, y1),
|
||||
Vector2D(x0, y1),
|
||||
Vector2D(x0, y0)
|
||||
).map { it.toMapboxPoint() }
|
||||
val poly = Polygon.fromLngLats(listOf(ring))
|
||||
val feature = Feature.fromGeometry(poly)
|
||||
|
||||
if (showDesignHeight) {
|
||||
// 显示设计高度,测试坡向是否正确,和高度是否计算正确
|
||||
feature.addStringProperty("color", palette(designElev))
|
||||
} else {
|
||||
// 显示高差
|
||||
feature.addStringProperty("color", palette(heightDiff))
|
||||
}
|
||||
// 显示原始高度
|
||||
// feature.addStringProperty("color", palette(originalElev))
|
||||
features.add(feature)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("displayGridWithDirectionArrows", "对比区域的土方量计算: ${elevationList.sum()}, 平均值:${elevationList.average()}")
|
||||
|
||||
// 设置图层
|
||||
setupEarthworkLayer(style, sourceId, layerId, features)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整的土方工程图层设置 - 修正版
|
||||
*/
|
||||
private fun setupEarthworkLayer(
|
||||
style: Style,
|
||||
sourceId: String,
|
||||
layerId: String,
|
||||
features: List<Feature>,
|
||||
) {
|
||||
// 创建数据源
|
||||
val source = geoJsonSource(sourceId) {
|
||||
featureCollection(FeatureCollection.fromFeatures(features))
|
||||
}
|
||||
|
||||
// 清理旧图层
|
||||
try {
|
||||
style.removeStyleLayer(layerId)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
try {
|
||||
style.removeStyleLayer("$layerId-arrow")
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
try {
|
||||
style.removeStyleLayer("$layerId-outline")
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
try {
|
||||
style.removeStyleLayer("$layerId-text")
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
if (style.styleSourceExists(sourceId)) {
|
||||
style.removeStyleSource(sourceId)
|
||||
}
|
||||
|
||||
// 添加数据源
|
||||
style.addSource(source)
|
||||
|
||||
// 主填充图层
|
||||
val fillLayer = FillLayer(layerId, sourceId).apply {
|
||||
fillColor(Expression.toColor(Expression.get("color")))
|
||||
fillOpacity(0.7)
|
||||
}
|
||||
style.addLayer(fillLayer)
|
||||
|
||||
// 边框图层
|
||||
val outlineLayer = LineLayer("$layerId-outline", sourceId).apply {
|
||||
lineColor("#333333")
|
||||
lineWidth(1.0)
|
||||
lineOpacity(0.5)
|
||||
}
|
||||
style.addLayer(outlineLayer)
|
||||
}
|
||||
438
android/src/main/java/com/icegps/orx/EarthworkManager.kt
Normal file
@@ -0,0 +1,438 @@
|
||||
package com.icegps.orx
|
||||
|
||||
import android.graphics.PointF
|
||||
import android.util.Log
|
||||
import com.icegps.common.helper.GeoHelper
|
||||
import com.icegps.math.geometry.Angle
|
||||
import com.icegps.math.geometry.Vector2D
|
||||
import com.icegps.math.geometry.degrees
|
||||
import com.icegps.shared.ktx.TAG
|
||||
import com.mapbox.android.gestures.MoveGestureDetector
|
||||
import com.mapbox.geojson.Point
|
||||
import com.mapbox.maps.MapView
|
||||
import com.mapbox.maps.ScreenCoordinate
|
||||
import com.mapbox.maps.plugin.gestures.OnMoveListener
|
||||
import com.mapbox.maps.plugin.gestures.addOnMoveListener
|
||||
import com.mapbox.maps.plugin.gestures.removeOnMoveListener
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* @author tabidachinokaze
|
||||
* @date 2025/11/26
|
||||
*/
|
||||
object SlopeCalculator {
|
||||
fun calculateSlope(
|
||||
grid: GridModel,
|
||||
slopeDirection: Double,
|
||||
slopePercentage: Double,
|
||||
baseHeightOffset: Double = 0.0
|
||||
): SlopeResult {
|
||||
val centerX = (grid.minX + grid.maxX) / 2
|
||||
val centerY = (grid.minY + grid.maxY) / 2
|
||||
|
||||
val elevations = grid.cells.filterNotNull()
|
||||
val baseElevation = elevations.average() + baseHeightOffset
|
||||
|
||||
val basePoint = Triple(centerX, centerY, baseElevation)
|
||||
|
||||
val earthworkResult = EarthworkCalculator.calculateForSlopeDesign(
|
||||
grid = grid,
|
||||
basePoint = basePoint,
|
||||
slope = slopePercentage,
|
||||
aspect = slopeDirection
|
||||
)
|
||||
|
||||
return SlopeResult(
|
||||
slopeDirection = slopeDirection,
|
||||
slopePercentage = slopePercentage,
|
||||
baseHeightOffset = baseHeightOffset,
|
||||
baseElevation = baseElevation,
|
||||
earthworkResult = earthworkResult,
|
||||
designSurface = generateSlopeDesignGrid(
|
||||
grid = grid,
|
||||
basePoint = basePoint,
|
||||
slopePercentage = slopePercentage,
|
||||
slopeDirection = slopeDirection
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 生成斜坡设计面网格(用于可视化)
|
||||
*/
|
||||
private fun generateSlopeDesignGrid(
|
||||
grid: GridModel,
|
||||
basePoint: Triple<Double, Double, Double>,
|
||||
slopePercentage: Double,
|
||||
slopeDirection: Double
|
||||
): GridModel {
|
||||
val designCells = Array<Double?>(grid.rows * grid.cols) { null }
|
||||
val (baseX, baseY, baseElev) = basePoint
|
||||
val slopeRatio = slopePercentage / 100.0
|
||||
|
||||
for (r in 0 until grid.rows) {
|
||||
for (c in 0 until grid.cols) {
|
||||
if (grid.getValue(r, c) != null) {
|
||||
val cellX = grid.minX + (c + 0.5) * (grid.maxX - grid.minX) / grid.cols
|
||||
val cellY = grid.minY + (r + 0.5) * (grid.maxY - grid.minY) / grid.rows
|
||||
|
||||
val designElev = calculateSlopeElevation(
|
||||
pointX = cellX,
|
||||
pointY = cellY,
|
||||
baseX = baseX,
|
||||
baseY = baseY,
|
||||
baseElev = baseElev,
|
||||
slopeRatio = slopeRatio,
|
||||
slopeDirection = slopeDirection
|
||||
)
|
||||
designCells[r * grid.cols + c] = designElev
|
||||
}
|
||||
}
|
||||
}
|
||||
return GridModel(
|
||||
minX = grid.minX,
|
||||
maxX = grid.maxX,
|
||||
minY = grid.minY,
|
||||
maxY = grid.maxY,
|
||||
rows = grid.rows,
|
||||
cols = grid.cols,
|
||||
cellSize = grid.cellSize,
|
||||
cells = designCells
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 斜坡高程计算
|
||||
*/
|
||||
fun calculateSlopeElevation(
|
||||
pointX: Double,
|
||||
pointY: Double,
|
||||
baseX: Double,
|
||||
baseY: Double,
|
||||
baseElev: Double,
|
||||
slopeRatio: Double,
|
||||
slopeDirection: Double
|
||||
): Double {
|
||||
val dx = (pointX - baseX) * cos(Math.toRadians(baseY))
|
||||
val dy = (pointY - baseY)
|
||||
|
||||
val slopeRad = (slopeDirection.degrees - 90.degrees).normalized.radians
|
||||
|
||||
val projection = dx * cos(slopeRad) + dy * sin(slopeRad)
|
||||
val heightDiff = projection * slopeRatio
|
||||
|
||||
return baseElev + heightDiff
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 斜面设计
|
||||
*
|
||||
* @property slopeDirection 坡向 (度)
|
||||
* @property slopePercentage 坡度 (%)
|
||||
* @property baseHeightOffset 基准面高度偏移 (m)
|
||||
* @property baseElevation 基准点高程 (m)
|
||||
* @property earthworkResult 土方量结果
|
||||
* @property designSurface 设计面网格(用于可视化)
|
||||
*/
|
||||
data class SlopeResult(
|
||||
val slopeDirection: Double,
|
||||
val slopePercentage: Double,
|
||||
val baseHeightOffset: Double,
|
||||
val baseElevation: Double,
|
||||
val earthworkResult: EarthworkResult,
|
||||
val designSurface: GridModel
|
||||
)
|
||||
|
||||
object EarthworkCalculator {
|
||||
/**
|
||||
* @param grid 栅格网模型
|
||||
* @param designElevation 设计高程
|
||||
*/
|
||||
fun calculateForFlatDesign(
|
||||
grid: GridModel,
|
||||
designElevation: Double
|
||||
): EarthworkResult {
|
||||
var cutVolume = 0.0
|
||||
var fillVolume = 0.0
|
||||
var cutArea = 0.0
|
||||
var fillArea = 0.0
|
||||
val cellArea = grid.cellSize * grid.cellSize
|
||||
|
||||
for (r in 0 until grid.rows) {
|
||||
for (c in 0 until grid.cols) {
|
||||
val originalElev = grid.getValue(r, c) ?: continue
|
||||
|
||||
val heightDiff = designElevation - originalElev
|
||||
|
||||
val volume = heightDiff * cellArea
|
||||
|
||||
if (volume > 0) {
|
||||
fillVolume += volume
|
||||
fillArea += cellArea
|
||||
} else if (volume < 0) {
|
||||
cutVolume += abs(volume)
|
||||
cutArea += cellArea
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return EarthworkResult(
|
||||
cutVolume = cutVolume,
|
||||
fillVolume = fillVolume,
|
||||
netVolume = fillVolume - cutVolume,
|
||||
cutArea = cutArea,
|
||||
fillArea = fillArea,
|
||||
totalArea = cutArea + fillArea
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算斜面设计的土方量
|
||||
*/
|
||||
fun calculateForSlopeDesign(
|
||||
grid: GridModel,
|
||||
basePoint: Triple<Double, Double, Double>,
|
||||
slope: Double,
|
||||
aspect: Double
|
||||
): EarthworkResult {
|
||||
var cutVolume = 0.0
|
||||
var fillVolume = 0.0
|
||||
var cutArea = 0.0
|
||||
var fillArea = 0.0
|
||||
val cellArea = grid.cellSize * grid.cellSize
|
||||
|
||||
val (baseX, baseY, baseElev) = basePoint
|
||||
val slopeRatio = slope / 100.0
|
||||
|
||||
for (r in 0 until grid.rows) {
|
||||
for (c in 0 until grid.cols) {
|
||||
val originalElev = grid.getValue(r, c) ?: continue
|
||||
|
||||
val cellX = grid.minX + (c + 0.5) * (grid.maxX - grid.minX) / grid.cols
|
||||
val cellY = grid.minY + (r + 0.5) * (grid.maxY - grid.minY) / grid.rows
|
||||
|
||||
val designElev = SlopeCalculator.calculateSlopeElevation(
|
||||
pointX = cellX,
|
||||
pointY = cellY,
|
||||
baseX = baseX,
|
||||
baseY = baseY,
|
||||
baseElev = baseElev,
|
||||
slopeRatio = slopeRatio,
|
||||
slopeDirection = aspect
|
||||
)
|
||||
|
||||
val heightElev = designElev - originalElev
|
||||
val volume = heightElev * cellArea
|
||||
|
||||
if (volume > 0) {
|
||||
fillVolume += volume
|
||||
fillArea += cellArea
|
||||
} else if (volume < 0) {
|
||||
cutVolume += abs(volume)
|
||||
cutArea += cellArea
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return EarthworkResult(
|
||||
cutVolume = cutVolume,
|
||||
fillVolume = fillVolume,
|
||||
netVolume = fillVolume - cutVolume,
|
||||
cutArea = cutArea,
|
||||
fillArea = fillArea,
|
||||
totalArea = cutArea + fillArea
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 土方量计算结果
|
||||
* @property cutVolume 挖方量 (m³)
|
||||
* @property fillVolume 填方量 (m³)
|
||||
* @property netVolume 净土方量 (m³)
|
||||
* @property cutArea 挖方面积 (m²)
|
||||
* @property fillArea 填方面积 (m²)
|
||||
* @property totalArea 总面积 (m²)
|
||||
*/
|
||||
data class EarthworkResult(
|
||||
val cutVolume: Double,
|
||||
val fillVolume: Double,
|
||||
val netVolume: Double,
|
||||
val cutArea: Double,
|
||||
val fillArea: Double,
|
||||
val totalArea: Double
|
||||
) {
|
||||
override fun toString(): String {
|
||||
return buildString {
|
||||
appendLine("EarthworkResult")
|
||||
appendLine("挖方: ${"%.1f".format(cutVolume)} m³")
|
||||
appendLine("填方: ${"%.1f".format(fillVolume)} m³")
|
||||
appendLine("净土方: ${"%.1f".format(netVolume)} m³")
|
||||
appendLine("挖方面积: ${"%.1f".format(cutArea)} m²")
|
||||
appendLine("填方面积: ${"%.1f".format(fillArea)} m²")
|
||||
appendLine("总面积:${"%.1f".format(totalArea)} m²")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EarthworkManager(
|
||||
private val mapView: MapView,
|
||||
private val scope: CoroutineScope
|
||||
) {
|
||||
private val arrowSourceId: String = "controllable-source-id-0"
|
||||
private val arrowLayerId: String = "controllable-layer-id-0"
|
||||
private var listener: OnMoveListener? = null
|
||||
|
||||
private var gridModel = MutableStateFlow<GridModel?>(null)
|
||||
private val arrowHead = MutableStateFlow(emptyList<Vector2D>())
|
||||
private var arrowCenter = MutableStateFlow(Vector2D(0.0, 0.0))
|
||||
private var arrowEnd = MutableStateFlow(Vector2D(0.0, 1.0))
|
||||
private var _slopeDirection = MutableStateFlow(0.degrees)
|
||||
val slopeDirection = _slopeDirection.asStateFlow()
|
||||
private val _slopePercentage = MutableStateFlow(90.0)
|
||||
val slopePercentage = _slopePercentage.asStateFlow()
|
||||
private val _baseHeightOffset = MutableStateFlow(0.0)
|
||||
val baseHeightOffset = _baseHeightOffset.asStateFlow()
|
||||
|
||||
init {
|
||||
combine(
|
||||
arrowCenter,
|
||||
arrowEnd,
|
||||
gridModel
|
||||
) { center, arrow, gridModel ->
|
||||
gridModel?.let { gridModel ->
|
||||
// _slopeDirection.value = angle
|
||||
displayControllableArrow(gridModel, getSlopeDirection(arrow, center))
|
||||
}
|
||||
}.launchIn(scope)
|
||||
combine(
|
||||
_slopeDirection,
|
||||
gridModel
|
||||
) { slopeDirection, gridModel ->
|
||||
gridModel?.let {
|
||||
displayControllableArrow(it, slopeDirection)
|
||||
}
|
||||
}.launchIn(scope)
|
||||
}
|
||||
|
||||
private fun getSlopeDirection(
|
||||
arrow: Vector2D,
|
||||
center: Vector2D
|
||||
): Angle {
|
||||
val direction = (arrow - center)
|
||||
val atan2 = Angle.atan2(direction.x, direction.y, Vector2D.UP)
|
||||
val angle = atan2.normalized
|
||||
return angle
|
||||
}
|
||||
|
||||
private fun displayControllableArrow(gridModel: GridModel, slopeDirection: Angle) {
|
||||
val arrowData = calculateArrowData(
|
||||
grid = gridModel,
|
||||
angle = slopeDirection,
|
||||
)
|
||||
arrowHead.value = arrowData.headRing
|
||||
mapView.displayControllableArrow(
|
||||
sourceId = arrowSourceId,
|
||||
layerId = arrowLayerId,
|
||||
arrowData = arrowData,
|
||||
)
|
||||
}
|
||||
|
||||
fun Point.toVector2D(): Vector2D {
|
||||
val geoHelper = GeoHelper.getSharedInstance()
|
||||
val enu = geoHelper.wgs84ToENU(lon = longitude(), lat = latitude(), hgt = 0.0)
|
||||
return Vector2D(enu.x, enu.y)
|
||||
}
|
||||
|
||||
fun removeOnMoveListener() {
|
||||
listener?.let(mapView.mapboxMap::removeOnMoveListener)
|
||||
listener = null
|
||||
}
|
||||
|
||||
fun setupOnMoveListener() {
|
||||
listener = object : OnMoveListener {
|
||||
private var beginning: Boolean = false
|
||||
private var isDragging: Boolean = false
|
||||
private fun getCoordinate(focalPoint: PointF): Point {
|
||||
return mapView.mapboxMap.coordinateForPixel(ScreenCoordinate(focalPoint.x.toDouble(), focalPoint.y.toDouble()))
|
||||
}
|
||||
|
||||
override fun onMove(detector: MoveGestureDetector): Boolean {
|
||||
val focalPoint = detector.focalPoint
|
||||
val point = mapView.mapboxMap
|
||||
.coordinateForPixel(ScreenCoordinate(focalPoint.x.toDouble(), focalPoint.y.toDouble()))
|
||||
.toVector2D()
|
||||
|
||||
val isPointInPolygon = RayCastingAlgorithm.isPointInPolygon(
|
||||
point = point,
|
||||
polygon = arrowHead.value
|
||||
)
|
||||
|
||||
if (isPointInPolygon) {
|
||||
isDragging = true
|
||||
}
|
||||
if (isDragging) {
|
||||
arrowEnd.value = point
|
||||
}
|
||||
return isDragging
|
||||
}
|
||||
|
||||
override fun onMoveBegin(detector: MoveGestureDetector) {
|
||||
Log.d(TAG, "onMoveBegin: $detector")
|
||||
beginning = true
|
||||
}
|
||||
|
||||
override fun onMoveEnd(detector: MoveGestureDetector) {
|
||||
Log.d(TAG, "onMoveEnd: $detector")
|
||||
val point = getCoordinate(detector.focalPoint)
|
||||
val arrow = point.toVector2D()
|
||||
if (beginning && isDragging) {
|
||||
arrowEnd.value = arrow
|
||||
val center = arrowCenter.value
|
||||
_slopeDirection.value = getSlopeDirection(arrow, center)
|
||||
}
|
||||
Log.d(
|
||||
TAG,
|
||||
buildString {
|
||||
appendLine("onMoveEnd: ")
|
||||
appendLine("${point.longitude()}, ${point.latitude()}")
|
||||
}
|
||||
)
|
||||
isDragging = false
|
||||
beginning = false
|
||||
}
|
||||
}.also(mapView.mapboxMap::addOnMoveListener)
|
||||
}
|
||||
|
||||
fun updateGridModel(gridModel: GridModel) {
|
||||
this.gridModel.value = gridModel
|
||||
calculateArrowCenter(gridModel)
|
||||
}
|
||||
|
||||
private fun calculateArrowCenter(gridModel: GridModel) {
|
||||
val centerX = (gridModel.minX + gridModel.maxX) / 2
|
||||
val centerY = (gridModel.minY + gridModel.maxY) / 2
|
||||
arrowCenter.value = Vector2D(centerX, centerY)
|
||||
}
|
||||
|
||||
fun updateSlopeDirection(angle: Angle) {
|
||||
_slopeDirection.value = angle
|
||||
}
|
||||
|
||||
fun updateSlopePercentage(value: Double) {
|
||||
_slopePercentage.value = value
|
||||
}
|
||||
|
||||
fun updateDesignHeight(value: Double) {
|
||||
_baseHeightOffset.value = value
|
||||
}
|
||||
}
|
||||
83
android/src/main/java/com/icegps/orx/GridDisplay.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
139
android/src/main/java/com/icegps/orx/GridModel.kt
Normal file
@@ -0,0 +1,139 @@
|
||||
package com.icegps.orx
|
||||
|
||||
import com.icegps.math.geometry.Vector2D
|
||||
import com.icegps.math.geometry.Vector3D
|
||||
import com.icegps.triangulation.DelaunayTriangulation
|
||||
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 getValue(row: Int, col: Int): Double? {
|
||||
if (row !in 0..<rows || col < 0 || col >= cols) {
|
||||
return null
|
||||
}
|
||||
return cells[row * cols + col]
|
||||
}
|
||||
}
|
||||
|
||||
fun triangulationToGrid(
|
||||
delaunator: DelaunayTriangulation,
|
||||
cellSize: Double = 50.0,
|
||||
maxSidePixels: Int = 5000
|
||||
): GridModel {
|
||||
fun pointInTriangle(pt: Vector2D, a: Vector3D, b: Vector3D, c: Vector3D): 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: Vector3D, b: Vector3D, c: Vector3D, values: DoubleArray): Double {
|
||||
val area = { p1: Vector2D, p2: Vector3D, p3: Vector3D ->
|
||||
((p2.x - p1.x) * (p3.y - p1.y) - (p3.x - p1.x) * (p2.y - p1.y)).absoluteValue / 2.0
|
||||
}
|
||||
val area2 = { p1: Vector3D, p2: Vector3D, p3: Vector3D ->
|
||||
((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
|
||||
}
|
||||
220
android/src/main/java/com/icegps/orx/MainActivity.kt
Normal file
@@ -0,0 +1,220 @@
|
||||
package com.icegps.orx
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
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.degrees
|
||||
import com.icegps.orx.databinding.ActivityMainBinding
|
||||
import com.icegps.shared.model.GeoPoint
|
||||
import com.mapbox.geojson.Point
|
||||
import com.mapbox.maps.CameraOptions
|
||||
import com.mapbox.maps.MapView
|
||||
import com.mapbox.maps.plugin.gestures.addOnMapClickListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
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
|
||||
private lateinit var earthworkManager: EarthworkManager
|
||||
|
||||
init {
|
||||
initGeoHelper()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
mapView = binding.mapView
|
||||
earthworkManager = EarthworkManager(mapView, lifecycleScope)
|
||||
setContentView(binding.root)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
|
||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
|
||||
insets
|
||||
}
|
||||
|
||||
mapView.mapboxMap.setCamera(
|
||||
CameraOptions.Builder()
|
||||
.center(Point.fromLngLat(home.longitude, home.latitude))
|
||||
.pitch(0.0)
|
||||
.zoom(18.0)
|
||||
.bearing(0.0)
|
||||
.build()
|
||||
)
|
||||
|
||||
val points = coordinateGenerate()
|
||||
|
||||
// divider
|
||||
contoursManager = ContoursManager(
|
||||
context = this,
|
||||
mapView = mapView,
|
||||
scope = lifecycleScope
|
||||
)
|
||||
contoursManager.updateContourSize(6)
|
||||
contoursManager.updatePoints(points)
|
||||
val height = points.map { it.z }
|
||||
val min = height.min()
|
||||
val max = height.max()
|
||||
contoursManager.updateHeightRange((min / 2)..max)
|
||||
binding.heightRange.values = listOf(min.toFloat() / 2, max.toFloat())
|
||||
binding.heightRange.valueFrom = min.toFloat()
|
||||
binding.heightRange.valueTo = max.toFloat()
|
||||
contoursManager.refresh()
|
||||
|
||||
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())
|
||||
contoursManager.refresh()
|
||||
}
|
||||
}
|
||||
)
|
||||
mapView.mapboxMap.addOnMapClickListener {
|
||||
viewModel.addPoint(it)
|
||||
true
|
||||
}
|
||||
binding.clearPoints.setOnClickListener {
|
||||
viewModel.clearPoints()
|
||||
}
|
||||
binding.slopeDirection.addOnSliderTouchListener(
|
||||
object : Slider.OnSliderTouchListener {
|
||||
override fun onStartTrackingTouch(slider: Slider) {
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(slider: Slider) {
|
||||
earthworkManager.updateSlopeDirection(slider.value.degrees)
|
||||
}
|
||||
}
|
||||
)
|
||||
binding.slopePercentage.addOnSliderTouchListener(
|
||||
object : Slider.OnSliderTouchListener {
|
||||
override fun onStartTrackingTouch(slider: Slider) {
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(slider: Slider) {
|
||||
earthworkManager.updateSlopePercentage(slider.value.toDouble())
|
||||
}
|
||||
}
|
||||
)
|
||||
binding.designHeight.addOnSliderTouchListener(
|
||||
object : Slider.OnSliderTouchListener {
|
||||
override fun onStartTrackingTouch(slider: Slider) {
|
||||
}
|
||||
|
||||
override fun onStopTrackingTouch(slider: Slider) {
|
||||
earthworkManager.updateDesignHeight(slider.value.toDouble())
|
||||
}
|
||||
}
|
||||
)
|
||||
binding.switchDesignSurface.setOnCheckedChangeListener { button, isChecked ->
|
||||
showDesignHeight.value = isChecked
|
||||
}
|
||||
earthworkManager.setupOnMoveListener()
|
||||
initData()
|
||||
}
|
||||
|
||||
private val showDesignHeight = MutableStateFlow(false)
|
||||
|
||||
private fun initData() {
|
||||
viewModel.points.onEach {
|
||||
contoursManager.updatePoints(it)
|
||||
contoursManager.updateHeightRange()
|
||||
contoursManager.refresh()
|
||||
}.launchIn(lifecycleScope)
|
||||
contoursManager.gridModel.filterNotNull().onEach {
|
||||
earthworkManager.updateGridModel(it)
|
||||
}.launchIn(lifecycleScope)
|
||||
earthworkManager.slopeDirection.onEach {
|
||||
binding.slopeDirection.value = it.degrees.toFloat()
|
||||
}.launchIn(lifecycleScope)
|
||||
combine(
|
||||
earthworkManager.slopeDirection,
|
||||
earthworkManager.slopePercentage,
|
||||
earthworkManager.baseHeightOffset,
|
||||
contoursManager.gridModel,
|
||||
showDesignHeight
|
||||
) { slopeDirection, slopePercentage, baseHeightOffset, gridModel, showDesignHeight ->
|
||||
gridModel?.let { gridModel ->
|
||||
val slopeResult: SlopeResult = SlopeCalculator.calculateSlope(
|
||||
grid = gridModel,
|
||||
slopeDirection = slopeDirection.degrees,
|
||||
slopePercentage = slopePercentage,
|
||||
baseHeightOffset = baseHeightOffset
|
||||
)
|
||||
mapView.displaySlopeResult(
|
||||
originalGrid = gridModel,
|
||||
slopeResult = slopeResult,
|
||||
palette = contoursManager.simplePalette::palette,
|
||||
showDesignHeight = showDesignHeight
|
||||
)
|
||||
}
|
||||
}.launchIn(lifecycleScope)
|
||||
}
|
||||
}
|
||||
|
||||
val home = GeoPoint(114.476060, 22.771073, 30.897)
|
||||
|
||||
fun initGeoHelper(base: GeoPoint = home) {
|
||||
val geoHelper = GeoHelper.getSharedInstance()
|
||||
geoHelper.wgs84ToENU(
|
||||
lon = base.longitude,
|
||||
lat = base.latitude,
|
||||
hgt = base.altitude
|
||||
)
|
||||
}
|
||||
59
android/src/main/java/com/icegps/orx/MainViewModel.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
123
android/src/main/java/com/icegps/orx/PolygonTest.kt
Normal 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() {
|
||||
|
||||
}
|
||||
}
|
||||
121
android/src/main/java/com/icegps/orx/PolylineManager.kt
Normal file
@@ -0,0 +1,121 @@
|
||||
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
|
||||
|
||||
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,
|
||||
) = 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]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
44
android/src/main/java/com/icegps/orx/RayCastingAlgorithm.kt
Normal file
@@ -0,0 +1,44 @@
|
||||
package com.icegps.orx
|
||||
|
||||
import com.icegps.math.geometry.Vector2D
|
||||
import com.icegps.math.geometry.Vector3D
|
||||
import com.icegps.math.geometry.toVector2D
|
||||
|
||||
/**
|
||||
* @author tabidachinokaze
|
||||
* @date 2025/11/26
|
||||
*/
|
||||
object RayCastingAlgorithm {
|
||||
/**
|
||||
* 使用射线法判断点是否在多边形内
|
||||
* @param point 测试点
|
||||
* @param polygon 多边形顶点列表
|
||||
* @return true如果在多边形内
|
||||
*/
|
||||
fun isPointInPolygon(point: Vector2D, polygon: List<Vector2D>): Boolean {
|
||||
if (polygon.size < 3) return false
|
||||
|
||||
val x = point.x
|
||||
val y = point.y
|
||||
var inside = false
|
||||
|
||||
var j = polygon.size - 1
|
||||
for (i in polygon.indices) {
|
||||
val xi = polygon[i].x
|
||||
val yi = polygon[i].y
|
||||
val xj = polygon[j].x
|
||||
val yj = polygon[j].y
|
||||
|
||||
val intersect = ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)
|
||||
|
||||
if (intersect) inside = !inside
|
||||
j = i
|
||||
}
|
||||
|
||||
return inside
|
||||
}
|
||||
|
||||
fun isPointInPolygon(point: Vector3D, polygon: List<Vector3D>): Boolean {
|
||||
return isPointInPolygon(point.toVector2D(), polygon.map { it.toVector2D() })
|
||||
}
|
||||
}
|
||||
123
android/src/main/java/com/icegps/orx/SimplePalette.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
135
android/src/main/java/com/icegps/orx/catmullrom/CatmullRom.kt
Normal file
@@ -0,0 +1,135 @@
|
||||
package com.icegps.orx.catmullrom
|
||||
|
||||
import com.icegps.math.geometry.Vector2D
|
||||
import com.icegps.orx.marchingsquares.Segment2D
|
||||
import com.icegps.orx.marchingsquares.ShapeContour
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
private const val almostZero = 0.00000001
|
||||
private const val almostOne = 0.99999999
|
||||
|
||||
/**
|
||||
* Creates a 2D Catmull-Rom spline curve.
|
||||
*
|
||||
* Can be represented as a segment drawn between [p1] and [p2],
|
||||
* while [p0] and [p3] are used as control points.
|
||||
*
|
||||
* Under some circumstances alpha can have
|
||||
* no perceptible effect, for example,
|
||||
* when creating closed shapes with the vertices
|
||||
* forming a regular 2D polygon.
|
||||
*
|
||||
* @param p0 The first control point.
|
||||
* @param p1 The starting anchor point.
|
||||
* @param p2 The ending anchor point.
|
||||
* @param p3 The second control point.
|
||||
* @param alpha The *tension* of the curve.
|
||||
* Use `0.0` for the uniform spline, `0.5` for the centripetal spline, `1.0` for the chordal spline.
|
||||
*/
|
||||
class CatmullRom2(val p0: Vector2D, val p1: Vector2D, val p2: Vector2D, val p3: Vector2D, val alpha: Double = 0.5) {
|
||||
/** Value of t for p0. */
|
||||
val t0: Double = 0.0
|
||||
|
||||
/** Value of t for p1. */
|
||||
val t1: Double = calculateT(t0, p0, p1)
|
||||
|
||||
/** Value of t for p2. */
|
||||
val t2: Double = calculateT(t1, p1, p2)
|
||||
|
||||
/** Value of t for p3. */
|
||||
val t3: Double = calculateT(t2, p2, p3)
|
||||
|
||||
fun position(rt: Double): Vector2D {
|
||||
val t = t1 + rt * (t2 - t1)
|
||||
val a1 = p0 * ((t1 - t) / (t1 - t0)) + p1 * ((t - t0) / (t1 - t0))
|
||||
val a2 = p1 * ((t2 - t) / (t2 - t1)) + p2 * ((t - t1) / (t2 - t1))
|
||||
val a3 = p2 * ((t3 - t) / (t3 - t2)) + p3 * ((t - t2) / (t3 - t2))
|
||||
|
||||
val b1 = a1 * ((t2 - t) / (t2 - t0)) + a2 * ((t - t0) / (t2 - t0))
|
||||
val b2 = a2 * ((t3 - t) / (t3 - t1)) + a3 * ((t - t1) / (t3 - t1))
|
||||
|
||||
val c = b1 * ((t2 - t) / (t2 - t1)) + b2 * ((t - t1) / (t2 - t1))
|
||||
return c
|
||||
}
|
||||
|
||||
private fun calculateT(t: Double, p0: Vector2D, p1: Vector2D): Double {
|
||||
val a = (p1.x - p0.x).pow(2.0) + (p1.y - p0.y).pow(2.0)
|
||||
val b = a.pow(0.5)
|
||||
val c = b.pow(alpha)
|
||||
return c + t
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the 2D Catmull–Rom spline for a chain of points and returns the combined curve.
|
||||
*
|
||||
* For more details, see [CatmullRom2].
|
||||
*
|
||||
* @param points The [List] of 2D points where [CatmullRom2] is applied in groups of 4.
|
||||
* @param alpha The *tension* of the curve.
|
||||
* Use `0.0` for the uniform spline, `0.5` for the centripetal spline, `1.0` for the chordal spline.
|
||||
* @param loop Whether to connect the first and last point, such that it forms a closed shape.
|
||||
*/
|
||||
class CatmullRomChain2(points: List<Vector2D>, alpha: Double = 0.5, val loop: Boolean = false) {
|
||||
val segments = if (!loop) {
|
||||
val startPoints = points.take(2)
|
||||
val endPoints = points.takeLast(2)
|
||||
val mirrorStart =
|
||||
startPoints.first() - (startPoints.last() - startPoints.first()).normalized
|
||||
val mirrorEnd = endPoints.last() + (endPoints.last() - endPoints.first()).normalized
|
||||
|
||||
(listOf(mirrorStart) + points + listOf(mirrorEnd)).windowed(4, 1).map {
|
||||
CatmullRom2(it[0], it[1], it[2], it[3], alpha)
|
||||
}
|
||||
} else {
|
||||
val cleanPoints = if (loop && points.first().distanceTo(points.last()) <= 1.0E-6) {
|
||||
points.dropLast(1)
|
||||
} else {
|
||||
points
|
||||
}
|
||||
(cleanPoints + cleanPoints.take(3)).windowed(4, 1).map {
|
||||
CatmullRom2(it[0], it[1], it[2], it[3], alpha)
|
||||
}
|
||||
}
|
||||
|
||||
fun positions(steps: Int = segments.size * 4): List<Vector2D> {
|
||||
return (0..steps).map {
|
||||
position(it.toDouble() / steps)
|
||||
}
|
||||
}
|
||||
|
||||
fun position(rt: Double): Vector2D {
|
||||
val st = if (loop) rt.mod(1.0) else rt.coerceIn(0.0, 1.0)
|
||||
val segmentIndex = (min(almostOne, st) * segments.size).toInt()
|
||||
val t = (min(almostOne, st) * segments.size) - segmentIndex
|
||||
return segments[segmentIndex].position(t)
|
||||
}
|
||||
}
|
||||
|
||||
fun List<Vector2D>.catmullRom(alpha: Double = 0.5, closed: Boolean) = CatmullRomChain2(this, alpha, closed)
|
||||
|
||||
/** Converts spline to a [Segment]. */
|
||||
fun CatmullRom2.toSegment(): Segment2D {
|
||||
val d1a2 = (p1 - p0).length.pow(2 * alpha)
|
||||
val d2a2 = (p2 - p1).length.pow(2 * alpha)
|
||||
val d3a2 = (p3 - p2).length.pow(2 * alpha)
|
||||
val d1a = (p1 - p0).length.pow(alpha)
|
||||
val d2a = (p2 - p1).length.pow(alpha)
|
||||
val d3a = (p3 - p2).length.pow(alpha)
|
||||
|
||||
val b0 = p1
|
||||
val b1 = (p2 * d1a2 - p0 * d2a2 + p1 * (2 * d1a2 + 3 * d1a * d2a + d2a2)) / (3 * d1a * (d1a + d2a))
|
||||
val b2 = (p1 * d3a2 - p3 * d2a2 + p2 * (2 * d3a2 + 3 * d3a * d2a + d2a2)) / (3 * d3a * (d3a + d2a))
|
||||
val b3 = p2
|
||||
|
||||
return Segment2D(b0, b1, b2, b3)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Converts chain to a [ShapeContour].
|
||||
*/
|
||||
@Suppress("unused")
|
||||
fun CatmullRomChain2.toContour(): ShapeContour =
|
||||
ShapeContour(segments.map { it.toSegment() }, this.loop)
|
||||
427
android/src/main/java/com/icegps/orx/color/ColorRGBa.kt
Normal file
@@ -0,0 +1,427 @@
|
||||
package com.icegps.orx.color
|
||||
|
||||
import com.icegps.math.geometry.Vector3D
|
||||
import com.icegps.math.geometry.Vector4D
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.math.pow
|
||||
|
||||
@Serializable
|
||||
enum class Linearity(val certainty: Int) {
|
||||
/**
|
||||
* Represents a linear color space.
|
||||
*
|
||||
* LINEAR typically signifies that the values in the color space are in a linear relationship,
|
||||
* meaning there is no gamma correction or transformation applied to the data.
|
||||
*/
|
||||
LINEAR(1),
|
||||
|
||||
/**
|
||||
* Represents a standard RGB (sRGB) color space.
|
||||
*
|
||||
* SRGB typically refers to a non-linear color space with gamma correction applied,
|
||||
* designed for consistent color representation across devices.
|
||||
*/
|
||||
SRGB(1),
|
||||
;
|
||||
|
||||
fun leastCertain(other: Linearity): Linearity {
|
||||
return if (this.certainty <= other.certainty) {
|
||||
this
|
||||
} else {
|
||||
other
|
||||
}
|
||||
}
|
||||
|
||||
fun isEquivalent(other: Linearity): Boolean {
|
||||
return this == other
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a color in the RGBA color space. Each component, including red, green, blue, and alpha (opacity),
|
||||
* is represented as a `Double` in the range `[0.0, 1.0]`. The color can be defined in either linear or sRGB space,
|
||||
* determined by the `linearity` property.
|
||||
*
|
||||
* This class provides a wide variety of utility functions for manipulating and converting colors, such as shading,
|
||||
* opacity adjustment, and format transformations. It also includes methods for parsing colors from hexadecimal
|
||||
* notation or vectors.
|
||||
*
|
||||
* @property r Red component of the color as a value between `0.0` and `1.0`.
|
||||
* @property g Green component of the color as a value between `0.0` and `1.0`.
|
||||
* @property b Blue component of the color as a value between `0.0` and `1.0`.
|
||||
* @property alpha Alpha (opacity) component of the color as a value between `0.0` and `1.0`. Defaults to `1.0`.
|
||||
* @property linearity Indicates whether the color is defined in linear or sRGB space. Defaults to [Linearity.LINEAR].
|
||||
*/
|
||||
@Serializable
|
||||
@Suppress("EqualsOrHashCode") // generated equals() is ok, only hashCode() needs to be overridden
|
||||
data class ColorRGBa(
|
||||
val r: Double,
|
||||
val g: Double,
|
||||
val b: Double,
|
||||
val alpha: Double = 1.0,
|
||||
val linearity: Linearity = Linearity.LINEAR
|
||||
) {
|
||||
|
||||
enum class Component {
|
||||
R,
|
||||
G,
|
||||
B
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Calculates a color from hexadecimal value. For values with transparency
|
||||
* use the [String] variant of this function.
|
||||
*/
|
||||
fun fromHex(hex: Int): ColorRGBa {
|
||||
val r = hex and (0xff0000) shr 16
|
||||
val g = hex and (0x00ff00) shr 8
|
||||
val b = hex and (0x0000ff)
|
||||
return ColorRGBa(r / 255.0, g / 255.0, b / 255.0, 1.0, Linearity.SRGB)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a color from hexadecimal notation, like in CSS.
|
||||
*
|
||||
* Supports the following formats
|
||||
* * `RGB`
|
||||
* * `RGBA`
|
||||
* * `RRGGBB`
|
||||
* * `RRGGBBAA`
|
||||
*
|
||||
* where every character is a valid hex digit between `0..f` (case-insensitive).
|
||||
* Supports leading "#" or "0x".
|
||||
*/
|
||||
fun fromHex(hex: String): ColorRGBa {
|
||||
val pos = when {
|
||||
hex.startsWith("#") -> 1
|
||||
hex.startsWith("0x") -> 2
|
||||
else -> 0
|
||||
}
|
||||
|
||||
fun fromHex1(str: String, pos: Int): Double {
|
||||
return 17 * str[pos].digitToInt(16) / 255.0
|
||||
}
|
||||
|
||||
fun fromHex2(str: String, pos: Int): Double {
|
||||
return (16 * str[pos].digitToInt(16) + str[pos + 1].digitToInt(16)) / 255.0
|
||||
}
|
||||
return when (hex.length - pos) {
|
||||
3 -> ColorRGBa(fromHex1(hex, pos), fromHex1(hex, pos + 1), fromHex1(hex, pos + 2), 1.0, Linearity.SRGB)
|
||||
4 -> ColorRGBa(
|
||||
fromHex1(hex, pos),
|
||||
fromHex1(hex, pos + 1),
|
||||
fromHex1(hex, pos + 2),
|
||||
fromHex1(hex, pos + 3),
|
||||
Linearity.SRGB
|
||||
)
|
||||
|
||||
6 -> ColorRGBa(fromHex2(hex, pos), fromHex2(hex, pos + 2), fromHex2(hex, pos + 4), 1.0, Linearity.SRGB)
|
||||
8 -> ColorRGBa(
|
||||
fromHex2(hex, pos),
|
||||
fromHex2(hex, pos + 2),
|
||||
fromHex2(hex, pos + 4),
|
||||
fromHex2(hex, pos + 6),
|
||||
Linearity.SRGB
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException("Invalid hex length/format for '$hex'")
|
||||
}
|
||||
}
|
||||
|
||||
/** @suppress */
|
||||
val PINK = fromHex(0xffc0cb)
|
||||
|
||||
/** @suppress */
|
||||
val BLACK = ColorRGBa(0.0, 0.0, 0.0, 1.0, Linearity.SRGB)
|
||||
|
||||
/** @suppress */
|
||||
val WHITE = ColorRGBa(1.0, 1.0, 1.0, 1.0, Linearity.SRGB)
|
||||
|
||||
/** @suppress */
|
||||
val RED = ColorRGBa(1.0, 0.0, 0.0, 1.0, Linearity.SRGB)
|
||||
|
||||
/** @suppress */
|
||||
val BLUE = ColorRGBa(0.0, 0.0, 1.0, 1.0, Linearity.SRGB)
|
||||
|
||||
/** @suppress */
|
||||
val GREEN = ColorRGBa(0.0, 1.0, 0.0, 1.0, Linearity.SRGB)
|
||||
|
||||
/** @suppress */
|
||||
val YELLOW = ColorRGBa(1.0, 1.0, 0.0, 1.0, Linearity.SRGB)
|
||||
|
||||
/** @suppress */
|
||||
val CYAN = ColorRGBa(0.0, 1.0, 1.0, 1.0, Linearity.SRGB)
|
||||
|
||||
/** @suppress */
|
||||
val MAGENTA = ColorRGBa(1.0, 0.0, 1.0, 1.0, Linearity.SRGB)
|
||||
|
||||
/** @suppress */
|
||||
val GRAY = ColorRGBa(0.5, 0.5, 0.5, 1.0, Linearity.SRGB)
|
||||
|
||||
/** @suppress */
|
||||
val TRANSPARENT = ColorRGBa(0.0, 0.0, 0.0, 0.0, Linearity.LINEAR)
|
||||
|
||||
/**
|
||||
* Create a ColorRGBa object from a [Vector3]
|
||||
* @param vector input vector, `[x, y, z]` is mapped to `[r, g, b]`
|
||||
* @param alpha optional alpha value, default is 1.0
|
||||
*/
|
||||
fun fromVector(vector: Vector3D, alpha: Double = 1.0, linearity: Linearity = Linearity.LINEAR): ColorRGBa {
|
||||
return ColorRGBa(vector.x, vector.y, vector.z, alpha, linearity)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a ColorRGBa object from a [Vector4]
|
||||
* @param vector input vector, `[x, y, z, w]` is mapped to `[r, g, b, a]`
|
||||
*/
|
||||
fun fromVector(vector: Vector4D, linearity: Linearity = Linearity.LINEAR): ColorRGBa {
|
||||
return ColorRGBa(vector.x, vector.y, vector.z, vector.w, linearity)
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Legacy alpha parameter name", ReplaceWith("alpha"))
|
||||
val a = alpha
|
||||
|
||||
/**
|
||||
* Creates a copy of color with adjusted opacity
|
||||
* @param factor a scaling factor used for the opacity
|
||||
* @return A [ColorRGBa] with scaled opacity
|
||||
* @see shade
|
||||
*/
|
||||
fun opacify(factor: Double): ColorRGBa = ColorRGBa(r, g, b, alpha * factor, linearity)
|
||||
|
||||
/**
|
||||
* Creates a copy of color with adjusted color
|
||||
* @param factor a scaling factor used for the opacity
|
||||
* @return A [ColorRGBa] with scaled colors
|
||||
* @see opacify
|
||||
*/
|
||||
fun shade(factor: Double): ColorRGBa = ColorRGBa(r * factor, g * factor, b * factor, alpha, linearity)
|
||||
|
||||
/**
|
||||
* Copy of the color with all of its fields clamped to `[0, 1]`
|
||||
*/
|
||||
|
||||
@Deprecated("Use clip() instead", replaceWith = ReplaceWith("clip()"))
|
||||
val saturated: ColorRGBa
|
||||
get() = clip()
|
||||
|
||||
/**
|
||||
* Copy of the color with all of its fields clamped to `[0, 1]`
|
||||
*/
|
||||
fun clip(): ColorRGBa = copy(
|
||||
r = r.coerceIn(0.0..1.0),
|
||||
g = g.coerceIn(0.0..1.0),
|
||||
b = b.coerceIn(0.0..1.0),
|
||||
alpha = alpha.coerceIn(0.0..1.0)
|
||||
)
|
||||
|
||||
|
||||
/**
|
||||
* Returns a new instance of [ColorRGBa] where the red, green, and blue components
|
||||
* are multiplied by the alpha value of the original color. The alpha value and linearity
|
||||
* remain unchanged.
|
||||
*
|
||||
* This computed property is commonly used for adjusting the color intensity based
|
||||
* on its transparency.
|
||||
*/
|
||||
val alphaMultiplied: ColorRGBa
|
||||
get() = ColorRGBa(r * alpha, g * alpha, b * alpha, alpha, linearity)
|
||||
|
||||
/**
|
||||
* The minimum value over `r`, `g`, `b`
|
||||
* @see maxValue
|
||||
*/
|
||||
val minValue get() = r.coerceAtMost(g).coerceAtMost(b)
|
||||
|
||||
/**
|
||||
* The maximum value over `r`, `g`, `b`
|
||||
* @see minValue
|
||||
*/
|
||||
val maxValue get() = r.coerceAtLeast(g).coerceAtLeast(b)
|
||||
|
||||
/**
|
||||
* calculate luminance value
|
||||
* luminance value is according to <a>https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef</a>
|
||||
*/
|
||||
val luminance: Double
|
||||
get() = when (linearity) {
|
||||
Linearity.SRGB -> toLinear().luminance
|
||||
else -> 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts this color to the specified linearity.
|
||||
*
|
||||
* @param linearity The target linearity to which the color should be converted.
|
||||
* Supported values are [Linearity.SRGB] and [Linearity.LINEAR].
|
||||
* @return A [ColorRGBa] instance in the specified linearity.
|
||||
*/
|
||||
fun toLinearity(linearity: Linearity): ColorRGBa {
|
||||
return when (linearity) {
|
||||
Linearity.SRGB -> toSRGB()
|
||||
Linearity.LINEAR -> toLinear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* calculate the contrast value between this color and the given color
|
||||
* contrast value is accordingo to <a>// see http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef</a>
|
||||
*/
|
||||
fun getContrastRatio(other: ColorRGBa): Double {
|
||||
val l1 = luminance
|
||||
val l2 = other.luminance
|
||||
return if (l1 > l2) (l1 + 0.05) / (l2 + 0.05) else (l2 + 0.05) / (l1 + 0.05)
|
||||
}
|
||||
|
||||
fun toLinear(): ColorRGBa {
|
||||
fun t(x: Double): Double {
|
||||
return if (x <= 0.04045) x / 12.92 else ((x + 0.055) / (1 + 0.055)).pow(2.4)
|
||||
}
|
||||
return when (linearity) {
|
||||
Linearity.SRGB -> ColorRGBa(t(r), t(g), t(b), alpha, Linearity.LINEAR)
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to SRGB
|
||||
* @see toLinear
|
||||
*/
|
||||
fun toSRGB(): ColorRGBa {
|
||||
fun t(x: Double): Double {
|
||||
return if (x <= 0.0031308) 12.92 * x else (1 + 0.055) * x.pow(1.0 / 2.4) - 0.055
|
||||
}
|
||||
return when (linearity) {
|
||||
Linearity.LINEAR -> ColorRGBa(t(r), t(g), t(b), alpha, Linearity.SRGB)
|
||||
else -> this
|
||||
}
|
||||
}
|
||||
|
||||
fun toRGBa(): ColorRGBa = this
|
||||
|
||||
// This is here because the default hashing of enums on the JVM is not stable.
|
||||
override fun hashCode(): Int {
|
||||
var result = r.hashCode()
|
||||
result = 31 * result + g.hashCode()
|
||||
result = 31 * result + b.hashCode()
|
||||
result = 31 * result + alpha.hashCode()
|
||||
// here we overcome the unstable hash by using the ordinal value
|
||||
result = 31 * result + linearity.ordinal.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
fun plus(right: ColorRGBa) = copy(
|
||||
r = r + right.r,
|
||||
g = g + right.g,
|
||||
b = b + right.b,
|
||||
alpha = alpha + right.alpha
|
||||
)
|
||||
|
||||
fun minus(right: ColorRGBa) = copy(
|
||||
r = r - right.r,
|
||||
g = g - right.g,
|
||||
b = b - right.b,
|
||||
alpha = alpha - right.alpha
|
||||
)
|
||||
|
||||
fun times(scale: Double) = copy(r = r * scale, g = g * scale, b = b * scale, alpha = alpha * scale)
|
||||
|
||||
fun mix(other: ColorRGBa, factor: Double): ColorRGBa {
|
||||
return mix(this, other, factor)
|
||||
}
|
||||
|
||||
fun toVector4(): Vector4D = Vector4D(r, g, b, alpha)
|
||||
|
||||
/**
|
||||
* Retrieves the color's RGBA component value based on the specified index:
|
||||
* [index] should be 0 for red, 1 for green, 2 for blue, 3 for alpha.
|
||||
* Other index values throw an [IndexOutOfBoundsException].
|
||||
*/
|
||||
operator fun get(index: Int) = when (index) {
|
||||
0 -> r
|
||||
1 -> g
|
||||
2 -> b
|
||||
3 -> alpha
|
||||
else -> throw IllegalArgumentException("unsupported index")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted mix between two colors in the generic RGB color space.
|
||||
* @param x the weighting of colors, a value 0.0 is equivalent to [left],
|
||||
* 1.0 is equivalent to [right] and at 0.5 both colors contribute to the result equally
|
||||
* @return a mix of [left] and [right] weighted by [x]
|
||||
*/
|
||||
fun mix(left: ColorRGBa, right: ColorRGBa, x: Double): ColorRGBa {
|
||||
val sx = x.coerceIn(0.0, 1.0)
|
||||
|
||||
if (left.linearity.isEquivalent(right.linearity)) {
|
||||
return ColorRGBa(
|
||||
(1.0 - sx) * left.r + sx * right.r,
|
||||
(1.0 - sx) * left.g + sx * right.g,
|
||||
(1.0 - sx) * left.b + sx * right.b,
|
||||
(1.0 - sx) * left.alpha + sx * right.alpha,
|
||||
linearity = left.linearity.leastCertain(right.linearity)
|
||||
)
|
||||
} else {
|
||||
return when (right.linearity) {
|
||||
Linearity.LINEAR -> {
|
||||
mix(left.toLinear(), right.toLinear(), x)
|
||||
}
|
||||
|
||||
Linearity.SRGB -> {
|
||||
mix(left.toSRGB(), right.toSRGB(), x)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand for calling [ColorRGBa].
|
||||
* Specify only one value to obtain a shade of gray.
|
||||
* @param r red in `[0,1]`
|
||||
* @param g green in `[0,1]`
|
||||
* @param b blue in `[0,1]`
|
||||
* @param a alpha in `[0,1]`, defaults to `1.0`
|
||||
*/
|
||||
fun rgb(r: Double, g: Double, b: Double, a: Double = 1.0) = ColorRGBa(r, g, b, a, linearity = Linearity.LINEAR)
|
||||
|
||||
/**
|
||||
* Shorthand for calling [ColorRGBa].
|
||||
* @param gray shade of gray in `[0,1]`
|
||||
* @param a alpha in `[0,1]`, defaults to `1.0`
|
||||
*/
|
||||
fun rgb(gray: Double, a: Double = 1.0) = ColorRGBa(gray, gray, gray, a, linearity = Linearity.LINEAR)
|
||||
|
||||
/**
|
||||
* Create a color in RGBa space
|
||||
* This function is a shorthand for using the ColorRGBa constructor
|
||||
* @param r red in `[0,1]`
|
||||
* @param g green in `[0,1]`
|
||||
* @param b blue in `[0,1]`
|
||||
* @param a alpha in `[0,1]`
|
||||
*/
|
||||
@Deprecated("Use rgb(r, g, b, a)", ReplaceWith("rgb(r, g, b, a)"), DeprecationLevel.WARNING)
|
||||
fun rgba(r: Double, g: Double, b: Double, a: Double) = ColorRGBa(r, g, b, a, linearity = Linearity.LINEAR)
|
||||
|
||||
/**
|
||||
* Shorthand for calling [ColorRGBa.fromHex].
|
||||
* Creates a [ColorRGBa] with [Linearity.SRGB] from a hex string.
|
||||
* @param hex string encoded hex value, for example `"ffc0cd"`
|
||||
*/
|
||||
fun rgb(hex: String) = ColorRGBa.fromHex(hex)
|
||||
|
||||
/**
|
||||
* Converts RGB integer color values into a ColorRGBa object with sRGB linearity.
|
||||
*
|
||||
* @param red The red component of the color, in the range 0-255.
|
||||
* @param green The green component of the color, in the range 0-255.
|
||||
* @param blue The blue component of the color, in the range 0-255.
|
||||
* @param alpha The alpha (transparency) component of the color, in the range 0-255. Default value is 255 (fully opaque).
|
||||
*/
|
||||
fun rgb(red: Int, green: Int, blue: Int, alpha: Int = 255) =
|
||||
ColorRGBa(red / 255.0, green / 255.0, blue / 255.0, alpha / 255.0, Linearity.SRGB)
|
||||
2777
android/src/main/java/com/icegps/orx/colorbrewer2/ColorBrewer2.kt
Normal file
23
android/src/main/java/com/icegps/orx/ktx/ColorRGBa.kt
Normal file
@@ -0,0 +1,23 @@
|
||||
package com.icegps.orx.ktx
|
||||
|
||||
import com.icegps.orx.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())
|
||||
}
|
||||
12
android/src/main/java/com/icegps/orx/ktx/Context.kt
Normal 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()
|
||||
}
|
||||
24
android/src/main/java/com/icegps/orx/ktx/Vector2D.kt
Normal file
@@ -0,0 +1,24 @@
|
||||
package com.icegps.orx.ktx
|
||||
|
||||
import com.icegps.common.helper.GeoHelper
|
||||
import com.icegps.math.geometry.Vector2D
|
||||
import com.mapbox.geojson.Point
|
||||
|
||||
/**
|
||||
* @author tabidachinokaze
|
||||
* @date 2025/11/26
|
||||
*/
|
||||
fun Vector2D.toMapboxPoint(): Point {
|
||||
val geoHelper = GeoHelper.getSharedInstance()
|
||||
val wgs84 = geoHelper.enuToWGS84Object(GeoHelper.ENU(x = x, y = y))
|
||||
return Point.fromLngLat(wgs84.lon, wgs84.lat)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolates between the current vector and the given vector `o` by the specified mixing factor.
|
||||
*
|
||||
* @param o The target vector to interpolate towards.
|
||||
* @param mix A mixing factor between 0 and 1 where `0` results in the current vector and `1` results in the vector `o`.
|
||||
* @return A new vector that is the result of the interpolation.
|
||||
*/
|
||||
fun Vector2D.mix(o: Vector2D, mix: Double): Vector2D = this * (1 - mix) + o * mix
|
||||
32
android/src/main/java/com/icegps/orx/ktx/Vector3D.kt
Normal 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)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package com.icegps.orx.marchingsquares
|
||||
|
||||
import com.icegps.math.geometry.Rectangle
|
||||
import com.icegps.math.geometry.Vector2D
|
||||
import com.icegps.math.geometry.Vector2I
|
||||
import com.icegps.orx.ktx.mix
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
private const val closeEpsilon = 1E-6
|
||||
|
||||
data class Segment2D(
|
||||
val start: Vector2D,
|
||||
val control: List<Vector2D>,
|
||||
val end: Vector2D,
|
||||
val corner: Boolean = false
|
||||
)
|
||||
|
||||
fun Segment2D(start: Vector2D, end: Vector2D, corner: Boolean = true) =
|
||||
Segment2D(start, emptyList(), end, corner)
|
||||
|
||||
fun Segment2D(start: Vector2D, c0: Vector2D, c1: Vector2D, end: Vector2D, corner: Boolean = true) =
|
||||
Segment2D(start, listOf(c0, c1), end, corner)
|
||||
|
||||
data class ShapeContour(
|
||||
val segments: List<Segment2D>,
|
||||
val closed: Boolean,
|
||||
) {
|
||||
companion object {
|
||||
val EMPTY = ShapeContour(
|
||||
segments = emptyList(),
|
||||
closed = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a ShapeContour from a list of points, specifying whether the contour is closed and its y-axis polarity.
|
||||
*
|
||||
* @param points A list of points (Vector2) defining the vertices of the contour.
|
||||
* @param closed Boolean indicating whether the contour should be closed (forms a loop).
|
||||
* @return A ShapeContour object representing the resulting contour.
|
||||
*/
|
||||
fun fromPoints(
|
||||
points: List<Vector2D>,
|
||||
closed: Boolean,
|
||||
): ShapeContour = if (points.isEmpty()) {
|
||||
EMPTY
|
||||
} else {
|
||||
if (!closed) {
|
||||
ShapeContour((0 until points.size - 1).map {
|
||||
Segment2D(
|
||||
points[it],
|
||||
points[it + 1]
|
||||
)
|
||||
}, false)
|
||||
} else {
|
||||
val d = (points.last() - points.first()).lengthSquared
|
||||
val usePoints = if (d > closeEpsilon) points else points.dropLast(1)
|
||||
ShapeContour((usePoints.indices).map {
|
||||
Segment2D(
|
||||
usePoints[it],
|
||||
usePoints[(it + 1) % usePoints.size]
|
||||
)
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class LineSegment(val start: Vector2D, val end: Vector2D)
|
||||
|
||||
/**
|
||||
* Find contours for a function [f] using the marching squares algorithm. A contour is found when f(x) crosses zero.
|
||||
* @param f the function
|
||||
* @param area a rectangular area in which the function should be evaluated
|
||||
* @param cellSize the size of the cells, smaller size gives higher resolution
|
||||
* @param useInterpolation intersection points will be interpolated if true, default true
|
||||
* @return a list of [ShapeContour] instances
|
||||
*/
|
||||
fun findContours(
|
||||
f: (Vector2D) -> Double,
|
||||
area: Rectangle,
|
||||
cellSize: Double,
|
||||
useInterpolation: Boolean = true
|
||||
): List<ShapeContour> {
|
||||
val segments = mutableListOf<LineSegment>()
|
||||
val values = mutableMapOf<Vector2I, Double>()
|
||||
val segmentsMap = mutableMapOf<Vector2D, MutableList<LineSegment>>()
|
||||
|
||||
for (y in 0 until (area.height / cellSize).toInt()) {
|
||||
for (x in 0 until (area.width / cellSize).toInt()) {
|
||||
values[Vector2I(x, y)] = f(Vector2D(x * cellSize + area.x, y * cellSize + area.y))
|
||||
}
|
||||
}
|
||||
|
||||
val zero = 0.0
|
||||
for (y in 0 until (area.height / cellSize).toInt()) {
|
||||
for (x in 0 until (area.width / cellSize).toInt()) {
|
||||
|
||||
// Here we check if we are at a right or top border. This is to ensure we create closed contours
|
||||
// later on in the process.
|
||||
val v00 = if (x == 0 || y == 0) zero else (values[Vector2I(x, y)] ?: zero)
|
||||
val v10 = if (y == 0) zero else (values[Vector2I(x + 1, y)] ?: zero)
|
||||
val v01 = if (x == 0) zero else (values[Vector2I(x, y + 1)] ?: zero)
|
||||
val v11 = (values[Vector2I(x + 1, y + 1)] ?: zero)
|
||||
|
||||
val p00 = Vector2D(x.toDouble(), y.toDouble()) * cellSize + area.topLeft
|
||||
val p10 = Vector2D((x + 1).toDouble(), y.toDouble()) * cellSize + area.topLeft
|
||||
val p01 = Vector2D(x.toDouble(), (y + 1).toDouble()) * cellSize + area.topLeft
|
||||
val p11 = Vector2D((x + 1).toDouble(), (y + 1).toDouble()) * cellSize + area.topLeft
|
||||
|
||||
val index = (if (v00 >= 0.0) 1 else 0) +
|
||||
(if (v10 >= 0.0) 2 else 0) +
|
||||
(if (v01 >= 0.0) 4 else 0) +
|
||||
(if (v11 >= 0.0) 8 else 0)
|
||||
|
||||
fun blend(v1: Double, v2: Double): Double {
|
||||
if (useInterpolation) {
|
||||
require(!v1.isNaN() && !v2.isNaN()) {
|
||||
"Input values v1=$v1 or v2=$v2 are NaN, which is not allowed."
|
||||
}
|
||||
val f1 = min(v1, v2)
|
||||
val f2 = max(v1, v2)
|
||||
val v = (-f1) / (f2 - f1)
|
||||
|
||||
require(v == v && v in 0.0..1.0) {
|
||||
"Invalid value calculated during interpolation: v=$v"
|
||||
}
|
||||
|
||||
return if (f1 == v1) {
|
||||
v
|
||||
} else {
|
||||
1.0 - v
|
||||
}
|
||||
} else {
|
||||
return 0.5
|
||||
}
|
||||
}
|
||||
|
||||
fun emitLine(
|
||||
p00: Vector2D, p01: Vector2D, v00: Double, v01: Double,
|
||||
p10: Vector2D, p11: Vector2D, v10: Double, v11: Double
|
||||
) {
|
||||
val r0 = blend(v00, v01)
|
||||
val r1 = blend(v10, v11)
|
||||
|
||||
val v0 = p00.mix(p01, r0)
|
||||
val v1 = p10.mix(p11, r1)
|
||||
val l0 = LineSegment(v0, v1)
|
||||
segmentsMap.getOrPut(v1) { mutableListOf() }.add(l0)
|
||||
segmentsMap.getOrPut(v0) { mutableListOf() }.add(l0)
|
||||
segments.add(l0)
|
||||
}
|
||||
|
||||
when (index) {
|
||||
0, 15 -> {}
|
||||
1, 15 xor 1 -> {
|
||||
emitLine(p00, p01, v00, v01, p00, p10, v00, v10)
|
||||
}
|
||||
|
||||
2, 15 xor 2 -> {
|
||||
emitLine(p00, p10, v00, v10, p10, p11, v10, v11)
|
||||
}
|
||||
|
||||
3, 15 xor 3 -> {
|
||||
emitLine(p00, p01, v00, v01, p10, p11, v10, v11)
|
||||
}
|
||||
|
||||
4, 15 xor 4 -> {
|
||||
emitLine(p00, p01, v00, v01, p01, p11, v01, v11)
|
||||
}
|
||||
|
||||
5, 15 xor 5 -> {
|
||||
emitLine(p00, p10, v00, v10, p01, p11, v01, v11)
|
||||
}
|
||||
|
||||
6, 15 xor 6 -> {
|
||||
emitLine(p00, p01, v00, v01, p00, p10, v00, v10)
|
||||
emitLine(p01, p11, v01, v11, p10, p11, v10, v11)
|
||||
}
|
||||
|
||||
7, 15 xor 7 -> {
|
||||
emitLine(p01, p11, v01, v11, p10, p11, v10, v11)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val processedSegments = mutableSetOf<LineSegment>()
|
||||
val contours = mutableListOf<ShapeContour>()
|
||||
for (segment in segments) {
|
||||
if (segment in processedSegments) {
|
||||
continue
|
||||
} else {
|
||||
val collected = mutableListOf<Vector2D>()
|
||||
var current: LineSegment? = segment
|
||||
var closed = true
|
||||
var lastVertex = Vector2D.INFINITY
|
||||
do {
|
||||
current!!
|
||||
if (lastVertex.squaredDistanceTo(current.start) > 1E-5) {
|
||||
collected.add(current.start)
|
||||
}
|
||||
lastVertex = current.start
|
||||
processedSegments.add(current)
|
||||
if (segmentsMap[current.start]!!.size < 2) {
|
||||
closed = false
|
||||
}
|
||||
val hold = current
|
||||
current = segmentsMap[current.start]?.firstOrNull { it !in processedSegments }
|
||||
if (current == null) {
|
||||
current = segmentsMap[hold.end]?.firstOrNull { it !in processedSegments }
|
||||
}
|
||||
} while (current != segment && current != null)
|
||||
|
||||
contours.add(ShapeContour.fromPoints(collected, closed = closed))
|
||||
}
|
||||
}
|
||||
return contours
|
||||
}
|
||||
170
android/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
30
android/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
183
android/src/main/res/layout-port/activity_main.xml
Normal file
@@ -0,0 +1,183 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/main"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<com.mapbox.maps.MapView
|
||||
android:id="@+id/map_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="3" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<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
|
||||
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/slope_direction"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:valueFrom="0"
|
||||
android:valueTo="360" />
|
||||
</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.Slider
|
||||
android:id="@+id/slope_percentage"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:valueFrom="0"
|
||||
android:valueTo="100" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="设计面高度(m):" />
|
||||
|
||||
<com.google.android.material.slider.Slider
|
||||
android:id="@+id/design_height"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:value="0"
|
||||
android:valueFrom="-100"
|
||||
android:valueTo="100" />
|
||||
</LinearLayout>
|
||||
|
||||
<Switch
|
||||
android:id="@+id/switch_design_surface"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:switchPadding="16dp"
|
||||
android:text="显示设计面" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
179
android/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,179 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/main"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="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
|
||||
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/slope_direction"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:valueFrom="0"
|
||||
android:valueTo="360" />
|
||||
</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.Slider
|
||||
android:id="@+id/slope_percentage"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:valueFrom="0"
|
||||
android:valueTo="100" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="设计面高度(m):" />
|
||||
|
||||
<com.google.android.material.slider.Slider
|
||||
android:id="@+id/design_height"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:value="0"
|
||||
android:valueFrom="-100"
|
||||
android:valueTo="100" />
|
||||
</LinearLayout>
|
||||
|
||||
<Switch
|
||||
android:id="@+id/switch_design_surface"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:switchPadding="16dp"
|
||||
android:text="显示设计面" />
|
||||
</LinearLayout>
|
||||
|
||||
<com.mapbox.maps.MapView
|
||||
android:id="@+id/map_view"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="3" />
|
||||
</LinearLayout>
|
||||
6
android/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
6
android/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
android/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
android/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
android/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
android/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
android/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
android/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
android/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
android/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
android/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
7
android/src/main/res/values-night/themes.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Base.Theme.Orx" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your dark theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_dark_primary</item> -->
|
||||
</style>
|
||||
</resources>
|
||||
5
android/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
4
android/src/main/res/values/mapbox_access_token.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="mapbox_access_token" translatable="false" tools:ignore="UnusedResources">pk.eyJ1IjoienpxMSIsImEiOiJjbWYzbzV1MzQwMHJvMmpvbG1wbjJwdjUyIn0.LvKjIrCv9dAFcGxOM52f2Q</string>
|
||||
</resources>
|
||||
3
android/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">icegps-orx</string>
|
||||
</resources>
|
||||
9
android/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Base.Theme.Orx" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<!-- Customize your light theme here. -->
|
||||
<!-- <item name="colorPrimary">@color/my_light_primary</item> -->
|
||||
</style>
|
||||
|
||||
<style name="Theme.Orx" parent="Base.Theme.Orx" />
|
||||
</resources>
|
||||
17
android/src/test/java/com/icegps/orx/ExampleUnitTest.kt
Normal file
@@ -0,0 +1,17 @@
|
||||
package com.icegps.orx
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ abstract class CollectScreenshotsTask @Inject constructor() : DefaultTask() {
|
||||
val codeLines = ktFile.readLines()
|
||||
val main = codeLines.indexOfFirst { it.startsWith("fun main") }
|
||||
val head = codeLines.take(main)
|
||||
val start = head.indexOfLast { it.startsWith("/**") }
|
||||
val start = head.indexOfLast { it.startsWith("/*") }
|
||||
val end = head.indexOfLast { it.endsWith("*/") }
|
||||
|
||||
if ((start < end) && (end < main)) {
|
||||
|
||||
@@ -2,6 +2,10 @@ plugins {
|
||||
alias(libs.plugins.nebula.release)
|
||||
alias(libs.plugins.nmcp)
|
||||
id("org.openrndr.extra.convention.dokka")
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.jvm) apply false
|
||||
alias(libs.plugins.android.library) apply false
|
||||
}
|
||||
|
||||
repositories {
|
||||
|
||||
37
desktop/build.gradle.kts
Normal file
@@ -0,0 +1,37 @@
|
||||
plugins {
|
||||
id("org.openrndr.extra.convention.kotlin-multiplatform")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
val commonMain by getting {
|
||||
dependencies {
|
||||
api(openrndr.math)
|
||||
api(openrndr.shape)
|
||||
implementation(project(":orx-noise"))
|
||||
}
|
||||
}
|
||||
val commonTest by getting {
|
||||
dependencies {
|
||||
implementation(project(":orx-shapes"))
|
||||
implementation(openrndr.shape)
|
||||
}
|
||||
}
|
||||
|
||||
val jvmDemo by getting {
|
||||
dependencies {
|
||||
implementation(project(":orx-triangulation"))
|
||||
implementation(project(":orx-shapes"))
|
||||
implementation(project(":orx-noise"))
|
||||
implementation(openrndr.shape)
|
||||
implementation(project(":math"))
|
||||
implementation(project(":orx-camera"))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||
implementation(project(":orx-marching-squares"))
|
||||
implementation(project(":orx-text-writer"))
|
||||
implementation(project(":orx-obj-loader"))
|
||||
implementation(project(":orx-palette"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
desktop/src/jvmDemo/kotlin/DemoColorBrewer2.kt
Normal file
@@ -0,0 +1,35 @@
|
||||
import org.openrndr.application
|
||||
import org.openrndr.shape.Rectangle
|
||||
|
||||
/**
|
||||
* Demonstrates how to use a ColorBrewer2 palette.
|
||||
* Finds the first available palette with 5 colors,
|
||||
* then draws concentric circles filled with those colors.
|
||||
*/
|
||||
fun main() = application {
|
||||
configure {
|
||||
width = 720
|
||||
height = 720
|
||||
}
|
||||
program {
|
||||
val palette = colorBrewer2Palettes(
|
||||
numberOfColors = 6,
|
||||
paletteType = ColorBrewer2Type.Any
|
||||
).first().colors
|
||||
val cellSize = 50.0
|
||||
extend {
|
||||
palette.forEachIndexed { i, color ->
|
||||
drawer.fill = color
|
||||
drawer.rectangle(
|
||||
Rectangle(
|
||||
x = 0.0,
|
||||
y = cellSize * i,
|
||||
width = cellSize,
|
||||
height = cellSize
|
||||
)
|
||||
)
|
||||
// drawer.circle(drawer.bounds.center, 300.0 - i * 40.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
654
desktop/src/jvmDemo/kotlin/DemoDelaunay03.kt
Normal file
@@ -0,0 +1,654 @@
|
||||
import com.icegps.math.geometry.Angle
|
||||
import com.icegps.math.geometry.Vector3D
|
||||
import com.icegps.math.geometry.degrees
|
||||
import org.openrndr.KEY_ARROW_DOWN
|
||||
import org.openrndr.KEY_ARROW_UP
|
||||
import org.openrndr.application
|
||||
import org.openrndr.color.ColorRGBa
|
||||
import org.openrndr.draw.TextSettingMode
|
||||
import org.openrndr.draw.loadFont
|
||||
import org.openrndr.extra.camera.Camera2D
|
||||
import org.openrndr.extra.marchingsquares.findContours
|
||||
import org.openrndr.extra.noise.gradientPerturbFractal
|
||||
import org.openrndr.extra.textwriter.writer
|
||||
import org.openrndr.extra.triangulation.DelaunayTriangulation
|
||||
import org.openrndr.math.Vector2
|
||||
import org.openrndr.math.Vector3
|
||||
import org.openrndr.shape.Segment2D
|
||||
import org.openrndr.shape.Segment3D
|
||||
import org.openrndr.shape.ShapeContour
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* @author tabidachinokaze
|
||||
* @date 2025/11/22
|
||||
*/
|
||||
fun main() = application {
|
||||
configure {
|
||||
width = 720
|
||||
height = 480
|
||||
title = "Delaunator"
|
||||
}
|
||||
program {
|
||||
val points3D = (0 until height).step(36).map { y ->
|
||||
(0 until width).step(36).map { x ->
|
||||
gradientPerturbFractal(
|
||||
300,
|
||||
frequency = 0.8,
|
||||
position = Vector3(x.toDouble(), y.toDouble(), seconds)
|
||||
)
|
||||
}
|
||||
}.flatten().map {
|
||||
it.copy(z = it.z * 100)
|
||||
}
|
||||
/*val points3D = HeightmapVolcanoGenerator.generateVolcanoClusterHeightmap(
|
||||
width = width,
|
||||
height = height,
|
||||
volcanoCount = 3
|
||||
)*/
|
||||
// val points3D = coordinateGenerate(width, height)
|
||||
val zs = points3D.map { it.z }
|
||||
println("zs = ${zs}")
|
||||
val associate: MutableMap<Vector2, Double> = points3D.associate {
|
||||
Vector2(it.x, it.y) to it.z
|
||||
}.toMutableMap()
|
||||
val delaunay = DelaunayTriangulation(associate.map { it.key })
|
||||
|
||||
//println(points3D.niceStr())
|
||||
extend(Camera2D())
|
||||
println("draw")
|
||||
var targetHeight: Double = zs.average()
|
||||
val step = zs.max() - zs.min() / 6
|
||||
var heightList = (0..5).map { index ->
|
||||
zs.min() + step * index
|
||||
}
|
||||
var logEnabled = true
|
||||
var useInterpolation = false
|
||||
var sampleLinear = false
|
||||
keyboard.keyDown.listen {
|
||||
logEnabled = true
|
||||
println(it)
|
||||
when (it.key) {
|
||||
KEY_ARROW_UP -> targetHeight++
|
||||
KEY_ARROW_DOWN -> targetHeight--
|
||||
73 -> useInterpolation = !useInterpolation
|
||||
83 -> sampleLinear = !sampleLinear
|
||||
}
|
||||
}
|
||||
extend {
|
||||
val triangles = delaunay.triangles()
|
||||
val segments = mutableListOf<Segment2D>()
|
||||
drawer.clear(ColorRGBa.BLACK)
|
||||
val indexDiff = (frameCount / 1000) % triangles.size
|
||||
for ((i, triangle) in triangles.withIndex()) {
|
||||
val segment2DS = triangle.contour.segments.filter {
|
||||
val startZ = associate[it.start]!!
|
||||
val endZ = associate[it.end]!!
|
||||
if (startZ < endZ) {
|
||||
targetHeight in startZ..endZ
|
||||
} else {
|
||||
targetHeight in endZ..startZ
|
||||
}
|
||||
}
|
||||
|
||||
if (segment2DS.size == 2) {
|
||||
val vector2s = segment2DS.map {
|
||||
val startZ = associate[it.start]!!
|
||||
val endZ = associate[it.end]!!
|
||||
val start = Vector3(it.start.x, it.start.y, startZ)
|
||||
val end = Vector3(it.end.x, it.end.y, endZ)
|
||||
if (startZ < endZ) {
|
||||
start to end
|
||||
} else {
|
||||
end to start
|
||||
}
|
||||
}.map { (start, end) ->
|
||||
val segment3D = Segment3D(start, end)
|
||||
val vector3 =
|
||||
segment3D.position(calculatePositionRatio(targetHeight, start.z, end.z))
|
||||
vector3
|
||||
}.map {
|
||||
associate[it.xy] = it.z
|
||||
it.xy
|
||||
}
|
||||
val element = Segment2D(vector2s[0], vector2s[1])
|
||||
segments.add(element)
|
||||
}
|
||||
drawer.fill = if (indexDiff == i) {
|
||||
ColorRGBa.CYAN
|
||||
} else {
|
||||
ColorRGBa.PINK.shade(1.0 - i / (triangles.size * 1.2))
|
||||
}
|
||||
drawer.stroke = ColorRGBa.PINK.shade(i / (triangles.size * 1.0) + 0.1)
|
||||
drawer.contour(triangle.contour)
|
||||
}
|
||||
|
||||
val sorted = connectAllSegments(segments)
|
||||
|
||||
drawer.stroke = ColorRGBa.WHITE
|
||||
drawer.strokeWeight = 2.0
|
||||
if (logEnabled) {
|
||||
segments.forEach {
|
||||
println("${it.start} -> ${it.end}")
|
||||
}
|
||||
println("=====")
|
||||
}
|
||||
|
||||
sorted.forEach {
|
||||
it.forEach {
|
||||
if (logEnabled) println("${it.start} -> ${it.end}")
|
||||
drawer.lineSegment(it.start, it.end)
|
||||
drawer.fill = ColorRGBa.WHITE
|
||||
}
|
||||
if (logEnabled) println("=")
|
||||
drawer.fill = ColorRGBa.YELLOW
|
||||
if (false) drawer.contour(ShapeContour.fromSegments(it, closed = true))
|
||||
}
|
||||
/*for (y in 0 until (area.height / cellSize).toInt()) {
|
||||
for (x in 0 until (area.width / cellSize).toInt()) {
|
||||
values[IntVector2(x, y)] = f(Vector2(x * cellSize + area.x, y * cellSize + area.y))
|
||||
}
|
||||
}*/
|
||||
val contours = findContours(
|
||||
f = {
|
||||
val triangle = triangles.firstOrNull { triangle ->
|
||||
isPointInTriangle(it, listOf(triangle.x1, triangle.x2, triangle.x3))
|
||||
}
|
||||
triangle ?: return@findContours 0.0
|
||||
val interpolate = interpolateHeight(
|
||||
point = it,
|
||||
triangle = listOf(
|
||||
triangle.x1,
|
||||
triangle.x2,
|
||||
triangle.x3,
|
||||
).map {
|
||||
Vector3(it.x, it.y, associate[it]!!)
|
||||
}
|
||||
)
|
||||
interpolate.z - targetHeight
|
||||
},
|
||||
area = drawer.bounds,
|
||||
cellSize = 4.0,
|
||||
useInterpolation = useInterpolation
|
||||
)
|
||||
val associateWith: List<List<ShapeContour>> = heightList.map { height ->
|
||||
findContours(
|
||||
f = {
|
||||
val triangle = triangles.firstOrNull { triangle ->
|
||||
isPointInTriangle(it, listOf(triangle.x1, triangle.x2, triangle.x3))
|
||||
}
|
||||
triangle ?: return@findContours 0.0
|
||||
val interpolate = interpolateHeight(
|
||||
point = it,
|
||||
triangle = listOf(
|
||||
triangle.x1,
|
||||
triangle.x2,
|
||||
triangle.x3,
|
||||
).map {
|
||||
Vector3(it.x, it.y, associate[it]!!)
|
||||
}
|
||||
)
|
||||
interpolate.z - height
|
||||
},
|
||||
area = drawer.bounds,
|
||||
cellSize = 4.0,
|
||||
useInterpolation = useInterpolation
|
||||
)
|
||||
}
|
||||
if (logEnabled) println("useInterpolation = $useInterpolation")
|
||||
drawer.stroke = null
|
||||
if (true) contours.forEach {
|
||||
drawer.fill = ColorRGBa.GREEN.opacify(0.1)
|
||||
drawer.contour(if (sampleLinear) it.sampleLinear() else it)
|
||||
}
|
||||
if (false) associateWith.forEachIndexed { index, contours ->
|
||||
contours.forEach {
|
||||
drawer.fill = colorBrewer2[index].colors.first().opacify(0.1)
|
||||
drawer.contour(it)
|
||||
}
|
||||
}
|
||||
|
||||
drawer.fontMap = loadFont("demo-data/fonts/IBMPlexMono-Regular.ttf", 24.0)
|
||||
writer {
|
||||
drawer.drawStyle.textSetting = TextSettingMode.SUBPIXEL
|
||||
text(targetHeight.toString())
|
||||
}
|
||||
logEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 射线法判断点是否在单个三角形内
|
||||
*/
|
||||
fun isPointInTriangle(point: Vector2, triangle: List<Vector2>): 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
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算点在三角形中的重心坐标
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
fun connectAllSegments(segments: List<Segment2D>): List<List<Segment2D>> {
|
||||
val remaining = segments.toMutableList()
|
||||
val allPaths = mutableListOf<List<Segment2D>>()
|
||||
|
||||
while (remaining.isNotEmpty()) {
|
||||
val path = mutableListOf<Segment2D>()
|
||||
|
||||
// 开始新路径
|
||||
path.add(remaining.removeAt(0))
|
||||
|
||||
var changed: Boolean
|
||||
do {
|
||||
changed = false
|
||||
|
||||
// 向前扩展
|
||||
val lastEnd = path.last().end
|
||||
val forwardSegment = remaining.find { it.start == lastEnd || it.end == lastEnd }
|
||||
if (forwardSegment != null) {
|
||||
val connectedSegment = if (forwardSegment.start == lastEnd) {
|
||||
forwardSegment // 正向
|
||||
} else {
|
||||
Segment2D(forwardSegment.end, forwardSegment.start) // 反向
|
||||
}
|
||||
path.add(connectedSegment)
|
||||
remaining.remove(forwardSegment)
|
||||
changed = true
|
||||
}
|
||||
|
||||
// 向后扩展
|
||||
val firstStart = path.first().start
|
||||
val backwardSegment = remaining.find { it.end == firstStart || it.start == firstStart }
|
||||
if (backwardSegment != null) {
|
||||
val connectedSegment = if (backwardSegment.end == firstStart) {
|
||||
backwardSegment // 正向
|
||||
} else {
|
||||
Segment2D(backwardSegment.end, backwardSegment.start) // 反向
|
||||
}
|
||||
path.add(0, connectedSegment)
|
||||
remaining.remove(backwardSegment)
|
||||
changed = true
|
||||
}
|
||||
|
||||
} while (changed && remaining.isNotEmpty())
|
||||
|
||||
allPaths.add(path)
|
||||
}
|
||||
|
||||
return allPaths
|
||||
}
|
||||
|
||||
fun connectSegmentsEfficient(segments: List<Segment2D>): List<Segment2D> {
|
||||
if (segments.isEmpty()) return emptyList()
|
||||
|
||||
val remaining = segments.toMutableList()
|
||||
val connected = mutableListOf<Segment2D>()
|
||||
|
||||
// 构建端点查找表
|
||||
val startMap = mutableMapOf<Vector2, MutableList<Segment2D>>()
|
||||
val endMap = mutableMapOf<Vector2, MutableList<Segment2D>>()
|
||||
|
||||
segments.forEach { segment ->
|
||||
startMap.getOrPut(segment.start) { mutableListOf() }.add(segment)
|
||||
endMap.getOrPut(segment.end) { mutableListOf() }.add(segment)
|
||||
}
|
||||
|
||||
// 从第一个线段开始
|
||||
var currentSegment = remaining.removeAt(0)
|
||||
connected.add(currentSegment)
|
||||
|
||||
// 更新查找表
|
||||
startMap[currentSegment.start]?.remove(currentSegment)
|
||||
endMap[currentSegment.end]?.remove(currentSegment)
|
||||
|
||||
// 向前连接
|
||||
while (true) {
|
||||
val nextFromStart = startMap[currentSegment.end]?.firstOrNull()
|
||||
val nextFromEnd = endMap[currentSegment.end]?.firstOrNull()
|
||||
|
||||
when {
|
||||
nextFromStart != null -> {
|
||||
// 正向连接
|
||||
connected.add(nextFromStart)
|
||||
remaining.remove(nextFromStart)
|
||||
startMap[nextFromStart.start]?.remove(nextFromStart)
|
||||
endMap[nextFromStart.end]?.remove(nextFromStart)
|
||||
currentSegment = nextFromStart
|
||||
}
|
||||
|
||||
nextFromEnd != null -> {
|
||||
// 反向连接
|
||||
val reversed = Segment2D(nextFromEnd.end, nextFromEnd.start)
|
||||
connected.add(reversed)
|
||||
remaining.remove(nextFromEnd)
|
||||
startMap[nextFromEnd.start]?.remove(nextFromEnd)
|
||||
endMap[nextFromEnd.end]?.remove(nextFromEnd)
|
||||
currentSegment = reversed
|
||||
}
|
||||
|
||||
else -> break
|
||||
}
|
||||
}
|
||||
|
||||
// 向后连接
|
||||
currentSegment = connected.first()
|
||||
while (true) {
|
||||
val prevFromEnd = endMap[currentSegment.start]?.firstOrNull()
|
||||
val prevFromStart = startMap[currentSegment.start]?.firstOrNull()
|
||||
|
||||
when {
|
||||
prevFromEnd != null -> {
|
||||
// 正向连接到开头
|
||||
connected.add(0, prevFromEnd)
|
||||
remaining.remove(prevFromEnd)
|
||||
startMap[prevFromEnd.start]?.remove(prevFromEnd)
|
||||
endMap[prevFromEnd.end]?.remove(prevFromEnd)
|
||||
currentSegment = prevFromEnd
|
||||
}
|
||||
|
||||
prevFromStart != null -> {
|
||||
// 反向连接到开头
|
||||
val reversed = Segment2D(prevFromStart.end, prevFromStart.start)
|
||||
connected.add(0, reversed)
|
||||
remaining.remove(prevFromStart)
|
||||
startMap[prevFromStart.start]?.remove(prevFromStart)
|
||||
endMap[prevFromStart.end]?.remove(prevFromStart)
|
||||
currentSegment = reversed
|
||||
}
|
||||
|
||||
else -> break
|
||||
}
|
||||
}
|
||||
|
||||
return connected
|
||||
}
|
||||
|
||||
fun connectSegments(segments: List<Segment2D>): List<Segment2D> {
|
||||
if (segments.isEmpty()) return emptyList()
|
||||
|
||||
val remaining = segments.toMutableList()
|
||||
val connected = mutableListOf<Segment2D>()
|
||||
|
||||
// 从第一个线段开始,保持原方向
|
||||
connected.add(remaining.removeAt(0))
|
||||
|
||||
while (remaining.isNotEmpty()) {
|
||||
val lastEnd = connected.last().end
|
||||
var found = false
|
||||
|
||||
// 查找可以连接的线段
|
||||
for (i in remaining.indices) {
|
||||
val segment = remaining[i]
|
||||
|
||||
// 检查四种可能的连接方式
|
||||
when {
|
||||
// 正向连接:当前终点 == 线段起点
|
||||
segment.start == lastEnd -> {
|
||||
connected.add(segment)
|
||||
remaining.removeAt(i)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
// 反向连接:当前终点 == 线段终点,需要反转线段
|
||||
segment.end == lastEnd -> {
|
||||
connected.add(Segment2D(segment.end, segment.start)) // 反转
|
||||
remaining.removeAt(i)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
// 正向连接另一端:当前起点 == 线段终点,需要插入到前面
|
||||
segment.end == connected.first().start -> {
|
||||
connected.add(0, Segment2D(segment.end, segment.start)) // 反转后插入开头
|
||||
remaining.removeAt(i)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
// 反向连接另一端:当前起点 == 线段起点,需要反转并插入到前面
|
||||
segment.start == connected.first().start -> {
|
||||
connected.add(0, segment) // 直接插入开头(已经是正确方向)
|
||||
remaining.removeAt(i)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) break // 无法找到连接线段
|
||||
}
|
||||
|
||||
return connected
|
||||
}
|
||||
|
||||
fun calculatePositionRatio(value: Double, rangeStart: Double, rangeEnd: Double): Double {
|
||||
if (rangeStart == rangeEnd) return 0.0 // 避免除零
|
||||
|
||||
val ratio = (value - rangeStart) / (rangeEnd - rangeStart)
|
||||
return ratio.coerceIn(0.0, 1.0)
|
||||
}
|
||||
|
||||
fun sortLinesEfficient(lines: List<Segment2D>): List<Segment2D> {
|
||||
if (lines.isEmpty()) return emptyList()
|
||||
|
||||
// 创建起点到线段的映射
|
||||
val startMap = lines.associateBy { it.start }
|
||||
val sorted = mutableListOf<Segment2D>()
|
||||
|
||||
// 找到起点(没有其他线段的终点指向它的起点)
|
||||
var currentLine = lines.firstOrNull { line ->
|
||||
lines.none { it.end == line.start }
|
||||
} ?: lines.first()
|
||||
|
||||
sorted.add(currentLine)
|
||||
|
||||
while (true) {
|
||||
val nextLine = startMap[currentLine.end]
|
||||
if (nextLine == null || nextLine == lines.first()) break
|
||||
sorted.add(nextLine)
|
||||
currentLine = nextLine
|
||||
}
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
fun sortLines(lines: List<Segment2D>): List<Segment2D> {
|
||||
if (lines.isEmpty()) return emptyList()
|
||||
|
||||
val remaining = lines.toMutableList()
|
||||
val sorted = mutableListOf<Segment2D>()
|
||||
|
||||
// 从第一个线段开始
|
||||
sorted.add(remaining.removeAt(0))
|
||||
|
||||
while (remaining.isNotEmpty()) {
|
||||
val lastEnd = sorted.last().end
|
||||
var found = false
|
||||
|
||||
// 查找下一个线段
|
||||
for (i in remaining.indices) {
|
||||
if (remaining[i].start == lastEnd) {
|
||||
sorted.add(remaining.removeAt(i))
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) break // 无法找到下一个线段
|
||||
}
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
fun findLineLoops(lines: List<Segment2D>): List<List<Segment2D>> {
|
||||
val remaining = lines.toMutableList()
|
||||
val loops = mutableListOf<List<Segment2D>>()
|
||||
|
||||
while (remaining.isNotEmpty()) {
|
||||
val loop = findSingleLoop(remaining)
|
||||
if (loop.isNotEmpty()) {
|
||||
loops.add(loop)
|
||||
// 移除已使用的线段
|
||||
loop.forEach { line ->
|
||||
remaining.remove(line)
|
||||
}
|
||||
} else {
|
||||
// 无法形成环的线段
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return loops
|
||||
}
|
||||
|
||||
fun findSingleLoop(remaining: MutableList<Segment2D>): List<Segment2D> {
|
||||
if (remaining.isEmpty()) return emptyList()
|
||||
|
||||
val loop = mutableListOf<Segment2D>()
|
||||
loop.add(remaining.removeAt(0))
|
||||
|
||||
// 向前查找连接
|
||||
while (remaining.isNotEmpty()) {
|
||||
val lastEnd = loop.last().end
|
||||
val nextIndex = remaining.indexOfFirst { it.start == lastEnd }
|
||||
|
||||
if (nextIndex == -1) {
|
||||
// 尝试向后查找连接
|
||||
val firstStart = loop.first().start
|
||||
val prevIndex = remaining.indexOfFirst { it.end == firstStart }
|
||||
|
||||
if (prevIndex != -1) {
|
||||
loop.add(0, remaining.removeAt(prevIndex))
|
||||
} else {
|
||||
break // 无法继续连接
|
||||
}
|
||||
} else {
|
||||
loop.add(remaining.removeAt(nextIndex))
|
||||
}
|
||||
|
||||
// 检查是否形成闭环
|
||||
if (loop.last().end == loop.first().start) {
|
||||
return loop
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有形成闭环,返回空列表(或者可以根据需求返回部分环)
|
||||
remaining.addAll(loop) // 将线段放回剩余列表
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
fun coordinateGenerate(width: Int, height: Int): List<Vector3D> {
|
||||
val minX = 0.0
|
||||
val maxX = width.toDouble()
|
||||
val minY = 0.0
|
||||
val maxY = height.toDouble()
|
||||
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<Vector3D> {
|
||||
val center = Vector3D(0.0, 0.0, 0.0)
|
||||
val direction = Vector3D(0.0, 1.0, -1.0)
|
||||
return (0..360).step(36).map<Int, List<Vector3D>> { degrees: Int ->
|
||||
val newDirection = direction.rotateAroundZ(angle = degrees.degrees)
|
||||
(0..5).map {
|
||||
center + newDirection * it * 100
|
||||
}
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
|
||||
fun Vector3D.niceStr(): String {
|
||||
return "[$x, $y, $z]".format(this)
|
||||
}
|
||||
|
||||
fun List<Vector3D>.niceStr(): String {
|
||||
return joinToString(", ", "[", "]") {
|
||||
it.niceStr()
|
||||
}
|
||||
}
|
||||
267
desktop/src/jvmDemo/kotlin/DemoDelaunay3D.kt
Normal file
@@ -0,0 +1,267 @@
|
||||
import org.openrndr.KEY_ARROW_DOWN
|
||||
import org.openrndr.KEY_ARROW_UP
|
||||
import org.openrndr.WindowMultisample
|
||||
import org.openrndr.application
|
||||
import org.openrndr.color.ColorRGBa
|
||||
import org.openrndr.draw.DrawPrimitive
|
||||
import org.openrndr.draw.TextSettingMode
|
||||
import org.openrndr.draw.loadFont
|
||||
import org.openrndr.draw.shadeStyle
|
||||
import org.openrndr.extra.camera.Orbital
|
||||
import org.openrndr.extra.marchingsquares.findContours
|
||||
import org.openrndr.extra.objloader.loadOBJasVertexBuffer
|
||||
import org.openrndr.extra.textwriter.writer
|
||||
import org.openrndr.extra.triangulation.DelaunayTriangulation
|
||||
import org.openrndr.extra.triangulation.DelaunayTriangulation3D
|
||||
import org.openrndr.math.Vector2
|
||||
import org.openrndr.math.Vector3
|
||||
import org.openrndr.shape.Path3D
|
||||
import org.openrndr.shape.Segment3D
|
||||
|
||||
/**
|
||||
* @author tabidachinokaze
|
||||
* @date 2025/11/22
|
||||
*/
|
||||
fun main() = application {
|
||||
configure {
|
||||
width = 720
|
||||
height = 720
|
||||
title = "Delaunator"
|
||||
multisample = WindowMultisample.SampleCount(8)
|
||||
|
||||
}
|
||||
program {
|
||||
/*val points3D = (0 until height).step(36).map { y ->
|
||||
(0 until width).step(36).map { x ->
|
||||
gradientPerturbFractal(
|
||||
300,
|
||||
frequency = 0.8,
|
||||
position = Vector3(x.toDouble(), y.toDouble(), seconds)
|
||||
)
|
||||
}
|
||||
}.flatten().map {
|
||||
it.copy(x = it.x - width / 2, y = it.y - height / 2, z = it.z * 100)
|
||||
}*/
|
||||
/*val points3D = HeightmapVolcanoGenerator.generateVolcanoClusterHeightmap(
|
||||
width = width,
|
||||
height = height,
|
||||
volcanoCount = 3
|
||||
)*/
|
||||
val points3D = coordinateGenerate(width, height).map {
|
||||
it.copy(x = it.x - width / 2, y = it.y - height / 2, z = it.z * 10)
|
||||
}
|
||||
val zs = points3D.map { it.z }
|
||||
println("zs = ${zs}")
|
||||
val associate: MutableMap<Vector2, Double> = points3D.associate {
|
||||
Vector2(it.x, it.y) to it.z
|
||||
}.toMutableMap()
|
||||
val delaunay = DelaunayTriangulation(associate.map { it.key })
|
||||
val delaunay3D = DelaunayTriangulation3D(points3D.map { Vector3(it.x, it.y, it.z) })
|
||||
|
||||
//println(points3D.niceStr())
|
||||
//extend(Camera2D())
|
||||
val cam = Orbital()
|
||||
extend(cam) {
|
||||
eye = Vector3(x = 100.0, y = 100.0, z = 0.0)
|
||||
lookAt = Vector3(x = 1.6, y = -1.9, z = 1.2)
|
||||
}
|
||||
|
||||
println("draw")
|
||||
var targetHeight: Double = zs.average()
|
||||
var logEnabled = true
|
||||
var useInterpolation = false
|
||||
var sampleLinear = false
|
||||
keyboard.keyDown.listen {
|
||||
logEnabled = true
|
||||
println(it)
|
||||
when (it.key) {
|
||||
KEY_ARROW_UP -> targetHeight++
|
||||
KEY_ARROW_DOWN -> targetHeight--
|
||||
73 -> useInterpolation = !useInterpolation
|
||||
83 -> sampleLinear = !sampleLinear
|
||||
}
|
||||
}
|
||||
val vb = loadOBJasVertexBuffer("orx-obj-loader/test-data/non-planar.obj")
|
||||
|
||||
extend {
|
||||
val triangles = delaunay3D.triangles()
|
||||
val segments = mutableListOf<Segment3D>()
|
||||
drawer.clear(ColorRGBa.BLACK)
|
||||
val indexDiff = (frameCount / 1000) % triangles.size
|
||||
drawer.shadeStyle = shadeStyle {
|
||||
fragmentTransform = """
|
||||
x_fill.rgb = normalize(v_viewNormal) * 0.5 + vec3(0.5);
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
drawer.vertexBuffer(vb, DrawPrimitive.TRIANGLES)
|
||||
|
||||
// 绘制等高线段区域
|
||||
for ((i, triangle) in triangles.withIndex()) {
|
||||
val segment2DS = triangle.segments.filter {
|
||||
val startZ = it.start.z
|
||||
val endZ = it.end.z
|
||||
if (startZ < endZ) {
|
||||
targetHeight in startZ..endZ
|
||||
} else {
|
||||
targetHeight in endZ..startZ
|
||||
}
|
||||
}
|
||||
|
||||
if (segment2DS.size == 2) {
|
||||
val vector2s = segment2DS.map {
|
||||
val startZ = it.start.z
|
||||
val endZ = it.end.z
|
||||
val start = Vector3(it.start.x, it.start.y, startZ)
|
||||
val end = Vector3(it.end.x, it.end.y, endZ)
|
||||
if (startZ < endZ) {
|
||||
start to end
|
||||
} else {
|
||||
end to start
|
||||
}
|
||||
}.map { (start, end) ->
|
||||
val segment3D = Segment3D(start, end)
|
||||
val vector3 =
|
||||
segment3D.position(calculatePositionRatio(targetHeight, start.z, end.z))
|
||||
vector3
|
||||
}.onEach {
|
||||
associate[it.xy] = it.z
|
||||
}
|
||||
val element = Segment3D(vector2s[0], vector2s[1])
|
||||
segments.add(element)
|
||||
}
|
||||
drawer.strokeWeight = 20.0
|
||||
drawer.stroke = ColorRGBa.PINK
|
||||
//drawer.contour(triangle.contour)
|
||||
drawer.path(triangle.path)
|
||||
}
|
||||
|
||||
val sorted = connectAllSegments(segments)
|
||||
|
||||
drawer.stroke = ColorRGBa.WHITE
|
||||
drawer.strokeWeight = 2.0
|
||||
if (logEnabled) {
|
||||
segments.forEach {
|
||||
println("${it.start} -> ${it.end}")
|
||||
}
|
||||
println("=====")
|
||||
}
|
||||
|
||||
sorted.forEach {
|
||||
it.forEach {
|
||||
if (logEnabled) println("${it.start} -> ${it.end}")
|
||||
drawer.lineSegment(it.start, it.end)
|
||||
drawer.fill = ColorRGBa.WHITE
|
||||
}
|
||||
if (logEnabled) println("=")
|
||||
drawer.fill = ColorRGBa.YELLOW
|
||||
// if (false) drawer.contour(ShapeContour.fromSegments(it, closed = true))
|
||||
}
|
||||
// 结束绘制等高线
|
||||
/*for (y in 0 until (area.height / cellSize).toInt()) {
|
||||
for (x in 0 until (area.width / cellSize).toInt()) {
|
||||
values[IntVector2(x, y)] = f(Vector2(x * cellSize + area.x, y * cellSize + area.y))
|
||||
}
|
||||
}*/
|
||||
val contours = findContours(
|
||||
f = {
|
||||
val triangle = triangles.firstOrNull { triangle ->
|
||||
isPointInTriangle3D(it, listOf(triangle.x1, triangle.x2, triangle.x3))
|
||||
}
|
||||
triangle ?: return@findContours 0.0
|
||||
val interpolate = interpolateHeight(
|
||||
point = it,
|
||||
triangle = listOf(
|
||||
triangle.x1,
|
||||
triangle.x2,
|
||||
triangle.x3,
|
||||
)
|
||||
)
|
||||
interpolate.z - targetHeight
|
||||
},
|
||||
area = drawer.bounds.movedTo(Vector2(-width / 2.0, -height / 2.0)),
|
||||
cellSize = 4.0,
|
||||
useInterpolation = useInterpolation
|
||||
)
|
||||
if (logEnabled) println("useInterpolation = $useInterpolation")
|
||||
drawer.stroke = null
|
||||
contours.map {
|
||||
if (false) drawer.contour(it)
|
||||
it.segments.map {
|
||||
Segment3D(
|
||||
it.start.vector3(),
|
||||
it.end.vector3()
|
||||
)
|
||||
}
|
||||
}.forEach {
|
||||
drawer.fill = ColorRGBa.GREEN.opacify(0.1)
|
||||
drawer.path(Path3D.fromSegments(it, closed = true))
|
||||
}
|
||||
|
||||
if (false) writer {
|
||||
drawer.fontMap = loadFont("demo-data/fonts/IBMPlexMono-Regular.ttf", 24.0)
|
||||
drawer.drawStyle.textSetting = TextSettingMode.SUBPIXEL
|
||||
text(targetHeight.toString())
|
||||
}
|
||||
logEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Triangle3D(
|
||||
val x1: Vector3,
|
||||
val x2: Vector3,
|
||||
val x3: Vector3,
|
||||
) {
|
||||
fun toList(): List<Vector3> = listOf(x1, x2, x3)
|
||||
}
|
||||
|
||||
fun connectAllSegments(segments: List<Segment3D>): List<List<Segment3D>> {
|
||||
val remaining = segments.toMutableList()
|
||||
val allPaths = mutableListOf<List<Segment3D>>()
|
||||
|
||||
while (remaining.isNotEmpty()) {
|
||||
val path = mutableListOf<Segment3D>()
|
||||
|
||||
// 开始新路径
|
||||
path.add(remaining.removeAt(0))
|
||||
|
||||
var changed: Boolean
|
||||
do {
|
||||
changed = false
|
||||
|
||||
// 向前扩展
|
||||
val lastEnd = path.last().end
|
||||
val forwardSegment = remaining.find { it.start == lastEnd || it.end == lastEnd }
|
||||
if (forwardSegment != null) {
|
||||
val connectedSegment = if (forwardSegment.start == lastEnd) {
|
||||
forwardSegment // 正向
|
||||
} else {
|
||||
Segment3D(forwardSegment.end, forwardSegment.start) // 反向
|
||||
}
|
||||
path.add(connectedSegment)
|
||||
remaining.remove(forwardSegment)
|
||||
changed = true
|
||||
}
|
||||
|
||||
// 向后扩展
|
||||
val firstStart = path.first().start
|
||||
val backwardSegment = remaining.find { it.end == firstStart || it.start == firstStart }
|
||||
if (backwardSegment != null) {
|
||||
val connectedSegment = if (backwardSegment.end == firstStart) {
|
||||
backwardSegment // 正向
|
||||
} else {
|
||||
Segment3D(backwardSegment.end, backwardSegment.start) // 反向
|
||||
}
|
||||
path.add(0, connectedSegment)
|
||||
remaining.remove(backwardSegment)
|
||||
changed = true
|
||||
}
|
||||
|
||||
} while (changed && remaining.isNotEmpty())
|
||||
|
||||
allPaths.add(path)
|
||||
}
|
||||
|
||||
return allPaths
|
||||
}
|
||||
94
desktop/src/jvmDemo/kotlin/FindContours.kt
Normal file
@@ -0,0 +1,94 @@
|
||||
import org.openrndr.application
|
||||
import org.openrndr.color.ColorRGBa
|
||||
import org.openrndr.extra.camera.Camera2D
|
||||
import org.openrndr.extra.marchingsquares.findContours
|
||||
import org.openrndr.math.Vector2
|
||||
import org.openrndr.math.Vector3
|
||||
|
||||
/**
|
||||
* A simple demonstration of using the `findContours` method provided by `orx-marching-squares`.
|
||||
*
|
||||
* `findContours` lets one generate contours by providing a mathematical function to be
|
||||
* sampled within the provided area and with the given cell size. Contours are generated
|
||||
* between the areas in which the function returns positive and negative values.
|
||||
*
|
||||
* In this example, the `f` function returns the distance of a point to the center of the window minus 200.0.
|
||||
* Therefore, sampled locations which are less than 200 pixels away from the center return
|
||||
* negative values and all others return positive values, effectively generating a circle of radius 200.0.
|
||||
*
|
||||
* Try increasing the cell size to see how the precision of the circle reduces.
|
||||
*
|
||||
* The circular contour created in this program has over 90 segments. The number of segments depends on the cell
|
||||
* size, and the resulting radius.
|
||||
*/
|
||||
fun main() = application {
|
||||
configure {
|
||||
width = 720
|
||||
height = 720
|
||||
}
|
||||
program {
|
||||
extend(Camera2D())
|
||||
var showLog = true
|
||||
val target = Vector2(0.0, 0.0)
|
||||
val points3D = (0..10).map { x ->
|
||||
(0..10).map { y ->
|
||||
Vector3(x.toDouble(), y.toDouble(), x * y * 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
extend {
|
||||
drawer.clear(ColorRGBa.BLACK)
|
||||
drawer.stroke = ColorRGBa.PINK
|
||||
fun f3(v: Vector2): Double {
|
||||
val distance = drawer.bounds.center.distanceTo(v)
|
||||
return when (distance) {
|
||||
in 0.0..<100.0 -> -3.0
|
||||
in 100.0..<200.0 -> 1.0
|
||||
in 200.0..300.0 -> -1.0
|
||||
else -> distance
|
||||
}
|
||||
}
|
||||
|
||||
fun f(v: Vector2): Double {
|
||||
val distanceTo = v.distanceTo(target)
|
||||
return (distanceTo - 100.0).also {
|
||||
if (showLog) println(
|
||||
buildString {
|
||||
appendLine("${v} distanceTo ${target} = ${distanceTo}")
|
||||
appendLine("distanceTo - 100.0 = ${distanceTo - 100.0}")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val points = mutableListOf<Vector2>()
|
||||
|
||||
fun f1(v: Vector2): Double {
|
||||
val result = if (v.x == v.y * 2 || v.x * 2 == v.y) {
|
||||
points.add(v)
|
||||
-1.0
|
||||
} else 0.0
|
||||
return result.also {
|
||||
if (showLog) {
|
||||
println("$v -> $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val contours = findContours(::f3, drawer.bounds, 4.0)
|
||||
drawer.fill = null
|
||||
drawer.contours(contours)
|
||||
|
||||
if (showLog) {
|
||||
println(
|
||||
buildString {
|
||||
for ((index, contour) in contours.withIndex()) {
|
||||
appendLine("index = ${index}, $contour")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
showLog = false
|
||||
}
|
||||
}
|
||||
}
|
||||
373
desktop/src/jvmDemo/kotlin/HeightmapVolcanoGenerator.kt
Normal file
@@ -0,0 +1,373 @@
|
||||
import com.icegps.math.geometry.Vector3D
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* @author tabidachinokaze
|
||||
* @date 2025/11/22
|
||||
*/
|
||||
object HeightmapVolcanoGenerator {
|
||||
|
||||
// 基础火山高度图
|
||||
fun generateVolcanoHeightmap(
|
||||
width: Int = 100,
|
||||
height: Int = 100,
|
||||
centerX: Double = 50.0,
|
||||
centerY: Double = 50.0,
|
||||
maxHeight: Double = 60.0,
|
||||
craterRadius: Double = 8.0,
|
||||
volcanoRadius: Double = 30.0
|
||||
): List<Vector3D> {
|
||||
val points = mutableListOf<Vector3D>()
|
||||
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
// 计算到火山中心的距离
|
||||
val dx = x - centerX
|
||||
val dy = y - centerY
|
||||
val distance = sqrt(dx * dx + dy * dy)
|
||||
|
||||
// 计算基础火山高度
|
||||
var z = calculateVolcanoHeight(distance, craterRadius, volcanoRadius, maxHeight)
|
||||
|
||||
// 添加噪声细节
|
||||
val noise = perlinNoise(x * 0.1, y * 0.1, 0.1) * 3.0
|
||||
z = max(0.0, z + noise)
|
||||
|
||||
points.add(Vector3D(x.toDouble(), y.toDouble(), z))
|
||||
}
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
// 复合火山群高度图
|
||||
fun generateVolcanoClusterHeightmap(
|
||||
width: Int = 150,
|
||||
height: Int = 150,
|
||||
volcanoCount: Int = 3
|
||||
): List<Vector3D> {
|
||||
val points = mutableListOf<Vector3D>()
|
||||
val volcanoes = generateRandomVolcanoPositions(volcanoCount, width, height)
|
||||
|
||||
for (x in (0 until width).step(25)) {
|
||||
for (y in (0 until height).step(25)) {
|
||||
var totalZ = 0.0
|
||||
|
||||
// 叠加所有火山的影响
|
||||
for (volcano in volcanoes) {
|
||||
val dx = x - volcano.x
|
||||
val dy = y - volcano.y
|
||||
val distance = sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (distance <= volcano.radius) {
|
||||
val volcanoHeight = calculateVolcanoHeight(
|
||||
distance,
|
||||
volcano.craterRadius,
|
||||
volcano.radius,
|
||||
volcano.maxHeight
|
||||
)
|
||||
totalZ += volcanoHeight
|
||||
}
|
||||
}
|
||||
|
||||
// 基础地形
|
||||
val baseNoise = perlinNoise(x * 0.02, y * 0.02, 0.05) * 5.0
|
||||
val detailNoise = perlinNoise(x * 0.1, y * 0.1, 0.2) * 2.0
|
||||
|
||||
points.add(Vector3D(x.toDouble(), y.toDouble(), totalZ + baseNoise + detailNoise))
|
||||
}
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
// 带熔岩流的火山高度图
|
||||
fun generateVolcanoWithLavaHeightmap(
|
||||
width: Int = 100,
|
||||
height: Int = 100
|
||||
): List<Vector3D> {
|
||||
val points = mutableListOf<Vector3D>()
|
||||
val centerX = width / 2.0
|
||||
val centerY = height / 2.0
|
||||
|
||||
// 生成熔岩流路径
|
||||
val lavaFlows = generateLavaFlowPaths(centerX, centerY, 3)
|
||||
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
val dx = x - centerX
|
||||
val dy = y - centerY
|
||||
val distance = sqrt(dx * dx + dy * dy)
|
||||
|
||||
// 基础火山高度
|
||||
var z = calculateVolcanoHeight(distance, 10.0, 35.0, 70.0)
|
||||
|
||||
// 添加熔岩流
|
||||
z += calculateLavaFlowEffect(x.toDouble(), y.toDouble(), lavaFlows)
|
||||
|
||||
// 侵蚀效果
|
||||
z += calculateErosionEffect(x.toDouble(), y.toDouble(), distance, z)
|
||||
|
||||
points.add(Vector3D(x.toDouble(), y.toDouble(), max(0.0, z)))
|
||||
}
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
// 破火山口高度图
|
||||
fun generateCalderaHeightmap(
|
||||
width: Int = 100,
|
||||
height: Int = 100
|
||||
): List<Vector3D> {
|
||||
val points = mutableListOf<Vector3D>()
|
||||
val centerX = width / 2.0
|
||||
val centerY = height / 2.0
|
||||
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
val dx = x - centerX
|
||||
val dy = y - centerY
|
||||
val distance = sqrt(dx * dx + dy * dy)
|
||||
|
||||
var z = calculateCalderaHeight(distance, 15.0, 45.0, 50.0)
|
||||
|
||||
// 内部平坦区域细节
|
||||
if (distance < 20) {
|
||||
z += perlinNoise(x * 0.2, y * 0.2, 0.3) * 1.5
|
||||
}
|
||||
|
||||
points.add(Vector3D(x.toDouble(), y.toDouble(), max(0.0, z)))
|
||||
}
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
// 线性火山链高度图
|
||||
fun generateVolcanoChainHeightmap(
|
||||
width: Int = 200,
|
||||
height: Int = 100
|
||||
): List<Vector3D> {
|
||||
val points = mutableListOf<Vector3D>()
|
||||
|
||||
// 在一条线上生成多个火山
|
||||
val chainCenters = listOf(
|
||||
Vector3D(30.0, 50.0, 0.0),
|
||||
Vector3D(70.0, 50.0, 0.0),
|
||||
Vector3D(110.0, 50.0, 0.0),
|
||||
Vector3D(150.0, 50.0, 0.0),
|
||||
Vector3D(170.0, 50.0, 0.0)
|
||||
)
|
||||
|
||||
for (x in 0 until width) {
|
||||
for (y in 0 until height) {
|
||||
var totalZ = 0.0
|
||||
|
||||
for (center in chainCenters) {
|
||||
val dx = x - center.x
|
||||
val dy = y - center.y
|
||||
val distance = sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (distance <= 25.0) {
|
||||
val volcanoZ = calculateVolcanoHeight(distance, 6.0, 25.0, 40.0)
|
||||
totalZ += volcanoZ
|
||||
}
|
||||
}
|
||||
|
||||
// 添加基底地形,模拟山脉链
|
||||
val baseRidge = calculateMountainRidge(x.toDouble(), y.toDouble(), width, height)
|
||||
totalZ += baseRidge
|
||||
|
||||
points.add(Vector3D(x.toDouble(), y.toDouble(), totalZ))
|
||||
}
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
private data class VolcanoInfo(
|
||||
val x: Double,
|
||||
val y: Double,
|
||||
val radius: Double,
|
||||
val craterRadius: Double,
|
||||
val maxHeight: Double
|
||||
)
|
||||
|
||||
private data class LavaFlowInfo(
|
||||
val startX: Double,
|
||||
val startY: Double,
|
||||
val angle: Double, // 弧度
|
||||
val length: Double,
|
||||
val width: Double,
|
||||
val intensity: Double
|
||||
)
|
||||
|
||||
private fun calculateVolcanoHeight(
|
||||
distance: Double,
|
||||
craterRadius: Double,
|
||||
volcanoRadius: Double,
|
||||
maxHeight: Double
|
||||
): Double {
|
||||
return when {
|
||||
distance <= craterRadius -> {
|
||||
// 火山口 - 中心凹陷
|
||||
val craterDepth = maxHeight * 0.4
|
||||
craterDepth * (1.0 - distance / craterRadius)
|
||||
}
|
||||
|
||||
distance <= volcanoRadius -> {
|
||||
// 火山锥
|
||||
val slopeDistance = distance - craterRadius
|
||||
val maxSlopeDistance = volcanoRadius - craterRadius
|
||||
val normalized = slopeDistance / maxSlopeDistance
|
||||
maxHeight * (1.0 - normalized * normalized)
|
||||
}
|
||||
|
||||
else -> 0.0
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateCalderaHeight(
|
||||
distance: Double,
|
||||
innerRadius: Double,
|
||||
outerRadius: Double,
|
||||
rimHeight: Double
|
||||
): Double {
|
||||
return when {
|
||||
distance <= innerRadius -> {
|
||||
// 平坦的破火山口底部
|
||||
rimHeight * 0.2
|
||||
}
|
||||
|
||||
distance <= outerRadius -> {
|
||||
// 陡峭的边缘
|
||||
val rimDistance = distance - innerRadius
|
||||
val rimWidth = outerRadius - innerRadius
|
||||
val normalized = rimDistance / rimWidth
|
||||
rimHeight * (1.0 - (1.0 - normalized) * (1.0 - normalized))
|
||||
}
|
||||
|
||||
else -> {
|
||||
// 外部平缓斜坡
|
||||
val externalDistance = distance - outerRadius
|
||||
rimHeight * exp(-externalDistance * 0.08)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateLavaFlowEffect(x: Double, y: Double, lavaFlows: List<LavaFlowInfo>): Double {
|
||||
var effect = 0.0
|
||||
|
||||
for (flow in lavaFlows) {
|
||||
val dx = x - flow.startX
|
||||
val dy = y - flow.startY
|
||||
|
||||
// 计算到熔岩流中心线的距离
|
||||
val flowDirX = cos(flow.angle)
|
||||
val flowDirY = sin(flow.angle)
|
||||
|
||||
val projection = dx * flowDirX + dy * flowDirY
|
||||
|
||||
if (projection in 0.0..flow.length) {
|
||||
val perpendicularX = dx - projection * flowDirX
|
||||
val perpendicularY = dy - projection * flowDirY
|
||||
val perpendicularDist = sqrt(perpendicularX * perpendicularX + perpendicularY * perpendicularY)
|
||||
|
||||
if (perpendicularDist <= flow.width) {
|
||||
val widthFactor = 1.0 - (perpendicularDist / flow.width)
|
||||
val lengthFactor = 1.0 - (projection / flow.length)
|
||||
effect += flow.intensity * widthFactor * lengthFactor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return effect
|
||||
}
|
||||
|
||||
private fun calculateErosionEffect(x: Double, y: Double, distance: Double, height: Double): Double {
|
||||
// 基于坡度的侵蚀
|
||||
val slopeNoise = perlinNoise(x * 0.15, y * 0.15, 0.1) * 2.0
|
||||
// 基于距离的侵蚀
|
||||
val distanceErosion = if (distance > 25) perlinNoise(x * 0.08, y * 0.08, 0.05) * 1.5 else 0.0
|
||||
return slopeNoise + distanceErosion
|
||||
}
|
||||
|
||||
private fun calculateMountainRidge(x: Double, y: Double, width: Int, height: Int): Double {
|
||||
// 创建山脉基底
|
||||
val ridgeCenter = height / 2.0
|
||||
val distanceToRidge = abs(y - ridgeCenter)
|
||||
val ridgeWidth = height * 0.3
|
||||
|
||||
if (distanceToRidge <= ridgeWidth) {
|
||||
val ridgeFactor = 1.0 - (distanceToRidge / ridgeWidth)
|
||||
return ridgeFactor * 15.0 * perlinNoise(x * 0.01, y * 0.01, 0.02)
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
|
||||
private fun generateRandomVolcanoPositions(count: Int, width: Int, height: Int): List<VolcanoInfo> {
|
||||
return List(count) {
|
||||
VolcanoInfo(
|
||||
x = (width * 0.2 + random() * width * 0.6),
|
||||
y = (height * 0.2 + random() * height * 0.6),
|
||||
radius = 20.0 + random() * 20.0,
|
||||
craterRadius = 5.0 + random() * 7.0,
|
||||
maxHeight = 25.0 + random() * 35.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateLavaFlowPaths(centerX: Double, centerY: Double, count: Int): List<LavaFlowInfo> {
|
||||
return List(count) {
|
||||
LavaFlowInfo(
|
||||
startX = centerX,
|
||||
startY = centerY,
|
||||
angle = random() * 2 * PI,
|
||||
length = 20.0 + random() * 15.0,
|
||||
width = 2.0 + random() * 3.0,
|
||||
intensity = 5.0 + random() * 8.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun perlinNoise(x: Double, y: Double, frequency: Double): Double {
|
||||
// 简化的柏林噪声实现
|
||||
val x0 = floor(x * frequency)
|
||||
val y0 = floor(y * frequency)
|
||||
val x1 = x0 + 1
|
||||
val y1 = y0 + 1
|
||||
|
||||
fun grad(ix: Int, iy: Int): Double {
|
||||
val random = sin(ix * 12.9898 + iy * 78.233) * 43758.5453
|
||||
return (random % 1.0) * 2 - 1
|
||||
}
|
||||
|
||||
fun interpolate(a: Double, b: Double, w: Double): Double {
|
||||
return a + (b - a) * (w * w * (3 - 2 * w))
|
||||
}
|
||||
|
||||
val g00 = grad(x0.toInt(), y0.toInt())
|
||||
val g10 = grad(x1.toInt(), y0.toInt())
|
||||
val g01 = grad(x0.toInt(), y1.toInt())
|
||||
val g11 = grad(x1.toInt(), y1.toInt())
|
||||
|
||||
val tx = x * frequency - x0
|
||||
val ty = y * frequency - y0
|
||||
|
||||
val n0 = interpolate(g00, g10, tx)
|
||||
val n1 = interpolate(g01, g11, tx)
|
||||
|
||||
return interpolate(n0, n1, ty)
|
||||
}
|
||||
|
||||
private fun random(): Double = Math.random()
|
||||
}
|
||||
@@ -21,4 +21,13 @@ kotlin.mpp.import.legacyTestSourceSetDetection=true
|
||||
|
||||
# Enable Dokka 2.0.0
|
||||
org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled
|
||||
org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true
|
||||
org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -16,15 +16,27 @@ antlrKotlin = "1.0.8"
|
||||
minim = "2.2.2"
|
||||
netty = "4.2.7.Final"
|
||||
rabbitcontrol = "0.3.39"
|
||||
zxing = "3.5.3"
|
||||
ktor = "3.3.1"
|
||||
zxing = "3.5.4"
|
||||
ktor = "3.3.2"
|
||||
jgit = "7.3.0.202506031305-r"
|
||||
javaosc = "0.9"
|
||||
jsoup = "1.21.2"
|
||||
mockk = "1.14.2"
|
||||
processing = "4.4.10"
|
||||
nmcp = "1.1.0"
|
||||
nmcp = "1.2.0"
|
||||
okhttp = "5.2.1"
|
||||
agp = "8.13.1"
|
||||
junit = "4.13.2"
|
||||
coreKtx = "1.17.0"
|
||||
junitVersion = "1.3.0"
|
||||
espressoCoreVersion = "3.7.0"
|
||||
appcompat = "1.7.1"
|
||||
material = "1.13.0"
|
||||
activity = "1.12.0"
|
||||
constraintlayout = "2.2.1"
|
||||
lifecycleRuntimeKtx = "2.10.0"
|
||||
kotlinx-serialization = "1.9.0"
|
||||
mapbox = "11.16.6"
|
||||
|
||||
[libraries]
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
@@ -57,10 +69,32 @@ antlr-core = { group = "org.antlr", name = "antlr4", version.ref = "antlr" }
|
||||
antlr-runtime = { group = "org.antlr", name = "antlr4-runtime", version.ref = "antlr" }
|
||||
antlr-kotlin-runtime = { group = "com.strumenta", name = "antlr-kotlin-runtime", version.ref = "antlrKotlin" }
|
||||
jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCoreVersion" }
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
|
||||
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||
#ktor
|
||||
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
|
||||
ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" }
|
||||
ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
|
||||
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
|
||||
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
mapbox-maps = { module = "com.mapbox.maps:android-ndk27", version.ref = "mapbox" }
|
||||
|
||||
[plugins]
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
nebula-release = { id = "nebula.release", version.ref = "nebulaRelease" }
|
||||
kotest-multiplatform = { id = "io.kotest.multiplatform", version.ref = "kotest" }
|
||||
antlr-kotlin = { id = "com.strumenta.antlr-kotlin", version.ref = "antlrKotlin" }
|
||||
nmcp = { id = "com.gradleup.nmcp.aggregation", version.ref = "nmcp" }
|
||||
nmcp = { id = "com.gradleup.nmcp.aggregation", version.ref = "nmcp" }
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
2
gradlew
vendored
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
|
||||
1
icegps-common/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
44
icegps-common/build.gradle.kts
Normal file
@@ -0,0 +1,44 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.icegps.common"
|
||||
compileSdk {
|
||||
version = release(36)
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 28
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions.jvmTarget = JvmTarget.JVM_17
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.ext.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
}
|
||||
0
icegps-common/consumer-rules.pro
Normal file
21
icegps-common/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.icegps.common
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.icegps.common.test", appContext.packageName)
|
||||
}
|
||||
}
|
||||
4
icegps-common/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
274
icegps-common/src/main/java/com/icegps/common/helper/BlhToEnu.kt
Normal file
@@ -0,0 +1,274 @@
|
||||
package com.icegps.common.helper
|
||||
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* BLH -> ENU
|
||||
*
|
||||
* @author lm
|
||||
* @date 2025/3/12
|
||||
* @link https://gist.github.com/komasaru/6ce0634475923ddac597f868288c54e9
|
||||
*/
|
||||
class BlhToEnu {
|
||||
|
||||
companion object {
|
||||
private const val PI_180 = Math.PI / 180.0
|
||||
|
||||
// WGS84 坐标参数
|
||||
private const val A = 6378137.0 // a(地球椭球体长半径(赤道面平均半径))
|
||||
private const val ONE_F = 298.257223563 // 1 / f(地球椭球体扁平率 = (a - b) / a)
|
||||
private val B = A * (1.0 - 1.0 / ONE_F) // b(地球椭球体短半径)
|
||||
private val E2 = (1.0 / ONE_F) * (2 - (1.0 / ONE_F))
|
||||
|
||||
// e^2 = 2 * f - f * f = (a^2 - b^2) / a^2
|
||||
private val ED2 = E2 * A * A / (B * B) // e'^2 = (a^2 - b^2) / b^2
|
||||
}
|
||||
|
||||
private var originLat: Double = 0.0
|
||||
private var originLon: Double = 0.0
|
||||
private var originHeight: Double = 0.0
|
||||
private var isOriginSet: Boolean = false
|
||||
|
||||
fun getOriginLat(): Double = originLat
|
||||
|
||||
fun getOriginLon(): Double = originLon
|
||||
|
||||
fun getOriginHeight(): Double = originHeight
|
||||
|
||||
fun resetEnuBenchmarkPoint() {
|
||||
isOriginSet = false
|
||||
}
|
||||
|
||||
fun wgs84ToEnu(lat: Double, lon: Double, height: Double = 0.0): DoubleArray {
|
||||
if (!isOriginSet) {
|
||||
originLat = lat
|
||||
originLon = lon
|
||||
originHeight = height
|
||||
isOriginSet = true
|
||||
return doubleArrayOf(0.0, 0.0, 0.0)
|
||||
}
|
||||
|
||||
val enu = blh2enu(originLat, originLon, originHeight, lat, lon, height)
|
||||
// var az = atan2(enu[0], enu[1]) * 180.0 / Math.PI
|
||||
// if (az < 0.0) {
|
||||
// az += 360.0
|
||||
// }
|
||||
// val el = atan2(
|
||||
// enu[2],
|
||||
// sqrt(enu[0] * enu[0] + enu[1] * enu[1])
|
||||
// ) * 180.0 / Math.PI
|
||||
// val dst = sqrt(enu.sumOf { it * it })
|
||||
|
||||
// println("--->")
|
||||
// println(
|
||||
// """
|
||||
// ENU: E = ${enu[0].format(3)}m
|
||||
// N = ${enu[1].format(3)}m
|
||||
// U = ${enu[2].format(3)}m
|
||||
// 方位角 = ${az.format(3)}°
|
||||
// 仰角 = ${el.format(3)}°
|
||||
// 距离 = ${dst.format(3)}m
|
||||
// """.trimIndent()
|
||||
// )
|
||||
|
||||
return enu
|
||||
}
|
||||
|
||||
fun enuToWgs84(e: Double, n: Double, u: Double): DoubleArray {
|
||||
if (!isOriginSet) {
|
||||
return doubleArrayOf(0.0, 0.0, 0.0)
|
||||
}
|
||||
|
||||
val blh = enu2blh(originLat, originLon, originHeight, e, n, u)
|
||||
|
||||
// println("--->")
|
||||
// println(
|
||||
// """
|
||||
// BLH: Beta = ${blh[0].format(8)}°
|
||||
// Lambda = ${blh[1].format(8)}°
|
||||
// Height = ${blh[2].format(3)}m
|
||||
// """.trimIndent()
|
||||
// )
|
||||
|
||||
return blh
|
||||
}
|
||||
|
||||
private fun Double.format(digits: Int) = "%.${digits}f".format(this)
|
||||
|
||||
/**
|
||||
* BLH -> ENU 转换(East, North, Up)
|
||||
*
|
||||
* @param bO 原点 Beta(纬度)
|
||||
* @param lO 原点 Lambda(经度)
|
||||
* @param hO 原点 Height(高度)
|
||||
* @param b 目标点 Beta(纬度)
|
||||
* @param l 目标点 Lambda(经度)
|
||||
* @param h 目标点 Height(高度)
|
||||
* @return ENU 坐标 [e, n, u]
|
||||
*/
|
||||
private fun blh2enu(bO: Double, lO: Double, hO: Double, b: Double, l: Double, h: Double): DoubleArray {
|
||||
val (xO, yO, zO) = blh2ecef(bO, lO, hO)
|
||||
val (x, y, z) = blh2ecef(b, l, h)
|
||||
val mat0 = matZ(90.0)
|
||||
val mat1 = matY(90.0 - bO)
|
||||
val mat2 = matZ(lO)
|
||||
val mat = mulMat(mulMat(mat0, mat1), mat2)
|
||||
return rotate(mat, doubleArrayOf(x - xO, y - yO, z - zO))
|
||||
}
|
||||
|
||||
/**
|
||||
* BLH -> ECEF 转换
|
||||
*
|
||||
* @param lat 纬度
|
||||
* @param lon 经度
|
||||
* @param height 高度
|
||||
* @return ECEF 坐标 [x, y, z]
|
||||
*/
|
||||
private fun blh2ecef(lat: Double, lon: Double, height: Double): DoubleArray {
|
||||
val n = { x: Double -> A / sqrt(1.0 - E2 * sin(x * PI_180).pow(2)) }
|
||||
val x = (n(lat) + height) * cos(lat * PI_180) * cos(lon * PI_180)
|
||||
val y = (n(lat) + height) * cos(lat * PI_180) * sin(lon * PI_180)
|
||||
val z = (n(lat) * (1.0 - E2) + height) * sin(lat * PI_180)
|
||||
return doubleArrayOf(x, y, z)
|
||||
}
|
||||
|
||||
/**
|
||||
* ENU -> BLH 转换
|
||||
*
|
||||
* @param e East 坐标
|
||||
* @param n North 坐标
|
||||
* @param u Up 坐标
|
||||
* @return WGS84 坐标 [纬度, 经度, 高度]
|
||||
*/
|
||||
private fun enu2blh(bO: Double, lO: Double, hO: Double, e: Double, n: Double, u: Double): DoubleArray {
|
||||
val mat0 = matZ(-lO)
|
||||
val mat1 = matY(-(90.0 - bO))
|
||||
val mat2 = matZ(-90.0)
|
||||
val mat = mulMat(mulMat(mat0, mat1), mat2)
|
||||
|
||||
val enu = doubleArrayOf(e, n, u)
|
||||
val xyz = rotate(mat, enu)
|
||||
|
||||
val (xO, yO, zO) = blh2ecef(bO, lO, hO)
|
||||
val x = xyz[0] + xO
|
||||
val y = xyz[1] + yO
|
||||
val z = xyz[2] + zO
|
||||
|
||||
return ecef2blh(x, y, z)
|
||||
}
|
||||
|
||||
/**
|
||||
* ECEF -> BLH 转换
|
||||
*
|
||||
* @param x ECEF X 坐标
|
||||
* @param y ECEF Y 坐标
|
||||
* @param z ECEF Z 坐标
|
||||
* @return WGS84 坐标 [纬度, 经度, 高度]
|
||||
*/
|
||||
private fun ecef2blh(x: Double, y: Double, z: Double): DoubleArray {
|
||||
val p = sqrt(x * x + y * y)
|
||||
val theta = atan2(z * A, p * B)
|
||||
val sinTheta = sin(theta)
|
||||
val cosTheta = cos(theta)
|
||||
|
||||
val lat = atan2(
|
||||
z + ED2 * B * sinTheta.pow(3),
|
||||
p - E2 * A * cosTheta.pow(3)
|
||||
)
|
||||
val lon = atan2(y, x)
|
||||
|
||||
val sinLat = sin(lat)
|
||||
val n = A / sqrt(1.0 - E2 * sinLat * sinLat)
|
||||
val h = p / cos(lat) - n
|
||||
|
||||
return doubleArrayOf(
|
||||
lat * 180.0 / Math.PI,
|
||||
lon * 180.0 / Math.PI,
|
||||
h
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 x 轴为轴的旋转矩阵
|
||||
*
|
||||
* @param ang 旋转角度(°)
|
||||
* @return 旋转矩阵(3x3)
|
||||
*/
|
||||
private fun matX(ang: Double): Array<DoubleArray> {
|
||||
val a = ang * PI_180
|
||||
val c = cos(a)
|
||||
val s = sin(a)
|
||||
return arrayOf(
|
||||
doubleArrayOf(1.0, 0.0, 0.0),
|
||||
doubleArrayOf(0.0, c, s),
|
||||
doubleArrayOf(0.0, -s, c)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 y 轴为轴的旋转矩阵
|
||||
*
|
||||
* @param ang 旋转角度(°)
|
||||
* @return 旋转矩阵(3x3)
|
||||
*/
|
||||
private fun matY(ang: Double): Array<DoubleArray> {
|
||||
val a = ang * PI_180
|
||||
val c = cos(a)
|
||||
val s = sin(a)
|
||||
return arrayOf(
|
||||
doubleArrayOf(c, 0.0, -s),
|
||||
doubleArrayOf(0.0, 1.0, 0.0),
|
||||
doubleArrayOf(s, 0.0, c)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 以 z 轴为轴的旋转矩阵
|
||||
*
|
||||
* @param ang 旋转角度(°)
|
||||
* @return 旋转矩阵(3x3)
|
||||
*/
|
||||
private fun matZ(ang: Double): Array<DoubleArray> {
|
||||
val a = ang * PI_180
|
||||
val c = cos(a)
|
||||
val s = sin(a)
|
||||
return arrayOf(
|
||||
doubleArrayOf(c, s, 0.0),
|
||||
doubleArrayOf(-s, c, 0.0),
|
||||
doubleArrayOf(0.0, 0.0, 1.0)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 两个矩阵(3x3)的乘积
|
||||
*
|
||||
* @param matA 3x3 矩阵
|
||||
* @param matB 3x3 矩阵
|
||||
* @return 3x3 矩阵
|
||||
*/
|
||||
private fun mulMat(matA: Array<DoubleArray>, matB: Array<DoubleArray>): Array<DoubleArray> {
|
||||
return Array(3) { k ->
|
||||
DoubleArray(3) { j ->
|
||||
(0..2).sumOf { i -> matA[k][i] * matB[i][j] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 点的旋转
|
||||
*
|
||||
* @param mat 3x3 旋转矩阵
|
||||
* @param pt 旋转前坐标 [x, y, z]
|
||||
* @return 旋转后坐标 [x, y, z]
|
||||
*/
|
||||
private fun rotate(mat: Array<DoubleArray>, pt: DoubleArray): DoubleArray {
|
||||
return DoubleArray(3) { j ->
|
||||
(0..2).sumOf { i -> mat[j][i] * pt[i] }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package com.icegps.common.helper
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import kotlin.math.atan
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cbrt
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.math.tan
|
||||
|
||||
/**
|
||||
* WGS84、EPSG3857、ENU 的坐标转换工具类
|
||||
*
|
||||
* @author lm
|
||||
* @date 2024/8/2
|
||||
*/
|
||||
class GeoHelper private constructor() {
|
||||
|
||||
companion object {
|
||||
private var sharedInstance: GeoHelper? = null
|
||||
fun getSharedInstance(): GeoHelper = sharedInstance ?: GeoHelper().also { sharedInstance = it }
|
||||
fun createInstance(): GeoHelper = GeoHelper()
|
||||
}
|
||||
|
||||
// WGS-84 ellipsoid parameters
|
||||
private val RADIUS = 6378137.0 // Major radius
|
||||
private val RADIUS_B = 6356752.314245 // Minor radius
|
||||
private val E = (RADIUS * RADIUS - RADIUS_B * RADIUS_B) / (RADIUS * RADIUS) // Eccentricity
|
||||
private val HALF_SIZE = Math.PI * RADIUS // Half circumference of Earth
|
||||
private val DEG2RAD = Math.PI / 180 // Degrees to radians conversion factor
|
||||
private val RAD2DEG = 180 / Math.PI // Radians to degrees conversion factor
|
||||
private val RE_WGS84 = 6378137.0 // Earth's equatorial radius in WGS84
|
||||
private val FE_WGS84 = 1.0 / 298.257223563 // Flattening of the WGS84 ellipsoid
|
||||
|
||||
private var isFirstPoint = true
|
||||
private var firstPoint = DoubleArray(3)
|
||||
private val bPos = DoubleArray(3)
|
||||
private var bECEF = DoubleArray(3)
|
||||
private val rPos = DoubleArray(3)
|
||||
private var rECEF = DoubleArray(3)
|
||||
private val vECEF = DoubleArray(3)
|
||||
|
||||
private var useBlhToEnu = true
|
||||
private var blhToEnu = BlhToEnu()
|
||||
|
||||
/**
|
||||
* 将 WGS84 坐标转换为 ENU (East-North-Up) 坐标
|
||||
* 如果是第一个点,它将被设置为 ENU 坐标系的基准点
|
||||
*
|
||||
* @param lon 经度(度)
|
||||
* @param lat 纬度(度)
|
||||
* @param hgt 高度(米)
|
||||
* @return 包含 ENU 坐标的 Enu 对象
|
||||
*/
|
||||
fun wgs84ToENU(lon: Double, lat: Double, hgt: Double): ENU {
|
||||
if (useBlhToEnu) {
|
||||
val enu = blhToEnu.wgs84ToEnu(lon = lon, lat = lat, height = hgt)
|
||||
return ENU(enu[0], enu[1], enu[2])
|
||||
}
|
||||
|
||||
if (isFirstPoint) setEnuBenchmark(lon, lat, hgt)
|
||||
rPos[0] = lat * DEG2RAD
|
||||
rPos[1] = lon * DEG2RAD
|
||||
rPos[2] = hgt
|
||||
|
||||
rECEF = pos2ecef(rPos)
|
||||
vECEF[0] = rECEF[0] - bECEF[0]
|
||||
vECEF[1] = rECEF[1] - bECEF[1]
|
||||
vECEF[2] = rECEF[2] - bECEF[2]
|
||||
val enuDoubleArray = ecef2enu(bPos, vECEF)
|
||||
return ENU(enuDoubleArray[0], enuDoubleArray[1], enuDoubleArray[2])
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 WGS84 坐标转换为 ENU (East-North-Up) 坐标
|
||||
* 如果是第一个点,它将被设置为 ENU 坐标系的基准点
|
||||
*
|
||||
* @param wgs84 WGS84 坐标对象
|
||||
* @return 包含 ENU 坐标的 Enu 对象
|
||||
*/
|
||||
fun wgs84ObjectToENU(wgs84: WGS84): ENU = wgs84ToENU(wgs84.lon, wgs84.lat, wgs84.hgt)
|
||||
|
||||
/**
|
||||
* 是否已设置 ENU 坐标系的基准点
|
||||
*/
|
||||
fun isEnuBenchmarkSet(): Boolean = !isFirstPoint
|
||||
|
||||
/**
|
||||
* 设置 ENU 坐标系的基准点
|
||||
*
|
||||
* @param lon 基准点经度(度)
|
||||
* @param lat 基准点纬度(度)
|
||||
* @param hgt 基准点高度(米)
|
||||
*/
|
||||
private fun setEnuBenchmark(lon: Double, lat: Double, hgt: Double) {
|
||||
firstPoint = doubleArrayOf(lon, lat, hgt)
|
||||
bPos[0] = lat * DEG2RAD
|
||||
bPos[1] = lon * DEG2RAD
|
||||
bPos[2] = hgt
|
||||
bECEF = pos2ecef(bPos)
|
||||
isFirstPoint = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 ENU 坐标系的基准点
|
||||
*
|
||||
* @return 包含 WGS84 坐标 {经度, 纬度, 高度} 的 DoubleArray
|
||||
*/
|
||||
fun getEnuBenchmarkPoint(): DoubleArray {
|
||||
if (useBlhToEnu) {
|
||||
return doubleArrayOf(blhToEnu.getOriginLon(), blhToEnu.getOriginLat(), blhToEnu.getOriginHeight())
|
||||
}
|
||||
return firstPoint
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 ENU 坐标系的基准点
|
||||
*
|
||||
* @return 包含 WGS84 坐标的 WGS84 对象
|
||||
*/
|
||||
fun getEnuBenchmarkPointAsWGS84(): WGS84 {
|
||||
if (useBlhToEnu) {
|
||||
return WGS84(blhToEnu.getOriginLon(), blhToEnu.getOriginLat(), blhToEnu.getOriginHeight())
|
||||
}
|
||||
return WGS84(firstPoint[0], firstPoint[1], firstPoint[2])
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置 ENU 基准点
|
||||
* 调用此方法后,下一次 wgs84ToENU 调用将设置新的基准点
|
||||
*/
|
||||
fun resetEnuBenchmarkPoint() {
|
||||
if (useBlhToEnu) {
|
||||
blhToEnu.resetEnuBenchmarkPoint()
|
||||
return
|
||||
}
|
||||
isFirstPoint = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 ENU (East-North-Up) 坐标转换为 WGS84 坐标
|
||||
*
|
||||
* @param enu 包含 ENU 坐标的 Enu 对象
|
||||
* @return 包含 WGS84 坐标 {经度, 纬度, 高度} 的 DoubleArray
|
||||
*/
|
||||
fun enuToWGS84(enu: ENU): DoubleArray {
|
||||
if (useBlhToEnu) {
|
||||
val wgs84 = blhToEnu.enuToWgs84(e = enu.x, n = enu.y, u = enu.z)
|
||||
return doubleArrayOf(wgs84[1], wgs84[0], wgs84[2])
|
||||
}
|
||||
|
||||
val enuArray = doubleArrayOf(enu.x, enu.y, enu.z)
|
||||
val enuToEcefMatrix = xyz2enu(bPos)
|
||||
val ecefArray = matmul(charArrayOf('T', 'N'), 3, 1, 3, 1.0, enuToEcefMatrix, enuArray, 0.0)
|
||||
vECEF[0] = bECEF[0] + ecefArray[0]
|
||||
vECEF[1] = bECEF[1] + ecefArray[1]
|
||||
vECEF[2] = bECEF[2] + ecefArray[2]
|
||||
return ecef2pos(vECEF)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 ENU (East-North-Up) 坐标转换为 WGS84 坐标
|
||||
*
|
||||
* @param enu 包含 ENU 坐标的 Enu 对象
|
||||
* @return 包含 WGS84 坐标的 WGS84 对象
|
||||
*/
|
||||
fun enuToWGS84Object(enu: ENU): WGS84 {
|
||||
val wgs84Array = enuToWGS84(enu)
|
||||
return WGS84(wgs84Array[0], wgs84Array[1], wgs84Array[2])
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 WGS84 坐标转换为 EPSG3857 坐标
|
||||
*
|
||||
* @param lon 经度(度)
|
||||
* @param lat 纬度(度)
|
||||
* @return 包含 EPSG3857 坐标的 EPSG3857 对象
|
||||
*/
|
||||
fun wgs84ToEPSG3857(lon: Double, lat: Double): EPSG3857 {
|
||||
val x = lon * HALF_SIZE / 180
|
||||
var y = RADIUS * ln(tan(Math.PI * (lat + 90) / 360))
|
||||
y = y.coerceIn(-HALF_SIZE, HALF_SIZE)
|
||||
return EPSG3857(x, y)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 WGS84 坐标转换为 EPSG3857 坐标
|
||||
*
|
||||
* @param wgs84 WGS84 坐标对象
|
||||
* @return 包含 EPSG3857 坐标的 EPSG3857 对象
|
||||
*/
|
||||
fun wgs84ObjectToEPSG3857(wgs84: WGS84): EPSG3857 = wgs84ToEPSG3857(wgs84.lon, wgs84.lat)
|
||||
|
||||
/**
|
||||
* 将 EPSG3857 坐标转换为 WGS84 坐标
|
||||
*
|
||||
* @param epsg3857 包含 EPSG3857 坐标的 EPSG3857 对象
|
||||
* @return 包含 WGS84 坐标 {经度, 纬度} 的 DoubleArray
|
||||
*/
|
||||
fun epsg3857ToWGS84(epsg3857: EPSG3857): DoubleArray {
|
||||
val lon = (epsg3857.x / HALF_SIZE) * 180.0
|
||||
val lat = (2 * atan(exp(epsg3857.y / RADIUS)) - Math.PI / 2) * RAD2DEG
|
||||
return doubleArrayOf(lon, lat)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 EPSG3857 坐标转换为 WGS84 坐标
|
||||
*
|
||||
* @param epsg3857 包含 EPSG3857 坐标的 EPSG3857 对象
|
||||
* @return 包含 WGS84 坐标的 WGS84 对象
|
||||
*/
|
||||
fun epsg3857ToWGS84Object(epsg3857: EPSG3857): WGS84 {
|
||||
val wgs84Array = epsg3857ToWGS84(epsg3857)
|
||||
return WGS84(wgs84Array[0], wgs84Array[1], 0.0)
|
||||
}
|
||||
|
||||
fun pos2ecef(pos: DoubleArray): DoubleArray {
|
||||
val (lat, lon, hgt) = pos
|
||||
val sinp = sin(lat)
|
||||
val cosp = cos(lat)
|
||||
val sin_l = sin(lon)
|
||||
val cos_l = cos(lon)
|
||||
val e2 = FE_WGS84 * (2.0 - FE_WGS84)
|
||||
val v = RE_WGS84 / sqrt(1.0 - e2 * sinp * sinp)
|
||||
|
||||
return doubleArrayOf(
|
||||
(v + hgt) * cosp * cos_l,
|
||||
(v + hgt) * cosp * sin_l,
|
||||
(v * (1.0 - e2) + hgt) * sinp
|
||||
)
|
||||
}
|
||||
|
||||
fun ecef2enu(pos: DoubleArray, r: DoubleArray): DoubleArray {
|
||||
val E = xyz2enu(pos)
|
||||
return matmul(charArrayOf('N', 'N'), 3, 1, 3, 1.0, E, r, 0.0)
|
||||
}
|
||||
|
||||
fun matmul(
|
||||
tr: CharArray,
|
||||
n: Int,
|
||||
k: Int,
|
||||
m: Int,
|
||||
alpha: Double,
|
||||
A: DoubleArray,
|
||||
B: DoubleArray,
|
||||
beta: Double
|
||||
): DoubleArray {
|
||||
val f = when {
|
||||
tr[0] == 'N' && tr[1] == 'N' -> 1
|
||||
tr[0] == 'N' && tr[1] == 'T' -> 2
|
||||
tr[0] == 'T' && tr[1] == 'N' -> 3
|
||||
else -> 4
|
||||
}
|
||||
val C = DoubleArray(n * k)
|
||||
for (i in 0 until n) {
|
||||
for (j in 0 until k) {
|
||||
var d = 0.0
|
||||
when (f) {
|
||||
1 -> for (x in 0 until m) d += A[i + x * n] * B[x + j * m]
|
||||
2 -> for (x in 0 until m) d += A[i + x * n] * B[j + x * k]
|
||||
3 -> for (x in 0 until m) d += A[x + i * m] * B[x + j * m]
|
||||
4 -> for (x in 0 until m) d += A[x + i * m] * B[j + x * k]
|
||||
}
|
||||
C[i + j * n] = alpha * d + beta * C[i + j * n]
|
||||
}
|
||||
}
|
||||
return C
|
||||
}
|
||||
|
||||
fun xyz2enu(pos: DoubleArray): DoubleArray {
|
||||
val (lat, lon) = pos
|
||||
val sinp = sin(lat)
|
||||
val cosp = cos(lat)
|
||||
val sin_l = sin(lon)
|
||||
val cos_l = cos(lon)
|
||||
|
||||
return doubleArrayOf(
|
||||
-sin_l, cos_l, 0.0,
|
||||
-sinp * cos_l, -sinp * sin_l, cosp,
|
||||
cosp * cos_l, cosp * sin_l, sinp
|
||||
)
|
||||
}
|
||||
|
||||
fun ecef2pos(ecef: DoubleArray): DoubleArray {
|
||||
val (x, y, z) = ecef
|
||||
val a = RE_WGS84
|
||||
val b = a * (1 - FE_WGS84)
|
||||
val e2 = (a * a - b * b) / (a * a)
|
||||
val e2p = (a * a - b * b) / (b * b)
|
||||
val r2 = x * x + y * y
|
||||
val r = sqrt(r2)
|
||||
val E2 = a * a - b * b
|
||||
val F = 54 * b * b * z * z
|
||||
val G = r2 + (1 - e2) * z * z - e2 * E2
|
||||
val c = (e2 * e2 * F * r2) / (G * G * G)
|
||||
val s = cbrt(1 + c + sqrt(c * c + 2 * c))
|
||||
val P = F / (3 * (s + 1 / s + 1) * (s + 1 / s + 1) * G * G)
|
||||
val Q = sqrt(1 + 2 * e2 * e2 * P)
|
||||
val r0 = -(P * e2 * r) / (1 + Q) + sqrt(0.5 * a * a * (1 + 1.0 / Q) - P * (1 - e2) * z * z / (Q * (1 + Q)) - 0.5 * P * r2)
|
||||
val U = sqrt((r - e2 * r0) * (r - e2 * r0) + z * z)
|
||||
val V = sqrt((r - e2 * r0) * (r - e2 * r0) + (1 - e2) * z * z)
|
||||
val Z0 = b * b * z / (a * V)
|
||||
|
||||
val lon = atan2(y, x) * RAD2DEG
|
||||
val lat = atan((z + e2p * Z0) / r) * RAD2DEG
|
||||
val hgt = U * (1 - b * b / (a * V))
|
||||
return doubleArrayOf(lon, lat, hgt)
|
||||
}
|
||||
|
||||
data class WGS84(var lon: Double = 0.0, var lat: Double = 0.0, var hgt: Double = 0.0) : Parcelable {
|
||||
constructor(parcel: Parcel) : this(
|
||||
parcel.readDouble(),
|
||||
parcel.readDouble(),
|
||||
parcel.readDouble()
|
||||
)
|
||||
|
||||
constructor(wgs84: DoubleArray) : this(
|
||||
lon = wgs84.getOrElse(0) { 0.0 },
|
||||
lat = wgs84.getOrElse(1) { 0.0 },
|
||||
hgt = wgs84.getOrElse(2) { 0.0 }
|
||||
)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeDouble(lon)
|
||||
parcel.writeDouble(lat)
|
||||
parcel.writeDouble(hgt)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int = 0
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<WGS84> {
|
||||
override fun createFromParcel(parcel: Parcel): WGS84 {
|
||||
return WGS84(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<WGS84?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "WGS84(lon=$lon, lat=$lat, hgt=$hgt)"
|
||||
}
|
||||
}
|
||||
|
||||
data class EPSG3857(var x: Double = 0.0, var y: Double = 0.0) : Parcelable {
|
||||
constructor(parcel: Parcel) : this(
|
||||
parcel.readDouble(),
|
||||
parcel.readDouble()
|
||||
)
|
||||
|
||||
constructor(epsG3857: DoubleArray) : this(
|
||||
x = epsG3857.getOrElse(0) { 0.0 },
|
||||
y = epsG3857.getOrElse(1) { 0.0 }
|
||||
)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeDouble(x)
|
||||
parcel.writeDouble(y)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int = 0
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<EPSG3857> {
|
||||
override fun createFromParcel(parcel: Parcel): EPSG3857 {
|
||||
return EPSG3857(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<EPSG3857?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "EPSG3857(x=$x, y=$y)"
|
||||
}
|
||||
}
|
||||
|
||||
data class ENU(var x: Double = 0.0, var y: Double = 0.0, var z: Double = 0.0) : Parcelable {
|
||||
constructor(parcel: Parcel) : this(
|
||||
parcel.readDouble(),
|
||||
parcel.readDouble(),
|
||||
parcel.readDouble()
|
||||
)
|
||||
|
||||
constructor(enu: DoubleArray) : this(
|
||||
x = enu.getOrElse(0) { 0.0 },
|
||||
y = enu.getOrElse(1) { 0.0 },
|
||||
z = enu.getOrElse(2) { 0.0 }
|
||||
)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeDouble(x)
|
||||
parcel.writeDouble(y)
|
||||
parcel.writeDouble(z)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int = 0
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<ENU> {
|
||||
override fun createFromParcel(parcel: Parcel): ENU {
|
||||
return ENU(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<ENU?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "ENU(x=$x, y=$y, z=$z)"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.icegps.common
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
1
icegps-shared/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
57
icegps-shared/build.gradle.kts
Normal file
@@ -0,0 +1,57 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.icegps.shared"
|
||||
compileSdk {
|
||||
version = release(36)
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 28
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions.jvmTarget = JvmTarget.JVM_17
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
|
||||
api(libs.kotlinx.serialization.json)
|
||||
api(libs.ktor.client.core)
|
||||
api(libs.ktor.client.cio)
|
||||
api(libs.ktor.serialization.kotlinx.json)
|
||||
api(libs.ktor.client.content.negotiation)
|
||||
api(libs.ktor.client.logging)
|
||||
api(project(":math"))
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.ext.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
}
|
||||
0
icegps-shared/consumer-rules.pro
Normal file
21
icegps-shared/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.icegps.shared
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.icegps.shared.test", appContext.packageName)
|
||||
}
|
||||
}
|
||||
4
icegps-shared/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.icegps.shared
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.plugins.logging.SIMPLE
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* @author tabidachinokaze
|
||||
* @date 2025/11/20
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
fun SharedHttpClient(json: Json): HttpClient {
|
||||
return HttpClient(CIO) {
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
json = json,
|
||||
contentType = ContentType.Text.Html
|
||||
)
|
||||
json(
|
||||
json = json,
|
||||
contentType = ContentType.Application.Json
|
||||
)
|
||||
}
|
||||
install(Logging) {
|
||||
this.level = LogLevel.ALL
|
||||
this.logger = Logger.SIMPLE
|
||||
}
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 1000 * 60 * 10
|
||||
connectTimeoutMillis = 1000 * 60 * 5
|
||||
socketTimeoutMillis = 1000 * 60 * 10
|
||||
}
|
||||
}
|
||||
}
|
||||
17
icegps-shared/src/main/java/com/icegps/shared/SharedJson.kt
Normal file
@@ -0,0 +1,17 @@
|
||||
package com.icegps.shared
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
|
||||
/**
|
||||
* @author tabidachinokaze
|
||||
* @date 2025/11/20
|
||||
*/
|
||||
@Suppress("FunctionName")
|
||||
fun SharedJson(): Json {
|
||||
return Json {
|
||||
ignoreUnknownKeys = true
|
||||
serializersModule = SerializersModule {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.icegps.shared.api
|
||||
|
||||
import com.icegps.shared.model.IGeoPoint
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LookupResponse(
|
||||
@SerialName("results")
|
||||
val results: List<Result>
|
||||
) {
|
||||
@Serializable
|
||||
data class Result(
|
||||
@SerialName("longitude")
|
||||
override val longitude: Double,
|
||||
@SerialName("latitude")
|
||||
override val latitude: Double,
|
||||
@SerialName("elevation")
|
||||
override val altitude: Double,
|
||||
) : IGeoPoint
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.icegps.shared.api
|
||||
|
||||
import com.icegps.shared.model.IGeoPoint
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.parameter
|
||||
import io.ktor.http.appendPathSegments
|
||||
|
||||
/**
|
||||
* @author tabidachinokaze
|
||||
* @date 2025/11/20
|
||||
*/
|
||||
interface OpenElevationApi {
|
||||
suspend fun lookup(values: List<IGeoPoint>): List<IGeoPoint>
|
||||
}
|
||||
|
||||
class OpenElevation(
|
||||
private val client: HttpClient
|
||||
) : OpenElevationApi {
|
||||
private val baseUrl: String = "https://api.open-elevation.com/api/v1/"
|
||||
|
||||
// curl 'https://api.open-elevation.com/api/v1/lookup?locations=10,10|20,20|41.161758,-8.583933'
|
||||
override suspend fun lookup(values: List<IGeoPoint>): List<IGeoPoint> {
|
||||
val response = client.get(baseUrl) {
|
||||
url {
|
||||
appendPathSegments("lookup")
|
||||
parameter(
|
||||
"locations",
|
||||
values.joinToString("|") { "${it.latitude},${it.longitude}" })
|
||||
}
|
||||
}
|
||||
return response.body<LookupResponse>().results
|
||||
}
|
||||
}
|
||||
7
icegps-shared/src/main/java/com/icegps/shared/ktx/Any.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.icegps.shared.ktx
|
||||
|
||||
/**
|
||||
* @author tabidachinokaze
|
||||
* @date 2025/11/22
|
||||
*/
|
||||
val Any.TAG: String get() = this::class.java.simpleName
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.icegps.shared.model
|
||||
|
||||
/**
|
||||
* @author tabidachinokaze
|
||||
* @date 2025/11/22
|
||||
*/
|
||||
data class GeoPoint(
|
||||
override val longitude: Double,
|
||||
override val latitude: Double,
|
||||
override val altitude: Double
|
||||
) : IGeoPoint
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.icegps.shared.model
|
||||
|
||||
/**
|
||||
* @author tabidachinokaze
|
||||
* @date 2025/11/22
|
||||
*/
|
||||
interface IGeoPoint {
|
||||
val longitude: Double
|
||||
val latitude: Double
|
||||
val altitude: Double
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.icegps.shared
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
1
icegps-triangulation/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
16
icegps-triangulation/build.gradle.kts
Normal file
@@ -0,0 +1,16 @@
|
||||
plugins {
|
||||
id("java-library")
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
}
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation(project(":math"))
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
package com.icegps.triangulation
|
||||
|
||||
import kotlin.math.*
|
||||
|
||||
val EPSILON: Double = 2.0.pow(-52)
|
||||
|
||||
/**
|
||||
* A Kotlin port of Mapbox's Delaunator incredibly fast JavaScript library for Delaunay triangulation of 2D points.
|
||||
*
|
||||
* @description Port of Mapbox's Delaunator (JavaScript) library - https://github.com/mapbox/delaunator
|
||||
* @property coords flat positions' array - [x0, y0, x1, y1..]
|
||||
*
|
||||
* @since f0ed80d - commit
|
||||
* @author Ricardo Matias
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class Delaunator(val coords: DoubleArray) {
|
||||
val EDGE_STACK = IntArray(512)
|
||||
|
||||
private var count = coords.size shr 1
|
||||
|
||||
// arrays that will store the triangulation graph
|
||||
val maxTriangles = (2 * count - 5).coerceAtLeast(0)
|
||||
private val _triangles = IntArray(maxTriangles * 3)
|
||||
private val _halfedges = IntArray(maxTriangles * 3)
|
||||
|
||||
lateinit var triangles: IntArray
|
||||
lateinit var halfedges: IntArray
|
||||
|
||||
// temporary arrays for tracking the edges of the advancing convex hull
|
||||
private var hashSize = ceil(sqrt(count * 1.0)).toInt()
|
||||
private var hullPrev = IntArray(count) // edge to prev edge
|
||||
private var hullNext = IntArray(count) // edge to next edge
|
||||
private var hullTri = IntArray(count) // edge to adjacent triangle
|
||||
private var hullHash = IntArray(hashSize) // angular edge hash
|
||||
private var hullStart: Int = -1
|
||||
|
||||
// temporary arrays for sorting points
|
||||
private var ids = IntArray(count)
|
||||
private var dists = DoubleArray(count)
|
||||
|
||||
private var cx: Double = Double.NaN
|
||||
private var cy: Double = Double.NaN
|
||||
|
||||
private var trianglesLen: Int = -1
|
||||
|
||||
lateinit var hull: IntArray
|
||||
|
||||
init {
|
||||
update()
|
||||
}
|
||||
|
||||
fun update() {
|
||||
if (coords.size <= 2) {
|
||||
halfedges = IntArray(0)
|
||||
triangles = IntArray(0)
|
||||
hull = IntArray(0)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// populate an array of point indices calculate input data bbox
|
||||
var minX = Double.POSITIVE_INFINITY
|
||||
var minY = Double.POSITIVE_INFINITY
|
||||
var maxX = Double.NEGATIVE_INFINITY
|
||||
var maxY = Double.NEGATIVE_INFINITY
|
||||
|
||||
// points -> points
|
||||
// minX, minY, maxX, maxY
|
||||
for (i in 0 until count) {
|
||||
val x = coords[2 * i]
|
||||
val y = coords[2 * i + 1]
|
||||
if (x < minX) minX = x
|
||||
if (y < minY) minY = y
|
||||
if (x > maxX) maxX = x
|
||||
if (y > maxY) maxY = y
|
||||
|
||||
ids[i] = i
|
||||
}
|
||||
|
||||
val cx = (minX + maxX) / 2
|
||||
val cy = (minY + maxY) / 2
|
||||
|
||||
var minDist = Double.POSITIVE_INFINITY
|
||||
|
||||
var i0: Int = -1
|
||||
var i1: Int = -1
|
||||
var i2: Int = -1
|
||||
|
||||
// pick a seed point close to the center
|
||||
for (i in 0 until count) {
|
||||
val d = dist(cx, cy, coords[2 * i], coords[2 * i + 1])
|
||||
|
||||
if (d < minDist) {
|
||||
i0 = i
|
||||
minDist = d
|
||||
}
|
||||
}
|
||||
|
||||
val i0x = coords[2 * i0]
|
||||
val i0y = coords[2 * i0 + 1]
|
||||
|
||||
minDist = Double.POSITIVE_INFINITY
|
||||
|
||||
// Find the point closest to the seed
|
||||
for(i in 0 until count) {
|
||||
if (i == i0) continue
|
||||
|
||||
val d = dist(i0x, i0y, coords[2 * i], coords[2 * i + 1])
|
||||
|
||||
if (d < minDist && d > 0) {
|
||||
i1 = i
|
||||
minDist = d
|
||||
}
|
||||
}
|
||||
|
||||
var i1x = coords[2 * i1]
|
||||
var i1y = coords[2 * i1 + 1]
|
||||
|
||||
var minRadius = Double.POSITIVE_INFINITY
|
||||
|
||||
// Find the third point which forms the smallest circumcircle with the first two
|
||||
for (i in 0 until count) {
|
||||
if(i == i0 || i == i1) continue
|
||||
|
||||
val r = circumradius(i0x, i0y, i1x, i1y, coords[2 * i], coords[2 * i + 1])
|
||||
|
||||
if(r < minRadius) {
|
||||
i2 = i
|
||||
minRadius = r
|
||||
}
|
||||
}
|
||||
|
||||
if (minRadius == Double.POSITIVE_INFINITY) {
|
||||
// order collinear points by dx (or dy if all x are identical)
|
||||
// and return the list as a hull
|
||||
for (i in 0 until count) {
|
||||
val a = (coords[2 * i] - coords[0])
|
||||
val b = (coords[2 * i + 1] - coords[1])
|
||||
dists[i] = if (a == 0.0) b else a
|
||||
}
|
||||
|
||||
quicksort(ids, dists, 0, count - 1)
|
||||
|
||||
val nhull = IntArray(count)
|
||||
var j = 0
|
||||
var d0 = Double.NEGATIVE_INFINITY
|
||||
|
||||
for (i in 0 until count) {
|
||||
val id = ids[i]
|
||||
if (dists[id] > d0) {
|
||||
nhull[j++] = id
|
||||
d0 = dists[id]
|
||||
}
|
||||
}
|
||||
|
||||
hull = nhull.copyOf(j)
|
||||
triangles = IntArray(0)
|
||||
halfedges = IntArray(0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var i2x = coords[2 * i2]
|
||||
var i2y = coords[2 * i2 + 1]
|
||||
|
||||
// swap the order of the seed points for counter-clockwise orientation
|
||||
if (orient2d(i0x, i0y, i1x, i1y, i2x, i2y) < 0.0) {
|
||||
val i = i1
|
||||
val x = i1x
|
||||
val y = i1y
|
||||
i1 = i2
|
||||
i1x = i2x
|
||||
i1y = i2y
|
||||
i2 = i
|
||||
i2x = x
|
||||
i2y = y
|
||||
}
|
||||
|
||||
|
||||
val center = circumcenter(i0x, i0y, i1x, i1y, i2x, i2y)
|
||||
|
||||
this.cx = center[0]
|
||||
this.cy = center[1]
|
||||
|
||||
for (i in 0 until count) {
|
||||
dists[i] = dist(coords[2 * i], coords[2 * i + 1], center[0], center[1])
|
||||
}
|
||||
|
||||
// sort the points by distance from the seed triangle circumcenter
|
||||
quicksort(ids, dists, 0, count - 1)
|
||||
|
||||
// set up the seed triangle as the starting hull
|
||||
hullStart = i0
|
||||
var hullSize = 3
|
||||
|
||||
hullNext[i0] = i1
|
||||
hullNext[i1] = i2
|
||||
hullNext[i2] = i0
|
||||
|
||||
hullPrev[i2] = i1
|
||||
hullPrev[i0] = i2
|
||||
hullPrev[i1] = i0
|
||||
|
||||
hullTri[i0] = 0
|
||||
hullTri[i1] = 1
|
||||
hullTri[i2] = 2
|
||||
|
||||
hullHash.fill(-1)
|
||||
hullHash[hashKey(i0x, i0y)] = i0
|
||||
hullHash[hashKey(i1x, i1y)] = i1
|
||||
hullHash[hashKey(i2x, i2y)] = i2
|
||||
|
||||
trianglesLen = 0
|
||||
addTriangle(i0, i1, i2, -1, -1, -1)
|
||||
|
||||
var xp = 0.0
|
||||
var yp = 0.0
|
||||
|
||||
for (k in ids.indices) {
|
||||
val i = ids[k]
|
||||
val x = coords[2 * i]
|
||||
val y = coords[2 * i + 1]
|
||||
|
||||
// skip near-duplicate points
|
||||
if (k > 0 && abs(x - xp) <= EPSILON && abs(y - yp) <= EPSILON) continue
|
||||
|
||||
xp = x
|
||||
yp = y
|
||||
|
||||
// skip seed triangle points
|
||||
if (i == i0 || i == i1 || i == i2) continue
|
||||
|
||||
// find a visible edge on the convex hull using edge hash
|
||||
var start = 0
|
||||
val key = hashKey(x, y)
|
||||
|
||||
for (j in 0 until hashSize) {
|
||||
start = hullHash[(key + j) % hashSize]
|
||||
|
||||
if (start != -1 && start != hullNext[start]) break
|
||||
}
|
||||
|
||||
start = hullPrev[start]
|
||||
|
||||
var e = start
|
||||
var q = hullNext[e]
|
||||
|
||||
while (orient2d(x, y, coords[2 * e], coords[2 * e + 1], coords[2 * q], coords[2 * q + 1]) >= 0) {
|
||||
e = q
|
||||
|
||||
if (e == start) {
|
||||
e = -1
|
||||
break
|
||||
}
|
||||
|
||||
q = hullNext[e]
|
||||
}
|
||||
|
||||
if (e == -1) continue // likely a near-duplicate point skip it
|
||||
|
||||
// add the first triangle from the point
|
||||
var t = addTriangle(e, i, hullNext[e], -1, -1, hullTri[e])
|
||||
|
||||
// recursively flip triangles from the point until they satisfy the Delaunay condition
|
||||
hullTri[i] = legalize(t + 2)
|
||||
hullTri[e] = t // keep track of boundary triangles on the hull
|
||||
hullSize++
|
||||
|
||||
// walk forward through the hull, adding more triangles and flipping recursively
|
||||
var next = hullNext[e]
|
||||
q = hullNext[next]
|
||||
|
||||
while (orient2d(x, y, coords[2 * next], coords[2 * next + 1], coords[2 * q], coords[2 * q + 1]) < 0) {
|
||||
t = addTriangle(next, i, q, hullTri[i], -1, hullTri[next])
|
||||
hullTri[i] = legalize(t + 2)
|
||||
hullNext[next] = next // mark as removed
|
||||
hullSize--
|
||||
|
||||
next = q
|
||||
q = hullNext[next]
|
||||
}
|
||||
|
||||
// walk backward from the other side, adding more triangles and flipping
|
||||
if (e == start) {
|
||||
q = hullPrev[e]
|
||||
|
||||
while (orient2d(x, y, coords[2 * q], coords[2 * q + 1], coords[2 * e], coords[2 * e + 1]) < 0) {
|
||||
t = addTriangle(q, i, e, -1, hullTri[e], hullTri[q])
|
||||
legalize(t + 2)
|
||||
hullTri[q] = t
|
||||
hullNext[e] = e // mark as removed
|
||||
hullSize--
|
||||
|
||||
e = q
|
||||
q = hullPrev[e]
|
||||
}
|
||||
}
|
||||
|
||||
// update the hull indices
|
||||
hullStart = e
|
||||
hullPrev[i] = e
|
||||
|
||||
hullNext[e] = i
|
||||
hullPrev[next] = i
|
||||
hullNext[i] = next
|
||||
|
||||
// save the two new edges in the hash table
|
||||
hullHash[hashKey(x, y)] = i
|
||||
hullHash[hashKey(coords[2 * e], coords[2 * e + 1])] = e
|
||||
}
|
||||
|
||||
hull = IntArray(hullSize)
|
||||
|
||||
var e = hullStart
|
||||
|
||||
for (i in 0 until hullSize) {
|
||||
hull[i] = e
|
||||
e = hullNext[e]
|
||||
}
|
||||
|
||||
// trim typed triangle mesh arrays
|
||||
triangles = _triangles.copyOf(trianglesLen)
|
||||
halfedges = _halfedges.copyOf(trianglesLen)
|
||||
}
|
||||
|
||||
private fun legalize(a: Int): Int {
|
||||
var i = 0
|
||||
var na = a
|
||||
var ar: Int
|
||||
|
||||
// recursion eliminated with a fixed-size stack
|
||||
while (true) {
|
||||
val b = _halfedges[na]
|
||||
|
||||
/* if the pair of triangles doesn't satisfy the Delaunay condition
|
||||
* (p1 is inside the circumcircle of [p0, pl, pr]), flip them,
|
||||
* then do the same check/flip recursively for the new pair of triangles
|
||||
*
|
||||
* pl pl
|
||||
* /||\ / \
|
||||
* al/ || \bl al/ \a
|
||||
* / || \ / \
|
||||
* / a||b \ flip /___ar___\
|
||||
* p0\ || /p1 => p0\---bl---/p1
|
||||
* \ || / \ /
|
||||
* ar\ || /br b\ /br
|
||||
* \||/ \ /
|
||||
* pr pr
|
||||
*/
|
||||
val a0 = na - na % 3
|
||||
ar = a0 + (na + 2) % 3
|
||||
|
||||
if (b == -1) { // convex hull edge
|
||||
if (i == 0) break
|
||||
na = EDGE_STACK[--i]
|
||||
continue
|
||||
}
|
||||
|
||||
val b0 = b - b % 3
|
||||
val al = a0 + (na + 1) % 3
|
||||
val bl = b0 + (b + 2) % 3
|
||||
|
||||
val p0 = _triangles[ar]
|
||||
val pr = _triangles[na]
|
||||
val pl = _triangles[al]
|
||||
val p1 = _triangles[bl]
|
||||
|
||||
val illegal = inCircleRobust(
|
||||
coords[2 * p0], coords[2 * p0 + 1],
|
||||
coords[2 * pr], coords[2 * pr + 1],
|
||||
coords[2 * pl], coords[2 * pl + 1],
|
||||
coords[2 * p1], coords[2 * p1 + 1])
|
||||
|
||||
if (illegal) {
|
||||
_triangles[na] = p1
|
||||
_triangles[b] = p0
|
||||
|
||||
val hbl = _halfedges[bl]
|
||||
|
||||
// edge swapped on the other side of the hull (rare) fix the halfedge reference
|
||||
if (hbl == -1) {
|
||||
var e = hullStart
|
||||
do {
|
||||
if (hullTri[e] == bl) {
|
||||
hullTri[e] = na
|
||||
break
|
||||
}
|
||||
e = hullPrev[e]
|
||||
} while (e != hullStart)
|
||||
}
|
||||
link(na, hbl)
|
||||
link(b, _halfedges[ar])
|
||||
link(ar, bl)
|
||||
|
||||
val br = b0 + (b + 1) % 3
|
||||
|
||||
// don't worry about hitting the cap: it can only happen on extremely degenerate input
|
||||
if (i < EDGE_STACK.size) {
|
||||
EDGE_STACK[i++] = br
|
||||
}
|
||||
} else {
|
||||
if (i == 0) break
|
||||
na = EDGE_STACK[--i]
|
||||
}
|
||||
}
|
||||
|
||||
return ar
|
||||
|
||||
}
|
||||
|
||||
private fun link(a:Int, b:Int) {
|
||||
_halfedges[a] = b
|
||||
if (b != -1) _halfedges[b] = a
|
||||
}
|
||||
|
||||
// add a new triangle given vertex indices and adjacent half-edge ids
|
||||
private fun addTriangle(i0: Int, i1: Int, i2: Int, a: Int, b: Int, c: Int): Int {
|
||||
val t = trianglesLen
|
||||
|
||||
_triangles[t] = i0
|
||||
_triangles[t + 1] = i1
|
||||
_triangles[t + 2] = i2
|
||||
|
||||
link(t, a)
|
||||
link(t + 1, b)
|
||||
link(t + 2, c)
|
||||
|
||||
trianglesLen += 3
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
private fun hashKey(x: Double, y: Double): Int {
|
||||
return (floor(pseudoAngle(x - cx, y - cy) * hashSize) % hashSize).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
fun circumradius(ax: Double, ay: Double,
|
||||
bx: Double, by: Double,
|
||||
cx: Double, cy: Double): Double {
|
||||
val dx = bx - ax
|
||||
val dy = by - ay
|
||||
val ex = cx - ax
|
||||
val ey = cy - ay
|
||||
|
||||
val bl = dx * dx + dy * dy
|
||||
val cl = ex * ex + ey * ey
|
||||
val d = 0.5 / (dx * ey - dy * ex)
|
||||
|
||||
val x = (ey * bl - dy * cl) * d
|
||||
val y = (dx * cl - ex * bl) * d
|
||||
|
||||
return x * x + y * y
|
||||
}
|
||||
|
||||
fun circumcenter(ax: Double, ay: Double,
|
||||
bx: Double, by: Double,
|
||||
cx: Double, cy: Double): DoubleArray {
|
||||
val dx = bx - ax
|
||||
val dy = by - ay
|
||||
val ex = cx - ax
|
||||
val ey = cy - ay
|
||||
|
||||
val bl = dx * dx + dy * dy
|
||||
val cl = ex * ex + ey * ey
|
||||
val d = 0.5 / (dx * ey - dy * ex)
|
||||
|
||||
val x = ax + (ey * bl - dy * cl) * d
|
||||
val y = ay + (dx * cl - ex * bl) * d
|
||||
|
||||
return doubleArrayOf(x, y)
|
||||
}
|
||||
|
||||
fun quicksort(ids: IntArray, dists: DoubleArray, left: Int, right: Int) {
|
||||
if (right - left <= 20) {
|
||||
for (i in (left + 1)..right) {
|
||||
val temp = ids[i]
|
||||
val tempDist = dists[temp]
|
||||
var j = i - 1
|
||||
while (j >= left && dists[ids[j]] > tempDist) ids[j + 1] = ids[j--]
|
||||
ids[j + 1] = temp
|
||||
}
|
||||
} else {
|
||||
val median = (left + right) shr 1
|
||||
var i = left + 1
|
||||
var j = right
|
||||
|
||||
swap(ids, median, i)
|
||||
|
||||
if (dists[ids[left]] > dists[ids[right]]) swap(ids, left, right)
|
||||
if (dists[ids[i]] > dists[ids[right]]) swap(ids, i, right)
|
||||
if (dists[ids[left]] > dists[ids[i]]) swap(ids, left, i)
|
||||
|
||||
val temp = ids[i]
|
||||
val tempDist = dists[temp]
|
||||
|
||||
while (true) {
|
||||
do i++ while (dists[ids[i]] < tempDist)
|
||||
do j-- while (dists[ids[j]] > tempDist)
|
||||
if (j < i) break
|
||||
swap(ids, i, j)
|
||||
}
|
||||
|
||||
ids[left + 1] = ids[j]
|
||||
ids[j] = temp
|
||||
|
||||
if (right - i + 1 >= j - left) {
|
||||
quicksort(ids, dists, i, right)
|
||||
quicksort(ids, dists, left, j - 1)
|
||||
} else {
|
||||
quicksort(ids, dists, left, j - 1)
|
||||
quicksort(ids, dists, i, right)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun swap(arr: IntArray, i: Int, j: Int) {
|
||||
val tmp = arr[i]
|
||||
arr[i] = arr[j]
|
||||
arr[j] = tmp
|
||||
}
|
||||
|
||||
// monotonically increases with real angle, but doesn't need expensive trigonometry
|
||||
private fun pseudoAngle(dx: Double, dy: Double): Double {
|
||||
val p = dx / (abs(dx) + abs(dy))
|
||||
val a = if (dy > 0.0) 3.0 - p else 1.0 + p
|
||||
|
||||
return a / 4.0 // [0..1]
|
||||
}
|
||||
|
||||
private fun inCircle(ax: Double, ay: Double,
|
||||
bx: Double, by: Double,
|
||||
cx: Double, cy: Double,
|
||||
px: Double, py: Double): Boolean {
|
||||
val dx = ax - px
|
||||
val dy = ay - py
|
||||
val ex = bx - px
|
||||
val ey = by - py
|
||||
val fx = cx - px
|
||||
val fy = cy - py
|
||||
|
||||
val ap = dx * dx + dy * dy
|
||||
val bp = ex * ex + ey * ey
|
||||
val cp = fx * fx + fy * fy
|
||||
|
||||
return dx * (ey * cp - bp * fy) -
|
||||
dy * (ex * cp - bp * fx) +
|
||||
ap * (ex * fy - ey * fx) < 0
|
||||
}
|
||||
|
||||
private fun inCircleRobust(
|
||||
ax: Double, ay: Double,
|
||||
bx: Double, by: Double,
|
||||
cx: Double, cy: Double,
|
||||
px: Double, py: Double
|
||||
): Boolean {
|
||||
|
||||
val dx = twoDiff(ax, px)
|
||||
val dy = twoDiff(ay, py)
|
||||
val ex = twoDiff(bx, px)
|
||||
val ey = twoDiff(by, py)
|
||||
val fx = twoDiff(cx, px)
|
||||
val fy = twoDiff(cy, py)
|
||||
|
||||
val ap = ddAddDd(ddMultDd(dx, dx), ddMultDd(dy, dy))
|
||||
val bp = ddAddDd(ddMultDd(ex, ex), ddMultDd(ey, ey))
|
||||
val cp = ddAddDd(ddMultDd(fx, fx), ddMultDd(fy, fy))
|
||||
|
||||
val dd = ddAddDd(
|
||||
ddDiffDd(
|
||||
ddMultDd(dx, ddDiffDd(ddMultDd(ey, cp), ddMultDd(bp, fy))),
|
||||
ddMultDd(dy, ddDiffDd(ddMultDd(ex, cp), ddMultDd(bp, fx)))
|
||||
),
|
||||
ddMultDd(ap, ddDiffDd(ddMultDd(ex, fy), ddMultDd(ey, fx)))
|
||||
)
|
||||
return (dd[1]) <= 0
|
||||
}
|
||||
|
||||
|
||||
private fun dist(ax: Double, ay: Double, bx: Double, by: Double): Double {
|
||||
//val dx = ax - bx
|
||||
//val dy = ay - by
|
||||
//return dx * dx + dy * dy
|
||||
|
||||
// double-double implementation but I think it is overkill.
|
||||
|
||||
val dx = twoDiff(ax, bx)
|
||||
val dy = twoDiff(ay, by)
|
||||
val dx2 = ddMultDd(dx, dx)
|
||||
val dy2 = ddMultDd(dy, dy)
|
||||
val d2 = ddAddDd(dx2, dy2)
|
||||
|
||||
return d2[0] + d2[1]
|
||||
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package com.icegps.triangulation
|
||||
|
||||
import com.icegps.math.geometry.Vector2D
|
||||
import com.icegps.triangulation.Delaunay.Companion.from
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sin
|
||||
|
||||
/*
|
||||
ISC License
|
||||
|
||||
Copyright 2021 Ricardo Matias.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose
|
||||
with or without fee is hereby granted, provided that the above copyright notice
|
||||
and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
||||
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
||||
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||
THIS SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Use [from] static method to use the delaunay triangulation
|
||||
*
|
||||
* @description Port of d3-delaunay (JavaScript) library - https://github.com/d3/d3-delaunay
|
||||
* @property points flat positions' array - [x0, y0, x1, y1..]
|
||||
*
|
||||
* @since 9258fa3 - commit
|
||||
* @author Ricardo Matias
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class Delaunay(val points: DoubleArray) {
|
||||
companion object {
|
||||
/**
|
||||
* Entry point for the delaunay triangulation
|
||||
*
|
||||
* @property points a list of 2D points
|
||||
*/
|
||||
fun from(points: List<Vector2D>): Delaunay {
|
||||
val n = points.size
|
||||
val coords = DoubleArray(n * 2)
|
||||
|
||||
for (i in points.indices) {
|
||||
val p = points[i]
|
||||
coords[2 * i] = p.x
|
||||
coords[2 * i + 1] = p.y
|
||||
}
|
||||
|
||||
return Delaunay(coords)
|
||||
}
|
||||
}
|
||||
|
||||
private var delaunator: Delaunator = Delaunator(points)
|
||||
|
||||
val inedges = IntArray(points.size / 2)
|
||||
private val hullIndex = IntArray(points.size / 2)
|
||||
|
||||
var halfedges: IntArray = delaunator.halfedges
|
||||
var hull: IntArray = delaunator.hull
|
||||
var triangles: IntArray = delaunator.triangles
|
||||
|
||||
init {
|
||||
init()
|
||||
}
|
||||
|
||||
fun update() {
|
||||
delaunator.update()
|
||||
init()
|
||||
}
|
||||
|
||||
fun neighbors(i: Int) = sequence<Int> {
|
||||
val e0 = inedges[i]
|
||||
if (e0 != -1) {
|
||||
var e = e0
|
||||
var p0 = -1
|
||||
|
||||
loop@ do {
|
||||
p0 = triangles[e]
|
||||
yield(p0)
|
||||
e = if (e % 3 == 2) e - 2 else e + 1
|
||||
if (e == -1) {
|
||||
break@loop
|
||||
}
|
||||
|
||||
if (triangles[e] != i) {
|
||||
break@loop
|
||||
//error("bad triangulation")
|
||||
}
|
||||
e = halfedges[e]
|
||||
|
||||
if (e == -1) {
|
||||
val p = hull[(hullIndex[i] + 1) % hull.size]
|
||||
if (p != p0) {
|
||||
yield(p)
|
||||
}
|
||||
break@loop
|
||||
}
|
||||
} while (e != e0)
|
||||
}
|
||||
}
|
||||
|
||||
fun collinear(): Boolean {
|
||||
for (i in 0 until triangles.size step 3) {
|
||||
val a = 2 * triangles[i]
|
||||
val b = 2 * triangles[i + 1]
|
||||
val c = 2 * triangles[i + 2]
|
||||
val coords = points
|
||||
val cross = (coords[c] - coords[a]) * (coords[b + 1] - coords[a + 1])
|
||||
-(coords[b] - coords[a]) * (coords[c + 1] - coords[a + 1])
|
||||
if (cross > 1e-10) return false;
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun jitter(x: Double, y: Double, r: Double): DoubleArray {
|
||||
return doubleArrayOf(x + sin(x + y) * r, y + cos(x - y) * r)
|
||||
}
|
||||
|
||||
fun init() {
|
||||
|
||||
if (hull.size > 2 && collinear()) {
|
||||
println("warning: triangulation is collinear")
|
||||
val r = 1E-8
|
||||
for (i in 0 until points.size step 2) {
|
||||
val p = jitter(points[i], points[i + 1], r)
|
||||
points[i] = p[0]
|
||||
points[i + 1] = p[1]
|
||||
}
|
||||
|
||||
delaunator = Delaunator(points)
|
||||
halfedges = delaunator.halfedges
|
||||
hull = delaunator.hull
|
||||
triangles = delaunator.triangles
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
inedges.fill(-1)
|
||||
hullIndex.fill(-1)
|
||||
|
||||
// Compute an index from each point to an (arbitrary) incoming halfedge
|
||||
// Used to give the first neighbor of each point for this reason,
|
||||
// on the hull we give priority to exterior halfedges
|
||||
for (e in halfedges.indices) {
|
||||
val p = triangles[nextHalfedge(e)]
|
||||
|
||||
if (halfedges[e] == -1 || inedges[p] == -1) inedges[p] = e
|
||||
}
|
||||
|
||||
for (i in hull.indices) {
|
||||
hullIndex[hull[i]] = i
|
||||
}
|
||||
|
||||
// degenerate case: 1 or 2 (distinct) points
|
||||
if (hull.size in 1..2) {
|
||||
triangles = IntArray(3) { -1 }
|
||||
halfedges = IntArray(3) { -1 }
|
||||
triangles[0] = hull[0]
|
||||
inedges[hull[0]] = 1
|
||||
if (hull.size == 2) {
|
||||
inedges[hull[1]] = 0
|
||||
triangles[1] = hull[1]
|
||||
triangles[2] = hull[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun find(x: Double, y: Double, i: Int = 0): Int {
|
||||
var i1 = i
|
||||
var c = step(i, x, y)
|
||||
|
||||
while (c >= 0 && c != i && c != i1) {
|
||||
i1 = c
|
||||
c = step(i1, x, y)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
fun nextHalfedge(e: Int) = if (e % 3 == 2) e - 2 else e + 1
|
||||
fun prevHalfedge(e: Int) = if (e % 3 == 0) e + 2 else e - 1
|
||||
|
||||
fun step(i: Int, x: Double, y: Double): Int {
|
||||
if (inedges[i] == -1 || points.isEmpty()) return (i + 1) % (points.size shr 1)
|
||||
|
||||
var c = i
|
||||
var dc = (x - points[i * 2]).pow(2) + (y - points[i * 2 + 1]).pow(2)
|
||||
val e0 = inedges[i]
|
||||
var e = e0
|
||||
do {
|
||||
val t = triangles[e]
|
||||
val dt = (x - points[t * 2]).pow(2) + (y - points[t * 2 + 1]).pow(2)
|
||||
|
||||
if (dt < dc) {
|
||||
dc = dt
|
||||
c = t
|
||||
}
|
||||
|
||||
e = if (e % 3 == 2) e - 2 else e + 1
|
||||
|
||||
if (triangles[e] != i) {
|
||||
//error("bad triangulation")
|
||||
break
|
||||
} // bad triangulation
|
||||
|
||||
e = halfedges[e]
|
||||
|
||||
if (e == -1) {
|
||||
e = hull[(hullIndex[i] + 1) % hull.size]
|
||||
if (e != t) {
|
||||
if ((x - points[e * 2]).pow(2) + (y - points[e * 2 + 1]).pow(2) < dc) return e
|
||||
}
|
||||
break
|
||||
}
|
||||
} while (e != e0)
|
||||
|
||||
return c
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.icegps.triangulation
|
||||
|
||||
import com.icegps.math.geometry.Vector3D
|
||||
import com.icegps.math.geometry.toVector2D
|
||||
|
||||
/**
|
||||
* Kotlin/OPENRNDR idiomatic interface to `Delaunay`
|
||||
*/
|
||||
class DelaunayTriangulation(val points: List<Vector3D>) {
|
||||
val delaunay: Delaunay = Delaunay.from(points.map { it.toVector2D() })
|
||||
|
||||
fun neighbors(pointIndex: Int): Sequence<Int> {
|
||||
return delaunay.neighbors(pointIndex)
|
||||
}
|
||||
|
||||
fun neighborPoints(pointIndex: Int): List<Vector3D> {
|
||||
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 }): List<Triangle> {
|
||||
val list = mutableListOf<Triangle>()
|
||||
|
||||
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(Triangle(p3, p2, p1))
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
fun nearest(query: Vector3D): Int = delaunay.find(query.x, query.y)
|
||||
|
||||
fun nearestPoint(query: Vector3D): Vector3D = 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<Vector3D>.delaunayTriangulation(): DelaunayTriangulation {
|
||||
return DelaunayTriangulation(this)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package com.icegps.triangulation
|
||||
|
||||
import kotlin.math.pow
|
||||
|
||||
// original code: https://github.com/FlorisSteenkamp/double-double/
|
||||
|
||||
|
||||
/**
|
||||
* Returns the difference and exact error of subtracting two floating point
|
||||
* numbers.
|
||||
* Uses an EFT (error-free transformation), i.e. `a-b === x+y` exactly.
|
||||
* The returned result is a non-overlapping expansion (smallest value first!).
|
||||
*
|
||||
* * **precondition:** `abs(a) >= abs(b)` - A fast test that can be used is
|
||||
* `(a > b) === (a > -b)`
|
||||
*
|
||||
* See https://people.eecs.berkeley.edu/~jrs/papers/robustr.pdf
|
||||
*/
|
||||
fun fastTwoDiff(a: Double, b: Double): DoubleArray {
|
||||
val x = a - b;
|
||||
val y = (a - x) - b;
|
||||
|
||||
return doubleArrayOf(y, x)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the sum and exact error of adding two floating point numbers.
|
||||
* Uses an EFT (error-free transformation), i.e. a+b === x+y exactly.
|
||||
* The returned sum is a non-overlapping expansion (smallest value first!).
|
||||
*
|
||||
* Precondition: abs(a) >= abs(b) - A fast test that can be used is
|
||||
* (a > b) === (a > -b)
|
||||
*
|
||||
* See https://people.eecs.berkeley.edu/~jrs/papers/robustr.pdf
|
||||
*/
|
||||
fun fastTwoSum(a: Double, b: Double): DoubleArray {
|
||||
val x = a + b;
|
||||
|
||||
return doubleArrayOf(b - (x - a), x)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Truncates a floating point value's significand and returns the result.
|
||||
* Similar to split, but with the ability to specify the number of bits to keep.
|
||||
*
|
||||
* **Theorem 17 (Veltkamp-Dekker)**: Let a be a p-bit floating-point number, where
|
||||
* p >= 3. Choose a splitting point s such that p/2 <= s <= p-1. Then the
|
||||
* following algorithm will produce a (p-s)-bit value a_hi and a
|
||||
* nonoverlapping (s-1)-bit value a_lo such that abs(a_hi) >= abs(a_lo) and
|
||||
* a = a_hi + a_lo.
|
||||
*
|
||||
* * see [Shewchuk](https://people.eecs.berkeley.edu/~jrs/papers/robustr.pdf)
|
||||
*
|
||||
* @param a a double
|
||||
* @param bits the number of significand bits to leave intact
|
||||
*/
|
||||
fun reduceSignificand(
|
||||
a: Double,
|
||||
bits: Int
|
||||
): Double {
|
||||
|
||||
val s = 53 - bits;
|
||||
val f = 2.0.pow(s) + 1;
|
||||
|
||||
val c = f * a;
|
||||
val r = c - (c - a);
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* === 2^Math.ceil(p/2) + 1 where p is the # of significand bits in a double === 53.
|
||||
* @internal
|
||||
*/
|
||||
const val f = 134217729; // 2**27 + 1;
|
||||
|
||||
|
||||
/**
|
||||
* Returns the result of splitting a double into 2 26-bit doubles.
|
||||
*
|
||||
* Theorem 17 (Veltkamp-Dekker): Let a be a p-bit floating-point number, where
|
||||
* p >= 3. Choose a splitting point s such that p/2 <= s <= p-1. Then the
|
||||
* following algorithm will produce a (p-s)-bit value a_hi and a
|
||||
* nonoverlapping (s-1)-bit value a_lo such that abs(a_hi) >= abs(a_lo) and
|
||||
* a = a_hi + a_lo.
|
||||
*
|
||||
* see e.g. [Shewchuk](https://people.eecs.berkeley.edu/~jrs/papers/robustr.pdf)
|
||||
* @param a A double floating point number
|
||||
*/
|
||||
fun split(a: Double): DoubleArray {
|
||||
val c = f * a;
|
||||
val a_h = c - (c - a);
|
||||
val a_l = a - a_h;
|
||||
|
||||
return doubleArrayOf(a_h, a_l)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the exact result of subtracting b from a.
|
||||
*
|
||||
* @param a minuend - a double-double precision floating point number
|
||||
* @param b subtrahend - a double-double precision floating point number
|
||||
*/
|
||||
fun twoDiff(a: Double, b: Double): DoubleArray {
|
||||
val x = a - b;
|
||||
val bvirt = a - x;
|
||||
val y = (a - (x + bvirt)) + (bvirt - b);
|
||||
|
||||
return doubleArrayOf(y, x)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the exact result of multiplying two doubles.
|
||||
*
|
||||
* * the resulting array is the reverse of the standard twoSum in the literature.
|
||||
*
|
||||
* Theorem 18 (Shewchuk): Let a and b be p-bit floating-point numbers, where
|
||||
* p >= 6. Then the following algorithm will produce a nonoverlapping expansion
|
||||
* x + y such that ab = x + y, where x is an approximation to ab and y
|
||||
* represents the roundoff error in the calculation of x. Furthermore, if
|
||||
* round-to-even tiebreaking is used, x and y are non-adjacent.
|
||||
*
|
||||
* See https://people.eecs.berkeley.edu/~jrs/papers/robustr.pdf
|
||||
* @param a A double
|
||||
* @param b Another double
|
||||
*/
|
||||
fun twoProduct(a: Double, b: Double): DoubleArray {
|
||||
val x = a * b;
|
||||
|
||||
//const [ah, al] = split(a);
|
||||
val c = f * a;
|
||||
val ah = c - (c - a);
|
||||
val al = a - ah;
|
||||
//const [bh, bl] = split(b);
|
||||
val d = f * b;
|
||||
val bh = d - (d - b);
|
||||
val bl = b - bh;
|
||||
|
||||
val y = (al * bl) - ((x - (ah * bh)) - (al * bh) - (ah * bl));
|
||||
|
||||
//const err1 = x - (ah * bh);
|
||||
//const err2 = err1 - (al * bh);
|
||||
//const err3 = err2 - (ah * bl);
|
||||
//const y = (al * bl) - err3;
|
||||
|
||||
return doubleArrayOf(y, x)
|
||||
}
|
||||
|
||||
fun twoSquare(a: Double): DoubleArray {
|
||||
val x = a * a;
|
||||
|
||||
//const [ah, al] = split(a);
|
||||
val c = f * a;
|
||||
val ah = c - (c - a);
|
||||
val al = a - ah;
|
||||
|
||||
val y = (al * al) - ((x - (ah * ah)) - 2 * (ah * al));
|
||||
|
||||
return doubleArrayOf(y, x)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the exact result of adding two doubles.
|
||||
*
|
||||
* * the resulting array is the reverse of the standard twoSum in the literature.
|
||||
*
|
||||
* Theorem 7 (Knuth): Let a and b be p-bit floating-point numbers. Then the
|
||||
* following algorithm will produce a nonoverlapping expansion x + y such that
|
||||
* a + b = x + y, where x is an approximation to a + b and y is the roundoff
|
||||
* error in the calculation of x.
|
||||
*
|
||||
* See https://people.eecs.berkeley.edu/~jrs/papers/robustr.pdf
|
||||
*/
|
||||
fun twoSum(a: Double, b: Double): DoubleArray {
|
||||
val x = a + b;
|
||||
val bv = x - a;
|
||||
|
||||
return doubleArrayOf((a - (x - bv)) + (b - bv), x)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the result of subtracting the second given double-double-precision
|
||||
* floating point number from the first.
|
||||
*
|
||||
* * relative error bound: 3u^2 + 13u^3, i.e. fl(a-b) = (a-b)(1+ϵ),
|
||||
* where ϵ <= 3u^2 + 13u^3, u = 0.5 * Number.EPSILON
|
||||
* * the error bound is not sharp - the worst case that could be found by the
|
||||
* authors were 2.25u^2
|
||||
*
|
||||
* ALGORITHM 6 of https://hal.archives-ouvertes.fr/hal-01351529v3/document
|
||||
* @param x a double-double precision floating point number
|
||||
* @param y another double-double precision floating point number
|
||||
*/
|
||||
fun ddDiffDd(x: DoubleArray, y: DoubleArray): DoubleArray {
|
||||
val xl = x[0];
|
||||
val xh = x[1];
|
||||
val yl = y[0];
|
||||
val yh = y[1];
|
||||
|
||||
//const [sl,sh] = twoSum(xh,yh);
|
||||
val sh = xh - yh;
|
||||
val _1 = sh - xh;
|
||||
val sl = (xh - (sh - _1)) + (-yh - _1);
|
||||
//const [tl,th] = twoSum(xl,yl);
|
||||
val th = xl - yl;
|
||||
val _2 = th - xl;
|
||||
val tl = (xl - (th - _2)) + (-yl - _2);
|
||||
val c = sl + th;
|
||||
//const [vl,vh] = fastTwoSum(sh,c)
|
||||
val vh = sh + c;
|
||||
val vl = c - (vh - sh);
|
||||
val w = tl + vl
|
||||
//const [zl,zh] = fastTwoSum(vh,w)
|
||||
val zh = vh + w;
|
||||
val zl = w - (zh - vh);
|
||||
|
||||
return doubleArrayOf(zl, zh)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the product of two double-double-precision floating point numbers.
|
||||
*
|
||||
* * relative error bound: 7u^2, i.e. fl(a+b) = (a+b)(1+ϵ),
|
||||
* where ϵ <= 7u^2, u = 0.5 * Number.EPSILON
|
||||
* the error bound is not sharp - the worst case that could be found by the
|
||||
* authors were 5u^2
|
||||
*
|
||||
* * ALGORITHM 10 of https://hal.archives-ouvertes.fr/hal-01351529v3/document
|
||||
* @param x a double-double precision floating point number
|
||||
* @param y another double-double precision floating point number
|
||||
*/
|
||||
fun ddMultDd(x: DoubleArray, y: DoubleArray): DoubleArray {
|
||||
|
||||
|
||||
//const xl = x[0];
|
||||
val xh = x[1];
|
||||
//const yl = y[0];
|
||||
val yh = y[1];
|
||||
|
||||
//const [cl1,ch] = twoProduct(xh,yh);
|
||||
val ch = xh * yh;
|
||||
val c = f * xh;
|
||||
val ah = c - (c - xh);
|
||||
val al = xh - ah;
|
||||
val d = f * yh;
|
||||
val bh = d - (d - yh);
|
||||
val bl = yh - bh;
|
||||
val cl1 = (al * bl) - ((ch - (ah * bh)) - (al * bh) - (ah * bl));
|
||||
|
||||
//return fastTwoSum(ch,cl1 + (xh*yl + xl*yh));
|
||||
val b = cl1 + (xh * y[0] + x[0] * yh);
|
||||
val xx = ch + b;
|
||||
|
||||
return doubleArrayOf(b - (xx - ch), xx)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the result of adding two double-double-precision floating point
|
||||
* numbers.
|
||||
*
|
||||
* * relative error bound: 3u^2 + 13u^3, i.e. fl(a+b) = (a+b)(1+ϵ),
|
||||
* where ϵ <= 3u^2 + 13u^3, u = 0.5 * Number.EPSILON
|
||||
* * the error bound is not sharp - the worst case that could be found by the
|
||||
* authors were 2.25u^2
|
||||
*
|
||||
* ALGORITHM 6 of https://hal.archives-ouvertes.fr/hal-01351529v3/document
|
||||
* @param x a double-double precision floating point number
|
||||
* @param y another double-double precision floating point number
|
||||
*/
|
||||
fun ddAddDd(x: DoubleArray, y: DoubleArray): DoubleArray {
|
||||
val xl = x[0];
|
||||
val xh = x[1];
|
||||
val yl = y[0];
|
||||
val yh = y[1];
|
||||
|
||||
//const [sl,sh] = twoSum(xh,yh);
|
||||
val sh = xh + yh;
|
||||
val _1 = sh - xh;
|
||||
val sl = (xh - (sh - _1)) + (yh - _1);
|
||||
//val [tl,th] = twoSum(xl,yl);
|
||||
val th = xl + yl;
|
||||
val _2 = th - xl;
|
||||
val tl = (xl - (th - _2)) + (yl - _2);
|
||||
val c = sl + th;
|
||||
//val [vl,vh] = fastTwoSum(sh,c)
|
||||
val vh = sh + c;
|
||||
val vl = c - (vh - sh);
|
||||
val w = tl + vl
|
||||
//val [zl,zh] = fastTwoSum(vh,w)
|
||||
val zh = vh + w;
|
||||
val zl = w - (zh - vh);
|
||||
|
||||
return doubleArrayOf(zl, zh)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the product of a double-double-precision floating point number and a
|
||||
* double.
|
||||
*
|
||||
* * slower than ALGORITHM 8 (one call to fastTwoSum more) but about 2x more
|
||||
* accurate
|
||||
* * relative error bound: 1.5u^2 + 4u^3, i.e. fl(a+b) = (a+b)(1+ϵ),
|
||||
* where ϵ <= 1.5u^2 + 4u^3, u = 0.5 * Number.EPSILON
|
||||
* * the bound is very sharp
|
||||
* * probably prefer `ddMultDouble2` due to extra speed
|
||||
*
|
||||
* * ALGORITHM 7 of https://hal.archives-ouvertes.fr/hal-01351529v3/document
|
||||
* @param y a double
|
||||
* @param x a double-double precision floating point number
|
||||
*/
|
||||
fun ddMultDouble1(y: Double, x: DoubleArray): DoubleArray {
|
||||
val xl = x[0];
|
||||
val xh = x[1];
|
||||
|
||||
//val [cl1,ch] = twoProduct(xh,y);
|
||||
val ch = xh * y;
|
||||
val c = f * xh;
|
||||
val ah = c - (c - xh);
|
||||
val al = xh - ah;
|
||||
val d = f * y;
|
||||
val bh = d - (d - y);
|
||||
val bl = y - bh;
|
||||
val cl1 = (al * bl) - ((ch - (ah * bh)) - (al * bh) - (ah * bl));
|
||||
|
||||
val cl2 = xl * y;
|
||||
//val [tl1,th] = fastTwoSum(ch,cl2);
|
||||
val th = ch + cl2;
|
||||
val tl1 = cl2 - (th - ch);
|
||||
|
||||
val tl2 = tl1 + cl1;
|
||||
//val [zl,zh] = fastTwoSum(th,tl2);
|
||||
val zh = th + tl2;
|
||||
val zl = tl2 - (zh - th);
|
||||
|
||||
return doubleArrayOf(zl, zh);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.icegps.triangulation
|
||||
|
||||
fun orient2d(bx: Double, by: Double, ax: Double, ay: Double, cx: Double, cy: Double): Double {
|
||||
// (ax,ay) (bx,by) are swapped such that the sign of the determinant is flipped. which is what Delaunator.kt expects.
|
||||
|
||||
/*
|
||||
| a b | = | ax - cx ay - cy |
|
||||
| c d | | bx - cx by - cy |
|
||||
*/
|
||||
|
||||
val a = twoDiff(ax, cx)
|
||||
val b = twoDiff(ay, cy)
|
||||
val c = twoDiff(bx, cx)
|
||||
val d = twoDiff(by, cy)
|
||||
|
||||
val determinant = ddDiffDd(ddMultDd(a, d), ddMultDd(b, c))
|
||||
|
||||
return determinant[1]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.icegps.triangulation
|
||||
|
||||
import com.icegps.math.geometry.Vector3D
|
||||
|
||||
/**
|
||||
* @author tabidachinokaze
|
||||
* @date 2025/11/26
|
||||
*/
|
||||
data class Triangle(
|
||||
val x1: Vector3D,
|
||||
val x2: Vector3D,
|
||||
val x3: Vector3D,
|
||||
)
|
||||
1
math/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
13
math/build.gradle.kts
Normal file
@@ -0,0 +1,13 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlin {
|
||||
compilerOptions.jvmTarget = JvmTarget.JVM_17
|
||||
}
|
||||
75
math/src/main/java/com/icegps/io/util/NumberExt.kt
Normal file
@@ -0,0 +1,75 @@
|
||||
@file:Suppress("NOTHING_TO_INLINE")
|
||||
|
||||
package com.icegps.io.util
|
||||
|
||||
import kotlin.math.*
|
||||
|
||||
//private fun Double.normalizeZero(): Double = if (this.isAlmostZero()) 0.0 else this
|
||||
private val MINUS_ZERO_D = -0.0
|
||||
private fun Double.normalizeZero(): Double = if (this == MINUS_ZERO_D) 0.0 else this
|
||||
|
||||
fun Double.toStringDecimal(decimalPlaces: Int, skipTrailingZeros: Boolean = false): String {
|
||||
if (this.isNanOrInfinite()) return this.toString()
|
||||
|
||||
//val bits = this.toRawBits()
|
||||
//val sign = (bits ushr 63) != 0L
|
||||
//val exponent = (bits ushr 52) and 0b11111111111
|
||||
//val fraction = bits and ((1L shl 52) - 1L)
|
||||
|
||||
val res = this.roundDecimalPlaces(decimalPlaces).normalizeZero().toString()
|
||||
|
||||
val eup = res.indexOf('E')
|
||||
val elo = res.indexOf('e')
|
||||
val eIndex = if (eup >= 0) eup else elo
|
||||
val rez = if (eIndex >= 0) {
|
||||
val base = res.substring(0, eIndex)
|
||||
val exp = res.substring(eIndex + 1).toInt()
|
||||
val rbase = if (base.contains(".")) base else "$base.0"
|
||||
val zeros = "0".repeat(exp.absoluteValue + 2)
|
||||
val part = if (exp > 0) "$rbase$zeros" else "$zeros$rbase"
|
||||
val pointIndex2 = part.indexOf(".")
|
||||
val pointIndex = if (pointIndex2 < 0) part.length else pointIndex2
|
||||
val outIndex = pointIndex + exp
|
||||
val part2 = part.replace(".", "")
|
||||
buildString {
|
||||
if ((0 until outIndex).all { part2[it] == '0' }) {
|
||||
append('0')
|
||||
} else {
|
||||
append(part2, 0, outIndex)
|
||||
}
|
||||
append('.')
|
||||
append(part2, outIndex, part2.length)
|
||||
}
|
||||
} else {
|
||||
res
|
||||
}
|
||||
|
||||
val pointIndex = rez.indexOf('.')
|
||||
val integral = if (pointIndex >= 0) rez.substring(0, pointIndex) else rez
|
||||
if (decimalPlaces == 0) return integral
|
||||
|
||||
val decimal = if (pointIndex >= 0) rez.substring(pointIndex + 1).trimEnd('0') else ""
|
||||
return buildString(2 + integral.length + decimalPlaces) {
|
||||
append(integral)
|
||||
if (decimal.isNotEmpty() || !skipTrailingZeros) {
|
||||
val decimalCount = min(decimal.length, decimalPlaces)
|
||||
val allZeros = (0 until decimalCount).all { decimal[it] == '0' }
|
||||
if (!skipTrailingZeros || !allZeros) {
|
||||
append('.')
|
||||
append(decimal, 0, decimalCount)
|
||||
if (!skipTrailingZeros) repeat(decimalPlaces - decimalCount) { append('0') }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Float.toStringDecimal(decimalPlaces: Int, skipTrailingZeros: Boolean = false): String = this.toDouble().toStringDecimal(decimalPlaces, skipTrailingZeros)
|
||||
|
||||
private fun Double.roundDecimalPlaces(places: Int): Double {
|
||||
if (places < 0) return this
|
||||
val placesFactor: Double = 10.0.pow(places.toDouble())
|
||||
return round(this * placesFactor) / placesFactor
|
||||
}
|
||||
|
||||
private fun Double.isNanOrInfinite() = this.isNaN() || this.isInfinite()
|
||||
private fun Float.isNanOrInfinite() = this.isNaN() || this.isInfinite()
|
||||
78
math/src/main/java/com/icegps/io/util/NumberParser.kt
Normal file
@@ -0,0 +1,78 @@
|
||||
package com.icegps.io.util
|
||||
|
||||
import kotlin.math.*
|
||||
|
||||
object NumberParser {
|
||||
const val END = '\u0000'
|
||||
|
||||
fun parseInt(str: String, start: Int = 0, end: Int = str.length, radix: Int = 10): Int {
|
||||
var n = start
|
||||
return parseInt(radix) { if (n >= end) END else str[n++] }
|
||||
}
|
||||
|
||||
fun parseDouble(str: String, start: Int = 0, end: Int = str.length): Double {
|
||||
var n = start
|
||||
return parseDouble { if (n >= end) END else str[n++] }
|
||||
}
|
||||
|
||||
inline fun parseInt(radix: Int = 10, gen: (Int) -> Char): Int {
|
||||
var positive = true
|
||||
var out = 0
|
||||
var n = 0
|
||||
while (true) {
|
||||
val c = gen(n++)
|
||||
if (c == END) break
|
||||
if (c == '-' || c == '+') {
|
||||
positive = (c == '+')
|
||||
} else {
|
||||
val value = c.ctypeAsInt()
|
||||
if (value < 0) break
|
||||
out *= radix
|
||||
out += value
|
||||
}
|
||||
}
|
||||
return if (positive) out else -out
|
||||
}
|
||||
|
||||
inline fun parseDouble(gen: (Int) -> Char): Double {
|
||||
var out = 0.0
|
||||
var frac = 1.0
|
||||
var pointSeen = false
|
||||
var eSeen = false
|
||||
var negate = false
|
||||
var negateExponent = false
|
||||
var exponent = 0
|
||||
var n = 0
|
||||
while (true) {
|
||||
val c = gen(n++)
|
||||
if (c == END) break
|
||||
when (c) {
|
||||
'e', 'E' -> eSeen = true
|
||||
'-' -> {
|
||||
if (eSeen) negateExponent = true else negate = true
|
||||
}
|
||||
'.' -> pointSeen = true
|
||||
else -> {
|
||||
if (eSeen) {
|
||||
exponent *= 10
|
||||
exponent += c.ctypeAsInt()
|
||||
} else {
|
||||
if (pointSeen) frac /= 10
|
||||
out *= 10
|
||||
out += c.ctypeAsInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val res = (out * frac) * 10.0.pow(if (negateExponent) -exponent else exponent)
|
||||
return if (negate) -res else res
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ConvertTwoComparisonsToRangeCheck") // @TODO: Kotlin-Native doesn't optimize ranges
|
||||
@PublishedApi internal fun Char.ctypeAsInt(): Int = when {
|
||||
this >= '0' && this <= '9' -> this - '0'
|
||||
this >= 'a' && this <= 'z' -> this - 'a' + 10
|
||||
this >= 'A' && this <= 'Z' -> this - 'A' + 10
|
||||
else -> -1
|
||||
}
|
||||
47
math/src/main/java/com/icegps/math/Alignment.kt
Normal file
@@ -0,0 +1,47 @@
|
||||
package com.icegps.math
|
||||
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
////////////////////
|
||||
////////////////////
|
||||
|
||||
/** Returns the next value of [this] that is multiple of [align]. If [this] is already multiple, returns itself. */
|
||||
public fun Int.nextAlignedTo(align: Int): Int = if (this.isAlignedTo(align)) this else (((this / align) + 1) * align)
|
||||
/** Returns the next value of [this] that is multiple of [align]. If [this] is already multiple, returns itself. */
|
||||
public fun Long.nextAlignedTo(align: Long): Long = if (this.isAlignedTo(align)) this else (((this / align) + 1) * align)
|
||||
/** Returns the next value of [this] that is multiple of [align]. If [this] is already multiple, returns itself. */
|
||||
public fun Float.nextAlignedTo(align: Float): Float = if (this.isAlignedTo(align)) this else (((this / align).toInt() + 1) * align)
|
||||
/** Returns the next value of [this] that is multiple of [align]. If [this] is already multiple, returns itself. */
|
||||
public fun Double.nextAlignedTo(align: Double): Double = if (this.isAlignedTo(align)) this else (((this / align).toInt() + 1) * align)
|
||||
|
||||
/** Returns the previous value of [this] that is multiple of [align]. If [this] is already multiple, returns itself. */
|
||||
public fun Int.prevAlignedTo(align: Int): Int = if (this.isAlignedTo(align)) this else nextAlignedTo(align) - align
|
||||
/** Returns the previous value of [this] that is multiple of [align]. If [this] is already multiple, returns itself. */
|
||||
public fun Long.prevAlignedTo(align: Long): Long = if (this.isAlignedTo(align)) this else nextAlignedTo(align) - align
|
||||
/** Returns the previous value of [this] that is multiple of [align]. If [this] is already multiple, returns itself. */
|
||||
public fun Float.prevAlignedTo(align: Float): Float = if (this.isAlignedTo(align)) this else nextAlignedTo(align) - align
|
||||
/** Returns the previous value of [this] that is multiple of [align]. If [this] is already multiple, returns itself. */
|
||||
public fun Double.prevAlignedTo(align: Double): Double = if (this.isAlignedTo(align)) this else nextAlignedTo(align) - align
|
||||
|
||||
/** Returns whether [this] is multiple of [alignment] */
|
||||
public fun Int.isAlignedTo(alignment: Int): Boolean = alignment == 0 || (this % alignment) == 0
|
||||
/** Returns whether [this] is multiple of [alignment] */
|
||||
public fun Long.isAlignedTo(alignment: Long): Boolean = alignment == 0L || (this % alignment) == 0L
|
||||
/** Returns whether [this] is multiple of [alignment] */
|
||||
public fun Float.isAlignedTo(alignment: Float): Boolean = alignment == 0f || (this % alignment) == 0f
|
||||
/** Returns whether [this] is multiple of [alignment] */
|
||||
public fun Double.isAlignedTo(alignment: Double): Boolean = alignment == 0.0 || (this % alignment) == 0.0
|
||||
|
||||
/** Returns the previous or next value of [this] that is multiple of [align]. If [this] is already multiple, returns itself. */
|
||||
public fun Float.nearestAlignedTo(align: Float): Float {
|
||||
val prev = this.prevAlignedTo(align)
|
||||
val next = this.nextAlignedTo(align)
|
||||
return if ((this - prev).absoluteValue < (this - next).absoluteValue) prev else next
|
||||
}
|
||||
/** Returns the previous or next value of [this] that is multiple of [align]. If [this] is already multiple, returns itself. */
|
||||
public fun Double.nearestAlignedTo(align: Double): Double {
|
||||
val prev = this.prevAlignedTo(align)
|
||||
val next = this.nextAlignedTo(align)
|
||||
return if ((this - prev).absoluteValue < (this - next).absoluteValue) prev else next
|
||||
}
|
||||
|
||||
9
math/src/main/java/com/icegps/math/BooleanConversion.kt
Normal file
@@ -0,0 +1,9 @@
|
||||
package com.icegps.math
|
||||
|
||||
////////////////////
|
||||
////////////////////
|
||||
|
||||
/** Converts this [Boolean] into integer: 1 for true, 0 for false */
|
||||
inline fun Boolean.toInt(): Int = if (this) 1 else 0
|
||||
inline fun Boolean.toByte(): Byte = if (this) 1 else 0
|
||||
inline fun Byte.toBoolean(): Boolean = this.toInt() != 0
|
||||
38
math/src/main/java/com/icegps/math/Clamp.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
package com.icegps.math
|
||||
|
||||
/** Clamps [this] value into the range [min] and [max] */
|
||||
fun Int.clamp(min: Int, max: Int): Int = if (this < min) min else if (this > max) max else this
|
||||
/** Clamps [this] value into the range [min] and [max] */
|
||||
fun Long.clamp(min: Long, max: Long): Long = if (this < min) min else if (this > max) max else this
|
||||
/** Clamps [this] value into the range [min] and [max] */
|
||||
fun Double.clamp(min: Double, max: Double): Double = if (this < min) min else if (this > max) max else this
|
||||
/** Clamps [this] value into the range [min] and [max] */
|
||||
fun Float.clamp(min: Float, max: Float): Float = if ((this < min)) min else if ((this > max)) max else this
|
||||
|
||||
/** Clamps [this] value into the range 0 and 1 */
|
||||
fun Double.clamp01(): Double = clamp(0.0, 1.0)
|
||||
/** Clamps [this] value into the range 0 and 1 */
|
||||
fun Float.clamp01(): Float = clamp(0f, 1f)
|
||||
|
||||
/** Clamps [this] [Long] value into the range [min] and [max] converting it into [Int]. The default parameters will cover the whole range of values. */
|
||||
fun Long.toIntClamp(min: Int = Int.MIN_VALUE, max: Int = Int.MAX_VALUE): Int {
|
||||
if (this < min) return min
|
||||
if (this > max) return max
|
||||
return this.toInt()
|
||||
}
|
||||
|
||||
/** Clamps [this] [Long] value into the range [min] and [max] converting it into [Int] (where [min] must be zero or positive). The default parameters will cover the whole range of positive and zero values. */
|
||||
fun Long.toUintClamp(min: Int = 0, max: Int = Int.MAX_VALUE): Int = this.toIntClamp(min, max)
|
||||
|
||||
/** Clamps the integer value in the 0..255 range */
|
||||
fun Int.clampUByte(): Int {
|
||||
val n = this and -(if (this >= 0) 1 else 0)
|
||||
return (n or (0xFF - n shr 31)) and 0xFF
|
||||
}
|
||||
fun Int.clampUShort(): Int {
|
||||
val n = this and -(if (this >= 0) 1 else 0)
|
||||
return (n or (0xFFFF - n shr 31)) and 0xFFFF
|
||||
}
|
||||
|
||||
fun Int.toShortClamped(): Short = this.clamp(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort()
|
||||
fun Int.toByteClamped(): Byte = this.clamp(Byte.MIN_VALUE.toInt(), Byte.MAX_VALUE.toInt()).toByte()
|
||||