Fix Timing Clocks Divergence

Fixed the timing clock divergence between
[tweening.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs)
and
[drawing.rs](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/drawing.rs)
by delegating animation progress evaluation exclusively to
`TweenController`.

`current_time()` in `tweening.rs` was using a monotonic `Instant` epoch
(to support headless execution and unit tests without panicking on
missing Macroquad context), while `drawing.rs` was measuring progress
via `macroquad::time::get_time() - tween.start_time`. Because these two
clocks operate on completely different epochs, subtracting them caused
negative elapsed times, premature clamping, jitter, or animation lockup.

In
[`turtle-lib/src/tweening.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs):
- Added `pub(crate) progress: f32` to
  [`CommandTween`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs#L55-L68)
  to track the current evaluated, eased progress in `[0.0, 1.0]`.
- Made `start_time: f64` and `duration: f64` private to `tweening.rs` so
  outside modules cannot accidentally perform desynchronized clock
  arithmetic.
- Initialized `progress: 0.0` when constructing a new `CommandTween`.
- In `TweenController::update`, evaluated and clamped `tween.progress =
  tween.heading_tweener.move_to(elapsed).clamp(0.0, 1.0)`.

In
[`turtle-lib/src/drawing.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/drawing.rs):
- Removed `use tween::CubicInOut;` and duplicate easing calculations.
- Replaced `get_time() - tween.start_time` and `CubicInOut.tween(...)`
  in in-flight fill preview with `tween.progress`.
- Replaced `get_time() - tween.start_time` and `CubicInOut.tween(...)`
  in `draw_tween_arc` with `tween.progress`.
- Ensured `drawing.rs` has zero clock queries and strictly renders the
  state provided by `TweenController`.

