Compare commits

..

4 Commits

Author SHA1 Message Date
29a9d3ebf3 Remove release comment workflow
All checks were successful
Build / build (push) Successful in 5m44s
2025-10-03 20:38:10 +02:00
1128f2c7dc Fix inverted compass direction
Some checks failed
Build / build (push) Has been cancelled
2025-10-03 20:37:06 +02:00
2b2455dbb3 Use magnetometer to determine bearing, add compass datafield
Some checks failed
Build / build (push) Successful in 5m32s
Comment on Fixed Issues/PRs on Release / comment-on-fixed (push) Failing after 9s
2025-10-03 19:25:25 +02:00
timklge
1814df247b
Remove forecast along route caveat for openweathermap provider 2025-09-05 11:01:52 +02:00
10 changed files with 356 additions and 64 deletions

View File

@ -1,54 +0,0 @@
name: Comment on Fixed Issues/PRs on Release
on:
push:
tags:
- '*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to run the workflow for'
required: false
default: ''
jobs:
comment-on-fixed:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for all tags and branches
- name: Find closed issues/PRs and comment
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Use the input tag if provided, otherwise use the tag from the push event
if [ -n "${{ github.event.inputs.tag }}" ]; then
RELEASE_TAG="${{ github.event.inputs.tag }}"
else
RELEASE_TAG="${{ github.ref }}"
# Remove the 'refs/tags/' part to get the tag name
RELEASE_TAG="${RELEASE_TAG#refs/tags/}"
fi
# Get the previous tag. If there is no previous tag, this will be empty.
PREVIOUS_TAG=$(git tag --sort=-v:refname | grep -v "$RELEASE_TAG" | head -n 1)
# Get the commit range
if [ -z "$PREVIOUS_TAG" ]; then
# If there is no previous tag, get all commits up to the current tag
COMMIT_RANGE="$RELEASE_TAG"
else
COMMIT_RANGE="$PREVIOUS_TAG..$RELEASE_TAG"
fi
# Find the commits in this release
COMMITS=$(git log "$COMMIT_RANGE" --pretty=format:"%B")
# Extract issues/PRs closed (simple regex, can be improved)
echo "$COMMITS" | grep -oE "#[0-9]+" | sort -u | while read ISSUE; do
ISSUE_NUMBER="${ISSUE//#/}"
COMMENT="This issue/pr has been fixed in release ${RELEASE_TAG} :tada:"
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
done
shell: bash

View File

