mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-11-08 08:01:09 +00:00
Smooth deep globe flights and open the picker at level 1
Flying into a fine geohash was fast, rough and jittery: - animateTo panned linearly while zooming exponentially over a fixed duration, so near the end the remaining fraction of a degree became a huge on-screen jump (the target was still ~130 screens away at 99% progress). Large zoom changes now follow GlobeFlight, the van Wijk and Nuij smooth pan-and-zoom path used by d3-zoom and Mapbox flyTo, along a great circle, with duration scaled to the distance. - The target precision was forced for the whole flight, so a fine cell was re-selected nearly every frame, recomposing the card each time. Precision now follows the zoom and lands on the target at the end. - Every selection change fired a haptic tick, buzzing through any programmatic flight. Ticks now fire only for user-driven changes. - The view center was a Float; at deep zoom one Float step of latitude spans several pixels. It is now a Double. Opening the picker on a selected geohash no longer dives into it: it plays the same whole-globe rotation as the no-selection case, turning to face that location at level 1 so the world's activity stays in view.
This commit is contained in:
parent
12703a405b
commit
a3d7c14276
@ -114,12 +114,10 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val initialGeohash = intent.getStringExtra(EXTRA_INITIAL_GEOHASH)?.trim()?.lowercase()
|
||||
var geohashToFocus: String? = null
|
||||
var initLat = 20.0
|
||||
var initLon = 10.0
|
||||
|
||||
if (!initialGeohash.isNullOrEmpty()) {
|
||||
geohashToFocus = initialGeohash
|
||||
try {
|
||||
val (lat, lon) = Geohash.decodeToCenter(initialGeohash)
|
||||
initLat = lat
|
||||
@ -132,7 +130,6 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
if (!channels.isNullOrEmpty()) {
|
||||
val coarsestChannel = channels.minByOrNull { it.geohash.length }
|
||||
if (coarsestChannel != null) {
|
||||
geohashToFocus = coarsestChannel.geohash
|
||||
try {
|
||||
val (lat, lon) = Geohash.decodeToCenter(coarsestChannel.geohash)
|
||||
initLat = lat
|
||||
@ -142,8 +139,9 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing to focus, settle on the whole globe so the world's activity is in view.
|
||||
val initialPrecision = (geohashToFocus?.length ?: 1).coerceIn(1, 12)
|
||||
// Always open on the whole globe, turning to face the selected location (if any) rather
|
||||
// than diving into it, so the world's activity stays in view.
|
||||
val initialPrecision = 1
|
||||
val targetLat = initLat
|
||||
val targetLon = initLon
|
||||
|
||||
@ -185,7 +183,7 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
else globeState.centerLat to globeState.centerLon
|
||||
}.collect { center ->
|
||||
if (center != null) {
|
||||
activityMonitor.focusOn(center.first.toDouble(), center.second.toDouble())
|
||||
activityMonitor.focusOn(center.first, center.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -316,8 +314,8 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
onWholeGlobe = {
|
||||
cluster = null
|
||||
globeState.animateTo(
|
||||
globeState.centerLat.toDouble(),
|
||||
globeState.centerLon.toDouble(),
|
||||
globeState.centerLat,
|
||||
globeState.centerLon,
|
||||
GlobeMath.MIN_ZOOM,
|
||||
1,
|
||||
durationMs = 700
|
||||
|
||||
103
app/src/main/java/com/bitchat/android/ui/globe/GlobeFlight.kt
Normal file
103
app/src/main/java/com/bitchat/android/ui/globe/GlobeFlight.kt
Normal file
@ -0,0 +1,103 @@
|
||||
package com.bitchat.android.ui.globe
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.acos
|
||||
import kotlin.math.asin
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.cosh
|
||||
import kotlin.math.exp
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sinh
|
||||
import kotlin.math.sqrt
|
||||
import kotlin.math.tanh
|
||||
|
||||
/**
|
||||
* Smooth pan-and-zoom path from van Wijk and Nuij, "Smooth and efficient zooming and panning"
|
||||
* (the curve behind d3-zoom and Mapbox flyTo). It keeps on-screen motion even when the zoom
|
||||
* changes by orders of magnitude: it pans while zoomed out and zooms while nearly on target,
|
||||
* instead of panning linearly while zooming exponentially, which races at the end.
|
||||
*
|
||||
* [distance] is the arc between the start and end centers in radians; widths are the angular
|
||||
* span visible across the screen (screen size over globe radius) at each end.
|
||||
*/
|
||||
internal class GlobeFlight(
|
||||
private val distance: Double,
|
||||
private val startWidth: Double,
|
||||
private val endWidth: Double,
|
||||
private val rho: Double = RHO
|
||||
) {
|
||||
private val panning = distance > EPSILON
|
||||
private val r0: Double
|
||||
private val length: Double
|
||||
|
||||
init {
|
||||
if (!panning) {
|
||||
r0 = 0.0
|
||||
length = ln(endWidth / startWidth) / rho
|
||||
} else {
|
||||
val rho2 = rho * rho
|
||||
val rho4 = rho2 * rho2
|
||||
val d2 = distance * distance
|
||||
val b0 = (endWidth * endWidth - startWidth * startWidth + rho4 * d2) / (2 * startWidth * rho2 * distance)
|
||||
val b1 = (endWidth * endWidth - startWidth * startWidth - rho4 * d2) / (2 * endWidth * rho2 * distance)
|
||||
r0 = ln(sqrt(b0 * b0 + 1) - b0)
|
||||
val r1 = ln(sqrt(b1 * b1 + 1) - b1)
|
||||
length = (r1 - r0) / rho
|
||||
}
|
||||
}
|
||||
|
||||
/** Path length in the curve's own units; longer flights should take longer. */
|
||||
val pathLength: Double get() = abs(length)
|
||||
|
||||
/** At progress [t] in 0..1: the fraction of [distance] covered and the visible width. */
|
||||
fun at(t: Double): Pair<Double, Double> {
|
||||
if (t >= 1.0) return 1.0 to endWidth
|
||||
val s = t * length
|
||||
if (!panning) return t to startWidth * exp(rho * s)
|
||||
val coshR0 = cosh(r0)
|
||||
val u = startWidth / (rho * rho * distance) * (coshR0 * tanh(rho * s + r0) - sinh(r0))
|
||||
val w = startWidth * coshR0 / cosh(rho * s + r0)
|
||||
return (u / distance).coerceIn(0.0, 1.0) to w
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** d3's default: how strongly the path zooms out to travel. */
|
||||
const val RHO = 1.414
|
||||
private const val EPSILON = 1e-9
|
||||
|
||||
/** Great-circle angle between two points, radians. */
|
||||
fun arc(lat0: Double, lon0: Double, lat1: Double, lon1: Double): Double {
|
||||
val a = unit(lat0, lon0)
|
||||
val b = unit(lat1, lon1)
|
||||
val dot = (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]).coerceIn(-1.0, 1.0)
|
||||
return acos(dot)
|
||||
}
|
||||
|
||||
/** Point a [fraction] of the way along the great circle; (lat, lon) in degrees. */
|
||||
fun slerp(lat0: Double, lon0: Double, lat1: Double, lon1: Double, fraction: Double): Pair<Double, Double> {
|
||||
val a = unit(lat0, lon0)
|
||||
val b = unit(lat1, lon1)
|
||||
val omega = acos((a[0] * b[0] + a[1] * b[1] + a[2] * b[2]).coerceIn(-1.0, 1.0))
|
||||
val sinOmega = sin(omega)
|
||||
if (sinOmega < 1e-6) {
|
||||
// Same point or exact antipodes: no unique great circle, so interpolate degrees.
|
||||
val dLon = GlobeMath.normalizeLon(lon1 - lon0)
|
||||
return (lat0 + (lat1 - lat0) * fraction) to GlobeMath.normalizeLon(lon0 + dLon * fraction)
|
||||
}
|
||||
val wa = sin((1 - fraction) * omega) / sinOmega
|
||||
val wb = sin(fraction * omega) / sinOmega
|
||||
val x = wa * a[0] + wb * b[0]
|
||||
val y = wa * a[1] + wb * b[1]
|
||||
val z = wa * a[2] + wb * b[2]
|
||||
return Math.toDegrees(asin(z.coerceIn(-1.0, 1.0))) to Math.toDegrees(atan2(y, x))
|
||||
}
|
||||
|
||||
private fun unit(latDeg: Double, lonDeg: Double): DoubleArray {
|
||||
val lat = Math.toRadians(latDeg)
|
||||
val lon = Math.toRadians(lonDeg)
|
||||
return doubleArrayOf(cos(lat) * cos(lon), cos(lat) * sin(lon), sin(lat))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableDoubleStateOf
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@ -30,9 +31,11 @@ class GlobeState(
|
||||
initialPrecision: Int,
|
||||
startZoomedOut: Boolean
|
||||
) {
|
||||
var centerLat by mutableFloatStateOf(if (startZoomedOut) (targetLat * 0.4).toFloat() else targetLat.toFloat())
|
||||
// Double, not Float: at deep zoom one Float step of latitude spans several pixels, which made
|
||||
// panning and fly-ins visibly step.
|
||||
var centerLat by mutableDoubleStateOf(if (startZoomedOut) targetLat * 0.4 else targetLat)
|
||||
private set
|
||||
var centerLon by mutableFloatStateOf(if (startZoomedOut) GlobeMath.normalizeLon(targetLon - 70.0).toFloat() else targetLon.toFloat())
|
||||
var centerLon by mutableDoubleStateOf(if (startZoomedOut) GlobeMath.normalizeLon(targetLon - 70.0) else targetLon)
|
||||
private set
|
||||
var zoom by mutableFloatStateOf(if (startZoomedOut) GlobeMath.MIN_ZOOM else 1f)
|
||||
private set
|
||||
@ -82,8 +85,8 @@ class GlobeState(
|
||||
val r = globeRadiusPx
|
||||
if (r <= 0f) return
|
||||
val degPerPx = 180.0 / (Math.PI * r)
|
||||
centerLon = GlobeMath.normalizeLon(centerLon - dxPx * degPerPx).toFloat()
|
||||
centerLat = (centerLat + dyPx * degPerPx).toFloat().coerceIn(MIN_LAT, MAX_LAT)
|
||||
centerLon = GlobeMath.normalizeLon(centerLon - dxPx * degPerPx)
|
||||
centerLat = (centerLat + dyPx * degPerPx).coerceIn(MIN_LAT, MAX_LAT)
|
||||
syncSelection()
|
||||
}
|
||||
|
||||
@ -100,7 +103,14 @@ class GlobeState(
|
||||
precision = GlobeMath.autoPrecision(globeRadiusPx, screenMinPx)
|
||||
}
|
||||
|
||||
/** Animate the view to a lat/lon, optionally to a zoom and precision. */
|
||||
/**
|
||||
* Animate the view to a lat/lon, optionally to a zoom and precision.
|
||||
*
|
||||
* A large zoom change flies along [GlobeFlight] so on-screen motion stays even, and takes
|
||||
* longer the further it travels; [durationMs] is then a minimum. Precision follows the zoom
|
||||
* on the way and lands on [targetPrecision] at the end, so fine cells are not re-selected on
|
||||
* every frame while the globe is still far out.
|
||||
*/
|
||||
fun animateTo(
|
||||
lat: Double,
|
||||
lon: Double,
|
||||
@ -112,27 +122,53 @@ class GlobeState(
|
||||
animJob?.cancel()
|
||||
val startLat = centerLat
|
||||
val startLon = centerLon
|
||||
val dLon = GlobeMath.normalizeLon(lon - startLon)
|
||||
val endLat = lat.coerceIn(MIN_LAT, MAX_LAT)
|
||||
val endLon = GlobeMath.normalizeLon(lon)
|
||||
val startZoom = zoom
|
||||
val endZoom = (targetZoom ?: zoom).coerceIn(GlobeMath.MIN_ZOOM, GlobeMath.MAX_ZOOM)
|
||||
val zoomRatio = endZoom / startZoom
|
||||
val flight = if (baseRadiusPx > 0f && (zoomRatio > FLIGHT_ZOOM_RATIO || zoomRatio < 1f / FLIGHT_ZOOM_RATIO)) {
|
||||
GlobeFlight(
|
||||
distance = GlobeFlight.arc(startLat, startLon, endLat, endLon),
|
||||
startWidth = visibleWidth(startZoom),
|
||||
endWidth = visibleWidth(endZoom)
|
||||
)
|
||||
} else null
|
||||
val duration = if (flight == null) durationMs else
|
||||
(flight.pathLength * FLIGHT_MS_PER_UNIT).toInt().coerceIn(durationMs, MAX_FLIGHT_MS)
|
||||
val dLon = GlobeMath.normalizeLon(endLon - startLon)
|
||||
val generation = ++animationGeneration
|
||||
isAnimating = true
|
||||
animJob = s.launch {
|
||||
try {
|
||||
val anim = Animatable(0f)
|
||||
anim.animateTo(1f, tween(durationMs, easing = FastOutSlowInEasing)) {
|
||||
val t = value
|
||||
centerLat = (startLat + (lat.toFloat() - startLat) * t).coerceIn(MIN_LAT, MAX_LAT)
|
||||
centerLon = GlobeMath.normalizeLon(startLon + dLon * t).toFloat()
|
||||
// exponential interpolation feels natural for zoom
|
||||
zoom = startZoom * (endZoom / startZoom).pow(t)
|
||||
if (targetPrecision != null) {
|
||||
precision = targetPrecision.coerceIn(1, GlobeMath.MAX_PRECISION)
|
||||
anim.animateTo(1f, tween(duration, easing = FastOutSlowInEasing)) {
|
||||
val t = value.toDouble()
|
||||
if (flight != null) {
|
||||
val (fraction, width) = flight.at(t)
|
||||
val (fLat, fLon) = GlobeFlight.slerp(startLat, startLon, endLat, endLon, fraction)
|
||||
centerLat = fLat.coerceIn(MIN_LAT, MAX_LAT)
|
||||
centerLon = fLon
|
||||
zoom = zoomForWidth(width)
|
||||
} else {
|
||||
syncPrecisionFromZoom()
|
||||
centerLat = (startLat + (endLat - startLat) * t).coerceIn(MIN_LAT, MAX_LAT)
|
||||
centerLon = GlobeMath.normalizeLon(startLon + dLon * t)
|
||||
// Exponential interpolation feels natural for small zoom changes.
|
||||
zoom = startZoom * zoomRatio.pow(value)
|
||||
}
|
||||
syncPrecisionFromZoom()
|
||||
syncSelection()
|
||||
}
|
||||
// Land exactly on the target once the motion completes.
|
||||
centerLat = endLat
|
||||
centerLon = endLon
|
||||
zoom = endZoom
|
||||
if (targetPrecision != null) {
|
||||
precision = targetPrecision.coerceIn(1, GlobeMath.MAX_PRECISION)
|
||||
} else {
|
||||
syncPrecisionFromZoom()
|
||||
}
|
||||
syncSelection()
|
||||
} finally {
|
||||
if (animationGeneration == generation) {
|
||||
isAnimating = false
|
||||
@ -142,12 +178,18 @@ class GlobeState(
|
||||
}
|
||||
}
|
||||
|
||||
/** Angular span across the screen's short side at [zoom], radians. */
|
||||
private fun visibleWidth(zoom: Float): Double = screenMinPx.toDouble() / (baseRadiusPx * zoom)
|
||||
|
||||
private fun zoomForWidth(width: Double): Float =
|
||||
(screenMinPx / (width * baseRadiusPx)).toFloat().coerceIn(GlobeMath.MIN_ZOOM, GlobeMath.MAX_ZOOM)
|
||||
|
||||
/** Button-driven precision change: adjusts precision and animates zoom to frame it. */
|
||||
fun animatePrecision(newPrecision: Int) {
|
||||
val p = newPrecision.coerceIn(1, GlobeMath.MAX_PRECISION)
|
||||
if (p == precision || baseRadiusPx <= 0f) return
|
||||
val targetZoom = GlobeMath.zoomForPrecision(p, baseRadiusPx, screenMinPx)
|
||||
animateTo(centerLat.toDouble(), centerLon.toDouble(), targetZoom, p, durationMs = 450)
|
||||
animateTo(centerLat, centerLon, targetZoom, p, durationMs = 450)
|
||||
}
|
||||
|
||||
/** Cinematic intro: spin and zoom from a far view into the target location. */
|
||||
@ -211,8 +253,12 @@ class GlobeState(
|
||||
private companion object {
|
||||
// Close to the projection limit so polar geohash cells remain selectable;
|
||||
// clamping tighter would make syncSelection() encode the wrong cell.
|
||||
const val MIN_LAT = -89f
|
||||
const val MAX_LAT = 89f
|
||||
const val MIN_LAT = -89.0
|
||||
const val MAX_LAT = 89.0
|
||||
/** Zoom changes beyond this factor fly along [GlobeFlight]; smaller ones glide. */
|
||||
const val FLIGHT_ZOOM_RATIO = 1.5f
|
||||
const val FLIGHT_MS_PER_UNIT = 230.0
|
||||
const val MAX_FLIGHT_MS = 2_200
|
||||
const val MIN_FLING_PX_PER_SECOND = 90f
|
||||
const val MAX_FLING_PX_PER_SECOND = 3_200f
|
||||
const val MAX_FRAME_DELTA_SECONDS = 1f / 30f
|
||||
@ -222,7 +268,7 @@ class GlobeState(
|
||||
|
||||
private fun syncSelection() {
|
||||
if (baseRadiusPx <= 0f) return
|
||||
val gh = Geohash.encode(centerLat.toDouble(), centerLon.toDouble(), precision)
|
||||
val gh = Geohash.encode(centerLat, centerLon, precision)
|
||||
if (gh != selectedGeohash) selectedGeohash = gh
|
||||
}
|
||||
}
|
||||
|
||||
@ -298,6 +298,8 @@ fun GlobeView(
|
||||
snapshotFlow { state.selectedGeohash }
|
||||
.drop(1)
|
||||
.collect {
|
||||
// Tick for cells the user drags across, not for every cell a fly-in passes.
|
||||
if (state.isAnimating) return@collect
|
||||
@Suppress("DEPRECATION")
|
||||
view.performHapticFeedback(
|
||||
HapticFeedbackConstants.KEYBOARD_TAP,
|
||||
@ -416,8 +418,8 @@ fun GlobeView(
|
||||
val latLon = GlobeMath.unproject(
|
||||
((downPos.x - cx) / r).toDouble(),
|
||||
((downPos.y - cy) / r).toDouble(),
|
||||
state.centerLat.toDouble(),
|
||||
state.centerLon.toDouble()
|
||||
state.centerLat,
|
||||
state.centerLon
|
||||
)
|
||||
if (latLon != null) {
|
||||
val now = upTime
|
||||
@ -446,8 +448,8 @@ fun GlobeView(
|
||||
val baseR = state.baseRadiusPx
|
||||
if (baseR <= 0f) return@Canvas
|
||||
val r = state.globeRadiusPx
|
||||
val cLat = state.centerLat.toDouble()
|
||||
val cLon = state.centerLon.toDouble()
|
||||
val cLat = state.centerLat
|
||||
val cLon = state.centerLon
|
||||
val preparedProjector = GlobeMath.PreparedProjector(cLat, cLon)
|
||||
|
||||
// Atmosphere glow
|
||||
@ -591,8 +593,8 @@ fun GlobeView(
|
||||
layoutGlobeMarkers(
|
||||
channels = activity,
|
||||
projector = GlobeMath.PreparedProjector(
|
||||
state.centerLat.toDouble(),
|
||||
state.centerLon.toDouble()
|
||||
state.centerLat,
|
||||
state.centerLon
|
||||
),
|
||||
cx = size.width / 2f,
|
||||
cy = size.height / 2f,
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
package com.bitchat.android.ui.globe
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.pow
|
||||
|
||||
class GlobeFlightTest {
|
||||
|
||||
// A dive from the whole globe into a city-block cell about 57 degrees away.
|
||||
private val distance = 1.0
|
||||
private val globeWidth = 2.27
|
||||
private val blockWidth = 7e-5
|
||||
|
||||
/** How far the target sits from screen center, in screen widths. */
|
||||
private fun targetOffset(fraction: Double, width: Double) = (1 - fraction) * distance / width
|
||||
|
||||
@Test
|
||||
fun flight_startsAndEndsExactly() {
|
||||
val flight = GlobeFlight(distance, globeWidth, blockWidth)
|
||||
|
||||
val (startFraction, startWidth) = flight.at(0.0)
|
||||
assertEquals(0.0, startFraction, 1e-9)
|
||||
assertEquals(globeWidth, startWidth, 1e-9)
|
||||
assertEquals(1.0 to blockWidth, flight.at(1.0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deepFlight_bringsTheTargetInSteadilyWithoutRacing() {
|
||||
val flight = GlobeFlight(distance, globeWidth, blockWidth)
|
||||
var previous = Double.MAX_VALUE
|
||||
var largestStep = 0.0
|
||||
val steps = 400
|
||||
for (i in 0..steps) {
|
||||
val (fraction, width) = flight.at(i.toDouble() / steps)
|
||||
val offset = targetOffset(fraction, width)
|
||||
assertTrue("target must never move away", offset <= previous + 1e-9)
|
||||
if (i > 0) largestStep = maxOf(largestStep, previous - offset)
|
||||
previous = offset
|
||||
}
|
||||
assertTrue("largest jump was $largestStep screens", largestStep < 0.05)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun linearPanWithExponentialZoom_racedAtTheEnd() {
|
||||
// The old path: just before landing the target was still many screens away.
|
||||
val t = 0.99
|
||||
val width = globeWidth * (blockWidth / globeWidth).pow(t)
|
||||
assertTrue(targetOffset(t, width) > 50.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zoomOnly_isExponentialInWidth() {
|
||||
val flight = GlobeFlight(0.0, 1.0, 0.01)
|
||||
|
||||
val (fraction, width) = flight.at(0.5)
|
||||
assertEquals(0.5, fraction, 1e-9)
|
||||
assertEquals(0.1, width, 1e-6)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longerFlights_takeLonger() {
|
||||
assertTrue(
|
||||
GlobeFlight(distance, globeWidth, blockWidth).pathLength >
|
||||
GlobeFlight(distance, globeWidth, 0.1).pathLength
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun slerp_followsTheShortWayAcrossTheAntimeridian() {
|
||||
val (lat, lon) = GlobeFlight.slerp(0.0, 170.0, 0.0, -170.0, 0.5)
|
||||
|
||||
assertEquals(0.0, lat, 1e-9)
|
||||
assertEquals(180.0, abs(lon), 1e-6)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun slerp_hitsBothEndpoints() {
|
||||
val (lat0, lon0) = GlobeFlight.slerp(10.0, 20.0, -30.0, 100.0, 0.0)
|
||||
val (lat1, lon1) = GlobeFlight.slerp(10.0, 20.0, -30.0, 100.0, 1.0)
|
||||
|
||||
assertEquals(10.0, lat0, 1e-9)
|
||||
assertEquals(20.0, lon0, 1e-9)
|
||||
assertEquals(-30.0, lat1, 1e-9)
|
||||
assertEquals(100.0, lon1, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun arc_measuresGreatCircleAngles() {
|
||||
assertEquals(Math.PI / 2, GlobeFlight.arc(0.0, 0.0, 0.0, 90.0), 1e-9)
|
||||
assertEquals(Math.PI / 2, GlobeFlight.arc(0.0, 0.0, 90.0, 0.0), 1e-9)
|
||||
assertEquals(0.0, GlobeFlight.arc(45.0, 45.0, 45.0, 45.0), 1e-6)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user