initial commit

This commit is contained in:
2025-11-26 18:58:15 +08:00
commit 3ec494ca69
168 changed files with 16142 additions and 0 deletions

38
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: "Bug report"
description: Report an issue or possible bug
title: "Bug report: "
labels: ["bug"]
assignees: []
body:
- type: markdown
attributes:
value: Thank you for taking the time to file a bug report! Please fill out this form as completely as possible.
- type: input
attributes:
label: Operating System
placeholder: Mac, Windows, Linux
validations:
required: true
- type: input
attributes:
label: OPENRNDR version
validations:
required: true
- type: input
attributes:
label: ORX version
validations:
required: true
- type: input
attributes:
label: Java version (if applicable)
- type: textarea
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is, optionally including a screenshot.
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce the bug
description: Describe the steps taken or paste/link a minimal code example that we can reproduce ourselves.

View File

@@ -0,0 +1,30 @@
name: "Feature request"
description: "Suggest an idea or general improvement."
title: "Feature request: "
labels: ["enhancement"]
assignees: []
body:
- type: markdown
attributes:
value: Thanks for taking the time to suggest a new feature! Please fill out this form as completely as possible.
- type: textarea
attributes:
label: Motivation
description: |
Description of what the problem is and what the desired solution would be.
**Include links to relevant issues or discussions, if there are any.**
placeholder: I want to be able to
validations:
required: true
- type: dropdown
attributes:
label: Help make it happen!
description: "Feature requests with contributing authors are much more likely to get done!"
multiple: false
options:
- I am not willing to submit a PR to implement this change.
- I am willing to submit a PR to implement this change, but would need some guidance.
- I am willing to submit a PR to implement this change.
validations:
required: true

53
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Build
on:
# Trigger the workflow on any pull request
pull_request:
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
defaults:
run:
working-directory: ./orx
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout current repository
uses: actions/checkout@v4
with:
path: ./orx
- name: Checkout OPENRNDR repository
uses: actions/checkout@v4
with:
fetch-depth: 0
repository: openrndr/openrndr
path: ./openrndr
ref: master
- name: Test glxinfo
run: |
sudo apt-get update
sudo apt-get install -y mesa-utils xvfb
xvfb-run glxinfo
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build OPENRNDR
working-directory: ./openrndr
run: ./gradlew publishToMavenLocal snapshot
- name: Build ORX
run: ./gradlew build
- name: Collect screenshots without errors
run: xvfb-run ./gradlew collectScreenshots

View File