@ -30,12 +30,13 @@ After installing this app on your Karoo and opening it once from the main menu,
- Relative grade (numerical): Shows the relative grade. The relative grade is calculated by estimating the force of the headwind, and then calculating the gradient you would need to ride at to experience this resistance if there was no wind. Example: If you are riding on an actual gradient of 2 %, face a headwind of 18 km/h while riding at 29 km/h, the relative grade will be shown as 5.2 % (with 3.2 % added to the actual grade due to the headwind). - Relative grade (numerical): Shows the relative grade. The relative grade is calculated by estimating the force of the headwind, and then calculating the gradient you would need to ride at to experience this resistance if there was no wind. Example: If you are riding on an actual gradient of 2 %, face a headwind of 18 km/h while riding at 29 km/h, the relative grade will be shown as 5.2 % (with 3.2 % added to the actual grade due to the headwind).
- Relative elevation gain (numerical): Shows the relative elegation gain. The relative elevation gain is calculated using the relative grade and is an estimation of how much climbing would have been equivalent to the headwind you faced during the ride. - Relative elevation gain (numerical): Shows the relative elegation gain. The relative elevation gain is calculated using the relative grade and is an estimation of how much climbing would have been equivalent to the headwind you faced during the ride.
- Resistance forces (graphical, 2x1 field): Shows a graphical representation of the different forces you have to overcome while riding, including gravity (actual gradient), rolling resistance (based on speed and weight), aerodynamic drag (based on speed) and wind resistance (based on headwind speed). The app reads your weight from your karoo user profile and uses rough estimates for CdA and Crr. - Resistance forces (graphical, 2x1 field): Shows a graphical representation of the different forces you have to overcome while riding, including gravity (actual gradient), rolling resistance (based on speed and weight), aerodynamic drag (based on speed) and wind resistance (based on headwind speed). The app reads your weight from your karoo user profile and uses rough estimates for CdA and Crr.
- Compass (graphical, 1x1 field): Shows a compass needle that uses the Karoo's magnetometer (red points north).
- Additionally, data fields that only show the current data value for headwind speed, humidity, cloud cover, absolute wind speed, absolute wind gust speed, absolute wind direction, rainfall and surface pressure can be added if desired. - Additionally, data fields that only show the current data value for headwind speed, humidity, cloud cover, absolute wind speed, absolute wind gust speed, absolute wind direction, rainfall and surface pressure can be added if desired.
The app can use OpenMeteo or OpenWeatherMap as providers for live weather data. The app can use OpenMeteo or OpenWeatherMap as providers for live weather data.
- OpenMeteo is the default provider and does not require any configuration. Wind speed will be reported in km/h if your karoo is set to metric units or mph if set to imperial. - OpenMeteo is the default provider and does not require any configuration. Wind speed will be reported in km/h if your karoo is set to metric units or mph if set to imperial.
- OpenWeatherMap can provide more accurate data for some locations. Forecasts along the loaded route are not available using OpenWeatherMap. OpenWeatherMap is free for personal use, but you need to register at https://openweathermap.org/home/sign_in and obtain a one call API key (e. g. by subscribing to "One Call API 3.0" from the [pricing page](https://openweathermap.org/price)). You can enter your API key in the app settings. Please note that it can take a few hours before OpenWeatherMap enables the key. You can check if your key is enabled by entering it in the app settings and pressing "Test API Key". Wind speed will be reported in km/h if your Karoo is set to metric units and miles per hour if set to imperial. - OpenWeatherMap can provide more accurate data for some locations. OpenWeatherMap is free for personal use, but you need to register at https://openweathermap.org/home/sign_in and obtain a one call API key (e. g. by subscribing to "One Call API 3.0" from the [pricing page](https://openweathermap.org/price)). You can enter your API key in the app settings. Please note that it can take a few hours before OpenWeatherMap enables the key. You can check if your key is enabled by entering it in the app settings and pressing "Test API Key". Wind speed will be reported in km/h if your Karoo is set to metric units and miles per hour if set to imperial.
The app will automatically attempt to download weather data from the selected data provider once your device has acquired a GPS fix. Your location is rounded to approximately three kilometers to maintain privacy. The app will automatically attempt to download weather data from the selected data provider once your device has acquired a GPS fix. Your location is rounded to approximately three kilometers to maintain privacy.
New weather data is downloaded when you ride more than three kilometers from the location where the weather data was downloaded for or after one hour at the latest. New weather data is downloaded when you ride more than three kilometers from the location where the weather data was downloaded for or after one hour at the latest.

View File

