add multi turtle support

This commit is contained in:
Franz Dietrich 2025-10-17 08:59:29 +02:00
parent bbb9348497
commit 3509060390
19 changed files with 624 additions and 628 deletions

View File

@ -19,7 +19,7 @@ use syn::{parse_macro_input, ItemFn};
///
/// # Example
///
/// ```no_run
/// ```ignore
/// use turtle_lib::*;
///
/// #[turtle_main("My Turtle Drawing")]
@ -34,7 +34,7 @@ use syn::{parse_macro_input, ItemFn};
///
/// If you need macroquad types not re-exported by turtle_lib:
///
/// ```no_run
/// ```ignore
/// use macroquad::prelude::SKYBLUE; // Import specific items
/// use turtle_lib::*;
///
@ -47,7 +47,7 @@ use syn::{parse_macro_input, ItemFn};
///
/// This expands to approximately:
///
/// ```no_run
/// ```ignore
/// use macroquad::prelude::*;
/// use turtle_lib::*;
///
@ -61,7 +61,7 @@ use syn::{parse_macro_input, ItemFn};
/// turtle.right(90.0);
/// turtle.forward(100.0);
///
/// let mut app = TurtleApp::new().with_commands(0, turtle.build());
/// let mut app = TurtleApp::new().with_commands(turtle.build());
///
/// loop {
/// clear_background(WHITE);
@ -108,7 +108,7 @@ pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream {
#fn_name(&mut turtle);
let mut app = turtle_lib::TurtleApp::new()
.with_commands(0, turtle.build());
.with_commands(turtle.build());
loop {
macroquad::prelude::clear_background(macroquad::prelude::WHITE);
@ -145,7 +145,7 @@ pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream {
#fn_block
let mut app = turtle_lib::TurtleApp::new()
.with_commands(0, turtle.build());
.with_commands(turtle.build());
loop {
macroquad::prelude::clear_background(macroquad::prelude::WHITE);

View File

@ -8,7 +8,10 @@ license = "MIT OR Apache-2.0"
macroquad = "0.4"
tween = "2.1.0"
lyon = "1.0"
tracing = { version = "0.1", features = ["log"], default-features = false }
tracing = { version = "0.1", features = [
"log",
"attributes",
], default-features = false }
turtle-lib-macros = { path = "../turtle-lib-macros" }
[dev-dependencies]

View File

@ -71,7 +71,7 @@ async fn main() {
println!("Building and executing turtle plan...");
// Execute the plan
let mut app = TurtleApp::new().with_commands(0, turtle.build());
let mut app = TurtleApp::new().with_commands(turtle.build());
loop {
clear_background(Color::new(0.95, 0.95, 0.98, 1.0));

View File

@ -13,11 +13,13 @@ fn draw(turtle: &mut TurtlePlan) {
turtle.set_speed(999);
turtle.circle_left(100.0, 540.0, 72); // partial circle to the left
turtle.begin_fill();
turtle.forward(150.0);
turtle.set_speed(100);
turtle.set_pen_color(BLUE);
turtle.circle_right(50.0, 270.0, 72); // partial circle to the right
// Set animation speed
turtle.end_fill();
turtle.set_speed(20);
turtle.forward(150.0);
turtle.circle_left(50.0, 180.0, 12);

View File

@ -40,7 +40,7 @@ use turtle_lib::*;
#[turtle_main("Dragon Curve")]
fn draw_dragon(turtle: &mut TurtlePlan) {
// Fast drawing
turtle.set_speed(1200);
turtle.set_speed(1020);
// Start position
turtle.pen_up();

View File

@ -111,7 +111,7 @@ async fn main() {
// Set animation speed
t.set_speed(500);
let mut app = TurtleApp::new().with_commands(0, t.build());
let mut app = TurtleApp::new().with_commands(t.build());
let target_fps = 1.0; // 1 frame per second for debugging
let frame_time = 1.0 / target_fps;

View File

@ -4,7 +4,7 @@
use turtle_lib::*;
#[turtle_main("Hello Turtle")]
#[turtle_main]
fn hello() {
turtle.set_pen_color(BLUE);
for _ in 0..4 {

View File

@ -31,7 +31,7 @@ async fn main() {
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
// Default to showing info-level logs if RUST_LOG is not set
tracing_subscriber::EnvFilter::new("turtle_lib=info")
tracing_subscriber::EnvFilter::new("turtle_lib=trace")
}),
)
.with_target(true) // Show which module the log came from
@ -75,7 +75,7 @@ async fn main() {
t.set_speed(100); // Slow animation to see the logs in real-time
// Create turtle app
let mut app = TurtleApp::new().with_commands(0, t.build());
let mut app = TurtleApp::new().with_commands(t.build());
// Main loop
loop {

View File

@ -1,17 +0,0 @@
//! Demo of the turtle_main macro with inline code
//!
//! This example shows that you can write your turtle code directly
//! in the function body without taking a turtle parameter.
use turtle_lib::*;
#[turtle_main("Macro Demo - Inline Spiral")]
fn draw_spiral() {
turtle.set_pen_color(RED);
turtle.set_pen_width(2.0);
for i in 0..36 {
turtle.forward(i as f32 * 3.0);
turtle.right(25.0);
}
}

View File

@ -0,0 +1,125 @@
//! Multi-turtle example demonstrating multiple independent turtles drawing simultaneously
//!
//! This example shows how to:
//! - Create multiple turtle instances using `add_turtle()`
//! - Control each turtle independently with separate command queues
//! - Position turtles at different locations using `go_to()`
//! - Use different colors and pen widths for each turtle
//! - Combine all turtle animations in a single rendering loop
use macroquad::{miniquad::window::set_window_size, prelude::*};
use turtle_lib::*;
#[macroquad::main("Multi-Turtle Example")]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
// Default to showing info-level logs if RUST_LOG is not set
tracing_subscriber::EnvFilter::new("turtle_lib=error")
}),
)
.with_target(true) // Show which module the log came from
.with_thread_ids(false)
.with_line_number(true) // Show line numbers
.with_file(false)
.without_time()
.init();
let mut app = TurtleApp::new();
set_window_size(1900, 1000);
// Turtle 0 (default turtle) - Draw a spiral (red)
let mut turtle0 = create_turtle();
turtle0.right(45.0);
turtle0.set_speed(1900.0);
turtle0.set_pen_color(RED);
turtle0.set_fill_color(RED);
turtle0.set_pen_width(2.0);
turtle0.begin_fill();
for i in 0..36 {
turtle0.forward(5.0 + i as f32 * 2.0).right(10.0);
}
turtle0.pen_up();
turtle0.go_to(vec2(-200.0, -200.0));
turtle0.pen_down();
turtle0.circle_left(30.0, 360.0, 36);
turtle0.end_fill();
// Turtle 1 - Draw a square (blue)
let turtle1_id = app.add_turtle();
let mut turtle1 = create_turtle();
turtle1.set_speed(1900.0);
turtle1.pen_up();
turtle1.go_to(vec2(-200.0, 0.0));
turtle1.pen_down();
turtle1.set_pen_color(BLUE);
turtle1.set_fill_color(BLUE);
turtle1.set_pen_width(3.0);
turtle1.begin_fill();
for _ in 0..4 {
turtle1.forward(100.0).right(90.0);
}
turtle1.end_fill();
// Turtle 2 - Draw a hexagon (green)
let turtle2_id = app.add_turtle();
let mut turtle2 = create_turtle();
turtle2.set_speed(150.0);
turtle2.pen_up();
turtle2.go_to(vec2(200.0, 0.0));
turtle2.pen_down();
turtle2.set_pen_color(GREEN);
turtle2.set_pen_width(3.0);
for _ in 0..6 {
turtle2.forward(80.0).right(60.0);
}
// Turtle 3 - Draw a star (yellow)
let turtle3_id = app.add_turtle();
let mut turtle3 = create_turtle();
turtle3.set_fill_color(ORANGE);
turtle3.begin_fill();
turtle3.set_speed(150.0);
turtle3.pen_up();
turtle3.go_to(vec2(0.0, 150.0));
turtle3.pen_down();
turtle3.set_pen_color(YELLOW);
turtle3.set_pen_width(3.0);
for _ in 0..5 {
turtle3.forward(120.0).right(144.0);
}
turtle3.end_fill();
// Turtle 4 - Draw a filled circle (purple)
let turtle4_id = app.add_turtle();
let mut turtle4 = create_turtle();
turtle4.set_speed(150.0);
turtle4.pen_up();
turtle4.go_to(vec2(0.0, -150.0));
turtle4.pen_down();
turtle4.set_pen_color(PURPLE);
turtle4.set_fill_color(Color::new(0.5, 0.0, 0.5, 0.5));
turtle4.begin_fill();
turtle4.circle_left(60.0, 360.0, 36);
turtle4.end_fill();
// Add all commands to the app
app = app.with_commands(turtle0.build());
app = app.with_commands_for_turtle(turtle1_id, turtle1.build());
app = app.with_commands_for_turtle(turtle2_id, turtle2.build());
app = app.with_commands_for_turtle(turtle3_id, turtle3.build());
app = app.with_commands_for_turtle(turtle4_id, turtle4.build());
// Main loop
loop {
clear_background(WHITE);
app.update();
app.render();
if is_key_pressed(KeyCode::Escape) || is_key_pressed(KeyCode::Q) {
break;
}
next_frame().await;
}
}

View File

@ -49,7 +49,7 @@ fn draw(turtle: &mut TurtlePlan) {
turtle.pen_up();
turtle.backward(80.0);
turtle.left(90.0);
turtle.forward(50.0);
turtle.backward(50.0);
turtle.right(90.0);
turtle.pen_down();

View File

@ -1,126 +0,0 @@
//! Celebrates the 1.0.0 release of the original sunjay/turtle library.
//!
//! This example draws "1.0.0" with decorative background lines and filled shapes.
//! Ported from the original sunjay/turtle example.
use turtle_lib::*;
#[turtle_main("Version 1.0.0")]
fn draw_version(turtle: &mut TurtlePlan) {
turtle.set_pen_width(10.0);
turtle.set_speed(999); // instant
turtle.pen_up();
turtle.go_to(vec2(350.0, 178.0));
turtle.pen_down();
bg_lines(turtle);
turtle.pen_up();
turtle.go_to(vec2(-270.0, -200.0));
turtle.set_heading(90.0);
turtle.pen_down();
turtle.set_speed(100); // normal
turtle.set_pen_color(BLUE);
// Cyan with alpha - using RGB values for Color::from("#00E5FF")
turtle.set_fill_color([0.0, 0.898, 1.0, 0.75]);
one(turtle);
turtle.set_speed(200); // faster
turtle.pen_up();
turtle.left(90.0);
turtle.backward(50.0);
turtle.pen_down();
small_circle(turtle);
turtle.pen_up();
turtle.backward(150.0);
turtle.pen_down();
zero(turtle);
turtle.pen_up();
turtle.backward(150.0);
turtle.pen_down();
small_circle(turtle);
turtle.pen_up();
turtle.backward(150.0);
turtle.pen_down();
zero(turtle);
}
fn bg_lines(turtle: &mut TurtlePlan) {
// Light green color for background lines (#76FF03)
turtle.set_pen_color([0.463, 1.0, 0.012, 1.0].into());
turtle.set_heading(165.0);
turtle.forward(280.0);
turtle.left(147.0);
turtle.forward(347.0);
turtle.right(158.0);
turtle.forward(547.0);
turtle.left(138.0);
turtle.forward(539.0);
turtle.right(168.0);
turtle.forward(477.0);
turtle.left(154.0);
turtle.forward(377.0);
turtle.right(158.0);
turtle.forward(329.0);
}
fn small_circle(turtle: &mut TurtlePlan) {
turtle.begin_fill();
for _ in 0..90 {
turtle.forward(1.0);
turtle.right(4.0);
}
turtle.end_fill();
}
fn one(turtle: &mut TurtlePlan) {
turtle.begin_fill();
for _ in 0..2 {
turtle.forward(420.0);
turtle.left(90.0);
turtle.forward(50.0);
turtle.left(90.0);
}
turtle.end_fill();
}
fn zero(turtle: &mut TurtlePlan) {
turtle.begin_fill();
for _ in 0..2 {
arc_right(turtle);
arc_forward(turtle);
}
turtle.end_fill();
}
fn arc_right(turtle: &mut TurtlePlan) {
// Draw an arc that moves right faster than it moves forward
for i in 0..90 {
turtle.forward(3.0);
turtle.right((90.0 - i as f32) / 45.0);
}
}
fn arc_forward(turtle: &mut TurtlePlan) {
// Draw an arc that moves forward faster than it moves right
for i in 0..90 {
turtle.forward(3.0);
turtle.right(i as f32 / 45.0);
}
}

View File

@ -24,7 +24,4 @@ fn draw(turtle: &mut TurtlePlan) {
turtle.pen_down();
turtle.circle_right(8.0, 360.0, 12);
turtle.end_fill();
// Set animation speed
turtle.set_speed(1000);
}

View File

@ -45,7 +45,7 @@ pub enum TurtleCommand {
}
/// Queue of turtle commands with execution state
#[derive(Debug)]
#[derive(Clone, Debug)]
pub struct CommandQueue {
commands: Vec<TurtleCommand>,
current_index: usize,

View File

@ -1,9 +1,8 @@
//! Rendering logic using Macroquad and Lyon tessellation
use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::state::{DrawCommand, TurtleState, TurtleWorld};
use crate::state::{DrawCommand, TurtleParams, TurtleWorld};
use crate::tessellation;
use crate::tweening::CommandTween;
use macroquad::prelude::*;
// Import the easing function from the tween crate
@ -25,19 +24,21 @@ pub fn render_world(world: &TurtleWorld) {
// Set camera
set_camera(&camera);
// Draw all accumulated commands
for cmd in &world.commands {
match cmd {
DrawCommand::Mesh { data, .. } => {
draw_mesh(&data.to_mesh());
// Draw all accumulated commands from all turtles
for turtle in &world.turtles {
for cmd in &turtle.commands {
match cmd {
DrawCommand::Mesh { data } => {
draw_mesh(&data.to_mesh());
}
}
}
}
// Draw all visible turtles
for turtle in &world.turtles {
if turtle.visible {
draw_turtle(turtle);
if turtle.params.visible {
draw_turtle(&turtle.params);
}
}
@ -47,11 +48,7 @@ pub fn render_world(world: &TurtleWorld) {
/// Render the turtle world with active tween visualization
#[allow(clippy::too_many_lines)]
pub fn render_world_with_tween(
world: &TurtleWorld,
active_tween: Option<&CommandTween>,
zoom_level: f32,
) {
pub fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
// Update camera zoom based on current screen size to prevent stretching
// Apply user zoom level by dividing by it (smaller zoom value = more zoomed in)
let camera = Camera2D {
@ -66,59 +63,58 @@ pub fn render_world_with_tween(
// Set camera
set_camera(&camera);
// Draw all accumulated commands
for cmd in &world.commands {
match cmd {
DrawCommand::Mesh { data, .. } => {
draw_mesh(&data.to_mesh());
// Draw all accumulated commands from all turtles
for turtle in &world.turtles {
for cmd in &turtle.commands {
match cmd {
DrawCommand::Mesh { data } => {
draw_mesh(&data.to_mesh());
}
}
}
}
// Draw in-progress tween line if pen is down
// Extract turtle_id from active tween (default to 0 if no active tween)
let active_turtle_id = active_tween.map_or(0, |tween| tween.turtle_id);
if let Some(tween) = active_tween {
if tween.start_state.pen_down {
match &tween.command {
crate::commands::TurtleCommand::Circle {
radius,
angle,
steps,
direction,
} => {
// Draw arc segments from start to current position
draw_tween_arc(tween, *radius, *angle, *steps, *direction);
}
_ if should_draw_tween_line(&tween.command) => {
// Draw straight line for other movement commands (use active turtle)
if let Some(turtle) = world.turtles.get(active_turtle_id) {
// Draw in-progress tween lines for all active tweens
for turtle in world.turtles.iter() {
if let Some(tween) = turtle.tween_controller.current_tween() {
// Only draw if pen is down
if tween.start_params.pen_down {
match &tween.command {
crate::commands::TurtleCommand::Circle {
radius,
angle,
steps,
direction,
} => {
// Draw arc segments from start to current position
draw_tween_arc(tween, *radius, *angle, *steps, *direction);
}
_ if should_draw_tween_line(&tween.command) => {
// Draw straight line for other movement commands (use tween's current position)
draw_line(
tween.start_state.position.x,
tween.start_state.position.y,
turtle.position.x,
turtle.position.y,
tween.start_state.pen_width,
tween.start_state.color,
tween.start_params.position.x,
tween.start_params.position.y,
tween.current_position.x,
tween.current_position.y,
tween.start_params.pen_width,
tween.start_params.color,
);
// Add circle at current position for smooth line joins
draw_circle(
turtle.position.x,
turtle.position.y,
tween.start_state.pen_width / 2.0,
tween.start_state.color,
tween.current_position.x,
tween.current_position.y,
tween.start_params.pen_width / 2.0,
tween.start_params.color,
);
}
_ => {}
}
_ => {}
}
}
}
// Draw live fill preview if currently filling (always show, not just during tweens)
// Use the active turtle if available, otherwise default to turtle 0
if let Some(turtle) = world.turtles.get(active_turtle_id) {
// Draw live fill preview for all turtles that are currently filling
for turtle in world.turtles.iter() {
if let Some(ref fill_state) = turtle.filling {
// Build all contours: completed contours + current contour with animation
let mut all_contours: Vec<Vec<Vec2>> = Vec::new();
@ -133,16 +129,22 @@ pub fn render_world_with_tween(
}
// Build current contour with animation
let mut current_preview: Vec<Vec2> = fill_state
.current_contour
.iter()
.map(|c| Vec2::new(c.x, c.y))
.collect();
// Find the tween for this specific turtle
let turtle_tween = turtle.tween_controller.current_tween();
// If we have an active tween, add progressive vertices
if let Some(tween) = active_tween {
// If we're animating a circle command with pen down, add arc vertices
if tween.start_state.pen_down {
let mut current_preview: Vec<Vec2>;
// If we have an active tween for this turtle, build the preview from tween state
if let Some(tween) = turtle_tween {
// Start with the existing contour vertices (vertices before the current tween)
current_preview = fill_state
.current_contour
.iter()
.map(|c| Vec2::new(c.x, c.y))
.collect();
// If we're animating a circle command with pen down, add progressive arc vertices
if tween.start_params.pen_down {
if let crate::commands::TurtleCommand::Circle {
radius,
angle,
@ -153,13 +155,11 @@ pub fn render_world_with_tween(
// Calculate partial arc vertices based on current progress
use crate::circle_geometry::CircleGeometry;
let geom = CircleGeometry::new(
tween.start_state.position,
tween.start_state.heading,
tween.start_params.position,
tween.start_params.heading,
*radius,
*direction,
);
// Calculate progress
); // Calculate progress
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);
@ -193,8 +193,11 @@ pub fn render_world_with_tween(
crate::commands::TurtleCommand::Move(_)
| crate::commands::TurtleCommand::Goto(_)
) {
// For Move/Goto commands, just add the current position
current_preview.push(Vec2::new(turtle.position.x, turtle.position.y));
// For Move/Goto commands, just add the current position from tween
current_preview.push(Vec2::new(
tween.current_position.x,
tween.current_position.y,
));
}
} else if matches!(
&tween.command,
@ -202,25 +205,38 @@ pub fn render_world_with_tween(
| crate::commands::TurtleCommand::Goto(_)
) {
// For Move/Goto with pen up during filling, still add current position for preview
current_preview.push(Vec2::new(turtle.position.x, turtle.position.y));
current_preview.push(Vec2::new(
tween.current_position.x,
tween.current_position.y,
));
}
// Add current turtle position if not already included
if let Some(last) = current_preview.last() {
let current_pos = turtle.position;
let current_pos = tween.current_position;
// Use a larger threshold to reduce flickering from tiny movements
if (last.x - current_pos.x).abs() > 0.1 || (last.y - current_pos.y).abs() > 0.1
{
current_preview.push(Vec2::new(current_pos.x, current_pos.y));
}
} else if !current_preview.is_empty() {
current_preview.push(Vec2::new(turtle.position.x, turtle.position.y));
current_preview.push(Vec2::new(
tween.current_position.x,
tween.current_position.y,
));
}
} else {
// No active tween - use the actual current contour from fill state
current_preview = fill_state
.current_contour
.iter()
.map(|c| Vec2::new(c.x, c.y))
.collect();
// No active tween - just show current state
if !current_preview.is_empty() {
if let Some(last) = current_preview.last() {
let current_pos = turtle.position;
let current_pos = turtle.params.position;
if (last.x - current_pos.x).abs() > 0.1
|| (last.y - current_pos.y).abs() > 0.1
{
@ -254,8 +270,8 @@ pub fn render_world_with_tween(
// Draw all visible turtles
for turtle in &world.turtles {
if turtle.visible {
draw_turtle(turtle);
if turtle.params.visible {
draw_turtle(&turtle.params);
}
}
@ -277,8 +293,8 @@ fn draw_tween_arc(
direction: CircleDirection,
) {
let geom = CircleGeometry::new(
tween.start_state.position,
tween.start_state.heading,
tween.start_params.position,
tween.start_params.heading,
radius,
direction,
);
@ -301,8 +317,8 @@ fn draw_tween_arc(
radius,
geom.start_angle_from_center.to_degrees(),
total_angle * progress,
tween.start_state.color,
tween.start_state.pen_width,
tween.start_params.color,
tween.start_params.pen_width,
((steps as f32 * progress).ceil() as usize).max(1),
direction,
) {
@ -311,15 +327,15 @@ fn draw_tween_arc(
}
/// Draw the turtle shape
pub fn draw_turtle(turtle: &TurtleState) {
let rotated_vertices = turtle.shape.rotated_vertices(turtle.heading);
pub fn draw_turtle(turtle_params: &TurtleParams) {
let rotated_vertices = turtle_params.shape.rotated_vertices(turtle_params.heading);
if turtle.shape.filled {
if turtle_params.shape.filled {
// Draw filled polygon using Lyon tessellation
if rotated_vertices.len() >= 3 {
let absolute_vertices: Vec<Vec2> = rotated_vertices
.iter()
.map(|v| turtle.position + *v)
.map(|v| turtle_params.position + *v)
.collect();
// Use Lyon for turtle shape too
@ -345,8 +361,8 @@ pub fn draw_turtle(turtle: &TurtleState) {
if !rotated_vertices.is_empty() {
for i in 0..rotated_vertices.len() {
let next_i = (i + 1) % rotated_vertices.len();
let p1 = turtle.position + rotated_vertices[i];
let p2 = turtle.position + rotated_vertices[next_i];
let p1 = turtle_params.position + rotated_vertices[i];
let p2 = turtle_params.position + rotated_vertices[next_i];
draw_line(p1.x, p1.y, p2.x, p2.y, 2.0, Color::new(0.0, 0.5, 1.0, 1.0));
}
}

View File

@ -2,7 +2,7 @@
use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::commands::TurtleCommand;
use crate::state::{DrawCommand, TurtleState, TurtleWorld};
use crate::state::{DrawCommand, Turtle, TurtleParams, TurtleWorld};
use crate::tessellation;
use macroquad::prelude::*;
@ -11,18 +11,20 @@ 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)
pub fn execute_command_side_effects(
command: &TurtleCommand,
state: &mut TurtleState,
commands: &mut Vec<DrawCommand>,
) -> bool {
pub fn execute_command_side_effects(command: &TurtleCommand, state: &mut Turtle) -> bool {
match command {
TurtleCommand::BeginFill => {
if state.filling.is_some() {
tracing::warn!("begin_fill() called while already filling");
tracing::warn!(
turtle_id = state.turtle_id,
"begin_fill() called while already filling"
);
}
let fill_color = state.fill_color.unwrap_or_else(|| {
tracing::warn!("No fill_color set, using black");
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);
@ -35,11 +37,20 @@ pub fn execute_command_side_effects(
fill_state.contours.push(fill_state.current_contour);
}
let span = tracing::debug_span!("end_fill", contours = fill_state.contours.len());
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!(contour_idx = i, vertices = contour.len(), "Contour info");
tracing::debug!(
turtle_id = state.turtle_id,
contour_idx = i,
vertices = contour.len(),
"Contour info"
);
}
if !fill_state.contours.is_empty() {
@ -48,38 +59,46 @@ pub fn execute_command_side_effects(
fill_state.fill_color,
) {
tracing::debug!(
turtle_id = state.turtle_id,
contours = fill_state.contours.len(),
"Successfully tessellated contours"
"Successfully created fill mesh - persisting to commands"
);
commands.push(DrawCommand::Mesh {
turtle_id: 0,
data: mesh_data,
});
state.commands.push(DrawCommand::Mesh { data: mesh_data });
} else {
tracing::error!("Failed to tessellate contours");
tracing::error!(
turtle_id = state.turtle_id,
"Failed to tessellate contours"
);
}
}
} else {
tracing::warn!("end_fill() called without begin_fill()");
tracing::warn!(
turtle_id = state.turtle_id,
"end_fill() called without begin_fill()"
);
}
true
}
TurtleCommand::PenUp => {
state.pen_down = false;
state.params.pen_down = false;
if state.filling.is_some() {
tracing::debug!("PenUp: Closing current contour");
tracing::debug!(
turtle_id = state.turtle_id,
"PenUp: Closing current contour"
);
}
state.close_fill_contour();
true
}
TurtleCommand::PenDown => {
state.pen_down = true;
state.params.pen_down = true;
if state.filling.is_some() {
tracing::debug!(
x = state.position.x,
y = state.position.y,
turtle_id = state.turtle_id,
x = state.params.position.x,
y = state.params.position.y,
"PenDown: Starting new contour"
);
}
@ -92,10 +111,11 @@ pub fn execute_command_side_effects(
}
/// Record fill vertices after movement commands have updated state
#[tracing::instrument]
pub fn record_fill_vertices_after_movement(
command: &TurtleCommand,
start_state: &TurtleState,
state: &mut TurtleState,
start_state: &TurtleParams,
state: &mut Turtle,
) {
if state.filling.is_none() {
return;
@ -131,9 +151,10 @@ pub fn record_fill_vertices_after_movement(
}
/// Execute a single turtle command, updating state and adding draw commands
pub fn execute_command(command: &TurtleCommand, state: &mut TurtleState, world: &mut TurtleWorld) {
#[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, &mut world.commands) {
if execute_command_side_effects(command, state) {
return; // Command fully handled
}
@ -143,29 +164,27 @@ pub fn execute_command(command: &TurtleCommand, state: &mut TurtleState, world:
// Execute movement and appearance commands
match command {
TurtleCommand::Move(distance) => {
let start = state.position;
let dx = distance * state.heading.cos();
let dy = distance * state.heading.sin();
state.position = vec2(state.position.x + dx, state.position.y + dy);
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.pen_down {
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.position],
state.color,
state.pen_width,
&[start, state.params.position],
state.params.color,
state.params.pen_width,
false, // not closed
) {
world.add_command(DrawCommand::Mesh {
turtle_id: 0,
data: mesh_data,
});
state.commands.push(DrawCommand::Mesh { data: mesh_data });
}
}
}
TurtleCommand::Turn(degrees) => {
state.heading += degrees.to_radians();
state.params.heading += degrees.to_radians();
}
TurtleCommand::Circle {
@ -174,71 +193,66 @@ pub fn execute_command(command: &TurtleCommand, state: &mut TurtleState, world:
steps,
direction,
} => {
let start_heading = state.heading;
let geom = CircleGeometry::new(state.position, start_heading, *radius, *direction);
let start_heading = state.params.heading;
let geom =
CircleGeometry::new(state.params.position, start_heading, *radius, *direction);
if state.pen_down {
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.color,
state.pen_width,
state.params.color,
state.params.pen_width,
*steps,
*direction,
) {
world.add_command(DrawCommand::Mesh {
turtle_id: 0,
data: mesh_data,
});
state.commands.push(DrawCommand::Mesh { data: mesh_data });
}
}
// Update turtle position and heading
state.position = geom.position_at_angle(angle.to_radians());
state.heading = match direction {
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.position;
state.position = *coord;
let start = state.params.position;
state.params.position = *coord;
if state.pen_down {
if state.params.pen_down {
// Draw line segment with round caps
if let Ok(mesh_data) = tessellation::tessellate_stroke(
&[start, state.position],
state.color,
state.pen_width,
&[start, state.params.position],
state.params.color,
state.params.pen_width,
false, // not closed
) {
world.add_command(DrawCommand::Mesh {
turtle_id: 0,
data: mesh_data,
});
state.commands.push(DrawCommand::Mesh { data: mesh_data });
}
}
}
// Appearance commands
TurtleCommand::SetColor(color) => state.color = *color,
TurtleCommand::SetFillColor(color) => state.fill_color = *color,
TurtleCommand::SetPenWidth(width) => state.pen_width = *width,
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.shape = shape.clone(),
TurtleCommand::SetHeading(heading) => state.heading = *heading,
TurtleCommand::ShowTurtle => state.visible = true,
TurtleCommand::HideTurtle => state.visible = false,
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,
_ => {} // Already handled by execute_command_side_effects
}
// Record fill vertices AFTER movement
record_fill_vertices_after_movement(command, &start_state, state);
record_fill_vertices_after_movement(command, &start_state.params, state);
}
/// Execute command on a specific turtle by ID
@ -246,7 +260,7 @@ pub fn execute_command_with_id(command: &TurtleCommand, turtle_id: usize, world:
// 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, world);
execute_command(command, &mut state);
// Update the turtle state back
if let Some(turtle_mut) = world.get_turtle_mut(turtle_id) {
*turtle_mut = state;
@ -254,85 +268,22 @@ pub fn execute_command_with_id(command: &TurtleCommand, turtle_id: usize, world:
}
}
/// Add drawing command for a completed tween with turtle_id tracking
pub fn add_draw_for_completed_tween_with_id(
command: &TurtleCommand,
start_state: &TurtleState,
end_state: &TurtleState,
world: &mut TurtleWorld,
turtle_id: usize,
) {
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,
) {
world.add_command(DrawCommand::Mesh {
turtle_id,
data: mesh_data,
});
}
}
}
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,
) {
world.add_command(DrawCommand::Mesh {
turtle_id,
data: mesh_data,
});
}
}
}
_ => {}
}
}
/// Add drawing command for a completed tween (state transition already occurred)
/// Add drawing command for a completed tween
pub fn add_draw_for_completed_tween(
command: &TurtleCommand,
start_state: &TurtleState,
end_state: &TurtleState,
world: &mut TurtleWorld,
) {
start_state: &TurtleParams,
end_state: &mut TurtleParams,
) -> Option<DrawCommand> {
match command {
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
if start_state.pen_down {
// Draw line segment with round caps
if let Ok(mesh_data) = tessellation::tessellate_stroke(
&[start_state.position, end_state.position],
start_state.color,
start_state.pen_width,
false,
) {
world.add_command(DrawCommand::Mesh {
turtle_id: 0,
data: mesh_data,
});
return Some(DrawCommand::Mesh { data: mesh_data });
}
}
}
@ -349,7 +300,6 @@ pub fn add_draw_for_completed_tween(
*radius,
*direction,
);
if let Ok(mesh_data) = tessellation::tessellate_arc(
geom.center,
*radius,
@ -360,17 +310,13 @@ pub fn add_draw_for_completed_tween(
*steps,
*direction,
) {
world.add_command(DrawCommand::Mesh {
turtle_id: 0,
data: mesh_data,
});
return Some(DrawCommand::Mesh { data: mesh_data });
}
}
}
_ => {
// Other commands don't create drawing
}
_ => (),
}
None
}
#[cfg(test)]
@ -378,28 +324,35 @@ 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)
let mut state = TurtleState {
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,
speed: AnimationSpeed::Animated(100.0),
visible: true,
shape: TurtleShape::turtle(),
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 mut world = TurtleWorld {
let world = TurtleWorld {
turtles: vec![state.clone()],
commands: Vec::new(),
camera: macroquad::camera::Camera2D {
zoom: vec2(1.0, 1.0),
target: vec2(0.0, 0.0),
@ -413,56 +366,56 @@ mod tests {
let mut state = world.turtles[0].clone();
// Initial state: position (0, 0), heading 0 (east)
assert_eq!(state.position.x, 0.0);
assert_eq!(state.position.y, 0.0);
assert_eq!(state.heading, 0.0);
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, &mut world);
execute_command(&TurtleCommand::Move(100.0), &mut state);
assert!(
(state.position.x - 100.0).abs() < 0.01,
(state.params.position.x - 100.0).abs() < 0.01,
"After forward(100): x = {}",
state.position.x
state.params.position.x
);
assert!(
(state.position.y - 0.0).abs() < 0.01,
(state.params.position.y - 0.0).abs() < 0.01,
"After forward(100): y = {}",
state.position.y
state.params.position.y
);
assert!((state.heading - 0.0).abs() < 0.01);
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, &mut world);
execute_command(&TurtleCommand::Turn(-90.0), &mut state);
assert!(
(state.position.x - 100.0).abs() < 0.01,
(state.params.position.x - 100.0).abs() < 0.01,
"After left(90): x = {}",
state.position.x
state.params.position.x
);
assert!(
(state.position.y - 0.0).abs() < 0.01,
(state.params.position.y - 0.0).abs() < 0.01,
"After left(90): y = {}",
state.position.y
state.params.position.y
);
let expected_heading = -90.0f32.to_radians();
assert!(
(state.heading - expected_heading).abs() < 0.01,
(state.params.heading - expected_heading).abs() < 0.01,
"After left(90): heading = {} (expected {})",
state.heading,
state.params.heading,
expected_heading
);
// Forward 50 - should move north (negative Y) to (100, -50)
execute_command(&TurtleCommand::Move(50.0), &mut state, &mut world);
execute_command(&TurtleCommand::Move(50.0), &mut state);
assert!(
(state.position.x - 100.0).abs() < 0.01,
(state.params.position.x - 100.0).abs() < 0.01,
"Final position: x = {} (expected 100.0)",
state.position.x
state.params.position.x
);
assert!(
(state.position.y - (-50.0)).abs() < 0.01,
(state.params.position.y - (-50.0)).abs() < 0.01,
"Final position: y = {} (expected -50.0)",
state.position.y
state.params.position.y
);
}
}

View File

@ -62,7 +62,7 @@ pub use builders::{CurvedMovement, DirectionalMovement, Turnable, TurtlePlan, Wi
pub use commands::{CommandQueue, TurtleCommand};
pub use general::{Angle, AnimationSpeed, Color, Coordinate, Length, Precision};
pub use shapes::{ShapeType, TurtleShape};
pub use state::{DrawCommand, TurtleState, TurtleWorld};
pub use state::{DrawCommand, Turtle, TurtleWorld};
pub use tweening::TweenController;
// Re-export the turtle_main macro
@ -78,9 +78,6 @@ use macroquad::prelude::*;
/// Main turtle application struct
pub struct TurtleApp {
world: TurtleWorld,
/// One tween controller per turtle (indexed by turtle ID)
tween_controllers: Vec<TweenController>,
speed: AnimationSpeed,
// Mouse panning state
is_dragging: bool,
last_mouse_pos: Option<Vec2>,
@ -94,8 +91,6 @@ impl TurtleApp {
pub fn new() -> Self {
Self {
world: TurtleWorld::new(),
tween_controllers: Vec::new(),
speed: AnimationSpeed::default(),
is_dragging: false,
last_mouse_pos: None,
zoom_level: 1.0,
@ -104,14 +99,23 @@ impl TurtleApp {
/// Add a new turtle and return its ID
pub fn add_turtle(&mut self) -> usize {
let id = self.world.add_turtle();
let speed = self.speed;
self.tween_controllers
.push(TweenController::new(id, CommandQueue::new(), speed));
id
self.world.add_turtle()
}
/// Add commands to a specific turtle
/// Add commands from a turtle plan to the application for the default turtle (ID 0)
///
/// Speed is controlled by `SetSpeed` commands in the queue.
/// Use `set_speed()` on the turtle plan to set animation speed.
/// Speed >= 999 = instant mode, speed < 999 = animated mode.
///
/// # Arguments
/// * `queue` - The command queue to execute
#[must_use]
pub fn with_commands(self, queue: CommandQueue) -> Self {
self.with_commands_for_turtle(0, queue)
}
/// Add commands from a turtle plan to the application for a specific turtle
///
/// Speed is controlled by `SetSpeed` commands in the queue.
/// Use `set_speed()` on the turtle plan to set animation speed.
@ -121,17 +125,16 @@ impl TurtleApp {
/// * `turtle_id` - The ID of the turtle to control
/// * `queue` - The command queue to execute
#[must_use]
pub fn with_commands(mut self, turtle_id: usize, queue: CommandQueue) -> Self {
// Ensure we have a controller for this turtle
while self.tween_controllers.len() <= turtle_id {
let id = self.tween_controllers.len();
let speed = self.speed;
self.tween_controllers
.push(TweenController::new(id, CommandQueue::new(), speed));
pub fn with_commands_for_turtle(mut self, turtle_id: usize, queue: CommandQueue) -> Self {
// Ensure turtle exists
while self.world.turtles.len() <= turtle_id {
self.world.add_turtle();
}
// Append commands to the controller
self.tween_controllers[turtle_id].append_commands(queue);
// Append commands to the turtle's controller
if let Some(turtle) = self.world.get_turtle_mut(turtle_id) {
turtle.tween_controller.append_commands(queue);
}
self
}
@ -144,15 +147,14 @@ impl TurtleApp {
/// Append commands to a turtle's animation queue
pub fn append_to_queue(&mut self, turtle_id: usize, plan: TurtlePlan) {
// Ensure we have a controller for this turtle
while self.tween_controllers.len() <= turtle_id {
let id = self.tween_controllers.len();
let speed = AnimationSpeed::default();
self.tween_controllers
.push(TweenController::new(id, CommandQueue::new(), speed));
// Ensure turtle exists
while self.world.turtles.len() <= turtle_id {
self.world.add_turtle();
}
self.tween_controllers[turtle_id].append_commands(plan.build());
if let Some(turtle) = self.world.get_turtle_mut(turtle_id) {
turtle.tween_controller.append_commands(plan.build());
}
}
/// Update animation state (call every frame)
@ -161,31 +163,22 @@ impl TurtleApp {
self.handle_mouse_panning();
self.handle_mouse_zoom();
// Update all active tween controllers
// Process each turtle separately to avoid borrow conflicts
let turtle_count = self.tween_controllers.len();
for turtle_id in 0..turtle_count {
// Extract commands temporarily to avoid double mutable borrow
let mut commands = std::mem::take(&mut self.world.commands);
// Update all turtles' tween controllers
for turtle in self.world.turtles.iter_mut() {
// Extract draw_commands and controller temporarily to avoid borrow conflicts
let completed_commands = if let Some(turtle) = self.world.get_turtle_mut(turtle_id) {
self.tween_controllers[turtle_id].update(turtle, &mut commands)
} else {
Vec::new()
};
// Update the controller
let completed_commands = TweenController::update(turtle);
// Put commands back
self.world.commands = commands;
// Process all completed commands
for (completed_cmd, start_state, end_state) in completed_commands {
execution::add_draw_for_completed_tween_with_id(
// Process all completed commands and add to the turtle's commands
for (completed_cmd, tween_start, mut end_state) in completed_commands {
let draw_command = execution::add_draw_for_completed_tween(
&completed_cmd,
&start_state,
&end_state,
&mut self.world,
turtle_id,
&tween_start,
&mut end_state,
);
// Add the new draw commands to the turtle
turtle.commands.extend(draw_command);
}
}
}
@ -237,21 +230,16 @@ impl TurtleApp {
/// Render the turtle world (call every frame)
pub fn render(&self) {
// Find the first active tween (turtle_id is now stored in the tween itself)
let active_tween = self
.tween_controllers
.iter()
.find_map(|controller| controller.current_tween());
drawing::render_world_with_tween(&self.world, active_tween, self.zoom_level);
drawing::render_world_with_tweens(&self.world, self.zoom_level);
}
/// Check if all commands have been executed
#[must_use]
pub fn is_complete(&self) -> bool {
self.tween_controllers
self.world
.turtles
.iter()
.all(TweenController::is_complete)
.all(|turtle| turtle.tween_controller.is_complete())
}
/// Get reference to the world state

View File

@ -1,7 +1,9 @@
//! Turtle state and world state management
use crate::general::{Angle, AnimationSpeed, Color, Coordinate, Precision};
use crate::commands::CommandQueue;
use crate::general::{Angle, AnimationSpeed, Color, Coordinate};
use crate::shapes::TurtleShape;
use crate::tweening::TweenController;
use macroquad::prelude::*;
/// State during active fill operation
@ -21,61 +23,88 @@ pub struct FillState {
pub fill_color: Color,
}
/// State of a single turtle
/// Parameters that define a turtle's visual state
#[derive(Clone, Debug)]
pub struct TurtleState {
pub position: Coordinate,
pub heading: Precision, // radians
pub struct TurtleParams {
pub position: Vec2,
pub heading: f32,
pub pen_down: bool,
pub pen_width: f32,
pub color: Color,
pub fill_color: Option<Color>,
pub pen_width: Precision,
pub speed: AnimationSpeed,
pub visible: bool,
pub shape: TurtleShape,
// Fill tracking
pub filling: Option<FillState>,
pub shape: crate::shapes::TurtleShape,
pub speed: AnimationSpeed,
}
impl Default for TurtleState {
impl Default for TurtleParams {
/// Create TurtleParams from default values
fn default() -> Self {
Self {
position: vec2(0.0, 0.0),
heading: 0.0, // pointing right (0 radians)
heading: 0.0,
pen_down: true,
pen_width: 2.0,
color: BLACK,
fill_color: None,
pen_width: 2.0,
speed: AnimationSpeed::default(),
visible: true,
shape: TurtleShape::turtle(),
filling: None,
speed: AnimationSpeed::default(),
}
}
}
impl TurtleState {
/// State of a single turtle
#[derive(Clone, Debug)]
pub struct Turtle {
pub turtle_id: usize,
pub params: TurtleParams,
// Fill tracking
pub filling: Option<FillState>,
// Drawing commands created by this turtle
pub commands: Vec<DrawCommand>,
// Animation controller for this turtle
pub tween_controller: TweenController,
}
impl Default for Turtle {
fn default() -> Self {
Self {
turtle_id: 0,
params: TurtleParams::default(),
filling: None,
commands: Vec::new(),
tween_controller: TweenController::new(CommandQueue::new(), AnimationSpeed::default()),
}
}
}
impl Turtle {
pub fn set_speed(&mut self, speed: AnimationSpeed) {
self.speed = speed;
self.params.speed = speed;
}
#[must_use]
pub fn heading_angle(&self) -> Angle {
Angle::radians(self.heading)
Angle::radians(self.params.heading)
}
/// Reset turtle to default state
/// Reset turtle to default state (preserves turtle_id)
pub fn reset(&mut self) {
let id = self.turtle_id;
*self = Self::default();
self.turtle_id = id;
}
/// Start recording fill vertices
pub fn begin_fill(&mut self, fill_color: Color) {
self.filling = Some(FillState {
start_position: self.position,
start_position: self.params.position,
contours: Vec::new(),
current_contour: vec![self.position],
current_contour: vec![self.params.position],
fill_color,
});
}
@ -83,16 +112,17 @@ impl TurtleState {
/// Record current position if filling and pen is down
pub fn record_fill_vertex(&mut self) {
if let Some(ref mut fill_state) = self.filling {
if self.pen_down {
if self.params.pen_down {
tracing::trace!(
x = self.position.x,
y = self.position.y,
turtle_id = self.turtle_id,
x = self.params.position.x,
y = self.params.position.y,
vertices = fill_state.current_contour.len() + 1,
"Adding vertex to current contour"
);
fill_state.current_contour.push(self.position);
fill_state.current_contour.push(self.params.position);
} else {
tracing::trace!("Skipping vertex (pen is up)");
tracing::trace!(turtle_id = self.turtle_id, "Skipping vertex (pen is up)");
}
}
}
@ -101,12 +131,14 @@ impl TurtleState {
pub fn close_fill_contour(&mut self) {
if let Some(ref mut fill_state) = self.filling {
tracing::debug!(
turtle_id = self.turtle_id,
vertices = fill_state.current_contour.len(),
"close_fill_contour called"
);
// Only close if we have vertices in current contour
if fill_state.current_contour.len() >= 2 {
tracing::debug!(
turtle_id = self.turtle_id,
vertices = fill_state.current_contour.len(),
first_x = fill_state.current_contour[0].x,
first_y = fill_state.current_contour[0].y,
@ -118,19 +150,27 @@ impl TurtleState {
let contour = std::mem::take(&mut fill_state.current_contour);
fill_state.contours.push(contour);
tracing::debug!(
turtle_id = self.turtle_id,
completed_contours = fill_state.contours.len(),
"Contour moved to completed list"
);
} else if !fill_state.current_contour.is_empty() {
tracing::warn!(
turtle_id = self.turtle_id,
vertices = fill_state.current_contour.len(),
"Current contour has insufficient vertices, not closing"
);
} else {
tracing::warn!("Current contour is empty, nothing to close");
tracing::warn!(
turtle_id = self.turtle_id,
"Current contour is empty, nothing to close"
);
}
} else {
tracing::warn!("close_fill_contour called but no active fill state");
tracing::warn!(
turtle_id = self.turtle_id,
"close_fill_contour called but no active fill state"
);
}
}
@ -139,12 +179,13 @@ impl TurtleState {
if let Some(ref mut fill_state) = self.filling {
// Start new contour at current position
tracing::debug!(
x = self.position.x,
y = self.position.y,
x = self.params.position.x,
y = self.params.position.y,
completed_contours = fill_state.contours.len(),
self.turtle_id = self.turtle_id,
"Starting new contour"
);
fill_state.current_contour = vec![self.position];
fill_state.current_contour = vec![self.params.position];
}
}
@ -160,11 +201,12 @@ impl TurtleState {
steps: u32,
) {
if let Some(ref mut fill_state) = self.filling {
if self.pen_down {
if self.params.pen_down {
// Sample points along the arc based on steps
let num_samples = steps as usize;
let num_samples = steps.max(1);
tracing::trace!(
turtle_id = self.turtle_id,
center_x = center.x,
center_y = center.y,
radius = radius,
@ -189,6 +231,7 @@ impl TurtleState {
center.y + radius * current_angle.sin(),
);
tracing::trace!(
turtle_id = self.turtle_id,
vertex_idx = i,
x = vertex.x,
y = vertex.y,
@ -230,16 +273,13 @@ impl MeshData {
#[derive(Clone, Debug)]
pub enum DrawCommand {
/// Pre-tessellated mesh data (lines, arcs, circles, polygons - all use this)
/// Includes the turtle ID that created this command
Mesh { turtle_id: usize, data: MeshData },
Mesh { data: MeshData },
}
/// The complete turtle world containing all drawing state
pub struct TurtleWorld {
/// All turtles in the world (indexed by turtle ID)
pub turtles: Vec<TurtleState>,
/// All drawing commands from all turtles
pub commands: Vec<DrawCommand>,
pub turtles: Vec<Turtle>,
pub camera: Camera2D,
pub background_color: Color,
}
@ -247,9 +287,12 @@ pub struct TurtleWorld {
impl TurtleWorld {
#[must_use]
pub fn new() -> Self {
let mut default_turtle = Turtle::default();
default_turtle.turtle_id = 0;
default_turtle.tween_controller =
TweenController::new(CommandQueue::new(), AnimationSpeed::default());
Self {
turtles: vec![TurtleState::default()], // Start with one default turtle
commands: Vec::new(),
turtles: vec![default_turtle], // Start with one default turtle
camera: Camera2D {
zoom: vec2(1.0 / screen_width() * 2.0, 1.0 / screen_height() * 2.0),
target: vec2(0.0, 0.0),
@ -261,18 +304,23 @@ impl TurtleWorld {
/// Add a new turtle and return its ID
pub fn add_turtle(&mut self) -> usize {
self.turtles.push(TurtleState::default());
self.turtles.len() - 1
let turtle_id = self.turtles.len();
let mut new_turtle = Turtle::default();
new_turtle.turtle_id = turtle_id;
new_turtle.tween_controller =
TweenController::new(CommandQueue::new(), AnimationSpeed::default());
self.turtles.push(new_turtle);
turtle_id
}
/// Get turtle by ID
#[must_use]
pub fn get_turtle(&self, id: usize) -> Option<&TurtleState> {
pub fn get_turtle(&self, id: usize) -> Option<&Turtle> {
self.turtles.get(id)
}
/// Get mutable turtle by ID
pub fn get_turtle_mut(&mut self, id: usize) -> Option<&mut TurtleState> {
pub fn get_turtle_mut(&mut self, id: usize) -> Option<&mut Turtle> {
self.turtles.get_mut(id)
}
@ -280,22 +328,15 @@ impl TurtleWorld {
pub fn reset_turtle(&mut self, turtle_id: usize) {
if let Some(turtle) = self.get_turtle_mut(turtle_id) {
turtle.reset();
turtle.turtle_id = turtle_id; // Preserve turtle_id after reset
}
// Remove all commands created by this turtle
self.commands.retain(|cmd| match cmd {
DrawCommand::Mesh { turtle_id: id, .. } => *id != turtle_id,
});
}
pub fn add_command(&mut self, cmd: DrawCommand) {
self.commands.push(cmd);
}
/// Clear all drawings and reset all turtle states
pub fn clear(&mut self) {
self.commands.clear();
for turtle in &mut self.turtles {
for (id, turtle) in self.turtles.iter_mut().enumerate() {
turtle.reset();
turtle.turtle_id = id; // Preserve turtle_id after reset
}
}
}

View File

@ -3,7 +3,7 @@
use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::commands::{CommandQueue, TurtleCommand};
use crate::general::AnimationSpeed;
use crate::state::TurtleState;
use crate::state::{Turtle, TurtleParams};
use macroquad::prelude::*;
use tween::{CubicInOut, TweenValue, Tweener};
@ -44,20 +44,33 @@ impl From<TweenVec2> for Vec2 {
}
/// Controls tweening of turtle commands
#[derive(Clone, Debug)]
pub struct TweenController {
turtle_id: usize,
queue: CommandQueue,
current_tween: Option<CommandTween>,
speed: AnimationSpeed,
}
impl Default for TweenController {
fn default() -> Self {
Self {
queue: CommandQueue::new(),
current_tween: None,
speed: AnimationSpeed::default(),
}
}
}
#[derive(Clone, Debug)]
pub struct CommandTween {
pub turtle_id: usize,
pub command: TurtleCommand,
pub start_time: f64,
pub duration: f64,
pub start_state: TurtleState,
pub target_state: TurtleState,
pub start_params: TurtleParams,
pub target_params: TurtleParams,
pub current_position: Vec2,
pub current_heading: f32,
position_tweener: Tweener<TweenVec2, f64, CubicInOut>,
heading_tweener: Tweener<f32, f64, CubicInOut>,
pen_width_tweener: Tweener<f32, f64, CubicInOut>,
@ -65,9 +78,8 @@ pub struct CommandTween {
impl TweenController {
#[must_use]
pub fn new(turtle_id: usize, queue: CommandQueue, speed: AnimationSpeed) -> Self {
pub fn new(queue: CommandQueue, speed: AnimationSpeed) -> Self {
Self {
turtle_id,
queue,
current_tween: None,
speed,
@ -86,53 +98,53 @@ impl TweenController {
/// Update the tween, returns `Vec` of (`command`, `start_state`, `end_state`) for all completed commands this frame
/// Also takes commands vec to handle side effects like fill operations
/// Each `command` has its own `start_state` and `end_state` pair
#[allow(clippy::too_many_lines)]
pub fn update(
&mut self,
state: &mut TurtleState,
commands: &mut Vec<crate::state::DrawCommand>,
) -> Vec<(TurtleCommand, TurtleState, TurtleState)> {
pub fn update(state: &mut Turtle) -> Vec<(TurtleCommand, TurtleParams, TurtleParams)> {
// In instant mode, execute commands up to the draw calls per frame limit
if let AnimationSpeed::Instant(max_draw_calls) = self.speed {
let mut completed_commands = Vec::new();
if let AnimationSpeed::Instant(max_draw_calls) = state.tween_controller.speed {
let mut completed_commands: Vec<(TurtleCommand, TurtleParams, TurtleParams)> =
Vec::new();
let mut draw_call_count = 0;
for command in self.queue.by_ref() {
let start_state = state.clone();
// Consume commands from the real queue so the current_index advances
loop {
let command = match state.tween_controller.queue.next() {
Some(cmd) => cmd,
None => break,
};
// Handle SetSpeed command to potentially switch modes
if let TurtleCommand::SetSpeed(new_speed) = &command {
state.set_speed(*new_speed);
self.speed = *new_speed;
if matches!(self.speed, AnimationSpeed::Animated(_)) {
state.params.speed = *new_speed;
state.tween_controller.speed = *new_speed;
if matches!(state.tween_controller.speed, AnimationSpeed::Animated(_)) {
break;
}
continue;
}
// Execute side-effect-only commands using centralized helper
if crate::execution::execute_command_side_effects(&command, state, commands) {
if crate::execution::execute_command_side_effects(&command, state) {
continue; // Command fully handled
}
// Execute movement commands
let target_state = Self::calculate_target_state(state, &command);
*state = target_state.clone();
// Save start state and compute target state
let start_params = state.params.clone();
let target_params = Self::calculate_target_state(&start_params, &command);
// Record fill vertices AFTER movement using centralized helper
// Update state to the target (instant execution)
state.params = target_params.clone();
// Record fill vertices AFTER movement
crate::execution::record_fill_vertices_after_movement(
&command,
&start_state,
&start_params,
state,
);
let end_state = state.clone();
// Collect drawable commands
if Self::command_creates_drawing(&command) && start_state.pen_down {
completed_commands.push((command, start_state, end_state));
// Collect drawable commands (return start and target so caller can create draw meshes)
if Self::command_creates_drawing(&command) && start_params.pen_down {
completed_commands.push((command, start_params.clone(), target_params.clone()));
draw_call_count += 1;
if draw_call_count >= max_draw_calls {
break;
}
@ -143,14 +155,14 @@ impl TweenController {
}
// Process current tween
if let Some(ref mut tween) = self.current_tween {
if let Some(ref mut tween) = state.tween_controller.current_tween {
let elapsed = get_time() - tween.start_time;
// 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);
state.position = match &tween.command {
let current_position = match &tween.command {
TurtleCommand::Circle {
radius,
angle,
@ -159,8 +171,8 @@ impl TweenController {
} => {
let angle_traveled = angle.to_radians() * progress;
calculate_circle_position(
tween.start_state.position,
tween.start_state.heading,
tween.start_params.position,
tween.start_params.heading,
*radius,
angle_traveled,
*direction,
@ -172,109 +184,109 @@ impl TweenController {
}
};
state.params.position = current_position;
tween.current_position = current_position;
// Heading changes proportionally with progress for all commands
state.heading = normalize_angle(match &tween.command {
let current_heading = normalize_angle(match &tween.command {
TurtleCommand::Circle {
angle, direction, ..
} => match direction {
CircleDirection::Left => {
tween.start_state.heading - angle.to_radians() * progress
tween.start_params.heading - angle.to_radians() * progress
}
CircleDirection::Right => {
tween.start_state.heading + angle.to_radians() * progress
tween.start_params.heading + angle.to_radians() * progress
}
},
TurtleCommand::Turn(angle) => {
tween.start_state.heading + angle.to_radians() * progress
tween.start_params.heading + angle.to_radians() * progress
}
_ => {
// For other commands that change heading, lerp directly
let heading_diff = tween.target_state.heading - tween.start_state.heading;
tween.start_state.heading + heading_diff * progress
let heading_diff = tween.target_params.heading - tween.start_params.heading;
tween.start_params.heading + heading_diff * progress
}
});
state.pen_width = tween.pen_width_tweener.move_to(elapsed);
state.params.heading = current_heading;
tween.current_heading = current_heading;
state.params.pen_width = tween.pen_width_tweener.move_to(elapsed);
// Discrete properties (switch at 50% progress)
let progress = (elapsed / tween.duration).min(1.0);
if progress >= 0.5 {
state.pen_down = tween.target_state.pen_down;
state.color = tween.target_state.color;
state.fill_color = tween.target_state.fill_color;
state.visible = tween.target_state.visible;
state.shape = tween.target_state.shape.clone();
state.params.pen_down = tween.target_params.pen_down;
state.params.color = tween.target_params.color;
state.params.fill_color = tween.target_params.fill_color;
state.params.visible = tween.target_params.visible;
state.params.shape = tween.target_params.shape.clone();
}
// Check if tween is finished (use heading_tweener as it's used by all commands)
if tween.heading_tweener.is_finished() {
let start_state = tween.start_state.clone();
*state = tween.target_state.clone();
let end_state = state.clone();
let start_params = tween.start_params.clone();
let target_params = tween.target_params.clone();
let command = tween.command.clone();
let completed_command = tween.command.clone();
self.current_tween = None;
// Drop the mutable borrow of tween before mutably borrowing state
state.params = target_params.clone();
// Execute side-effect-only commands using centralized helper
if crate::execution::execute_command_side_effects(
&completed_command,
state,
commands,
) {
return self.update(state, commands); // Continue to next command
}
// Record fill vertices for movement commands using centralized helper
crate::execution::record_fill_vertices_after_movement(
&completed_command,
&start_state,
&command,
&start_params,
state,
);
// Return drawable commands
if Self::command_creates_drawing(&completed_command) && start_state.pen_down {
return vec![(completed_command, start_state, end_state)];
state.tween_controller.current_tween = None;
// Execute side-effect-only commands using centralized helper
if crate::execution::execute_command_side_effects(&command, state) {
return Self::update(state); // Continue to next command
}
return self.update(state, commands); // Continue to next command
// Return drawable commands using the original start and target params
if Self::command_creates_drawing(&command) && start_params.pen_down {
return vec![(command, start_params.clone(), target_params.clone())];
}
return Self::update(state); // Continue to next command
}
return Vec::new();
}
// Start next tween
if let Some(command) = self.queue.next() {
if let Some(command) = state.tween_controller.queue.next() {
let command_clone = command.clone();
// Handle commands that should execute immediately (no animation)
match &command_clone {
TurtleCommand::SetSpeed(new_speed) => {
state.set_speed(*new_speed);
self.speed = *new_speed;
if matches!(self.speed, AnimationSpeed::Instant(_)) {
return self.update(state, commands);
state.tween_controller.speed = *new_speed;
if matches!(state.tween_controller.speed, AnimationSpeed::Instant(_)) {
return Self::update(state);
}
return self.update(state, commands);
return Self::update(state);
}
_ => {
// Use centralized helper for side effects
if crate::execution::execute_command_side_effects(
&command_clone,
state,
commands,
) {
return self.update(state, commands);
if crate::execution::execute_command_side_effects(&command_clone, state) {
return Self::update(state);
}
}
}
let speed = state.speed; // Extract speed before borrowing self
let speed = state.tween_controller.speed; // Extract speed before borrowing self
let duration = Self::calculate_duration_with_state(&command_clone, state, speed);
// Calculate target state
let target_state = Self::calculate_target_state(state, &command_clone);
let target_state = Self::calculate_target_state(&state.params, &command_clone);
// Create tweeners for smooth animation
let position_tweener = Tweener::new(
TweenVec2::from(state.position),
TweenVec2::from(state.params.position),
TweenVec2::from(target_state.position),
duration,
CubicInOut,
@ -286,19 +298,21 @@ impl TweenController {
);
let pen_width_tweener = Tweener::new(
state.pen_width,
state.params.pen_width,
target_state.pen_width,
duration,
CubicInOut,
);
self.current_tween = Some(CommandTween {
turtle_id: self.turtle_id,
state.tween_controller.current_tween = Some(CommandTween {
turtle_id: state.turtle_id,
command: command_clone,
start_time: get_time(),
duration,
start_state: state.clone(),
target_state,
start_params: state.params.clone(),
target_params: target_state.clone(),
current_position: state.params.position,
current_heading: state.params.heading,
position_tweener,
heading_tweener,
pen_width_tweener,
@ -327,7 +341,7 @@ impl TweenController {
fn calculate_duration_with_state(
command: &TurtleCommand,
current: &TurtleState,
current: &Turtle,
speed: AnimationSpeed,
) -> f64 {
let speed = speed.value();
@ -344,8 +358,8 @@ impl TweenController {
}
TurtleCommand::Goto(target) => {
// Calculate actual distance from current position to target
let dx = target.x - current.position.x;
let dy = target.y - current.position.y;
let dx = target.x - current.params.position.x;
let dy = target.y - current.params.position.y;
let distance = (dx * dx + dy * dy).sqrt();
distance / speed
}
@ -354,7 +368,7 @@ impl TweenController {
f64::from(base_time.max(0.01)) // Minimum duration
}
fn calculate_target_state(current: &TurtleState, command: &TurtleCommand) -> TurtleState {
fn calculate_target_state(current: &TurtleParams, command: &TurtleCommand) -> TurtleParams {
let mut target = current.clone();
match command {