improve command handling

This commit is contained in:
Franz Dietrich 2026-05-17 07:23:13 +02:00
parent 44046abe12
commit 3c076fdd03
5 changed files with 337 additions and 167 deletions

View File

@ -53,11 +53,14 @@ pub enum TurtleCommand {
Reset, Reset,
} }
/// Queue of turtle commands with execution state /// A pure-data sequence of turtle commands.
///
/// `CommandQueue` is intentionally *not* an `Iterator` — it carries no cursor
/// state. Execution state ("which command are we on?") belongs to the
/// consumer; `TweenController` owns the cursor that walks this queue.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct CommandQueue { pub struct CommandQueue {
commands: Vec<TurtleCommand>, commands: Vec<TurtleCommand>,
current_index: usize,
} }
impl CommandQueue { impl CommandQueue {
@ -65,14 +68,12 @@ impl CommandQueue {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
commands: Vec::new(), commands: Vec::new(),
current_index: 0,
} }
} }
#[must_use] #[must_use]
pub fn with_capacity(capacity: usize) -> Self { pub fn with_capacity(capacity: usize) -> Self {
Self { Self {
commands: Vec::with_capacity(capacity), commands: Vec::with_capacity(capacity),
current_index: 0,
} }
} }
@ -83,26 +84,22 @@ impl CommandQueue {
pub fn extend(&mut self, commands: impl IntoIterator<Item = TurtleCommand>) { pub fn extend(&mut self, commands: impl IntoIterator<Item = TurtleCommand>) {
self.commands.extend(commands); self.commands.extend(commands);
} }
/// Return a reference to the command at `index`, or `None` if out of range.
#[must_use] #[must_use]
pub fn is_complete(&self) -> bool { pub fn get(&self, index: usize) -> Option<&TurtleCommand> {
self.current_index >= self.commands.len() self.commands.get(index)
}
pub fn reset(&mut self) {
self.current_index = 0;
} }
#[must_use] #[must_use]
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.commands.len() self.commands.len()
} }
#[must_use] #[must_use]
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.commands.is_empty() self.commands.is_empty()
} }
#[must_use]
pub fn remaining(&self) -> usize {
self.commands.len().saturating_sub(self.current_index)
}
} }
impl Default for CommandQueue { impl Default for CommandQueue {
@ -111,16 +108,16 @@ impl Default for CommandQueue {
} }
} }
impl Iterator for CommandQueue { /// Consuming iteration — yields every command in order.
///
/// This is used by `CommandQueue::extend` and `TweenController::append_commands`
/// to drain one queue into another. It does *not* imply that `CommandQueue`
/// itself is stateful; the cursor always lives in the consumer.
impl IntoIterator for CommandQueue {
type Item = TurtleCommand; type Item = TurtleCommand;
type IntoIter = std::vec::IntoIter<TurtleCommand>;
fn next(&mut self) -> Option<Self::Item> { fn into_iter(self) -> Self::IntoIter {
if self.current_index < self.commands.len() { self.commands.into_iter()
let cmd = self.commands[self.current_index].clone();
self.current_index += 1;
Some(cmd)
} else {
None
}
} }
} }

View File