@ -72,7 +72,7 @@ tasks.register("generateManifest") {
"latestVersionCode" to android.defaultConfig.versionCode, "latestVersionCode" to android.defaultConfig.versionCode,
"developer" to "github.com/timklge", "developer" to "github.com/timklge",
"description" to "Open-source extension that provides headwind direction, wind speed, forecast and other weather data fields.", "description" to "Open-source extension that provides headwind direction, wind speed, forecast and other weather data fields.",
"releaseNotes" to "* Open main extension menu when clicking on graphical headwind / tailwind datafield\n* Only update position if the estimated accuracy is within 500 meters\n* Add force distribution datafield\n* Only increase relative elevation gain when relative grade is positive", "releaseNotes" to "* Add compass data field\n* Use magnetometer to determine heading",
"screenshotUrls" to listOf( "screenshotUrls" to listOf(
"$baseUrl/preview1.png", "$baseUrl/preview1.png",
"$baseUrl/preview3.png", "$baseUrl/preview3.png",

View File

@ -1,14 +1,20 @@
package de.timklge.karooheadwind package de.timklge.karooheadwind
import android.content.Context import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.util.Log import android.util.Log
import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.util.signedAngleDifference import de.timklge.karooheadwind.util.signedAngleDifference
import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.models.DataType import io.hammerhead.karooext.models.DataType
import io.hammerhead.karooext.models.StreamState import io.hammerhead.karooext.models.StreamState
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.emitAll
@ -17,6 +23,7 @@ import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import java.time.Instant
sealed class HeadingResponse { sealed class HeadingResponse {
data object NoGps: HeadingResponse() data object NoGps: HeadingResponse()
@ -47,12 +54,10 @@ fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow<HeadingRes
} }
fun KarooSystemService.getHeadingFlow(context: Context): Flow<HeadingResponse> { fun KarooSystemService.getHeadingFlow(context: Context): Flow<HeadingResponse> {
// return flowOf(HeadingResponse.Value(20.0)) // Use magnetometer heading instead of GPS bearing
return getMagnetometerHeadingFlow(context)
return getGpsCoordinateFlow(context) .map { heading ->
.map { coords -> Log.d(KarooHeadwindExtension.TAG, "Updated magnetometer heading: $heading")
val heading = coords?.bearing
Log.d(KarooHeadwindExtension.TAG, "Updated gps bearing: $heading")
val headingValue = heading?.let { HeadingResponse.Value(it) } val headingValue = heading?.let { HeadingResponse.Value(it) }
headingValue ?: HeadingResponse.NoGps headingValue ?: HeadingResponse.NoGps
@ -156,3 +161,85 @@ fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow<GpsCoordinat
} }
.dropNullsIfNullEncountered() .dropNullsIfNullEncountered()
} }
fun KarooSystemService.getMagnetometerHeadingFlow(context: Context): Flow<Double?> = callbackFlow {
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val rotationVectorSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
Log.d(KarooHeadwindExtension.TAG, "Using magnetometer for heading")
if (rotationVectorSensor == null) {
Log.w(KarooHeadwindExtension.TAG, "Rotation vector sensor not available, falling back to GPS bearing")
// Fall back to GPS bearing
getGpsCoordinateFlow(context)
.collect { coords ->
val bearing = coords?.bearing
if (bearing != null) {
trySend(bearing)
} else {
trySend(null)
}
}
return@callbackFlow
}
var lastEventReceived: Instant? = null
val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent?) {
if (event == null){
Log.w(KarooHeadwindExtension.TAG, "Received null sensor event")
return
}
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) {
if (lastEventReceived != null){
val now = Instant.now()
val duration = java.time.Duration.between(lastEventReceived, now).toMillis()
if (duration < 500){
// Throttle to max 2 updates per second
return
}
lastEventReceived = now
} else {
lastEventReceived = Instant.now()
}
val rotationMatrix = FloatArray(9)
val orientationAngles = FloatArray(3)
// Convert rotation vector to rotation matrix
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
// Get orientation angles from rotation matrix
SensorManager.getOrientation(rotationMatrix, orientationAngles)
// Azimuth in radians, convert to degrees
val azimuthRad = orientationAngles[0]
var azimuthDeg = Math.toDegrees(azimuthRad.toDouble())
// Normalize to 0-360 range
if (azimuthDeg < 0) {
azimuthDeg += 360.0
}
// Log.d(KarooHeadwindExtension.TAG, "Rotation vector heading: $azimuthDeg degrees")
trySend(azimuthDeg)
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
Log.d(KarooHeadwindExtension.TAG, "Sensor accuracy changed: ${sensor?.name}, accuracy: $accuracy")
}
}
// Register listener for rotation vector sensor
sensorManager.registerListener(listener, rotationVectorSensor, SensorManager.SENSOR_DELAY_UI)
awaitClose {
sensorManager.unregisterListener(listener)
Log.d(KarooHeadwindExtension.TAG, "Rotation vector listener unregistered")
}
}

View File

@ -5,6 +5,7 @@ import com.mapbox.geojson.LineString
import com.mapbox.turf.TurfConstants import com.mapbox.turf.TurfConstants
import com.mapbox.turf.TurfMeasurement import com.mapbox.turf.TurfMeasurement
import de.timklge.karooheadwind.datatypes.CloudCoverDataType import de.timklge.karooheadwind.datatypes.CloudCoverDataType
import de.timklge.karooheadwind.datatypes.CompassDataType
import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.datatypes.GpsCoordinates
import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType
import de.timklge.karooheadwind.datatypes.HeadwindForecastDataType import de.timklge.karooheadwind.datatypes.HeadwindForecastDataType
@ -89,7 +90,8 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS
RelativeElevationGainDataType(karooSystem, applicationContext), RelativeElevationGainDataType(karooSystem, applicationContext),
TemperatureDataType(karooSystem, applicationContext), TemperatureDataType(karooSystem, applicationContext),
UviDataType(karooSystem, applicationContext), UviDataType(karooSystem, applicationContext),
ResistanceForcesDataType(karooSystem, applicationContext) ResistanceForcesDataType(karooSystem, applicationContext),
CompassDataType(karooSystem, applicationContext)
) )
} }

View File

