tween source

← 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.

ItemDefinedWorked exampleCall sites
Curve enum76—109 130 142 136 142 21 … (37 total)
Shape enum84—109 137 142 21 22 23 … (33 total)
ease fn109TWN-003203 21 22 23 26 27 … (31 total)
curve_at fn130—111 112 114 115
curve_in fn142—133 93 101 104 114 48
lerp fn112TWN-002203 72 73 33 35
Tween struct123—142 143 152 163 190 202 … (10 total)
tween_new fn141—19 84 85 111 112 20 … (18 total)
delay fn152—104 107
advance fn163TWN-001251 271 291 27 88 92 … (33 total)
progress fn190—203 80 82 84 86
value fn202—316 94 104 26 27 29 … (19 total)
done fn207—272 298 300 26 100 103 … (19 total)
rewind fn213—322 325 144 145 130 131
Play enum219—233 240 18 83 110 130 … (7 total)
Track struct231—240 241 245 251 265 286 … (12 total)
track_new fn240—18 83 110 130 134 21
add fn245—19 84 85 111 112 22 … (7 total)
advance fn251—163 271 291 27 88 92 … (33 total)
done fn298—207 272 300 26 100 103 … (19 total)
active fn306—87 93 146 25
value_at fn314—89 90 94 99 104 115 … (9 total)
rewind fn322—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");
}