@@ -0,0 +1,69 @@
name: Build and generate screenshots
on:
push:
branches: [ master ]
defaults:
run:
working-directory: ./orx
jobs:
generate_screenshots:
runs-on: ubuntu-latest
steps:
- name: Checkout current repository
uses: actions/checkout@v4
with:
path: ./orx
- name: Checkout OPENRNDR repository
uses: actions/checkout@v4
with:
fetch-depth: 0
repository: openrndr/openrndr
path: ./openrndr
ref: master
- name: Test glxinfo
run: |
sudo apt-get update
sudo apt-get install -y mesa-utils xvfb
xvfb-run glxinfo
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build OPENRNDR
working-directory: ./openrndr
run: ./gradlew publishToMavenLocal snapshot
- name: Build ORX
run: ./gradlew build
- name: Collect screenshots
run: xvfb-run ./gradlew collectScreenshots
- name: Build main readme
run: xvfb-run ./gradlew buildMainReadme
- name: Prepare media branch
run: |
git config --global user.email "actions@openrndr.org"
git config --global user.name "OPENRNDR Actions"
git reset HEAD -- .
(git add README.md && git commit -m "add auto-generated README" && git push origin master) || true
(git add [a-z-]*/README.md && git commit -m "add demos to README.md" && git push origin master) || true
(git add orx-jvm/[a-z-]*/README.md && git commit -m "add orx-jvm demos to README.md" && git push origin master) || true
git checkout --orphan media
git reset HEAD -- .
git add [a-z-]*/images/*.png
cd orx-jvm
git add [a-z-]*/images/*.png
cd ..
git commit -m "add auto-generated media"
git push -f origin media

13
.github/workflows/pick-me.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
name: Pick me issue assignment
on:
issue_comment:
types: [created, edited]
jobs:
auto-assign:
runs-on: ubuntu-latest
steps:
- name: 'Auto-assign issue'
uses: edwinRNDR/pick-me@v0.3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}

65
.github/workflows/release-apidocs.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: Release API docs
on:
push:
branches: [ master ]
defaults:
run:
working-directory: ./orx
jobs:
release_apidocs:
if: github.repository == 'openrndr/orx'
runs-on: ubuntu-latest
steps:
- name: Checkout current repository
uses: actions/checkout@v4
with:
path: ./orx
fetch-depth: 0
- name: Checkout OPENRNDR repository
uses: actions/checkout@v4
with:
fetch-depth: 0
repository: openrndr/openrndr
path: ./openrndr
ref: master
- name: Test glxinfo
run: |
sudo apt-get update
sudo apt-get install -y mesa-utils xvfb
xvfb-run glxinfo
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Build OPENRNDR
working-directory: ./openrndr
run: ./gradlew publishToMavenLocal snapshot
- name: Build apidocs
run: ./gradlew :dokkaGenerate -Dorg.gradle.jvmargs=-Xmx1536M
- name: Publish to gh-pages
run: |
git worktree add --detach docs-temp
cd docs-temp
git checkout --orphan gh-pages
git reset HEAD -- .
git clean -df
mv ../build/dokka/html/* .
cp ../dokka/styles/* styles/
echo orx.openrndr.org > CNAME
git status
git config --global user.email "actions@openrndr.org"
git config --global user.name "OPENRNDR Actions"
git add .
git commit -m "Add automatically generated API docs"
git push origin gh-pages --force

View File

@@ -0,0 +1,25 @@
name: Release candidate to Maven Central
on:
push:
tags:
- v[0-9].[0-9]+.[0-9]+-rc.[0-9]+
jobs:
release_candidate_to_maven_central:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Build ORX
run: ./gradlew -Prelease.useLastTag=true build
- name: Decode
run: |
echo "${{secrets.SIGNING_SECRET_KEY_RING_FILE}}" > ~/.gradle/secring.gpg.b64
base64 -d ~/.gradle/secring.gpg.b64 > ~/.gradle/secring.gpg
- name: Publish
run: ./gradlew publishAggregationToCentralPortal -Prelease.useLastTag=true -Psigning.keyId=${{secrets.SIGNING_KEY_ID}} -Psigning.password=openrndr -Psigning.secretKeyRingFile=$(echo ~/.gradle/secring.gpg)
env:
OSSRH_USERNAME: ${{secrets.OSSRH_USERNAME}}
OSSRH_PASSWORD: ${{secrets.OSSRH_PASSWORD}}

View File

@@ -0,0 +1,27 @@
name: Release to Maven Central
on:
push:
tags:
- v[0-9].[0-9]+.[0-9]+
- v[0-9].[0-9]+.[0-9]+-alpha[0-9]+
- v[0-9].[0-9]+.[0-9]+-beta[0-9]+
jobs:
release_to_maven_central:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Build ORX
run: ./gradlew -Prelease.useLastTag=true build
- name: Decode
run: |
echo "${{secrets.SIGNING_SECRET_KEY_RING_FILE}}" > ~/.gradle/secring.gpg.b64
base64 -d ~/.gradle/secring.gpg.b64 > ~/.gradle/secring.gpg
- name: Publish
run: ./gradlew publishAggregationToCentralPortal -Prelease.useLastTag=true -Psigning.keyId=${{secrets.SIGNING_KEY_ID}} -Psigning.password=openrndr -Psigning.secretKeyRingFile=$(echo ~/.gradle/secring.gpg)
env:
OSSRH_USERNAME: ${{secrets.OSSRH_USERNAME}}
OSSRH_PASSWORD: ${{secrets.OSSRH_PASSWORD}}

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/.kotlin

1
android/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

62
android/build.gradle.kts Normal file
View 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.geotools"
compileSdk {
version = release(36)
}
defaultConfig {
applicationId = "com.icegps.geotools"
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
View 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

View File

@@ -0,0 +1,24 @@
package com.icegps.geotools
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.geotools", appContext.packageName)
}
}

View 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>

View File

@@ -0,0 +1,427 @@
package com.icegps.geotools
import ColorBrewer2Type
import android.content.Context
import android.util.Log
import colorBrewer2Palettes
import com.icegps.math.geometry.Rectangle
import com.icegps.math.geometry.Vector2D
import com.icegps.math.geometry.Vector3D
import com.icegps.geotools.catmullrom.CatmullRomChain2
import com.icegps.geotools.ktx.area
import com.icegps.geotools.ktx.toColorInt
import com.icegps.geotools.ktx.toMapboxPoint
import com.icegps.geotools.ktx.toast
import com.icegps.geotools.marchingsquares.ShapeContour
import com.icegps.geotools.marchingsquares.findContours
import com.icegps.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)
}

View File

@@ -0,0 +1,197 @@
package com.icegps.geotools
import com.icegps.math.geometry.Angle
import com.icegps.math.geometry.Vector2D
import com.icegps.geotools.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)
}
}

View File

@@ -0,0 +1,53 @@
package com.icegps.geotools
import com.icegps.math.geometry.Angle
import com.icegps.math.geometry.Vector3D
import com.icegps.math.geometry.degrees
import kotlin.math.cos
import kotlin.math.sin
import kotlin.random.Random
/**
* @author tabidachinokaze
* @date 2025/11/25
*/
fun coordinateGenerate(): List<Vector3D> {
val minX = -20.0
val maxX = 20.0
val minY = -20.0
val maxY = 20.0
val minZ = -20.0
val maxZ = 20.0
val x: () -> Double = { Random.nextDouble(minX, maxX) }
val y: () -> Double = { Random.nextDouble(minY, maxY) }
val z: () -> Double = { Random.nextDouble(minZ, maxZ) }
val dPoints = (0..60).map {
Vector3D(x(), y(), z())
}
return dPoints
}
fun coordinateGenerate1(): List<List<Vector3D>> {
/**
* 绕 Z 轴旋转指定角度(弧度)
*/
fun Vector3D.rotateAroundZ(angle: Angle): Vector3D {
val cosAngle = cos(angle.radians)
val sinAngle = sin(angle.radians)
return Vector3D(
x = x * cosAngle - y * sinAngle,
y = x * sinAngle + y * cosAngle,
z = z
)
}
val center = Vector3D()
val direction = Vector3D(0.0, 1.0, -1.0)
return (0..360).step(10).map {
val nowDirection = direction.rotateAroundZ(it.degrees)
listOf(2, 6, 10).map {
center + nowDirection * it
}
}
}

