Initial macroquad version for compiletime reasons

```rust
// Movement
plan.forward(100);
plan.backward(50);

// Rotation
plan.left(90);    // degrees
plan.right(45);

// Circular arcs
plan.circle_left(50.0, 180.0, 36);   // radius, angle (degrees), segments
plan.circle_right(50.0, 180.0, 36);  // draws arc to the right

// Pen control
plan.pen_up();
plan.pen_down();

// Appearance
plan.set_color(RED);
plan.set_pen_width(5.0);
plan.hide();
plan.show();

// Turtle shape
plan.shape(ShapeType::Triangle);
plan.shape(ShapeType::Turtle);    // Default classic turtle shape
plan.shape(ShapeType::Circle);
plan.shape(ShapeType::Square);
plan.shape(ShapeType::Arrow);

// Custom shape
let custom = TurtleShape::new(
    vec![vec2(10.0, 0.0), vec2(-5.0, 5.0), vec2(-5.0, -5.0)],
    true  // filled
);
plan.set_shape(custom);

// Chaining
plan.forward(100).right(90).forward(50);
```
This commit is contained in:
Franz Dietrich 2025-10-09 09:12:16 +02:00
parent 9ab58e39e7
commit 25753b47ce
25 changed files with 2941 additions and 2 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
/target
Cargo.lock

15
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,15 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "cargo check (workspace)",
"type": "shell",
"command": "cargo check",
"isBackground": false,
"problemMatcher": [
"$rustc"
],
"group": "build"
}
]
}

View File

@ -1,7 +1,7 @@
[workspace]
resolver = "2"
members = ["turtle-lib", "turtle-example"]
members = ["turtle-lib", "turtle-example", "turtle-lib-macroquad"]
[workspace.dependencies]
# Pin Bevy across the workspace

View File

@ -0,0 +1,13 @@
[package]
name = "turtle-lib-macroquad"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
[dependencies]
macroquad = "0.4"
earcutr = "0.5"
tween = "2.1.0"
[dev-dependencies]
# For examples and testing

View File

