initial commit

This commit is contained in:
2025-11-13 18:31:10 +08:00
commit da58415989
137 changed files with 10167 additions and 0 deletions

1
app/.gitignore vendored Normal file
View File

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

49
app/build.gradle Normal file
View File

@@ -0,0 +1,49 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
}
android {
namespace 'com.icegps.geotools'
compileSdk 36
defaultConfig {
applicationId "com.icegps.geotools"
minSdk 28
targetSdk 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = '11'
}
}
dependencies {
implementation libs.androidx.core.ktx
implementation libs.androidx.appcompat
implementation libs.material
implementation 'com.mapbox.maps:android-ndk27:11.16.2'
implementation libs.androidx.activity
implementation libs.androidx.constraintlayout
implementation project(':delaunator')
implementation project(':math')
testImplementation libs.junit
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core
}

21
app/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,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 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:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Geotools"
tools:targetApi="31">
<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,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,83 @@
package com.icegps.geotools
import com.icegps.geotools.model.IPoint
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
/**
* @author tabidachinokaze
* @date 2025/11/5
*/
object GeoJsonUtils {
// 生成三角形PolygonFeatureCollection
fun <T : IPoint> trianglesToPolygons(delaunator: Delaunator<T>): FeatureCollection {
val features = mutableListOf<Feature>()
val tris = delaunator.triangles
// triangles 是按 3 个索引为一组三角形存储
var i = 0
while (i <= tris.lastIndex) {
val a = tris[i]
val b = tris[i + 1]
val c = tris[i + 2]
val pa = delaunator.points[a]
val pb = delaunator.points[b]
val pc = delaunator.points[c]
// Polygon 要求外环首尾闭合
val ring = listOf(
Point.fromLngLat(pa.x, pa.y),
Point.fromLngLat(pb.x, pb.y),
Point.fromLngLat(pc.x, pc.y),
Point.fromLngLat(pa.x, pa.y)
)
val polygon = Polygon.fromLngLats(listOf(ring))
val feature = Feature.fromGeometry(polygon)
// 可把三角形的顶点 id/索引用作属性,方便后续交互
feature.addNumberProperty("i0", a)
feature.addNumberProperty("i1", b)
feature.addNumberProperty("i2", c)
features.add(feature)
i += 3
}
return FeatureCollection.fromFeatures(features)
}
// 生成边LineStringFeatureCollection每条边只输出一次
fun <T : IPoint> trianglesToUniqueEdges(delaunator: Delaunator<T>): FeatureCollection {
val features = mutableListOf<Feature>()
val seen = HashSet<Pair<Int, Int>>()
val tris = delaunator.triangles
var i = 0
while (i <= tris.lastIndex) {
val a = tris[i]
val b = tris[i + 1]
val c = tris[i + 2]
val edges = listOf(Pair(a, b), Pair(b, c), Pair(c, a))
for ((p, q) in edges) {
// 归一化顺序避免重复p,qq,p
val key = if (p <= q) Pair(p, q) else Pair(q, p)
if (seen.add(key)) {
val pp = delaunator.points[p]
val qq = delaunator.points[q]
val line = LineString.fromLngLats(
listOf(
Point.fromLngLat(pp.x, pp.y),
Point.fromLngLat(qq.x, qq.y)
)
)
val feature = Feature.fromGeometry(line)
feature.addNumberProperty("p0", p)
feature.addNumberProperty("p1", q)
features.add(feature)
}
}
i += 3
}
return FeatureCollection.fromFeatures(features)
}
}

View File