View File

@@ -0,0 +1,144 @@
package com.icegps.geotools
import android.util.Log
import com.icegps.math.geometry.Vector2D
import com.icegps.geotools.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)
}

View File

@@ -0,0 +1,438 @@
package com.icegps.geotools
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)}")
appendLine("填方: ${"%.1f".format(fillVolume)}")
appendLine("净土方: ${"%.1f".format(netVolume)}")
appendLine("挖方面积: ${"%.1f".format(cutArea)}")
appendLine("填方面积: ${"%.1f".format(fillArea)}")
appendLine("总面积:${"%.1f".format(totalArea)}")
}
}
}
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
}
}

View File

@@ -0,0 +1,83 @@
package com.icegps.geotools
import com.icegps.common.helper.GeoHelper
import com.icegps.math.geometry.Vector2D
import com.mapbox.geojson.Feature
import com.mapbox.geojson.FeatureCollection
import com.mapbox.geojson.Point
import com.mapbox.geojson.Polygon
import com.mapbox.maps.MapView
import com.mapbox.maps.extension.style.expressions.generated.Expression
import com.mapbox.maps.extension.style.layers.addLayer
import com.mapbox.maps.extension.style.layers.generated.FillLayer
import com.mapbox.maps.extension.style.sources.addSource
import com.mapbox.maps.extension.style.sources.generated.geoJsonSource
/**
* @author tabidachinokaze
* @date 2025/11/25
*/
fun MapView.displayGridModel(
grid: GridModel,
sourceId: String,
layerId: String,
palette: (Double?) -> String,
) {
val geoHelper = GeoHelper.getSharedInstance()
mapboxMap.getStyle { style ->
val polygonFeatures = mutableListOf<Feature>()
val minX = grid.minX
val maxY = grid.maxY
val cellSize = grid.cellSize
for (r in 0 until grid.rows) {
for (c in 0 until grid.cols) {
val idx = r * grid.cols + c
val v = grid.cells[idx] ?: continue
val x0 = minX + c * cellSize
val y0 = maxY - r * cellSize
val x1 = x0 + cellSize
val y1 = y0 - cellSize
val ring = listOf(
Vector2D(x0, y0),
Vector2D(x1, y0),
Vector2D(x1, y1),
Vector2D(x0, y1),
Vector2D(x0, y0),
).map {
geoHelper.enuToWGS84Object(GeoHelper.ENU(it.x, it.y))
}.map {
Point.fromLngLat(it.lon, it.lat)
}
val poly = Polygon.fromLngLats(listOf(ring))
val polyFeature = Feature.fromGeometry(poly)
polyFeature.addStringProperty("color", palette(v))
polyFeature.addNumberProperty("value", v ?: -9999.0)
polygonFeatures.add(polyFeature)
}
}
try {
style.removeStyleLayer(layerId)
} catch (_: Exception) {
}
if (style.styleSourceExists(sourceId)) {
style.removeStyleSource(sourceId)
}
val polygonSource = geoJsonSource(sourceId) {
featureCollection(FeatureCollection.fromFeatures(polygonFeatures))
}
style.addSource(polygonSource)
val fillLayer = FillLayer(layerId, sourceId).apply {
fillColor(Expression.toColor(Expression.get("color")))
fillOpacity(0.5)
}
style.addLayer(fillLayer)
}
}