@ -0,0 +1,229 @@
# Turtle Graphics Library for Macroquad
A turtle graphics library built on [Macroquad](https://macroquad.rs/), providing an intuitive API for creating drawings and animations.
## Features
- **Simple Builder API**: Chain commands like `forward(100).right(90)`
- **Smooth Animations**: Tweening support with easing functions
- **Instant Mode**: Execute commands immediately without animation (speed > 999.0)
- **Lightweight**: Fast compilation (~30-60 seconds from clean build)
- **Macroquad Integration**: Built on the simple and fast Macroquad framework
## Quick Start
Add to your `Cargo.toml`:
```toml
[dependencies]
turtle-lib-macroquad = { path = "../turtle-lib-macroquad" }
macroquad = "0.4"
```
### Basic Example
```rust
use macroquad::prelude::*;
use turtle_lib_macroquad::*;
#[macroquad::main("Turtle")]
async fn main() {
// Create a turtle plan
let mut plan = create_turtle();
// Draw a square
for _ in 0..4 {
plan.forward(100).right(90);
}
// Create app with animation (100 pixels/sec)
let mut app = TurtleApp::new().with_commands(plan.build(), 100.0);
loop {
clear_background(WHITE);
app.update();
app.render();
next_frame().await
}
}
```
## API Overview
### Creating Plans
```rust
let mut plan = create_turtle();
// Movement
plan.forward(100);
plan.backward(50);
// Rotation
plan.left(90); // degrees
plan.right(45);
// Circular arcs
plan.circle_left(50.0, 180.0, 36); // radius, angle (degrees), segments
plan.circle_right(50.0, 180.0, 36); // draws arc to the right
// Pen control
plan.pen_up();
plan.pen_down();
// Appearance
plan.set_color(RED);
plan.set_pen_width(5.0);
plan.hide();
plan.show();
// Turtle shape
plan.shape(ShapeType::Triangle);
plan.shape(ShapeType::Turtle); // Default classic turtle shape
plan.shape(ShapeType::Circle);
plan.shape(ShapeType::Square);
plan.shape(ShapeType::Arrow);
// Custom shape
let custom = TurtleShape::new(
vec![vec2(10.0, 0.0), vec2(-5.0, 5.0), vec2(-5.0, -5.0)],
true // filled
);
plan.set_shape(custom);
// Chaining
plan.forward(100).right(90).forward(50);
```
### Execution Modes
```rust
// Animated mode (speed in pixels/sec, 0.5-999.0)
let app = TurtleApp::new().with_commands(queue, 100.0);
// Instant mode (speed >= 999.0)
let app = TurtleApp::new().with_commands(queue, 1000.0);
```
### Animation Loop
```rust
loop {
clear_background(WHITE);
app.update(); // Update animation state
app.render(); // Draw to screen
if app.is_complete() {
// All commands executed
}
next_frame().await
}
```
## Examples
Run examples with:
```bash
cargo run --example square
cargo run --example koch
cargo run --example shapes
cargo run --example yinyang
cargo run --example stern
cargo run --example nikolaus
```
### Available Examples
- **square.rs**: Basic square drawing
- **koch.rs**: Koch snowflake fractal
- **shapes.rs**: Demonstrates different turtle shapes
- **yinyang.rs**: Yin-yang symbol drawing
- **stern.rs**: Star pattern drawing
- **nikolaus.rs**: Nikolaus (Santa) drawing
## Turtle Shapes
The library supports multiple turtle shapes that can be changed during drawing:
### Built-in Shapes
- **Triangle** (default): Simple arrow shape
- **Turtle**: Classic turtle shape with detailed outline
- **Circle**: Circular shape
- **Square**: Square shape
- **Arrow**: Arrow-like shape
### Using Shapes
```rust
// Using built-in shapes
plan.shape(ShapeType::Turtle);
// Creating custom shapes
let my_shape = TurtleShape::new(
vec![
vec2(15.0, 0.0), // Point at front
vec2(-10.0, -8.0), // Bottom back
vec2(-10.0, 8.0), // Top back
],
true // filled
);
plan.set_shape(my_shape);
```
Shapes are automatically rotated to match the turtle's heading direction.
## Architecture
The library is designed for easy extension and potential multi-threading support:
- **State Management**: Clean separation between turtle state and world state
- **Command Queue**: Commands are queued and can be executed immediately or with tweening
- **Tweening System**: Smooth interpolation between states with easing functions
- **Rendering**: Direct Macroquad drawing calls with earcutr polygon triangulation
- **Shape System**: Extensible turtle shapes with support for both convex and concave polygons
### Module Structure
```
src/
├── lib.rs - Public API and TurtleApp
├── state.rs - TurtleState and TurtleWorld
├── commands.rs - TurtleCommand enum and CommandQueue
├── builders.rs - Builder traits (DirectionalMovement, Turnable, etc.)
├── execution.rs - Command execution logic
├── tweening.rs - Animation/tweening controller
├── drawing.rs - Macroquad rendering
├── shapes.rs - Turtle shape definitions
└── general/ - Type definitions (Angle, Length, etc.)
```
## Design Decisions
### Multi-threading Ready
While multi-threading is not implemented yet, the architecture supports future additions:
- State and world are separated
- Commands can be generated on separate threads
- Rendering happens on main thread (Macroquad requirement)
### Tweening vs Interpolation
We use "tweening" terminology throughout the codebase for clarity and game development conventions.
### No API Compatibility Constraints
This library is designed from scratch without backwards compatibility requirements, allowing for optimal design choices.
## Future Enhancements
Potential additions (not yet implemented):
- Multi-threading support for interactive games
- Filled shapes and polygons
- Text rendering
- Image stamps
## License
MIT OR Apache-2.0

View File

@ -0,0 +1,41 @@
//! Test circle_left and circle_right commands
use macroquad::prelude::*;
use turtle_lib_macroquad::*;
#[macroquad::main("Circle Test")]
async fn main() {
// Create a turtle plan
let mut plan = create_turtle();
plan.shape(ShapeType::Turtle);
// Draw some circles
plan.set_color(RED);
plan.set_pen_width(0.5);
plan.left(90.0);
plan.circle_left(100.0, 540.0, 72); // partial circle to the left
plan.forward(150.0);
plan.set_color(BLUE);
plan.circle_right(50.0, 270.0, 72); // partial circle to the right
plan.forward(150.0);
plan.set_color(GREEN);
plan.circle_left(50.0, 180.0, 36); // Half circle to the left
// Create turtle app with animation (speed = 100 pixels/sec)
let mut app = TurtleApp::new().with_commands(plan.build(), 100.0);
// Main loop
loop {
clear_background(WHITE);
// Update and render
app.update();
app.render();
next_frame().await
}
}

View File

@ -0,0 +1,30 @@
//! Test circle_left and circle_right commands
use macroquad::prelude::*;
use turtle_lib_macroquad::*;
#[macroquad::main("Circle Test")]
async fn main() {
// Create a turtle plan
let mut plan = create_turtle();
plan.right(45.0);
plan.forward(100.0);
plan.right(45.0);
plan.forward(100.0);
//plan.circle_left(100.0, 90.0, 72); // Full circle to the left
// Create turtle app with animation (speed = 100 pixels/sec)
let mut app = TurtleApp::new().with_commands(plan.build(), 10.0);
// Main loop
loop {
clear_background(WHITE);
// Update and render
app.update();
app.render();
next_frame().await
}
}

View File

@ -0,0 +1,49 @@
//! Koch snowflake fractal example
use macroquad::prelude::*;
use turtle_lib_macroquad::*;
fn koch(depth: u32, plan: &mut TurtlePlan, distance: f32) {
if depth == 0 {
plan.forward(distance);
} else {
let new_distance = distance / 3.0;
koch(depth - 1, plan, new_distance);
plan.left(60.0);
koch(depth - 1, plan, new_distance);
plan.right(120.0);
koch(depth - 1, plan, new_distance);
plan.left(60.0);
koch(depth - 1, plan, new_distance);
}
}
#[macroquad::main("Koch Snowflake")]
async fn main() {
let mut plan = create_turtle();
// Position turtle
plan.set_speed(10);
plan.pen_up();
plan.backward(150.0);
plan.pen_down();
// Draw Koch snowflake (triangle of Koch curves)
for _ in 0..3 {
koch(4, &mut plan, 300.0);
plan.right(120.0);
}
plan.hide(); // Hide turtle when done
// Create app with animation
let mut app = TurtleApp::new().with_commands(plan.build(), 1000.0);
loop {
clear_background(WHITE);
app.update();
app.render();
next_frame().await
}
}

View File

@ -0,0 +1,74 @@
//! Nikolaus example - draws a house-like figure
use macroquad::prelude::*;
use turtle_lib_macroquad::*;
fn nikolausquadrat(plan: &mut TurtlePlan, groesse: f32) {
plan.forward(groesse);
plan.left(90.0);
plan.forward(groesse);
plan.left(90.0);
plan.forward(groesse);
plan.left(90.0);
plan.forward(groesse);
plan.left(90.0);
}
fn nikolausdiag(plan: &mut TurtlePlan, groesse: f32) {
let quadrat = groesse * groesse;
let diag = (quadrat + quadrat).sqrt();
plan.left(45.0);
plan.forward(diag);
plan.left(45.0);
nikolausdach2(plan, groesse);
plan.left(45.0);
plan.forward(diag);
plan.left(45.0);
}
fn nikolausdach2(plan: &mut TurtlePlan, groesse: f32) {
let quadrat = groesse * groesse;
let diag = (quadrat + quadrat).sqrt();
plan.left(45.0);
plan.forward(diag / 2.0);
plan.left(90.0);
plan.forward(diag / 2.0);
plan.left(45.0);
}
fn nikolaus(plan: &mut TurtlePlan, groesse: f32) {
nikolausquadrat(plan, groesse);
nikolausdiag(plan, groesse);
}
#[macroquad::main("Nikolaus")]
async fn main() {
// Create a turtle plan
let mut plan = create_turtle();
plan.shape(ShapeType::Turtle);
// Position the turtle (pen up, move, pen down)
plan.pen_up();
plan.backward(80.0);
plan.left(90.0);
plan.forward(50.0);
plan.right(90.0);
plan.pen_down();
nikolaus(&mut plan, 100.0);
// Create turtle app with animation (speed = 100 pixels/sec)
let mut app = TurtleApp::new().with_commands(plan.build(), 100.0);
// Main loop
loop {
clear_background(WHITE);
// Update and render
app.update();
app.render();
next_frame().await
}
}

View File

@ -0,0 +1,47 @@
//! Example demonstrating different turtle shapes
use macroquad::prelude::*;
use turtle_lib_macroquad::*;
#[macroquad::main("Turtle Shapes")]
async fn main() {
// Create a turtle plan that demonstrates different shapes
let mut plan = create_turtle();
// Start with triangle (default)
plan.forward(100.0);
plan.right(90.0);
// Change to turtle shape
plan.shape(ShapeType::Turtle);
plan.forward(100.0);
plan.right(90.0);
// Change to circle
plan.shape(ShapeType::Circle);
plan.forward(100.0);
plan.right(90.0);
// Change to square
plan.shape(ShapeType::Square);
plan.forward(100.0);
plan.right(90.0);
// Change to arrow
plan.shape(ShapeType::Arrow);
plan.forward(100.0);
// Create turtle app with animation (speed = 100 pixels/sec for slower animation)
let mut app = TurtleApp::new().with_commands(plan.build(), 700.0);
// Main loop
loop {
clear_background(WHITE);
// Update and render
app.update();
app.render();
next_frame().await
}
}

View File

@ -0,0 +1,30 @@
//! Simple square example demonstrating basic turtle graphics
use macroquad::prelude::*;
use turtle_lib_macroquad::*;
#[macroquad::main("Turtle Square")]
async fn main() {
// Create a turtle plan
let mut plan = create_turtle();
plan.shape(ShapeType::Turtle);
// Draw a square
for _ in 0..4 {
plan.forward(100.0).right(90.0);
}
// Create turtle app with animation (speed = 100 pixels/sec)
let mut app = TurtleApp::new().with_commands(plan.build(), 100.0);
// Main loop
loop {
clear_background(WHITE);
// Update and render
app.update();
app.render();
next_frame().await
}
}

View File

@ -0,0 +1,34 @@
//! Simple square example demonstrating basic turtle graphics
use macroquad::prelude::*;
use turtle_lib_macroquad::*;
#[macroquad::main("Turtle Square")]
async fn main() {
// Create a turtle plan
let mut plan = create_turtle();
plan.shape(ShapeType::Turtle);
plan.set_speed(800);
// Draw a square
for _ in 0..5 {
plan.forward(200.0);
plan.circle_left(10.0, 72.0, 1000);
plan.circle_right(5.0, 360.0, 1000);
plan.circle_left(10.0, 72.0, 1000);
}
// Create turtle app with animation (speed = 100 pixels/sec)
let mut app = TurtleApp::new().with_commands(plan.build(), 100.0);
// Main loop
loop {
clear_background(WHITE);
// Update and render
app.update();
app.render();
next_frame().await
}
}

View File

@ -0,0 +1,41 @@
//! Simple square example demonstrating basic turtle graphics
use macroquad::prelude::*;
use turtle_lib_macroquad::*;
#[macroquad::main("Turtle Square")]
async fn main() {
// Create a turtle plan
let mut t = create_turtle();
t.circle_left(90.0, 180.0, 36);
t.circle_left(90.0, 180.0, 36);
t.circle_left(45.0, 180.0, 26);
t.circle_right(45.0, 180.0, 26);
t.pen_up();
t.right(90.0);
t.forward(37.0);
t.left(90.0);
t.pen_down();
t.circle_right(8.0, 360.0, 12);
t.pen_up();
t.right(90.0);
t.forward(90.0);
t.left(90.0);
t.pen_down();
t.circle_right(8.0, 360.0, 12);
// Create turtle app with animation (speed = 100 pixels/sec)
let mut app = TurtleApp::new().with_commands(t.build(), 100.0);
// Main loop
loop {
clear_background(WHITE);
// Update and render
app.update();
app.render();
next_frame().await
}
}

View File

@ -0,0 +1,168 @@
//! Builder pattern traits for creating turtle command sequences
use crate::commands::{CommandQueue, TurtleCommand};
use crate::general::{Color, Precision};
use crate::shapes::{ShapeType, TurtleShape};
/// Trait for adding commands to a queue
pub trait WithCommands {
fn get_commands_mut(&mut self) -> &mut CommandQueue;
fn get_commands(self) -> CommandQueue;
}
/// Trait for forward/backward movement
pub trait DirectionalMovement: WithCommands {
fn forward<T>(&mut self, distance: T) -> &mut Self
where
T: Into<Precision>,
{
let dist: Precision = distance.into();
self.get_commands_mut().push(TurtleCommand::Forward(dist));
self
}
fn backward<T>(&mut self, distance: T) -> &mut Self
where
T: Into<Precision>,
{
let dist: Precision = distance.into();
self.get_commands_mut().push(TurtleCommand::Backward(dist));
self
}
}
/// Trait for turning operations
pub trait Turnable: WithCommands {
fn left<T>(&mut self, angle: T) -> &mut Self
where
T: Into<Precision>,
{
let degrees: Precision = angle.into();
self.get_commands_mut().push(TurtleCommand::Left(degrees));
self
}
fn right<T>(&mut self, angle: T) -> &mut Self
where
T: Into<Precision>,
{
let degrees: Precision = angle.into();
self.get_commands_mut().push(TurtleCommand::Right(degrees));
self
}
}
/// Trait for curved movement (circles)
pub trait CurvedMovement: WithCommands {
fn circle_left<R, A>(&mut self, radius: R, angle: A, steps: usize) -> &mut Self
where
R: Into<Precision>,
A: Into<Precision>,
{
let r: Precision = radius.into();
let a: Precision = angle.into();
self.get_commands_mut().push(TurtleCommand::CircleLeft {
radius: r,
angle: a,
steps,
});
self
}
fn circle_right<R, A>(&mut self, radius: R, angle: A, steps: usize) -> &mut Self
where
R: Into<Precision>,
A: Into<Precision>,
{
let r: Precision = radius.into();
let a: Precision = angle.into();
self.get_commands_mut().push(TurtleCommand::CircleRight {
radius: r,
angle: a,
steps,
});
self
}
}
/// Builder for creating turtle command sequences
#[derive(Default, Debug)]
pub struct TurtlePlan {
queue: CommandQueue,
}
impl TurtlePlan {
pub fn new() -> Self {
Self {
queue: CommandQueue::new(),
}
}
pub fn with_capacity(capacity: usize) -> Self {
Self {
queue: CommandQueue::with_capacity(capacity),
}
}
pub fn set_speed(&mut self, speed: u32) -> &mut Self {
self.queue.push(TurtleCommand::SetSpeed(speed));
self
}
pub fn set_color(&mut self, color: Color) -> &mut Self {
self.queue.push(TurtleCommand::SetColor(color));
self
}
pub fn set_pen_width(&mut self, width: Precision) -> &mut Self {
self.queue.push(TurtleCommand::SetPenWidth(width));
self
}
pub fn pen_up(&mut self) -> &mut Self {
self.queue.push(TurtleCommand::PenUp);
self
}
pub fn pen_down(&mut self) -> &mut Self {
self.queue.push(TurtleCommand::PenDown);
self
}
pub fn hide(&mut self) -> &mut Self {
self.queue.push(TurtleCommand::HideTurtle);
self
}
pub fn show(&mut self) -> &mut Self {
self.queue.push(TurtleCommand::ShowTurtle);
self
}
pub fn set_shape(&mut self, shape: TurtleShape) -> &mut Self {
self.queue.push(TurtleCommand::SetShape(shape));
self
}
pub fn shape(&mut self, shape_type: ShapeType) -> &mut Self {
self.set_shape(shape_type.to_shape())
}
pub fn build(self) -> CommandQueue {
self.queue
}
}
impl WithCommands for TurtlePlan {
fn get_commands_mut(&mut self) -> &mut CommandQueue {
&mut self.queue
}
fn get_commands(self) -> CommandQueue {
self.queue
}
}
impl DirectionalMovement for TurtlePlan {}
impl Turnable for TurtlePlan {}
impl CurvedMovement for TurtlePlan {}

View File

@ -0,0 +1,207 @@
//! Circle geometry calculations - single source of truth for circle_left and circle_right
use macroquad::prelude::*;
/// Direction of circular motion (in screen coordinates with Y-down)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CircleDirection {
Left, // Counter-clockwise visually, heading decreases
Right, // Clockwise visually, heading increases
}
/// Encapsulates all geometry for a circular arc
pub struct CircleGeometry {
pub center: Vec2,
pub radius: f32,
pub start_angle_from_center: f32, // radians
pub direction: CircleDirection,
}
impl CircleGeometry {
/// Create geometry for a circle command
pub fn new(
turtle_pos: Vec2,
turtle_heading: f32,
radius: f32,
direction: CircleDirection,
) -> Self {
use std::f32::consts::FRAC_PI_2;
// Calculate center based on direction
// In screen coordinates (Y-down):
// - Left turn (counter-clockwise visually): center is perpendicular-left from turtle's perspective
// which is heading - π/2 (rotated clockwise from heading vector)
// - Right turn (clockwise visually): center is perpendicular-right from turtle's perspective
// which is heading + π/2 (rotated counter-clockwise from heading vector)
let center_offset_angle = match direction {
CircleDirection::Left => turtle_heading - FRAC_PI_2,
CircleDirection::Right => turtle_heading + FRAC_PI_2,
};
let center = vec2(
turtle_pos.x + radius * center_offset_angle.cos(),
turtle_pos.y + radius * center_offset_angle.sin(),
);
// Angle from center back to turtle position
let start_angle_from_center = match direction {
CircleDirection::Left => turtle_heading + FRAC_PI_2,
CircleDirection::Right => turtle_heading - FRAC_PI_2,
};
Self {
center,
radius,
start_angle_from_center,
direction,
}
}
/// Calculate position after traveling an angle along the arc
pub fn position_at_angle(&self, angle_traveled: f32) -> Vec2 {
let current_angle = match self.direction {
CircleDirection::Left => self.start_angle_from_center - angle_traveled,
CircleDirection::Right => self.start_angle_from_center + angle_traveled,
};
vec2(
self.center.x + self.radius * current_angle.cos(),
self.center.y + self.radius * current_angle.sin(),
)
}
/// Calculate position at a given progress (0.0 to 1.0) through total_angle
pub fn position_at_progress(&self, total_angle: f32, progress: f32) -> Vec2 {
let angle_traveled = total_angle * progress;
self.position_at_angle(angle_traveled)
}
/// Get the angle traveled from start position to a given position
pub fn angle_to_position(&self, position: Vec2) -> f32 {
let displacement = position - self.center;
let current_angle = displacement.y.atan2(displacement.x);
let mut angle_diff = match self.direction {
CircleDirection::Left => self.start_angle_from_center - current_angle,
CircleDirection::Right => current_angle - self.start_angle_from_center,
};
// Normalize to [0, 2π)
if angle_diff < 0.0 {
angle_diff += 2.0 * std::f32::consts::PI;
}
angle_diff
}
/// Get draw_arc parameters for the full arc
/// Returns (rotation_degrees, arc_degrees) for macroquad's draw_arc
pub fn draw_arc_params(&self, total_angle_degrees: f32) -> (f32, f32) {
match self.direction {
CircleDirection::Left => {
// For left (counter-clockwise), we need to draw counter-clockwise from end back to start
// so we start at (start - total_angle) and draw total_angle counter-clockwise
let end_angle = self.start_angle_from_center - total_angle_degrees.to_radians();
(end_angle.to_degrees(), total_angle_degrees)
}
CircleDirection::Right => {
// For right (clockwise), draw from start
(
self.start_angle_from_center.to_degrees(),
total_angle_degrees,
)
}
}
}
/// Get draw_arc parameters for a partial arc (during tweening)
/// Returns (rotation_degrees, arc_degrees) for macroquad's draw_arc
pub fn draw_arc_params_partial(&self, angle_traveled: f32) -> (f32, f32) {
let angle_traveled_degrees = angle_traveled.to_degrees();
match self.direction {
CircleDirection::Left => {
// Draw from current position backwards (counter-clockwise) to start
let current_angle = self.start_angle_from_center - angle_traveled;
(current_angle.to_degrees(), angle_traveled_degrees)
}
CircleDirection::Right => {
// Draw from start, counter-clockwise
(
self.start_angle_from_center.to_degrees(),
angle_traveled_degrees,
)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::{FRAC_PI_2, PI};
#[test]
fn test_circle_left_geometry() {
let geom = CircleGeometry::new(
vec2(0.0, 0.0),
0.0, // heading east (0 radians)
100.0,
CircleDirection::Left,
);
// For left turn with heading east (0), center should be at heading - π/2
// That's -π/2 radians = south
// Center = start + 100 * (cos(-π/2), sin(-π/2)) = (0, 0) + (0, -100) = (0, -100)
assert!(
(geom.center.x - 0.0).abs() < 0.01,
"center.x = {}",
geom.center.x
);
assert!(
(geom.center.y - (-100.0)).abs() < 0.01,
"center.y = {}",
geom.center.y
);
// After π/2 radians counter-clockwise around a circle centered at (0, -100):
// start_angle = π/2 (pointing north from center, which is where (0,0) is)
// after π/2 counter-clockwise (subtract in screen coords): angle = π/2 - π/2 = 0 (pointing east from center)
// pos = (0, -100) + 100 * (cos(0), sin(0)) = (0, -100) + (100, 0) = (100, -100)
let pos = geom.position_at_angle(FRAC_PI_2);
assert!((pos.x - 100.0).abs() < 0.01, "pos.x = {}", pos.x);
assert!((pos.y - (-100.0)).abs() < 0.01, "pos.y = {}", pos.y);
}
#[test]
fn test_circle_right_geometry() {
let geom = CircleGeometry::new(
vec2(0.0, 0.0),
0.0, // heading east
100.0,
CircleDirection::Right,
);
// For right turn with heading east (0), center should be at heading + π/2
// That's π/2 radians = north
// Center = start + 100 * (cos(π/2), sin(π/2)) = (0, 0) + (0, 100) = (0, 100)
assert!(
(geom.center.x - 0.0).abs() < 0.01,
"center.x = {}",
geom.center.x
);
assert!(
(geom.center.y - 100.0).abs() < 0.01,
"center.y = {}",
geom.center.y
);
// After π/2 radians clockwise around a circle centered at (0, 100):
// start_angle = -π/2 (pointing south from center, which is where (0,0) is)
// after π/2 clockwise (add in screen coords): angle = -π/2 + π/2 = 0 (pointing east from center)
// pos = (0, 100) + 100 * (cos(0), sin(0)) = (0, 100) + (100, 0) = (100, 100)
let pos = geom.position_at_angle(PI / 2.0);
assert!((pos.x - 100.0).abs() < 0.01, "pos.x = {}", pos.x);
assert!((pos.y - 100.0).abs() < 0.01, "pos.y = {}", pos.y);
}
}

View File

@ -0,0 +1,114 @@
//! Turtle commands and command queue
use crate::general::{Color, Coordinate, Precision};
use crate::shapes::TurtleShape;
/// Individual turtle commands
#[derive(Clone, Debug)]
pub enum TurtleCommand {
// Movement
Forward(Precision),
Backward(Precision),
// Rotation
Left(Precision), // degrees
Right(Precision), // degrees
// Circle drawing
CircleLeft {
radius: Precision,
angle: Precision, // degrees
steps: usize,
},
CircleRight {
radius: Precision,
angle: Precision, // degrees
steps: usize,
},
// Pen control
PenUp,
PenDown,
// Appearance
SetColor(Color),
SetFillColor(Option<Color>),
SetPenWidth(Precision),
SetSpeed(u32),
SetShape(TurtleShape),
// Position
Goto(Coordinate),
SetHeading(Precision), // radians
// Visibility
ShowTurtle,
HideTurtle,
}
/// Queue of turtle commands with execution state
#[derive(Debug)]
pub struct CommandQueue {
commands: Vec<TurtleCommand>,
current_index: usize,
}
impl CommandQueue {
pub fn new() -> Self {
Self {
commands: Vec::new(),
current_index: 0,
}
}
pub fn with_capacity(capacity: usize) -> Self {
Self {
commands: Vec::with_capacity(capacity),
current_index: 0,
}
}
pub fn push(&mut self, command: TurtleCommand) {
self.commands.push(command);
}
pub fn extend(&mut self, commands: impl IntoIterator<Item = TurtleCommand>) {
self.commands.extend(commands);
}
pub fn next(&mut self) -> Option<&TurtleCommand> {
if self.current_index < self.commands.len() {
let cmd = &self.commands[self.current_index];
self.current_index += 1;
Some(cmd)
} else {
None
}
}
pub fn is_complete(&self) -> bool {
self.current_index >= self.commands.len()
}
pub fn reset(&mut self) {
self.current_index = 0;
}
pub fn len(&self) -> usize {
self.commands.len()
}
pub fn is_empty(&self) -> bool {
self.commands.is_empty()
}
pub fn remaining(&self) -> usize {
self.commands.len().saturating_sub(self.current_index)
}
}
impl Default for CommandQueue {
fn default() -> Self {
Self::new()
}
}

View File

@ -0,0 +1,345 @@
//! Rendering logic using Macroquad
use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::state::{DrawCommand, TurtleState, TurtleWorld};
use macroquad::prelude::*;
// Import the easing function from the tween crate
// To change the easing, change both this import and the usage in the draw_tween_arc_* functions below
// Available options: Linear, SineInOut, QuadInOut, CubicInOut, QuartInOut, QuintInOut,
// ExpoInOut, CircInOut, BackInOut, ElasticInOut, BounceInOut, etc.
// See https://easings.net/ for visual demonstrations
use tween::CubicInOut;
/// Render the entire turtle world
pub fn render_world(world: &TurtleWorld) {
// Update camera zoom based on current screen size to prevent stretching
let camera = Camera2D {
zoom: vec2(1.0 / screen_width() * 2.0, 1.0 / screen_height() * 2.0),
target: world.camera.target,
..Default::default()
};
// Set camera
set_camera(&camera);
// Draw all accumulated commands
for cmd in &world.commands {
match cmd {
DrawCommand::Line {
start,
end,
color,
width,
} => {
draw_line(start.x, start.y, end.x, end.y, *width, *color);
}
DrawCommand::Circle {
center,
radius,
color,
filled,
} => {
if *filled {
draw_circle(center.x, center.y, *radius, *color);
} else {
draw_circle_lines(center.x, center.y, *radius, 2.0, *color);
}
}
DrawCommand::Arc {
center,
radius,
rotation,
arc,
color,
width,
sides,
} => {
draw_arc(
center.x, center.y, *sides, *radius, *rotation, *width, *arc, *color,
);
}
DrawCommand::FilledPolygon { vertices, color } => {
if vertices.len() >= 3 {
draw_filled_polygon(vertices, *color);
}
}
}
}
// Draw turtle if visible
if world.turtle.visible {
draw_turtle(&world.turtle);
}
// Reset to default camera
set_default_camera();
}
/// Render the turtle world with active tween visualization
pub(crate) fn render_world_with_tween(
world: &TurtleWorld,
active_tween: Option<&crate::tweening::CommandTween>,
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 {
zoom: vec2(
1.0 / screen_width() * 2.0 / zoom_level,
1.0 / screen_height() * 2.0 / zoom_level,
),
target: world.camera.target,
..Default::default()
};
// Set camera
set_camera(&camera);
// Draw all accumulated commands
for cmd in &world.commands {
match cmd {
DrawCommand::Line {
start,
end,
color,
width,
} => {
draw_line(start.x, start.y, end.x, end.y, *width, *color);
}
DrawCommand::Circle {
center,
radius,
color,
filled,
} => {
if *filled {
draw_circle(center.x, center.y, *radius, *color);
} else {
draw_circle_lines(center.x, center.y, *radius, 2.0, *color);
}
}
DrawCommand::Arc {
center,
radius,
rotation,
arc,
color,
width,
sides,
} => {
draw_arc(
center.x, center.y, *sides, *radius, *rotation, *width, *arc, *color,
);
}
DrawCommand::FilledPolygon { vertices, color } => {
if vertices.len() >= 3 {
draw_filled_polygon(vertices, *color);
}
}
}
}
// Draw in-progress tween line if pen is down
if let Some(tween) = active_tween {
if tween.start_state.pen_down {
match &tween.command {
crate::commands::TurtleCommand::CircleLeft {
radius,
angle,
steps,
} => {
// Draw arc segments from start to current position
draw_tween_arc_left(tween, *radius, *angle, *steps);
}
crate::commands::TurtleCommand::CircleRight {
radius,
angle,
steps,
} => {
// Draw arc segments from start to current position
draw_tween_arc_right(tween, *radius, *angle, *steps);
}
_ if should_draw_tween_line(&tween.command) => {
// Draw straight line for other movement commands
draw_line(
tween.start_state.position.x,
tween.start_state.position.y,
world.turtle.position.x,
world.turtle.position.y,
tween.start_state.pen_width,
tween.start_state.color,
);
// Add circle at current position for smooth line joins
draw_circle(
world.turtle.position.x,
world.turtle.position.y,
tween.start_state.pen_width / 2.0,
tween.start_state.color,
);
}
_ => {}
}
}
}
// Draw turtle if visible
if world.turtle.visible {
draw_turtle(&world.turtle);
}
// Reset to default camera
set_default_camera();
}
fn should_draw_tween_line(command: &crate::commands::TurtleCommand) -> bool {
use crate::commands::TurtleCommand;
matches!(
command,
TurtleCommand::Forward(..) | TurtleCommand::Backward(..) | TurtleCommand::Goto(..)
)
}
/// Draw arc segments for circle_left tween animation
fn draw_tween_arc_left(
tween: &crate::tweening::CommandTween,
radius: f32,
total_angle: f32,
steps: usize,
) {
let geom = CircleGeometry::new(
tween.start_state.position,
tween.start_state.heading,
radius,
CircleDirection::Left,
);
// Debug: draw center
draw_circle(geom.center.x, geom.center.y, 5.0, GRAY);
// Calculate how much of the arc we've traveled based on tween progress
// Use the same eased progress as the turtle position for synchronized animation
let elapsed = (get_time() - tween.start_time) as f32;
let t = (elapsed / tween.duration as f32).min(1.0);
let progress = CubicInOut.tween(1.0, t); // 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);
// Adjust radius inward by half the line width so the line sits on the turtle's path
let draw_radius = radius - tween.start_state.pen_width / 2.0;
// Draw the partial arc
draw_arc(
geom.center.x,
geom.center.y,
steps as u8,
draw_radius,
rotation_degrees,
tween.start_state.pen_width,
arc_degrees,
tween.start_state.color,
);
}
/// Draw arc segments for circle_right tween animation
fn draw_tween_arc_right(
tween: &crate::tweening::CommandTween,
radius: f32,
total_angle: f32,
steps: usize,
) {
let geom = CircleGeometry::new(
tween.start_state.position,
tween.start_state.heading,
radius,
CircleDirection::Right,
);
// Debug: draw center
draw_circle(geom.center.x, geom.center.y, 5.0, GRAY);
// Calculate how much of the arc we've traveled based on tween progress
// Use the same eased progress as the turtle position for synchronized animation
let elapsed = (get_time() - tween.start_time) as f32;
let t = (elapsed / tween.duration as f32).min(1.0);
let progress = CubicInOut.tween(1.0, t); // 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);
// Adjust radius inward by half the line width so the line sits on the turtle's path
let draw_radius = radius - tween.start_state.pen_width / 2.0;
// Draw the partial arc
draw_arc(
geom.center.x,
geom.center.y,
steps as u8,
draw_radius,
rotation_degrees,
tween.start_state.pen_width,
arc_degrees,
tween.start_state.color,
);
}
/// Draw the turtle shape
pub fn draw_turtle(turtle: &TurtleState) {
let rotated_vertices = turtle.shape.rotated_vertices(turtle.heading);
if turtle.shape.filled {
// Draw filled polygon (now supports concave shapes via ear clipping)
if rotated_vertices.len() >= 3 {
let absolute_vertices: Vec<Vec2> = rotated_vertices
.iter()
.map(|v| turtle.position + *v)
.collect();
draw_filled_polygon(&absolute_vertices, Color::new(0.0, 0.5, 1.0, 1.0));
}
} else {
// Draw outline
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];
draw_line(p1.x, p1.y, p2.x, p2.y, 2.0, Color::new(0.0, 0.5, 1.0, 1.0));
}
}
}
}
/// Draw a filled polygon using triangulation
fn draw_filled_polygon(vertices: &[Vec2], color: Color) {
if vertices.len() < 3 {
return;
}
// Flatten vertices into the format expected by earcutr: [x0, y0, x1, y1, ...]
let flattened: Vec<f64> = vertices
.iter()
.flat_map(|v| vec![v.x as f64, v.y as f64])
.collect();
// Triangulate using earcutr (no holes, 2 dimensions)
match earcutr::earcut(&flattened, &[], 2) {
Ok(indices) => {
// Draw each triangle
for triangle in indices.chunks(3) {
if triangle.len() == 3 {
let v0 = vertices[triangle[0]];
let v1 = vertices[triangle[1]];
let v2 = vertices[triangle[2]];
draw_triangle(v0, v1, v2, color);
}
}
}
Err(_) => {
// Fallback: if triangulation fails, try simple fan triangulation
let first = vertices[0];
for i in 1..vertices.len() - 1 {
draw_triangle(first, vertices[i], vertices[i + 1], color);
}
}
}
}

