521 lines
19 KiB
Rust
521 lines
19 KiB
Rust
//! Command execution logic
|
|
|
|
use crate::circle_geometry::{CircleDirection, CircleGeometry};
|
|
use crate::commands::TurtleCommand;
|
|
use crate::state::{DrawCommand, Turtle, TurtleParams, TurtleWorld};
|
|
use crate::tessellation;
|
|
use macroquad::prelude::*;
|
|
|
|
#[cfg(test)]
|
|
use crate::general::AnimationSpeed;
|
|
|
|
/// Execute side effects for commands that don't involve movement
|
|
/// Returns true if the command was handled (caller should skip movement processing)
|
|
#[allow(clippy::too_many_lines)]
|
|
pub fn execute_command_side_effects(command: &TurtleCommand, state: &mut Turtle) -> bool {
|
|
match command {
|
|
TurtleCommand::BeginFill => {
|
|
if state.filling.is_some() {
|
|
tracing::warn!(
|
|
turtle_id = state.turtle_id,
|
|
"begin_fill() called while already filling"
|
|
);
|
|
}
|
|
let fill_color = state.params.fill_color.unwrap_or_else(|| {
|
|
tracing::warn!(
|
|
turtle_id = state.turtle_id,
|
|
"No fill_color set, using black"
|
|
);
|
|
BLACK
|
|
});
|
|
state.begin_fill(fill_color);
|
|
true
|
|
}
|
|
TurtleCommand::EndFill => {
|
|
if let Some(mut fill_state) = state.filling.take() {
|
|
if !fill_state.current_contour.is_empty() {
|
|
fill_state.contours.push(fill_state.current_contour);
|
|
}
|
|
|
|
let span = tracing::debug_span!(
|
|
"end_fill",
|
|
turtle_id = state.turtle_id,
|
|
contours = fill_state.contours.len()
|
|
);
|
|
let _enter = span.enter();
|
|
|
|
for (i, contour) in fill_state.contours.iter().enumerate() {
|
|
tracing::debug!(
|
|
turtle_id = state.turtle_id,
|
|
contour_idx = i,
|
|
vertices = contour.len(),
|
|
"Contour info"
|
|
);
|
|
}
|
|
|
|
if !fill_state.contours.is_empty() {
|
|
if let Ok(mesh_data) = tessellation::tessellate_multi_contour(
|
|
&fill_state.contours,
|
|
fill_state.fill_color,
|
|
) {
|
|
tracing::debug!(
|
|
turtle_id = state.turtle_id,
|
|
contours = fill_state.contours.len(),
|
|
"Successfully created fill mesh - persisting to commands"
|
|
);
|
|
state.commands.push(DrawCommand::Mesh {
|
|
data: mesh_data,
|
|
source: crate::state::TurtleSource {
|
|
command: crate::commands::TurtleCommand::EndFill,
|
|
color: state.params.color,
|
|
fill_color: fill_state.fill_color,
|
|
pen_width: state.params.pen_width,
|
|
start_position: fill_state.start_position,
|
|
end_position: fill_state.start_position,
|
|
},
|
|
});
|
|
} else {
|
|
tracing::error!(
|
|
turtle_id = state.turtle_id,
|
|
"Failed to tessellate contours"
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
tracing::warn!(
|
|
turtle_id = state.turtle_id,
|
|
"end_fill() called without begin_fill()"
|
|
);
|
|
}
|
|
true
|
|
}
|
|
TurtleCommand::PenUp => {
|
|
state.params.pen_down = false;
|
|
if state.filling.is_some() {
|
|
tracing::debug!(
|
|
turtle_id = state.turtle_id,
|
|
"PenUp: Closing current contour"
|
|
);
|
|
}
|
|
state.close_fill_contour();
|
|
true
|
|
}
|
|
TurtleCommand::PenDown => {
|
|
state.params.pen_down = true;
|
|
if state.filling.is_some() {
|
|
tracing::debug!(
|
|
turtle_id = state.turtle_id,
|
|
x = state.params.position.x,
|
|
y = state.params.position.y,
|
|
"PenDown: Starting new contour"
|
|
);
|
|
}
|
|
state.start_fill_contour();
|
|
true
|
|
}
|
|
|
|
TurtleCommand::Reset => {
|
|
state.reset();
|
|
true
|
|
}
|
|
|
|
TurtleCommand::WriteText { text, font_size } => {
|
|
state.commands.push(DrawCommand::Text {
|
|
text: text.clone(),
|
|
position: state.params.position,
|
|
heading: state.params.heading,
|
|
font_size: *font_size,
|
|
color: state.params.color,
|
|
source: crate::state::TurtleSource {
|
|
command: command.clone(),
|
|
color: state.params.color,
|
|
fill_color: state.params.fill_color.unwrap_or(BLACK),
|
|
pen_width: state.params.pen_width,
|
|
start_position: state.params.position,
|
|
end_position: state.params.position,
|
|
},
|
|
});
|
|
true
|
|
}
|
|
|
|
TurtleCommand::Move(_)
|
|
| TurtleCommand::Turn(_)
|
|
| TurtleCommand::Circle { .. }
|
|
| TurtleCommand::Goto(_)
|
|
| TurtleCommand::SetColor(_)
|
|
| TurtleCommand::SetFillColor(_)
|
|
| TurtleCommand::SetPenWidth(_)
|
|
| TurtleCommand::SetSpeed(_)
|
|
| TurtleCommand::SetShape(_)
|
|
| TurtleCommand::SetHeading(_)
|
|
| TurtleCommand::ShowTurtle
|
|
| TurtleCommand::HideTurtle => false,
|
|
}
|
|
}
|
|
|
|
/// Record fill vertices after movement commands have updated state
|
|
#[tracing::instrument]
|
|
pub fn record_fill_vertices_after_movement(
|
|
command: &TurtleCommand,
|
|
start_state: &TurtleParams,
|
|
state: &mut Turtle,
|
|
) {
|
|
if state.filling.is_none() {
|
|
return;
|
|
}
|
|
|
|
match command {
|
|
TurtleCommand::Circle {
|
|
radius,
|
|
angle,
|
|
steps,
|
|
direction,
|
|
} => {
|
|
let geom = CircleGeometry::new(
|
|
start_state.position,
|
|
start_state.heading,
|
|
*radius,
|
|
*direction,
|
|
);
|
|
state.record_fill_vertices_for_arc(
|
|
geom.center,
|
|
*radius,
|
|
geom.start_angle_from_center,
|
|
angle.to_radians(),
|
|
*direction,
|
|
*steps as u32,
|
|
);
|
|
}
|
|
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
|
|
state.record_fill_vertex();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Execute a single turtle command, updating state and adding draw commands
|
|
#[tracing::instrument]
|
|
pub fn execute_command(command: &TurtleCommand, state: &mut Turtle) {
|
|
// Try to execute as side-effect-only command first
|
|
if execute_command_side_effects(command, state) {
|
|
return; // Command fully handled
|
|
}
|
|
|
|
// Store start state for fill vertex recording
|
|
let start_state = state.clone();
|
|
|
|
// Execute movement and appearance commands
|
|
match command {
|
|
TurtleCommand::Move(distance) => {
|
|
let start = state.params.position;
|
|
let dx = distance * state.params.heading.cos();
|
|
let dy = distance * state.params.heading.sin();
|
|
state.params.position =
|
|
vec2(state.params.position.x + dx, state.params.position.y + dy);
|
|
|
|
if state.params.pen_down {
|
|
// Draw line segment with round caps (caps handled by tessellate_stroke)
|
|
if let Ok(mesh_data) = tessellation::tessellate_stroke(
|
|
&[start, state.params.position],
|
|
state.params.color,
|
|
state.params.pen_width,
|
|
false, // not closed
|
|
) {
|
|
state.commands.push(DrawCommand::Mesh {
|
|
data: mesh_data,
|
|
source: crate::state::TurtleSource {
|
|
command: command.clone(),
|
|
color: state.params.color,
|
|
fill_color: state.params.fill_color.unwrap_or(BLACK),
|
|
pen_width: state.params.pen_width,
|
|
start_position: start,
|
|
end_position: state.params.position,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
TurtleCommand::Turn(degrees) => {
|
|
state.params.heading += degrees.to_radians();
|
|
}
|
|
|
|
TurtleCommand::Circle {
|
|
radius,
|
|
angle,
|
|
steps,
|
|
direction,
|
|
} => {
|
|
let start_heading = state.params.heading;
|
|
let geom =
|
|
CircleGeometry::new(state.params.position, start_heading, *radius, *direction);
|
|
|
|
if state.params.pen_down {
|
|
// Use Lyon to tessellate the arc
|
|
if let Ok(mesh_data) = tessellation::tessellate_arc(
|
|
geom.center,
|
|
*radius,
|
|
geom.start_angle_from_center.to_degrees(),
|
|
*angle,
|
|
state.params.color,
|
|
state.params.pen_width,
|
|
*steps,
|
|
*direction,
|
|
) {
|
|
state.commands.push(DrawCommand::Mesh {
|
|
data: mesh_data,
|
|
source: crate::state::TurtleSource {
|
|
command: command.clone(),
|
|
color: state.params.color,
|
|
fill_color: state.params.fill_color.unwrap_or(BLACK),
|
|
pen_width: state.params.pen_width,
|
|
start_position: state.params.position,
|
|
end_position: state.params.position,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update turtle position and heading
|
|
state.params.position = geom.position_at_angle(angle.to_radians());
|
|
state.params.heading = match direction {
|
|
CircleDirection::Left => start_heading - angle.to_radians(),
|
|
CircleDirection::Right => start_heading + angle.to_radians(),
|
|
};
|
|
}
|
|
|
|
TurtleCommand::Goto(coord) => {
|
|
let start = state.params.position;
|
|
// Flip Y coordinate: turtle graphics uses Y+ = up, but Macroquad uses Y+ = down
|
|
state.params.position = vec2(coord.x, -coord.y);
|
|
|
|
if state.params.pen_down {
|
|
// Draw line segment with round caps
|
|
if let Ok(mesh_data) = tessellation::tessellate_stroke(
|
|
&[start, state.params.position],
|
|
state.params.color,
|
|
state.params.pen_width,
|
|
false, // not closed
|
|
) {
|
|
state.commands.push(DrawCommand::Mesh {
|
|
data: mesh_data,
|
|
source: crate::state::TurtleSource {
|
|
command: command.clone(),
|
|
color: state.params.color,
|
|
fill_color: state.params.fill_color.unwrap_or(BLACK),
|
|
pen_width: state.params.pen_width,
|
|
start_position: start,
|
|
end_position: state.params.position,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Appearance commands
|
|
TurtleCommand::SetColor(color) => state.params.color = *color,
|
|
TurtleCommand::SetFillColor(color) => state.params.fill_color = *color,
|
|
TurtleCommand::SetPenWidth(width) => state.params.pen_width = *width,
|
|
TurtleCommand::SetSpeed(speed) => state.set_speed(*speed),
|
|
TurtleCommand::SetShape(shape) => state.params.shape = shape.clone(),
|
|
TurtleCommand::SetHeading(heading) => state.params.heading = *heading,
|
|
TurtleCommand::ShowTurtle => state.params.visible = true,
|
|
TurtleCommand::HideTurtle => state.params.visible = false,
|
|
|
|
// Reset
|
|
TurtleCommand::Reset => {
|
|
state.reset();
|
|
}
|
|
|
|
_ => {} // Already handled by execute_command_side_effects
|
|
}
|
|
|
|
// Record fill vertices AFTER movement
|
|
record_fill_vertices_after_movement(command, &start_state.params, state);
|
|
}
|
|
|
|
/// Execute command on a specific turtle by ID
|
|
pub fn execute_command_with_id(command: &TurtleCommand, turtle_id: usize, world: &mut TurtleWorld) {
|
|
// Clone turtle state to avoid borrow checker issues
|
|
if let Some(turtle) = world.get_turtle(turtle_id) {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Add drawing command for a completed tween
|
|
pub fn add_draw_for_completed_tween(
|
|
command: &TurtleCommand,
|
|
start_state: &TurtleParams,
|
|
end_state: &mut TurtleParams,
|
|
) -> Option<DrawCommand> {
|
|
match command {
|
|
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
|
|
if start_state.pen_down {
|
|
if let Ok(mesh_data) = tessellation::tessellate_stroke(
|
|
&[start_state.position, end_state.position],
|
|
start_state.color,
|
|
start_state.pen_width,
|
|
false,
|
|
) {
|
|
return Some(DrawCommand::Mesh {
|
|
data: mesh_data,
|
|
source: crate::state::TurtleSource {
|
|
command: command.clone(),
|
|
color: start_state.color,
|
|
fill_color: start_state.fill_color.unwrap_or(BLACK),
|
|
pen_width: start_state.pen_width,
|
|
start_position: start_state.position,
|
|
end_position: end_state.position,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
TurtleCommand::Circle {
|
|
radius,
|
|
angle,
|
|
steps,
|
|
direction,
|
|
} => {
|
|
if start_state.pen_down {
|
|
let geom = CircleGeometry::new(
|
|
start_state.position,
|
|
start_state.heading,
|
|
*radius,
|
|
*direction,
|
|
);
|
|
if let Ok(mesh_data) = tessellation::tessellate_arc(
|
|
geom.center,
|
|
*radius,
|
|
geom.start_angle_from_center.to_degrees(),
|
|
*angle,
|
|
start_state.color,
|
|
start_state.pen_width,
|
|
*steps,
|
|
*direction,
|
|
) {
|
|
return Some(DrawCommand::Mesh {
|
|
data: mesh_data,
|
|
source: crate::state::TurtleSource {
|
|
command: command.clone(),
|
|
color: start_state.color,
|
|
fill_color: start_state.fill_color.unwrap_or(BLACK),
|
|
pen_width: start_state.pen_width,
|
|
start_position: start_state.position,
|
|
end_position: end_state.position,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
_ => (),
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::commands::TurtleCommand;
|
|
use crate::shapes::TurtleShape;
|
|
use crate::TweenController;
|
|
|
|
#[test]
|
|
fn test_forward_left_forward() {
|
|
// Test that after forward(100), left(90), forward(50)
|
|
// the turtle ends up at (100, -50) from initial position (0, 0)
|
|
use crate::state::TurtleParams;
|
|
|
|
let state = Turtle {
|
|
turtle_id: 0,
|
|
params: TurtleParams {
|
|
position: vec2(0.0, 0.0),
|
|
heading: 0.0,
|
|
pen_down: false, // Disable drawing to avoid needing TurtleWorld
|
|
pen_width: 1.0,
|
|
color: Color::new(0.0, 0.0, 0.0, 1.0),
|
|
fill_color: None,
|
|
visible: true,
|
|
shape: TurtleShape::turtle(),
|
|
speed: AnimationSpeed::Instant(100),
|
|
},
|
|
filling: None,
|
|
commands: Vec::new(),
|
|
tween_controller: TweenController::default(),
|
|
};
|
|
|
|
// We'll use a dummy world but won't actually call drawing commands
|
|
let world = TurtleWorld {
|
|
turtles: vec![state.clone()],
|
|
camera: macroquad::camera::Camera2D {
|
|
zoom: vec2(1.0, 1.0),
|
|
target: vec2(0.0, 0.0),
|
|
offset: vec2(0.0, 0.0),
|
|
rotation: 0.0,
|
|
render_target: None,
|
|
viewport: None,
|
|
},
|
|
background_color: Color::new(1.0, 1.0, 1.0, 1.0),
|
|
};
|
|
let mut state = world.turtles[0].clone();
|
|
|
|
// Initial state: position (0, 0), heading 0 (east)
|
|
assert_eq!(state.params.position.x, 0.0);
|
|
assert_eq!(state.params.position.y, 0.0);
|
|
assert_eq!(state.params.heading, 0.0);
|
|
|
|
// Forward 100 - should move to (100, 0)
|
|
execute_command(&TurtleCommand::Move(100.0), &mut state);
|
|
assert!(
|
|
(state.params.position.x - 100.0).abs() < 0.01,
|
|
"After forward(100): x = {}",
|
|
state.params.position.x
|
|
);
|
|
assert!(
|
|
(state.params.position.y - 0.0).abs() < 0.01,
|
|
"After forward(100): y = {}",
|
|
state.params.position.y
|
|
);
|
|
assert!((state.params.heading - 0.0).abs() < 0.01);
|
|
|
|
// Left 90 degrees - should face north (heading decreases by 90°)
|
|
// In screen coords: north = -90° = -π/2
|
|
execute_command(&TurtleCommand::Turn(-90.0), &mut state);
|
|
assert!(
|
|
(state.params.position.x - 100.0).abs() < 0.01,
|
|
"After left(90): x = {}",
|
|
state.params.position.x
|
|
);
|
|
assert!(
|
|
(state.params.position.y - 0.0).abs() < 0.01,
|
|
"After left(90): y = {}",
|
|
state.params.position.y
|
|
);
|
|
let expected_heading = -90.0f32.to_radians();
|
|
assert!(
|
|
(state.params.heading - expected_heading).abs() < 0.01,
|
|
"After left(90): heading = {} (expected {})",
|
|
state.params.heading,
|
|
expected_heading
|
|
);
|
|
|
|
// Forward 50 - should move north (negative Y) to (100, -50)
|
|
execute_command(&TurtleCommand::Move(50.0), &mut state);
|
|
assert!(
|
|
(state.params.position.x - 100.0).abs() < 0.01,
|
|
"Final position: x = {} (expected 100.0)",
|
|
state.params.position.x
|
|
);
|
|
assert!(
|
|
(state.params.position.y - (-50.0)).abs() < 0.01,
|
|
"Final position: y = {} (expected -50.0)",
|
|
state.params.position.y
|
|
);
|
|
}
|
|
}
|