@ -0,0 +1,183 @@
package de.timklge.karooheadwind.datatypes
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import androidx.glance.GlanceModifier
import androidx.glance.Image
import androidx.glance.ImageProvider
import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi
import androidx.glance.appwidget.GlanceRemoteViews
import androidx.glance.appwidget.background
import androidx.glance.appwidget.cornerRadius
import androidx.glance.layout.Box
import androidx.glance.layout.ContentScale
import androidx.glance.layout.fillMaxSize
import androidx.glance.layout.padding
import de.timklge.karooheadwind.HeadingResponse
import de.timklge.karooheadwind.KarooHeadwindExtension
import de.timklge.karooheadwind.MainActivity
import de.timklge.karooheadwind.getHeadingFlow
import de.timklge.karooheadwind.screens.isNightMode
import de.timklge.karooheadwind.streamDatatypeIsVisible
import de.timklge.karooheadwind.throttle
import io.hammerhead.karooext.KarooSystemService
import io.hammerhead.karooext.extension.DataTypeImpl
import io.hammerhead.karooext.internal.ViewEmitter
import io.hammerhead.karooext.models.ShowCustomStreamState
import io.hammerhead.karooext.models.UpdateGraphicConfig
import io.hammerhead.karooext.models.ViewConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
class CompassDataType(
private val karooSystem: KarooSystemService,
private val applicationContext: Context
) : DataTypeImpl("karoo-headwind", "compass") {
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
private val glance = GlanceRemoteViews()
data class StreamData(val bearing: Float, val isVisible: Boolean)
private fun drawCompassNeedle(bitmap: Bitmap, baseColor: Int) {
val canvas = Canvas(bitmap)
val centerX = bitmap.width / 2f
val centerY = bitmap.height / 2f
// Draw black needle pointing south (downward)
val blackPaint = Paint().apply {
color = baseColor
style = Paint.Style.FILL
isAntiAlias = true
}
val southNeedle = Path().apply {
moveTo(centerX, centerY * 1.9f) // Bottom point
lineTo(centerX - 12f, centerY) // Left base
lineTo(centerX + 12f, centerY) // Right base
close()
}
canvas.drawPath(southNeedle, blackPaint)
// Draw red needle pointing north (upward) SECOND so it's on top
val redPaint = Paint().apply {
color = android.graphics.Color.RED
style = Paint.Style.FILL
isAntiAlias = true
}
val northNeedle = Path().apply {
moveTo(centerX, centerY * 0.1f) // Top point
lineTo(centerX - 12f, centerY) // Left base
lineTo(centerX + 12f, centerY) // Right base
close()
}
canvas.drawPath(northNeedle, redPaint)
// Draw text labels
val textPaint = Paint().apply {
color = baseColor
textSize = 24f
textAlign = Paint.Align.CENTER
isAntiAlias = true
isFakeBoldText = true
}
// Draw "N" at the top
// canvas.drawText("N", centerX, centerY * 0.3f, textPaint)
// Draw "S" at the bottom
// canvas.drawText("S", centerX, centerY * 1.7f, textPaint)
// Draw center circle
val centerPaint = Paint().apply {
color = android.graphics.Color.DKGRAY
style = Paint.Style.FILL
isAntiAlias = true
}
canvas.drawCircle(centerX, centerY, 8f, centerPaint)
}
private fun previewFlow(): Flow<StreamData> {
return flow {
while (true) {
emit(StreamData(isVisible = true, bearing = 360f * Math.random().toFloat()))
delay(5_000)
}
}
}
@OptIn(ExperimentalGlanceRemoteViewsApi::class)
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
Log.d(KarooHeadwindExtension.TAG, "Starting compass view with $emitter")
val baseModifier = GlanceModifier.fillMaxSize().padding(2.dp).background(Color.White, Color.Black).cornerRadius(10.dp)
val configJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(UpdateGraphicConfig(showHeader = false))
awaitCancellation()
}
val isNight = isNightMode(context)
val baseBitmap = createBitmap(128, 128).also {
drawCompassNeedle(it, if (isNight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
}
val flow = if (config.preview) {
previewFlow()
} else {
combine(karooSystem.getHeadingFlow(context), karooSystem.streamDatatypeIsVisible(dataTypeId)){ bearingResponse, isVisible ->
val bearing = when(bearingResponse) {
is HeadingResponse.Value -> ((bearingResponse.diff + 360) % 360).toFloat()
else -> null
}
StreamData(bearing = bearing ?: 0f, isVisible = isVisible)
}
}
val viewJob = CoroutineScope(Dispatchers.IO).launch {
emitter.onNext(ShowCustomStreamState("", null))
val refreshRate = karooSystem.getRefreshRateInMilliseconds(context)
flow.filter { it.isVisible }.throttle(refreshRate).collect { streamData ->
val result = glance.compose(context, androidx.compose.ui.unit.DpSize.Unspecified) {
Box(
modifier = if (!config.preview) baseModifier.clickable(actionStartActivity<MainActivity>()) else baseModifier,
) {
Image(
modifier = GlanceModifier.fillMaxSize(),
provider = ImageProvider(getArrowBitmapByBearing(baseBitmap, 0-streamData.bearing.toInt())),
contentDescription = "Relative wind direction indicator",
contentScale = ContentScale.Fit,
)
}
}
emitter.updateView(result.remoteViews)
}
}
emitter.setCancellable {
Log.d(KarooHeadwindExtension.TAG, "Stopping compass view with $emitter")
configJob.cancel()
viewJob.cancel()
}
}
}

View File

@ -72,7 +72,7 @@ class WindDirectionAndSpeedDataType(
@OptIn(ExperimentalGlanceRemoteViewsApi::class) @OptIn(ExperimentalGlanceRemoteViewsApi::class)
override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) { override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) {
Log.d(KarooHeadwindExtension.TAG, "Starting headwind direction view with $emitter") Log.d(KarooHeadwindExtension.TAG, "Starting wind direction and speed view with $emitter")
val baseBitmap = BitmapFactory.decodeResource( val baseBitmap = BitmapFactory.decodeResource(
context.resources, context.resources,

View File

@ -49,4 +49,6 @@
<string name="windDirectionAndSpeedCircle_description">Current wind direction and wind speed (Circle graphics)</string> <string name="windDirectionAndSpeedCircle_description">Current wind direction and wind speed (Circle graphics)</string>
<string name="forces">Resistance forces</string> <string name="forces">Resistance forces</string>
<string name="forces_description">Current resistance forces (air, rolling, gradient)</string> <string name="forces_description">Current resistance forces (air, rolling, gradient)</string>
<string name="compass">Compass</string>
<string name="compass_description">Current compass direction</string>
</resources> </resources>

View File

@ -165,4 +165,11 @@
graphical="true" graphical="true"
icon="@drawable/wind" icon="@drawable/wind"
typeId="forces" /> typeId="forces" />
<DataType
description="@string/compass_description"
displayName="@string/compass"
graphical="true"
icon="@drawable/arrow"
typeId="compass" />
</ExtensionInfo> </ExtensionInfo>

View File

@ -0,0 +1,64 @@
package de.timklge.karooheadwind
import org.junit.Test
import org.junit.Assert.assertEquals
class DataStoreTest {
@Test
fun testLerpAngle_normalCase() {
// Normal case (no wrap-around)
val result = lerpAngle(90.0, 180.0, 0.5)
assertEquals(135.0, result, 0.001)
}
@Test
fun testLerpAngle_wrapAround0To360() {
// Wrap around 0/360 degrees
val result = lerpAngle(350.0, 10.0, 0.5)
assertEquals(0.0, result, 0.001)
}
@Test
fun testLerpAngle_wrapAround360To0() {
// Wrap around 0/360 degrees (reverse)
val result = lerpAngle(10.0, 350.0, 0.5)
assertEquals(0.0, result, 0.001)
}
@Test
fun testLerpAngle_largeAngleDifference() {
// Large angle difference (should take shorter path)
val result = lerpAngle(45.0, 315.0, 0.5)
assertEquals(0.0, result, 0.001)
}
@Test
fun testLerpAngle_exactly180DegreesApart() {
// Exactly 180 degrees apart
assertEquals(90.0, lerpAngle(0.0, 180.0, 0.5), 0.001)
assertEquals(90.0, lerpAngle(180.0, 0.0, 0.5), 0.001)
}
@Test
fun testLerpAngle_factorBoundaries() {
// Factor = 0 and 1
assertEquals(350.0, lerpAngle(350.0, 10.0, 0.0), 0.001)
assertEquals(0.0, lerpAngle(350.0, 10.0, 0.5), 0.001)
assertEquals(10.0, lerpAngle(350.0, 10.0, 1.0), 0.001)
}
@Test
fun testLerpAngle_negativeAngles() {
// Negative angles
val result = lerpAngle(-10.0, 10.0, 0.5)
assertEquals(0.0, result, 0.001)
}
@Test
fun testLerpAngle_anglesGreaterThan360() {
// Angles > 360
val result = lerpAngle(370.0, 380.0, 0.5)
assertEquals(15.0, result, 0.001)
}
}