View File

@ -0,0 +1,402 @@
//! Command execution logic
use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::commands::TurtleCommand;
use crate::state::{DrawCommand, TurtleState, TurtleWorld};
use macroquad::prelude::*;
/// Execute a single turtle command, updating state and adding draw commands
pub fn execute_command(command: &TurtleCommand, state: &mut TurtleState, world: &mut TurtleWorld) {
match command {
TurtleCommand::Forward(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);
if state.pen_down {
world.add_command(DrawCommand::Line {
start,
end: state.position,
color: state.color,
width: state.pen_width,
});
// Add circle at end point for smooth line joins
world.add_command(DrawCommand::Circle {
center: state.position,
radius: state.pen_width / 2.0,
color: state.color,
filled: true,
});
}
}
TurtleCommand::Backward(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);
if state.pen_down {
world.add_command(DrawCommand::Line {
start,
end: state.position,
color: state.color,
width: state.pen_width,
});
// Add circle at end point for smooth line joins
world.add_command(DrawCommand::Circle {
center: state.position,
radius: state.pen_width / 2.0,
color: state.color,
filled: true,
});
}
}
TurtleCommand::Left(degrees) => {
state.heading -= degrees.to_radians();
}
TurtleCommand::Right(degrees) => {
state.heading += degrees.to_radians();
}
TurtleCommand::CircleLeft {
radius,
angle,
steps,
} => {
let start_heading = state.heading;
let geom = CircleGeometry::new(
state.position,
start_heading,
*radius,
CircleDirection::Left,
);
if state.pen_down {
let (rotation_degrees, arc_degrees) = geom.draw_arc_params(*angle);
world.add_command(DrawCommand::Arc {
center: geom.center,
radius: *radius - state.pen_width, // Adjust radius for pen width to keep arc inside
rotation: rotation_degrees,
arc: arc_degrees,
color: state.color,
width: state.pen_width,
sides: *steps as u8,
});
}
// Update turtle position and heading
state.position = geom.position_at_angle(angle.to_radians());
state.heading = start_heading - angle.to_radians();
}
TurtleCommand::CircleRight {
radius,
angle,
steps,
} => {
let start_heading = state.heading;
let geom = CircleGeometry::new(
state.position,
start_heading,
*radius,
CircleDirection::Right,
);
if state.pen_down {
let (rotation_degrees, arc_degrees) = geom.draw_arc_params(*angle);
world.add_command(DrawCommand::Arc {
center: geom.center,
radius: *radius - state.pen_width, // Adjust radius for pen width to keep arc inside
rotation: rotation_degrees,
arc: arc_degrees,
color: state.color,
width: state.pen_width,
sides: *steps as u8,
});
}
// Update turtle position and heading
state.position = geom.position_at_angle(angle.to_radians());
state.heading = start_heading + angle.to_radians();
}
TurtleCommand::PenUp => {
state.pen_down = false;
}
TurtleCommand::PenDown => {
state.pen_down = true;
}
TurtleCommand::SetColor(color) => {
state.color = *color;
}
TurtleCommand::SetFillColor(color) => {
state.fill_color = *color;
}
TurtleCommand::SetPenWidth(width) => {
state.pen_width = *width;
}
TurtleCommand::SetSpeed(speed) => {
state.set_speed(*speed);
}
TurtleCommand::SetShape(shape) => {
state.shape = shape.clone();
}
TurtleCommand::Goto(coord) => {
let start = state.position;
state.position = *coord;
if state.pen_down {
world.add_command(DrawCommand::Line {
start,
end: state.position,
color: state.color,
width: state.pen_width,
});
// Add circle at end point for smooth line joins
world.add_command(DrawCommand::Circle {
center: state.position,
radius: state.pen_width / 2.0,
color: state.color,
filled: true,
});
}
}
TurtleCommand::SetHeading(heading) => {
state.heading = *heading;
}
TurtleCommand::ShowTurtle => {
state.visible = true;
}
TurtleCommand::HideTurtle => {
state.visible = false;
}
}
}
/// Execute all commands immediately (no animation)
pub fn execute_all_immediate(
queue: &mut crate::commands::CommandQueue,
state: &mut TurtleState,
world: &mut TurtleWorld,
) {
while let Some(command) = queue.next() {
execute_command(command, state, world);
}
}
/// 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::Forward(_) | TurtleCommand::Backward(_) | TurtleCommand::Goto(_) => {
if start_state.pen_down {
world.add_command(DrawCommand::Line {
start: start_state.position,
end: end_state.position,
color: start_state.color,
width: start_state.pen_width,
});
// Add circle at end point for smooth line joins
world.add_command(DrawCommand::Circle {
center: end_state.position,
radius: start_state.pen_width / 2.0,
color: start_state.color,
filled: true,
});
}
}
TurtleCommand::CircleLeft {
radius,
angle,
steps,
} => {
if start_state.pen_down {
let geom = CircleGeometry::new(
start_state.position,
start_state.heading,
*radius,
CircleDirection::Left,
);
let (rotation_degrees, arc_degrees) = geom.draw_arc_params(*angle);
world.add_command(DrawCommand::Arc {
center: geom.center,
radius: *radius - start_state.pen_width / 2.0,
rotation: rotation_degrees,
arc: arc_degrees,
color: start_state.color,
width: start_state.pen_width,
sides: *steps as u8,
});
// Add endpoint circles for smooth joins
world.add_command(DrawCommand::Circle {
center: start_state.position,
radius: start_state.pen_width / 2.0,
color: start_state.color,
filled: true,
});
world.add_command(DrawCommand::Circle {
center: end_state.position,
radius: start_state.pen_width / 2.0,
color: start_state.color,
filled: true,
});
}
}
TurtleCommand::CircleRight {
radius,
angle,
steps,
} => {
if start_state.pen_down {
let geom = CircleGeometry::new(
start_state.position,
start_state.heading,
*radius,
CircleDirection::Right,
);
let (rotation_degrees, arc_degrees) = geom.draw_arc_params(*angle);
world.add_command(DrawCommand::Arc {
center: geom.center,
radius: *radius - start_state.pen_width / 2.0,
rotation: rotation_degrees,
arc: arc_degrees,
color: start_state.color,
width: start_state.pen_width,
sides: *steps as u8,
});
// Add endpoint circles for smooth joins
world.add_command(DrawCommand::Circle {
center: start_state.position,
radius: start_state.pen_width / 2.0,
color: start_state.color,
filled: true,
});
world.add_command(DrawCommand::Circle {
center: end_state.position,
radius: start_state.pen_width / 2.0,
color: start_state.color,
filled: true,
});
}
}
_ => {
// Other commands don't create drawing
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::TurtleCommand;
use crate::shapes::TurtleShape;
#[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: 100,
visible: true,
shape: TurtleShape::turtle(),
};
// We'll use a dummy world but won't actually call drawing commands
let mut world = TurtleWorld {
turtle: state.clone(),
commands: Vec::new(),
camera: macroquad::camera::Camera2D {
zoom: vec2(1.0, 1.0),
target: vec2(0.0, 0.0),
offset: vec2(0.0, 0.0),
rotation: 0.0,
render_target: None,
viewport: None,
},
background_color: Color::new(1.0, 1.0, 1.0, 1.0),
};
// 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);
// Forward 100 - should move to (100, 0)
execute_command(&TurtleCommand::Forward(100.0), &mut state, &mut world);
assert!(
(state.position.x - 100.0).abs() < 0.01,
"After forward(100): x = {}",
state.position.x
);
assert!(
(state.position.y - 0.0).abs() < 0.01,
"After forward(100): y = {}",
state.position.y
);
assert!((state.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::Left(90.0), &mut state, &mut world);
assert!(
(state.position.x - 100.0).abs() < 0.01,
"After left(90): x = {}",
state.position.x
);
assert!(
(state.position.y - 0.0).abs() < 0.01,
"After left(90): y = {}",
state.position.y
);
let expected_heading = -90.0f32.to_radians();
assert!(
(state.heading - expected_heading).abs() < 0.01,
"After left(90): heading = {} (expected {})",
state.heading,
expected_heading
);
// Forward 50 - should move north (negative Y) to (100, -50)
execute_command(&TurtleCommand::Forward(50.0), &mut state, &mut world);
assert!(
(state.position.x - 100.0).abs() < 0.01,
"Final position: x = {} (expected 100.0)",
state.position.x
);
assert!(
(state.position.y - (-50.0)).abs() < 0.01,
"Final position: y = {} (expected -50.0)",
state.position.y
);
}
}

