152 lines
5.5 KiB
Python
152 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""AOSP's fling spline, transcribed independently of the Rust port.
|
|
|
|
This exists so the numbers in `sense.rs`'s `the_spline_matches_aosps_own_table`
|
|
and `a_flick_decelerates_the_way_aosp_says_it_does` are not the Rust code
|
|
grading its own homework. Every test iris's fling had before 2026-09-07
|
|
compared the curve with itself -- monotonic, signed, integrates to the closed
|
|
form -- and all of them passed while `distance_fraction(t)` was returning
|
|
exactly `t` (see `android_fling_spline`'s doc comment). Numbers checked into a
|
|
test have to come from somewhere else, and this is the somewhere else.
|
|
|
|
Transcribed by hand from, and only from:
|
|
|
|
* frameworks/base `core/java/android/widget/OverScroller.java`,
|
|
`SplineOverScroller`'s static initialiser, `getSplineDeceleration`,
|
|
`getSplineFlingDistance`, `getSplineFlingDuration` and `update`.
|
|
* androidx.compose.animation:animation:1.12.0 `SplineBasedDecay.kt`
|
|
(`computeSplineInfo`, `AndroidFlingSpline.flingPosition`) and
|
|
`FlingCalculator.kt` (`computeDeceleration`, `flingDistance`,
|
|
`flingDuration`, `FlingInfo.position`/`velocity`). The two agree line for
|
|
line, which is why iris ports one curve rather than two.
|
|
|
|
Run it with no arguments; it prints the table entries and the (velocity,
|
|
density, t) points the Rust tests assert on.
|
|
"""
|
|
|
|
NB_SAMPLES = 100
|
|
INFLEXION = 0.35
|
|
START_TENSION = 0.5
|
|
END_TENSION = 1.0
|
|
P1 = START_TENSION * INFLEXION
|
|
P2 = 1.0 - END_TENSION * (1.0 - INFLEXION)
|
|
|
|
SCROLL_FRICTION = 0.015
|
|
TUNING = 0.84
|
|
GRAVITY_EARTH = 9.80665
|
|
INCHES_PER_METER = 39.37
|
|
|
|
import math
|
|
|
|
DECELERATION_RATE = math.log(0.78) / math.log(0.9)
|
|
|
|
|
|
def spline_positions():
|
|
"""SPLINE_POSITION: distance fraction at each of 101 even time steps."""
|
|
position = [0.0] * (NB_SAMPLES + 1)
|
|
x_min = 0.0
|
|
for i in range(NB_SAMPLES):
|
|
alpha = i / NB_SAMPLES
|
|
x_max = 1.0
|
|
while True:
|
|
x = x_min + (x_max - x_min) / 2.0
|
|
coef = 3.0 * x * (1.0 - x)
|
|
tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x
|
|
if abs(tx - alpha) < 1e-5:
|
|
break
|
|
if tx > alpha:
|
|
x_max = x
|
|
else:
|
|
x_min = x
|
|
position[i] = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x
|
|
position[NB_SAMPLES] = 1.0
|
|
return position
|
|
|
|
|
|
POSITION = spline_positions()
|
|
|
|
|
|
def fling_sample(t):
|
|
"""(distance fraction, velocity fraction) at time fraction `t`."""
|
|
t = min(max(t, 0.0), 1.0)
|
|
index = int(t * NB_SAMPLES)
|
|
if index >= NB_SAMPLES:
|
|
return 1.0, 0.0
|
|
t_inf = index / NB_SAMPLES
|
|
t_sup = (index + 1) / NB_SAMPLES
|
|
velocity_coef = (POSITION[index + 1] - POSITION[index]) / (t_sup - t_inf)
|
|
return POSITION[index] + (t - t_inf) * velocity_coef, velocity_coef
|
|
|
|
|
|
def physical_coefficient(density):
|
|
return GRAVITY_EARTH * INCHES_PER_METER * density * 160.0 * TUNING
|
|
|
|
|
|
def deceleration(velocity, density):
|
|
return math.log(
|
|
INFLEXION * abs(velocity) / (SCROLL_FRICTION * physical_coefficient(density))
|
|
)
|
|
|
|
|
|
def fling_distance(velocity, density):
|
|
l = deceleration(velocity, density)
|
|
return (
|
|
SCROLL_FRICTION
|
|
* physical_coefficient(density)
|
|
* math.exp(DECELERATION_RATE / (DECELERATION_RATE - 1.0) * l)
|
|
)
|
|
|
|
|
|
def fling_duration_s(velocity, density):
|
|
l = deceleration(velocity, density)
|
|
return math.exp(l / (DECELERATION_RATE - 1.0))
|
|
|
|
|
|
def position_at(velocity, density, t_seconds):
|
|
d = fling_duration_s(velocity, density)
|
|
return fling_distance(velocity, density) * fling_sample(t_seconds / d)[0]
|
|
|
|
|
|
def velocity_at(velocity, density, t_seconds):
|
|
d = fling_duration_s(velocity, density)
|
|
return fling_sample(t_seconds / d)[1] * fling_distance(velocity, density) / d
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("SPLINE_POSITION at a few indices (index: value)")
|
|
for i in (0, 1, 10, 25, 50, 75, 99, 100):
|
|
print(f" {i:3}: {POSITION[i]:.6f}")
|
|
print()
|
|
print("distance/velocity fraction at time fractions")
|
|
for t in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0):
|
|
d, v = fling_sample(t)
|
|
print(f" t={t:<5} distance={d:.6f} velocity={v:.6f}")
|
|
print()
|
|
# 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md);
|
|
# 2.75 is this checkout's emulator.
|
|
for density in (2.55, 2.75):
|
|
# 15250 is `app/touch/flick-120hz.touch`'s own
|
|
# release velocity (velocity_reference.py), so `phone_screen.rs`
|
|
# can bound the fling it produces from *here* rather than from the
|
|
# `FlingCalculator` under test (docs/REVIEW-2026-09-07.md's T1).
|
|
for velocity in (5000.0, 11064.0, 15250.0):
|
|
dur = fling_duration_s(velocity, density)
|
|
print(
|
|
f"density={density} v={velocity}: "
|
|
f"distance={fling_distance(velocity, density):.3f}px "
|
|
f"duration={dur:.4f}s"
|
|
)
|
|
# Deliberately not round fractions. The velocity coefficient is
|
|
# piecewise *constant* across each of the 100 samples, so it
|
|
# steps at t = k/100 and a test asserting on 0.75 is asserting
|
|
# on which side of a discontinuity the last float landed --
|
|
# which is genuinely different between Python and Rust and says
|
|
# nothing about the curve.
|
|
for frac in (0.125, 0.335, 0.505, 0.755):
|
|
t = frac * dur
|
|
print(
|
|
f" t={frac:>4} of duration ({t:.4f}s): "
|
|
f"pos={position_at(velocity, density, t):.3f}px "
|
|
f"vel={velocity_at(velocity, density, t):.3f}px/s"
|
|
)
|