@ -1,52 +1,120 @@
//! Command execution logic //! Command execution logic
use crate::circle_geometry::CircleGeometry; use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::commands::TurtleCommand; use crate::commands::TurtleCommand;
use crate::state::{DrawCommand, Turtle, TurtleParams, TurtleWorld}; use crate::general::Coordinate;
use crate::state::{DrawCommand, FillState, Turtle, TurtleParams, TurtleWorld};
use crate::tessellation; use crate::tessellation;
use macroquad::prelude::*; use macroquad::prelude::*;
#[cfg(test)] #[cfg(test)]
use crate::general::AnimationSpeed; use crate::general::AnimationSpeed;
/// Execute side effects for commands that don't involve movement /// Close the current open fill contour (factored out of `Turtle::close_fill_contour`).
/// Returns true if the command was handled (caller should skip movement processing) fn close_fill_contour(turtle_id: usize, filling: &mut Option<FillState>) {
#[allow(clippy::too_many_lines)] if let Some(ref mut fill_state) = filling {
pub(crate) fn execute_command_side_effects(command: &TurtleCommand, state: &mut Turtle) -> bool { tracing::debug!(
match command { turtle_id,
TurtleCommand::BeginFill => { vertices = fill_state.current_contour.len(),
if state.filling.is_some() { "close_fill_contour called"
);
if fill_state.current_contour.len() >= 2 {
tracing::debug!(
turtle_id,
vertices = fill_state.current_contour.len(),
first_x = fill_state.current_contour[0].x,
first_y = fill_state.current_contour[0].y,
last_x = fill_state.current_contour[fill_state.current_contour.len() - 1].x,
last_y = fill_state.current_contour[fill_state.current_contour.len() - 1].y,
"Closing contour"
);
let contour = std::mem::take(&mut fill_state.current_contour);
fill_state.contours.push(contour);
tracing::debug!(
turtle_id,
completed_contours = fill_state.contours.len(),
"Contour moved to completed list"
);
} else if !fill_state.current_contour.is_empty() {
tracing::warn!( tracing::warn!(
turtle_id = state.turtle_id, turtle_id,
"begin_fill() called while already filling" vertices = fill_state.current_contour.len(),
"Current contour has insufficient vertices, not closing"
);
} else {
tracing::warn!(turtle_id, "Current contour is empty, nothing to close");
}
} else {
tracing::warn!(
turtle_id,
"close_fill_contour called but no active fill state"
); );
} }
let fill_color = state.params.fill_color.unwrap_or_else(|| { }
tracing::warn!(
turtle_id = state.turtle_id, /// Begin a new fill contour at `position` (factored out of `Turtle::start_fill_contour`).
"No fill_color set, using black" fn start_fill_contour(turtle_id: usize, position: Coordinate, filling: &mut Option<FillState>) {
if let Some(ref mut fill_state) = filling {
tracing::debug!(
x = position.x,
y = position.y,
completed_contours = fill_state.contours.len(),
turtle_id,
"Starting new contour"
); );
fill_state.current_contour = vec![position];
}
}
/// Execute side effects for commands that don't involve movement.
///
/// Returns `true` if the command was fully handled; the caller should skip
/// params-update and tessellation when this returns `true`.
///
/// Accepts the three logically-separate pieces of turtle state as disjoint
/// mutable borrows so that this function can be called from
/// `TweenController::update(&mut self, …)` without requiring a `&mut Turtle`.
#[allow(clippy::too_many_lines)]
pub(crate) fn execute_command_side_effects(
command: &TurtleCommand,
turtle_id: usize,
params: &mut TurtleParams,
filling: &mut Option<FillState>,
commands: &mut Vec<DrawCommand>,
) -> bool {
match command {
TurtleCommand::BeginFill => {
if filling.is_some() {
tracing::warn!(turtle_id, "begin_fill() called while already filling");
}
let fill_color = params.fill_color.unwrap_or_else(|| {
tracing::warn!(turtle_id, "No fill_color set, using black");
BLACK BLACK
}); });
state.begin_fill(fill_color); *filling = Some(FillState {
start_position: params.position,
contours: Vec::new(),
current_contour: vec![params.position],
fill_color,
});
true true
} }
TurtleCommand::EndFill => { TurtleCommand::EndFill => {
if let Some(mut fill_state) = state.filling.take() { if let Some(mut fill_state) = filling.take() {
if !fill_state.current_contour.is_empty() { if !fill_state.current_contour.is_empty() {
fill_state.contours.push(fill_state.current_contour); fill_state.contours.push(fill_state.current_contour);
} }
let span = tracing::debug_span!( let span = tracing::debug_span!(
"end_fill", "end_fill",
turtle_id = state.turtle_id, turtle_id,
contours = fill_state.contours.len() contours = fill_state.contours.len()
); );
let _enter = span.enter(); let _enter = span.enter();
for (i, contour) in fill_state.contours.iter().enumerate() { for (i, contour) in fill_state.contours.iter().enumerate() {
tracing::debug!( tracing::debug!(
turtle_id = state.turtle_id, turtle_id,
contour_idx = i, contour_idx = i,
vertices = contour.len(), vertices = contour.len(),
"Contour info" "Contour info"
@ -59,83 +127,76 @@ pub(crate) fn execute_command_side_effects(command: &TurtleCommand, state: &mut
fill_state.fill_color, fill_state.fill_color,
) { ) {
tracing::debug!( tracing::debug!(
turtle_id = state.turtle_id, turtle_id,
contours = fill_state.contours.len(), contours = fill_state.contours.len(),
"Successfully created fill mesh - persisting to commands" "Successfully created fill mesh - persisting to commands"
); );
state.commands.push(DrawCommand::Mesh { commands.push(DrawCommand::Mesh {
data: mesh_data, data: mesh_data,
source: crate::state::TurtleSource { source: crate::state::TurtleSource {
command: crate::commands::TurtleCommand::EndFill, command: crate::commands::TurtleCommand::EndFill,
color: state.params.color, color: params.color,
fill_color: fill_state.fill_color, fill_color: fill_state.fill_color,
pen_width: state.params.pen_width, pen_width: params.pen_width,
start_position: fill_state.start_position, start_position: fill_state.start_position,
end_position: fill_state.start_position, end_position: fill_state.start_position,
start_heading: state.params.heading, start_heading: params.heading,
contours: Some(fill_state.contours.clone()), contours: Some(fill_state.contours.clone()),
}, },
}); });
} else { } else {
tracing::error!( tracing::error!(turtle_id, "Failed to tessellate contours");
turtle_id = state.turtle_id,
"Failed to tessellate contours"
);
} }
} }
} else { } else {
tracing::warn!( tracing::warn!(turtle_id, "end_fill() called without begin_fill()");
turtle_id = state.turtle_id,
"end_fill() called without begin_fill()"
);
} }
true true
} }
TurtleCommand::PenUp => { TurtleCommand::PenUp => {
state.params.pen_down = false; params.pen_down = false;
if state.filling.is_some() { if filling.is_some() {
tracing::debug!( tracing::debug!(turtle_id, "PenUp: Closing current contour");
turtle_id = state.turtle_id,
"PenUp: Closing current contour"
);
} }
state.close_fill_contour(); close_fill_contour(turtle_id, filling);
true true
} }
TurtleCommand::PenDown => { TurtleCommand::PenDown => {
state.params.pen_down = true; params.pen_down = true;
if state.filling.is_some() { if filling.is_some() {
tracing::debug!( tracing::debug!(
turtle_id = state.turtle_id, turtle_id,
x = state.params.position.x, x = params.position.x,
y = state.params.position.y, y = params.position.y,
"PenDown: Starting new contour" "PenDown: Starting new contour"
); );
} }
state.start_fill_contour(); start_fill_contour(turtle_id, params.position, filling);
true true
} }
TurtleCommand::Reset => { TurtleCommand::Reset => {
state.reset(); commands.clear();
*filling = None;
*params = TurtleParams::default();
true true
} }
TurtleCommand::WriteText { text, font_size } => { TurtleCommand::WriteText { text, font_size } => {
state.commands.push(DrawCommand::Text { commands.push(DrawCommand::Text {
text: text.clone(), text: text.clone(),
position: state.params.position, position: params.position,
heading: state.params.heading, heading: params.heading,
font_size: *font_size, font_size: *font_size,
color: state.params.color, color: params.color,
source: crate::state::TurtleSource { source: crate::state::TurtleSource {
command: command.clone(), command: command.clone(),
color: state.params.color, color: params.color,
fill_color: state.params.fill_color.unwrap_or(BLACK), fill_color: params.fill_color.unwrap_or(BLACK),
pen_width: state.params.pen_width, pen_width: params.pen_width,
start_position: state.params.position, start_position: params.position,
end_position: state.params.position, end_position: params.position,
start_heading: state.params.heading, start_heading: params.heading,
contours: None, contours: None,
}, },
}); });
@ -157,14 +218,23 @@ pub(crate) fn execute_command_side_effects(command: &TurtleCommand, state: &mut
} }
} }
/// Record fill vertices after movement commands have updated state /// Record fill vertices after movement commands have updated state.
#[tracing::instrument] ///
/// `start_state` is the params snapshot taken **before** the command ran.
/// `params` is the current (post-movement) state — `params.position` is the
/// endpoint that gets pushed into the active fill contour.
///
/// Accepts disjoint borrows so it can be called from `TweenController::update`
/// without needing a `&mut Turtle`.
#[tracing::instrument(skip(params, filling))]
pub(crate) fn record_fill_vertices_after_movement( pub(crate) fn record_fill_vertices_after_movement(
command: &TurtleCommand, command: &TurtleCommand,
start_state: &TurtleParams, start_state: &TurtleParams,
state: &mut Turtle, turtle_id: usize,
params: &TurtleParams,
filling: &mut Option<FillState>,
) { ) {
if state.filling.is_none() { if filling.is_none() {
return; return;
} }
@ -181,17 +251,60 @@ pub(crate) fn record_fill_vertices_after_movement(
*radius, *radius,
*direction, *direction,
); );
state.record_fill_vertices_for_arc( if let Some(ref mut fill_state) = filling {
geom.center, if params.pen_down {
*radius, let num_samples = (*steps as u32).max(1);
geom.start_angle_from_center, tracing::trace!(
angle.to_radians(), turtle_id,
*direction, center_x = geom.center.x,
*steps as u32, center_y = geom.center.y,
radius,
steps,
num_samples,
"Recording arc vertices"
); );
for i in 1..=num_samples {
let progress = i as f32 / num_samples as f32;
let current_angle = match direction {
CircleDirection::Left => {
geom.start_angle_from_center - angle.to_radians() * progress
}
CircleDirection::Right => {
geom.start_angle_from_center + angle.to_radians() * progress
}
};
let vertex = Coordinate::new(
geom.center.x + radius * current_angle.cos(),
geom.center.y + radius * current_angle.sin(),
);
tracing::trace!(
turtle_id,
vertex_idx = i,
x = vertex.x,
y = vertex.y,
angle_degrees = current_angle.to_degrees(),
"Arc vertex"
);
fill_state.current_contour.push(vertex);
}
}
}
} }
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => { TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
state.record_fill_vertex(); if let Some(ref mut fill_state) = filling {
if params.pen_down {
tracing::trace!(
turtle_id,
x = params.position.x,
y = params.position.y,
vertices = fill_state.current_contour.len() + 1,
"Adding vertex to current contour"
);
fill_state.current_contour.push(params.position);
} else {
tracing::trace!(turtle_id, "Skipping vertex (pen is up)");
}
}
} }
_ => {} _ => {}
} }
@ -283,12 +396,18 @@ pub(crate) fn tessellate_command(
} }
} }
/// Execute a single turtle command, updating state and adding draw commands /// Execute a single turtle command, updating state and adding draw commands.
#[tracing::instrument] #[tracing::instrument(skip(state))]
pub(crate) fn execute_command(command: &TurtleCommand, state: &mut Turtle) { pub(crate) fn execute_command(command: &TurtleCommand, state: &mut Turtle) {
// Phase 1: side effects (fills, pen contours, reset, text). // Phase 1: side effects (fills, pen contours, reset, text).
// Returns true if the command is fully handled — no params update or tessellation needed. // Returns true if the command is fully handled — no params update or tessellation needed.
if execute_command_side_effects(command, state) { if execute_command_side_effects(
command,
state.turtle_id,
&mut state.params,
&mut state.filling,
&mut state.commands,
) {
return; return;
} }
@ -297,7 +416,13 @@ pub(crate) fn execute_command(command: &TurtleCommand, state: &mut Turtle) {
command.apply_to_params(&mut state.params); command.apply_to_params(&mut state.params);
// Phase 3: record fill vertices after movement (must follow params update) // Phase 3: record fill vertices after movement (must follow params update)
record_fill_vertices_after_movement(command, &start_params, state); record_fill_vertices_after_movement(
command,
&start_params,
state.turtle_id,
&state.params,
&mut state.filling,
);
// Phase 4: tessellate and persist the committed drawing // Phase 4: tessellate and persist the committed drawing
if let Some(draw_cmd) = tessellate_command(command, &start_params, state.params.position) { if let Some(draw_cmd) = tessellate_command(command, &start_params, state.params.position) {
@ -305,20 +430,18 @@ pub(crate) fn execute_command(command: &TurtleCommand, state: &mut Turtle) {
} }
} }
/// Execute command on a specific turtle by ID /// Execute command on a specific turtle by ID.
///
/// There is no ownership conflict here: `execute_command` only needs `&mut Turtle`
/// and never touches `TurtleWorld`, so we can obtain the mutable reference directly
/// from `get_turtle_mut` without any intermediate clone.
pub(crate) fn execute_command_with_id( pub(crate) fn execute_command_with_id(
command: &TurtleCommand, command: &TurtleCommand,
turtle_id: usize, turtle_id: usize,
world: &mut TurtleWorld, world: &mut TurtleWorld,
) { ) {
// Clone turtle state to avoid borrow checker issues if let Some(turtle) = world.get_turtle_mut(turtle_id) {
if let Some(turtle) = world.get_turtle(turtle_id) { execute_command(command, turtle);
let mut state = turtle.clone();
execute_command(command, &mut state);
// Update the turtle state back
if let Some(turtle_mut) = world.get_turtle_mut(turtle_id) {
*turtle_mut = state;
}
} }
} }
@ -327,7 +450,7 @@ mod tests {
use super::*; use super::*;
use crate::commands::TurtleCommand; use crate::commands::TurtleCommand;
use crate::shapes::TurtleShape; use crate::shapes::TurtleShape;
use crate::TweenController; use crate::tweening::TweenController;
#[test] #[test]
fn test_forward_left_forward() { fn test_forward_left_forward() {

View File

@ -80,7 +80,6 @@ pub use macroquad::prelude::{
use crate::commands_channel::TurtleCommandReceiver; use crate::commands_channel::TurtleCommandReceiver;
use crate::state::TurtleWorld; use crate::state::TurtleWorld;
use crate::tweening::TweenController;
use macroquad::prelude::*; use macroquad::prelude::*;
use std::collections::HashMap; use std::collections::HashMap;
@ -292,10 +291,10 @@ impl TurtleApp {
// Update all turtles' tween controllers // Update all turtles' tween controllers
for turtle in &mut self.world.turtles { for turtle in &mut self.world.turtles {
// Extract draw_commands and controller temporarily to avoid borrow conflicts // Drive this turtle's animation controller for one frame.
// `update_tweens` splits &mut Turtle into disjoint field borrows so
// Update the controller // TweenController::update can be a proper &mut self method.
let completed_commands = TweenController::update(turtle); let completed_commands = turtle.update_tweens();
// Process all completed commands and add to the turtle's commands // Process all completed commands and add to the turtle's commands
for (completed_cmd, tween_start, end_state) in completed_commands { for (completed_cmd, tween_start, end_state) in completed_commands {

View File

@ -106,6 +106,26 @@ impl Turtle {
// Keep turtle_id and tween_controller (preserves queued commands) // Keep turtle_id and tween_controller (preserves queued commands)
} }
/// Drive the animation controller for one frame.
///
/// Returns `(command, start_params, end_params)` for every command that
/// completed this frame and whose stroke needs to be tessellated by the
/// caller into a `DrawCommand`.
///
/// This method performs the correct disjoint field-borrow split so that
/// `TweenController::update` can be a proper `&mut self` method instead
/// of the old static-method borrow-checker workaround.
pub fn update_tweens(
&mut self,
) -> Vec<(crate::commands::TurtleCommand, TurtleParams, TurtleParams)> {
self.tween_controller.update(
self.turtle_id,
&mut self.params,
&mut self.filling,
&mut self.commands,
)
}
/// Start recording fill vertices /// Start recording fill vertices
pub fn begin_fill(&mut self, fill_color: Color) { pub fn begin_fill(&mut self, fill_color: Color) {
self.filling = Some(FillState { self.filling = Some(FillState {

View File

@ -3,7 +3,7 @@
use crate::circle_geometry::{CircleDirection, CircleGeometry}; use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::commands::{CommandQueue, TurtleCommand}; use crate::commands::{CommandQueue, TurtleCommand};
use crate::general::AnimationSpeed; use crate::general::AnimationSpeed;
use crate::state::{Turtle, TurtleParams}; use crate::state::{DrawCommand, FillState, TurtleParams};
use macroquad::prelude::*; use macroquad::prelude::*;
use tween::{CubicInOut, TweenValue, Tweener}; use tween::{CubicInOut, TweenValue, Tweener};
@ -47,6 +47,10 @@ impl From<TweenVec2> for Vec2 {
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub(crate) struct TweenController { pub(crate) struct TweenController {
queue: CommandQueue, queue: CommandQueue,
/// Cursor into `queue` — tracks which command executes next.
/// Lives here, not in `CommandQueue`, so that cloning or appending to the
/// queue never silently resets or mid-stream-shifts the execution position.
cursor: usize,
current_tween: Option<CommandTween>, current_tween: Option<CommandTween>,
speed: AnimationSpeed, speed: AnimationSpeed,
} }
@ -71,6 +75,7 @@ impl TweenController {
pub fn new(queue: CommandQueue, speed: AnimationSpeed) -> Self { pub fn new(queue: CommandQueue, speed: AnimationSpeed) -> Self {
Self { Self {
queue, queue,
cursor: 0,
current_tween: None, current_tween: None,
speed, speed,
} }
@ -80,51 +85,71 @@ impl TweenController {
self.speed = speed; self.speed = speed;
} }
/// Append commands to the queue /// Append commands to the queue.
///
/// The cursor is **not** reset — commands already consumed remain consumed,
/// and the new commands are picked up naturally as the cursor advances.
pub fn append_commands(&mut self, new_queue: CommandQueue) { pub fn append_commands(&mut self, new_queue: CommandQueue) {
self.queue.extend(new_queue); self.queue.extend(new_queue);
} }
/// Update the tween, returns `Vec` of (`command`, `start_state`, `end_state`) for all completed commands this frame /// Drive the animation controller for one frame.
/// Also takes commands vec to handle side effects like fill operations ///
/// Each `command` has its own `start_state` and `end_state` pair /// Returns `(command, start_params, end_params)` for every command that
/// completed this frame and whose stroke needs to be tessellated by the
/// caller.
///
/// By accepting `params`, `filling`, and `commands` as separate mutable
/// borrows the caller can split `&mut Turtle` into disjoint field borrows,
/// eliminating the old static-method borrow-checker workaround.
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub fn update(state: &mut Turtle) -> Vec<(TurtleCommand, TurtleParams, TurtleParams)> { pub fn update(
&mut self,
turtle_id: usize,
params: &mut TurtleParams,
filling: &mut Option<FillState>,
commands: &mut Vec<DrawCommand>,
) -> Vec<(TurtleCommand, TurtleParams, TurtleParams)> {
// In instant mode, execute commands up to the draw calls per frame limit // In instant mode, execute commands up to the draw calls per frame limit
if let AnimationSpeed::Instant(max_draw_calls) = state.tween_controller.speed { if let AnimationSpeed::Instant(max_draw_calls) = self.speed {
let mut completed_commands: Vec<(TurtleCommand, TurtleParams, TurtleParams)> = let mut completed_commands: Vec<(TurtleCommand, TurtleParams, TurtleParams)> =
Vec::new(); Vec::new();
let mut draw_call_count = 0; let mut draw_call_count = 0;
// Consume commands from the real queue so the current_index advances // Advance cursor through the queue for each command consumed
while let Some(command) = state.tween_controller.queue.next() { while let Some(command) = self.queue.get(self.cursor).cloned() {
self.cursor += 1;
// Handle SetSpeed command to potentially switch modes // Handle SetSpeed command to potentially switch modes
if let TurtleCommand::SetSpeed(new_speed) = &command { if let TurtleCommand::SetSpeed(new_speed) = &command {
state.params.speed = *new_speed; params.speed = *new_speed;
state.tween_controller.speed = *new_speed; self.speed = *new_speed;
if matches!(state.tween_controller.speed, AnimationSpeed::Animated(_)) { if matches!(self.speed, AnimationSpeed::Animated(_)) {
break; break;
} }
continue; continue;
} }
// Execute side-effect-only commands using centralized helper // Execute side-effect-only commands using centralized helper
if crate::execution::execute_command_side_effects(&command, state) { if crate::execution::execute_command_side_effects(
&command, turtle_id, params, filling, commands,
) {
continue; // Command fully handled continue; // Command fully handled
} }
// Save start state and compute target state // Save start state and compute target state
let start_params = state.params.clone(); let start_params = params.clone();
let target_params = Self::calculate_target_state(&start_params, &command); let target_params = Self::calculate_target_state(&start_params, &command);
// Update state to the target (instant execution) // Update state to the target (instant execution)
state.params = target_params.clone(); *params = target_params.clone();
// Record fill vertices AFTER movement // Record fill vertices AFTER movement
crate::execution::record_fill_vertices_after_movement( crate::execution::record_fill_vertices_after_movement(
&command, &command,
&start_params, &start_params,
state, turtle_id,
params,
filling,
); );
// Collect drawable commands (return start and target so caller can create draw meshes) // Collect drawable commands (return start and target so caller can create draw meshes)
@ -141,7 +166,7 @@ impl TweenController {
} }
// Process current tween // Process current tween
if let Some(ref mut tween) = state.tween_controller.current_tween { if let Some(ref mut tween) = self.current_tween {
let elapsed = get_time() - tween.start_time; let elapsed = get_time() - tween.start_time;
// Use tweeners to calculate current values // Use tweeners to calculate current values
@ -170,7 +195,7 @@ impl TweenController {
} }
}; };
state.params.position = current_position; params.position = current_position;
tween.current_position = current_position; tween.current_position = current_position;
// Heading changes proportionally with progress for all commands // Heading changes proportionally with progress for all commands
@ -195,18 +220,18 @@ impl TweenController {
} }
}); });
state.params.heading = current_heading; params.heading = current_heading;
tween.current_heading = current_heading; tween.current_heading = current_heading;
state.params.pen_width = tween.pen_width_tweener.move_to(elapsed); params.pen_width = tween.pen_width_tweener.move_to(elapsed);
// Discrete properties (switch at 50% progress) // Discrete properties (switch at 50% progress)
let progress = (elapsed / tween.duration).min(1.0); let progress = (elapsed / tween.duration).min(1.0);
if progress >= 0.5 { if progress >= 0.5 {
state.params.pen_down = tween.target_params.pen_down; params.pen_down = tween.target_params.pen_down;
state.params.color = tween.target_params.color; params.color = tween.target_params.color;
state.params.fill_color = tween.target_params.fill_color; params.fill_color = tween.target_params.fill_color;
state.params.visible = tween.target_params.visible; params.visible = tween.target_params.visible;
state.params.shape = tween.target_params.shape.clone(); params.shape = tween.target_params.shape.clone();
} }
// Check if tween is finished (use heading_tweener as it's used by all commands) // Check if tween is finished (use heading_tweener as it's used by all commands)
@ -215,20 +240,24 @@ impl TweenController {
let target_params = tween.target_params.clone(); let target_params = tween.target_params.clone();
let command = tween.command.clone(); let command = tween.command.clone();
// Drop the mutable borrow of tween before mutably borrowing state // tween borrow ends here (NLL) — safe to reassign self.current_tween below
state.params = target_params.clone(); *params = target_params.clone();
crate::execution::record_fill_vertices_after_movement( crate::execution::record_fill_vertices_after_movement(
&command, &command,
&start_params, &start_params,
state, turtle_id,
params,
filling,
); );
state.tween_controller.current_tween = None; self.current_tween = None;
// Execute side-effect-only commands using centralized helper // Execute side-effect-only commands using centralized helper
if crate::execution::execute_command_side_effects(&command, state) { if crate::execution::execute_command_side_effects(
return Self::update(state); // Continue to next command &command, turtle_id, params, filling, commands,
) {
return self.update(turtle_id, params, filling, commands);
} }
// Return drawable commands using the original start and target params // Return drawable commands using the original start and target params
@ -236,43 +265,45 @@ impl TweenController {
return vec![(command, start_params.clone(), target_params.clone())]; return vec![(command, start_params.clone(), target_params.clone())];
} }
return Self::update(state); // Continue to next command return self.update(turtle_id, params, filling, commands);
} }
return Vec::new(); return Vec::new();
} }
// Start next tween // Start next tween
if let Some(command) = state.tween_controller.queue.next() { if let Some(command) = self.queue.get(self.cursor).cloned() {
let command_clone = command.clone(); self.cursor += 1;
// Handle commands that should execute immediately (no animation) // Handle commands that should execute immediately (no animation)
match &command_clone { match &command {
TurtleCommand::SetSpeed(new_speed) => { TurtleCommand::SetSpeed(new_speed) => {
state.set_speed(*new_speed); params.speed = *new_speed;
state.tween_controller.speed = *new_speed; self.speed = *new_speed;
if matches!(state.tween_controller.speed, AnimationSpeed::Instant(_)) { if matches!(self.speed, AnimationSpeed::Instant(_)) {
return Self::update(state); return self.update(turtle_id, params, filling, commands);
} }
return Self::update(state); return self.update(turtle_id, params, filling, commands);
} }
_ => { _ => {
// Use centralized helper for side effects // Use centralized helper for side effects
if crate::execution::execute_command_side_effects(&command_clone, state) { if crate::execution::execute_command_side_effects(
return Self::update(state); &command, turtle_id, params, filling, commands,
) {
return self.update(turtle_id, params, filling, commands);
} }
} }
} }
let speed = state.tween_controller.speed; // Extract speed before borrowing self let speed = self.speed;
let duration = Self::calculate_duration_with_state(&command_clone, state, speed); let duration = Self::calculate_duration_with_state(&command, params, speed);
// Calculate target state // Calculate target state
let target_state = Self::calculate_target_state(&state.params, &command_clone); let target_state = Self::calculate_target_state(params, &command);
// Create tweeners for smooth animation // Create tweeners for smooth animation
let position_tweener = Tweener::new( let position_tweener = Tweener::new(
TweenVec2::from(state.params.position), TweenVec2::from(params.position),
TweenVec2::from(target_state.position), TweenVec2::from(target_state.position),
duration, duration,
CubicInOut, CubicInOut,
@ -284,21 +315,21 @@ impl TweenController {
); );
let pen_width_tweener = Tweener::new( let pen_width_tweener = Tweener::new(
state.params.pen_width, params.pen_width,
target_state.pen_width, target_state.pen_width,
duration, duration,
CubicInOut, CubicInOut,
); );
state.tween_controller.current_tween = Some(CommandTween { self.current_tween = Some(CommandTween {
turtle_id: state.turtle_id, turtle_id,
command: command_clone, command,
start_time: get_time(), start_time: get_time(),
duration, duration,
start_params: state.params.clone(), start_params: params.clone(),
target_params: target_state.clone(), target_params: target_state.clone(),
current_position: state.params.position, current_position: params.position,
current_heading: state.params.heading, current_heading: params.heading,
position_tweener, position_tweener,
heading_tweener, heading_tweener,
pen_width_tweener, pen_width_tweener,
@ -310,7 +341,7 @@ impl TweenController {
#[must_use] #[must_use]
pub fn is_complete(&self) -> bool { pub fn is_complete(&self) -> bool {
self.current_tween.is_none() && self.queue.is_complete() self.current_tween.is_none() && self.cursor >= self.queue.len()
} }
/// Get the current active tween if one is in progress /// Get the current active tween if one is in progress
@ -324,10 +355,10 @@ impl TweenController {
fn calculate_duration_with_state( fn calculate_duration_with_state(
command: &TurtleCommand, command: &TurtleCommand,
current: &Turtle, params: &TurtleParams,
speed: AnimationSpeed, speed: AnimationSpeed,
) -> f64 { ) -> f64 {
command.animation_duration(&current.params, speed) command.animation_duration(params, speed)
} }
fn calculate_target_state(current: &TurtleParams, command: &TurtleCommand) -> TurtleParams { fn calculate_target_state(current: &TurtleParams, command: &TurtleCommand) -> TurtleParams {