View File

@ -0,0 +1,24 @@
//! General types and type aliases used throughout the turtle library
use macroquad::prelude::*;
pub mod angle;
pub mod length;
pub use angle::Angle;
pub use length::Length;
/// Precision type for calculations
pub type Precision = f32;
/// 2D coordinate in screen space
pub type Coordinate = Vec2;
/// Visibility flag for turtle
pub type Visibility = bool;
/// Speed of animations (higher = faster, >= 999 = instant)
pub type Speed = u32;
/// Color type re-export from macroquad
pub use macroquad::color::Color;

View File

@ -0,0 +1,200 @@
//! Angle type with degrees and radians support
use super::Precision;
use std::ops::{Add, Div, Mul, Neg, Rem, Sub};
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum AngleUnit {
Degrees(Precision),
Radians(Precision),
}
impl Default for AngleUnit {
fn default() -> Self {
Self::Degrees(0.0)
}
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct Angle {
value: AngleUnit,
}
impl Default for Angle {
fn default() -> Self {
Self {
value: AngleUnit::Degrees(0.0),
}
}
}
impl From<i16> for Angle {
fn from(i: i16) -> Self {
Self {
value: AngleUnit::Degrees(i as Precision),
}
}
}
impl From<f32> for Angle {
fn from(f: f32) -> Self {
Self {
value: AngleUnit::Degrees(f),
}
}
}
impl Rem<Precision> for Angle {
type Output = Self;
fn rem(self, rhs: Precision) -> Self::Output {
match self.value {
AngleUnit::Degrees(v) => Self::degrees(v % rhs),
AngleUnit::Radians(v) => Self::radians(v % rhs),
}
}
}
impl Mul<Precision> for Angle {
type Output = Self;
fn mul(self, rhs: Precision) -> Self::Output {
match self.value {
AngleUnit::Degrees(v) => Self::degrees(v * rhs),
AngleUnit::Radians(v) => Self::radians(v * rhs),
}
}
}
impl Div<Precision> for Angle {
type Output = Self;
fn div(self, rhs: Precision) -> Self::Output {
match self.value {
AngleUnit::Degrees(v) => Self::degrees(v / rhs),
AngleUnit::Radians(v) => Self::radians(v / rhs),
}
}
}
impl Neg for Angle {
type Output = Self;
fn neg(self) -> Self::Output {
match self.value {
AngleUnit::Degrees(v) => Self::degrees(-v),
AngleUnit::Radians(v) => Self::radians(-v),
}
}
}
impl Neg for &Angle {
type Output = Angle;
fn neg(self) -> Self::Output {
match self.value {
AngleUnit::Degrees(v) => Angle::degrees(-v),
AngleUnit::Radians(v) => Angle::radians(-v),
}
}
}
impl Add for Angle {
type Output = Angle;
fn add(self, rhs: Self) -> Self::Output {
match (self.value, rhs.value) {
(AngleUnit::Degrees(v), AngleUnit::Degrees(o)) => Self::degrees(v + o),
(AngleUnit::Degrees(v), AngleUnit::Radians(o)) => Self::radians(v.to_radians() + o),
(AngleUnit::Radians(v), AngleUnit::Degrees(o)) => Self::radians(v + o.to_radians()),
(AngleUnit::Radians(v), AngleUnit::Radians(o)) => Self::radians(v + o),
}
}
}
impl Sub for Angle {
type Output = Angle;
fn sub(self, rhs: Self) -> Self::Output {
match (self.value, rhs.value) {
(AngleUnit::Degrees(v), AngleUnit::Degrees(o)) => Self::degrees(v - o),
(AngleUnit::Degrees(v), AngleUnit::Radians(o)) => Self::radians(v.to_radians() - o),
(AngleUnit::Radians(v), AngleUnit::Degrees(o)) => Self::radians(v - o.to_radians()),
(AngleUnit::Radians(v), AngleUnit::Radians(o)) => Self::radians(v - o),
}
}
}
impl Angle {
pub fn degrees(value: Precision) -> Self {
Self {
value: AngleUnit::Degrees(value),
}
}
pub fn radians(value: Precision) -> Self {
Self {
value: AngleUnit::Radians(value),
}
}
pub fn value(&self) -> Precision {
match self.value {
AngleUnit::Degrees(v) => v,
AngleUnit::Radians(v) => v,
}
}
pub fn to_radians(self) -> Self {
match self.value {
AngleUnit::Degrees(v) => Self::radians(v.to_radians()),
AngleUnit::Radians(_) => self,
}
}
pub fn to_degrees(self) -> Self {
match self.value {
AngleUnit::Degrees(_) => self,
AngleUnit::Radians(v) => Self::degrees(v.to_degrees()),
}
}
pub fn limit_smaller_than_full_circle(self) -> Self {
use std::f32::consts::PI;
match self.value {
AngleUnit::Degrees(v) => Self::degrees(v % 360.0),
AngleUnit::Radians(v) => Self::radians(v % (2.0 * PI)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn convert_to_radians() {
let radi = Angle::radians(30f32.to_radians());
let degr = Angle::degrees(30f32);
let converted = degr.to_radians();
assert!((radi.value() - converted.value()).abs() < 0.0001);
}
#[test]
fn sum_degrees() {
let fst = Angle::degrees(30f32);
let snd = Angle::degrees(30f32);
let sum = fst + snd;
assert!((sum.value() - 60f32).abs() < 0.0001);
assert!((sum.to_radians().value() - 60f32.to_radians()).abs() < 0.0001);
}
#[test]
fn sum_mixed() {
let fst = Angle::degrees(30f32);
let snd = Angle::radians(30f32.to_radians());
let sum = fst + snd;
assert!((sum.to_degrees().value() - 60f32).abs() < 0.0001);
assert!((sum.to_radians().value() - 60f32.to_radians()).abs() < 0.0001);
}
}

View File

@ -0,0 +1,24 @@
//! Length type for distance measurements
use super::Precision;
#[derive(Default, Copy, Clone, Debug, PartialEq)]
pub struct Length(pub Precision);
impl From<i16> for Length {
fn from(i: i16) -> Self {
Self(i as Precision)
}
}
impl From<f32> for Length {
fn from(f: f32) -> Self {
Self(f)
}
}
impl From<i32> for Length {
fn from(i: i32) -> Self {
Self(i as Precision)
}
}

View File

@ -0,0 +1,222 @@
//! Turtle graphics library for Macroquad
//!
//! This library provides a turtle graphics API for creating drawings and animations
//! using the Macroquad game framework.
//!
//! # Example
//! ```no_run
//! use macroquad::prelude::*;
//! use turtle_lib_macroquad::*;
//!
//! #[macroquad::main("Turtle")]
//! async fn main() {
//! let mut plan = create_turtle();
//! plan.forward(100.0).right(90.0).forward(100.0);
//!
//! let mut app = TurtleApp::new().with_commands(plan.build(), 100.0);
//!
//! loop {
//! clear_background(WHITE);
//! app.update();
//! app.render();
//! next_frame().await
//! }
//! }
//! ```
pub mod builders;
pub mod circle_geometry;
pub mod commands;
pub mod drawing;
pub mod execution;
pub mod general;
pub mod shapes;
pub mod state;
pub mod tweening;
// Re-export commonly used types
pub use builders::{CurvedMovement, DirectionalMovement, Turnable, TurtlePlan, WithCommands};
pub use commands::{CommandQueue, TurtleCommand};
pub use general::{Angle, Color, Coordinate, Length, Precision, Speed};
pub use shapes::{ShapeType, TurtleShape};
pub use state::{DrawCommand, TurtleState, TurtleWorld};
pub use tweening::TweenController;
use macroquad::prelude::*;
/// Main turtle application struct
pub struct TurtleApp {
world: TurtleWorld,
tween_controller: Option<TweenController>,
mode: ExecutionMode,
// Mouse panning state
is_dragging: bool,
last_mouse_pos: Option<Vec2>,
// Zoom state
zoom_level: f32,
}
enum ExecutionMode {
Immediate,
Animated,
}
impl TurtleApp {
/// Create a new turtle application with default settings
pub fn new() -> Self {
Self {
world: TurtleWorld::new(),
tween_controller: None,
mode: ExecutionMode::Immediate,
is_dragging: false,
last_mouse_pos: None,
zoom_level: 1.0,
}
}
/// Add commands to the turtle with specified speed
///
/// # Arguments
/// * `queue` - The command queue to execute
/// * `speed` - Animation speed in pixels/sec (>= 999.0 = instant, 0.5-999.0 = animated)
pub fn with_commands(mut self, queue: CommandQueue, speed: f32) -> Self {
if speed <= 0.5 || speed.is_infinite() || speed.is_nan() {
// Compiler error speed should be between 0.5 and 1000.0
panic!("Speed must be greater than 0.5 and less than 1000.0");
}
if speed >= 999.0 {
// Immediate mode - execute all commands instantly
self.mode = ExecutionMode::Immediate;
let mut state = TurtleState::default();
let mut queue_mut = queue;
execution::execute_all_immediate(&mut queue_mut, &mut state, &mut self.world);
self.world.turtle = state;
} else {
// Animated mode - tween between states
self.mode = ExecutionMode::Animated;
self.tween_controller = Some(TweenController::new(queue, speed));
}
self
}
/// Update animation state (call every frame)
pub fn update(&mut self) {
// Handle mouse panning and zoom
self.handle_mouse_panning();
self.handle_mouse_zoom();
if let Some(ref mut controller) = self.tween_controller {
if let Some((completed_cmd, start_state)) = controller.update(&mut self.world.turtle) {
// Copy end state before we borrow world mutably
let end_state = self.world.turtle.clone();
// Add draw commands for the completed tween
execution::add_draw_for_completed_tween(
&completed_cmd,
&start_state,
&end_state,
&mut self.world,
);
}
}
}
/// Handle mouse click and drag for panning
fn handle_mouse_panning(&mut self) {
let mouse_pos = mouse_position();
let mouse_pos = vec2(mouse_pos.0, mouse_pos.1);
if is_mouse_button_pressed(MouseButton::Left) {
self.is_dragging = true;
self.last_mouse_pos = Some(mouse_pos);
}
if is_mouse_button_released(MouseButton::Left) {
self.is_dragging = false;
self.last_mouse_pos = None;
}
if self.is_dragging {
if let Some(last_pos) = self.last_mouse_pos {
// Calculate delta in screen space
let delta = mouse_pos - last_pos;
// Convert screen delta to world space delta
// The camera zoom is 2.0 / screen_width, so world_units = screen_pixels / (screen_size * zoom / 2)
let world_delta = vec2(
-delta.x, -delta.y, // Flip Y because screen Y is down
);
self.world.camera.target += world_delta * self.zoom_level;
}
self.last_mouse_pos = Some(mouse_pos);
}
}
/// Handle mouse wheel for zooming
fn handle_mouse_zoom(&mut self) {
let (_wheel_x, wheel_y) = mouse_wheel();
if wheel_y != 0.0 {
// Zoom factor: positive wheel_y = zoom in, negative = zoom out
let zoom_factor = 1.0 + wheel_y * 0.1;
self.zoom_level *= zoom_factor;
// Clamp zoom level to reasonable values
self.zoom_level = self.zoom_level.clamp(0.1, 10.0);
}
}
/// Render the turtle world (call every frame)
pub fn render(&self) {
// Get active tween if in animated mode
let active_tween = self
.tween_controller
.as_ref()
.and_then(|c| c.current_tween());
drawing::render_world_with_tween(&self.world, active_tween, self.zoom_level);
}
/// Check if all commands have been executed
pub fn is_complete(&self) -> bool {
self.tween_controller
.as_ref()
.map(|c| c.is_complete())
.unwrap_or(true)
}
/// Get reference to the world state
pub fn world(&self) -> &TurtleWorld {
&self.world
}
/// Get mutable reference to the world state
pub fn world_mut(&mut self) -> &mut TurtleWorld {
&mut self.world
}
}
impl Default for TurtleApp {
fn default() -> Self {
Self::new()
}
}
/// Helper function to create a new turtle plan
///
/// # Example
/// ```
/// use turtle_lib_macroquad::*;
///
/// let mut turtle = create_turtle();
/// turtle.forward(100.0).right(90.0).forward(50.0);
/// let commands = turtle.build();
/// ```
pub fn create_turtle() -> TurtlePlan {
TurtlePlan::new()
}
/// Convenience function to get a turtle plan (alias for create_turtle)
pub fn get_a_turtle() -> TurtlePlan {
create_turtle()
}

View File

@ -0,0 +1,162 @@
//! Turtle shape definitions
use macroquad::prelude::*;
use std::f32::consts::PI;
/// A shape that can be drawn for the turtle
#[derive(Clone, Debug)]
pub struct TurtleShape {
/// Vertices of the shape (relative to turtle position)
pub vertices: Vec<Vec2>,
/// Whether to draw as filled polygon (true) or outline (false)
pub filled: bool,
}
impl TurtleShape {
/// Create a new custom shape from vertices
pub fn new(vertices: Vec<Vec2>, filled: bool) -> Self {
Self { vertices, filled }
}
/// Get vertices rotated by the given angle
pub fn rotated_vertices(&self, angle: f32) -> Vec<Vec2> {
self.vertices
.iter()
.map(|v| {
let cos_a = angle.cos();
let sin_a = angle.sin();
vec2(v.x * cos_a - v.y * sin_a, v.x * sin_a + v.y * cos_a)
})
.collect()
}
/// Triangle shape (simple arrow pointing right)
pub fn triangle() -> Self {
Self {
vertices: vec![
vec2(15.0, 0.0), // Point
vec2(-10.0, -8.0), // Bottom left
vec2(-10.0, 8.0), // Top left
],
filled: true,
}
}
/// Classic turtle shape
pub fn turtle() -> Self {
// Based on the original turtle shape from turtle-lib
let polygon: &[[f32; 2]; 23] = &[
[-2.5, 14.0],
[-1.25, 10.0],
[-4.0, 7.0],
[-7.0, 9.0],
[-9.0, 8.0],
[-6.0, 5.0],
[-7.0, 1.0],
[-5.0, -3.0],
[-8.0, -6.0],
[-6.0, -8.0],
[-4.0, -5.0],
[0.0, -7.0],
[4.0, -5.0],
[6.0, -8.0],
[8.0, -6.0],
[5.0, -3.0],
[7.0, 1.0],
[6.0, 5.0],
[9.0, 8.0],
[7.0, 9.0],
[4.0, 7.0],
[1.25, 10.0],
[2.5, 14.0],
];
// Rotate by -90 degrees to point right (original points up)
let vertices: Vec<Vec2> = polygon
.iter()
.map(|[x, y]| {
let v = vec2(*x, *y);
let cos_a = (-PI / 2.0).cos();
let sin_a = (-PI / 2.0).sin();
vec2(v.x * cos_a - v.y * sin_a, v.x * sin_a + v.y * cos_a)
})
.collect();
Self {
vertices,
filled: true, // Now uses ear clipping for proper concave polygon rendering
}
}
/// Circle shape
pub fn circle() -> Self {
let segments = 16;
let radius = 10.0;
let vertices: Vec<Vec2> = (0..segments)
.map(|i| {
let angle = (i as f32 / segments as f32) * 2.0 * PI;
vec2(radius * angle.cos(), radius * angle.sin())
})
.collect();
Self {
vertices,
filled: true,
}
}
/// Square shape
pub fn square() -> Self {
Self {
vertices: vec![
vec2(8.0, 8.0),
vec2(-8.0, 8.0),
vec2(-8.0, -8.0),
vec2(8.0, -8.0),
],
filled: true,
}
}
/// Arrow shape (simple arrow pointing right)
pub fn arrow() -> Self {
Self {
vertices: vec![
vec2(12.0, 0.0), // Point
vec2(-8.0, 6.0), // Top back
vec2(-4.0, 0.0), // Middle back
vec2(-8.0, -6.0), // Bottom back
],
filled: true,
}
}
}
/// Pre-defined shape types
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ShapeType {
Triangle,
Turtle,
Circle,
Square,
Arrow,
}
impl ShapeType {
/// Get the corresponding TurtleShape
pub fn to_shape(&self) -> TurtleShape {
match self {
ShapeType::Triangle => TurtleShape::triangle(),
ShapeType::Turtle => TurtleShape::turtle(),
ShapeType::Circle => TurtleShape::circle(),
ShapeType::Square => TurtleShape::square(),
ShapeType::Arrow => TurtleShape::arrow(),
}
}
}
impl Default for ShapeType {
fn default() -> Self {
ShapeType::Turtle
}
}

View File

@ -0,0 +1,113 @@
//! Turtle state and world state management
use crate::general::{Angle, Color, Coordinate, Precision, Speed};
use crate::shapes::TurtleShape;
use macroquad::prelude::*;
/// State of a single turtle
#[derive(Clone, Debug)]
pub struct TurtleState {
pub position: Coordinate,
pub heading: Precision, // radians
pub pen_down: bool,
pub color: Color,
pub fill_color: Option<Color>,
pub pen_width: Precision,
pub speed: Speed,
pub visible: bool,
pub shape: TurtleShape,
}
impl Default for TurtleState {
fn default() -> Self {
Self {
position: vec2(0.0, 0.0),
heading: 0.0, // pointing right (0 radians)
pen_down: true,
color: BLACK,
fill_color: None,
pen_width: 2.0,
speed: 100, // pixels per second
visible: true,
shape: TurtleShape::turtle(),
}
}
}
impl TurtleState {
pub fn set_speed(&mut self, speed: Speed) {
self.speed = speed.max(1);
}
pub fn heading_angle(&self) -> Angle {
Angle::radians(self.heading)
}
}
/// Drawable elements in the world
#[derive(Clone, Debug)]
pub enum DrawCommand {
Line {
start: Coordinate,
end: Coordinate,
color: Color,
width: Precision,
},
Circle {
center: Coordinate,
radius: Precision,
color: Color,
filled: bool,
},
Arc {
center: Coordinate,
radius: Precision,
rotation: Precision, // Start angle in degrees
arc: Precision, // Arc extent in degrees
color: Color,
width: Precision,
sides: u8, // Number of segments for quality
},
FilledPolygon {
vertices: Vec<Coordinate>,
color: Color,
},
}
/// The complete turtle world containing all drawing state
pub struct TurtleWorld {
pub turtle: TurtleState,
pub commands: Vec<DrawCommand>,
pub camera: Camera2D,
pub background_color: Color,
}
impl TurtleWorld {
pub fn new() -> Self {
Self {
turtle: TurtleState::default(),
commands: Vec::new(),
camera: Camera2D {
zoom: vec2(1.0 / screen_width() * 2.0, 1.0 / screen_height() * 2.0),
target: vec2(0.0, 0.0),
..Default::default()
},
background_color: WHITE,
}
}
pub fn add_command(&mut self, cmd: DrawCommand) {
self.commands.push(cmd);
}
pub fn clear(&mut self) {
self.commands.clear();
self.turtle = TurtleState::default();
}
}
impl Default for TurtleWorld {
fn default() -> Self {
Self::new()
}
}

View File

@ -0,0 +1,354 @@
//! Tweening system for smooth animations
use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::commands::{CommandQueue, TurtleCommand};
use crate::state::TurtleState;
use macroquad::prelude::*;
use tween::{CubicInOut, TweenValue, Tweener};
// Newtype wrapper for Vec2 to implement TweenValue
#[derive(Debug, Clone, Copy)]
pub(crate) struct TweenVec2(Vec2);
impl TweenValue for TweenVec2 {
fn scale(self, scalar: f32) -> Self {
TweenVec2(self.0 * scalar)
}
}
impl std::ops::Add for TweenVec2 {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
TweenVec2(self.0 + rhs.0)
}
}
impl std::ops::Sub for TweenVec2 {
type Output = Self;
fn sub(self, rhs: Self) -> Self::Output {
TweenVec2(self.0 - rhs.0)
}
}
impl From<Vec2> for TweenVec2 {
fn from(v: Vec2) -> Self {
TweenVec2(v)
}
}
impl From<TweenVec2> for Vec2 {
fn from(v: TweenVec2) -> Self {
v.0
}
}
/// Controls tweening of turtle commands
pub struct TweenController {
queue: CommandQueue,
current_tween: Option<CommandTween>,
speed: f32, // pixels per second (or degrees per second for rotations)
}
pub(crate) struct CommandTween {
pub command: TurtleCommand,
pub start_time: f64,
pub duration: f64,
pub start_state: TurtleState,
pub target_state: TurtleState,
pub position_tweener: Tweener<TweenVec2, f32, CubicInOut>,
pub heading_tweener: Tweener<f32, f32, CubicInOut>,
pub pen_width_tweener: Tweener<f32, f32, CubicInOut>,
}
impl TweenController {
pub fn new(queue: CommandQueue, speed: f32) -> Self {
Self {
queue,
current_tween: None,
speed: speed.max(1.0),
}
}
pub fn set_speed(&mut self, speed: f32) {
self.speed = speed.max(1.0);
}
/// Update the tween, returns (command, start_state) if command completed
pub fn update(&mut self, state: &mut TurtleState) -> Option<(TurtleCommand, TurtleState)> {
// Process current tween
if let Some(ref mut tween) = self.current_tween {
let elapsed = (get_time() - tween.start_time) as f32;
// 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 {
TurtleCommand::CircleLeft { radius, angle, .. } => {
let angle_traveled = angle.to_radians() * progress;
calculate_circle_left_position(
tween.start_state.position,
tween.start_state.heading,
*radius,
angle_traveled,
)
}
TurtleCommand::CircleRight { radius, angle, .. } => {
let angle_traveled = angle.to_radians() * progress;
calculate_circle_right_position(
tween.start_state.position,
tween.start_state.heading,
*radius,
angle_traveled,
)
}
_ => {
// For non-circle commands, use normal position tweening
tween.position_tweener.move_to(elapsed).into()
}
};
// Heading changes proportionally with progress for all commands
state.heading = match &tween.command {
TurtleCommand::CircleLeft { angle, .. } => {
tween.start_state.heading - angle.to_radians() * progress
}
TurtleCommand::CircleRight { angle, .. } => {
tween.start_state.heading + angle.to_radians() * progress
}
TurtleCommand::Left(angle) => {
tween.start_state.heading - angle.to_radians() * progress
}
TurtleCommand::Right(angle) => {
tween.start_state.heading + angle.to_radians() * progress
}
TurtleCommand::SetHeading(_) | _ => {
// 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
}
};
state.pen_width = tween.pen_width_tweener.move_to(elapsed);
// Discrete properties (switch at 50% progress)
let progress = (elapsed / tween.duration as f32).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();
}
// Check if tween is finished (use heading_tweener as it's used by all commands)
if tween.heading_tweener.is_finished() {
// Tween complete, finalize state
let start_state = tween.start_state.clone();
*state = tween.target_state.clone();
// Return the completed command and start state to add draw commands
let completed_command = tween.command.clone();
self.current_tween = None;
// Only return command if it creates drawable elements
if Self::command_creates_drawing(&completed_command) {
return Some((completed_command, start_state));
}
}
return None;
}
// Start next tween
if let Some(command) = self.queue.next() {
let command_clone = command.clone();
let speed = state.speed; // Extract speed before borrowing self
let duration = self.calculate_duration(&command_clone, speed);
// Calculate target state
let target_state = self.calculate_target_state(state, &command_clone);
// Create tweeners for smooth animation
let position_tweener = Tweener::new(
TweenVec2::from(state.position),
TweenVec2::from(target_state.position),
duration as f32,
CubicInOut,
);
let heading_tweener = Tweener::new(
0.0, // We'll handle angle wrapping separately
1.0,
duration as f32,
CubicInOut,
);
let pen_width_tweener = Tweener::new(
state.pen_width,
target_state.pen_width,
duration as f32,
CubicInOut,
);
self.current_tween = Some(CommandTween {
command: command_clone,
start_time: get_time(),
duration,
start_state: state.clone(),
target_state,
position_tweener,
heading_tweener,
pen_width_tweener,
});
}
None
}
pub fn is_complete(&self) -> bool {
self.current_tween.is_none() && self.queue.is_complete()
}
/// Get the current active tween if one is in progress
pub(crate) fn current_tween(&self) -> Option<&CommandTween> {
self.current_tween.as_ref()
}
fn command_creates_drawing(command: &TurtleCommand) -> bool {
matches!(
command,
TurtleCommand::Forward(_)
| TurtleCommand::Backward(_)
| TurtleCommand::CircleLeft { .. }
| TurtleCommand::CircleRight { .. }
| TurtleCommand::Goto(_)
)
}
fn calculate_duration(&self, command: &TurtleCommand, speed: u32) -> f64 {
let speed = speed.max(1) as f32;
let base_time = match command {
TurtleCommand::Forward(dist) | TurtleCommand::Backward(dist) => dist.abs() / speed,
TurtleCommand::Left(angle) | TurtleCommand::Right(angle) => {
// Rotation speed: assume 180 degrees per second at speed 100
angle.abs() / (speed * 1.8)
}
TurtleCommand::CircleLeft { radius, angle, .. }
| TurtleCommand::CircleRight { radius, angle, .. } => {
let arc_length = radius * angle.to_radians().abs();
arc_length / speed
}
TurtleCommand::Goto(_target) => {
// Calculate distance (handled in calculate_target_state)
0.1 // Placeholder, will be calculated properly
}
_ => 0.0, // Instant commands
};
base_time.max(0.01) as f64 // Minimum duration
}
fn calculate_target_state(
&self,
current: &TurtleState,
command: &TurtleCommand,
) -> TurtleState {
let mut target = current.clone();
match command {
TurtleCommand::Forward(dist) => {
let dx = dist * current.heading.cos();
let dy = dist * current.heading.sin();
target.position = vec2(current.position.x + dx, current.position.y + dy);
}
TurtleCommand::Backward(dist) => {
let dx = -dist * current.heading.cos();
let dy = -dist * current.heading.sin();
target.position = vec2(current.position.x + dx, current.position.y + dy);
}
TurtleCommand::Left(angle) => {
target.heading -= angle.to_radians();
}
TurtleCommand::Right(angle) => {
target.heading += angle.to_radians();
}
TurtleCommand::CircleLeft { radius, angle, .. } => {
// Use helper function to calculate final position
target.position = calculate_circle_left_position(
current.position,
current.heading,
*radius,
angle.to_radians(),
);
target.heading = current.heading - angle.to_radians();
}
TurtleCommand::CircleRight { radius, angle, .. } => {
// Use helper function to calculate final position
target.position = calculate_circle_right_position(
current.position,
current.heading,
*radius,
angle.to_radians(),
);
target.heading = current.heading + angle.to_radians();
}
TurtleCommand::Goto(coord) => {
target.position = *coord;
}
TurtleCommand::SetHeading(heading) => {
target.heading = *heading;
}
TurtleCommand::SetColor(color) => {
target.color = *color;
}
TurtleCommand::SetPenWidth(width) => {
target.pen_width = *width;
}
TurtleCommand::SetSpeed(speed) => {
target.speed = *speed;
}
TurtleCommand::SetShape(shape) => {
target.shape = shape.clone();
}
TurtleCommand::PenUp => {
target.pen_down = false;
}
TurtleCommand::PenDown => {
target.pen_down = true;
}
TurtleCommand::ShowTurtle => {
target.visible = true;
}
TurtleCommand::HideTurtle => {
target.visible = false;
}
TurtleCommand::SetFillColor(color) => {
target.fill_color = *color;
}
}
target
}
}
/// Calculate position on a circular arc for circle_left
fn calculate_circle_left_position(
start_pos: Vec2,
start_heading: f32,
radius: f32,
angle_traveled: f32, // How much of the total angle we've traveled (in radians)
) -> Vec2 {
let geom = CircleGeometry::new(start_pos, start_heading, radius, CircleDirection::Left);
geom.position_at_angle(angle_traveled)
}
/// Calculate position on a circular arc for circle_right
fn calculate_circle_right_position(
start_pos: Vec2,
start_heading: f32,
radius: f32,
angle_traveled: f32, // How much of the total angle we've traveled (in radians)
) -> Vec2 {
let geom = CircleGeometry::new(start_pos, start_heading, radius, CircleDirection::Right);
geom.position_at_angle(angle_traveled)
}