View File

@@ -0,0 +1,139 @@
package com.icegps.geotools
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
}

View File

@@ -0,0 +1,265 @@
package com.icegps.geotools
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.geotools.databinding.ActivityMainBinding
import com.icegps.math.geometry.Angle
import com.icegps.math.geometry.degrees
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.map
import kotlinx.coroutines.flow.onEach
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
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()
binding.switchSlopeResult.setOnCheckedChangeListener { _, isChecked ->
slopeResultVisible.value = isChecked
}
initData()
}
private val showDesignHeight = MutableStateFlow(false)
private val slopeResultVisible = MutableStateFlow(false)
@OptIn(ExperimentalUuidApi::class)
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)
val slopeResultSourceId: String = Uuid.random().toString()
val slopeResultLayerId: String = Uuid.random().toString()
combine(
earthworkManager.slopeDirection,
earthworkManager.slopePercentage,
earthworkManager.baseHeightOffset,
contoursManager.gridModel,
showDesignHeight,
slopeResultVisible
) {
Params6(
p1 = it[0] as Angle,
p2 = it[1] as Double,
p3 = it[2] as Double,
p4 = it[3] as? GridModel?,
p5 = it[4] as Boolean,
p6 = it[5] as Boolean
)
}.map { (slopeDirection, slopePercentage, baseHeightOffset, gridModel, showDesignHeight, slopeResultVisible) ->
if (!slopeResultVisible) {
mapView.mapboxMap.getStyle { style ->
style.removeStyleLayer(slopeResultLayerId)
style.removeStyleLayer("${slopeResultLayerId}-outline")
style.removeStyleSource(slopeResultSourceId)
}
} else gridModel?.let { gridModel ->
val slopeResult: SlopeResult = SlopeCalculator.calculateSlope(
grid = gridModel,
slopeDirection = slopeDirection.degrees,
slopePercentage = slopePercentage,
baseHeightOffset = baseHeightOffset
)
mapView.displaySlopeResult(
originalGrid = gridModel,
slopeResult = slopeResult,
sourceId = slopeResultSourceId,
layerId = slopeResultLayerId,
palette = contoursManager.simplePalette::palette,
showDesignHeight = showDesignHeight
)
}
}.launchIn(lifecycleScope)
}
}
data class Params6<
out P1,
out P2,
out P3,
out P4,
out P5,
out P6,
>(
val p1: P1,
val p2: P2,
val p3: P3,
val p4: P4,
val p5: P5,
val p6: P6,
)
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
)
}

View File

