git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit27f5e82cee1aa8194d3f7a2c36b661e42b080925
parent0d8b4a1f2c
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 21:01
fix(playbar): play every frame, slowing down rather than skipping

A tick moves the shown frame by one at most, so a simulation too slow for
the rate plays every step slower instead of drawing some of them. The loop
is every frame of the range, the end and the start a frame each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md      | 15 +++++++++++++++
 src/main.rs    | 42 +++++++++++++++++++++++++++++++++++++++++-
 src/playbar.rs | 47 +++++++++++++++++++++++++++--------------------
 3 files changed, 83 insertions(+), 21 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index ff737d8..cd16a0c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3252,6 +3252,21 @@ bottom-anchored text, the scale readout and a viewer state's line, stands
 on it (`State::scene_text_floor`) rather than on its transport.
 `the_playbar_is_attached_to_the_bottom_edge` is the test.
 
+### Playback plays every frame (since 2026-10-06)
+
+`Playbar::tick` moves the playhead by `dt * fps`, but the SHOWN frame
+(the playhead rounded) by one at most: a tick that would carry it further
+lands on the next frame. So playback holds the rate while the frames keep
+up and slows to a frame a tick when they do not — a simulation too slow
+for its rate plays every step, slower, where until then the playhead kept
+to the clock and the frames between two ticks were never drawn (the solve
+still ran them; they were not seen). The loop is every frame of the
+range, frame k shown over k ± 0.5, so the step past the end is to the
+start in either direction; it was a loop of `end - start` that gave the
+end and the start half a frame each, and with the cap would have stepped
+over the start. With Repeat off, landing on the far end stops there.
+`playback_plays_every_frame_however_late_the_tick` is the test.
+
 ### The playbar's right-click menu
 
 A right press on the playbar's plate (`over_playbar`) opens the sixth
diff --git a/src/main.rs b/src/main.rs
index 7febd41..70def00 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -18,6 +18,7 @@ pub mod shapes;
 pub mod gpu;
 pub mod springs;
 pub mod collide;
+pub mod surface_flow;
 
 // Root-level aliases some modules import via `crate::` paths.
 #[allow(unused_imports)]
@@ -3601,11 +3602,50 @@ mod tests {
         let moved =
             cce_ui::widget::Input::tick(state.slots.playbar.inner_mut(), 0.1, rect);
         assert!(moved, "reverse playback advances the frame");
+        assert_eq!(state.slots.playbar.inner().current_frame, 1.0, "the start frame is played, not stepped over");
+        cce_ui::widget::Input::tick(state.slots.playbar.inner_mut(), 0.1, rect);
         let f = state.slots.playbar.inner().current_frame;
-        assert!(f > 200.0, "running off the start wraps to the end, got {f}");
+        assert_eq!(f.round(), 240.0, "running off the start wraps to the end, got {f}");
         assert!(state.slots.playbar.inner().playing, "the wrap does not stop playback");
     }
 
+    /// Playback plays every frame: a tick that came late moves the shown
+    /// frame by one, not by as many as the clock says, so a slow
+    /// simulation plays every step slower rather than skipping some — in
+    /// either direction and across the loop. A tick that keeps up still
+    /// plays at the rate.
+    #[test]
+    fn playback_plays_every_frame_however_late_the_tick() {
+        use cce_ui::widget::Input;
+        let rect = cce_ui::scene::layout::Rect { x: 0.0, y: 0.0, width: 100.0, height: 30.0 };
+        let mut adapted = crate::playbar::Playbar::new();
+        let pb = adapted.inner_mut();
+        (pb.start_frame, pb.end_frame, pb.fps, pb.repeat) = (1.0, 10.0, 24.0, true);
+        for reversed in [false, true] {
+            pb.current_frame = 5.0;
+            pb.begin(reversed);
+            let mut shown = vec![5];
+            for _ in 0..25 {
+                // A quarter of a second a tick: six frames at the rate.
+                Input::tick(&mut *pb, 0.25, rect);
+                shown.push(pb.current_frame.round() as i32);
+            }
+            let step = if reversed { -1 } else { 1 };
+            for w in shown.windows(2) {
+                let expected = (w[0] - 1 + step).rem_euclid(10) + 1;
+                assert_eq!(w[1], expected, "reversed {reversed}: {shown:?}");
+            }
+        }
+        // On time: 48 ticks of a 48th of a second at 24 fps is 24 frames.
+        pb.current_frame = 1.0;
+        pb.end_frame = 100.0;
+        pb.begin(false);
+        for _ in 0..48 {
+            Input::tick(&mut *pb, 1.0 / 48.0, rect);
+        }
+        assert_eq!(pb.current_frame.round(), 25.0);
+    }
+
     #[test]
     fn test_load_default_project() {
         let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("default_project.json");
diff --git a/src/playbar.rs b/src/playbar.rs
index 7bf6690..38699d0 100644
--- a/src/playbar.rs
+++ b/src/playbar.rs
@@ -366,26 +366,33 @@ impl Input for Playbar {
             return false;
         }
         let dir = if self.reversed { -1.0 } else { 1.0 };
-        self.current_frame += dir * dt * self.fps;
-        let range = (self.end_frame - self.start_frame).max(1.0);
-        if self.current_frame > self.end_frame {
-            if self.repeat {
-                self.current_frame = self.start_frame + (self.current_frame - self.start_frame) % range;
-            } else {
-                // Repeat off: land ON the last frame and stop there, so the
-                // final state of a simulation is what stays on screen.
-                self.current_frame = self.end_frame;
-                self.playing = false;
-            }
-        } else if self.current_frame < self.start_frame {
-            // The reverse wrap, mirroring the forward one: run off the start,
-            // come back in from the end.
-            if self.repeat {
-                self.current_frame = self.end_frame - (self.start_frame - self.current_frame) % range;
-            } else {
-                self.current_frame = self.start_frame;
-                self.playing = false;
-            }
+        let shown = self.current_frame.round();
+        let mut next = self.current_frame + dir * dt * self.fps;
+        // Every frame is played: a tick moves the shown frame by one at
+        // most, landing ON the next frame when the time since the last tick
+        // would carry it further. So the rate is the fps while the frames
+        // keep up, and slows to a frame a tick when they do not — a
+        // simulation too slow for its rate plays every step slower rather
+        // than showing some of them. Until 2026-10-06 the playhead kept to
+        // the clock and the frames between two ticks were never drawn.
+        if (next.round() - shown).abs() > 1.0 {
+            next = shown + dir;
+        }
+        if !self.repeat && (next >= self.end_frame && !self.reversed || next <= self.start_frame && self.reversed) {
+            // Repeat off: land ON the last frame and stop there, so the
+            // final state of a simulation is what stays on screen.
+            self.current_frame = if self.reversed { self.start_frame } else { self.end_frame };
+            self.playing = false;
+        } else if self.repeat {
+            // The loop is every frame of the range, the end and the start
+            // each shown for a frame (frame k is shown over k ± 0.5), so the
+            // step past the end is to the start and the one before the start
+            // is to the end, in either direction.
+            let lo = self.start_frame - 0.5;
+            let frames = (self.end_frame - self.start_frame + 1.0).max(1.0);
+            self.current_frame = lo + (next - lo).rem_euclid(frames);
+        } else {
+            self.current_frame = next.clamp(self.start_frame, self.end_frame);
         }
         true
     }