graphic design tool
git clone https://git.lucas.co/cce-designer.git
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
}