remove the bevy based turtle and rename turtle-lib-macroquad to turtle-lib
This commit is contained in:
@@ -0,0 +1,673 @@
|
||||
//! Builder pattern traits for creating turtle command sequences
|
||||
|
||||
use crate::commands::{CommandQueue, TurtleCommand};
|
||||
use crate::general::{AnimationSpeed, Color, Coordinate, 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 {
|
||||
/// Moves the turtle forward by the specified distance.
|
||||
///
|
||||
/// The turtle moves in the direction of its current heading.
|
||||
/// If the pen is down, a line is drawn.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Forward Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// // Move forward 100 pixels
|
||||
/// turtle.forward(100.0);
|
||||
///
|
||||
/// // Chain movements
|
||||
/// turtle.forward(50.0).right(90.0).forward(50.0);
|
||||
/// }
|
||||
/// ```
|
||||
fn forward<T>(&mut self, distance: T) -> &mut Self
|
||||
where
|
||||
T: Into<Precision>,
|
||||
{
|
||||
let dist: Precision = distance.into();
|
||||
self.get_commands_mut().push(TurtleCommand::Move(dist));
|
||||
self
|
||||
}
|
||||
|
||||
/// Moves the turtle backward by the specified distance.
|
||||
///
|
||||
/// The turtle moves opposite to its current heading without changing
|
||||
/// the heading direction. If the pen is down, a line is drawn.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Backward Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// // Move backward 100 pixels
|
||||
/// turtle.backward(100.0);
|
||||
///
|
||||
/// // Draw a line forward, then retrace backward
|
||||
/// turtle.forward(100.0).backward(50.0);
|
||||
/// }
|
||||
/// ```
|
||||
fn backward<T>(&mut self, distance: T) -> &mut Self
|
||||
where
|
||||
T: Into<Precision>,
|
||||
{
|
||||
let dist: Precision = distance.into();
|
||||
self.get_commands_mut().push(TurtleCommand::Move(-dist));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for turning operations
|
||||
pub trait Turnable: WithCommands {
|
||||
/// Turns the turtle left (counter-clockwise) by the specified angle in degrees.
|
||||
///
|
||||
/// Changes the turtle's heading without moving its position.
|
||||
/// Does not draw anything.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Left Turn Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// // Draw a square using left turns
|
||||
/// for _ in 0..4 {
|
||||
/// turtle.forward(100.0).left(90.0);
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
fn left<T>(&mut self, angle: T) -> &mut Self
|
||||
where
|
||||
T: Into<Precision>,
|
||||
{
|
||||
let degrees: Precision = angle.into();
|
||||
self.get_commands_mut().push(TurtleCommand::Turn(-degrees));
|
||||
self
|
||||
}
|
||||
|
||||
/// Turns the turtle right (clockwise) by the specified angle in degrees.
|
||||
///
|
||||
/// Changes the turtle's heading without moving its position.
|
||||
/// Does not draw anything.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Right Turn Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// // Draw a triangle using right turns
|
||||
/// for _ in 0..3 {
|
||||
/// turtle.forward(100.0).right(120.0);
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
fn right<T>(&mut self, angle: T) -> &mut Self
|
||||
where
|
||||
T: Into<Precision>,
|
||||
{
|
||||
let degrees: Precision = angle.into();
|
||||
self.get_commands_mut().push(TurtleCommand::Turn(degrees));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for curved movement (circles)
|
||||
pub trait CurvedMovement: WithCommands {
|
||||
/// Draws a circular arc turning to the left (counter-clockwise).
|
||||
///
|
||||
/// The turtle draws a circular arc with the specified radius, sweeping through
|
||||
/// the given angle. The circle center is positioned to the left of the turtle.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// - `radius`: Distance from turtle to circle center (in pixels)
|
||||
/// - `angle`: Arc sweep angle in degrees (360° = full circle)
|
||||
/// - `steps`: Number of line segments to approximate the arc (more = smoother)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Circle Left Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// // Draw a full circle
|
||||
/// turtle.circle_left(50.0, 360.0, 36);
|
||||
///
|
||||
/// // Filled circle
|
||||
/// turtle.pen_up().go_to(vec2(100.0, 0.0)).pen_down();
|
||||
/// turtle.set_fill_color(RED)
|
||||
/// .begin_fill()
|
||||
/// .circle_left(50.0, 360.0, 72)
|
||||
/// .end_fill();
|
||||
/// }
|
||||
/// ```
|
||||
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::Circle {
|
||||
radius: r,
|
||||
angle: a,
|
||||
steps,
|
||||
direction: crate::circle_geometry::CircleDirection::Left,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Draws a circular arc turning to the right (clockwise).
|
||||
///
|
||||
/// The turtle draws a circular arc with the specified radius, sweeping through
|
||||
/// the given angle. The circle center is positioned to the right of the turtle.
|
||||
///
|
||||
/// # Parameters
|
||||
///
|
||||
/// - `radius`: Distance from turtle to circle center (in pixels)
|
||||
/// - `angle`: Arc sweep angle in degrees (360° = full circle)
|
||||
/// - `steps`: Number of line segments to approximate the arc (more = smoother)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Circle Right Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// // Draw an S-curve using both directions
|
||||
/// turtle.circle_left(50.0, 180.0, 36)
|
||||
/// .circle_right(50.0, 180.0, 36);
|
||||
///
|
||||
/// // Yin-yang pattern uses circle_left and circle_right
|
||||
/// turtle.set_fill_color(BLACK)
|
||||
/// .begin_fill()
|
||||
/// .circle_right(100.0, 180.0, 36)
|
||||
/// .circle_right(50.0, 180.0, 36)
|
||||
/// .circle_left(50.0, 180.0, 36)
|
||||
/// .end_fill();
|
||||
/// }
|
||||
/// ```
|
||||
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::Circle {
|
||||
radius: r,
|
||||
angle: a,
|
||||
steps,
|
||||
direction: crate::circle_geometry::CircleDirection::Right,
|
||||
});
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for creating turtle command sequences
|
||||
#[derive(Default, Debug)]
|
||||
pub struct TurtlePlan {
|
||||
queue: CommandQueue,
|
||||
}
|
||||
|
||||
impl TurtlePlan {
|
||||
/// Creates a new empty turtle command plan.
|
||||
///
|
||||
/// This has to be used when not using the `turtle_main` macro.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// use turtle_lib::*;
|
||||
/// use macroquad::prelude::*;
|
||||
///
|
||||
/// #[macroquad::main("Manual Setup")]
|
||||
/// async fn main() {
|
||||
/// let mut turtle = TurtlePlan::new();
|
||||
/// turtle.forward(100.0).right(90.0).forward(100.0);
|
||||
///
|
||||
/// let mut app = TurtleApp::new().with_commands(turtle.build());
|
||||
///
|
||||
/// loop {
|
||||
/// clear_background(WHITE);
|
||||
/// app.update();
|
||||
/// app.render();
|
||||
///
|
||||
/// if is_key_pressed(KeyCode::Escape) || is_key_pressed(KeyCode::Q) {
|
||||
/// break;
|
||||
/// }
|
||||
/// next_frame().await;
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
queue: CommandQueue::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the animation speed for turtle movements.
|
||||
///
|
||||
/// Speed controls how fast the turtle moves during animations:
|
||||
/// - Values `>= 1000`: Instant mode - commands execute immediately without animation.
|
||||
/// The bigger the number, the more segments are drawn per frame.
|
||||
/// - Values `< 1000`: Animated mode - turtle moves at specified pixels per second
|
||||
///
|
||||
/// You can dynamically switch between instant and animated modes during execution.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Speed Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// // Slow animation at 50 pixels/second
|
||||
/// turtle.set_speed(50.0)
|
||||
/// .forward(100.0);
|
||||
///
|
||||
/// // Switch to instant mode
|
||||
/// turtle.set_speed(1000.0)
|
||||
/// .forward(100.0); // Executes immediately
|
||||
/// }
|
||||
/// ```
|
||||
pub fn set_speed(&mut self, speed: impl Into<AnimationSpeed>) -> &mut Self {
|
||||
self.queue.push(TurtleCommand::SetSpeed(speed.into()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the pen color for drawing lines.
|
||||
///
|
||||
/// The pen color affects all subsequent drawing operations (forward, backward, circles)
|
||||
/// until changed again. Does not affect fill color.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Pen Color Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// // Draw with predefined colors
|
||||
/// turtle.set_pen_color(RED)
|
||||
/// .forward(100.0)
|
||||
/// .set_pen_color(BLUE)
|
||||
/// .right(90.0)
|
||||
/// .forward(100.0);
|
||||
/// }
|
||||
/// ```
|
||||
pub fn set_pen_color(&mut self, color: Color) -> &mut Self {
|
||||
self.queue.push(TurtleCommand::SetColor(color));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the pen width (thickness) for drawing lines.
|
||||
///
|
||||
/// The width is measured in pixels. Default is typically 2.0.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Pen Width Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// // Thin line
|
||||
/// turtle.set_pen_width(1.0)
|
||||
/// .forward(100.0);
|
||||
///
|
||||
/// // Thick line
|
||||
/// turtle.set_pen_width(10.0)
|
||||
/// .forward(100.0);
|
||||
/// }
|
||||
/// ```
|
||||
pub fn set_pen_width(&mut self, width: Precision) -> &mut Self {
|
||||
self.queue.push(TurtleCommand::SetPenWidth(width));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the turtle's absolute heading direction in degrees.
|
||||
///
|
||||
/// - `0°` points to the right (east)
|
||||
/// - `90°` points up (north)
|
||||
/// - `180°` points left (west)
|
||||
/// - `270°` points down (south)
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Heading Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// // Point upward
|
||||
/// turtle.set_heading(90.0)
|
||||
/// .forward(100.0);
|
||||
///
|
||||
/// // Point left
|
||||
/// turtle.set_heading(180.0)
|
||||
/// .forward(100.0);
|
||||
/// }
|
||||
/// ```
|
||||
pub fn set_heading(&mut self, heading: Precision) -> &mut Self {
|
||||
self.queue.push(TurtleCommand::SetHeading(heading));
|
||||
self
|
||||
}
|
||||
|
||||
/// Lifts the pen up so the turtle can move without drawing.
|
||||
///
|
||||
/// When filling shapes, `pen_up()` also closes the current contour,
|
||||
/// allowing you to create multi-contour fills (e.g., shapes with holes).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Pen Up/Down Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// // Move without drawing
|
||||
/// turtle.pen_up()
|
||||
/// .forward(100.0) // No line drawn
|
||||
/// .pen_down()
|
||||
/// .forward(100.0); // Line drawn
|
||||
///
|
||||
/// // Create a donut shape (outer circle with inner hole)
|
||||
/// turtle.set_fill_color(BLUE)
|
||||
/// .begin_fill()
|
||||
/// .circle_left(100.0, 360.0, 72) // Outer circle
|
||||
/// .pen_up() // Close first contour
|
||||
/// .go_to(vec2(0.0, -30.0))
|
||||
/// .pen_down() // Start second contour
|
||||
/// .circle_left(30.0, 360.0, 36) // Inner circle (becomes hole)
|
||||
/// .end_fill();
|
||||
/// }
|
||||
/// ```
|
||||
pub fn pen_up(&mut self) -> &mut Self {
|
||||
self.queue.push(TurtleCommand::PenUp);
|
||||
self
|
||||
}
|
||||
|
||||
/// Lowers the pen so the turtle draws when moving.
|
||||
///
|
||||
/// This is the default state. When filling shapes, `pen_down()` starts
|
||||
/// a new contour after `pen_up()` was called.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Pen Down Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// turtle.pen_up()
|
||||
/// .forward(50.0) // Move without drawing
|
||||
/// .pen_down() // Start drawing
|
||||
/// .forward(100.0); // Line appears
|
||||
/// }
|
||||
/// ```
|
||||
pub fn pen_down(&mut self) -> &mut Self {
|
||||
self.queue.push(TurtleCommand::PenDown);
|
||||
self
|
||||
}
|
||||
|
||||
/// Hides the turtle cursor from view.
|
||||
///
|
||||
/// The turtle will still execute commands and draw, but the cursor
|
||||
/// (typically an arrow or triangle) won't be visible.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Hide Turtle Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// turtle.hide() // Turtle cursor invisible
|
||||
/// .forward(100.0)
|
||||
/// .right(90.0)
|
||||
/// .forward(100.0);
|
||||
/// }
|
||||
/// ```
|
||||
pub fn hide(&mut self) -> &mut Self {
|
||||
self.queue.push(TurtleCommand::HideTurtle);
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows the turtle cursor.
|
||||
///
|
||||
/// Makes the turtle cursor visible if it was previously hidden.
|
||||
/// This is the default state.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Show Turtle Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// turtle.hide()
|
||||
/// .forward(100.0)
|
||||
/// .show() // Turtle becomes visible again
|
||||
/// .forward(100.0);
|
||||
/// }
|
||||
/// ```
|
||||
pub fn show(&mut self) -> &mut Self {
|
||||
self.queue.push(TurtleCommand::ShowTurtle);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the turtle's shape using a `TurtleShape` object.
|
||||
///
|
||||
/// For most use cases, prefer using `shape()` which accepts a `ShapeType` enum.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Shape Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// let custom_shape = ShapeType::Arrow.to_shape();
|
||||
/// turtle.set_shape(custom_shape);
|
||||
/// }
|
||||
/// ```
|
||||
pub fn set_shape(&mut self, shape: TurtleShape) -> &mut Self {
|
||||
self.queue.push(TurtleCommand::SetShape(shape));
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the turtle's visual appearance.
|
||||
///
|
||||
/// Available shapes: `Arrow`, `Triangle`, `Square`, `Circle`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Shape Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// // Use different shapes
|
||||
/// turtle.shape(ShapeType::Arrow)
|
||||
/// .forward(50.0)
|
||||
/// .shape(ShapeType::Circle)
|
||||
/// .forward(50.0);
|
||||
/// }
|
||||
/// ```
|
||||
pub fn shape(&mut self, shape_type: ShapeType) -> &mut Self {
|
||||
self.set_shape(shape_type.to_shape())
|
||||
}
|
||||
|
||||
/// Starts recording a shape to be filled.
|
||||
///
|
||||
/// All turtle movements between `begin_fill()` and `end_fill()` define
|
||||
/// the shape's outline. The shape is filled using the fill color when
|
||||
/// `end_fill()` is called.
|
||||
///
|
||||
/// Multiple contours can be created using `pen_up()` and `pen_down()`.
|
||||
/// The `EvenOdd` fill rule automatically creates holes for inner contours.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Fill Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// // Fill a square
|
||||
/// turtle.set_fill_color(BLUE)
|
||||
/// .begin_fill();
|
||||
/// for _ in 0..4 {
|
||||
/// turtle.forward(100.0).right(90.0);
|
||||
/// }
|
||||
/// turtle.end_fill();
|
||||
///
|
||||
/// // Fill a circle
|
||||
/// turtle.pen_up().go_to(vec2(150.0, 0.0)).pen_down();
|
||||
/// turtle.set_fill_color(RED)
|
||||
/// .begin_fill()
|
||||
/// .circle_left(50.0, 360.0, 36)
|
||||
/// .end_fill();
|
||||
/// }
|
||||
/// ```
|
||||
pub fn begin_fill(&mut self) -> &mut Self {
|
||||
self.queue.push(TurtleCommand::BeginFill);
|
||||
self
|
||||
}
|
||||
|
||||
/// Completes the fill operation started with `begin_fill()`.
|
||||
///
|
||||
/// Closes the current shape and fills it with the fill color.
|
||||
/// All contours recorded since `begin_fill()` are filled together.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("End Fill Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// // Triangle with fill
|
||||
/// turtle.set_fill_color(GREEN)
|
||||
/// .begin_fill();
|
||||
/// for _ in 0..3 {
|
||||
/// turtle.forward(100.0).right(120.0);
|
||||
/// }
|
||||
/// turtle.end_fill();
|
||||
/// }
|
||||
/// ```
|
||||
pub fn end_fill(&mut self) -> &mut Self {
|
||||
self.queue.push(TurtleCommand::EndFill);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the color used to fill shapes.
|
||||
///
|
||||
/// This affects all shapes filled with `begin_fill()`/`end_fill()`.
|
||||
/// Independent from the pen color used for outlines.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Fill Color Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// // Yellow fill with blue outline
|
||||
/// turtle.set_fill_color(YELLOW)
|
||||
/// .set_pen_color(BLUE)
|
||||
/// .begin_fill()
|
||||
/// .circle_left(50.0, 360.0, 36)
|
||||
/// .end_fill();
|
||||
/// }
|
||||
/// ```
|
||||
pub fn set_fill_color(&mut self, color: impl Into<Color>) -> &mut Self {
|
||||
self.queue
|
||||
.push(TurtleCommand::SetFillColor(Some(color.into())));
|
||||
self
|
||||
}
|
||||
|
||||
/// Moves the turtle to an absolute position.
|
||||
///
|
||||
/// The turtle moves in a straight line to the specified coordinates.
|
||||
/// If the pen is down, a line is drawn. The turtle's heading is not changed.
|
||||
///
|
||||
/// Coordinates are in screen space:
|
||||
/// - `(0, 0)` is at the center
|
||||
/// - Positive x goes right
|
||||
/// - Positive y goes down
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// #[turtle_main("Goto Example")]
|
||||
/// fn draw(turtle: &mut TurtlePlan) {
|
||||
/// // Draw a triangle by connecting points
|
||||
/// turtle.go_to(vec2(0.0, 0.0));
|
||||
/// turtle.go_to(vec2(100.0, 0.0));
|
||||
/// turtle.go_to(vec2(50.0, 86.6));
|
||||
/// turtle.go_to(vec2(0.0, 0.0));
|
||||
/// }
|
||||
/// ```
|
||||
pub fn go_to(&mut self, coord: impl Into<Coordinate>) -> &mut Self {
|
||||
self.queue.push(TurtleCommand::Goto(coord.into()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Consumes the `TurtlePlan` and returns the command queue.
|
||||
///
|
||||
/// Use this to finalize the turtle commands and pass them to `TurtleApp`.
|
||||
/// This method consumes `self`, so the plan cannot be used afterward.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// # use turtle_lib::*;
|
||||
/// #
|
||||
/// let mut turtle = TurtlePlan::new();
|
||||
/// turtle.forward(100.0).right(90.0).forward(100.0);
|
||||
///
|
||||
/// // Build and get the command queue
|
||||
/// let commands = turtle.build();
|
||||
/// # assert!(!commands.is_empty());
|
||||
/// ```
|
||||
#[must_use]
|
||||
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 {}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! 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
|
||||
#[must_use]
|
||||
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
|
||||
#[must_use]
|
||||
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`
|
||||
#[must_use]
|
||||
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
|
||||
#[must_use]
|
||||
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`
|
||||
#[must_use]
|
||||
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`
|
||||
#[must_use]
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Turtle commands and command queue
|
||||
|
||||
use crate::general::{AnimationSpeed, Color, Coordinate, Precision};
|
||||
use crate::shapes::TurtleShape;
|
||||
|
||||
/// Individual turtle commands
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TurtleCommand {
|
||||
// Movement (positive = forward, negative = backward)
|
||||
Move(Precision),
|
||||
|
||||
// Rotation (positive = right/clockwise, negative = left/counter-clockwise in degrees)
|
||||
Turn(Precision),
|
||||
|
||||
// Circle drawing
|
||||
Circle {
|
||||
radius: Precision,
|
||||
angle: Precision, // degrees
|
||||
steps: usize,
|
||||
direction: crate::circle_geometry::CircleDirection,
|
||||
},
|
||||
|
||||
// Pen control
|
||||
PenUp,
|
||||
PenDown,
|
||||
|
||||
// Appearance
|
||||
SetColor(Color),
|
||||
SetFillColor(Option<Color>),
|
||||
SetPenWidth(Precision),
|
||||
SetSpeed(AnimationSpeed),
|
||||
SetShape(TurtleShape),
|
||||
|
||||
// Position
|
||||
Goto(Coordinate),
|
||||
SetHeading(Precision), // radians
|
||||
|
||||
// Visibility
|
||||
ShowTurtle,
|
||||
HideTurtle,
|
||||
|
||||
// Fill operations
|
||||
BeginFill,
|
||||
EndFill,
|
||||
}
|
||||
|
||||
/// Queue of turtle commands with execution state
|
||||
#[derive(Debug)]
|
||||
pub struct CommandQueue {
|
||||
commands: Vec<TurtleCommand>,
|
||||
current_index: usize,
|
||||
}
|
||||
|
||||
impl CommandQueue {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
commands: Vec::new(),
|
||||
current_index: 0,
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
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);
|
||||
}
|
||||
#[must_use]
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.current_index >= self.commands.len()
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.current_index = 0;
|
||||
}
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.commands.len()
|
||||
}
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.commands.is_empty()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.commands.len().saturating_sub(self.current_index)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CommandQueue {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for CommandQueue {
|
||||
type Item = TurtleCommand;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.current_index < self.commands.len() {
|
||||
let cmd = self.commands[self.current_index].clone();
|
||||
self.current_index += 1;
|
||||
Some(cmd)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Rendering logic using Macroquad and Lyon tessellation
|
||||
|
||||
use crate::circle_geometry::{CircleDirection, CircleGeometry};
|
||||
use crate::state::{DrawCommand, TurtleState, TurtleWorld};
|
||||
use crate::tessellation;
|
||||
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 function 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::Mesh(mesh_data) => {
|
||||
draw_mesh(&mesh_data.to_mesh());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
#[allow(clippy::too_many_lines)]
|
||||
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::Mesh(mesh_data) => {
|
||||
draw_mesh(&mesh_data.to_mesh());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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::Circle {
|
||||
radius,
|
||||
angle,
|
||||
steps,
|
||||
direction,
|
||||
} => {
|
||||
// Draw arc segments from start to current position
|
||||
draw_tween_arc(tween, *radius, *angle, *steps, *direction);
|
||||
}
|
||||
_ if should_draw_tween_line(&tween.command) => {
|
||||
// Draw straight line for other movement commands
|
||||
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 live fill preview if currently filling (always show, not just during tweens)
|
||||
if let Some(ref fill_state) = world.turtle.filling {
|
||||
// Build all contours: completed contours + current contour with animation
|
||||
let mut all_contours: Vec<Vec<Vec2>> = Vec::new();
|
||||
|
||||
// Add all completed contours
|
||||
for completed_contour in &fill_state.contours {
|
||||
let contour_vec2: Vec<Vec2> = completed_contour
|
||||
.iter()
|
||||
.map(|c| Vec2::new(c.x, c.y))
|
||||
.collect();
|
||||
all_contours.push(contour_vec2);
|
||||
}
|
||||
|
||||
// Build current contour with animation
|
||||
let mut current_preview: Vec<Vec2> = fill_state
|
||||
.current_contour
|
||||
.iter()
|
||||
.map(|c| Vec2::new(c.x, c.y))
|
||||
.collect();
|
||||
|
||||
// If we have an active tween, add progressive vertices
|
||||
if let Some(tween) = active_tween {
|
||||
// If we're animating a circle command with pen down, add arc vertices
|
||||
if tween.start_state.pen_down {
|
||||
if let crate::commands::TurtleCommand::Circle {
|
||||
radius,
|
||||
angle,
|
||||
steps,
|
||||
direction,
|
||||
} = &tween.command
|
||||
{
|
||||
// Calculate partial arc vertices based on current progress
|
||||
use crate::circle_geometry::CircleGeometry;
|
||||
let geom = CircleGeometry::new(
|
||||
tween.start_state.position,
|
||||
tween.start_state.heading,
|
||||
*radius,
|
||||
*direction,
|
||||
);
|
||||
|
||||
// Calculate progress
|
||||
let elapsed = get_time() - tween.start_time;
|
||||
let progress = (elapsed / tween.duration).min(1.0);
|
||||
let eased_progress = CubicInOut.tween(1.0, progress as f32);
|
||||
|
||||
// Generate arc vertices for the partial arc
|
||||
let num_samples = *steps.max(&1);
|
||||
let samples_to_draw = ((num_samples as f32 * eased_progress) as usize).max(1);
|
||||
|
||||
for i in 1..=samples_to_draw {
|
||||
let sample_progress = i as f32 / num_samples as f32;
|
||||
let current_angle = match direction {
|
||||
crate::circle_geometry::CircleDirection::Left => {
|
||||
geom.start_angle_from_center - angle.to_radians() * sample_progress
|
||||
}
|
||||
crate::circle_geometry::CircleDirection::Right => {
|
||||
geom.start_angle_from_center + angle.to_radians() * sample_progress
|
||||
}
|
||||
};
|
||||
|
||||
let vertex = Vec2::new(
|
||||
geom.center.x + radius * current_angle.cos(),
|
||||
geom.center.y + radius * current_angle.sin(),
|
||||
);
|
||||
current_preview.push(vertex);
|
||||
}
|
||||
} else if matches!(
|
||||
&tween.command,
|
||||
crate::commands::TurtleCommand::Move(_)
|
||||
| crate::commands::TurtleCommand::Goto(_)
|
||||
) {
|
||||
// For Move/Goto commands, just add the current position
|
||||
current_preview
|
||||
.push(Vec2::new(world.turtle.position.x, world.turtle.position.y));
|
||||
}
|
||||
} else if matches!(
|
||||
&tween.command,
|
||||
crate::commands::TurtleCommand::Move(_) | crate::commands::TurtleCommand::Goto(_)
|
||||
) {
|
||||
// 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));
|
||||
}
|
||||
|
||||
// Add current turtle position if not already included
|
||||
if let Some(last) = current_preview.last() {
|
||||
let current_pos = world.turtle.position;
|
||||
// Use a larger threshold to reduce flickering from tiny movements
|
||||
if (last.x - current_pos.x).abs() > 0.1 || (last.y - current_pos.y).abs() > 0.1 {
|
||||
current_preview.push(Vec2::new(current_pos.x, current_pos.y));
|
||||
}
|
||||
} else if !current_preview.is_empty() {
|
||||
current_preview.push(Vec2::new(world.turtle.position.x, world.turtle.position.y));
|
||||
}
|
||||
} else {
|
||||
// No active tween - just show current state
|
||||
if !current_preview.is_empty() {
|
||||
if let Some(last) = current_preview.last() {
|
||||
let current_pos = world.turtle.position;
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add current contour to all contours if it has enough vertices
|
||||
if current_preview.len() >= 3 {
|
||||
all_contours.push(current_preview);
|
||||
}
|
||||
|
||||
// Tessellate and draw all contours together using multi-contour tessellation
|
||||
if !all_contours.is_empty() {
|
||||
match crate::tessellation::tessellate_multi_contour(
|
||||
&all_contours,
|
||||
fill_state.fill_color,
|
||||
) {
|
||||
Ok(mesh_data) => {
|
||||
draw_mesh(&mesh_data.to_mesh());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = ?e,
|
||||
"Lyon multi-contour tessellation error for fill preview"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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::Move(..) | TurtleCommand::Goto(..))
|
||||
}
|
||||
|
||||
/// Draw arc segments for circle tween animation
|
||||
fn draw_tween_arc(
|
||||
tween: &crate::tweening::CommandTween,
|
||||
radius: f32,
|
||||
total_angle: f32,
|
||||
steps: usize,
|
||||
direction: CircleDirection,
|
||||
) {
|
||||
let geom = CircleGeometry::new(
|
||||
tween.start_state.position,
|
||||
tween.start_state.heading,
|
||||
radius,
|
||||
direction,
|
||||
);
|
||||
|
||||
// Debug: draw center using Lyon tessellation
|
||||
if let Ok(mesh_data) = crate::tessellation::tessellate_circle(geom.center, 5.0, GRAY, true, 1.0)
|
||||
{
|
||||
draw_mesh(&mesh_data.to_mesh());
|
||||
}
|
||||
|
||||
// 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;
|
||||
let t = (elapsed / tween.duration).min(1.0);
|
||||
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
|
||||
if let Ok(mesh_data) = crate::tessellation::tessellate_arc(
|
||||
geom.center,
|
||||
radius,
|
||||
rotation_degrees,
|
||||
arc_degrees,
|
||||
tween.start_state.color,
|
||||
tween.start_state.pen_width,
|
||||
steps,
|
||||
) {
|
||||
draw_mesh(&mesh_data.to_mesh());
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 using Lyon tessellation
|
||||
if rotated_vertices.len() >= 3 {
|
||||
let absolute_vertices: Vec<Vec2> = rotated_vertices
|
||||
.iter()
|
||||
.map(|v| turtle.position + *v)
|
||||
.collect();
|
||||
|
||||
// Use Lyon for turtle shape too
|
||||
if let Ok(mesh_data) =
|
||||
tessellation::tessellate_polygon(&absolute_vertices, Color::new(0.0, 0.5, 1.0, 1.0))
|
||||
{
|
||||
draw_mesh(&mesh_data.to_mesh());
|
||||
} else {
|
||||
// Fallback to simple triangle fan if Lyon fails
|
||||
let first = absolute_vertices[0];
|
||||
for i in 1..absolute_vertices.len() - 1 {
|
||||
draw_triangle(
|
||||
first,
|
||||
absolute_vertices[i],
|
||||
absolute_vertices[i + 1],
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//! Command execution logic
|
||||
|
||||
use crate::circle_geometry::{CircleDirection, CircleGeometry};
|
||||
use crate::commands::TurtleCommand;
|
||||
use crate::state::{DrawCommand, TurtleState, TurtleWorld};
|
||||
use crate::tessellation;
|
||||
use macroquad::prelude::*;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::general::AnimationSpeed;
|
||||
|
||||
/// Execute side effects for commands that don't involve movement
|
||||
/// Returns true if the command was handled (caller should skip movement processing)
|
||||
pub fn execute_command_side_effects(
|
||||
command: &TurtleCommand,
|
||||
state: &mut TurtleState,
|
||||
commands: &mut Vec<DrawCommand>,
|
||||
) -> bool {
|
||||
match command {
|
||||
TurtleCommand::BeginFill => {
|
||||
if state.filling.is_some() {
|
||||
tracing::warn!("begin_fill() called while already filling");
|
||||
}
|
||||
let fill_color = state.fill_color.unwrap_or_else(|| {
|
||||
tracing::warn!("No fill_color set, using black");
|
||||
BLACK
|
||||
});
|
||||
state.begin_fill(fill_color);
|
||||
true
|
||||
}
|
||||
|
||||
TurtleCommand::EndFill => {
|
||||
if let Some(mut fill_state) = state.filling.take() {
|
||||
if !fill_state.current_contour.is_empty() {
|
||||
fill_state.contours.push(fill_state.current_contour);
|
||||
}
|
||||
|
||||
let span = tracing::debug_span!("end_fill", contours = fill_state.contours.len());
|
||||
let _enter = span.enter();
|
||||
|
||||
for (i, contour) in fill_state.contours.iter().enumerate() {
|
||||
tracing::debug!(contour_idx = i, vertices = contour.len(), "Contour info");
|
||||
}
|
||||
|
||||
if !fill_state.contours.is_empty() {
|
||||
if let Ok(mesh_data) = tessellation::tessellate_multi_contour(
|
||||
&fill_state.contours,
|
||||
fill_state.fill_color,
|
||||
) {
|
||||
tracing::debug!(
|
||||
contours = fill_state.contours.len(),
|
||||
"Successfully tessellated contours"
|
||||
);
|
||||
commands.push(DrawCommand::Mesh(mesh_data));
|
||||
} else {
|
||||
tracing::error!("Failed to tessellate contours");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("end_fill() called without begin_fill()");
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
TurtleCommand::PenUp => {
|
||||
state.pen_down = false;
|
||||
if state.filling.is_some() {
|
||||
tracing::debug!("PenUp: Closing current contour");
|
||||
}
|
||||
state.close_fill_contour();
|
||||
true
|
||||
}
|
||||
|
||||
TurtleCommand::PenDown => {
|
||||
state.pen_down = true;
|
||||
if state.filling.is_some() {
|
||||
tracing::debug!(
|
||||
x = state.position.x,
|
||||
y = state.position.y,
|
||||
"PenDown: Starting new contour"
|
||||
);
|
||||
}
|
||||
state.start_fill_contour();
|
||||
true
|
||||
}
|
||||
|
||||
_ => false, // Not a side-effect-only command
|
||||
}
|
||||
}
|
||||
|
||||
/// Record fill vertices after movement commands have updated state
|
||||
pub fn record_fill_vertices_after_movement(
|
||||
command: &TurtleCommand,
|
||||
start_state: &TurtleState,
|
||||
state: &mut TurtleState,
|
||||
) {
|
||||
if state.filling.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
match command {
|
||||
TurtleCommand::Circle {
|
||||
radius,
|
||||
angle,
|
||||
steps,
|
||||
direction,
|
||||
} => {
|
||||
let geom = CircleGeometry::new(
|
||||
start_state.position,
|
||||
start_state.heading,
|
||||
*radius,
|
||||
*direction,
|
||||
);
|
||||
state.record_fill_vertices_for_arc(
|
||||
geom.center,
|
||||
*radius,
|
||||
geom.start_angle_from_center,
|
||||
angle.to_radians(),
|
||||
*direction,
|
||||
*steps as u32,
|
||||
);
|
||||
}
|
||||
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
|
||||
state.record_fill_vertex();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a single turtle command, updating state and adding draw commands
|
||||
pub fn execute_command(command: &TurtleCommand, state: &mut TurtleState, world: &mut TurtleWorld) {
|
||||
// Try to execute as side-effect-only command first
|
||||
if execute_command_side_effects(command, state, &mut world.commands) {
|
||||
return; // Command fully handled
|
||||
}
|
||||
|
||||
// Store start state for fill vertex recording
|
||||
let start_state = state.clone();
|
||||
|
||||
// Execute movement and appearance commands
|
||||
match command {
|
||||
TurtleCommand::Move(distance) => {
|
||||
let start = state.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 {
|
||||
// Draw line segment with round caps (caps handled by tessellate_stroke)
|
||||
if let Ok(mesh_data) = tessellation::tessellate_stroke(
|
||||
&[start, state.position],
|
||||
state.color,
|
||||
state.pen_width,
|
||||
false, // not closed
|
||||
) {
|
||||
world.add_command(DrawCommand::Mesh(mesh_data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TurtleCommand::Turn(degrees) => {
|
||||
state.heading += degrees.to_radians();
|
||||
}
|
||||
|
||||
TurtleCommand::Circle {
|
||||
radius,
|
||||
angle,
|
||||
steps,
|
||||
direction,
|
||||
} => {
|
||||
let start_heading = state.heading;
|
||||
let geom = CircleGeometry::new(state.position, start_heading, *radius, *direction);
|
||||
|
||||
if state.pen_down {
|
||||
let (rotation_degrees, arc_degrees) = geom.draw_arc_params(*angle);
|
||||
|
||||
// Use Lyon to tessellate the arc
|
||||
if let Ok(mesh_data) = tessellation::tessellate_arc(
|
||||
geom.center,
|
||||
*radius,
|
||||
rotation_degrees,
|
||||
arc_degrees,
|
||||
state.color,
|
||||
state.pen_width,
|
||||
*steps,
|
||||
) {
|
||||
world.add_command(DrawCommand::Mesh(mesh_data));
|
||||
}
|
||||
}
|
||||
|
||||
// Update turtle position and heading
|
||||
state.position = geom.position_at_angle(angle.to_radians());
|
||||
state.heading = match direction {
|
||||
CircleDirection::Left => start_heading - angle.to_radians(),
|
||||
CircleDirection::Right => start_heading + angle.to_radians(),
|
||||
};
|
||||
}
|
||||
|
||||
TurtleCommand::Goto(coord) => {
|
||||
let start = state.position;
|
||||
state.position = *coord;
|
||||
|
||||
if state.pen_down {
|
||||
// Draw line segment with round caps
|
||||
if let Ok(mesh_data) = tessellation::tessellate_stroke(
|
||||
&[start, state.position],
|
||||
state.color,
|
||||
state.pen_width,
|
||||
false, // not closed
|
||||
) {
|
||||
world.add_command(DrawCommand::Mesh(mesh_data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Appearance commands
|
||||
TurtleCommand::SetColor(color) => state.color = *color,
|
||||
TurtleCommand::SetFillColor(color) => state.fill_color = *color,
|
||||
TurtleCommand::SetPenWidth(width) => state.pen_width = *width,
|
||||
TurtleCommand::SetSpeed(speed) => state.set_speed(*speed),
|
||||
TurtleCommand::SetShape(shape) => state.shape = shape.clone(),
|
||||
TurtleCommand::SetHeading(heading) => state.heading = *heading,
|
||||
TurtleCommand::ShowTurtle => state.visible = true,
|
||||
TurtleCommand::HideTurtle => state.visible = false,
|
||||
|
||||
_ => {} // Already handled by execute_command_side_effects
|
||||
}
|
||||
|
||||
// Record fill vertices AFTER movement
|
||||
record_fill_vertices_after_movement(command, &start_state, state);
|
||||
}
|
||||
|
||||
/// 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, // not closed
|
||||
) {
|
||||
world.add_command(DrawCommand::Mesh(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,
|
||||
);
|
||||
let (rotation_degrees, arc_degrees) = geom.draw_arc_params(*angle);
|
||||
|
||||
// Use Lyon to tessellate the arc
|
||||
if let Ok(mesh_data) = tessellation::tessellate_arc(
|
||||
geom.center,
|
||||
*radius,
|
||||
rotation_degrees,
|
||||
arc_degrees,
|
||||
start_state.color,
|
||||
start_state.pen_width,
|
||||
*steps,
|
||||
) {
|
||||
world.add_command(DrawCommand::Mesh(mesh_data));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 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: AnimationSpeed::Animated(100.0),
|
||||
visible: true,
|
||||
shape: TurtleShape::turtle(),
|
||||
filling: None,
|
||||
};
|
||||
|
||||
// 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::Move(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::Turn(-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::Move(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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! 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;
|
||||
|
||||
/// Execution speed setting
|
||||
/// - `Instant(draw_calls)`: Fast execution with limited draw calls per frame (speed - 1000, minimum 1)
|
||||
/// - `Animated(speed)`: Smooth animation at specified pixels/second
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum AnimationSpeed {
|
||||
Instant(u32), // Number of draw calls per frame (minimum 1)
|
||||
Animated(f32), // pixels per second
|
||||
}
|
||||
|
||||
impl AnimationSpeed {
|
||||
/// Check if this is instant mode
|
||||
#[must_use]
|
||||
pub fn is_animating(&self) -> bool {
|
||||
matches!(self, AnimationSpeed::Animated(_))
|
||||
}
|
||||
|
||||
/// Get the speed value (returns encoded value for Instant)
|
||||
#[must_use]
|
||||
pub fn value(&self) -> f32 {
|
||||
match self {
|
||||
AnimationSpeed::Instant(calls) => 1000.0 + *calls as f32,
|
||||
AnimationSpeed::Animated(speed) => *speed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from a raw speed value
|
||||
/// - speed >= 1000 becomes Instant with max(1, speed - 1000) draw calls per frame
|
||||
/// - speed < 1000 becomes Animated
|
||||
#[must_use]
|
||||
pub fn from_value(speed: f32) -> Self {
|
||||
if speed >= 1000.0 {
|
||||
let draw_calls = (speed - 1000.0).max(1.0) as u32; // Ensure at least 1
|
||||
AnimationSpeed::Instant(draw_calls)
|
||||
} else {
|
||||
AnimationSpeed::Animated(speed.max(1.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from a u32 value for backward compatibility
|
||||
#[must_use]
|
||||
pub fn from_u32(speed: u32) -> Self {
|
||||
Self::from_value(speed as f32)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AnimationSpeed {
|
||||
fn default() -> Self {
|
||||
AnimationSpeed::Animated(100.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f32> for AnimationSpeed {
|
||||
fn from(speed: f32) -> Self {
|
||||
AnimationSpeed::from_value(speed)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u32> for AnimationSpeed {
|
||||
fn from(speed: u32) -> Self {
|
||||
AnimationSpeed::from_u32(speed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Color type re-export from macroquad
|
||||
pub use macroquad::color::Color;
|
||||
@@ -0,0 +1,205 @@
|
||||
//! 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(Precision::from(i)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
#[must_use]
|
||||
pub fn degrees(value: Precision) -> Self {
|
||||
Self {
|
||||
value: AngleUnit::Degrees(value),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn radians(value: Precision) -> Self {
|
||||
Self {
|
||||
value: AngleUnit::Radians(value),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn value(&self) -> Precision {
|
||||
match self.value {
|
||||
AngleUnit::Degrees(v) | AngleUnit::Radians(v) => v,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn to_radians(self) -> Self {
|
||||
match self.value {
|
||||
AngleUnit::Degrees(v) => Self::radians(v.to_radians()),
|
||||
AngleUnit::Radians(_) => self,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn to_degrees(self) -> Self {
|
||||
match self.value {
|
||||
AngleUnit::Degrees(_) => self,
|
||||
AngleUnit::Radians(v) => Self::degrees(v.to_degrees()),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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(Precision::from(i))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Turtle graphics library for Macroquad
|
||||
//!
|
||||
//! This library provides a turtle graphics API for creating drawings and animations
|
||||
//! using the Macroquad game framework.
|
||||
//!
|
||||
//! # Quick Start with `turtle_main` Macro
|
||||
//!
|
||||
//! The easiest way to create a turtle program is using the `turtle_main` macro:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use macroquad::prelude::*;
|
||||
//! use turtle_lib::*;
|
||||
//!
|
||||
//! #[turtle_main("My Drawing")]
|
||||
//! fn draw(turtle: &mut TurtlePlan) {
|
||||
//! turtle.set_pen_color(RED);
|
||||
//! turtle.forward(100.0);
|
||||
//! turtle.right(90.0);
|
||||
//! turtle.forward(100.0);
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! The macro automatically handles window setup, rendering loop, and quit handling.
|
||||
//!
|
||||
//! # Manual Setup Example
|
||||
//!
|
||||
//! For more control, you can set up the application manually:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use macroquad::prelude::*;
|
||||
//! use turtle_lib::*;
|
||||
//!
|
||||
//! #[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());
|
||||
//!
|
||||
//! 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 tessellation;
|
||||
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, AnimationSpeed, Color, Coordinate, Length, Precision};
|
||||
pub use shapes::{ShapeType, TurtleShape};
|
||||
pub use state::{DrawCommand, TurtleState, TurtleWorld};
|
||||
pub use tweening::TweenController;
|
||||
|
||||
// Re-export the turtle_main macro
|
||||
pub use turtle_lib_macros::turtle_main;
|
||||
|
||||
// Re-export common macroquad types and colors for convenience
|
||||
pub use macroquad::prelude::{
|
||||
vec2, BLACK, BLUE, DARKGRAY, GOLD, GREEN, ORANGE, PURPLE, RED, WHITE, YELLOW,
|
||||
};
|
||||
|
||||
use macroquad::prelude::*;
|
||||
|
||||
/// Main turtle application struct
|
||||
pub struct TurtleApp {
|
||||
world: TurtleWorld,
|
||||
tween_controller: Option<TweenController>,
|
||||
speed: AnimationSpeed,
|
||||
// Mouse panning state
|
||||
is_dragging: bool,
|
||||
last_mouse_pos: Option<Vec2>,
|
||||
// Zoom state
|
||||
zoom_level: f32,
|
||||
}
|
||||
|
||||
impl TurtleApp {
|
||||
/// Create a new `TurtleApp` with default settings
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
world: TurtleWorld::new(),
|
||||
tween_controller: None,
|
||||
speed: AnimationSpeed::default(),
|
||||
is_dragging: false,
|
||||
last_mouse_pos: None,
|
||||
zoom_level: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add commands to the turtle
|
||||
///
|
||||
/// Speed is controlled by `SetSpeed` commands in the queue.
|
||||
/// Use `set_speed()` on the turtle plan to set animation speed.
|
||||
/// Speed >= 999 = instant mode, speed < 999 = animated mode.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `queue` - The command queue to execute
|
||||
#[must_use]
|
||||
pub fn with_commands(mut self, queue: CommandQueue) -> Self {
|
||||
// The `TweenController` will switch between instant and animated mode
|
||||
// based on `SetSpeed` commands encountered
|
||||
self.tween_controller = Some(TweenController::new(queue, self.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 {
|
||||
let completed_commands =
|
||||
controller.update(&mut self.world.turtle, &mut self.world.commands);
|
||||
|
||||
// Process all completed commands (multiple in instant mode, 0-1 in animated mode)
|
||||
for (completed_cmd, start_state, end_state) in completed_commands {
|
||||
// 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
|
||||
#[must_use]
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.tween_controller
|
||||
.as_ref()
|
||||
.is_none_or(TweenController::is_complete)
|
||||
}
|
||||
|
||||
/// Get reference to the world state
|
||||
#[must_use]
|
||||
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::*;
|
||||
///
|
||||
/// let mut turtle = create_turtle();
|
||||
/// turtle.forward(100.0).right(90.0).forward(50.0);
|
||||
/// let commands = turtle.build();
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn create_turtle() -> TurtlePlan {
|
||||
TurtlePlan::new()
|
||||
}
|
||||
|
||||
/// Convenience function to get a turtle plan (alias for `create_turtle`)
|
||||
#[must_use]
|
||||
pub fn get_a_turtle() -> TurtlePlan {
|
||||
create_turtle()
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! 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
|
||||
#[must_use]
|
||||
pub fn new(vertices: Vec<Vec2>, filled: bool) -> Self {
|
||||
Self { vertices, filled }
|
||||
}
|
||||
|
||||
/// Get vertices rotated by the given angle
|
||||
#[must_use]
|
||||
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)
|
||||
#[must_use]
|
||||
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
|
||||
#[must_use]
|
||||
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
|
||||
#[must_use]
|
||||
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
|
||||
#[must_use]
|
||||
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)
|
||||
#[must_use]
|
||||
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, Default)]
|
||||
pub enum ShapeType {
|
||||
Triangle,
|
||||
#[default]
|
||||
Turtle,
|
||||
Circle,
|
||||
Square,
|
||||
Arrow,
|
||||
}
|
||||
|
||||
impl ShapeType {
|
||||
/// Get the corresponding `TurtleShape`
|
||||
#[must_use]
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
//! Turtle state and world state management
|
||||
|
||||
use crate::general::{Angle, AnimationSpeed, Color, Coordinate, Precision};
|
||||
use crate::shapes::TurtleShape;
|
||||
use macroquad::prelude::*;
|
||||
|
||||
/// State during active fill operation
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FillState {
|
||||
/// Starting position of the fill
|
||||
pub start_position: Coordinate,
|
||||
|
||||
/// All contours collected so far. Each contour is a separate closed path.
|
||||
/// The first contour is the outer boundary, subsequent contours are holes.
|
||||
pub contours: Vec<Vec<Coordinate>>,
|
||||
|
||||
/// Current contour being built (vertices for the active `pen_down` segment)
|
||||
pub current_contour: Vec<Coordinate>,
|
||||
|
||||
/// Fill color (cached from when `begin_fill` was called)
|
||||
pub fill_color: Color,
|
||||
}
|
||||
|
||||
/// 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: AnimationSpeed,
|
||||
pub visible: bool,
|
||||
pub shape: TurtleShape,
|
||||
|
||||
// Fill tracking
|
||||
pub filling: Option<FillState>,
|
||||
}
|
||||
|
||||
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: AnimationSpeed::default(),
|
||||
visible: true,
|
||||
shape: TurtleShape::turtle(),
|
||||
filling: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TurtleState {
|
||||
pub fn set_speed(&mut self, speed: AnimationSpeed) {
|
||||
self.speed = speed;
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn heading_angle(&self) -> Angle {
|
||||
Angle::radians(self.heading)
|
||||
}
|
||||
|
||||
/// Start recording fill vertices
|
||||
pub fn begin_fill(&mut self, fill_color: Color) {
|
||||
self.filling = Some(FillState {
|
||||
start_position: self.position,
|
||||
contours: Vec::new(),
|
||||
current_contour: vec![self.position],
|
||||
fill_color,
|
||||
});
|
||||
}
|
||||
|
||||
/// Record current position if filling and pen is down
|
||||
pub fn record_fill_vertex(&mut self) {
|
||||
if let Some(ref mut fill_state) = self.filling {
|
||||
if self.pen_down {
|
||||
tracing::trace!(
|
||||
x = self.position.x,
|
||||
y = self.position.y,
|
||||
vertices = fill_state.current_contour.len() + 1,
|
||||
"Adding vertex to current contour"
|
||||
);
|
||||
fill_state.current_contour.push(self.position);
|
||||
} else {
|
||||
tracing::trace!("Skipping vertex (pen is up)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the current contour and prepare for a new one (called on `pen_up`)
|
||||
pub fn close_fill_contour(&mut self) {
|
||||
if let Some(ref mut fill_state) = self.filling {
|
||||
tracing::debug!(
|
||||
vertices = fill_state.current_contour.len(),
|
||||
"close_fill_contour called"
|
||||
);
|
||||
// Only close if we have vertices in current contour
|
||||
if fill_state.current_contour.len() >= 2 {
|
||||
tracing::debug!(
|
||||
vertices = fill_state.current_contour.len(),
|
||||
first_x = fill_state.current_contour[0].x,
|
||||
first_y = fill_state.current_contour[0].y,
|
||||
last_x = fill_state.current_contour[fill_state.current_contour.len() - 1].x,
|
||||
last_y = fill_state.current_contour[fill_state.current_contour.len() - 1].y,
|
||||
"Closing contour"
|
||||
);
|
||||
// Move current contour to completed contours
|
||||
let contour = std::mem::take(&mut fill_state.current_contour);
|
||||
fill_state.contours.push(contour);
|
||||
tracing::debug!(
|
||||
completed_contours = fill_state.contours.len(),
|
||||
"Contour moved to completed list"
|
||||
);
|
||||
} else if !fill_state.current_contour.is_empty() {
|
||||
tracing::warn!(
|
||||
vertices = fill_state.current_contour.len(),
|
||||
"Current contour has insufficient vertices, not closing"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!("Current contour is empty, nothing to close");
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("close_fill_contour called but no active fill state");
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new contour (called on `pen_down`)
|
||||
pub fn start_fill_contour(&mut self) {
|
||||
if let Some(ref mut fill_state) = self.filling {
|
||||
// Start new contour at current position
|
||||
tracing::debug!(
|
||||
x = self.position.x,
|
||||
y = self.position.y,
|
||||
completed_contours = fill_state.contours.len(),
|
||||
"Starting new contour"
|
||||
);
|
||||
fill_state.current_contour = vec![self.position];
|
||||
}
|
||||
}
|
||||
|
||||
/// Record multiple vertices along a circle arc for filling
|
||||
/// This ensures circles are properly filled by sampling points along the arc
|
||||
pub fn record_fill_vertices_for_arc(
|
||||
&mut self,
|
||||
center: Coordinate,
|
||||
radius: f32,
|
||||
start_angle: f32,
|
||||
angle_traveled: f32,
|
||||
direction: crate::circle_geometry::CircleDirection,
|
||||
steps: u32,
|
||||
) {
|
||||
if let Some(ref mut fill_state) = self.filling {
|
||||
if self.pen_down {
|
||||
// Sample points along the arc based on steps
|
||||
let num_samples = steps as usize;
|
||||
|
||||
tracing::trace!(
|
||||
center_x = center.x,
|
||||
center_y = center.y,
|
||||
radius = radius,
|
||||
steps = steps,
|
||||
num_samples = num_samples,
|
||||
"Recording arc vertices"
|
||||
);
|
||||
|
||||
for i in 1..=num_samples {
|
||||
let progress = i as f32 / num_samples as f32;
|
||||
let current_angle = match direction {
|
||||
crate::circle_geometry::CircleDirection::Left => {
|
||||
start_angle - angle_traveled * progress
|
||||
}
|
||||
crate::circle_geometry::CircleDirection::Right => {
|
||||
start_angle + angle_traveled * progress
|
||||
}
|
||||
};
|
||||
|
||||
let vertex = Coordinate::new(
|
||||
center.x + radius * current_angle.cos(),
|
||||
center.y + radius * current_angle.sin(),
|
||||
);
|
||||
tracing::trace!(
|
||||
vertex_idx = i,
|
||||
x = vertex.x,
|
||||
y = vertex.y,
|
||||
angle_degrees = current_angle.to_degrees(),
|
||||
"Arc vertex"
|
||||
);
|
||||
fill_state.current_contour.push(vertex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear fill state (called after `end_fill`)
|
||||
pub fn reset_fill(&mut self) {
|
||||
self.filling = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached mesh data that can be cloned and converted to Mesh when needed
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MeshData {
|
||||
pub vertices: Vec<macroquad::prelude::Vertex>,
|
||||
pub indices: Vec<u16>,
|
||||
}
|
||||
|
||||
impl MeshData {
|
||||
#[must_use]
|
||||
pub fn to_mesh(&self) -> macroquad::prelude::Mesh {
|
||||
macroquad::prelude::Mesh {
|
||||
vertices: self.vertices.clone(),
|
||||
indices: self.indices.clone(),
|
||||
texture: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drawable elements in the world
|
||||
/// All drawing is done via Lyon-tessellated meshes for consistency and quality
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum DrawCommand {
|
||||
/// Pre-tessellated mesh data (lines, arcs, circles, polygons - all use this)
|
||||
Mesh(MeshData),
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
#[must_use]
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Lyon tessellation utilities for turtle graphics
|
||||
//!
|
||||
//! This module provides helper functions to tessellate paths using Lyon,
|
||||
//! which replaces the manual triangulation with GPU-optimized tessellation.
|
||||
|
||||
use crate::state::MeshData;
|
||||
use lyon::math::{point, Point};
|
||||
use lyon::path::{LineCap, LineJoin, Path};
|
||||
use lyon::tessellation::{
|
||||
BuffersBuilder, FillOptions, FillRule, FillTessellator, FillVertex, StrokeOptions,
|
||||
StrokeTessellator, StrokeVertex, VertexBuffers,
|
||||
};
|
||||
use macroquad::prelude::*;
|
||||
|
||||
/// Convert macroquad Vec2 to Lyon Point
|
||||
#[must_use]
|
||||
pub fn to_lyon_point(v: Vec2) -> Point {
|
||||
point(v.x, v.y)
|
||||
}
|
||||
|
||||
/// Convert Lyon Point to macroquad Vec2
|
||||
#[allow(dead_code)]
|
||||
#[must_use]
|
||||
pub fn to_macroquad_vec2(p: Point) -> Vec2 {
|
||||
vec2(p.x, p.y)
|
||||
}
|
||||
|
||||
/// Simple vertex type for Lyon tessellation
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct SimpleVertex {
|
||||
pub position: [f32; 2],
|
||||
}
|
||||
|
||||
/// Build mesh data from Lyon tessellation
|
||||
#[must_use]
|
||||
pub fn build_mesh_data(vertices: &[SimpleVertex], indices: &[u16], color: Color) -> MeshData {
|
||||
let verts: Vec<Vertex> = vertices
|
||||
.iter()
|
||||
.map(|v| Vertex {
|
||||
position: Vec3::new(v.position[0], v.position[1], 0.0),
|
||||
uv: Vec2::ZERO,
|
||||
color: [
|
||||
(color.r * 255.0) as u8,
|
||||
(color.g * 255.0) as u8,
|
||||
(color.b * 255.0) as u8,
|
||||
(color.a * 255.0) as u8,
|
||||
],
|
||||
normal: Vec4::ZERO,
|
||||
})
|
||||
.collect();
|
||||
|
||||
MeshData {
|
||||
vertices: verts,
|
||||
indices: indices.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tessellate a polygon and return mesh
|
||||
///
|
||||
/// This automatically handles holes when the path crosses itself.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if no vertices are provided or if tessellation fails.
|
||||
pub fn tessellate_polygon(
|
||||
vertices: &[Vec2],
|
||||
color: Color,
|
||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
||||
if vertices.is_empty() {
|
||||
return Err("No vertices provided".into());
|
||||
}
|
||||
|
||||
// Build path
|
||||
let mut builder = Path::builder();
|
||||
builder.begin(to_lyon_point(vertices[0]));
|
||||
for v in &vertices[1..] {
|
||||
builder.line_to(to_lyon_point(*v));
|
||||
}
|
||||
builder.end(true); // Close the path
|
||||
|
||||
let path = builder.build();
|
||||
|
||||
// Tessellate with EvenOdd fill rule (automatic hole detection)
|
||||
let mut geometry: VertexBuffers<SimpleVertex, u16> = VertexBuffers::new();
|
||||
let mut tessellator = FillTessellator::new();
|
||||
|
||||
tessellator.tessellate_path(
|
||||
&path,
|
||||
&FillOptions::default().with_fill_rule(FillRule::EvenOdd),
|
||||
&mut BuffersBuilder::new(&mut geometry, |vertex: FillVertex| SimpleVertex {
|
||||
position: vertex.position().to_array(),
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(build_mesh_data(
|
||||
&geometry.vertices,
|
||||
&geometry.indices,
|
||||
color,
|
||||
))
|
||||
}
|
||||
|
||||
/// Tessellate multiple contours (outer boundary + holes) and return mesh
|
||||
///
|
||||
/// The first contour is the outer boundary, subsequent contours are holes.
|
||||
/// Lyon's `EvenOdd` fill rule automatically creates holes where contours overlap.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if no contours are provided or if tessellation fails.
|
||||
pub fn tessellate_multi_contour(
|
||||
contours: &[Vec<Vec2>],
|
||||
color: Color,
|
||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
||||
if contours.is_empty() {
|
||||
return Err("No contours provided".into());
|
||||
}
|
||||
|
||||
let span = tracing::debug_span!("tessellate_multi_contour", contours = contours.len());
|
||||
let _enter = span.enter();
|
||||
|
||||
tracing::debug!("Starting multi-contour tessellation");
|
||||
|
||||
// Build path with multiple sub-paths (contours)
|
||||
let mut builder = Path::builder();
|
||||
|
||||
for (idx, contour) in contours.iter().enumerate() {
|
||||
if contour.is_empty() {
|
||||
tracing::warn!(contour_idx = idx, "Contour is empty, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::trace!(
|
||||
contour_idx = idx,
|
||||
vertices = contour.len(),
|
||||
first_x = contour[0].x,
|
||||
first_y = contour[0].y,
|
||||
"Processing contour"
|
||||
);
|
||||
if contour.len() > 1 {
|
||||
tracing::trace!(
|
||||
last_x = contour[contour.len() - 1].x,
|
||||
last_y = contour[contour.len() - 1].y,
|
||||
"Contour end vertex"
|
||||
);
|
||||
}
|
||||
|
||||
// Each contour is a separate closed sub-path
|
||||
builder.begin(to_lyon_point(contour[0]));
|
||||
for (i, v) in contour[1..].iter().enumerate() {
|
||||
builder.line_to(to_lyon_point(*v));
|
||||
if i < 3 || i >= contour.len() - 4 {
|
||||
tracing::trace!(vertex_idx = i + 1, x = v.x, y = v.y, "Contour vertex");
|
||||
} else if i == 3 {
|
||||
tracing::trace!(
|
||||
omitted = contour.len() - 7,
|
||||
"Additional vertices omitted from trace"
|
||||
);
|
||||
}
|
||||
}
|
||||
builder.end(true); // Close this contour
|
||||
tracing::trace!(contour_idx = idx, "Contour closed");
|
||||
}
|
||||
|
||||
tracing::debug!("Building Lyon path");
|
||||
let path = builder.build();
|
||||
tracing::debug!("Path built successfully");
|
||||
|
||||
// Tessellate with EvenOdd fill rule - overlapping areas become holes
|
||||
let mut geometry: VertexBuffers<SimpleVertex, u16> = VertexBuffers::new();
|
||||
let mut tessellator = FillTessellator::new();
|
||||
|
||||
tracing::debug!("Starting tessellation with EvenOdd fill rule");
|
||||
match tessellator.tessellate_path(
|
||||
&path,
|
||||
&FillOptions::default().with_fill_rule(FillRule::EvenOdd),
|
||||
&mut BuffersBuilder::new(&mut geometry, |vertex: FillVertex| SimpleVertex {
|
||||
position: vertex.position().to_array(),
|
||||
}),
|
||||
) {
|
||||
Ok(()) => {
|
||||
tracing::debug!(
|
||||
vertices = geometry.vertices.len(),
|
||||
indices = geometry.indices.len(),
|
||||
triangles = geometry.indices.len() / 3,
|
||||
"Tessellation successful"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Tessellation failed");
|
||||
return Err(Box::new(e));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(build_mesh_data(
|
||||
&geometry.vertices,
|
||||
&geometry.indices,
|
||||
color,
|
||||
))
|
||||
}
|
||||
|
||||
/// Tessellate a stroked path and return mesh
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if no vertices are provided or if tessellation fails.
|
||||
pub fn tessellate_stroke(
|
||||
vertices: &[Vec2],
|
||||
color: Color,
|
||||
width: f32,
|
||||
closed: bool,
|
||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
||||
if vertices.is_empty() {
|
||||
return Err("No vertices provided".into());
|
||||
}
|
||||
|
||||
// Build path
|
||||
let mut builder = Path::builder();
|
||||
builder.begin(to_lyon_point(vertices[0]));
|
||||
for v in &vertices[1..] {
|
||||
builder.line_to(to_lyon_point(*v));
|
||||
}
|
||||
builder.end(closed);
|
||||
let path = builder.build();
|
||||
|
||||
// Tessellate with round caps and joins for smooth lines
|
||||
let mut geometry: VertexBuffers<SimpleVertex, u16> = VertexBuffers::new();
|
||||
let mut tessellator = StrokeTessellator::new();
|
||||
|
||||
tessellator.tessellate_path(
|
||||
&path,
|
||||
&StrokeOptions::default()
|
||||
.with_line_width(width)
|
||||
.with_line_cap(LineCap::Round)
|
||||
.with_line_join(LineJoin::Round),
|
||||
&mut BuffersBuilder::new(&mut geometry, |vertex: StrokeVertex| SimpleVertex {
|
||||
position: vertex.position().to_array(),
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(build_mesh_data(
|
||||
&geometry.vertices,
|
||||
&geometry.indices,
|
||||
color,
|
||||
))
|
||||
}
|
||||
|
||||
/// Tessellate a circle and return mesh
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if tessellation fails.
|
||||
pub fn tessellate_circle(
|
||||
center: Vec2,
|
||||
radius: f32,
|
||||
color: Color,
|
||||
filled: bool,
|
||||
stroke_width: f32,
|
||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
||||
let mut builder = Path::builder();
|
||||
builder.add_circle(to_lyon_point(center), radius, lyon::path::Winding::Positive);
|
||||
let path = builder.build();
|
||||
|
||||
let mut geometry: VertexBuffers<SimpleVertex, u16> = VertexBuffers::new();
|
||||
|
||||
if filled {
|
||||
let mut tessellator = FillTessellator::new();
|
||||
tessellator.tessellate_path(
|
||||
&path,
|
||||
&FillOptions::default(),
|
||||
&mut BuffersBuilder::new(&mut geometry, |vertex: FillVertex| SimpleVertex {
|
||||
position: vertex.position().to_array(),
|
||||
}),
|
||||
)?;
|
||||
} else {
|
||||
let mut tessellator = StrokeTessellator::new();
|
||||
tessellator.tessellate_path(
|
||||
&path,
|
||||
&StrokeOptions::default().with_line_width(stroke_width),
|
||||
&mut BuffersBuilder::new(&mut geometry, |vertex: StrokeVertex| SimpleVertex {
|
||||
position: vertex.position().to_array(),
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(build_mesh_data(
|
||||
&geometry.vertices,
|
||||
&geometry.indices,
|
||||
color,
|
||||
))
|
||||
}
|
||||
|
||||
/// Tessellate an arc (partial circle) and return mesh
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if tessellation fails.
|
||||
pub fn tessellate_arc(
|
||||
center: Vec2,
|
||||
radius: f32,
|
||||
start_angle_degrees: f32,
|
||||
arc_angle_degrees: f32,
|
||||
color: Color,
|
||||
stroke_width: f32,
|
||||
segments: usize,
|
||||
) -> Result<MeshData, Box<dyn std::error::Error>> {
|
||||
// Build arc path manually from segments
|
||||
let mut builder = Path::builder();
|
||||
|
||||
let start_angle = start_angle_degrees.to_radians();
|
||||
let arc_angle = arc_angle_degrees.to_radians();
|
||||
let step = arc_angle / segments as f32;
|
||||
|
||||
// Calculate first point
|
||||
let first_angle = start_angle;
|
||||
let first_point = point(
|
||||
center.x + radius * first_angle.cos(),
|
||||
center.y + radius * first_angle.sin(),
|
||||
);
|
||||
builder.begin(first_point);
|
||||
|
||||
// Add remaining points
|
||||
for i in 1..=segments {
|
||||
let angle = start_angle + step * i as f32;
|
||||
let pt = point(
|
||||
center.x + radius * angle.cos(),
|
||||
center.y + radius * angle.sin(),
|
||||
);
|
||||
builder.line_to(pt);
|
||||
}
|
||||
|
||||
builder.end(false); // Don't close the arc
|
||||
let path = builder.build();
|
||||
|
||||
// Tessellate stroke
|
||||
let mut geometry: VertexBuffers<SimpleVertex, u16> = VertexBuffers::new();
|
||||
let mut tessellator = StrokeTessellator::new();
|
||||
|
||||
tessellator.tessellate_path(
|
||||
&path,
|
||||
&StrokeOptions::default()
|
||||
.with_line_width(stroke_width)
|
||||
.with_line_cap(lyon::tessellation::LineCap::Round)
|
||||
.with_line_join(lyon::tessellation::LineJoin::Round),
|
||||
&mut BuffersBuilder::new(&mut geometry, |vertex: StrokeVertex| SimpleVertex {
|
||||
position: vertex.position().to_array(),
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(build_mesh_data(
|
||||
&geometry.vertices,
|
||||
&geometry.indices,
|
||||
color,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
//! Tweening system for smooth animations
|
||||
|
||||
use crate::circle_geometry::{CircleDirection, CircleGeometry};
|
||||
use crate::commands::{CommandQueue, TurtleCommand};
|
||||
use crate::general::AnimationSpeed;
|
||||
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: AnimationSpeed,
|
||||
}
|
||||
|
||||
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, f64, CubicInOut>,
|
||||
pub heading_tweener: Tweener<f32, f64, CubicInOut>,
|
||||
pub pen_width_tweener: Tweener<f32, f64, CubicInOut>,
|
||||
}
|
||||
|
||||
impl TweenController {
|
||||
#[must_use]
|
||||
pub fn new(queue: CommandQueue, speed: AnimationSpeed) -> Self {
|
||||
Self {
|
||||
queue,
|
||||
current_tween: None,
|
||||
speed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_speed(&mut self, speed: AnimationSpeed) {
|
||||
self.speed = speed;
|
||||
}
|
||||
|
||||
/// Update the tween, returns `Vec` of (`command`, `start_state`, `end_state`) for all completed commands this frame
|
||||
/// Also takes commands vec to handle side effects like fill operations
|
||||
/// Each `command` has its own `start_state` and `end_state` pair
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
state: &mut TurtleState,
|
||||
commands: &mut Vec<crate::state::DrawCommand>,
|
||||
) -> Vec<(TurtleCommand, TurtleState, TurtleState)> {
|
||||
// In instant mode, execute commands up to the draw calls per frame limit
|
||||
if let AnimationSpeed::Instant(max_draw_calls) = self.speed {
|
||||
let mut completed_commands = Vec::new();
|
||||
let mut draw_call_count = 0;
|
||||
|
||||
for command in self.queue.by_ref() {
|
||||
let start_state = state.clone();
|
||||
|
||||
// Handle SetSpeed command to potentially switch modes
|
||||
if let TurtleCommand::SetSpeed(new_speed) = &command {
|
||||
state.set_speed(*new_speed);
|
||||
self.speed = *new_speed;
|
||||
if matches!(self.speed, AnimationSpeed::Animated(_)) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Execute side-effect-only commands using centralized helper
|
||||
if crate::execution::execute_command_side_effects(&command, state, commands) {
|
||||
continue; // Command fully handled
|
||||
}
|
||||
|
||||
// Execute movement commands
|
||||
let target_state = Self::calculate_target_state(state, &command);
|
||||
*state = target_state.clone();
|
||||
|
||||
// Record fill vertices AFTER movement using centralized helper
|
||||
crate::execution::record_fill_vertices_after_movement(
|
||||
&command,
|
||||
&start_state,
|
||||
state,
|
||||
);
|
||||
|
||||
let end_state = state.clone();
|
||||
|
||||
// Collect drawable commands
|
||||
if Self::command_creates_drawing(&command) && start_state.pen_down {
|
||||
completed_commands.push((command, start_state, end_state));
|
||||
draw_call_count += 1;
|
||||
|
||||
if draw_call_count >= max_draw_calls {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completed_commands;
|
||||
}
|
||||
|
||||
// Process current tween
|
||||
if let Some(ref mut tween) = self.current_tween {
|
||||
let elapsed = get_time() - tween.start_time;
|
||||
|
||||
// Use tweeners to calculate current values
|
||||
// For circles, calculate position along the arc instead of straight line
|
||||
let progress = tween.heading_tweener.move_to(elapsed);
|
||||
|
||||
state.position = match &tween.command {
|
||||
TurtleCommand::Circle {
|
||||
radius,
|
||||
angle,
|
||||
direction,
|
||||
..
|
||||
} => {
|
||||
let angle_traveled = angle.to_radians() * progress;
|
||||
calculate_circle_position(
|
||||
tween.start_state.position,
|
||||
tween.start_state.heading,
|
||||
*radius,
|
||||
angle_traveled,
|
||||
*direction,
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
// 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 = normalize_angle(match &tween.command {
|
||||
TurtleCommand::Circle {
|
||||
angle, direction, ..
|
||||
} => match direction {
|
||||
CircleDirection::Left => {
|
||||
tween.start_state.heading - angle.to_radians() * progress
|
||||
}
|
||||
CircleDirection::Right => {
|
||||
tween.start_state.heading + angle.to_radians() * progress
|
||||
}
|
||||
},
|
||||
TurtleCommand::Turn(angle) => {
|
||||
tween.start_state.heading + angle.to_radians() * progress
|
||||
}
|
||||
_ => {
|
||||
// For other commands that change heading, lerp directly
|
||||
let heading_diff = tween.target_state.heading - tween.start_state.heading;
|
||||
tween.start_state.heading + heading_diff * progress
|
||||
}
|
||||
});
|
||||
state.pen_width = tween.pen_width_tweener.move_to(elapsed);
|
||||
|
||||
// Discrete properties (switch at 50% progress)
|
||||
let progress = (elapsed / tween.duration).min(1.0);
|
||||
if progress >= 0.5 {
|
||||
state.pen_down = tween.target_state.pen_down;
|
||||
state.color = tween.target_state.color;
|
||||
state.fill_color = tween.target_state.fill_color;
|
||||
state.visible = tween.target_state.visible;
|
||||
state.shape = tween.target_state.shape.clone();
|
||||
}
|
||||
|
||||
// Check if tween is finished (use heading_tweener as it's used by all commands)
|
||||
if tween.heading_tweener.is_finished() {
|
||||
let start_state = tween.start_state.clone();
|
||||
*state = tween.target_state.clone();
|
||||
let end_state = state.clone();
|
||||
|
||||
let completed_command = tween.command.clone();
|
||||
self.current_tween = None;
|
||||
|
||||
// Execute side-effect-only commands using centralized helper
|
||||
if crate::execution::execute_command_side_effects(
|
||||
&completed_command,
|
||||
state,
|
||||
commands,
|
||||
) {
|
||||
return self.update(state, commands); // Continue to next command
|
||||
}
|
||||
|
||||
// Record fill vertices for movement commands using centralized helper
|
||||
crate::execution::record_fill_vertices_after_movement(
|
||||
&completed_command,
|
||||
&start_state,
|
||||
state,
|
||||
);
|
||||
|
||||
// Return drawable commands
|
||||
if Self::command_creates_drawing(&completed_command) && start_state.pen_down {
|
||||
return vec![(completed_command, start_state, end_state)];
|
||||
}
|
||||
return self.update(state, commands); // Continue to next command
|
||||
}
|
||||
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Start next tween
|
||||
if let Some(command) = self.queue.next() {
|
||||
let command_clone = command.clone();
|
||||
|
||||
// Handle commands that should execute immediately (no animation)
|
||||
match &command_clone {
|
||||
TurtleCommand::SetSpeed(new_speed) => {
|
||||
state.set_speed(*new_speed);
|
||||
self.speed = *new_speed;
|
||||
if matches!(self.speed, AnimationSpeed::Instant(_)) {
|
||||
return self.update(state, commands);
|
||||
}
|
||||
return self.update(state, commands);
|
||||
}
|
||||
_ => {
|
||||
// Use centralized helper for side effects
|
||||
if crate::execution::execute_command_side_effects(
|
||||
&command_clone,
|
||||
state,
|
||||
commands,
|
||||
) {
|
||||
return self.update(state, commands);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let speed = state.speed; // Extract speed before borrowing self
|
||||
let duration = Self::calculate_duration_with_state(&command_clone, state, speed);
|
||||
|
||||
// Calculate target state
|
||||
let target_state = Self::calculate_target_state(state, &command_clone);
|
||||
|
||||
// Create tweeners for smooth animation
|
||||
let position_tweener = Tweener::new(
|
||||
TweenVec2::from(state.position),
|
||||
TweenVec2::from(target_state.position),
|
||||
duration,
|
||||
CubicInOut,
|
||||
);
|
||||
|
||||
let heading_tweener = Tweener::new(
|
||||
0.0, // We'll handle angle wrapping separately
|
||||
1.0, duration, CubicInOut,
|
||||
);
|
||||
|
||||
let pen_width_tweener = Tweener::new(
|
||||
state.pen_width,
|
||||
target_state.pen_width,
|
||||
duration,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
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::Move(_) | TurtleCommand::Circle { .. } | TurtleCommand::Goto(_)
|
||||
)
|
||||
}
|
||||
|
||||
fn calculate_duration_with_state(
|
||||
command: &TurtleCommand,
|
||||
current: &TurtleState,
|
||||
speed: AnimationSpeed,
|
||||
) -> f64 {
|
||||
let speed = speed.value();
|
||||
|
||||
let base_time = match command {
|
||||
TurtleCommand::Move(dist) => dist.abs() / speed,
|
||||
TurtleCommand::Turn(angle) => {
|
||||
// Rotation speed: assume 180 degrees per second at speed 100
|
||||
angle.abs() / (speed * 1.8)
|
||||
}
|
||||
TurtleCommand::Circle { radius, angle, .. } => {
|
||||
let arc_length = radius * angle.to_radians().abs();
|
||||
arc_length / speed
|
||||
}
|
||||
TurtleCommand::Goto(target) => {
|
||||
// Calculate actual distance from current position to target
|
||||
let dx = target.x - current.position.x;
|
||||
let dy = target.y - current.position.y;
|
||||
let distance = (dx * dx + dy * dy).sqrt();
|
||||
distance / speed
|
||||
}
|
||||
_ => 0.0, // Instant commands
|
||||
};
|
||||
f64::from(base_time.max(0.01)) // Minimum duration
|
||||
}
|
||||
|
||||
fn calculate_target_state(current: &TurtleState, command: &TurtleCommand) -> TurtleState {
|
||||
let mut target = current.clone();
|
||||
|
||||
match command {
|
||||
TurtleCommand::Move(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::Turn(angle) => {
|
||||
target.heading = normalize_angle(current.heading + angle.to_radians());
|
||||
}
|
||||
TurtleCommand::Circle {
|
||||
radius,
|
||||
angle,
|
||||
direction,
|
||||
..
|
||||
} => {
|
||||
// Use helper function to calculate final position
|
||||
target.position = calculate_circle_position(
|
||||
current.position,
|
||||
current.heading,
|
||||
*radius,
|
||||
angle.to_radians(),
|
||||
*direction,
|
||||
);
|
||||
target.heading = normalize_angle(match direction {
|
||||
CircleDirection::Left => current.heading - angle.to_radians(),
|
||||
CircleDirection::Right => current.heading + angle.to_radians(),
|
||||
});
|
||||
}
|
||||
TurtleCommand::Goto(coord) => {
|
||||
target.position = *coord;
|
||||
}
|
||||
TurtleCommand::SetHeading(heading) => {
|
||||
target.heading = normalize_angle(*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;
|
||||
}
|
||||
TurtleCommand::BeginFill | TurtleCommand::EndFill => {
|
||||
// Fill commands don't change turtle state for tweening purposes
|
||||
// They're handled directly in execution
|
||||
}
|
||||
}
|
||||
|
||||
target
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate position on a circular arc
|
||||
fn calculate_circle_position(
|
||||
start_pos: Vec2,
|
||||
start_heading: f32,
|
||||
radius: f32,
|
||||
angle_traveled: f32, // How much of the total angle we've traveled (in radians)
|
||||
direction: CircleDirection,
|
||||
) -> Vec2 {
|
||||
let geom = CircleGeometry::new(start_pos, start_heading, radius, direction);
|
||||
geom.position_at_angle(angle_traveled)
|
||||
}
|
||||
|
||||
/// Normalize angle to range [-PI, PI] to prevent floating-point drift
|
||||
fn normalize_angle(angle: f32) -> f32 {
|
||||
let two_pi = std::f32::consts::PI * 2.0;
|
||||
let mut normalized = angle % two_pi;
|
||||
|
||||
// Ensure result is in [-PI, PI]
|
||||
if normalized > std::f32::consts::PI {
|
||||
normalized -= two_pi;
|
||||
} else if normalized < -std::f32::consts::PI {
|
||||
normalized += two_pi;
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
Reference in New Issue
Block a user