@@ -0,0 +1,59 @@
package com.icegps.geotools
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.geotools.ktx.toast
import com.icegps.shared.SharedHttpClient
import com.icegps.shared.SharedJson
import com.icegps.shared.api.OpenElevation
import com.icegps.shared.api.OpenElevationApi
import com.icegps.shared.ktx.TAG
import com.icegps.shared.model.GeoPoint
import com.mapbox.geojson.Point
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
class MainViewModel(private val context: Application) : AndroidViewModel(context) {
private val geoHelper = GeoHelper.Companion.getSharedInstance()
private val openElevation: OpenElevationApi = OpenElevation(SharedHttpClient(SharedJson()))
private val _points = MutableStateFlow<List<Point>>(emptyList())
val points = _points.filter { it.size > 3 }.debounce(1000).map {
openElevation.lookup(it.map { GeoPoint(it.longitude(), it.latitude(), it.altitude()) })
}.catch {
Log.e(TAG, "高程请求失败", it)
context.toast("高程请求失败")
}.map {
it.map {
val enu = geoHelper.wgs84ToENU(lon = it.longitude, lat = it.latitude, hgt = it.altitude)
Vector3D(enu.x, enu.y, enu.z)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Companion.Eagerly,
initialValue = emptyList()
)
fun addPoint(point: Point) {
context.toast("${point.longitude()}, ${point.latitude()}")
_points.update {
it.toMutableList().apply {
add(point)
}
}
}
fun clearPoints() {
_points.value = emptyList()
}
}

View File

@@ -0,0 +1,121 @@
package com.icegps.geotools
import android.graphics.Color
import com.icegps.math.geometry.Line3D
import com.icegps.math.geometry.Vector3D
import com.icegps.geotools.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]
)
}
}
}

View File

@@ -0,0 +1,44 @@
package com.icegps.geotools
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() })
}
}

View File

@@ -0,0 +1,123 @@
package com.icegps.geotools
import android.util.Log
/**
* @author tabidachinokaze
* @date 2025/11/25
*/
class SimplePalette(
private var range: ClosedFloatingPointRange<Double>
) {
fun setRange(range: ClosedFloatingPointRange<Double>) {
this.range = range
}
private val colors: Map<Int, String>
init {
colors = generateTerrainColorMap()
}
fun palette(value: Double?): String {
if (value == null) return "#00000000"
val minH = range.start
val maxH = range.endInclusive
val normalized = ((value - minH) / (maxH - minH)).coerceIn(0.0, 1.0)
return colors[(normalized * 255).toInt()] ?: "#00000000"
}
fun palette1(value: Double?): String {
return if (value == null) "#00000000" else {
// 假设您已经知道高度范围,或者动态计算
val minH = range.start
val maxH = range.endInclusive
val normalized = ((value - minH) / (maxH - minH)).coerceIn(0.0, 1.0)
val alpha = (normalized * 255).toInt()
String.format("#%02X%02X%02X", alpha, 0, 0)
}.also {
Log.d("simplePalette", "$value -> $it")
}
}
fun generateGrayscaleColorMap2(): MutableMap<Int, String> {
val colorMap = mutableMapOf<Int, String>()
// 定义关键灰度点
val black = Color(0, 0, 0) // 低地势 - 黑色
val darkGray = Color(64, 64, 64) // 过渡
val midGray = Color(128, 128, 128) // 中间
val lightGray = Color(192, 192, 192) // 过渡
val white = Color(255, 255, 255) // 高地势 - 白色
for (i in 0..255) {
val position = i / 255.0
val color = when {
position < 0.25 -> interpolateColor(black, darkGray, position / 0.25)
position < 0.5 -> interpolateColor(darkGray, midGray, (position - 0.25) / 0.25)
position < 0.75 -> interpolateColor(midGray, lightGray, (position - 0.5) / 0.25)
else -> interpolateColor(lightGray, white, (position - 0.75) / 0.25)
}
colorMap[i] = color.toHex()
}
return colorMap
}
fun generateGrayscaleColorMap(): MutableMap<Int, String> {
val colorMap = mutableMapOf<Int, String>()
for (i in 0..255) {
// 从黑色到白色的线性渐变
val grayValue = i
val color = Color(grayValue, grayValue, grayValue)
colorMap[i] = color.toHex()
}
return colorMap
}
fun generateTerrainColorMap(): MutableMap<Int, String> {
val colorMap = mutableMapOf<Int, String>()
// 定义关键颜色点
val blue = Color(0, 0, 255) // 低地势 - 蓝色
val cyan = Color(0, 255, 255) // 中间过渡
val green = Color(0, 255, 0) // 中间过渡
val yellow = Color(255, 255, 0) // 中间过渡
val red = Color(255, 0, 0) // 高地势 - 红色
for (i in 0..255) {
val position = i / 255.0
val color = when {
position < 0.25 -> interpolateColor(blue, cyan, position / 0.25)
position < 0.5 -> interpolateColor(cyan, green, (position - 0.25) / 0.25)
position < 0.75 -> interpolateColor(green, yellow, (position - 0.5) / 0.25)
else -> interpolateColor(yellow, red, (position - 0.75) / 0.25)
}
colorMap[i] = color.toHex()
}
return colorMap
}
fun interpolateColor(start: Color, end: Color, fraction: Double): Color {
val r = (start.red + (end.red - start.red) * fraction).toInt()
val g = (start.green + (end.green - start.green) * fraction).toInt()
val b = (start.blue + (end.blue - start.blue) * fraction).toInt()
return Color(r, g, b)
}
// Color类简化实现
class Color(val red: Int, val green: Int, val blue: Int) {
fun toArgb(): Int {
return (0xFF shl 24) or (red shl 16) or (green shl 8) or blue
}
fun toHex(): String {
return String.format("#%06X", toArgb() and 0xFFFFFF)
}
}
}