In
[`turtle-lib/src/tweening.rs`](file:///home/dietrich/Projekte/Source/turtlers/turtle-lib/src/tweening.rs#L540-L579):
- Updated `test_animated_mode_pops_to_current_tween` to assert that
  `progress` initializes to `0.0`.
- Added `test_animated_mode_progress_advances_and_clamps` to verify that
  `progress` advances monotonically across frames and stays within
  `[0.0, 1.0]`.

---

```bash
cargo test --package turtle-lib --features svg
```
Output:
```text
running 17 tests
test circle_geometry::tests::test_circle_left_geometry ... ok
test circle_geometry::tests::test_circle_right_geometry ... ok
test command_behavior::tests::test_goto_duration_cartesian_inversion ...
ok
test
command_behavior::tests::test_set_heading_degrees_and_instant_duration
... ok
test execution::tests::test_forward_left_forward ... ok
test general::angle::tests::degrees_to_radians_roundtrip ... ok
test general::angle::tests::from_f64 ... ok
test general::angle::tests::from_integer ... ok
test general::angle::tests::negation ... ok
test general::fontsize::tests::font_size_conversions ... ok
test general::length::tests::test_length_conversions_and_negation ... ok
test general::tests::animation_speed_conversions ... ok
test tweening::tests::test_animated_mode_pops_to_current_tween ... ok
test tweening::tests::test_instant_mode_drains_queue ... ok
test
tweening::tests::test_instant_mode_respects_batch_limit_and_retains_pending
... ok
test tweening::tests::test_streaming_append_commands_does_not_accumulate
... ok
test tweening::tests::test_animated_mode_progress_advances_and_clamps
... ok

test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered
out; finished in 0.02s

Doc-tests turtle_lib: 34 passed; 0 failed; 1 ignored; 0 measured; 0
filtered out; finished in 0.26s
```

```bash
cargo clippy --package turtle-lib --features svg -- -Wclippy::pedantic \
-Aclippy::cast_precision_loss -Aclippy::cast_sign_loss
-Aclippy::cast_possible_truncation
```
Output:
```text
Finished `dev` profile [optimized + debuginfo] target(s) in 0.82s
```
**0 warnings.**

```bash
cargo run --example yinyang --features svg -- --export-svg
/tmp/yinyang_test.svg
```
Output:
```text
SVG exported successfully to: /tmp/yinyang_test.svg
```

```bash
cargo build --package turtle-lib --examples
```
Output:
```text
Finished `dev` profile [optimized + debuginfo] target(s) in 6.74s
```
**All 30 examples compiled cleanly with 0 errors and 0 warnings.**
This commit is contained in:
2026-09-19 10:52:00 +02:00
parent f4357cb73d
commit f117361950
4 changed files with 51 additions and 28 deletions
+7 -22
View File
@@ -5,12 +5,7 @@ use crate::state::{DrawCommand, TurtleParams, TurtleWorld};
use crate::tessellation;
use macroquad::prelude::*;
// Import the easing function from the tween crate
// To change the easing, change both this import and the usage in the draw_tween_arc function below
// Available options: Linear, SineInOut, QuadInOut, CubicInOut, QuartInOut, QuintInOut,
// ExpoInOut, CircInOut, BackInOut, ElasticInOut, BounceInOut, etc.
// See https://easings.net/ for visual demonstrations
use tween::CubicInOut;
/// Render the turtle world with active tween visualization.
#[allow(clippy::too_many_lines)]
@@ -136,15 +131,11 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
radius.value(),
*direction,
);
let elapsed = get_time() - tween.start_time;
let progress = (elapsed / tween.duration).min(1.0);
let eased_progress = CubicInOut.tween(1.0, progress as f32);
// Delegate to the shared arc_points function — same sampling
// strategy as tessellate_arc, eliminating the divergence.
// strategy as tessellate_arc, using tween.progress directly.
let samples_to_draw =
(((*steps).max(1) as f32 * eased_progress) as usize).max(1);
let sweep_so_far = angle.as_radians().value() * eased_progress;
(((*steps).max(1) as f32 * tween.progress) as usize).max(1);
let sweep_so_far = angle.as_radians().value() * tween.progress;
for pt in arc_points(
geom.center,
radius.value(),
@@ -309,21 +300,15 @@ fn draw_tween_arc(
draw_mesh(&mesh);
}
// Calculate how much of the arc we've traveled based on tween progress
// Use the same eased progress as the turtle position for synchronized animation
let elapsed = get_time() - tween.start_time;
let t = (elapsed / tween.duration).min(1.0);
let progress = CubicInOut.tween(1.0, t as f32); // tween from 0 to 1
// Use Lyon to tessellate and draw the partial arc
// Draw the partial arc traveled based on tween progress
if let Ok(mesh) = crate::tessellation::tessellate_arc(
geom.center,
radius,
geom.start_angle_from_center.to_degrees(),
total_angle.value() * progress,
total_angle.value() * tween.progress,
tween.start_params.color,
tween.start_params.pen_width,
((steps as f32 * progress).ceil() as usize).max(1),
((steps as f32 * tween.progress).ceil() as usize).max(1),
direction,
) {
draw_mesh(&mesh);
+42 -4
View File
@@ -55,12 +55,13 @@ pub(crate) struct TweenController {
#[derive(Clone, Debug)]
pub(crate) struct CommandTween {
pub(crate) command: TurtleCommand,
pub(crate) start_time: f64,
pub(crate) duration: f64,
pub(crate) progress: f32,
pub(crate) start_params: TurtleParams,
pub(crate) target_params: TurtleParams,
pub(crate) current_position: Vec2,
pub(crate) current_heading: f32,
start_time: f64,
duration: f64,
position_tweener: Tweener<TweenVec2, f64, CubicInOut>,
heading_tweener: Tweener<f32, f64, CubicInOut>,
pen_width_tweener: Tweener<f32, f64, CubicInOut>,
@@ -166,7 +167,8 @@ impl TweenController {
// Use tweeners to calculate current values
// For circles, calculate position along the arc instead of straight line
let progress = tween.heading_tweener.move_to(elapsed);
let progress = tween.heading_tweener.move_to(elapsed).clamp(0.0, 1.0);
tween.progress = progress;
let current_position = match &tween.command {
TurtleCommand::Circle {
@@ -316,6 +318,7 @@ impl TweenController {
self.current_tween = Some(CommandTween {
command,
progress: 0.0,
start_time: current_time(),
duration,
start_params: params.clone(),
@@ -537,7 +540,42 @@ mod tests {
controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
assert_eq!(controller.queue.len(), 1, "First command must be popped into current_tween");
assert!(controller.current_tween().is_some());
let active = controller.current_tween().expect("Must have active tween");
assert_eq!(active.progress, 0.0, "New tween must initialize progress to 0.0");
assert!(!controller.is_complete());
}
#[test]
fn test_animated_mode_progress_advances_and_clamps() {
let mut queue = CommandQueue::new();
// Circle command with speed 10.0 and radius 100 => duration ~62.8s
queue.push(TurtleCommand::Circle {
radius: Length::new(100.0),
angle: Degrees::new(360.0),
steps: 36,
direction: CircleDirection::Right,
});
let mut controller = TweenController::new(
queue,
AnimationSpeed::Animated(10.0),
);
let mut params = make_test_params();
let mut filling = None;
let mut commands = Vec::new();
let mut svg_log = crate::state::SvgLog::default();
// Frame 0: pops into current_tween with progress = 0.0
controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
let initial_progress = controller.current_tween().unwrap().progress;
assert_eq!(initial_progress, 0.0);
// Advance time by sleeping briefly
std::thread::sleep(std::time::Duration::from_millis(15));
controller.update(0, &mut params, &mut filling, &mut commands, &mut svg_log);
if let Some(tween) = controller.current_tween() {
assert!(tween.progress >= 0.0 && tween.progress <= 1.0);
assert!(tween.progress >= initial_progress);
}
}
}