@@ -0,0 +1,529 @@
package com.icegps.geotools
/**
* @author tabidachinokaze
* @date 2025/11/13
*/
// Imports根据你项目调整
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import androidx.core.graphics.toColorInt
import com.icegps.geotools.model.DPoint
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.layers.generated.rasterLayer
import com.mapbox.maps.extension.style.layers.properties.generated.Visibility
import com.mapbox.maps.extension.style.sources.addSource
import com.mapbox.maps.extension.style.sources.generated.ImageSource
import com.mapbox.maps.extension.style.sources.generated.geoJsonSource
import com.mapbox.maps.extension.style.sources.generated.imageSource
import com.mapbox.maps.extension.style.sources.getSourceAs
import com.mapbox.maps.extension.style.sources.updateImage
import kotlin.math.PI
import kotlin.math.absoluteValue
import kotlin.math.atan
import kotlin.math.ceil
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.min
import kotlin.math.tan
// -----------------------------
// Helper: WebMercator projection (EPSG:3857)
// -----------------------------
fun lonToMercX(lon: Double): Double = lon * 20037508.34 / 180.0
fun latToMercY(lat: Double): Double {
val y = ln(tan((90.0 + lat) * PI / 360.0)) / (PI / 180.0)
return y * 20037508.34 / 180.0
}
fun mercXToLon(x: Double): Double = x * 180.0 / 20037508.34
fun mercYToLat(y: Double): Double {
val v = y * 180.0 / 20037508.34
return 180.0 / PI * (2.0 * atan(exp(v * PI / 180.0)) - PI / 2.0)
}
// -----------------------------
// Geometry helpers
// -----------------------------
data class Vec2(val x: Double, val y: Double)
/** 点是否在三角形内(在 mercator 坐标系中) — 使用重心 / 矩阵法 */
fun pointInTriangle(pt: Vec2, a: Vec2, b: Vec2, c: Vec2): 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
}
/** 可选:用三角形顶点值做双线性/重心内插(这里示例:按顶点值插值)
* valueAtVerts: DoubleArray of length 3 for the triangle's vertex values
*/
fun barycentricInterpolate(pt: Vec2, a: Vec2, b: Vec2, c: Vec2, values: DoubleArray): Double {
// compute areas (using cross product) as barycentric weights
val area = { p1: Vec2, p2: Vec2, p3: Vec2 ->
((p2.x - p1.x) * (p3.y - p1.y) - (p3.x - p1.x) * (p2.y - p1.y)).absoluteValue / 2.0
}
val areaTotal = area(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
}
// -----------------------------
// 主函数:把 Delaunay 转成规则栅格(格子中心采样)
// -----------------------------
data class GridCell(val row: Int, val col: Int, val centerLon: Double, val centerLat: Double, var value: Double? = null)
data class GridModel(
val minLon: Double, val minLat: Double,
val maxLon: Double, val maxLat: Double,
val rows: Int,
val cols: Int,
val cellSizeMeters: Double,
val cells: Array<Double?> // length rows*cols, row-major: idx = r*cols + c
)
fun triangulationToGrid(
delaunator: Delaunator<DPoint>,
cellSizeMeters: Double = 50.0, // 每个格子的边长(米)
maxSidePixels: Int = 5000 // 限制 max rows/cols 防止 OOM可选
): GridModel {
val pts = delaunator.points
require(pts.isNotEmpty()) { "points empty" }
// 1) bbox in lon/lat
var minLon = Double.POSITIVE_INFINITY
var maxLon = Double.NEGATIVE_INFINITY
var minLat = Double.POSITIVE_INFINITY
var maxLat = Double.NEGATIVE_INFINITY
for (p in pts) {
if (p.x < minLon) minLon = p.x
if (p.x > maxLon) maxLon = p.x
if (p.y < minLat) minLat = p.y
if (p.y > maxLat) maxLat = p.y
}
if (minLon == maxLon) {
minLon -= 0.0001; maxLon += 0.0001
}
if (minLat == maxLat) {
minLat -= 0.0001; maxLat += 0.0001
}
// 2) 转为 mercator
val minX = lonToMercX(minLon)
val maxX = lonToMercX(maxLon)
val minY = latToMercY(minLat)
val maxY = latToMercY(maxLat)
val widthMeters = maxX - minX
val heightMeters = maxY - minY
// rows/cols
var cols = ceil(widthMeters / cellSizeMeters).toInt()
var rows = ceil(heightMeters / cellSizeMeters).toInt()
// 防止过大
if (cols > maxSidePixels) cols = maxSidePixels
if (rows > maxSidePixels) rows = maxSidePixels
// prepare output array
val cells = Array<Double?>(rows * cols) { null }
// 准备点/三角形在 mercator 下的缓存坐标
val mercPts = pts.map { p -> Vec2(lonToMercX(p.x), latToMercY(p.y)) }
// triangles 数组(每 3 个为一组)
val triIdx = delaunator.triangles
val triCount = triIdx.size / 3
// For potential vertex values: if you have scalar per vertex, prepare here.
// Example: create placeholder values (e.g., 0.0). Replace with your actual values if available.
val vertexValues = DoubleArray(pts.size) { 0.0 }
// 3) iterate triangles and rasterize onto grid by checking the grid cells that intersect triangle bbox
for (ti in 0 until triCount) {
val i0 = triIdx[3 * ti]
val i1 = triIdx[3 * ti + 1]
val i2 = triIdx[3 * ti + 2]
val a = mercPts[i0]
val b = mercPts[i1]
val c = mercPts[i2]
// triangle bbox in mercator
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)
// convert bbox to grid indices (clamp)
val colMin = ((tminX - minX) / cellSizeMeters).toInt().coerceIn(0, cols - 1)
val colMax = ((tmaxX - minX) / cellSizeMeters).toInt().coerceIn(0, cols - 1)
val rowMin = ((maxY - tmaxY) / cellSizeMeters).toInt().coerceIn(0, rows - 1) // 注意 Y 方向
val rowMax = ((maxY - tminY) / cellSizeMeters).toInt().coerceIn(0, rows - 1)
// optional: get vertex values for interpolation
val triVertexVals = doubleArrayOf(vertexValues[i0], vertexValues[i1], vertexValues[i2])
for (r in rowMin..rowMax) {
for (cIdx in colMin..colMax) {
// center of this cell in mercator
val centerX = minX + (cIdx + 0.5) * cellSizeMeters
val centerY = maxY - (r + 0.5) * cellSizeMeters
val pt = Vec2(centerX, centerY)
if (pointInTriangle(pt, a, b, c)) {
// example: set cell value as triangle index, or do interpolation
// cells index:
val idx = r * cols + cIdx
// choose value: triangle index -> convert to Double
cells[idx] = ti.toDouble()
// OR for interpolation:
// val valInterp = barycentricInterpolate(pt, a, b, c, triVertexVals)
// cells[idx] = valInterp
}
}
}
}
// 4) produce GridModel and also convert bbox back to lon/lat for metadata
val grid = GridModel(
minLon = minLon,
minLat = minLat,
maxLon = maxLon,
maxLat = maxLat,
rows = rows,
cols = cols,
cellSizeMeters = cellSizeMeters,
cells = cells
)
return grid
}
// -----------------------------
// 显示:把 GridModel 渲染成 Bitmap 并用 ImageSource 显示在 Mapbox推荐
// -----------------------------
fun MapView.displayGridAsImageSource(
grid: GridModel,
testSourceId: String,
testLayerId: String,
palette: (Double?) -> Int = { v -> // 默认配色:基于三角形索引取色
if (v == null) Color.TRANSPARENT
else {
val idx = v.toInt()
val r = (50 + (idx * 37) % 200)
val g = (80 + (idx * 61) % 150)
val b = (100 + (idx * 47) % 120)
Color.argb(220, r, g, b)
}
}
) {
mapboxMap.getStyle { style ->
val cols = grid.cols
val rows = grid.rows
// 限制渲染大小防 OOM可按需缩放
val maxDim = 2048
val width = cols.coerceAtMost(maxDim)
val height = rows.coerceAtMost(maxDim)
// 如果 rows/cols 超过 maxDim我们在渲染时按比例抽样nearest neighbor
val sampleX = cols.toDouble() / width.toDouble()
val sampleY = rows.toDouble() / height.toDouble()
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
canvas.drawColor(Color.TRANSPARENT)
val paint = Paint().apply {
this.style = Paint.Style.FILL
}
for (y in 0 until height) {
val srcRow = min((y * sampleY).toInt(), rows - 1)
for (x in 0 until width) {
val srcCol = min((x * sampleX).toInt(), cols - 1)
val valCell = grid.cells[srcRow * cols + srcCol]
paint.color = palette(valCell)
// draw a single pixel rect scaled to bitmap coordinates
canvas.drawRect(RectF(x.toFloat(), y.toFloat(), (x + 1).toFloat(), (y + 1).toFloat()), paint)
}
}
// image source coords: grid bbox four corners (lon/lat)
val topLeft = listOf(grid.minLon, grid.maxLat)
val topRight = listOf(grid.maxLon, grid.maxLat)
val bottomRight = listOf(grid.maxLon, grid.minLat)
val bottomLeft = listOf(grid.minLon, grid.minLat)
val coords = listOf(topLeft, topRight, bottomRight, bottomLeft)
if (style.styleSourceExists(testSourceId)) style.removeStyleSource(testSourceId)
val imgSource = imageSource(testSourceId) {
coordinates(coords)
}
style.addSource(imgSource)
// remove old layer if present
try {
style.removeStyleLayer(testLayerId)
} catch (_: Exception) {
}
val rasterLayer = rasterLayer(testLayerId, testSourceId) {
visibility(Visibility.VISIBLE)
}
style.addLayer(rasterLayer)
// set image (updateImage or setImage)
val src = style.getSourceAs<ImageSource>(testSourceId)
src?.updateImage(bmp) // 若你的 SDK 使用 setImage(bitmap) -> 改为 setImage
}
}
fun MapView.displayGridAsImageSourceHighRes(
grid: GridModel,
testSourceId: String,
testLayerId: String,
maxSidePx: Int = 4096, // 根据内存调整,越大越清晰但越耗内存
palette: (Double?) -> Int = { v ->
if (v == null) Color.TRANSPARENT else {
val idx = v.toInt()
Color.argb(220, (50 + (idx * 37) % 205), (80 + (idx * 61) % 175), (100 + (idx * 97) % 155))
}
}
) {
mapboxMap.getStyle { style ->
val cols = grid.cols
val rows = grid.rows
if (cols <= 0 || rows <= 0) return@getStyle
// 设备像素比(用于在高 DPI 设备上生成更清晰的位图)
val density = context.resources.displayMetrics.density // e.g. 3.0 for xxhdpi
// target bitmap size: 尽量把每个格子映射为至少 1 device-pixel
var targetW = (cols * density).toInt()
var targetH = (rows * density).toInt()
// 限制最大边长,防止 OOM
val scaleDown = max(1.0, max(targetW.toDouble() / maxSidePx, targetH.toDouble() / maxSidePx))
if (scaleDown > 1.0) {
targetW = (targetW / scaleDown).toInt().coerceAtLeast(1)
targetH = (targetH / scaleDown).toInt().coerceAtLeast(1)
}
// 如果 target 更小于 cols/rows说明被压缩我们先绘制到原始 colsxrows 的 bitmap 再用最近邻缩放到 target。
// 但为了避免一次性分配超大内存,这里用两步策略:
val srcW = cols.coerceAtMost(maxSidePx) // 防止超大
val srcH = rows.coerceAtMost(maxSidePx)
// 创建源位图(每格一个像素的近似表示)
val srcBmp = Bitmap.createBitmap(srcW, srcH, Bitmap.Config.ARGB_8888)
val canvas = Canvas(srcBmp)
canvas.drawColor(Color.TRANSPARENT)
val paint = Paint().apply {
this.style = Paint.Style.FILL
isAntiAlias = false // 关闭抗锯齿
isFilterBitmap = false // 关键:绘制/缩放时不使用双线性滤波
}
// 采样比(如果原 grid 比 src 大,则以 nearest-neighbor 采样)
val sampleX = cols.toDouble() / srcW.toDouble()
val sampleY = rows.toDouble() / srcH.toDouble()
for (y in 0 until srcH) {
val srcRow = min((y * sampleY).toInt(), grid.rows - 1)
for (x in 0 until srcW) {
val srcCol = min((x * sampleX).toInt(), grid.cols - 1)
val v = grid.cells[srcRow * grid.cols + srcCol]
paint.color = palette(v)
// draw 1x1 rect == set single pixel
canvas.drawRect(x.toFloat(), y.toFloat(), (x + 1).toFloat(), (y + 1).toFloat(), paint)
}
}
// 再把 srcBmp 最近邻缩放到 targetW x targetHfilter=false => nearest neighbor
val finalBmp = if (srcW == targetW && srcH == targetH) {
srcBmp
} else {
Bitmap.createScaledBitmap(srcBmp, targetW, targetH, /*filter=*/ false)
}
// 清理 srcBmp若不再需要
if (finalBmp !== srcBmp) {
srcBmp.recycle()
}
// 把 finalBmp 传给 mapboximageSource coords 与之前一致)
val topLeft = listOf(grid.minLon, grid.maxLat)
val topRight = listOf(grid.maxLon, grid.maxLat)
val bottomRight = listOf(grid.maxLon, grid.minLat)
val bottomLeft = listOf(grid.minLon, grid.minLat)
val coords = listOf(topLeft, topRight, bottomRight, bottomLeft)
if (style.styleSourceExists(testSourceId)) style.removeStyleSource(testSourceId)
val imgSource = imageSource(testSourceId) { coordinates(coords) }
style.addSource(imgSource)
try {
style.removeStyleLayer(testLayerId)
} catch (_: Exception) {
}
val rasterLayer = rasterLayer(testLayerId, testSourceId) { visibility(Visibility.VISIBLE) }
style.addLayer(rasterLayer)
val src = style.getSourceAs<ImageSource>(testSourceId)
src?.updateImage(finalBmp) // 或 setImage(finalBmp) 视 SDK 而定
}
}
// -----------------------------
// 可选:把每个格子做成 GeoJSON Polygons每格一个 Fill并显示交互式但格子多时非常慢
// -----------------------------
fun MapView.displayGridAsGeoJsonPolygons(
grid: GridModel,
testSourceId: String,
testLayerId: String,
palette: (Double?) -> String = { v ->
if (v == null) "#00000000" else {
val idx = v.toInt()
// 生成 hex color 例如 #RRGGBB
String.format("#%02X%02X%02X", (50 + (idx * 37) % 200), (80 + (idx * 61) % 150), (100 + (idx * 47) % 120))
}
}
) {
mapboxMap.getStyle { style ->
val features = mutableListOf<Feature>()
val minX = lonToMercX(grid.minLon)
val maxY = latToMercY(grid.maxLat)
val cellMeters = grid.cellSizeMeters
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
// compute four corners in mercator
val x0 = minX + c * cellMeters
val y0 = maxY - r * cellMeters
val x1 = x0 + cellMeters
val y1 = y0 - cellMeters
// to lon/lat
val lon0 = mercXToLon(x0);
val lat0 = mercYToLat(y0)
val lon1 = mercXToLon(x1);
val lat1 = mercYToLat(y1)
val ring = listOf(
Point.fromLngLat(lon0, lat0),
Point.fromLngLat(lon1, lat0),
Point.fromLngLat(lon1, lat1),
Point.fromLngLat(lon0, lat1),
Point.fromLngLat(lon0, lat0)
)
val poly = Polygon.fromLngLats(listOf(ring))
val f = Feature.fromGeometry(poly)
f.addStringProperty("color", palette(v))
f.addNumberProperty("value", v)
features.add(f)
}
}
val fc = FeatureCollection.fromFeatures(features)
if (style.styleSourceExists(testSourceId)) style.removeStyleSource(testSourceId)
style.addSource(geoJsonSource(testSourceId) { featureCollection(fc) })
try {
style.removeStyleLayer(testLayerId)
} catch (_: Exception) {
}
val fill = FillLayer(testLayerId, testSourceId).apply {
// 使用 property 作为颜色表达式会更灵活;这里示例直接 constant color
fillColor(Expression.toColor(Expression.get("color")))
fillOpacity(0.8)
// Note: For property-driven color you'd use expressions; kept simple here.
}
style.addLayer(fill)
}
}
fun MapView.displayGridAsGeoJsonWithHeight(
grid: GridModel,
testSourceId: String,
testLayerId: String,
heightToColor: (Double) -> Int
) {
mapboxMap.getStyle { style ->
val features = mutableListOf<Feature>()
// 计算每格经纬度跨度
val deltaLon = (grid.maxLon - grid.minLon) / grid.cols
val deltaLat = (grid.maxLat - grid.minLat) / grid.rows
for (r in 0 until grid.rows) {
for (c in 0 until grid.cols) {
val z = grid.cells[r * grid.cols + c] ?: continue
val lon0 = grid.minLon + c * deltaLon
val lon1 = grid.minLon + (c + 1) * deltaLon
val lat0 = grid.maxLat - r * deltaLat
val lat1 = grid.maxLat - (r + 1) * deltaLat
val ring = listOf(
Point.fromLngLat(lon0, lat0),
Point.fromLngLat(lon1, lat0),
Point.fromLngLat(lon1, lat1),
Point.fromLngLat(lon0, lat1),
Point.fromLngLat(lon0, lat0)
)
val poly = Polygon.fromLngLats(listOf(ring))
val f = Feature.fromGeometry(poly)
// 添加高度属性
f.addNumberProperty("value", z)
// 根据回调生成颜色
val colorInt = heightToColor(z)
val colorStr = String.format("#%08X", colorInt)
f.addStringProperty("color", colorStr)
features.add(f)
}
}
val fc = FeatureCollection.fromFeatures(features)
// 添加或更新 GeoJSON Source
if (style.styleSourceExists(testSourceId)) style.removeStyleSource(testSourceId)
style.addSource(geoJsonSource(testSourceId) { featureCollection(fc) })
// 创建 FillLayer 并使用 feature.color
try { style.removeStyleLayer(testLayerId) } catch (_: Exception) {}
val fillLayer = FillLayer(testLayerId, testSourceId).apply {
fillColor(Expression.toColor(Expression.get("color")))
fillOpacity(0.9)
}
style.addLayer(fillLayer)
}
}

View File

@@ -0,0 +1,594 @@
package com.icegps.geotools
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Bitmap.Config
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.graphics.drawable.BitmapDrawable
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.icegps.common.helper.GeoHelper
import com.icegps.geotools.ktx.TAG
import com.icegps.geotools.ktx.niceStr
import com.icegps.geotools.model.DPoint
import com.icegps.math.geometry.Vector3D
import com.mapbox.geojson.Feature
import com.mapbox.geojson.FeatureCollection
import com.mapbox.geojson.MultiPoint
import com.mapbox.geojson.Point
import com.mapbox.geojson.Polygon
import com.mapbox.maps.CameraOptions
import com.mapbox.maps.MapView
import com.mapbox.maps.Style
import com.mapbox.maps.extension.style.expressions.generated.Expression.Companion.get
import com.mapbox.maps.extension.style.layers.addLayer
import com.mapbox.maps.extension.style.layers.addLayerBelow
import com.mapbox.maps.extension.style.layers.generated.FillLayer
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.generated.rasterLayer
import com.mapbox.maps.extension.style.layers.generated.symbolLayer
import com.mapbox.maps.extension.style.layers.properties.generated.IconAnchor
import com.mapbox.maps.extension.style.layers.properties.generated.LineJoin
import com.mapbox.maps.extension.style.layers.properties.generated.TextAnchor
import com.mapbox.maps.extension.style.layers.properties.generated.TextJustify
import com.mapbox.maps.extension.style.layers.properties.generated.Visibility
import com.mapbox.maps.extension.style.sources.addSource
import com.mapbox.maps.extension.style.sources.generated.GeoJsonSource
import com.mapbox.maps.extension.style.sources.generated.ImageSource
import com.mapbox.maps.extension.style.sources.generated.geoJsonSource
import com.mapbox.maps.extension.style.sources.generated.imageSource
import com.mapbox.maps.extension.style.sources.getSourceAs
import com.mapbox.maps.extension.style.sources.updateImage
import com.mapbox.maps.extension.style.style
import kotlin.math.roundToInt
import kotlin.random.Random
class MainActivity : AppCompatActivity() {
private lateinit var mapView: MapView
private val geoHelper = GeoHelper.getSharedInstance()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mapView = MapView(this)
setContentView(mapView)
initGeoHelper()
val pointsVector = coordinateGenerate()
val pointsLatLng = pointsVector.map {
val wgS84 = geoHelper.enuToWGS84Object(
GeoHelper.ENU(x = it.x, y = it.y, z = it.z)
)
Point.fromLngLat(wgS84.lon, wgS84.lat, wgS84.hgt)
}
Log.d(
TAG,
buildString {
appendLine("enu 坐标集合")
appendLine(pointsVector.niceStr())
}
)
/*val symbolLayer = SymbolLayer("point-layer", "point-source").apply {
this.iconImage("marker-icon")
}*/
// mapView.mapboxMap.addLayer(symbolLayer)
val delaunator = Delaunator(points = pointsLatLng.map {
DPoint(x = it.longitude(), y = it.latitude(), z = it.altitude())
})
val polygons = GeoJsonUtils.trianglesToPolygons(delaunator)
// 显示三角网
if (false) mapView.loadGeoJson(delaunator)
// val rasterLayer = RasterLayer("raster-layer", sourceId = "raster-source")
if (false) mapView.loadRaterFromTin(delaunator)
// 加载栅格网,但是没有显示出来
if (false) mapView.loadImageTest(RasterUtils.boundingBox(pointsVector.map { com.icegps.geotools.model.Point(it.x, it.y) }))
// 测试显示栅格图
// mapView.loadTestRater()
mapView.loadRasterFromResource()
val testCenter = getCenter(testCoordinates2)
// 显示三角网的各点
if (false) mapView.mapboxMap.getStyle { style ->
val geoJsonSource = GeoJsonSource.Builder(sourceId = "geojson-source")
.feature(
value = Feature.fromGeometry(
MultiPoint.fromLngLats(pointsLatLng)
)
)
// .featureCollection(polygons)
.build()
style.addSource(geoJsonSource)
// style.addLayer(FillLayer("geojson-layer", "geojson-source"))
style.addImage(
"marker-icon",
getBitmapFromDrawable(R.drawable.ic_pile_marker)
)
val symbol = symbolLayer("symbol-layer", "geojson-source") {
// filter(eq(get("geometry.type"), literal("Point")))
iconImage("marker-icon")
iconAllowOverlap(true)
iconAnchor(IconAnchor.BOTTOM)
textField(get("name")) // 显示 properties.name
textAnchor(TextAnchor.TOP)
textJustify(TextJustify.CENTER)
}
style.addLayer(symbol)
}
// 显示三角网的另一种方式
if (false) mapView.showVoronoiAsPolygons(
delaunator = delaunator,
testSourceId = "polygons-source-id-0",
testLayerId = "polygons-layer-id-0"
)
if (false) mapView.rasterizeDelaunayToMap(
delaunator = delaunator,
testSourceId = "polygons-source-id-1",
testLayerId = "polygons-layer-id-1",
)
// 显示栅格模型
val gridModel = triangulationToGrid(
delaunator = delaunator,
cellSizeMeters = 2.0,
maxSidePixels = 6553500
)
val palette: (Double?) -> Int = { v ->
if (v == null) Color.MAGENTA else { // null 显 magenta 便于确认
val idx = v.toInt()
Color.rgb((idx * 37) and 0xFF, (idx * 61) and 0xFF, (idx * 97) and 0xFF)
}
}
if (false) mapView.displayGridAsImageSourceHighRes(
grid = gridModel,
testSourceId = "raster-source-id-1",
testLayerId = "raster-layer-id-1",
)
if (true) mapView.displayGridAsGeoJsonPolygons(
grid = gridModel,
testSourceId = "raster-source-id-0",
testLayerId = "raster-layer-id-0",
)
mapView.mapboxMap.setCamera(
CameraOptions.Builder()
//.center(pointsLatLng.first())
//.center(testCenter)
.center(delaunator.points.first().let { Point.fromLngLat(it.x, it.y) })
.pitch(0.0)
.zoom(18.0)
.bearing(0.0)
.build()
)
}
}
fun initGeoHelper(base: DPoint = DPoint(114.476060, 22.771073, 30.897)) {
val geoHelper = GeoHelper.getSharedInstance()
geoHelper.wgs84ToENU(
lon = base.x,
lat = base.y,
hgt = base.z
)
}
fun coordinateGenerate(): List<Vector3D> {
val dPoints = (0..60).map {
Vector3D(x, y, z)
}
return dPoints
}
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 get() = Random.nextDouble(minX, maxX)
val y: Double get() = Random.nextDouble(minY, maxY)
val z: Double get() = Random.nextDouble(minZ, maxZ)
fun Context.getBitmapFromDrawable(drawableResId: Int): Bitmap {
val drawable = ContextCompat.getDrawable(this, drawableResId)!!
if (drawable is BitmapDrawable) {
// 如果本身就是位图,直接返回
return drawable.bitmap
}
// 如果是 VectorDrawable 或其他类型,需手动转 Bitmap
val bitmap = Bitmap.createBitmap(
drawable.intrinsicWidth.takeIf { it > 0 } ?: 1,
drawable.intrinsicHeight.takeIf { it > 0 } ?: 1,
Config.ARGB_8888
)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
return bitmap
}
// 在 Activity/Fragment 中
fun MapView.loadGeoJson(delaunator: Delaunator<DPoint>) {
mapboxMap.getStyle { style ->
// 1) 构建 FeatureCollection选择边或三角形
val edgesFc = GeoJsonUtils.trianglesToUniqueEdges(delaunator) // 或 trianglesToPolygons(delaunator)
// 2) 添加 GeoJsonSourceid = "triangles-source")
val source = GeoJsonSource.Builder("triangles-source")
.featureCollection(edgesFc)
.build()
style.addSource(source)
// 3) 添加 LineLayer 渲染三角网边
val line = lineLayer("triangles-line-layer", "triangles-source") {
lineWidth(1.5)
lineJoin(LineJoin.ROUND)
lineOpacity(1.0)
// 可以通过 expression 使用属性动态着色,这里演示静态颜色:
lineColor("#ff0000")
}
style.addLayer(line)
// 可选:如果你用 polygons 并想填充三角形
val fill = fillLayer("triangles-fill-layer", "triangles-source") {
fillOpacity(0.2)
fillColor("#00FF00")
}
style.addLayerBelow(fill, "triangles-line-layer")
}
}
private const val ID_IMAGE_SOURCE = "image_source-id"
private const val ID_IMAGE_LAYER = "image_layer-id"
fun MapView.loadImageTest(bbox: BoundingBox) {
this.mapboxMap.loadStyle(
style(style = Style.DARK) {
+imageSource(ID_IMAGE_SOURCE) {
val bboxCoordinates: List<List<Double>> = listOf(
listOf(bbox.minX, bbox.maxY), // top-left (lon, lat)
listOf(bbox.maxX, bbox.maxY), // top-right
listOf(bbox.maxX, bbox.minY), // bottom-right
listOf(bbox.minX, bbox.minY) // bottom-left
)
coordinates(bboxCoordinates)
}
+rasterLayer(ID_IMAGE_LAYER, ID_IMAGE_SOURCE) {}
}
) {
context.getBitmapFromDrawable(R.drawable.ic_launcher_background).let { bitmap ->
val imageSource: ImageSource? = it.getSourceAs(ID_IMAGE_SOURCE)
imageSource?.updateImage(bitmap)
}
}
}
fun MapView.loadRaterFromTin(delaunator: Delaunator<DPoint>) {
// add ImageSource
delaunator.points
val bbox = RasterUtils.boundingBox(delaunator.points)
val (floats, bitmap) = RasterUtils.rasterizeDelaunay(
delaunator = delaunator,
minX = bbox.minX,
minY = bbox.minY,
maxX = bbox.maxX,
maxY = bbox.maxY,
cols = 10,
rows = 10,
valueGetter = { it.z }
)
val bboxCoordinates: List<List<Double>> = listOf(
listOf(minX, maxY), // top-left (lon, lat)
listOf(maxX, maxY), // top-right
listOf(maxX, minY), // bottom-right
listOf(minX, minY) // bottom-left
)
mapboxMap.getStyle { style ->
// 如果已经存在同 id 的 source/layer先移除避免重复添加
val sourceId = "raster-image-source"
val layerId = "raster-layer"
try {
if (style.styleSourceExists(sourceId)) {
style.removeStyleSource(sourceId)
}
if (style.styleLayerExists(layerId)) {
style.removeStyleLayer(layerId)
}
} catch (e: Exception) {
// ignore
}
// add ImageSource直接传入 bitmap
val imageSrc = imageSource(sourceId) {
coordinates(bboxCoordinates)
}
style.addSource(imageSrc)
// add RasterLayer 显示 image source
val raster = rasterLayer(layerId, sourceId) {
rasterOpacity(0.9)
}
style.addLayer(raster)
}
}
val testSourceId = "test-raster-image-source"
val testLayerId = "test-rater-layer"
val testCoordinates = listOf(
listOf(-80.425, 46.437),
listOf(-71.516, 46.437),
listOf(-71.516, 37.936),
listOf(-80.425, 37.936)
)
val testCoordinates2 = listOf(
listOf(-122.5233, 37.7091), // 左上
listOf(-122.3566, 37.7091), // 右上
listOf(-122.3566, 37.8120), // 右下
listOf(-122.5233, 37.8120) // 左下
)
fun getCenter(coordinates: List<List<Double>>): Point? {
val minX = coordinates.minOf { it[0] }
val maxX = coordinates.maxOf { it[0] }
val minY = coordinates.minOf { it[1] }
val maxY = coordinates.maxOf { it[1] }
val centerX = (minX + maxX) / 2
val centerY = (minY + maxY) / 2
return Point.fromLngLat(centerX, centerY)
}
fun MapView.loadTestRater() {
mapboxMap.getStyle { style ->
// 示例 4 个角点(经度, 纬度按左Top、右Top、右Bottom、左Bottom 顺序
val coords = testCoordinates2
val imageSource = imageSource(
id = testSourceId,
block = {
url("https://docs.mapbox.com/mapbox-gl-js/assets/radar.gif")
coordinates(coords)
}
)
// 添加 source如果已有同 id 的 source 会导致异常,必要时先移除)
if (style.styleSourceExists(testSourceId)) {
style.removeStyleSource(testSourceId)
}
style.addSource(imageSource)
// 创建 raster layer类型为 rastersourceId 必须和上面一致
val rasterLayer = rasterLayer(
layerId = testLayerId,
sourceId = testSourceId,
block = {
// 可选:设置初始透明度或可见性
visibility(Visibility.VISIBLE)
rasterOpacity(1.0)
}
)
// 把 layer 插入在合适位置:例如放在 "water" 之上,或放到最顶层
// 如果你不知道在哪放,先加到最顶层:
style.addLayer(rasterLayer)
// 或者 style.addLayerAbove(rasterLayer, "water") 之类的
}
}
fun MapView.loadRasterFromResource() {
mapboxMap.getStyle { style ->
val coords = testCoordinates2
// 第一步:创建空的 ImageSource仅带坐标
val imageSource = imageSource(testSourceId) {
coordinates(coords)
}
// 如果之前存在旧的同名 source移除
if (style.styleSourceExists(testSourceId)) {
style.removeStyleSource(testSourceId)
}
style.addSource(imageSource)
// 第二步:创建 raster 图层
val rasterLayer = rasterLayer(
layerId = testLayerId,
sourceId = testSourceId
) {
visibility(Visibility.VISIBLE)
}
style.addLayer(rasterLayer)
// 第三步:加载本地 Bitmap 并更新到 source
val bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.voroni4)
style.getSourceAs<ImageSource>(testSourceId)?.updateImage(bitmap)
}
}
@Deprecated("显示效果不对")
fun MapView.showVoronoiAsPolygons(
delaunator: Delaunator<DPoint>,
testSourceId: String,
testLayerId: String
) {
mapboxMap.getStyle { style ->
// 假定 getVoronoiCells() 返回 VoronoiCell(index, points)
val features = mutableListOf<Feature>()
for (cell in delaunator.getVoronoiCells()) {
// cell.points 是 IPoint 的列表,按顺序构成多边形
val coords = cell.points.map { Point.fromLngLat(it.x, it.y) }
// GeoJSON polygon 要求首尾点相同
val ring = coords.toMutableList()
if (ring.first() != ring.last()) ring.add(ring.first())
val polygon = Polygon.fromLngLats(listOf(ring))
val f = Feature.fromGeometry(polygon)
f.addNumberProperty("index", cell.index)
features.add(f)
}
val fc = FeatureCollection.fromFeatures(features)
if (style.styleSourceExists(testSourceId)) style.removeStyleSource(testSourceId)
style.addSource(geoJsonSource(testSourceId) {
featureCollection(fc)
})
// 添加 fill layer
if (style.styleLayerExists(testLayerId)) style.removeStyleLayer(testLayerId)
val fillLayer = FillLayer(testLayerId, testSourceId).apply {
// 这里用表达式或常量颜色,也可按 property 着色
fillOpacity(0.7)
fillColor("#ff6600")
}
style.addLayer(fillLayer)
}
}
/**
* rasterizeDelaunayToMap:
* - delaunator: 提供三角网points + triangles
* - pixelsPerDegree: 控制生成的栅格分辨率(每经度多少像素),或直接给 width/height
*/
fun MapView.rasterizeDelaunayToMap(
delaunator: Delaunator<DPoint>,
testSourceId: String,
testLayerId: String,
pixelsPerDegree: Double = 400.0 // 可调:值越大图片越精细、越大
) {
mapboxMap.getStyle { style ->
val pts = delaunator.points
if (pts.isEmpty()) return@getStyle
// 1) 计算经纬包围箱
var minLon = Double.MAX_VALUE
var maxLon = -Double.MAX_VALUE
var minLat = Double.MAX_VALUE
var maxLat = -Double.MAX_VALUE
for (p in pts) {
if (p.x < minLon) minLon = p.x
if (p.x > maxLon) maxLon = p.x
if (p.y < minLat) minLat = p.y
if (p.y > maxLat) maxLat = p.y
}
// 防止 deg=0 的情况
if (minLon == maxLon) {
minLon -= 0.0001; maxLon += 0.0001
}
if (minLat == maxLat) {
minLat -= 0.0001; maxLat += 0.0001
}
val lonSpan = maxLon - minLon
val latSpan = maxLat - minLat
// 2) 决定 bitmap 尺寸(可以用 pixelsPerDegree 或直接固定大小)
val width = (pixelsPerDegree * lonSpan).roundToInt().coerceAtLeast(64)
val height = (pixelsPerDegree * latSpan).roundToInt().coerceAtLeast(64)
// 3) 创建 bitmap 和 canvas
val bitmap = Bitmap.createBitmap(width, height, Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.TRANSPARENT)
val paintFill = Paint(Paint.ANTI_ALIAS_FLAG).apply {
this.style = Paint.Style.FILL
}
val paintStroke = Paint(Paint.ANTI_ALIAS_FLAG).apply {
this.style = Paint.Style.STROKE
strokeWidth = 1f
color = Color.BLACK
}
// helper: 经纬 -> 像素
fun lon2x(lon: Double): Float {
val rel = (lon - minLon) / lonSpan
return (rel * (width - 1)).toFloat()
}
fun lat2y(lat: Double): Float {
// 像素 y 从上到下,纬度从上(大)到下(小);所以用 (maxLat - lat)
val rel = (maxLat - lat) / latSpan
return (rel * (height - 1)).toFloat()
}
// 4) 遍历三角形: delaunator.triangles 是索引数组(三元组按顺序)
val tris = delaunator.triangles
if (tris.size % 3 != 0) {
// 非常规情况:不处理
}
val triCount = tris.size / 3
for (i in 0 until triCount) {
val i0 = tris[3 * i]
val i1 = tris[3 * i + 1]
val i2 = tris[3 * i + 2]
val p0 = pts[i0]
val p1 = pts[i1]
val p2 = pts[i2]
val path = Path().apply {
moveTo(lon2x(p0.x), lat2y(p0.y))
lineTo(lon2x(p1.x), lat2y(p1.y))
lineTo(lon2x(p2.x), lat2y(p2.y))
close()
}
// 你可以自定义着色函数:例如基于点索引、面积、属性等
// 下面示例:按 index 渐变色(仅示例)
val color = colorFromIndex(i)
paintFill.color = color
canvas.drawPath(path, paintFill)
canvas.drawPath(path, paintStroke)
}
// 5) 在 Mapbox 上创建 imageSource四角经纬
val topLeft = listOf(minLon, maxLat)
val topRight = listOf(maxLon, maxLat)
val bottomRight = listOf(maxLon, minLat)
val bottomLeft = listOf(minLon, minLat)
val coords = listOf(topLeft, topRight, bottomRight, bottomLeft)
// remove old source if exists
if (style.styleSourceExists(testSourceId)) {
style.removeStyleSource(testSourceId)
}
val imgSource = imageSource(testSourceId) {
coordinates(coords)
}
style.addSource(imgSource)
// add layer
val rasterLayer = rasterLayer(testLayerId, testSourceId) {
visibility(Visibility.VISIBLE)
}
// remove old layer if present
try {
style.removeStyleLayer(testLayerId)
} catch (_: Exception) {
}
style.addLayer(rasterLayer)
// 6) 把 bitmap 更新到 source多数新版叫 updateImage
val src = style.getSourceAs<ImageSource>(testSourceId)
src?.updateImage(bitmap) // 或 src?.setImage(bitmap) 视 SDK 而定
}
}
/** 简单的着色函数:按 index 生成颜色(示例,不必准确) */
fun colorFromIndex(i: Int): Int {
// 生成一些可视颜色
val r = (50 + (i * 37) % 200)
val g = (80 + (i * 61) % 150)
val b = (100 + (i * 47) % 120)
return Color.argb(200, r, g, b)
}