View File

@@ -0,0 +1,135 @@
package com.icegps.geotools.catmullrom
import com.icegps.math.geometry.Vector2D
import com.icegps.geotools.marchingsquares.Segment2D
import com.icegps.geotools.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 CatmullRom 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)

View File

@@ -0,0 +1,427 @@
package com.icegps.geotools.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)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
package com.icegps.geotools.ktx
import com.icegps.geotools.color.ColorRGBa
/**
* @author tabidachinokaze
* @date 2025/11/25
*/
fun ColorRGBa.toColorInt(): Int {
val clampedR = r.coerceIn(0.0, 1.0)
val clampedG = g.coerceIn(0.0, 1.0)
val clampedB = b.coerceIn(0.0, 1.0)
val clampedAlpha = alpha.coerceIn(0.0, 1.0)
return ((clampedAlpha * 255).toInt() shl 24) or
((clampedR * 255).toInt() shl 16) or
((clampedG * 255).toInt() shl 8) or
((clampedB * 255).toInt())
}
fun ColorRGBa.toColorHex(): String {
return String.format("#%06X", 0xFFFFFF and toColorInt())
}

View File

@@ -0,0 +1,12 @@
package com.icegps.geotools.ktx
import android.content.Context
import android.widget.Toast
/**
* @author tabidachinokaze
* @date 2025/11/25
*/
fun Context.toast(text: String, duration: Int = Toast.LENGTH_SHORT) {
Toast.makeText(this, text, duration).show()
}

View File

