From f117361950533b4d61341305e1013c0c2eebc2fb Mon Sep 17 00:00:00 2001 From: Franz Dietrich Date: Sat, 19 Sep 2026 10:52:00 +0200 Subject: [PATCH] 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.** --- turtle-lib-macros/Cargo.toml | 2 +- turtle-lib/Cargo.toml | 2 +- turtle-lib/src/drawing.rs | 29 ++++++----------------- turtle-lib/src/tweening.rs | 46 ++++++++++++++++++++++++++++++++---- 4 files changed, 51 insertions(+), 28 deletions(-) diff --git a/turtle-lib-macros/Cargo.toml b/turtle-lib-macros/Cargo.toml index fe9f050..7d677b2 100644 --- a/turtle-lib-macros/Cargo.toml +++ b/turtle-lib-macros/Cargo.toml @@ -10,4 +10,4 @@ proc-macro = true [dependencies] proc-macro2 = "1.0" quote = "1.0" -syn = { version = "2.0", features = ["full"] } +syn = { version = "3.0", features = ["full"] } diff --git a/turtle-lib/Cargo.toml b/turtle-lib/Cargo.toml index d096df5..9b485c3 100644 --- a/turtle-lib/Cargo.toml +++ b/turtle-lib/Cargo.toml @@ -18,7 +18,7 @@ crossbeam = "0.8" [dev-dependencies] # For examples and testing tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } -dialog = "*" +dialog = "0.3" chrono = "0.4" [features] diff --git a/turtle-lib/src/drawing.rs b/turtle-lib/src/drawing.rs index 9f02241..fb2a505 100644 --- a/turtle-lib/src/drawing.rs +++ b/turtle-lib/src/drawing.rs @@ -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); diff --git a/turtle-lib/src/tweening.rs b/turtle-lib/src/tweening.rs index 6e124e2..c167ebc 100644 --- a/turtle-lib/src/tweening.rs +++ b/turtle-lib/src/tweening.rs @@ -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, heading_tweener: Tweener, pen_width_tweener: Tweener, @@ -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); + } + } }