View File

@@ -0,0 +1,165 @@
package com.icegps.geotools
import android.graphics.Bitmap
import com.icegps.geotools.model.IPoint
data class BoundingBox(
val minX: Double,
val minY: Double,
val maxX: Double,
val maxY: Double,
)
/**
* @author tabidachinokaze
* @date 2025/11/5
*/
object RasterUtils {
fun boundingBox(points: List<IPoint>): BoundingBox {
return BoundingBox(
minX = points.minOf { it.x },
maxX = points.maxOf { it.x },
minY = points.minOf { it.y },
maxY = points.maxOf { it.y },
)
}
/**
* Rasterize a Delaunator mesh into a regular grid.
*
* @param delaunator your Delaunator<T : IPoint> instance (points are in map coordinates, e.g., lon/lat)
* @param minX left bound (coordinate)
* @param minY bottom bound (coordinate)
* @param maxX right bound
* @param maxY top bound
* @param cols number of columns (width) of the raster
* @param rows number of rows (height) of the raster
* @param valueGetter a function that given a point index returns the value (Double) to rasterize (e.g. elevation)
* @param noDataValue value for cells not covered by any triangle (default = Float.NaN)
* @return Pair(FloatArray(values row-major), Bitmap visualization)
*/
fun <T : IPoint> rasterizeDelaunay(
delaunator: Delaunator<T>,
minX: Double,
minY: Double,
maxX: Double,
maxY: Double,
cols: Int,
rows: Int,
valueGetter: (T) -> Double,
noDataValue: Float = Float.NaN
): Pair<FloatArray, Bitmap> {
require(cols > 0 && rows > 0)
// grid: row-major, index = row * cols + col
val raster = FloatArray(cols * rows) { noDataValue }
val cellWidth = (maxX - minX) / cols.toDouble()
val cellHeight = (maxY - minY) / rows.toDouble()
// helper: convert grid coords to world coordinates (center of cell)
fun centerX(col: Int) = minX + (col + 0.5) * cellWidth
fun centerY(row: Int) = maxY - (row + 0.5) * cellHeight // top->down rows
// For performance, iterate triangles, compute their bbox in grid coords, then fill pixels inside.
val tris = delaunator.triangles
var t = 0
while (t <= tris.lastIndex) {
val ia = tris[t]
val ib = tris[t + 1]
val ic = tris[t + 2]
val pa = delaunator.points[ia]
val pb = delaunator.points[ib]
val pc = delaunator.points[ic]
// triangle bbox in world coords
val triMinX = minOf(pa.x, pb.x, pc.x)
val triMaxX = maxOf(pa.x, pb.x, pc.x)
val triMinY = minOf(pa.y, pb.y, pc.y)
val triMaxY = maxOf(pa.y, pb.y, pc.y)
// map bbox to grid index range (clamp to raster)
val minCol = (((triMinX - minX) / cellWidth).toInt()).coerceIn(0, cols - 1)
val maxCol = (((triMaxX - minX) / cellWidth).toInt()).coerceIn(0, cols - 1)
val minRow = (((maxY - triMaxY) / cellHeight).toInt()).coerceIn(0, rows - 1)
val maxRow = (((maxY - triMinY) / cellHeight).toInt()).coerceIn(0, rows - 1)
// Precompute values at vertices
val va = valueGetter(pa)
val vb = valueGetter(pb)
val vc = valueGetter(pc)
// Precompute for barycentric / edge function method
// We'll compute barycentric weights using area method
val x0 = pa.x;
val y0 = pa.y
val x1 = pb.x;
val y1 = pb.y
val x2 = pc.x;
val y2 = pc.y
// area * 2
val denom = (y1 - y2) * (x0 - x2) + (x2 - x1) * (y0 - y2)
// if denom == 0 -> degenerate triangle, skip
if (kotlin.math.abs(denom) < 1e-15) {
t += 3
continue
}
for (row in minRow..maxRow) {
val cy = centerY(row)
for (col in minCol..maxCol) {
val cx = centerX(col)
// compute barycentric coordinates (l1,l2,l3) for point (cx,cy)
val l1 = ((y1 - y2) * (cx - x2) + (x2 - x1) * (cy - y2)) / denom
val l2 = ((y2 - y0) * (cx - x2) + (x0 - x2) * (cy - y2)) / denom
val l3 = 1.0 - l1 - l2
// check if inside triangle (allow tiny negative eps)
if (l1 >= -1e-8 && l2 >= -1e-8 && l3 >= -1e-8) {
val value = l1 * va + l2 * vb + l3 * vc
raster[row * cols + col] = value.toFloat()
}
}
}
t += 3
}
// Create a simple grayscale Bitmap for visualization (normalize values ignoring NaN)
var minV = Double.POSITIVE_INFINITY
var maxV = Double.NEGATIVE_INFINITY
for (v in raster) {
if (!v.isNaN()) {
if (v < minV) minV = v.toDouble()
if (v > maxV) maxV = v.toDouble()
}
}
if (minV == Double.POSITIVE_INFINITY || maxV == Double.NEGATIVE_INFINITY) {
// all nodata
minV = 0.0
maxV = 1.0
}
val bmp = Bitmap.createBitmap(cols, rows, Bitmap.Config.ARGB_8888)
for (r in 0 until rows) {
for (c in 0 until cols) {
val v = raster[r * cols + c]
val color = if (v.isNaN()) {
// transparent for nodata
0x00000000
} else {
val norm = ((v - minV) / (maxV - minV)).coerceIn(0.0, 1.0)
val gray = (norm * 255.0).toInt()
// ARGB
(0xFF shl 24) or (gray shl 16) or (gray shl 8) or gray
}
bmp.setPixel(c, r, color)
}
}
return Pair(raster, bmp)
}
}