@@ -0,0 +1,24 @@
package com.icegps.geotools.ktx
import com.icegps.common.helper.GeoHelper
import com.icegps.math.geometry.Vector2D
import com.mapbox.geojson.Point
/**
* @author tabidachinokaze
* @date 2025/11/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

View File

@@ -0,0 +1,32 @@
package com.icegps.geotools.ktx
import com.icegps.common.helper.GeoHelper
import com.icegps.math.geometry.Rectangle
import com.icegps.math.geometry.Vector3D
import com.mapbox.geojson.Point
fun Vector3D.niceStr(): String {
return "[$x, $y, $z]".format(this)
}
fun List<Vector3D>.niceStr(): String {
return joinToString(", ", "[", "]") {
it.niceStr()
}
}
fun Vector3D.toMapboxPoint(): Point {
val geoHelper = GeoHelper.getSharedInstance()
return geoHelper.enuToWGS84Object(GeoHelper.ENU(x, y, z)).run {
Point.fromLngLat(lon, lat, hgt)
}
}
val List<Vector3D>.area: Rectangle
get() {
val minX = minOf { it.x }
val maxX = maxOf { it.x }
val minY = minOf { it.y }
val maxY = maxOf { it.y }
return Rectangle(x = minX, y = minY, width = maxX - minX, height = maxY - minY)
}

View File

@@ -0,0 +1,219 @@
package com.icegps.geotools.marchingsquares
import com.icegps.math.geometry.Rectangle
import com.icegps.math.geometry.Vector2D
import com.icegps.math.geometry.Vector2I
import com.icegps.geotools.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
}

View 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>

View 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>

View File

@@ -0,0 +1,190 @@
<?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="显示设计面" />
<Switch
android:id="@+id/switch_slope_result"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:switchPadding="16dp"
android:text="显示计算的坡面" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -0,0 +1,186 @@
<?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="显示设计面" />
<Switch
android:id="@+id/switch_slope_result"
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>

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View 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>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View 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>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">icegps-orx</string>
</resources>

View 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>

View File

@@ -0,0 +1,17 @@
package com.icegps.geotools
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)
}
}

View File

@@ -0,0 +1,71 @@
package com.icegps.geotools
import com.icegps.geotools.ktx.area
import com.icegps.geotools.ktx.niceStr
import com.icegps.math.geometry.Vector3D
import com.icegps.triangulation.delaunayTriangulation
import org.junit.Test
import kotlin.math.max
/**
* @author tabidachinokaze
* @date 2025/11/26
*/
class TriangulationToGridTest {
@Test
fun testTriangulationToGrid() {
val points = listOf(
Vector3D(-10.0, 10.0, 0.0),
Vector3D(10.0, 10.0, 10.0),
Vector3D(-10.0, -10.0, 20.0),
Vector3D(10.0, -10.0, 30.0),
)
points.map {
it / 8
}.niceStr().let(::println)
val area = points.area
val cellSize = max(area.x + area.width, area.y + area.height) / 10
val triangulation = points.delaunayTriangulation()
val triangles = triangulation.triangles()
val grid = triangulationToGrid(
delaunator = triangulation,
cellSize = cellSize,
)
grid.string().let(::println)
val slopeResult = SlopeCalculator.calculateSlope(
grid = grid,
slopeDirection = 0.0,
slopePercentage = 100.0,
baseHeightOffset = 0.0
)
slopeResult.designSurface.string().let(::println)
println("原来的 Volume: ${grid.volumeSum()}")
println("做坡的 Volume: ${slopeResult.designSurface.volumeSum()}")
println(slopeResult.earthworkResult)
}
}
fun GridModel.string() = buildString {
for (r in 0 until rows) {
for (c in 0 until cols) {
val originalElev = getValue(r, c) ?: continue
append("${originalElev.format()}, ")
}
appendLine()
}
}
fun GridModel.volumeSum(): Double {
var volume = 0.0
for (r in 0 until rows) {
for (c in 0 until cols) {
val height = getValue(r, c) ?: continue
volume += height
}
}
return volume
}
fun Double.format(): String {
return "%.1f".format(this)
}

6
build.gradle Normal file
View File

@@ -0,0 +1,6 @@
plugins {
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
}

23
gradle.properties Normal file
View File

@@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=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
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# 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

42
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,42 @@
[versions]
kotlin = "2.2.21"
ktor = "3.3.2"
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]
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" }
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 Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
gradlew vendored Normal file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 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.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

1
icegps-common/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View 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)
}

View File

21
icegps-common/proguard-rules.pro vendored Normal file
View 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

View File

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

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View 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] }
}
}
}

View File

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

View File

@@ -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
View File

@@ -0,0 +1 @@
/build

View 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)
}

View File

21
icegps-shared/proguard-rules.pro vendored Normal file
View 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

View File

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

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

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

View 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 {
}
}
}

View File

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

View File

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

View 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

View File

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

View File

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

View File

@@ -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
View File

@@ -0,0 +1 @@
/build

View 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"))
}

View File

@@ -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]
}

View File

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

View File

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

View File

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

View File

@@ -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]
}

View File

@@ -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
View File

@@ -0,0 +1 @@
/build

13
math/build.gradle.kts Normal file
View 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
}

View 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()

View 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
}

View 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
}

Some files were not shown because too many files have changed in this diff Show More