initial multi-turtle support

This commit is contained in:
Franz Dietrich 2025-10-13 09:42:34 +02:00
parent 1366f5e77f
commit bbb9348497
11 changed files with 408 additions and 198 deletions

View File

@ -61,7 +61,7 @@ use syn::{parse_macro_input, ItemFn};
/// turtle.right(90.0); /// turtle.right(90.0);
/// turtle.forward(100.0); /// turtle.forward(100.0);
/// ///
/// let mut app = TurtleApp::new().with_commands(turtle.build()); /// let mut app = TurtleApp::new().with_commands(0, turtle.build());
/// ///
/// loop { /// loop {
/// clear_background(WHITE); /// clear_background(WHITE);
@ -108,7 +108,7 @@ pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream {
#fn_name(&mut turtle); #fn_name(&mut turtle);
let mut app = turtle_lib::TurtleApp::new() let mut app = turtle_lib::TurtleApp::new()
.with_commands(turtle.build()); .with_commands(0, turtle.build());
loop { loop {
macroquad::prelude::clear_background(macroquad::prelude::WHITE); macroquad::prelude::clear_background(macroquad::prelude::WHITE);
@ -145,7 +145,7 @@ pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream {
#fn_block #fn_block
let mut app = turtle_lib::TurtleApp::new() let mut app = turtle_lib::TurtleApp::new()
.with_commands(turtle.build()); .with_commands(0, turtle.build());
loop { loop {
macroquad::prelude::clear_background(macroquad::prelude::WHITE); macroquad::prelude::clear_background(macroquad::prelude::WHITE);

View File

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

View File

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

View File

@ -75,7 +75,7 @@ async fn main() {
t.set_speed(100); // Slow animation to see the logs in real-time t.set_speed(100); // Slow animation to see the logs in real-time
// Create turtle app // Create turtle app
let mut app = TurtleApp::new().with_commands(t.build()); let mut app = TurtleApp::new().with_commands(0, t.build());
// Main loop // Main loop
loop { loop {

View File

@ -4,7 +4,7 @@ use turtle_lib::*;
#[turtle_main("Yin-Yang")] #[turtle_main("Yin-Yang")]
fn draw(turtle: &mut TurtlePlan) { fn draw(turtle: &mut TurtlePlan) {
turtle.set_speed(200); turtle.set_speed(100);
turtle.circle_left(90.0, 180.0, 36); turtle.circle_left(90.0, 180.0, 36);
turtle.begin_fill(); turtle.begin_fill();

View File

@ -3,6 +3,7 @@
use crate::circle_geometry::{CircleDirection, CircleGeometry}; use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::state::{DrawCommand, TurtleState, TurtleWorld}; use crate::state::{DrawCommand, TurtleState, TurtleWorld};
use crate::tessellation; use crate::tessellation;
use crate::tweening::CommandTween;
use macroquad::prelude::*; use macroquad::prelude::*;
// Import the easing function from the tween crate // Import the easing function from the tween crate
@ -27,15 +28,17 @@ pub fn render_world(world: &TurtleWorld) {
// Draw all accumulated commands // Draw all accumulated commands
for cmd in &world.commands { for cmd in &world.commands {
match cmd { match cmd {
DrawCommand::Mesh(mesh_data) => { DrawCommand::Mesh { data, .. } => {
draw_mesh(&mesh_data.to_mesh()); draw_mesh(&data.to_mesh());
} }
} }
} }
// Draw turtle if visible // Draw all visible turtles
if world.turtle.visible { for turtle in &world.turtles {
draw_turtle(&world.turtle); if turtle.visible {
draw_turtle(turtle);
}
} }
// Reset to default camera // Reset to default camera
@ -44,9 +47,9 @@ pub fn render_world(world: &TurtleWorld) {
/// Render the turtle world with active tween visualization /// Render the turtle world with active tween visualization
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub(crate) fn render_world_with_tween( pub fn render_world_with_tween(
world: &TurtleWorld, world: &TurtleWorld,
active_tween: Option<&crate::tweening::CommandTween>, active_tween: Option<&CommandTween>,
zoom_level: f32, zoom_level: f32,
) { ) {
// Update camera zoom based on current screen size to prevent stretching // Update camera zoom based on current screen size to prevent stretching
@ -66,13 +69,16 @@ pub(crate) fn render_world_with_tween(
// Draw all accumulated commands // Draw all accumulated commands
for cmd in &world.commands { for cmd in &world.commands {
match cmd { match cmd {
DrawCommand::Mesh(mesh_data) => { DrawCommand::Mesh { data, .. } => {
draw_mesh(&mesh_data.to_mesh()); draw_mesh(&data.to_mesh());
} }
} }
} }
// Draw in-progress tween line if pen is down // 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 let Some(tween) = active_tween {
if tween.start_state.pen_down { if tween.start_state.pen_down {
match &tween.command { match &tween.command {
@ -86,30 +92,34 @@ pub(crate) fn render_world_with_tween(
draw_tween_arc(tween, *radius, *angle, *steps, *direction); draw_tween_arc(tween, *radius, *angle, *steps, *direction);
} }
_ if should_draw_tween_line(&tween.command) => { _ if should_draw_tween_line(&tween.command) => {
// Draw straight line for other movement commands // Draw straight line for other movement commands (use active turtle)
if let Some(turtle) = world.turtles.get(active_turtle_id) {
draw_line( draw_line(
tween.start_state.position.x, tween.start_state.position.x,
tween.start_state.position.y, tween.start_state.position.y,
world.turtle.position.x, turtle.position.x,
world.turtle.position.y, turtle.position.y,
tween.start_state.pen_width, tween.start_state.pen_width,
tween.start_state.color, tween.start_state.color,
); );
// Add circle at current position for smooth line joins // Add circle at current position for smooth line joins
draw_circle( draw_circle(
world.turtle.position.x, turtle.position.x,
world.turtle.position.y, turtle.position.y,
tween.start_state.pen_width / 2.0, tween.start_state.pen_width / 2.0,
tween.start_state.color, tween.start_state.color,
); );
} }
}
_ => {} _ => {}
} }
} }
} }
// Draw live fill preview if currently filling (always show, not just during tweens) // Draw live fill preview if currently filling (always show, not just during tweens)
if let Some(ref fill_state) = world.turtle.filling { // Use the active turtle if available, otherwise default to turtle 0
if let Some(turtle) = world.turtles.get(active_turtle_id) {
if let Some(ref fill_state) = turtle.filling {
// Build all contours: completed contours + current contour with animation // Build all contours: completed contours + current contour with animation
let mut all_contours: Vec<Vec<Vec2>> = Vec::new(); let mut all_contours: Vec<Vec<Vec2>> = Vec::new();
@ -156,16 +166,19 @@ pub(crate) fn render_world_with_tween(
// Generate arc vertices for the partial arc // Generate arc vertices for the partial arc
let num_samples = *steps.max(&1); let num_samples = *steps.max(&1);
let samples_to_draw = ((num_samples as f32 * eased_progress) as usize).max(1); let samples_to_draw =
((num_samples as f32 * eased_progress) as usize).max(1);
for i in 1..=samples_to_draw { for i in 1..=samples_to_draw {
let sample_progress = i as f32 / num_samples as f32; let sample_progress = i as f32 / num_samples as f32;
let current_angle = match direction { let current_angle = match direction {
crate::circle_geometry::CircleDirection::Left => { crate::circle_geometry::CircleDirection::Left => {
geom.start_angle_from_center - angle.to_radians() * sample_progress geom.start_angle_from_center
- angle.to_radians() * sample_progress
} }
crate::circle_geometry::CircleDirection::Right => { crate::circle_geometry::CircleDirection::Right => {
geom.start_angle_from_center + angle.to_radians() * sample_progress geom.start_angle_from_center
+ angle.to_radians() * sample_progress
} }
}; };
@ -181,33 +194,35 @@ pub(crate) fn render_world_with_tween(
| crate::commands::TurtleCommand::Goto(_) | crate::commands::TurtleCommand::Goto(_)
) { ) {
// For Move/Goto commands, just add the current position // For Move/Goto commands, just add the current position
current_preview current_preview.push(Vec2::new(turtle.position.x, turtle.position.y));
.push(Vec2::new(world.turtle.position.x, world.turtle.position.y));
} }
} else if matches!( } else if matches!(
&tween.command, &tween.command,
crate::commands::TurtleCommand::Move(_) | crate::commands::TurtleCommand::Goto(_) crate::commands::TurtleCommand::Move(_)
| crate::commands::TurtleCommand::Goto(_)
) { ) {
// For Move/Goto with pen up during filling, still add current position for preview // For Move/Goto with pen up during filling, still add current position for preview
current_preview.push(Vec2::new(world.turtle.position.x, world.turtle.position.y)); current_preview.push(Vec2::new(turtle.position.x, turtle.position.y));
} }
// Add current turtle position if not already included // Add current turtle position if not already included
if let Some(last) = current_preview.last() { if let Some(last) = current_preview.last() {
let current_pos = world.turtle.position; let current_pos = turtle.position;
// Use a larger threshold to reduce flickering from tiny movements // 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 { 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)); current_preview.push(Vec2::new(current_pos.x, current_pos.y));
} }
} else if !current_preview.is_empty() { } else if !current_preview.is_empty() {
current_preview.push(Vec2::new(world.turtle.position.x, world.turtle.position.y)); current_preview.push(Vec2::new(turtle.position.x, turtle.position.y));
} }
} else { } else {
// No active tween - just show current state // No active tween - just show current state
if !current_preview.is_empty() { if !current_preview.is_empty() {
if let Some(last) = current_preview.last() { if let Some(last) = current_preview.last() {
let current_pos = world.turtle.position; let current_pos = turtle.position;
if (last.x - current_pos.x).abs() > 0.1 || (last.y - current_pos.y).abs() > 0.1 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)); current_preview.push(Vec2::new(current_pos.x, current_pos.y));
} }
@ -230,18 +245,18 @@ pub(crate) fn render_world_with_tween(
draw_mesh(&mesh_data.to_mesh()); draw_mesh(&mesh_data.to_mesh());
} }
Err(e) => { Err(e) => {
tracing::error!( tracing::error!("Failed to tessellate fill preview: {:?}", e);
error = ?e, }
"Lyon multi-contour tessellation error for fill preview"
);
} }
} }
} }
} }
// Draw turtle if visible // Draw all visible turtles
if world.turtle.visible { for turtle in &world.turtles {
draw_turtle(&world.turtle); if turtle.visible {
draw_turtle(turtle);
}
} }
// Reset to default camera // Reset to default camera
@ -279,18 +294,17 @@ fn draw_tween_arc(
let elapsed = get_time() - tween.start_time; let elapsed = get_time() - tween.start_time;
let t = (elapsed / tween.duration).min(1.0); let t = (elapsed / tween.duration).min(1.0);
let progress = CubicInOut.tween(1.0, t as f32); // tween from 0 to 1 let progress = CubicInOut.tween(1.0, t as f32); // tween from 0 to 1
let angle_traveled = total_angle.to_radians() * progress;
let (rotation_degrees, arc_degrees) = geom.draw_arc_params_partial(angle_traveled);
// Use Lyon to tessellate and draw the partial arc // Use Lyon to tessellate and draw the partial arc
if let Ok(mesh_data) = crate::tessellation::tessellate_arc( if let Ok(mesh_data) = crate::tessellation::tessellate_arc(
geom.center, geom.center,
radius, radius,
rotation_degrees, geom.start_angle_from_center.to_degrees(),
arc_degrees, total_angle * progress,
tween.start_state.color, tween.start_state.color,
tween.start_state.pen_width, tween.start_state.pen_width,
steps, ((steps as f32 * progress).ceil() as usize).max(1),
direction,
) { ) {
draw_mesh(&mesh_data.to_mesh()); draw_mesh(&mesh_data.to_mesh());
} }

View File

@ -51,7 +51,10 @@ pub fn execute_command_side_effects(
contours = fill_state.contours.len(), contours = fill_state.contours.len(),
"Successfully tessellated contours" "Successfully tessellated contours"
); );
commands.push(DrawCommand::Mesh(mesh_data)); commands.push(DrawCommand::Mesh {
turtle_id: 0,
data: mesh_data,
});
} else { } else {
tracing::error!("Failed to tessellate contours"); tracing::error!("Failed to tessellate contours");
} }
@ -153,7 +156,10 @@ pub fn execute_command(command: &TurtleCommand, state: &mut TurtleState, world:
state.pen_width, state.pen_width,
false, // not closed false, // not closed
) { ) {
world.add_command(DrawCommand::Mesh(mesh_data)); world.add_command(DrawCommand::Mesh {
turtle_id: 0,
data: mesh_data,
});
} }
} }
} }
@ -172,19 +178,21 @@ pub fn execute_command(command: &TurtleCommand, state: &mut TurtleState, world:
let geom = CircleGeometry::new(state.position, start_heading, *radius, *direction); let geom = CircleGeometry::new(state.position, start_heading, *radius, *direction);
if state.pen_down { if state.pen_down {
let (rotation_degrees, arc_degrees) = geom.draw_arc_params(*angle);
// Use Lyon to tessellate the arc // Use Lyon to tessellate the arc
if let Ok(mesh_data) = tessellation::tessellate_arc( if let Ok(mesh_data) = tessellation::tessellate_arc(
geom.center, geom.center,
*radius, *radius,
rotation_degrees, geom.start_angle_from_center.to_degrees(),
arc_degrees, *angle,
state.color, state.color,
state.pen_width, state.pen_width,
*steps, *steps,
*direction,
) { ) {
world.add_command(DrawCommand::Mesh(mesh_data)); world.add_command(DrawCommand::Mesh {
turtle_id: 0,
data: mesh_data,
});
} }
} }
@ -208,7 +216,10 @@ pub fn execute_command(command: &TurtleCommand, state: &mut TurtleState, world:
state.pen_width, state.pen_width,
false, // not closed false, // not closed
) { ) {
world.add_command(DrawCommand::Mesh(mesh_data)); world.add_command(DrawCommand::Mesh {
turtle_id: 0,
data: mesh_data,
});
} }
} }
} }
@ -230,24 +241,40 @@ pub fn execute_command(command: &TurtleCommand, state: &mut TurtleState, world:
record_fill_vertices_after_movement(command, &start_state, state); record_fill_vertices_after_movement(command, &start_state, state);
} }
/// Add drawing command for a completed tween (state transition already occurred) /// Execute command on a specific turtle by ID
pub fn add_draw_for_completed_tween( 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, world);
// 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 with turtle_id tracking
pub fn add_draw_for_completed_tween_with_id(
command: &TurtleCommand, command: &TurtleCommand,
start_state: &TurtleState, start_state: &TurtleState,
end_state: &TurtleState, end_state: &TurtleState,
world: &mut TurtleWorld, world: &mut TurtleWorld,
turtle_id: usize,
) { ) {
match command { match command {
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => { TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
if start_state.pen_down { if start_state.pen_down {
// Draw line segment with round caps
if let Ok(mesh_data) = tessellation::tessellate_stroke( if let Ok(mesh_data) = tessellation::tessellate_stroke(
&[start_state.position, end_state.position], &[start_state.position, end_state.position],
start_state.color, start_state.color,
start_state.pen_width, start_state.pen_width,
false, // not closed false,
) { ) {
world.add_command(DrawCommand::Mesh(mesh_data)); world.add_command(DrawCommand::Mesh {
turtle_id,
data: mesh_data,
});
} }
} }
} }
@ -264,19 +291,79 @@ pub fn add_draw_for_completed_tween(
*radius, *radius,
*direction, *direction,
); );
let (rotation_degrees, arc_degrees) = geom.draw_arc_params(*angle);
// Use Lyon to tessellate the arc
if let Ok(mesh_data) = tessellation::tessellate_arc( if let Ok(mesh_data) = tessellation::tessellate_arc(
geom.center, geom.center,
*radius, *radius,
rotation_degrees, geom.start_angle_from_center.to_degrees(),
arc_degrees, *angle,
start_state.color, start_state.color,
start_state.pen_width, start_state.pen_width,
*steps, *steps,
*direction,
) { ) {
world.add_command(DrawCommand::Mesh(mesh_data)); world.add_command(DrawCommand::Mesh {
turtle_id,
data: mesh_data,
});
}
}
}
_ => {}
}
}
/// Add drawing command for a completed tween (state transition already occurred)
pub fn add_draw_for_completed_tween(
command: &TurtleCommand,
start_state: &TurtleState,
end_state: &TurtleState,
world: &mut TurtleWorld,
) {
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,
});
}
}
}
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: 0,
data: mesh_data,
});
} }
} }
} }
@ -311,7 +398,7 @@ mod tests {
// We'll use a dummy world but won't actually call drawing commands // We'll use a dummy world but won't actually call drawing commands
let mut world = TurtleWorld { let mut world = TurtleWorld {
turtle: state.clone(), turtles: vec![state.clone()],
commands: Vec::new(), commands: Vec::new(),
camera: macroquad::camera::Camera2D { camera: macroquad::camera::Camera2D {
zoom: vec2(1.0, 1.0), zoom: vec2(1.0, 1.0),
@ -323,6 +410,7 @@ mod tests {
}, },
background_color: Color::new(1.0, 1.0, 1.0, 1.0), 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) // Initial state: position (0, 0), heading 0 (east)
assert_eq!(state.position.x, 0.0); assert_eq!(state.position.x, 0.0);

View File

@ -78,7 +78,8 @@ use macroquad::prelude::*;
/// Main turtle application struct /// Main turtle application struct
pub struct TurtleApp { pub struct TurtleApp {
world: TurtleWorld, world: TurtleWorld,
tween_controller: Option<TweenController>, /// One tween controller per turtle (indexed by turtle ID)
tween_controllers: Vec<TweenController>,
speed: AnimationSpeed, speed: AnimationSpeed,
// Mouse panning state // Mouse panning state
is_dragging: bool, is_dragging: bool,
@ -93,7 +94,7 @@ impl TurtleApp {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
world: TurtleWorld::new(), world: TurtleWorld::new(),
tween_controller: None, tween_controllers: Vec::new(),
speed: AnimationSpeed::default(), speed: AnimationSpeed::default(),
is_dragging: false, is_dragging: false,
last_mouse_pos: None, last_mouse_pos: None,
@ -101,40 +102,89 @@ impl TurtleApp {
} }
} }
/// Add commands to the turtle /// 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
}
/// Add commands to a specific turtle
/// ///
/// Speed is controlled by `SetSpeed` commands in the queue. /// Speed is controlled by `SetSpeed` commands in the queue.
/// Use `set_speed()` on the turtle plan to set animation speed. /// Use `set_speed()` on the turtle plan to set animation speed.
/// Speed >= 999 = instant mode, speed < 999 = animated mode. /// Speed >= 999 = instant mode, speed < 999 = animated mode.
/// ///
/// # Arguments /// # Arguments
/// * `turtle_id` - The ID of the turtle to control
/// * `queue` - The command queue to execute /// * `queue` - The command queue to execute
#[must_use] #[must_use]
pub fn with_commands(mut self, queue: CommandQueue) -> Self { pub fn with_commands(mut self, turtle_id: usize, queue: CommandQueue) -> Self {
// The `TweenController` will switch between instant and animated mode // Ensure we have a controller for this turtle
// based on `SetSpeed` commands encountered while self.tween_controllers.len() <= turtle_id {
self.tween_controller = Some(TweenController::new(queue, self.speed)); let id = self.tween_controllers.len();
let speed = self.speed;
self.tween_controllers
.push(TweenController::new(id, CommandQueue::new(), speed));
}
// Append commands to the controller
self.tween_controllers[turtle_id].append_commands(queue);
self self
} }
/// Execute a plan immediately on a specific turtle (no animation)
pub fn execute_immediate(&mut self, turtle_id: usize, plan: TurtlePlan) {
for ref cmd in plan.build() {
execution::execute_command_with_id(cmd, turtle_id, &mut self.world);
}
}
/// 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));
}
self.tween_controllers[turtle_id].append_commands(plan.build());
}
/// Update animation state (call every frame) /// Update animation state (call every frame)
pub fn update(&mut self) { pub fn update(&mut self) {
// Handle mouse panning and zoom // Handle mouse panning and zoom
self.handle_mouse_panning(); self.handle_mouse_panning();
self.handle_mouse_zoom(); self.handle_mouse_zoom();
if let Some(ref mut controller) = self.tween_controller { // Update all active tween controllers
let completed_commands = // Process each turtle separately to avoid borrow conflicts
controller.update(&mut self.world.turtle, &mut self.world.commands); 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);
// Process all completed commands (multiple in instant mode, 0-1 in animated mode) 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()
};
// Put commands back
self.world.commands = commands;
// Process all completed commands
for (completed_cmd, start_state, end_state) in completed_commands { for (completed_cmd, start_state, end_state) in completed_commands {
// Add draw commands for the completed tween execution::add_draw_for_completed_tween_with_id(
execution::add_draw_for_completed_tween(
&completed_cmd, &completed_cmd,
&start_state, &start_state,
&end_state, &end_state,
&mut self.world, &mut self.world,
turtle_id,
); );
} }
} }
@ -187,20 +237,21 @@ impl TurtleApp {
/// Render the turtle world (call every frame) /// Render the turtle world (call every frame)
pub fn render(&self) { pub fn render(&self) {
// Get active tween if in animated mode // Find the first active tween (turtle_id is now stored in the tween itself)
let active_tween = self let active_tween = self
.tween_controller .tween_controllers
.as_ref() .iter()
.and_then(|c| c.current_tween()); .find_map(|controller| controller.current_tween());
drawing::render_world_with_tween(&self.world, active_tween, self.zoom_level); drawing::render_world_with_tween(&self.world, active_tween, self.zoom_level);
} }
/// Check if all commands have been executed /// Check if all commands have been executed
#[must_use] #[must_use]
pub fn is_complete(&self) -> bool { pub fn is_complete(&self) -> bool {
self.tween_controller self.tween_controllers
.as_ref() .iter()
.is_none_or(TweenController::is_complete) .all(TweenController::is_complete)
} }
/// Get reference to the world state /// Get reference to the world state

View File

@ -65,6 +65,11 @@ impl TurtleState {
Angle::radians(self.heading) Angle::radians(self.heading)
} }
/// Reset turtle to default state
pub fn reset(&mut self) {
*self = Self::default();
}
/// 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 {
@ -225,12 +230,15 @@ impl MeshData {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum DrawCommand { pub enum DrawCommand {
/// Pre-tessellated mesh data (lines, arcs, circles, polygons - all use this) /// Pre-tessellated mesh data (lines, arcs, circles, polygons - all use this)
Mesh(MeshData), /// Includes the turtle ID that created this command
Mesh { turtle_id: usize, data: MeshData },
} }
/// The complete turtle world containing all drawing state /// The complete turtle world containing all drawing state
pub struct TurtleWorld { pub struct TurtleWorld {
pub turtle: TurtleState, /// All turtles in the world (indexed by turtle ID)
pub turtles: Vec<TurtleState>,
/// All drawing commands from all turtles
pub commands: Vec<DrawCommand>, pub commands: Vec<DrawCommand>,
pub camera: Camera2D, pub camera: Camera2D,
pub background_color: Color, pub background_color: Color,
@ -240,7 +248,7 @@ impl TurtleWorld {
#[must_use] #[must_use]
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
turtle: TurtleState::default(), turtles: vec![TurtleState::default()], // Start with one default turtle
commands: Vec::new(), commands: Vec::new(),
camera: Camera2D { camera: Camera2D {
zoom: vec2(1.0 / screen_width() * 2.0, 1.0 / screen_height() * 2.0), zoom: vec2(1.0 / screen_width() * 2.0, 1.0 / screen_height() * 2.0),
@ -251,13 +259,44 @@ 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
}
/// Get turtle by ID
#[must_use]
pub fn get_turtle(&self, id: usize) -> Option<&TurtleState> {
self.turtles.get(id)
}
/// Get mutable turtle by ID
pub fn get_turtle_mut(&mut self, id: usize) -> Option<&mut TurtleState> {
self.turtles.get_mut(id)
}
/// Reset a specific turtle to default state and remove all its drawings
pub fn reset_turtle(&mut self, turtle_id: usize) {
if let Some(turtle) = self.get_turtle_mut(turtle_id) {
turtle.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) { pub fn add_command(&mut self, cmd: DrawCommand) {
self.commands.push(cmd); self.commands.push(cmd);
} }
/// Clear all drawings and reset all turtle states
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.commands.clear(); self.commands.clear();
self.turtle = TurtleState::default(); for turtle in &mut self.turtles {
turtle.reset();
}
} }
} }

View File

@ -302,6 +302,7 @@ pub fn tessellate_arc(
color: Color, color: Color,
stroke_width: f32, stroke_width: f32,
segments: usize, segments: usize,
direction: crate::circle_geometry::CircleDirection,
) -> Result<MeshData, Box<dyn std::error::Error>> { ) -> Result<MeshData, Box<dyn std::error::Error>> {
// Build arc path manually from segments // Build arc path manually from segments
let mut builder = Path::builder(); let mut builder = Path::builder();
@ -311,16 +312,24 @@ pub fn tessellate_arc(
let step = arc_angle / segments as f32; let step = arc_angle / segments as f32;
// Calculate first point // Calculate first point
let first_angle = start_angle;
let first_point = point( let first_point = point(
center.x + radius * first_angle.cos(), center.x + radius * start_angle.cos(),
center.y + radius * first_angle.sin(), center.y + radius * start_angle.sin(),
); );
builder.begin(first_point); builder.begin(first_point);
// Add remaining points // Add remaining points - direction matters!
for i in 1..=segments { for i in 1..=segments {
let angle = start_angle + step * i as f32; let angle = match direction {
crate::circle_geometry::CircleDirection::Left => {
// Counter-clockwise: subtract angle
start_angle - step * i as f32
}
crate::circle_geometry::CircleDirection::Right => {
// Clockwise: add angle
start_angle + step * i as f32
}
};
let pt = point( let pt = point(
center.x + radius * angle.cos(), center.x + radius * angle.cos(),
center.y + radius * angle.sin(), center.y + radius * angle.sin(),

View File

@ -45,26 +45,29 @@ impl From<TweenVec2> for Vec2 {
/// Controls tweening of turtle commands /// Controls tweening of turtle commands
pub struct TweenController { pub struct TweenController {
turtle_id: usize,
queue: CommandQueue, queue: CommandQueue,
current_tween: Option<CommandTween>, current_tween: Option<CommandTween>,
speed: AnimationSpeed, speed: AnimationSpeed,
} }
pub(crate) struct CommandTween { pub struct CommandTween {
pub turtle_id: usize,
pub command: TurtleCommand, pub command: TurtleCommand,
pub start_time: f64, pub start_time: f64,
pub duration: f64, pub duration: f64,
pub start_state: TurtleState, pub start_state: TurtleState,
pub target_state: TurtleState, pub target_state: TurtleState,
pub position_tweener: Tweener<TweenVec2, f64, CubicInOut>, position_tweener: Tweener<TweenVec2, f64, CubicInOut>,
pub heading_tweener: Tweener<f32, f64, CubicInOut>, heading_tweener: Tweener<f32, f64, CubicInOut>,
pub pen_width_tweener: Tweener<f32, f64, CubicInOut>, pen_width_tweener: Tweener<f32, f64, CubicInOut>,
} }
impl TweenController { impl TweenController {
#[must_use] #[must_use]
pub fn new(queue: CommandQueue, speed: AnimationSpeed) -> Self { pub fn new(turtle_id: usize, queue: CommandQueue, speed: AnimationSpeed) -> Self {
Self { Self {
turtle_id,
queue, queue,
current_tween: None, current_tween: None,
speed, speed,
@ -75,6 +78,11 @@ impl TweenController {
self.speed = speed; self.speed = speed;
} }
/// Append commands to the queue
pub fn append_commands(&mut self, new_queue: CommandQueue) {
self.queue.extend(new_queue);
}
/// Update the tween, returns `Vec` of (`command`, `start_state`, `end_state`) for all completed commands this frame /// 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 /// Also takes commands vec to handle side effects like fill operations
/// Each `command` has its own `start_state` and `end_state` pair /// Each `command` has its own `start_state` and `end_state` pair
@ -285,6 +293,7 @@ impl TweenController {
); );
self.current_tween = Some(CommandTween { self.current_tween = Some(CommandTween {
turtle_id: self.turtle_id,
command: command_clone, command: command_clone,
start_time: get_time(), start_time: get_time(),
duration, duration,