View File

@@ -0,0 +1,7 @@
package com.icegps.geotools.ktx
/**
* @author tabidachinokaze
* @date 2025/11/5
*/
val Any.TAG: String get() = this::class.java.simpleName

View File

@@ -0,0 +1,17 @@
package com.icegps.geotools.ktx
import com.icegps.math.geometry.Vector3D
/**
* @author tabidachinokaze
* @date 2025/11/5
*/
fun Vector3D.niceStr(): String {
return "[$x, $y, $z]".format(this)
}
fun List<Vector3D>.niceStr(): String {
return joinToString(", ", "[", "]") {
it.niceStr()
}
}

View File

@@ -0,0 +1,11 @@
package com.icegps.geotools.model
/**
* @author tabidachinokaze
* @date 2025/11/5
*/
data class DPoint(
override var x: Double,
override var y: Double,
var z: Double
) : IPoint

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,29 @@
<!-- res/drawable/ic_pile_marker.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FF2196F3"> <!-- 蓝色,可修改 -->
<!-- 圆形底座 -->
<path
android:fillColor="@android:color/white"
android:strokeColor="#FF000000"
android:strokeWidth="0.5"
android:pathData="M12,22c5.523,0 10,-4.477 10,-10S17.523,2 12,2 2,6.477 2,12s4.477,10 10,10z" />
<!-- 打桩机简笔画 -->
<path
android:fillColor="#FF000000"
android:pathData="M11,7h2v11h-2z" /> <!-- 桩杆 -->
<path
android:fillColor="#FF000000"
android:pathData="M8,7h8v2h-8z" /> <!-- 桩锤 -->
<!-- 中心定位点 -->
<path
android:fillColor="#FF2196F3"
android:pathData="M12,13.5a1.5,1.5 0,1 0,0 -3,1.5,1.5 0,0 0,0 3z" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
</androidx.constraintlayout.widget.ConstraintLayout>

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,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Geotools" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<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">geotools</string>
</resources>

View File

@@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Geotools" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

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