← tween · API reference · version 0.1.2
6 file(s), 984 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.
Public items
A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
Curve enum | 76 | — | 109 130 142 136 142 21 … (37 total) |
Shape enum | 84 | — | 109 137 142 21 22 23 … (33 total) |
ease fn | 109 | TWN-003 | 203 21 22 23 26 27 … (31 total) |
curve_at fn | 130 | — | 111 112 114 115 |
curve_in fn | 142 | — | 133 93 101 104 114 48 |
lerp fn | 112 | TWN-002 | 203 72 73 33 35 |
Tween struct | 123 | — | 142 143 152 163 190 202 … (10 total) |
tween_new fn | 141 | — | 19 84 85 111 112 20 … (18 total) |
delay fn | 152 | — | 104 107 |
advance fn | 163 | TWN-001 | 251 271 291 27 88 92 … (33 total) |
progress fn | 190 | — | 203 80 82 84 86 |
value fn | 202 | — | 316 94 104 26 27 29 … (19 total) |
done fn | 207 | — | 272 298 300 26 100 103 … (19 total) |
rewind fn | 213 | — | 322 325 144 145 130 131 |
Play enum | 219 | — | 233 240 18 83 110 130 … (7 total) |
Track struct | 231 | — | 240 241 245 251 265 286 … (12 total) |
track_new fn | 240 | — | 18 83 110 130 134 21 |
add fn | 245 | — | 19 84 85 111 112 22 … (7 total) |
advance fn | 251 | — | 163 271 291 27 88 92 … (33 total) |
done fn | 298 | — | 207 272 300 26 100 103 … (19 total) |
active fn | 306 | — | 87 93 146 25 |
value_at fn | 314 | — | 89 90 94 99 104 115 … (9 total) |
rewind fn | 322 | — | 213 325 144 145 130 131 |
src/easing.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// The easing curves — eleven shapes, three directions, and ONE place that
// decides what happens at the ends.
//
// ── ⚠⚠ WHY THIS IS ONE FUNCTION AND NOT THIRTY-THREE ────────────
//
// An easing set is normally written out as `ease_quad_in`, `ease_quad_out`,
// `ease_quad_in_out`, … — thirty-three functions, each of which has to land on
// **exactly** 0 at `t = 0` and **exactly** 1 at `t = 1`. That is thirty-three
// independent chances to get an endpoint wrong, and getting one wrong is
// SILENT: a curve that answers 0.999999 at the end leaves whatever it was
// driving a hair short of its target, for ever, with nothing to report.
//
// ⚠ It is not a hypothetical. The textbook exponential ease is
// `2^(10(t−1))`, which answers **0.0009765625 at t = 0** rather than zero —
// every library that ships it patches the hole with an `if t == 0` special
// case, and that special case is the thirty-three-chances problem in its
// natural habitat. The textbook sine ease has the same hole at the other
// end: `1 − cos(t·π/2)` at `t = 1` is `0.9999999999999999`, because
// `cos(π/2)` in binary floating point is `6.1e−17` and not zero.
//
// So the endpoints are decided in **one** place — `curve_at`, the clamped
// accessor that every direction reads the curve through:
//
// 1. `curve_at` CLAMPS — outside `(0, 1)` it answers 0 or 1 and never
// consults the curve at all. Every one of the thirty-three combinations
// is exact at both ends because none of them is asked.
// 2. Only the **In** direction of each curve is written down. `Out` and
// `InOut` are REFLECTIONS of it:
//
// out(t) = 1 − in(1 − t)
// inout(t) = in(2t)/2 for t < ½
// = 1 − in(2 − 2t)/2 for t ≥ ½
//
// so eleven curves cover thirty-three, and a new curve is one arm of one
// match rather than three functions.
//
// ⚠ Rule 1 is what makes the contract exact; rule 2 is what keeps the file
// short enough to read. They are independent, and the test suite gates them
// separately — `curves.loft` asserts that the raw sine curve MISSES 1.0 while
// `ease` hits it, so the clamp is proven load-bearing rather than decorative.
//
// ⚠⚠ **And the clamp has to sit under the reflections, not in front of them.**
// The first cut of this file clamped `ease`'s own `t` instead, which looks
// equivalent and is not: rule 2 evaluates the curve at `1 − t` and at `2 − 2t`,
// so an in-out at exactly the midpoint asks the raw curve for `in(1)` — the
// very value that misses. It answered `0.5000000000000001`, and the test that
// caught it is `test_out_and_inout_are_exact_reflections_of_in`. A guard in
// front of three derivations is three chances to bypass it; one underneath
// them is none.
//
// ── ⚠ WHAT THE REFLECTION COSTS, STATED RATHER THAN HIDDEN ──────
//
// Penner's originals tune `back` and `elastic` with a SECOND constant for
// their in-out variants, so a hand-written `ease_back_in_out` overshoots
// harder than the reflection of `ease_back_in` does. This file does not:
// `InOut` is the reflection, always, and the two overshooting curves are
// therefore gentler in the middle than their textbook namesakes. That is a
// deliberate trade — one rule in one place against thirty-three hand-tuned
// functions — and a consumer who needs the sharper overshoot raises the
// amplitude at the call site.
//
// ── WHAT IS NOT HERE ────────────────────────────────────────────
//
// **No time, no state, no value.** A curve maps `[0, 1]` to roughly `[0, 1]`
// and knows nothing about durations or what is being animated. `tween.loft`
// owns both.
// Which shape the curve has.
//
// ⚠ `Back` and `Elastic` leave `[0, 1]` on purpose — they overshoot and come
// back — so a consumer clamping the RESULT of an ease is undoing the effect it
// asked for. Every other curve stays inside.
pub enum Curve {
Linear,
Quad, Cubic, Quart, Quint,
Sine, Expo, Circ,
Back, Elastic, Bounce,
}
// Which direction the curve runs: slow at the start, slow at the end, or both.
pub enum Shape { In, Out, InOut }
const HALF_PI = 1.5707963267948966; // π/2
const ELASTIC_P = 2.0943951023931953; // 2π/3 — elastic's period
const BACK_C1 = 1.70158; // the classic overshoot constant
// `2^-10` and `1 − 2^-10`: what an un-normalised exponential curve answers at
// `t = 0`, and the span that divides it back out. See the header.
const EXPO_ZERO = 0.0009765625;
const EXPO_SPAN = 0.9990234375;
const BOUNCE_N = 7.5625;
const BOUNCE_B1 = 0.36363636363636365; // 1 / 2.75
const BOUNCE_B2 = 0.7272727272727273; // 2 / 2.75
const BOUNCE_B3 = 0.9090909090909091; // 2.5 / 2.75
const BOUNCE_S2 = 0.5454545454545454; // 1.5 / 2.75
const BOUNCE_S3 = 0.8181818181818182; // 2.25 / 2.75
const BOUNCE_S4 = 0.9545454545454546; // 2.625 / 2.75
// Ease `t` — the entry point, and the three reflections of the header's rule 2.
//
// ⚠ There is no clamp HERE on purpose; every arm reads the curve through
// `curve_at`, which is where the endpoints are decided. See the header for
// why in front of the reflections is the wrong side of them.
// Example: @TWN-003
pub fn ease(c: Curve, s: Shape, t: float) -> float {
match s {
In => curve_at(c, t),
Out => 1.0 - curve_at(c, 1.0 - t),
InOut => {
if t < 0.5 { curve_at(c, t * 2.0) * 0.5 }
else { 1.0 - curve_at(c, (1.0 - t) * 2.0) * 0.5 }
},
}
}
// The curve, clamped — **the one place all thirty-three combinations get their
// endpoints from.**
//
// ⚠ **Clamped, not trusted.** `t <= 0` answers exactly 0.0 and `t >= 1`
// exactly 1.0 without consulting the curve, which is what makes the endpoints
// exact — including for the two curves whose textbook definitions miss them.
//
// ⚠ A `null` progress (a NaN) is neither `<= 0.0` nor `>= 1.0`, so it reaches
// the curve and comes back null. That is deliberate: an unanswerable progress
// must not silently read as "finished".
pub fn curve_at(c: Curve, t: float) -> float {
if t <= 0.0 { return 0.0; }
if t >= 1.0 { return 1.0; }
curve_in(c, t)
}
// The **In** direction of each curve, raw — the eleven definitions everything
// else is reflected from.
//
// ⚠ **Public so it can be gated, not because it is the door.** It is NOT
// clamped: `curve_in(Sine, 1.0)` is `0.9999999999999999`, which is exactly the
// hole `ease` exists to close. Reach for `ease`.
pub fn curve_in(c: Curve, t: float) -> float {
match c {
Linear => t,
Quad => t * t,
Cubic => t * t * t,
Quart => t * t * t * t,
Quint => t * t * t * t * t,
Sine => 1.0 - cos(t * HALF_PI),
// Normalised so it starts at zero rather than at 2^-10 — the header's
// first example.
Expo => ((pow2(10.0 * t - 10.0) - EXPO_ZERO) / EXPO_SPAN)?,
Circ => 1.0 - (sqrt(1.0 - t * t) ?? 0.0),
Back => (BACK_C1 + 1.0) * t * t * t - BACK_C1 * t * t,
Elastic => -pow2(10.0 * t - 10.0) * sin((t * 10.0 - 10.75) * ELASTIC_P),
Bounce => 1.0 - bounce(1.0 - t),
}
}
// `2^x`, with the nullable discharged — `pow` answers `float?`.
fn pow2(x: float) -> float {
pow(2.0, x) ?? 0.0
}
// The four parabolic hops, written in the direction they are natural in.
//
// ⚠ Bounce is the one curve whose textbook form is the OUT direction, so
// `curve_in` reflects THIS rather than the other way round — which is why the
// eleven-curve table still holds.
fn bounce(t: float) -> float {
if t < BOUNCE_B1 { return BOUNCE_N * t * t; }
if t < BOUNCE_B2 {
b = t - BOUNCE_S2;
return BOUNCE_N * b * b + 0.75;
}
if t < BOUNCE_B3 {
b = t - BOUNCE_S3;
return BOUNCE_N * b * b + 0.9375;
}
b = t - BOUNCE_S4;
BOUNCE_N * b * b + 0.984375
}
src/tween.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// tween — a value moving from one number to another over an exact duration.
//
// ── ⚠⚠ THE INVARIANT ────────────────────────────────────────────
//
// > **A tween's value is a function of the exact ratio `elapsed / duration`
// > alone — never of how the frames were cut — and both endpoints are values
// > it returns EXACTLY.**
//
// Two halves, and each of them was measured before it was written.
//
// ── ⚠ HALF ONE: TIME IS AN INTEGER, AND THE ALTERNATIVE IS NOT A ROUNDING
// DIFFERENCE ─────────────────────────────────────────────────
//
// The obvious tween keeps `elapsed` in float seconds and adds `dt` each frame.
// Measured, one second of it:
//
// 30 frames of 1/30 s sums to 0.99999999999999989
// 60 frames of 1/60 s sums to 1.00000000000000133
//
// Neither is 1.0 and neither is the other. So the same animation, at the two
// most ordinary frame rates, **ends in two different places** — and the 30 Hz
// one never arrives at all: it stops a hair short of its target and stays
// there, which is the "it lands on end − ε" bug wearing its other face.
//
// ⚠ **They are one defect, not two.** Fix the accumulation and both go away;
// clamp the endpoint and only the visible half does.
//
// So a duration here is an **integer count of base units**, and `advance` adds
// integers. Sixty units of 50 000 and thirty of 100 000 are both exactly
// 3 000 000, to the bit, and there is nothing left for a frame rate to change.
//
// ⚠ **The unit is yours.** This package never converts seconds to units and
// never names a rate — feed it whatever integer unit your clock already runs
// in, as long as it is the same one everywhere. With `fixstep` that is
// `CLOCK_UNITS_PER_SECOND` and its `clock_units_from_seconds`; **that is also
// why this package declares no dependency on it** — a second definition of
// "how long a second is" is two facts that can disagree, and the conversion
// already has a home.
//
// ── ⚠ HALF TWO: WHICH INTERPOLATION, MEASURED ───────────────────
//
// There are two ways to write a linear interpolation and they are not
// equivalent at the ends:
//
// a + (b − a)·t lands on `b` for 5 of 8 ordinary pairs
// a·(1 − t) + b·t lands on `b` for 8 of 8
//
// ⚠⚠ And the miss is not always an ε. For `a = 1e17, b = 1.0` the first form
// answers **0.0** — `(b − a)` loses `b` entirely, and adding it back to `a`
// cancels to nothing. A camera pulled from a far coordinate to the origin
// arrives at zero instead of at one, and no clamp on `t` can see it, because
// `t` was perfectly 1.0 the whole time.
//
// This package uses the second form (`lerp`), so **both** endpoints are exact
// by construction rather than by a special case. Swept over `[0, 1]` it also
// never left `[min(a,b), max(a,b)]` in 8008 samples.
//
// ── THE RULES UNDER IT, INHERITED FROM `fixstep` ────────────────
//
// 1. **No callbacks, and nothing calls your tick.** `advance` answers how
// much time it did NOT use; `done` answers whether it finished. A
// consumer polls. That is what lets one tween serve a frame loop, a
// server pump, a script runner and a test.
// 2. **Time never runs backwards.** A non-positive `advance` is dropped
// rather than owed, so a paused consumer handing down zero cannot creep.
//
// ── ⚠⚠ WHY `advance` ANSWERS A LEFTOVER, WHICH IS THE WHOLE OF SEQUENCING
//
// `fixstep`'s `Timer` DISCARDS whatever time overshoots its boundary, on
// purpose: a one-shot's residue means nothing afterwards. In a CHAIN it means
// everything — it is exactly the time the next link should already have run
// for. Measured, ten links of 0.07 s each:
//
// frame rate discard the residue carry it
// 60 Hz 0.833 s 0.700 s
// 30 Hz 1.000 s 0.700 s
// 24 Hz 0.833 s 0.708 s
//
// ⚠ Discarding does not merely round — it makes the chain's total duration a
// function of the frame rate, and **43 % long at 30 Hz**. Carrying costs at
// most one frame for the WHOLE chain (the 24 Hz cell, whose frame does not
// divide the total), rather than up to one frame PER LINK.
//
// That is why `advance` returns an integer instead of a boolean, and why
// `Track` is eleven lines rather than a scheduler.
//
// ── WHAT IS DELIBERATELY NOT HERE ───────────────────────────────
//
// **No setter and no property.** A tween ANSWERS a number; writing it
// somewhere is the consumer's, and binding one to a scene-graph property is
// `@PLN145 C2`'s. Keeping the write out is what lets the same type drive a
// camera, a colour channel, an audio gain and a value on a server.
//
// **No seconds, no frame rate, no clock.** See the unit note above.
//
// **No vector, colour or transform tween.** Those are N tweens sharing one
// clock, and N of these is already that.
pub use easing::*;
// A linear interpolation that lands EXACTLY on both endpoints.
//
// ⚠ **`a·(1−t) + b·t`, and the other spelling is not equivalent** — see the
// header: `a + (b−a)·t` answers 0.0 for `lerp(1e17, 1.0, 1.0)`. Reach for
// this one whenever an endpoint has to be hit rather than approached.
//
// `t` outside `[0, 1]` extrapolates, which is what makes the overshooting
// curves (`Back`, `Elastic`) work.
// Example: @TWN-002
pub fn lerp(a: float, b: float, t: float) -> float {
a * (1.0 - t) + b * t
}
// One value moving from `from` to `to` over `duration` base units.
//
// ⚠ **`duration <= 0` means INSTANT, not unarmed** — it reads `to` and is
// `done` immediately. That is the opposite of `fixstep`'s `Timer`, where a
// zero total means nothing is running, and it is deliberate: "move there over
// no time" is a step, and a chain built from one must pass straight through
// it rather than stall.
pub struct Tween {
from: float,
to: float,
// The whole duration, in base units. Zero or less is instant.
duration: integer,
// Base units of it that have gone by. Never past `duration`.
elapsed: integer,
// Base units still to wait before `elapsed` starts moving.
lead: integer,
curve: Curve = Curve.Linear,
shape: Shape = Shape.In,
}
// A tween ready to run.
pub fn tween_new(from: float, to: float, duration: integer,
c: Curve = Curve.Linear, s: Shape = Shape.In) -> Tween {
Tween { from: from, to: to, duration: max(duration, 0), elapsed: 0, lead: 0,
curve: c, shape: s }
}
// Wait `units` before starting.
//
// ⚠ A door rather than a sixth parameter, for `fixstep`'s reason: a delay is a
// case some callers have and most do not. During the wait the tween reads
// `from` and is not `done`.
pub fn delay(self: Tween, units: integer) {
self.lead = max(units, 0);
}
// Spend `units` of time on it, and answer how many were LEFT OVER — the time
// past its end, which is what the next link of a chain must already have run
// for. Zero while it is still running.
//
// ⚠ Non-positive time is dropped rather than owed (`fixstep`'s rule 2), so a
// paused consumer may call this every frame.
// Example: @TWN-001
pub fn advance(self: Tween, units: integer) -> integer {
if units <= 0 { return 0; }
left = units;
if self.lead > 0 {
eat = min(self.lead, left);
self.lead = self.lead - eat;
left = left - eat;
if left <= 0 { return 0; }
}
if self.duration <= 0 { return left; }
room = self.duration - self.elapsed;
if room <= 0 { return left; }
if left < room {
self.elapsed = self.elapsed + left;
return 0;
}
self.elapsed = self.duration;
left - room
}
// How far through it is, before easing — the exact ratio `elapsed / duration`,
// in `[0, 1]`.
//
// ⚠ **It reaches 1.0 and STAYS there**, which is the difference from
// `fixstep`'s `timer_fraction` (documented `[0, 1)`, and 0.0 again once the
// timer fires — a tween built on one would snap back to its start the instant
// it finished).
pub fn progress(self: const Tween) -> float {
if self.duration <= 0 { return 1.0; }
if self.elapsed >= self.duration { return 1.0; }
if self.elapsed <= 0 { return 0.0; }
((self.elapsed as float) / (self.duration as float)) ?? 0.0
}
// What it reads right now.
//
// ⚠ Exactly `from` before it starts and exactly `to` once it has finished —
// see the header for why that needs the interpolation to be written one way
// and not the other.
pub fn value(self: const Tween) -> float {
lerp(self.from, self.to, ease(self.curve, self.shape, progress(self)))
}
// Has it finished? A tween still waiting out its `delay` has not.
pub fn done(self: const Tween) -> boolean {
if self.lead > 0 { return false; }
self.duration <= 0 || self.elapsed >= self.duration
}
// Put it back at the start, delay and all — the same tween, run again.
pub fn rewind(self: Tween, lead: integer = 0) {
self.elapsed = 0;
self.lead = max(lead, 0);
}
// How several tweens share one stream of time.
pub enum Play {
// One after another; each link starts where the one before it ended.
Chain,
// All at once, off the same clock.
Parallel,
}
// Several tweens driven together.
//
// ⚠ **It is a rule about time, not a scheduler** — there is no queue, no
// priority and no callback. `Chain` hands each link's leftover to the next,
// which is the entire reason `advance` answers one.
pub struct Track {
steps: vector<Tween>,
mode: Play = Play.Chain,
// Which link is running, for `Chain`. Past the end when it has finished.
at: integer,
}
// An empty track.
pub fn track_new(mode: Play = Play.Chain) -> Track {
Track { steps: [], mode: mode, at: 0 }
}
// Add a link. For `Chain` the order of the calls is the order they run in.
pub fn add(self: Track, tw: Tween) {
self.steps += [tw];
}
// Spend `units` on the track, and answer the leftover — the same contract one
// tween has, so a track composes into a chain exactly like a tween does.
pub fn advance(self: Track, units: integer) -> integer {
if units <= 0 { return 0; }
match self.mode {
Chain => chain_advance(self, units),
Parallel => group_advance(self, units),
}
}
// Each link gets what the previous one did not use.
//
// ⚠ The loop is a `for` over a written-down bound rather than a `while`, and
// that is not a style choice: one pass either finishes a link (so `at` grows)
// or returns, so `len(steps) + 1` passes is the exact worst case — and a loop
// whose bound a reader can see is also one loft's admission control can.
fn chain_advance(self: Track, units: integer) -> integer {
left = units;
for _ in 0..=len(self.steps) {
if left <= 0 { return 0; }
i = self.at;
if i < len(self.steps) {
left = advance(self.steps[i], left);
if !done(self.steps[i]) { return 0; }
self.at = i + 1;
} else {
return left;
}
}
left
}
// Every link gets the same time.
//
// ⚠ The group's leftover is the SMALLEST of its links', because the group ends
// when the last one does — a link that finished early has more time to spare,
// and reporting that would run the next link of an enclosing chain too far.
fn group_advance(self: Track, units: integer) -> integer {
if len(self.steps) == 0 { return units; }
out = units;
for i in 0..len(self.steps) {
if i < len(self.steps) {
out = min(out, advance(self.steps[i], units));
}
}
out
}
// Have all its links finished?
pub fn done(self: const Track) -> boolean {
for s in self.steps {
if !done(s) { return false; }
}
true
}
// Which link is running, for `Chain` — `len(steps)` once it has finished.
pub fn active(self: const Track) -> integer {
self.at
}
// What the link at `i` reads right now.
//
// ⚠ A door rather than making a consumer index `steps` itself, because an
// out-of-range index there reads `null` and this answers `0.0`.
pub fn value_at(self: const Track, i: integer) -> float {
if i >= 0 {
if i < len(self.steps) { return value(self.steps[i]); }
}
0.0
}
// Put every link back at its start.
pub fn rewind(self: Track) {
for i in 0..len(self.steps) {
if i < len(self.steps) {
rewind(self.steps[i], 0);
}
}
self.at = 0;
}
tests/curves.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN145 C1 — the easing table, and the one rule that decides its endpoints.
//
// The hand-computed values below are split into two groups ON PURPOSE. The
// polynomial curves are asserted with `==`, because their sample points are
// dyadic — `quad_in(½) = ¼` is exact in binary and any tolerance there would
// hide a real error. The rest go through `cos`, `sqrt` or `pow`, so they are
// asserted to within 1e-15 and the test says so rather than pretending.
use tween::*;
fn close(a: float, b: float) -> boolean {
abs(a - b) < 0.000000000000001
}
// THE HAND-COMPUTED TABLE. Every expected value here was derived on paper
// from the definition in `easing.loft`, not read off a run.
fn test_the_easing_table_is_what_was_computed_by_hand() {
// Linear is the identity, exactly.
assert(ease(Curve.Linear, Shape.In, 0.25) == 0.25, "linear is the identity");
assert(ease(Curve.Linear, Shape.Out, 0.25) == 0.25, "and has no direction");
assert(ease(Curve.Linear, Shape.InOut, 0.25) == 0.25, "in any of the three");
// t², t³, t⁴, t⁵ at t = ½ — all dyadic, so exact.
assert(ease(Curve.Quad, Shape.In, 0.5) == 0.25, "quad in ½ = ¼");
assert(ease(Curve.Cubic, Shape.In, 0.5) == 0.125, "cubic in ½ = ⅛");
assert(ease(Curve.Quart, Shape.In, 0.5) == 0.0625, "quart in ½ = 1/16");
assert(ease(Curve.Quint, Shape.In, 0.5) == 0.03125, "quint in ½ = 1/32");
// Out is the reflection: 1 − in(1 − t).
assert(ease(Curve.Quad, Shape.Out, 0.5) == 0.75, "quad out ½ = 1 − ¼");
assert(ease(Curve.Quad, Shape.Out, 0.25) == 0.4375, "quad out ¼ = 1 − 0.75²");
assert(ease(Curve.Quad, Shape.In, 0.25) == 0.0625, "quad in ¼ = 1/16");
// InOut is in(2t)/2 below the midpoint and its mirror above.
assert(ease(Curve.Quad, Shape.InOut, 0.25) == 0.125, "quad in-out ¼ = ¼/2");
assert(ease(Curve.Quad, Shape.InOut, 0.75) == 0.875, "quad in-out ¾ = 1 − ¼/2");
assert(ease(Curve.Cubic, Shape.InOut, 0.25) == 0.0625, "cubic in-out ¼ = ⅛/2");
// The transcendental four, to 1e-15 — see the file header.
assert(close(ease(Curve.Sine, Shape.In, 0.5), 0.2928932188134524),
"sine in ½ = 1 − cos(π/4) = 1 − √2/2");
assert(close(ease(Curve.Circ, Shape.In, 0.5), 0.1339745962155614),
"circ in ½ = 1 − √0.75");
// (2^-5 − 2^-10) / (1 − 2^-10) = 31/1023 exactly, as a rational.
assert(close(ease(Curve.Expo, Shape.In, 0.5), 0.030303030303030304),
"expo in ½ = 31/1023");
// 2.70158·⅛ − 1.70158·¼ — negative, because `back` starts by pulling away.
assert(close(ease(Curve.Back, Shape.In, 0.5), -0.0876975),
"back in ½ dips below zero");
assert(ease(Curve.Back, Shape.In, 0.5) < 0.0, "and that is the point of it");
// Bounce's last hop: 7.5625·(½ − 1.5/2.75)² + 0.75 = 1/64 + ¾.
assert(close(ease(Curve.Bounce, Shape.Out, 0.5), 0.765625),
"bounce out ½ = 1/64 + ¾");
}
fn all_curves() -> vector<Curve> {
[Curve.Linear, Curve.Quad, Curve.Cubic, Curve.Quart, Curve.Quint,
Curve.Sine, Curve.Expo, Curve.Circ, Curve.Back, Curve.Elastic, Curve.Bounce]
}
fn all_shapes() -> vector<Shape> {
[Shape.In, Shape.Out, Shape.InOut]
}
// THE GATE THE WHOLE FILE IS BUILT AROUND. Every one of the thirty-three
// combinations lands on exactly 0 and exactly 1 — not near them.
fn test_every_curve_lands_exactly_on_both_endpoints() {
n = 0;
for c in all_curves() {
for s in all_shapes() {
lo = ease(c, s, 0.0);
hi = ease(c, s, 1.0);
assert(lo == 0.0, "a curve started at {lo} instead of exactly 0");
assert(hi == 1.0, "a curve ended at {hi} instead of exactly 1");
// and outside the interval it stays put rather than extrapolating
assert(ease(c, s, -3.0) == 0.0, "before the start it holds at 0");
assert(ease(c, s, 7.5) == 1.0, "after the end it holds at 1");
n = n + 1;
}
}
assert(n == 33, "eleven curves times three directions is 33, counted {n}");
}
// ⚠ THE CONTROL, and it is the reason the clamp is in `ease` rather than in
// each curve: the RAW curves genuinely miss their endpoints. If this test
// ever goes green by the raw values being exact, the gate above has stopped
// proving anything.
fn test_the_raw_curves_miss_the_endpoints_that_ease_hits() {
// 1 − cos(π/2), and cos(π/2) is 6.1e-17 in binary rather than zero.
raw_sine = curve_in(Curve.Sine, 1.0);
assert(raw_sine != 1.0, "the raw sine curve is expected to MISS 1.0");
assert(raw_sine > 0.9999999999999, "…but only just — it is a last-bit miss");
assert(ease(Curve.Sine, Shape.In, 1.0) == 1.0, "while `ease` lands on it");
// The textbook exponential answers 2^-10 at t = 0; the normalised one is 0.
textbook_expo = pow(2.0, -10.0) ?? 0.0;
assert(textbook_expo == 0.0009765625, "the textbook expo starts at 2^-10");
assert(curve_in(Curve.Expo, 0.0) == 0.0, "the normalised one starts at zero");
// Elastic's raw form misses at both ends.
assert(curve_in(Curve.Elastic, 0.0) != 0.0, "raw elastic misses 0");
assert(ease(Curve.Elastic, Shape.In, 0.0) == 0.0, "`ease` does not");
}
// The reflection rule is an identity, not an approximation — this is what
// lets eleven definitions cover thirty-three.
fn test_out_and_inout_are_exact_reflections_of_in() {
ts = [0.1, 0.25, 0.375, 0.5, 0.625, 0.75, 0.9];
for c in all_curves() {
for t in ts {
out_direct = 1.0 - curve_in(c, 1.0 - t);
assert(ease(c, Shape.Out, t) == out_direct,
"out(t) must be exactly 1 − in(1 − t)");
}
// The midpoint of an in-out is exactly half, for every curve.
assert(ease(c, Shape.InOut, 0.5) == 0.5,
"an in-out curve passes exactly through (½, ½)");
}
}
// The two overshooting curves leave [0,1] on purpose; the other nine do not.
fn test_only_back_and_elastic_leave_the_unit_interval() {
for c in all_curves() {
out = 0;
for k in 0..=200 {
t = (k as float) / 200.0;
v = ease(c, Shape.In, t);
if v < 0.0 || v > 1.0 { out = out + 1; }
}
if c == Curve.Back || c == Curve.Elastic {
assert(out > 0, "back and elastic are supposed to overshoot");
} else {
assert(out == 0, "a curve left [0,1] {out} times and should not have");
}
}
}
tests/track.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN145 C1 — sequencing, and the one number that makes it exact: the
// LEFTOVER a link hands to the next.
//
// `fixstep`'s `Timer` throws its overshoot away on purpose — a one-shot's
// residue means nothing afterwards. A chain's residue means everything, and
// the control below computes what throwing it away would have cost.
use tween::*;
const SECOND = 3000000;
const LINK = 210000; // 0.07 s — chosen because no ordinary frame divides it
const LINKS = 10;
const TOTAL = 2100000; // 0.70 s
fn chain_of(n: integer, each: integer) -> Track {
tr = track_new(Play.Chain);
for _ in 0..n { tr.add(tween_new(0.0, 1.0, each)); }
tr
}
// How many frames of `frame` units it takes to finish.
fn frames_to_finish(tr: Track, frame: integer) -> integer {
f = 0;
while !tr.done() {
_ = tr.advance(frame);
f = f + 1;
if f > 10000 { return f; } // the harness must not hang on a bug
}
f
}
// THE GATE. Ten links of 0.07 s take 0.70 s of wall time, and the frame rate
// does not get a say — bar the single partial frame at the end of the whole
// chain when the rate does not divide it.
fn test_a_chain_keeps_exact_time_at_every_frame_rate() {
at60 = frames_to_finish(chain_of(LINKS, LINK), 50000);
assert(at60 == 42, "60 Hz: 2 100 000 / 50 000 = 42 frames, got {at60}");
at30 = frames_to_finish(chain_of(LINKS, LINK), 100000);
assert(at30 == 21, "30 Hz: the same 0.70 s, in 21 frames, got {at30}");
at24 = frames_to_finish(chain_of(LINKS, LINK), 125000);
assert(at24 == 17, "24 Hz: 16.8 frames, so 17 — got {at24}");
// The 24 Hz cell overruns by ONE frame for the whole chain, not per link.
over = at24 * 125000 - TOTAL;
assert(over == 25000, "the overrun is {over} units — under one frame");
assert(over < 125000, "and strictly less than a frame, for ten links");
}
// ⚠ THE CONTROL. A chain whose links dropped their overshoot — which is what
// building one on a one-shot timer gives you — runs long, and by an amount
// that depends on the frame rate. This computes that model directly, so the
// gate above is measured against the alternative rather than against itself.
fn test_discarding_the_leftover_would_run_long_and_vary_by_rate() {
rates = [50000, 100000, 125000];
answers: vector<integer> = [];
for frame in rates {
f = 0;
for _ in 0..LINKS {
spent = 0;
while spent < LINK { spent = spent + frame; f = f + 1; }
}
answers += [f];
}
d60 = answers[0] ?? 0;
d30 = answers[1] ?? 0;
d24 = answers[2] ?? 0;
assert(d60 == 50, "discarding costs 50 frames at 60 Hz, not 42");
assert(d30 == 30, "and 30 at 30 Hz — a full second for a 0.70 s chain");
assert(d24 == 20, "and 20 at 24 Hz");
// 30 frames of 1/30 s is 1.0 s against a true 0.70 s: 43 % long.
assert(d30 * 100000 == SECOND, "the 30 Hz cell runs a whole second");
assert(d30 * 100000 > TOTAL, "which is longer than the chain actually is");
}
// A chain runs its links in order, and each one really does move — the link
// lives inside the track's own vector, so this also gates that advancing it
// through the collection reaches the stored record rather than a copy.
fn test_a_chain_runs_its_links_in_order() {
tr = track_new(Play.Chain);
tr.add(tween_new(0.0, 10.0, SECOND));
tr.add(tween_new(100.0, 200.0, SECOND));
assert(tr.active() == 0, "it starts on the first link");
_ = tr.advance(SECOND / 2 ?? 0);
assert(tr.value_at(0) == 5.0, "the first link moved to its half-way point");
assert(tr.value_at(1) == 100.0, "and the second has not started");
_ = tr.advance(SECOND / 2 ?? 0);
assert(tr.active() == 1, "the first link finished, so the second is active");
assert(tr.value_at(0) == 10.0, "and the first is parked on its end value");
// Three quarters of a second lands the second link three quarters along —
// which is only true if the first link handed over its unused time.
_ = tr.advance((SECOND * 3) / 4 ?? 0);
assert(tr.value_at(1) == 175.0, "the second link is at 175, got {tr.value_at(1)}");
assert(!tr.done(), "and the chain is not finished yet");
spare = tr.advance(SECOND);
assert(tr.done(), "one more second finishes it");
assert(tr.value_at(1) == 200.0, "on its exact end value");
assert(spare == (SECOND * 3) / 4 ?? 0, "handing back what it did not use");
}
// One stream of time, every link at once.
fn test_a_group_runs_its_links_together() {
tr = track_new(Play.Parallel);
tr.add(tween_new(0.0, 10.0, SECOND));
tr.add(tween_new(0.0, 20.0, SECOND * 2));
_ = tr.advance(SECOND);
assert(tr.value_at(0) == 10.0, "the short link finished");
assert(tr.value_at(1) == 10.0, "and the long one is half way");
assert(!tr.done(), "so the group is not done");
spare = tr.advance(SECOND * 2);
assert(tr.done(), "now every link has finished");
// ⚠ The group's leftover is the SMALLEST of its links'. The short link had
// two spare seconds; the long one had one. Reporting the short link's would
// run an enclosing chain a second too far.
assert(spare == SECOND, "the group's leftover is the slowest link's: {spare}");
}
// An empty track is finished, and hands every unit straight back — which is
// what lets a chain step over one without stalling.
fn test_an_empty_track_passes_time_straight_through() {
tr = track_new(Play.Chain);
assert(tr.done(), "an empty chain has nothing left to do");
assert(tr.advance(12345) == 12345, "and consumes none of the time");
gr = track_new(Play.Parallel);
assert(gr.done(), "the same for an empty group");
assert(gr.advance(12345) == 12345, "which is what makes them composable");
}
// Rewind puts every link back, so a track can be replayed.
fn test_rewind_replays_the_whole_track() {
tr = chain_of(3, SECOND);
_ = tr.advance(SECOND * 5);
assert(tr.done(), "it ran to the end");
tr.rewind();
assert(!tr.done(), "rewind put it back");
assert(tr.active() == 0, "on its first link");
assert(tr.value_at(2) == 0.0, "with the last link at its start again");
assert(frames_to_finish(tr, SECOND) == 3, "and it runs the same length again");
}
tests/value.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// @PLN145 C1 — the tween core: the same animation must come out the same
// however the frames were cut, and it must ARRIVE.
//
// Both gates below are paired with a control that computes the same thing the
// obvious way (float seconds, or the other spelling of a linear
// interpolation) and asserts that it DISAGREES. Without those, a green run
// here would only prove that this file agrees with itself.
use tween::*;
// 3 000 000 base units per second — `fixstep`'s unit, and the divisor that
// makes 24, 25, 30, 50, 60 and 120 Hz all whole numbers.
const SECOND = 3000000;
// THE GATE. One second of animation, sampled at the same ten instants, is
// bit-identical at 30 Hz and at 60 Hz.
fn test_thirty_hertz_and_sixty_hertz_agree_to_the_bit() {
slow = tween_new(0.0, 100.0, SECOND, Curve.Cubic, Shape.InOut);
fast = tween_new(0.0, 100.0, SECOND, Curve.Cubic, Shape.InOut);
for k in 0..30 {
_ = slow.advance(100000); // one frame at 30 Hz
_ = fast.advance(50000); // two frames at 60 Hz
_ = fast.advance(50000);
assert(slow.value() == fast.value(),
"at instant {k} the two rates parted: {slow.value()} vs {fast.value()}");
}
assert(slow.value() == 100.0, "and 30 Hz ARRIVED, exactly");
assert(fast.value() == 100.0, "and so did 60 Hz");
}
// ⚠ THE CONTROL for the gate above: the obvious float-seconds accumulation
// does NOT agree, and does NOT arrive. If this ever passes, integer time has
// stopped being the reason the gate holds.
fn test_float_seconds_would_disagree_and_would_not_arrive() {
slow = 0.0;
for _ in 0..30 { slow = slow + (1.0 / 30.0 ?? 0.0); }
fast = 0.0;
for _ in 0..60 { fast = fast + (1.0 / 60.0 ?? 0.0); }
assert(slow != fast, "float seconds are expected to part at the two rates");
assert(slow != 1.0, "30 Hz in float seconds stops SHORT of the end");
assert(fast != 1.0, "60 Hz in float seconds runs PAST it");
assert(slow < 1.0 && fast > 1.0, "and they miss in opposite directions");
}
// A tween reads exactly `from` before it moves and exactly `to` once it is
// done — for pairs where the arithmetic makes that hard.
fn test_it_lands_on_the_endpoints_exactly() {
froms = [0.1, -3.7, 100.0, 1e17, 0.0];
tos = [0.3, 9.1, 0.7, 1.0, 1.0];
for i in 0..len(froms) {
a = froms[i] ?? 0.0;
b = tos[i] ?? 0.0;
tw = tween_new(a, b, SECOND, Curve.Elastic, Shape.InOut);
assert(tw.value() == a, "before it starts a tween reads exactly `from`");
_ = tw.advance(SECOND);
assert(tw.done(), "one full duration finishes it");
assert(tw.value() == b, "and it lands on exactly `to`, not near it");
}
}
// ⚠ THE CONTROL for the endpoints: the other spelling of a linear
// interpolation misses — and for the far-apart pair it does not merely miss,
// it answers zero.
fn test_the_other_interpolation_spelling_misses() {
a = 1e17;
b = 1.0;
naive = a + (b - a) * 1.0;
assert(naive != b, "`a + (b−a)·t` is expected to miss at t = 1");
assert(naive == 0.0, "and for this pair it loses `b` completely");
assert(lerp(a, b, 1.0) == b, "while `lerp` lands on it");
assert(lerp(a, b, 0.0) == a, "at both ends");
}
// Progress reaches 1.0 and STAYS there — the difference from a one-shot timer,
// which disarms at its boundary and reads zero again afterwards.
fn test_progress_reaches_one_and_stays() {
tw = tween_new(0.0, 1.0, SECOND);
assert(tw.progress() == 0.0, "it starts at zero");
_ = tw.advance(SECOND / 2 ?? 0);
assert(tw.progress() == 0.5, "half the duration is exactly half way");
_ = tw.advance(SECOND);
assert(tw.progress() == 1.0, "and it reaches exactly one");
_ = tw.advance(SECOND * 10);
assert(tw.progress() == 1.0, "…and stays there however long it is fed");
assert(tw.value() == 1.0, "so the value stays at the end, not back at the start");
}
// Time never runs backwards, and a paused consumer handing down nothing cannot
// creep.
fn test_non_positive_time_is_dropped() {
tw = tween_new(0.0, 1.0, SECOND);
_ = tw.advance(SECOND / 4 ?? 0);
before = tw.value();
for _ in 0..100 { _ = tw.advance(0); }
for _ in 0..100 { _ = tw.advance(-50000); }
assert(tw.value() == before, "zero and negative time moved it");
}
// A delay holds it at the start without finishing it, then it runs normally.
fn test_a_delay_holds_it_at_the_start() {
tw = tween_new(5.0, 9.0, SECOND);
tw.delay(SECOND);
assert(!tw.done(), "a delayed tween has not finished");
_ = tw.advance(SECOND);
assert(tw.value() == 5.0, "the whole delay passed and it has not moved");
assert(!tw.done(), "and it is still not finished");
_ = tw.advance(SECOND / 2 ?? 0);
assert(tw.value() == 7.0, "then it runs: half of 5 → 9 is 7");
over = tw.advance(SECOND);
assert(tw.done() && tw.value() == 9.0, "and it arrives");
assert(over == (SECOND / 2 ?? 0), "with the unused half handed back");
}
// A duration of zero is INSTANT, not unarmed — the deliberate divergence from
// `fixstep`'s `Timer`, where a zero total means nothing is running.
fn test_a_zero_duration_is_instant() {
tw = tween_new(2.0, 8.0, 0);
assert(tw.done(), "a zero-duration tween is already finished");
assert(tw.value() == 8.0, "and reads its destination");
assert(tw.advance(1234) == 1234, "it consumes no time at all");
}
// Rewind runs the same tween again, delay and all.
fn test_rewind_runs_it_again() {
tw = tween_new(0.0, 4.0, SECOND);
_ = tw.advance(SECOND);
assert(tw.done(), "it finished once");
tw.rewind();
assert(!tw.done(), "and rewind put it back");
assert(tw.value() == 0.0, "at the start");
_ = tw.advance(SECOND / 2 ?? 0);
assert(tw.value() == 2.0, "running the same way as before");
}
tests/worked-examples.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for `tween` (@PLN141): each `test_*` is a running demonstration of a
// contract the signatures cannot state, cited from the function it explains with
// `// Example: @TWN-00N`.
use tween::*;
// @TWN-001 — time is an INTEGER count of your clock's units, and `advance` answers the
// leftover. Sixty steps of 50 000 and thirty of 100 000 are both exactly the 3 000 000 a
// tween of that duration needs, so it lands on `to` at either rate; and the units past its end
// are handed back, which is what lets a chain run its next link for exactly that long.
fn test_integer_time_lands_and_hands_back_the_rest() {
at60 = tween_new(0.0, 1.0, 3000000);
for _i in 0..60 { _ = at60.advance(50000); }
at30 = tween_new(0.0, 1.0, 3000000);
for _i in 0..30 { _ = at30.advance(100000); }
assert(at60.value() == 1.0 && at30.value() == 1.0, "both rates arrive exactly");
short = tween_new(0.0, 1.0, 100);
assert(short.advance(130) == 30, "30 units past the end are handed back");
chain = track_new();
chain.add(tween_new(0.0, 1.0, 100));
chain.add(tween_new(1.0, 2.0, 100));
_ = chain.advance(150);
assert(chain.active() == 1 && chain.value_at(1) == 1.5, "the second link ran for the 50 the first left");
}
// @TWN-002 — `lerp` is `a·(1−t) + b·t`, and the other spelling misses the end. `a + (b−a)·t`
// loses `b` inside `b − a` when the two are far apart: from 1e17 to 1.0 it arrives at 0.0.
fn test_lerp_hits_the_end_the_other_form_misses() {
a = 1e17;
b = 1.0;
assert(lerp(a, b, 1.0) == 1.0, "lerp arrives at b");
assert(a + (b - a) * 1.0 == 0.0, "the other spelling arrives at 0");
assert(lerp(2.0, 4.0, 0.5) == 3.0, "and in the middle it is the ordinary answer");
}
// @TWN-003 — every curve is exact at both ends because `ease` never asks the curve there,
// and `Back` / `Elastic` leave [0, 1] on purpose. The raw `curve_in(Sine, 1.0)` is
// 0.9999999999999999 — the hole `ease` closes — and clamping an overshooting curve's result
// undoes the overshoot it was chosen for.
fn test_endpoints_are_exact_and_overshoot_is_meant() {
for c in [Curve.Linear, Quad, Sine, Expo, Back, Elastic, Bounce] {
for s in [Shape.In, Out, InOut] {
assert(ease(c, s, 0.0) == 0.0 && ease(c, s, 1.0) == 1.0, "exact ends for every curve and shape");
}
}
assert(curve_in(Sine, 1.0) < 1.0, "the raw curve misses the end");
assert(ease(Back, In, 0.3) < 0.0, "Back pulls back below 0 before it goes");
}