feat: polish & windviz & deploy
This commit is contained in:
parent
81b8e763bd
commit
465ad00f7b
78 changed files with 20622 additions and 2154 deletions
25
internal/numerics/atmosphere.go
Normal file
25
internal/numerics/atmosphere.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package numerics
|
||||
|
||||
import "math"
|
||||
|
||||
// NasaDensity returns air density in kg/m^3 at the given altitude in metres,
|
||||
// using the NASA piecewise standard-atmosphere model.
|
||||
// See https://www.grc.nasa.gov/WWW/K-12/airplane/atmosmet.html.
|
||||
//
|
||||
// The model is split into three altitude bands (troposphere, lower
|
||||
// stratosphere, upper stratosphere); density is pressure / (0.2869 * T_K).
|
||||
func NasaDensity(alt float64) float64 {
|
||||
var temp, pressure float64
|
||||
switch {
|
||||
case alt > 25000:
|
||||
temp = -131.21 + 0.00299*alt
|
||||
pressure = 2.488 * math.Pow((temp+273.1)/216.6, -11.388)
|
||||
case alt > 11000:
|
||||
temp = -56.46
|
||||
pressure = 22.65 * math.Exp(1.73-0.000157*alt)
|
||||
default:
|
||||
temp = 15.04 - 0.00649*alt
|
||||
pressure = 101.29 * math.Pow((temp+273.1)/288.08, 5.256)
|
||||
}
|
||||
return pressure / (0.2869 * (temp + 273.1))
|
||||
}
|
||||
41
internal/numerics/geometry.go
Normal file
41
internal/numerics/geometry.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package numerics
|
||||
|
||||
import "math"
|
||||
|
||||
// PointInPolygon reports whether (lat, lng) lies inside the closed polygon
|
||||
// whose vertices are given as parallel latitude/longitude slices (degrees).
|
||||
//
|
||||
// The test is ray casting in plate-carrée space. Every longitude is
|
||||
// normalised to within 180° of the first vertex before testing, so a polygon
|
||||
// spanning the antimeridian is handled correctly as long as it spans no more
|
||||
// than 180° in longitude. polyLat and polyLng must have equal length >= 3.
|
||||
func PointInPolygon(lat, lng float64, polyLat, polyLng []float64) bool {
|
||||
n := len(polyLat)
|
||||
if n < 3 || len(polyLng) != n {
|
||||
return false
|
||||
}
|
||||
ref := polyLng[0]
|
||||
qx := NormalizeLng(lng, ref)
|
||||
|
||||
inside := false
|
||||
for i, j := 0, n-1; i < n; j, i = i, i+1 {
|
||||
yi, yj := polyLat[i], polyLat[j]
|
||||
xi := NormalizeLng(polyLng[i], ref)
|
||||
xj := NormalizeLng(polyLng[j], ref)
|
||||
|
||||
if (yi > lat) != (yj > lat) {
|
||||
xIntersect := (xj-xi)*(lat-yi)/(yj-yi) + xi
|
||||
if qx < xIntersect {
|
||||
inside = !inside
|
||||
}
|
||||
}
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
// NormalizeLng rewrites v so that it lies within 180° of ref. For example,
|
||||
// NormalizeLng(350, 10) returns -10. Used to make longitude comparisons
|
||||
// continuous across the antimeridian.
|
||||
func NormalizeLng(v, ref float64) float64 {
|
||||
return ref + math.Mod(v-ref+540, 360) - 180
|
||||
}
|
||||
|
|
@ -56,31 +56,74 @@ func (a Axis) Locate(value float64) (Bracket, error) {
|
|||
return Bracket{Lo: lo, Hi: hi, Frac: pos - float64(lo)}, nil
|
||||
}
|
||||
|
||||
// EvalTrilinear samples a 3D field via f at the eight corners defined by b3
|
||||
// and returns the trilinearly interpolated value.
|
||||
// TrilinearWeights returns the eight corner weights for a (axis0, axis1,
|
||||
// axis2) bracket triple, in the canonical visiting order
|
||||
//
|
||||
// The corners are visited in the order (axis0 outer, axis2 inner), matching
|
||||
// the Cython reference. With f(i,j,k) = a*i + b*j + c*k + d this returns
|
||||
// a*pos0 + b*pos1 + c*pos2 + d exactly, modulo floating-point rounding.
|
||||
func EvalTrilinear(b3 [3]Bracket, f func(i, j, k int) float64) float64 {
|
||||
// (0,0,0) (0,0,1) (0,1,0) (0,1,1) (1,0,0) (1,0,1) (1,1,0) (1,1,1)
|
||||
//
|
||||
// where the bit triple selects Lo (0) or Hi (1) on each axis. The weights sum
|
||||
// to 1. Pair this with Dot8 over corner values fetched in the same order.
|
||||
func TrilinearWeights(b3 [3]Bracket) [8]float64 {
|
||||
wa0, wa1 := 1-b3[0].Frac, b3[0].Frac
|
||||
wb0, wb1 := 1-b3[1].Frac, b3[1].Frac
|
||||
wc0, wc1 := 1-b3[2].Frac, b3[2].Frac
|
||||
|
||||
wa0wb0 := wa0 * wb0
|
||||
wa0wb1 := wa0 * wb1
|
||||
wa1wb0 := wa1 * wb0
|
||||
wa1wb1 := wa1 * wb1
|
||||
|
||||
return [8]float64{
|
||||
wa0wb0 * wc0,
|
||||
wa0wb0 * wc1,
|
||||
wa0wb1 * wc0,
|
||||
wa0wb1 * wc1,
|
||||
wa1wb0 * wc0,
|
||||
wa1wb0 * wc1,
|
||||
wa1wb1 * wc0,
|
||||
wa1wb1 * wc1,
|
||||
}
|
||||
}
|
||||
|
||||
// Dot8 returns the multiply-accumulate sum w[0]*v[0] + ... + w[7]*v[7].
|
||||
//
|
||||
// The fixed length and straight-line accumulation are written so the Go
|
||||
// compiler can keep the values in registers and a future hand-vectorised
|
||||
// port can replace the body with a single SIMD MAC. The accumulation order
|
||||
// is fixed (ascending index) so results are reproducible.
|
||||
func Dot8(w, v *[8]float64) float64 {
|
||||
acc := w[0] * v[0]
|
||||
acc = w[1]*v[1] + acc
|
||||
acc = w[2]*v[2] + acc
|
||||
acc = w[3]*v[3] + acc
|
||||
acc = w[4]*v[4] + acc
|
||||
acc = w[5]*v[5] + acc
|
||||
acc = w[6]*v[6] + acc
|
||||
acc = w[7]*v[7] + acc
|
||||
return acc
|
||||
}
|
||||
|
||||
// EvalTrilinear samples a 3D field via f at the eight corners defined by b3
|
||||
// and returns the trilinearly interpolated value.
|
||||
//
|
||||
// Corners are visited in the canonical order documented on TrilinearWeights.
|
||||
// With f(i,j,k) = a*i + b*j + c*k + d this returns a*pos0 + b*pos1 + c*pos2
|
||||
// + d, modulo floating-point rounding. For the hot path prefer precomputing
|
||||
// weights once via TrilinearWeights and reducing with Dot8.
|
||||
func EvalTrilinear(b3 [3]Bracket, f func(i, j, k int) float64) float64 {
|
||||
w := TrilinearWeights(b3)
|
||||
a0, a1 := b3[0].Lo, b3[0].Hi
|
||||
bb0, bb1 := b3[1].Lo, b3[1].Hi
|
||||
b0, b1 := b3[1].Lo, b3[1].Hi
|
||||
c0, c1 := b3[2].Lo, b3[2].Hi
|
||||
|
||||
return wa0*wb0*wc0*f(a0, bb0, c0) +
|
||||
wa0*wb0*wc1*f(a0, bb0, c1) +
|
||||
wa0*wb1*wc0*f(a0, bb1, c0) +
|
||||
wa0*wb1*wc1*f(a0, bb1, c1) +
|
||||
wa1*wb0*wc0*f(a1, bb0, c0) +
|
||||
wa1*wb0*wc1*f(a1, bb0, c1) +
|
||||
wa1*wb1*wc0*f(a1, bb1, c0) +
|
||||
wa1*wb1*wc1*f(a1, bb1, c1)
|
||||
}
|
||||
|
||||
// Lerp returns (1-l)*a + l*b.
|
||||
func Lerp(a, b, l float64) float64 {
|
||||
return (1-l)*a + l*b
|
||||
v := [8]float64{
|
||||
f(a0, b0, c0),
|
||||
f(a0, b0, c1),
|
||||
f(a0, b1, c0),
|
||||
f(a0, b1, c1),
|
||||
f(a1, b0, c0),
|
||||
f(a1, b0, c1),
|
||||
f(a1, b1, c0),
|
||||
f(a1, b1, c1),
|
||||
}
|
||||
return Dot8(&w, &v)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +1,46 @@
|
|||
package numerics
|
||||
|
||||
// VecAdd computes y + k*dy on the domain state type S.
|
||||
// Any coordinate-wrap or other domain-specific operation lives here.
|
||||
type VecAdd[S any] func(y S, k float64, dy S) S
|
||||
// Field returns the time derivative of a geographic state at (t, y).
|
||||
// The derivative is direction-independent; the integrator applies the sign
|
||||
// of dt for reverse-time integration.
|
||||
type Field func(t float64, y GeoVec) GeoVec
|
||||
|
||||
// VecLerp computes (1-l)*a + l*b on the domain state type S.
|
||||
type VecLerp[S any] func(a, b S, l float64) S
|
||||
|
||||
// Deriv computes the time derivative of state.
|
||||
type Deriv[S any] func(t float64, y S) S
|
||||
|
||||
// Trigger reports whether a termination condition holds at (t, y).
|
||||
type Trigger[S any] func(t float64, y S) bool
|
||||
// Crossed reports whether a termination condition holds at (t, y).
|
||||
type Crossed func(t float64, y GeoVec) bool
|
||||
|
||||
// RK4Step performs one classical Runge-Kutta-4 step from (t, y) with step dt.
|
||||
// dt may be negative to integrate backwards in time.
|
||||
func RK4Step[S any](t float64, y S, dt float64, deriv Deriv[S], add VecAdd[S]) S {
|
||||
k1 := deriv(t, y)
|
||||
k2 := deriv(t+dt/2, add(y, dt/2, k1))
|
||||
k3 := deriv(t+dt/2, add(y, dt/2, k2))
|
||||
k4 := deriv(t+dt, add(y, dt, k3))
|
||||
// dt may be negative to integrate backwards in time. Longitude wrapping is
|
||||
// applied at every intermediate add via GeoAdd, matching the reference
|
||||
// integrator. The function performs no heap allocation.
|
||||
func RK4Step(t float64, y GeoVec, dt float64, f Field) GeoVec {
|
||||
half := dt / 2
|
||||
k1 := f(t, y)
|
||||
k2 := f(t+half, GeoAdd(y, half, k1))
|
||||
k3 := f(t+half, GeoAdd(y, half, k2))
|
||||
k4 := f(t+dt, GeoAdd(y, dt, k3))
|
||||
|
||||
y2 := y
|
||||
y2 = add(y2, dt/6, k1)
|
||||
y2 = add(y2, dt/3, k2)
|
||||
y2 = add(y2, dt/3, k3)
|
||||
y2 = add(y2, dt/6, k4)
|
||||
y2 := GeoAdd(y, dt/6, k1)
|
||||
y2 = GeoAdd(y2, dt/3, k2)
|
||||
y2 = GeoAdd(y2, dt/3, k3)
|
||||
y2 = GeoAdd(y2, dt/6, k4)
|
||||
return y2
|
||||
}
|
||||
|
||||
// RefineTrigger locates the trigger point between (t1, y1) (trigger not fired)
|
||||
// and (t2, y2) (trigger fired) via binary search in the linear-interpolation
|
||||
// parameter space, stopping when the parameter interval is narrower than tol.
|
||||
// RefineCrossing locates a crossing between (t1, y1) (not crossed) and
|
||||
// (t2, y2) (crossed) by binary search in the linear-interpolation parameter
|
||||
// space, stopping when the parameter interval is narrower than tol.
|
||||
//
|
||||
// Returns the final midpoint sampled, matching the behavior of Tawhiri's
|
||||
// solver.pyx (the returned point is *not* guaranteed to satisfy the trigger;
|
||||
// for tol << 1 the difference is at most one tolerance-width either side).
|
||||
func RefineTrigger[S any](
|
||||
t1 float64, y1 S,
|
||||
t2 float64, y2 S,
|
||||
trigger Trigger[S],
|
||||
lerp VecLerp[S],
|
||||
tol float64,
|
||||
) (float64, S) {
|
||||
// It returns the final midpoint sampled, matching Tawhiri's solver.pyx: the
|
||||
// returned point is not guaranteed to satisfy the predicate, but for tol << 1
|
||||
// it is within one tolerance-width of the true crossing.
|
||||
func RefineCrossing(t1 float64, y1 GeoVec, t2 float64, y2 GeoVec, crossed Crossed, tol float64) (float64, GeoVec) {
|
||||
left, right := 0.0, 1.0
|
||||
t3 := t2
|
||||
y3 := y2
|
||||
|
||||
t3, y3 := t2, y2
|
||||
for right-left > tol {
|
||||
mid := (left + right) / 2
|
||||
t3 = Lerp(t1, t2, mid)
|
||||
y3 = lerp(y1, y2, mid)
|
||||
if trigger(t3, y3) {
|
||||
y3 = GeoLerp(y1, y2, mid)
|
||||
if crossed(t3, y3) {
|
||||
right = mid
|
||||
} else {
|
||||
left = mid
|
||||
|
|
@ -59,3 +48,47 @@ func RefineTrigger[S any](
|
|||
}
|
||||
return t3, y3
|
||||
}
|
||||
|
||||
// Path is a struct-of-arrays trajectory: parallel slices of time and the
|
||||
// three state components. SoA layout keeps each component contiguous, which
|
||||
// is friendlier to cache and to vectorised post-processing than a slice of
|
||||
// point structs, and lets the integrator append with a single bounds check
|
||||
// per component.
|
||||
type Path struct {
|
||||
T []float64
|
||||
Lat []float64
|
||||
Lng []float64
|
||||
Altitude []float64
|
||||
}
|
||||
|
||||
// NewPath returns a Path with capacity reserved for n points.
|
||||
func NewPath(n int) Path {
|
||||
return Path{
|
||||
T: make([]float64, 0, n),
|
||||
Lat: make([]float64, 0, n),
|
||||
Lng: make([]float64, 0, n),
|
||||
Altitude: make([]float64, 0, n),
|
||||
}
|
||||
}
|
||||
|
||||
// Len returns the number of points in the path.
|
||||
func (p *Path) Len() int { return len(p.T) }
|
||||
|
||||
// Append adds one point to the path.
|
||||
func (p *Path) Append(t float64, y GeoVec) {
|
||||
p.T = append(p.T, t)
|
||||
p.Lat = append(p.Lat, y.Lat)
|
||||
p.Lng = append(p.Lng, y.Lng)
|
||||
p.Altitude = append(p.Altitude, y.Altitude)
|
||||
}
|
||||
|
||||
// Last returns the final (t, state) of the path. It panics on an empty path.
|
||||
func (p *Path) Last() (float64, GeoVec) {
|
||||
i := len(p.T) - 1
|
||||
return p.T[i], GeoVec{Lat: p.Lat[i], Lng: p.Lng[i], Altitude: p.Altitude[i]}
|
||||
}
|
||||
|
||||
// At returns the point at index i.
|
||||
func (p *Path) At(i int) (float64, GeoVec) {
|
||||
return p.T[i], GeoVec{Lat: p.Lat[i], Lng: p.Lng[i], Altitude: p.Altitude[i]}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,57 +5,74 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
// scalarAdd / scalarLerp let us drive RK4 on a plain float64.
|
||||
func scalarAdd(y float64, k float64, dy float64) float64 { return y + k*dy }
|
||||
func scalarLerpF(a, b float64, l float64) float64 { return Lerp(a, b, l) }
|
||||
|
||||
func TestRK4ExponentialDecay(t *testing.T) {
|
||||
// dy/dt = -y → exact: y(t) = y0 * exp(-t).
|
||||
deriv := func(_ float64, y float64) float64 { return -y }
|
||||
// dAlt/dt = -Alt → exact: Alt(t) = Alt0 * exp(-t).
|
||||
f := func(_ float64, y GeoVec) GeoVec { return GeoVec{Altitude: -y.Altitude} }
|
||||
|
||||
y := 1.0
|
||||
tnow := 0.0
|
||||
dt := 0.01
|
||||
y := GeoVec{Altitude: 1}
|
||||
tnow, dt := 0.0, 0.01
|
||||
for range 100 {
|
||||
y = RK4Step(tnow, y, dt, deriv, scalarAdd)
|
||||
y = RK4Step(tnow, y, dt, f)
|
||||
tnow += dt
|
||||
}
|
||||
want := math.Exp(-1.0)
|
||||
if math.Abs(y-want) > 1e-8 {
|
||||
t.Errorf("RK4 exp decay at t=1: got %v, want %v (diff %v)", y, want, y-want)
|
||||
if math.Abs(y.Altitude-want) > 1e-8 {
|
||||
t.Errorf("RK4 exp decay at t=1: got %v, want %v", y.Altitude, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRK4ReverseTime(t *testing.T) {
|
||||
// dy/dt = y → exact: y(t) = y0 * exp(t).
|
||||
// Integrating from t=1 backwards with dt=-0.01 over 100 steps should give y0.
|
||||
deriv := func(_ float64, y float64) float64 { return y }
|
||||
// dAlt/dt = Alt → exact: Alt(t) = Alt0 * exp(t).
|
||||
f := func(_ float64, y GeoVec) GeoVec { return GeoVec{Altitude: y.Altitude} }
|
||||
|
||||
y := math.E
|
||||
tnow := 1.0
|
||||
dt := -0.01
|
||||
y := GeoVec{Altitude: math.E}
|
||||
tnow, dt := 1.0, -0.01
|
||||
for range 100 {
|
||||
y = RK4Step(tnow, y, dt, deriv, scalarAdd)
|
||||
y = RK4Step(tnow, y, dt, f)
|
||||
tnow += dt
|
||||
}
|
||||
if math.Abs(y-1.0) > 1e-8 {
|
||||
t.Errorf("RK4 reverse: got %v, want 1.0 (diff %v)", y, y-1.0)
|
||||
if math.Abs(y.Altitude-1.0) > 1e-8 {
|
||||
t.Errorf("RK4 reverse: got %v, want 1.0", y.Altitude)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefineTrigger(t *testing.T) {
|
||||
// y crosses 0 at l=0.4 between y1=1 and y2=-1.5.
|
||||
y1, y2 := 1.0, -1.5
|
||||
t1, t2 := 0.0, 1.0
|
||||
trig := func(_ float64, y float64) bool { return y <= 0 }
|
||||
func TestRefineCrossing(t *testing.T) {
|
||||
y1 := GeoVec{Altitude: 1}
|
||||
y2 := GeoVec{Altitude: -1.5}
|
||||
crossed := func(_ float64, y GeoVec) bool { return y.Altitude <= 0 }
|
||||
|
||||
tr, yr := RefineTrigger(t1, y1, t2, y2, trig, scalarLerpF, 0.001)
|
||||
|
||||
// The exact crossing is at l = 1/(1+1.5) = 0.4 → t = 0.4, y = 0.
|
||||
tr, yr := RefineCrossing(0, y1, 1, y2, crossed, 0.001)
|
||||
if math.Abs(tr-0.4) > 0.01 {
|
||||
t.Errorf("Refined t = %v, want ~0.4", tr)
|
||||
t.Errorf("refined t = %v, want ~0.4", tr)
|
||||
}
|
||||
if math.Abs(yr) > 0.01 {
|
||||
t.Errorf("Refined y = %v, want ~0", yr)
|
||||
if math.Abs(yr.Altitude) > 0.01 {
|
||||
t.Errorf("refined alt = %v, want ~0", yr.Altitude)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeoAddWrapsLongitude(t *testing.T) {
|
||||
y := GeoAdd(GeoVec{Lng: 350}, 1, GeoVec{Lng: 20})
|
||||
if math.Abs(y.Lng-10) > 1e-9 {
|
||||
t.Errorf("GeoAdd wrap: lng = %v, want 10", y.Lng)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeoLerpWrap(t *testing.T) {
|
||||
mid := GeoLerp(GeoVec{Lng: 350}, GeoVec{Lng: 10}, 0.5)
|
||||
if math.Abs(mid.Lng) > 1e-9 && math.Abs(mid.Lng-360) > 1e-9 {
|
||||
t.Errorf("GeoLerp lng wrap: %v, want 0 or 360", mid.Lng)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathSoA(t *testing.T) {
|
||||
p := NewPath(4)
|
||||
p.Append(0, GeoVec{Lat: 1, Lng: 2, Altitude: 3})
|
||||
p.Append(60, GeoVec{Lat: 4, Lng: 5, Altitude: 6})
|
||||
if p.Len() != 2 {
|
||||
t.Fatalf("len = %d, want 2", p.Len())
|
||||
}
|
||||
tt, last := p.Last()
|
||||
if tt != 60 || last.Lat != 4 {
|
||||
t.Errorf("last = %v, %+v", tt, last)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
66
internal/numerics/vec.go
Normal file
66
internal/numerics/vec.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package numerics
|
||||
|
||||
import "math"
|
||||
|
||||
// GeoVec is a geographic state vector: latitude and longitude in degrees and
|
||||
// altitude in metres. The same struct represents a per-second derivative,
|
||||
// in which case the fields are deg/s and m/s.
|
||||
//
|
||||
// GeoVec is the hot-path state type for the integrator. It is a small value
|
||||
// type (three float64) and is passed by value to stay allocation-free; a
|
||||
// future SIMD/SoA batch integrator can lift these fields into parallel
|
||||
// slices (see Path).
|
||||
type GeoVec struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Altitude float64 `json:"altitude"`
|
||||
}
|
||||
|
||||
// PyMod returns a mod b with Python semantics: the result carries the sign of
|
||||
// b, so for b > 0 it always lies in [0, b).
|
||||
func PyMod(a, b float64) float64 {
|
||||
r := math.Mod(a, b)
|
||||
if r < 0 {
|
||||
r += b
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// GeoAdd returns y + k*dy with longitude wrapped to [0, 360). Latitude and
|
||||
// altitude accumulate linearly. This is the integrator's state-update step.
|
||||
func GeoAdd(y GeoVec, k float64, dy GeoVec) GeoVec {
|
||||
return GeoVec{
|
||||
Lat: y.Lat + k*dy.Lat,
|
||||
Lng: PyMod(y.Lng+k*dy.Lng, 360),
|
||||
Altitude: y.Altitude + k*dy.Altitude,
|
||||
}
|
||||
}
|
||||
|
||||
// GeoLerp linearly interpolates two geographic states by parameter l in
|
||||
// [0, 1]. Longitude takes the shorter great-circle arc.
|
||||
func GeoLerp(a, b GeoVec, l float64) GeoVec {
|
||||
return GeoVec{
|
||||
Lat: (1-l)*a.Lat + l*b.Lat,
|
||||
Lng: LngLerp(a.Lng, b.Lng, l),
|
||||
Altitude: (1-l)*a.Altitude + l*b.Altitude,
|
||||
}
|
||||
}
|
||||
|
||||
// LngLerp interpolates between two longitudes in [0, 360), choosing the
|
||||
// shorter arc and wrapping the result back into range.
|
||||
func LngLerp(a, b, l float64) float64 {
|
||||
l2 := 1 - l
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
l, l2 = l2, l
|
||||
}
|
||||
if b-a < 180 {
|
||||
return l2*a + l*b
|
||||
}
|
||||
return PyMod(l2*(a+360)+l*b, 360)
|
||||
}
|
||||
|
||||
// Lerp returns (1-l)*a + l*b.
|
||||
func Lerp(a, b, l float64) float64 {
|
||||
return (1-l)*a + l*b
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue