Compare commits

..

23 Commits

Author SHA1 Message Date
59d6bc164e improve examples 2026-05-21 21:50:13 +02:00
ece26bfe04 create a svg log instead of caching the commands where they do not
belong...
2026-05-21 21:28:14 +02:00
156301f272 remove dead code and translate german comments to english 2026-05-21 17:35:59 +02:00
96b02f61be improve circle construction and rendering 2026-05-21 17:29:27 +02:00
998cffdcbf make the drwing of the center of the circle a regular feature. 2026-05-21 15:05:23 +02:00
6b558ca8a0 more consistent use of angle types 2026-05-21 15:02:57 +02:00
3c076fdd03 improve command handling 2026-05-17 07:23:13 +02:00
44046abe12 improve command handling 2026-05-16 16:03:09 +02:00
a7570911d8 reduce exposed internal functions and fields 2026-05-16 15:31:10 +02:00
cd589b2513
Merge pull request #4 from enaut/copilot/change-license-to-mit-or-apache2
Add dual MIT/Apache-2.0 license files
2026-01-16 19:43:28 +01:00
copilot-swe-agent[bot]
4ce176f44d Add LICENSE-MIT and LICENSE-APACHE files for dual licensing
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-16 18:34:55 +00:00
copilot-swe-agent[bot]
ef6e8e4c76 Initial plan 2026-01-16 18:31:28 +00:00
8a4ed0bf32 avoid cfg svg in public macro 2026-01-02 14:47:21 +01:00
402a8be205 additional feature flag to suppress a warning 2026-01-02 13:50:13 +01:00
3820f20048
Merge pull request #2 from enaut/copilot/add-cmdline-parameters-svg-export
Add --export-svg CLI parameter to turtle_main macro
2026-01-02 12:09:03 +01:00
6f29d97bb6 unify comandline parsing into function 2026-01-02 11:53:18 +01:00
cadc5a6798 fix cartesian axes example 2026-01-02 11:45:20 +01:00
copilot-swe-agent[bot]
9568bc10d9 Add comment explaining intentional code duplication in proc macro
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-01 20:44:27 +00:00
copilot-swe-agent[bot]
cbe249b9b7 Update README with CLI SVG export documentation and examples
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-01 20:42:53 +00:00
copilot-swe-agent[bot]
f5140361d5 Add documentation comment explaining magic number in instant speed
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-01 20:41:52 +00:00
copilot-swe-agent[bot]
c806570156 Implement CLI --export-svg parameter for instant SVG export
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-01 20:40:06 +00:00
copilot-swe-agent[bot]
d85a9c7d26 Add --export-svg CLI parameter support to turtle_main macro
Co-authored-by: enaut <290005+enaut@users.noreply.github.com>
2026-01-01 20:31:58 +00:00
copilot-swe-agent[bot]
2b64be29a8 Initial plan 2026-01-01 20:27:41 +00:00
24 changed files with 1626 additions and 1145 deletions

201
LICENSE-APACHE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

21
LICENSE-MIT Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Turtle Graphics Library Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -195,7 +195,32 @@ Add the `svg` feature to enable SVG export functionality:
cargo run --example export_svg --features svg
```
### Usage
### Command-Line SVG Export
When using the `turtle_main` macro with the `svg` feature enabled, you can export drawings directly to SVG files using the `--export-svg` command-line parameter:
```bash
# Export any example to SVG without showing the window
cargo run --example macro_demo --features svg -- --export-svg output.svg
# Works with all turtle_main-based examples
cargo run --example hello_turtle --features svg -- --export-svg square.svg
```
This will:
- Execute all drawing commands instantly (no animation)
- Export the result to an SVG file
- Exit immediately without opening a window
**Note**: The program still requires a display context to initialize. In headless environments, use `xvfb-run`:
```bash
xvfb-run -a cargo run --example macro_demo --features svg -- --export-svg output.svg
```
### Programmatic SVG Export
You can also export SVG programmatically from your code:
```rust
use turtle_lib::*;
@ -239,6 +264,10 @@ cargo run --example nikolaus
# SVG export example (requires --features svg)
cargo run --example export_svg --features svg
# Export any example to SVG using CLI parameter (requires --features svg)
cargo run --example macro_demo --features svg -- --export-svg output.svg
cargo run --example hello_turtle --features svg -- --export-svg square.svg
# Logging example - shows how to enable debug output
cargo run --example logging_example
RUST_LOG=turtle_lib=debug cargo run --example logging_example

View File

@ -16,6 +16,14 @@ use syn::{parse_macro_input, ItemFn};
/// - Creates a turtle instance (`turtle`)
/// - Sets up the `TurtleApp` with your drawing commands
/// - Provides a main loop with rendering and quit handling (ESC or Q)
/// - Adds command-line parameter support for SVG export (when `svg` feature is enabled)
///
/// # Command-Line Parameters
///
/// When the `svg` feature is enabled, the following command-line parameter is available:
///
/// * `--export-svg <filename>` - Exports the drawing to an SVG file and exits immediately
/// without opening the window. Example: `cargo run --features svg -- --export-svg output.svg`
///
/// # Example
///
@ -45,6 +53,13 @@ use syn::{parse_macro_input, ItemFn};
/// }
/// ```
///
/// # SVG Export Example
///
/// ```bash
/// # Run with SVG export (requires svg feature)
/// cargo run --package turtle-lib --example macro_demo --features svg -- --export-svg output.svg
/// ```
///
/// This expands to approximately:
///
/// ```ignore
@ -53,6 +68,10 @@ use syn::{parse_macro_input, ItemFn};
///
/// #[macroquad::main("My Turtle Drawing")]
/// async fn main() {
/// // Parse CLI args for --export-svg flag
/// let args: Vec<String> = std::env::args().collect();
/// // ... (argument parsing logic)
///
/// let mut turtle = create_turtle_plan();
///
/// // Your drawing code here
@ -63,6 +82,8 @@ use syn::{parse_macro_input, ItemFn};
///
/// let mut app = TurtleApp::new().with_commands(turtle.build());
///
/// // If --export-svg flag is present, export and exit
/// // Otherwise, enter normal rendering loop
/// loop {
/// clear_background(WHITE);
/// app.update();
@ -97,15 +118,29 @@ pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream {
// Check if the function has the expected signature
let has_turtle_param = input_fn.sig.inputs.len() == 1;
// Note: The following code has some duplication between the two branches
// (with/without turtle parameter). This is intentional in proc macros as
// we're generating different code paths, and extracting the common parts
// into helper functions would make the macro more complex without significant benefit.
let expanded = if has_turtle_param {
// Function takes a turtle parameter
quote! {
#[macroquad::main(#window_title)]
async fn main() {
// Build function reused for both export and normal rendering
let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
#fn_name(turtle);
};
// Handle optional SVG export internally in turtle-lib
turtle_lib::export::handle_svg_export(&mut build_commands);
// Normal rendering mode (with window)
let mut turtle = turtle_lib::create_turtle_plan();
// Call the user's function with the turtle
#fn_name(&mut turtle);
build_commands(&mut turtle);
let mut app = turtle_lib::TurtleApp::new()
.with_commands(turtle.build());
@ -139,10 +174,18 @@ pub fn turtle_main(args: TokenStream, input: TokenStream) -> TokenStream {
quote! {
#[macroquad::main(#window_title)]
async fn main() {
let mut turtle = turtle_lib::create_turtle_plan();
// Inline the user's code
// Build function reused for both export and normal rendering
let mut build_commands = |turtle: &mut turtle_lib::TurtlePlan| {
let turtle = turtle;
#fn_block
};
// Handle optional SVG export internally in turtle-lib
turtle_lib::export::handle_svg_export(&mut build_commands);
// Normal rendering mode (with window)
let mut turtle = turtle_lib::create_turtle_plan();
build_commands(&mut turtle);
let mut app = turtle_lib::TurtleApp::new()
.with_commands(turtle.build());

View File

@ -80,10 +80,10 @@ fn draw(turtle: &mut TurtlePlan) {
// Draw and label 4 points (one per quadrant)
let points = vec![
(vec2(120.0, 100.0), "A(2|1)"),
(vec2(-120.0, 100.0), "B(-2|1)"),
(vec2(-120.0, -100.0), "C(-2|-1)"),
(vec2(120.0, -100.0), "D(2|-1)"),
(vec2(100.0, 50.0), "A(2|1)"),
(vec2(-100.0, 50.0), "B(-2|1)"),
(vec2(-100.0, -50.0), "C(-2|-1)"),
(vec2(100.0, -50.0), "D(2|-1)"),
];
for (position, label) in points {

View File

@ -1,5 +1,6 @@
//! Beispiel: Exportiere ein SVG aus einer einfachen Zeichnung
#[cfg(feature = "svg")]
use turtle_lib::*;
#[cfg(feature = "svg")]

View File

@ -20,7 +20,7 @@ fn koch(depth: u32, turtle: &mut TurtlePlan, distance: f32) {
#[turtle_main("Koch Snowflake")]
fn draw(turtle: &mut TurtlePlan) {
// Position turtle
turtle.set_speed(1001);
turtle.set_speed(5000);
turtle.pen_up();
turtle.backward(150.0);
@ -28,7 +28,7 @@ fn draw(turtle: &mut TurtlePlan) {
// Draw Koch snowflake (triangle of Koch curves)
for _ in 0..3 {
koch(4, turtle, 300.0);
koch(6, turtle, 300.0);
turtle.right(120.0);
turtle.set_speed(1200);
}

View File

@ -0,0 +1,36 @@
//! Test example for CLI SVG export feature
//!
//! Run this with: cargo run --package turtle-lib --example test_svg_export --features svg -- --export-svg test_output.svg
use turtle_lib::*;
#[turtle_main("SVG Export Test")]
fn draw_test(turtle: &mut TurtlePlan) {
turtle.set_pen_color(RED);
turtle.set_pen_width(3.0);
// Draw a square
for _ in 0..4 {
turtle.forward(100.0);
turtle.right(90.0);
}
// Draw a circle
turtle.set_pen_color(BLUE);
turtle.pen_up();
turtle.forward(150.0);
turtle.pen_down();
turtle.circle_left(50.0, 360.0, 36);
// Draw a filled triangle
turtle.set_fill_color(GREEN);
turtle.pen_up();
turtle.go_to(vec2(-50.0, 100.0));
turtle.pen_down();
turtle.begin_fill();
for _ in 0..3 {
turtle.forward(80.0);
turtle.right(120.0);
}
turtle.end_fill();
}

View File

@ -23,5 +23,6 @@ fn draw(turtle: &mut TurtlePlan) {
.left(90.0)
.pen_down()
.circle_right(8.0, 360.0, 12)
.end_fill();
.end_fill()
.hide();
}

View File

@ -1,7 +1,7 @@
//! Builder pattern traits for creating turtle command sequences
use crate::commands::{CommandQueue, TurtleCommand};
use crate::general::{AnimationSpeed, Color, Coordinate, FontSize, Precision};
use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Precision};
use crate::shapes::{ShapeType, TurtleShape};
/// Trait for adding commands to a queue
@ -91,10 +91,10 @@ pub trait Turnable: WithCommands {
/// ```
fn left<T>(&mut self, angle: T) -> &mut Self
where
T: Into<Precision>,
T: Into<Degrees>,
{
let degrees: Precision = angle.into();
self.get_commands_mut().push(TurtleCommand::Turn(-degrees));
self.get_commands_mut()
.push(TurtleCommand::Turn(-angle.into()));
self
}
@ -118,10 +118,10 @@ pub trait Turnable: WithCommands {
/// ```
fn right<T>(&mut self, angle: T) -> &mut Self
where
T: Into<Precision>,
T: Into<Degrees>,
{
let degrees: Precision = angle.into();
self.get_commands_mut().push(TurtleCommand::Turn(degrees));
self.get_commands_mut()
.push(TurtleCommand::Turn(angle.into()));
self
}
}
@ -160,13 +160,12 @@ pub trait CurvedMovement: WithCommands {
fn circle_left<R, A>(&mut self, radius: R, angle: A, steps: usize) -> &mut Self
where
R: Into<Precision>,
A: Into<Precision>,
A: Into<Degrees>,
{
let r: Precision = radius.into();
let a: Precision = angle.into();
self.get_commands_mut().push(TurtleCommand::Circle {
radius: r,
angle: a,
angle: angle.into(),
steps,
direction: crate::circle_geometry::CircleDirection::Left,
});
@ -207,13 +206,12 @@ pub trait CurvedMovement: WithCommands {
fn circle_right<R, A>(&mut self, radius: R, angle: A, steps: usize) -> &mut Self
where
R: Into<Precision>,
A: Into<Precision>,
A: Into<Degrees>,
{
let r: Precision = radius.into();
let a: Precision = angle.into();
self.get_commands_mut().push(TurtleCommand::Circle {
radius: r,
angle: a,
angle: angle.into(),
steps,
direction: crate::circle_geometry::CircleDirection::Right,
});
@ -367,9 +365,9 @@ impl TurtlePlan {
/// .forward(100.0);
/// }
/// ```
pub fn set_heading(&mut self, heading: Precision) -> &mut Self {
pub fn set_heading<T: Into<Degrees>>(&mut self, heading: T) -> &mut Self {
self.queue
.push(TurtleCommand::SetHeading(-heading.to_radians()));
.push(TurtleCommand::SetHeading(-heading.into().as_radians()));
self
}

View File

@ -1,7 +1,43 @@
//! Circle geometry calculations - single source of truth for `circle_left` and `circle_right`
use crate::general::Radians;
use macroquad::prelude::*;
/// Generate evenly-spaced points along a circular arc.
///
/// Returns exactly `steps` points, uniformly distributed from (not including)
/// the arc start to (including) the arc end. This is the **single source of
/// truth** for arc sampling used by tessellation, tween stroke drawing, and
/// fill-polygon preview.
///
/// # Arguments
/// * `center` — centre of the circle
/// * `radius` — arc radius
/// * `start_angle` — angle from `center` to the turtle's start position (radians)
/// * `sweep_angle` — total arc sweep in radians (absolute; sign comes from `direction`)
/// * `steps` — number of sample points (clamped to ≥ 1)
/// * `direction` — which way the arc curves
pub(crate) fn arc_points(
center: Vec2,
radius: f32,
start_angle: f32,
sweep_angle: f32,
steps: usize,
direction: CircleDirection,
) -> Vec<Vec2> {
let n = steps.max(1);
let step_size = sweep_angle / n as f32;
(1..=n)
.map(|i| {
let a = match direction {
CircleDirection::Left => start_angle - step_size * i as f32,
CircleDirection::Right => start_angle + step_size * i as f32,
};
Vec2::new(center.x + radius * a.cos(), center.y + radius * a.sin())
})
.collect()
}
/// Direction of circular motion (in screen coordinates with Y-down)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CircleDirection {
@ -10,11 +46,11 @@ pub enum CircleDirection {
}
/// 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,
pub(crate) struct CircleGeometry {
pub(crate) center: Vec2,
pub(crate) radius: f32,
pub(crate) start_angle_from_center: f32, // radians
pub(crate) direction: CircleDirection,
}
impl CircleGeometry {
@ -22,12 +58,15 @@ impl CircleGeometry {
#[must_use]
pub fn new(
turtle_pos: Vec2,
turtle_heading: f32,
turtle_heading: Radians,
radius: f32,
direction: CircleDirection,
) -> Self {
use std::f32::consts::FRAC_PI_2;
// Extract raw f32 once — all arithmetic below is in radians
let heading = turtle_heading.value();
// Calculate center based on direction
// In screen coordinates (Y-down):
// - Left turn (counter-clockwise visually): center is perpendicular-left from turtle's perspective
@ -35,8 +74,8 @@ impl CircleGeometry {
// - 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,
CircleDirection::Left => heading - FRAC_PI_2,
CircleDirection::Right => heading + FRAC_PI_2,
};
let center = vec2(
@ -46,8 +85,8 @@ impl CircleGeometry {
// 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,
CircleDirection::Left => heading + FRAC_PI_2,
CircleDirection::Right => heading - FRAC_PI_2,
};
Self {
@ -151,7 +190,7 @@ mod tests {
fn test_circle_left_geometry() {
let geom = CircleGeometry::new(
vec2(0.0, 0.0),
0.0, // heading east (0 radians)
Radians::new(0.0), // heading east (0 radians)
100.0,
CircleDirection::Left,
);
@ -183,7 +222,7 @@ mod tests {
fn test_circle_right_geometry() {
let geom = CircleGeometry::new(
vec2(0.0, 0.0),
0.0, // heading east
Radians::new(0.0), // heading east
100.0,
CircleDirection::Right,
);

View File

@ -0,0 +1,150 @@
//! Centralised behavioural contract for `TurtleCommand`.
//!
//! All knowledge about what a command does to `TurtleParams`, how long it
//! animates, and whether it produces a drawable stroke lives here.
//!
//! Adding a new `TurtleCommand` variant requires editing this file (and
//! `execute_command_side_effects` / `tessellate_command` in `execution.rs`
//! if the variant has side effects or produces a mesh).
use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::commands::TurtleCommand;
use crate::general::{AnimationSpeed, Radians};
use crate::state::TurtleParams;
use crate::tweening::normalize_angle;
use macroquad::prelude::vec2;
impl TurtleCommand {
/// Apply this command's effect to `params` in place.
///
/// This is the **single source of truth** for what a command changes in
/// `TurtleParams`. Used by:
/// - `execute_command()` — instant-mode path, after side-effects return `false`
/// - `TweenController::calculate_target_state()` — animated-mode target computation
///
/// Variants handled by `execute_command_side_effects` (`BeginFill`, `EndFill`,
/// `PenUp`, `PenDown`, `WriteText`, `Reset`) are included here so that
/// `calculate_target_state` can produce a correct tween target. In the
/// `execute_command()` call path those variants never reach this method because
/// `execute_command_side_effects` returns `true` and the caller returns early —
/// there is no double-application.
pub(crate) fn apply_to_params(&self, params: &mut TurtleParams) {
match self {
TurtleCommand::Move(dist) => {
let dx = dist * params.heading.cos();
let dy = dist * params.heading.sin();
params.position = vec2(params.position.x + dx, params.position.y + dy);
}
TurtleCommand::Turn(angle) => {
params.heading = normalize_angle(params.heading + angle.as_radians().value());
}
TurtleCommand::Circle {
radius,
angle,
direction,
..
} => {
let geom = CircleGeometry::new(
params.position,
Radians::new(params.heading),
*radius,
*direction,
);
let angle_rad = angle.as_radians().value();
params.position = geom.position_at_angle(angle_rad);
params.heading = normalize_angle(match direction {
CircleDirection::Left => params.heading - angle_rad,
CircleDirection::Right => params.heading + angle_rad,
});
}
TurtleCommand::Goto(coord) => {
// Y-flip: turtle graphics Y+ = up; Macroquad Y+ = down
params.position = vec2(coord.x, -coord.y);
}
TurtleCommand::SetHeading(heading) => {
params.heading = normalize_angle(heading.value());
}
TurtleCommand::SetColor(color) => {
params.color = *color;
}
TurtleCommand::SetFillColor(color) => {
params.fill_color = *color;
}
TurtleCommand::SetPenWidth(width) => {
params.pen_width = *width;
}
TurtleCommand::SetSpeed(speed) => {
params.speed = *speed;
}
TurtleCommand::SetShape(shape) => {
params.shape = shape.clone();
}
TurtleCommand::PenUp => {
params.pen_down = false;
}
TurtleCommand::PenDown => {
params.pen_down = true;
}
TurtleCommand::ShowTurtle => {
params.visible = true;
}
TurtleCommand::HideTurtle => {
params.visible = false;
}
TurtleCommand::Reset => {
*params = TurtleParams::default();
}
// Fill/text commands do not change TurtleParams for tweening purposes;
// their effects are handled entirely by execute_command_side_effects.
TurtleCommand::BeginFill | TurtleCommand::EndFill | TurtleCommand::WriteText { .. } => {
}
}
}
/// Duration in seconds for this command's animation at the given speed.
///
/// Returns `0.01` (minimum) for commands that have no animated component.
/// This is the **single source of truth**; replaces
/// `TweenController::calculate_duration_with_state` in `tweening.rs`.
pub(crate) fn animation_duration(&self, params: &TurtleParams, speed: AnimationSpeed) -> f64 {
let AnimationSpeed::Animated(mut spd) = speed else {
// Instant mode — duration is irrelevant; return the minimum so tweener
// infrastructure still has a valid duration if called accidentally.
return f64::from(0.01_f32);
};
// Exponential speed scaling for high values (matches original behaviour)
if spd > 100.0 {
spd *= spd / 100.0;
}
let base: f32 = match self {
TurtleCommand::Move(dist) => dist.abs() / spd,
TurtleCommand::Turn(angle) => angle.value().abs() / (spd * 1.8),
TurtleCommand::Circle { radius, angle, .. } => {
let arc_length = radius * angle.as_radians().value().abs();
arc_length / spd
}
TurtleCommand::Goto(target) => {
let dx = target.x - params.position.x;
let dy = target.y - params.position.y;
(dx * dx + dy * dy).sqrt() / spd
}
_ => 0.0,
};
f64::from(base.max(0.01))
}
/// Whether executing this command (when pen is down) produces a stroke or fill mesh.
///
/// This is the **single source of truth**; replaces
/// `TweenController::command_creates_drawing` in `tweening.rs`.
#[must_use]
pub(crate) fn produces_drawing(&self) -> bool {
matches!(
self,
TurtleCommand::Move(_) | TurtleCommand::Circle { .. } | TurtleCommand::Goto(_)
)
}
}

View File

@ -1,6 +1,6 @@
//! Turtle commands and command queue
use crate::general::{AnimationSpeed, Color, Coordinate, FontSize, Precision};
use crate::general::{AnimationSpeed, Color, Coordinate, Degrees, FontSize, Precision, Radians};
use crate::shapes::TurtleShape;
/// Individual turtle commands
@ -9,13 +9,14 @@ pub enum TurtleCommand {
// Movement (positive = forward, negative = backward)
Move(Precision),
// Rotation (positive = right/clockwise, negative = left/counter-clockwise in degrees)
Turn(Precision),
// Rotation (positive = right/clockwise, negative = left/counter-clockwise)
// Stored in degrees — the natural unit at the user-facing API boundary.
Turn(Degrees),
// Circle drawing
Circle {
radius: Precision,
angle: Precision, // degrees
angle: Degrees, // sweep angle — degrees, as supplied by the user
steps: usize,
direction: crate::circle_geometry::CircleDirection,
},
@ -33,7 +34,8 @@ pub enum TurtleCommand {
// Position
Goto(Coordinate),
SetHeading(Precision), // radians
/// Heading stored as radians — already converted by the builder.
SetHeading(Radians),
// Visibility
ShowTurtle,
@ -53,11 +55,14 @@ pub enum TurtleCommand {
Reset,
}
/// Queue of turtle commands with execution state
/// A pure-data sequence of turtle commands.
///
/// `CommandQueue` is intentionally *not* an `Iterator` — it carries no cursor
/// state. Execution state ("which command are we on?") belongs to the
/// consumer; `TweenController` owns the cursor that walks this queue.
#[derive(Clone, Debug)]
pub struct CommandQueue {
commands: Vec<TurtleCommand>,
current_index: usize,
}
impl CommandQueue {
@ -65,14 +70,12 @@ impl CommandQueue {
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,
}
}
@ -83,26 +86,22 @@ impl CommandQueue {
pub fn extend(&mut self, commands: impl IntoIterator<Item = TurtleCommand>) {
self.commands.extend(commands);
}
/// Return a reference to the command at `index`, or `None` if out of range.
#[must_use]
pub fn is_complete(&self) -> bool {
self.current_index >= self.commands.len()
}
pub fn reset(&mut self) {
self.current_index = 0;
pub fn get(&self, index: usize) -> Option<&TurtleCommand> {
self.commands.get(index)
}
#[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 {
@ -111,16 +110,16 @@ impl Default for CommandQueue {
}
}
impl Iterator for CommandQueue {
/// Consuming iteration — yields every command in order.
///
/// This is used by `CommandQueue::extend` and `TweenController::append_commands`
/// to drain one queue into another. It does *not* imply that `CommandQueue`
/// itself is stateful; the cursor always lives in the consumer.
impl IntoIterator for CommandQueue {
type Item = TurtleCommand;
type IntoIter = std::vec::IntoIter<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
}
fn into_iter(self) -> Self::IntoIter {
self.commands.into_iter()
}
}

View File

@ -76,7 +76,7 @@ pub struct TurtleCommandSender {
///
/// Paired with `TurtleCommandSender` via `turtle_command_channel()`.
/// Automatically managed by `TurtleApp::process_commands()`.
pub struct TurtleCommandReceiver {
pub(crate) struct TurtleCommandReceiver {
turtle_id: usize,
rx: Receiver<CommandQueue>,
}
@ -212,7 +212,7 @@ impl TurtleCommandReceiver {
/// # }
/// ```
#[must_use]
pub fn turtle_command_channel(
pub(crate) fn turtle_command_channel(
turtle_id: usize,
buffer_size: usize,
) -> (TurtleCommandSender, TurtleCommandReceiver) {

View File

@ -12,56 +12,9 @@ use macroquad::prelude::*;
// 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 from all turtles
for turtle in &world.turtles {
for cmd in &turtle.commands {
match cmd {
DrawCommand::Mesh { data, source: _ } => {
// Rendering wie bisher
draw_mesh(&data.to_mesh());
// Hier könnte man das source für Debug/Export loggen
}
DrawCommand::Text {
text,
position,
heading,
font_size,
color,
source: _,
} => {
draw_text_command(text, *position, *heading, *font_size, *color);
// Hier könnte man das source für Debug/Export loggen
}
}
}
}
// Draw all visible turtles
for turtle in &world.turtles {
if turtle.params.visible {
draw_turtle(&turtle.params);
}
}
// Reset to default camera
set_default_camera();
}
/// Render the turtle world with active tween visualization
/// Render the turtle world with active tween visualization.
#[allow(clippy::too_many_lines)]
pub fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
// Update camera zoom based on current screen size to prevent stretching
// Apply user zoom level by dividing by it (smaller zoom value = more zoomed in)
let camera = Camera2D {
@ -80,7 +33,7 @@ pub fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
for turtle in &world.turtles {
for cmd in &turtle.commands {
match cmd {
DrawCommand::Mesh { data, source: _ } => {
DrawCommand::Mesh { data } => {
draw_mesh(&data.to_mesh());
}
DrawCommand::Text {
@ -89,7 +42,6 @@ pub fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
heading,
font_size,
color,
source: _,
} => {
draw_text_command(text, *position, *heading, *font_size, *color);
}
@ -176,40 +128,32 @@ pub fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) {
} = &tween.command
{
// Calculate partial arc vertices based on current progress
use crate::circle_geometry::CircleGeometry;
use crate::circle_geometry::{arc_points, CircleGeometry};
use crate::general::Radians;
let geom = CircleGeometry::new(
tween.start_params.position,
tween.start_params.heading,
Radians::new(tween.start_params.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);
// Delegate to the shared arc_points function — same sampling
// strategy as tessellate_arc, eliminating the divergence.
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);
(((*steps).max(1) as f32 * eased_progress) as usize).max(1);
let sweep_so_far = angle.as_radians().value() * eased_progress;
for pt in arc_points(
geom.center,
*radius,
geom.start_angle_from_center,
sweep_so_far,
samples_to_draw,
*direction,
) {
current_preview.push(pt);
}
} else if matches!(
&tween.command,
@ -347,18 +291,19 @@ fn draw_text_command(
fn draw_tween_arc(
tween: &crate::tweening::CommandTween,
radius: f32,
total_angle: f32,
total_angle: crate::general::Degrees,
steps: usize,
direction: CircleDirection,
) {
use crate::general::Radians;
let geom = CircleGeometry::new(
tween.start_params.position,
tween.start_params.heading,
Radians::new(tween.start_params.heading),
radius,
direction,
);
// Debug: draw center using Lyon tessellation
// Draw center using Lyon tessellation this helps visualizing what is done.
if let Ok(mesh_data) = crate::tessellation::tessellate_circle(geom.center, 5.0, GRAY, true, 1.0)
{
draw_mesh(&mesh_data.to_mesh());
@ -375,7 +320,7 @@ fn draw_tween_arc(
geom.center,
radius,
geom.start_angle_from_center.to_degrees(),
total_angle * progress,
total_angle.value() * progress,
tween.start_params.color,
tween.start_params.pen_width,
((steps as f32 * progress).ceil() as usize).max(1),
@ -386,7 +331,7 @@ fn draw_tween_arc(
}
/// Draw the turtle shape
pub fn draw_turtle(turtle_params: &TurtleParams) {
pub(crate) fn draw_turtle(turtle_params: &TurtleParams) {
let rotated_vertices = turtle_params.shape.rotated_vertices(turtle_params.heading);
if turtle_params.shape.filled {

View File

@ -2,51 +2,120 @@
use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::commands::TurtleCommand;
use crate::state::{DrawCommand, Turtle, TurtleParams, TurtleWorld};
use crate::general::{Coordinate, Radians};
use crate::state::{DrawCommand, FillState, Turtle, TurtleParams, TurtleWorld};
use crate::tessellation;
use macroquad::prelude::*;
#[cfg(test)]
use crate::general::AnimationSpeed;
/// Execute side effects for commands that don't involve movement
/// Returns true if the command was handled (caller should skip movement processing)
#[allow(clippy::too_many_lines)]
pub fn execute_command_side_effects(command: &TurtleCommand, state: &mut Turtle) -> bool {
match command {
TurtleCommand::BeginFill => {
if state.filling.is_some() {
/// Close the current open fill contour (factored out of `Turtle::close_fill_contour`).
fn close_fill_contour(turtle_id: usize, filling: &mut Option<FillState>) {
if let Some(ref mut fill_state) = filling {
tracing::debug!(
turtle_id,
vertices = fill_state.current_contour.len(),
"close_fill_contour called"
);
if fill_state.current_contour.len() >= 2 {
tracing::debug!(
turtle_id,
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"
);
let contour = std::mem::take(&mut fill_state.current_contour);
fill_state.contours.push(contour);
tracing::debug!(
turtle_id,
completed_contours = fill_state.contours.len(),
"Contour moved to completed list"
);
} else if !fill_state.current_contour.is_empty() {
tracing::warn!(
turtle_id = state.turtle_id,
"begin_fill() called while already filling"
turtle_id,
vertices = fill_state.current_contour.len(),
"Current contour has insufficient vertices, not closing"
);
} else {
tracing::warn!(turtle_id, "Current contour is empty, nothing to close");
}
} else {
tracing::warn!(
turtle_id,
"close_fill_contour called but no active fill state"
);
}
let fill_color = state.params.fill_color.unwrap_or_else(|| {
tracing::warn!(
turtle_id = state.turtle_id,
"No fill_color set, using black"
}
/// Begin a new fill contour at `position` (factored out of `Turtle::start_fill_contour`).
fn start_fill_contour(turtle_id: usize, position: Coordinate, filling: &mut Option<FillState>) {
if let Some(ref mut fill_state) = filling {
tracing::debug!(
x = position.x,
y = position.y,
completed_contours = fill_state.contours.len(),
turtle_id,
"Starting new contour"
);
fill_state.current_contour = vec![position];
}
}
/// Execute side effects for commands that don't involve movement.
///
/// Returns `true` if the command was fully handled; the caller should skip
/// params-update and tessellation when this returns `true`.
///
/// Accepts the three logically-separate pieces of turtle state as disjoint
/// mutable borrows so that this function can be called from
/// `TweenController::update(&mut self, …)` without requiring a `&mut Turtle`.
#[allow(clippy::too_many_lines)]
pub(crate) fn execute_command_side_effects(
command: &TurtleCommand,
turtle_id: usize,
params: &mut TurtleParams,
filling: &mut Option<FillState>,
commands: &mut Vec<DrawCommand>,
svg_log: &mut crate::state::SvgLog,
) -> bool {
match command {
TurtleCommand::BeginFill => {
if filling.is_some() {
tracing::warn!(turtle_id, "begin_fill() called while already filling");
}
let fill_color = params.fill_color.unwrap_or_else(|| {
tracing::warn!(turtle_id, "No fill_color set, using black");
BLACK
});
state.begin_fill(fill_color);
*filling = Some(FillState {
start_position: params.position,
contours: Vec::new(),
current_contour: vec![params.position],
fill_color,
});
true
}
TurtleCommand::EndFill => {
if let Some(mut fill_state) = state.filling.take() {
if let Some(mut fill_state) = filling.take() {
if !fill_state.current_contour.is_empty() {
fill_state.contours.push(fill_state.current_contour);
}
let span = tracing::debug_span!(
"end_fill",
turtle_id = state.turtle_id,
turtle_id,
contours = fill_state.contours.len()
);
let _enter = span.enter();
for (i, contour) in fill_state.contours.iter().enumerate() {
tracing::debug!(
turtle_id = state.turtle_id,
turtle_id,
contour_idx = i,
vertices = contour.len(),
"Contour info"
@ -59,85 +128,69 @@ pub fn execute_command_side_effects(command: &TurtleCommand, state: &mut Turtle)
fill_state.fill_color,
) {
tracing::debug!(
turtle_id = state.turtle_id,
turtle_id,
contours = fill_state.contours.len(),
"Successfully created fill mesh - persisting to commands"
);
state.commands.push(DrawCommand::Mesh {
data: mesh_data,
source: crate::state::TurtleSource {
command: crate::commands::TurtleCommand::EndFill,
color: state.params.color,
commands.push(DrawCommand::Mesh { data: mesh_data });
#[cfg(feature = "svg")]
svg_log.push(crate::state::SvgRecord::Fill {
contours: fill_state.contours,
fill_color: fill_state.fill_color,
pen_width: state.params.pen_width,
start_position: fill_state.start_position,
end_position: fill_state.start_position,
start_heading: state.params.heading,
contours: Some(fill_state.contours.clone()),
},
stroke_color: params.color,
});
} else {
tracing::error!(
turtle_id = state.turtle_id,
"Failed to tessellate contours"
);
tracing::error!(turtle_id, "Failed to tessellate contours");
}
}
} else {
tracing::warn!(
turtle_id = state.turtle_id,
"end_fill() called without begin_fill()"
);
tracing::warn!(turtle_id, "end_fill() called without begin_fill()");
}
true
}
TurtleCommand::PenUp => {
state.params.pen_down = false;
if state.filling.is_some() {
tracing::debug!(
turtle_id = state.turtle_id,
"PenUp: Closing current contour"
);
params.pen_down = false;
if filling.is_some() {
tracing::debug!(turtle_id, "PenUp: Closing current contour");
}
state.close_fill_contour();
close_fill_contour(turtle_id, filling);
true
}
TurtleCommand::PenDown => {
state.params.pen_down = true;
if state.filling.is_some() {
params.pen_down = true;
if filling.is_some() {
tracing::debug!(
turtle_id = state.turtle_id,
x = state.params.position.x,
y = state.params.position.y,
turtle_id,
x = params.position.x,
y = params.position.y,
"PenDown: Starting new contour"
);
}
state.start_fill_contour();
start_fill_contour(turtle_id, params.position, filling);
true
}
TurtleCommand::Reset => {
state.reset();
commands.clear();
svg_log.clear();
*filling = None;
*params = TurtleParams::default();
true
}
TurtleCommand::WriteText { text, font_size } => {
state.commands.push(DrawCommand::Text {
commands.push(DrawCommand::Text {
text: text.clone(),
position: state.params.position,
heading: state.params.heading,
position: params.position,
heading: params.heading,
font_size: *font_size,
color: state.params.color,
source: crate::state::TurtleSource {
command: command.clone(),
color: state.params.color,
fill_color: state.params.fill_color.unwrap_or(BLACK),
pen_width: state.params.pen_width,
start_position: state.params.position,
end_position: state.params.position,
start_heading: state.params.heading,
contours: None,
},
color: params.color,
});
#[cfg(feature = "svg")]
svg_log.push(crate::state::SvgRecord::Text {
text: text.clone(),
position: params.position,
color: params.color,
});
true
}
@ -157,14 +210,23 @@ pub fn execute_command_side_effects(command: &TurtleCommand, state: &mut Turtle)
}
}
/// Record fill vertices after movement commands have updated state
#[tracing::instrument]
pub fn record_fill_vertices_after_movement(
/// Record fill vertices after movement commands have updated state.
///
/// `start_state` is the params snapshot taken **before** the command ran.
/// `params` is the current (post-movement) state — `params.position` is the
/// endpoint that gets pushed into the active fill contour.
///
/// Accepts disjoint borrows so it can be called from `TweenController::update`
/// without needing a `&mut Turtle`.
#[tracing::instrument(skip(params, filling))]
pub(crate) fn record_fill_vertices_after_movement(
command: &TurtleCommand,
start_state: &TurtleParams,
state: &mut Turtle,
turtle_id: usize,
params: &TurtleParams,
filling: &mut Option<FillState>,
) {
if state.filling.is_none() {
if filling.is_none() {
return;
}
@ -177,268 +239,242 @@ pub fn record_fill_vertices_after_movement(
} => {
let geom = CircleGeometry::new(
start_state.position,
start_state.heading,
Radians::new(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,
if let Some(ref mut fill_state) = filling {
if params.pen_down {
let num_samples = (*steps as u32).max(1);
tracing::trace!(
turtle_id,
center_x = geom.center.x,
center_y = geom.center.y,
radius,
steps,
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 {
CircleDirection::Left => {
geom.start_angle_from_center - angle.as_radians().value() * progress
}
CircleDirection::Right => {
geom.start_angle_from_center + angle.as_radians().value() * progress
}
};
let vertex = Coordinate::new(
geom.center.x + radius * current_angle.cos(),
geom.center.y + radius * current_angle.sin(),
);
tracing::trace!(
turtle_id,
vertex_idx = i,
x = vertex.x,
y = vertex.y,
angle_degrees = current_angle.to_degrees(),
"Arc vertex"
);
fill_state.current_contour.push(vertex);
}
}
}
}
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
state.record_fill_vertex();
if let Some(ref mut fill_state) = filling {
if params.pen_down {
tracing::trace!(
turtle_id,
x = params.position.x,
y = params.position.y,
vertices = fill_state.current_contour.len() + 1,
"Adding vertex to current contour"
);
fill_state.current_contour.push(params.position);
} else {
tracing::trace!(turtle_id, "Skipping vertex (pen is up)");
}
}
}
_ => {}
}
}
/// Execute a single turtle command, updating state and adding draw commands
#[tracing::instrument]
#[allow(clippy::too_many_lines)]
pub fn execute_command(command: &TurtleCommand, state: &mut Turtle) {
// Try to execute as side-effect-only command first
if execute_command_side_effects(command, state) {
return; // Command fully handled
}
// Store start state for fill vertex recording
let start_state = state.clone();
// Execute movement and appearance commands
match command {
TurtleCommand::Move(distance) => {
let start = state.params.position;
let dx = distance * state.params.heading.cos();
let dy = distance * state.params.heading.sin();
state.params.position =
vec2(state.params.position.x + dx, state.params.position.y + dy);
if state.params.pen_down {
// Draw line segment with round caps (caps handled by tessellate_stroke)
if let Ok(mesh_data) = tessellation::tessellate_stroke(
&[start, state.params.position],
state.params.color,
state.params.pen_width,
false, // not closed
) {
state.commands.push(DrawCommand::Mesh {
data: mesh_data,
source: crate::state::TurtleSource {
command: command.clone(),
color: state.params.color,
fill_color: state.params.fill_color.unwrap_or(BLACK),
pen_width: state.params.pen_width,
start_position: start,
end_position: state.params.position,
start_heading: state.params.heading,
contours: None,
},
});
}
}
}
TurtleCommand::Turn(degrees) => {
state.params.heading += degrees.to_radians();
}
TurtleCommand::Circle {
radius,
angle,
steps,
direction,
} => {
let start_heading = state.params.heading;
let geom =
CircleGeometry::new(state.params.position, start_heading, *radius, *direction);
if state.params.pen_down {
// Use Lyon to tessellate the arc
if let Ok(mesh_data) = tessellation::tessellate_arc(
geom.center,
*radius,
geom.start_angle_from_center.to_degrees(),
*angle,
state.params.color,
state.params.pen_width,
*steps,
*direction,
) {
state.commands.push(DrawCommand::Mesh {
data: mesh_data,
source: crate::state::TurtleSource {
command: command.clone(),
color: state.params.color,
fill_color: state.params.fill_color.unwrap_or(BLACK),
pen_width: state.params.pen_width,
start_position: state.params.position,
end_position: geom.position_at_angle(angle.to_radians()),
start_heading,
contours: None,
},
});
}
}
// Update turtle position and heading
state.params.position = geom.position_at_angle(angle.to_radians());
state.params.heading = match direction {
CircleDirection::Left => start_heading - angle.to_radians(),
CircleDirection::Right => start_heading + angle.to_radians(),
};
}
TurtleCommand::Goto(coord) => {
let start = state.params.position;
// Flip Y coordinate: turtle graphics uses Y+ = up, but Macroquad uses Y+ = down
state.params.position = vec2(coord.x, -coord.y);
if state.params.pen_down {
// Draw line segment with round caps
if let Ok(mesh_data) = tessellation::tessellate_stroke(
&[start, state.params.position],
state.params.color,
state.params.pen_width,
false, // not closed
) {
state.commands.push(DrawCommand::Mesh {
data: mesh_data,
source: crate::state::TurtleSource {
command: command.clone(),
color: state.params.color,
fill_color: state.params.fill_color.unwrap_or(BLACK),
pen_width: state.params.pen_width,
start_position: start,
end_position: state.params.position,
start_heading: state.params.heading,
contours: None,
},
});
}
}
}
// Appearance commands
TurtleCommand::SetColor(color) => state.params.color = *color,
TurtleCommand::SetFillColor(color) => state.params.fill_color = *color,
TurtleCommand::SetPenWidth(width) => state.params.pen_width = *width,
TurtleCommand::SetSpeed(speed) => state.set_speed(*speed),
TurtleCommand::SetShape(shape) => state.params.shape = shape.clone(),
TurtleCommand::SetHeading(heading) => state.params.heading = *heading,
TurtleCommand::ShowTurtle => state.params.visible = true,
TurtleCommand::HideTurtle => state.params.visible = false,
// Reset
TurtleCommand::Reset => {
state.reset();
}
_ => {} // Already handled by execute_command_side_effects
}
// Record fill vertices AFTER movement
record_fill_vertices_after_movement(command, &start_state.params, state);
}
/// Execute command on a specific turtle by ID
pub fn execute_command_with_id(command: &TurtleCommand, turtle_id: usize, world: &mut TurtleWorld) {
// Clone turtle state to avoid borrow checker issues
if let Some(turtle) = world.get_turtle(turtle_id) {
let mut state = turtle.clone();
execute_command(command, &mut state);
// Update the turtle state back
if let Some(turtle_mut) = world.get_turtle_mut(turtle_id) {
*turtle_mut = state;
}
}
}
/// Add drawing command for a completed tween
pub fn add_draw_for_completed_tween(
/// Tessellate a completed movement command into a [`DrawCommand`] mesh.
///
/// Returns `None` if the pen was up or the command does not produce a drawing.
///
/// `end_position` is the turtle's position after the command completed:
/// - instant-mode: `state.params.position` after [`TurtleCommand::apply_to_params`]
/// - animated-mode: `tween.target_params.position` when the tween finishes
///
/// This is the **single** tessellation site for all committed line/arc meshes.
/// It replaces both the inline tessellation inside `execute_command` and the
/// now-deleted `add_draw_for_completed_tween`.
pub(crate) fn tessellate_command(
command: &TurtleCommand,
start_state: &TurtleParams,
end_state: &mut TurtleParams,
start: &TurtleParams,
end_position: Vec2,
) -> Option<DrawCommand> {
if !start.pen_down || !command.produces_drawing() {
return None;
}
match command {
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
if start_state.pen_down {
if let Ok(mesh_data) = tessellation::tessellate_stroke(
&[start_state.position, end_state.position],
start_state.color,
start_state.pen_width,
let mesh_data = tessellation::tessellate_stroke(
&[start.position, end_position],
start.color,
start.pen_width,
false,
) {
return Some(DrawCommand::Mesh {
data: mesh_data,
source: crate::state::TurtleSource {
command: command.clone(),
color: start_state.color,
fill_color: start_state.fill_color.unwrap_or(BLACK),
pen_width: start_state.pen_width,
start_position: start_state.position,
end_position: end_state.position,
start_heading: start_state.heading,
contours: None,
},
});
}
}
)
.ok()?;
Some(DrawCommand::Mesh { data: mesh_data })
}
TurtleCommand::Circle {
radius,
angle,
steps,
direction,
} => {
if start_state.pen_down {
use crate::circle_geometry::CircleGeometry;
let geom = CircleGeometry::new(
start_state.position,
start_state.heading,
start.position,
Radians::new(start.heading),
*radius,
*direction,
);
if let Ok(mesh_data) = tessellation::tessellate_arc(
let mesh_data = tessellation::tessellate_arc(
geom.center,
*radius,
geom.start_angle_from_center.to_degrees(),
*angle,
start_state.color,
start_state.pen_width,
angle.value(),
start.color,
start.pen_width,
*steps,
*direction,
)
.ok()?;
Some(DrawCommand::Mesh { data: mesh_data })
}
// `produces_drawing()` guards entry — this arm is only reachable if
// `produces_drawing` and the match above diverge, which would be a bug.
_ => None,
}
}
/// Push an [`SvgRecord`] for a completed line or arc drawing command.
///
/// Only compiled when the `svg` feature is enabled.
/// Must be called at the same call sites as `tessellate_command` so that
/// `svg_log` stays in sync with `commands`.
#[cfg(feature = "svg")]
pub(crate) fn push_svg_for_draw(
command: &TurtleCommand,
start: &TurtleParams,
end_position: Vec2,
svg_log: &mut crate::state::SvgLog,
) {
return Some(DrawCommand::Mesh {
data: mesh_data,
source: crate::state::TurtleSource {
command: command.clone(),
color: start_state.color,
fill_color: start_state.fill_color.unwrap_or(BLACK),
pen_width: start_state.pen_width,
start_position: start_state.position,
end_position: end_state.position,
start_heading: start_state.heading,
contours: None,
},
use crate::state::SvgRecord;
match command {
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
svg_log.push(SvgRecord::Line {
start: start.position,
end: end_position,
color: start.color,
pen_width: start.pen_width,
});
}
TurtleCommand::Circle {
radius,
angle,
direction,
..
} => {
svg_log.push(SvgRecord::Arc {
start_position: start.position,
start_heading: start.heading,
radius: *radius,
angle: *angle,
direction: *direction,
color: start.color,
pen_width: start.pen_width,
});
}
_ => {}
}
}
_ => (),
/// Execute a single turtle command, updating state and adding draw commands.
#[tracing::instrument(skip(state))]
pub(crate) fn execute_command(command: &TurtleCommand, state: &mut Turtle) {
// Phase 1: side effects (fills, pen contours, reset, text).
// Returns true if the command is fully handled — no params update or tessellation needed.
if execute_command_side_effects(
command,
state.turtle_id,
&mut state.params,
&mut state.filling,
&mut state.commands,
&mut state.svg_log,
) {
return;
}
// Phase 2: update TurtleParams (position, heading, colour, speed, etc.)
let start_params = state.params.clone();
command.apply_to_params(&mut state.params);
// Phase 3: record fill vertices after movement (must follow params update)
record_fill_vertices_after_movement(
command,
&start_params,
state.turtle_id,
&state.params,
&mut state.filling,
);
// Phase 4: tessellate, push SVG record, and persist the committed drawing
if let Some(draw_cmd) = tessellate_command(command, &start_params, state.params.position) {
#[cfg(feature = "svg")]
push_svg_for_draw(
command,
&start_params,
state.params.position,
&mut state.svg_log,
);
state.commands.push(draw_cmd);
}
}
/// Execute command on a specific turtle by ID.
///
/// There is no ownership conflict here: `execute_command` only needs `&mut Turtle`
/// and never touches `TurtleWorld`, so we can obtain the mutable reference directly
/// from `get_turtle_mut` without any intermediate clone.
pub(crate) fn execute_command_with_id(
command: &TurtleCommand,
turtle_id: usize,
world: &mut TurtleWorld,
) {
if let Some(turtle) = world.get_turtle_mut(turtle_id) {
execute_command(command, turtle);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::TurtleCommand;
use crate::general::Degrees;
use crate::shapes::TurtleShape;
use crate::TweenController;
use crate::tweening::TweenController;
#[test]
fn test_forward_left_forward() {
@ -461,6 +497,7 @@ mod tests {
},
filling: None,
commands: Vec::new(),
svg_log: crate::state::SvgLog::default(),
tween_controller: TweenController::default(),
};
@ -500,7 +537,7 @@ mod tests {
// Left 90 degrees - should face north (heading decreases by 90°)
// In screen coords: north = -90° = -π/2
execute_command(&TurtleCommand::Turn(-90.0), &mut state);
execute_command(&TurtleCommand::Turn(Degrees::new(-90.0)), &mut state);
assert!(
(state.params.position.x - 100.0).abs() < 0.01,
"After left(90): x = {}",

View File

@ -1,22 +1,23 @@
//! Export-Backend-Trait und zentrale Export-Typen
//! Export backend trait and core export types.
use crate::state::TurtleWorld;
use crate::TurtlePlan;
#[derive(Debug)]
pub enum ExportError {
Io(std::io::Error),
Format(String),
// Weitere Formate können ergänzt werden
// Additional formats can be added here.
}
#[derive(Clone, Copy, Debug)]
pub enum DrawingFormat {
#[cfg(feature = "svg")]
Svg,
// Weitere Formate wie Png, Pdf, ...
// Additional formats: Png, Pdf, …
}
pub trait DrawingExporter {
pub(crate) trait DrawingExporter {
/// Export the drawing to the specified format and filename
///
/// # Errors
@ -24,3 +25,62 @@ pub trait DrawingExporter {
/// Returns an error if the export fails (e.g., file I/O error)
fn export(&self, world: &TurtleWorld, filename: &str) -> Result<(), ExportError>;
}
pub(crate) fn parse_svg_export_arg() -> Option<String> {
let args: Vec<String> = std::env::args().collect();
let mut i = 1;
while i < args.len() {
if args[i] == "--export-svg" && i + 1 < args.len() {
return Some(args[i + 1].clone());
}
i += 1;
}
None
}
/// Handle the optional `--export-svg` CLI flag.
///
/// The feature gating lives inside `turtle-lib`, so the `turtle_main` macro
/// no longer needs to reference cfg flags from the consuming crate.
pub fn handle_svg_export<F>(build_commands: F)
where
F: FnMut(&mut TurtlePlan),
{
// Avoid unused warnings when the feature is disabled
let _ = &build_commands;
if let Some(filename) = parse_svg_export_arg() {
#[cfg(feature = "svg")]
{
let mut build_commands = build_commands;
let mut turtle = crate::create_turtle_plan();
build_commands(&mut turtle);
let mut app = crate::TurtleApp::new().with_commands(turtle.build());
app.set_all_turtles_speed(crate::AnimationSpeed::Instant(1000));
while !app.all_animations_complete() {
app.update();
}
match app.export_drawing(&filename, crate::export::DrawingFormat::Svg) {
Ok(_) => {
println!("SVG exported successfully to: {}", filename);
std::process::exit(0);
}
Err(e) => {
eprintln!("Error exporting SVG: {:?}", e);
std::process::exit(1);
}
}
}
#[cfg(not(feature = "svg"))]
{
let _ = &filename;
eprintln!("Error: SVG export feature is not enabled.");
eprintln!("Please rebuild with --features svg");
std::process::exit(1);
}
}
}

View File

@ -1,13 +1,12 @@
//! SVG-Export-Backend für TurtleWorld
//! SVG export backend for `TurtleWorld`.
#[cfg(feature = "svg")]
pub mod svg_export {
use crate::commands::TurtleCommand;
use crate::export::{DrawingExporter, ExportError};
use crate::state::{DrawCommand, TurtleWorld};
use crate::state::{SvgRecord, TurtleWorld};
use std::fs::File;
use svg::{
node::element::{Circle, Line, Polygon, Text as SvgText},
node::element::{Circle, Line, Text as SvgText},
Document,
};
@ -37,47 +36,50 @@ pub mod svg_export {
}
for turtle in &world.turtles {
for cmd in &turtle.commands {
match cmd {
DrawCommand::Mesh { source, .. } => {
match &source.command {
TurtleCommand::Move(_) | TurtleCommand::Goto(_) => {
// Linie als <line>
let start = source.start_position;
let end = source.end_position;
for record in &turtle.svg_log.records {
match record {
SvgRecord::Line {
start,
end,
color,
pen_width,
} => {
update_bounds(
&mut min_x, &mut max_x, &mut min_y, &mut max_y, start.x,
start.y,
&mut min_x, &mut max_x, &mut min_y, &mut max_y, start.x, start.y,
);
update_bounds(
&mut min_x, &mut max_x, &mut min_y, &mut max_y, end.x,
end.y,
&mut min_x, &mut max_x, &mut min_y, &mut max_y, end.x, end.y,
);
let line = Line::new()
.set("x1", start.x)
.set("y1", start.y)
.set("x2", end.x)
.set("y2", end.y)
.set("stroke", color_to_svg(source.color))
.set("stroke-width", source.pen_width);
.set("stroke", color_to_svg(*color))
.set("stroke-width", *pen_width);
doc = doc.add(line);
}
TurtleCommand::Circle {
SvgRecord::Arc {
start_position,
start_heading,
radius,
angle,
direction,
..
color,
pen_width,
} => {
use crate::circle_geometry::CircleGeometry;
use crate::general::Radians;
let geom = CircleGeometry::new(
source.start_position,
source.start_heading,
*start_position,
Radians::new(*start_heading),
*radius,
*direction,
);
let center = geom.center;
if (*angle - 360.0).abs() < 1e-3 {
// Voller Kreis
// Include the bounding box of the full circle so partial arcs
// are never clipped.
update_bounds(
&mut min_x,
&mut max_x,
@ -94,67 +96,55 @@ pub mod svg_export {
center.x + radius,
center.y + radius,
);
if (angle.value() - 360.0).abs() < 1e-3 {
// Full circle — emit as <circle>
let circle = Circle::new()
.set("cx", center.x)
.set("cy", center.y)
.set("r", *radius)
.set("stroke", color_to_svg(source.color))
.set("stroke-width", source.pen_width)
.set("stroke", color_to_svg(*color))
.set("stroke-width", *pen_width)
.set("fill", "none");
doc = doc.add(circle);
} else {
// Kreisbogen als <path>
let start = source.start_position;
let end = source.end_position;
// For arcs, include the full circle bounds to ensure complete visibility
update_bounds(
&mut min_x,
&mut max_x,
&mut min_y,
&mut max_y,
center.x - radius,
center.y - radius,
);
update_bounds(
&mut min_x,
&mut max_x,
&mut min_y,
&mut max_y,
center.x + radius,
center.y + radius,
);
let large_arc = if *angle > 180.0 { 1 } else { 0 };
// Partial arc — emit as <path A …>
let end = geom.position_at_angle(angle.as_radians().value());
let large_arc = if angle.value() > 180.0 { 1 } else { 0 };
let sweep = match direction {
crate::circle_geometry::CircleDirection::Left => 0,
crate::circle_geometry::CircleDirection::Right => 1,
};
let d = format!(
"M {} {} A {} {} 0 {} {} {} {}",
start.x,
start.y,
start_position.x,
start_position.y,
radius,
radius,
large_arc,
sweep,
end.x,
end.y
end.y,
);
let path = svg::node::element::Path::new()
.set("d", d)
.set("stroke", color_to_svg(source.color))
.set("stroke-width", source.pen_width)
.set("stroke", color_to_svg(*color))
.set("stroke-width", *pen_width)
.set("fill", "none");
doc = doc.add(path);
}
}
TurtleCommand::EndFill => {
// Fills werden als <path> mit Konturen ausgegeben
if let Some(contours) = &source.contours {
SvgRecord::Fill {
contours,
fill_color,
stroke_color,
} => {
for contour in contours {
for point in contour {
update_bounds(
&mut min_x, &mut max_x, &mut min_y, &mut max_y,
point.x, point.y,
&mut min_x, &mut max_x, &mut min_y, &mut max_y, point.x,
point.y,
);
}
}
@ -164,15 +154,9 @@ pub mod svg_export {
if i > 0 {
d.push(' ');
}
d.push_str(&format!(
"M {} {}",
contour[0].x, contour[0].y
));
d.push_str(&format!("M {} {}", contour[0].x, contour[0].y));
for point in contour.iter().skip(1) {
d.push_str(&format!(
" L {} {}",
point.x, point.y
));
d.push_str(&format!(" L {} {}", point.x, point.y));
}
d.push_str(" Z");
}
@ -180,63 +164,17 @@ pub mod svg_export {
if !d.is_empty() {
let path = svg::node::element::Path::new()
.set("d", d)
.set("fill", color_to_svg(source.fill_color))
.set("fill", color_to_svg(*fill_color))
.set("fill-rule", "evenodd")
.set("stroke", color_to_svg(source.color));
.set("stroke", color_to_svg(*stroke_color));
doc = doc.add(path);
}
} else {
// Fallback: Dummy-Polygon
update_bounds(
&mut min_x,
&mut max_x,
&mut min_y,
&mut max_y,
source.start_position.x,
source.start_position.y,
);
update_bounds(
&mut min_x,
&mut max_x,
&mut min_y,
&mut max_y,
source.start_position.x + 10.0,
source.start_position.y + 10.0,
);
update_bounds(
&mut min_x,
&mut max_x,
&mut min_y,
&mut max_y,
source.start_position.x + 5.0,
source.start_position.y + 15.0,
);
let poly = Polygon::new()
.set(
"points",
format!(
"{},{} {},{} {},{}",
source.start_position.x,
source.start_position.y,
source.start_position.x + 10.0,
source.start_position.y + 10.0,
source.start_position.x + 5.0,
source.start_position.y + 15.0
),
)
.set("fill", color_to_svg(source.fill_color))
.set("stroke", color_to_svg(source.color));
doc = doc.add(poly);
}
}
_ => {}
}
}
DrawCommand::Text {
SvgRecord::Text {
text,
position,
source,
..
color,
} => {
update_bounds(
&mut min_x, &mut max_x, &mut min_y, &mut max_y, position.x,
@ -245,7 +183,7 @@ pub mod svg_export {
let txt = SvgText::new()
.set("x", position.x)
.set("y", position.y)
.set("fill", color_to_svg(source.color))
.set("fill", color_to_svg(*color))
.add(svg::node::Text::new(text.clone()));
doc = doc.add(txt);
}
@ -260,7 +198,6 @@ pub mod svg_export {
let view_box = format!("{} {} {} {}", min_x - 20.0, min_y - 20.0, width, height);
doc = doc.set("viewBox", view_box);
} else {
// Default viewBox if no elements
doc = doc.set("viewBox", "0 0 400 400");
}

View File

@ -6,7 +6,7 @@ pub mod angle;
pub mod fontsize;
pub mod length;
pub use angle::Angle;
pub use angle::{Degrees, Radians};
pub use fontsize::FontSize;
pub use length::Length;

View File

@ -1,205 +1,162 @@
//! Angle type with degrees and radians support
//! Angle unit newtypes: `Degrees` and `Radians`.
//!
//! ## Design
//!
//! Two separate types instead of a single enum so that function signatures are
//! self-documenting and the compiler rejects wrong-unit arguments.
//!
//! - **`Degrees`** — public API boundary. Builder methods and `TurtleCommand`
//! fields that originate from user input store this type. Convert with
//! `as_radians()` before entering the rendering pipeline.
//!
//! - **`Radians`** — internal pipeline. All geometry functions and
//! `TurtleParams` arithmetic work in radians. Extract the raw `f32` with
//! `value()` only where stdlib trig functions (`sin`, `cos`, …) require it.
//!
//! There is intentionally **no** conversion from `Radians` back to `f32` that
//! strips the unit tag silently — use `.value()` explicitly and at the last
//! possible moment.
use super::Precision;
use std::ops::{Add, Div, Mul, Neg, Rem, Sub};
use std::ops::Neg;
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum AngleUnit {
Degrees(Precision),
Radians(Precision),
/// An angle measured in degrees.
///
/// Used at the public API boundary. Convert to [`Radians`] with `as_radians()`
/// before passing into internal rendering functions.
#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Default)]
pub struct Degrees(pub Precision);
impl Degrees {
/// Construct from a raw degrees value.
#[must_use]
pub fn new(v: Precision) -> Self {
Self(v)
}
impl Default for AngleUnit {
fn default() -> Self {
Self::Degrees(0.0)
/// Convert to [`Radians`] for use in the rendering pipeline.
///
/// This is the **only** correct way to enter the internal math layer.
#[must_use]
pub fn as_radians(self) -> Radians {
Radians(self.0.to_radians())
}
/// The raw degrees value.
///
/// Use only for degree-to-degree arithmetic (e.g. negating a turn angle
/// before storing it as a command). Do not pass this to trig functions.
#[must_use]
pub fn value(self) -> Precision {
self.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 {
impl Neg for Degrees {
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),
}
fn neg(self) -> Self {
Self(-self.0)
}
}
impl Mul<Precision> for Angle {
impl From<f32> for Degrees {
fn from(v: f32) -> Self {
Self(v)
}
}
impl From<i32> for Degrees {
fn from(v: i32) -> Self {
Self(v as Precision)
}
}
impl From<i16> for Degrees {
fn from(v: i16) -> Self {
Self(Precision::from(v))
}
}
// ─────────────────────────────────────────────────────────────────────────────
/// An angle measured in radians.
///
/// Used in all internal function signatures and geometry math. Extract the
/// raw `f32` with [`value()`](Radians::value) only when calling stdlib trig
/// functions (`sin`, `cos`, etc.).
#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Default)]
pub struct Radians(pub Precision);
impl Radians {
/// Construct from a raw radians value.
#[must_use]
pub fn new(v: Precision) -> Self {
Self(v)
}
/// Convert to [`Degrees`] for display or user-facing output.
#[must_use]
pub fn as_degrees(self) -> Degrees {
Degrees(self.0.to_degrees())
}
/// The raw radians value.
///
/// Use only when calling stdlib trig functions or other `f32`-based
/// math APIs. Keep `Radians` as the type at all internal function
/// boundaries.
#[must_use]
pub fn value(self) -> Precision {
self.0
}
}
impl Neg for Radians {
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),
fn neg(self) -> Self {
Self(-self.0)
}
}
#[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)),
}
impl From<f32> for Radians {
fn from(v: f32) -> Self {
Self(v)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::PI;
#[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);
fn degrees_to_radians_roundtrip() {
let deg = Degrees::new(180.0);
let rad = deg.as_radians();
assert!(
(rad.value() - PI).abs() < 1e-6,
"expected π, got {}",
rad.value()
);
let back = rad.as_degrees();
assert!(
(back.value() - 180.0).abs() < 1e-4,
"expected 180°, got {}",
back.value()
);
}
#[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);
fn negation() {
assert_eq!(-Degrees::new(90.0), Degrees::new(-90.0));
assert_eq!(-Radians::new(1.0), Radians::new(-1.0));
}
#[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);
fn from_integer() {
let d: Degrees = 90_i32.into();
assert_eq!(d, Degrees::new(90.0));
let d2: Degrees = 45_i16.into();
assert_eq!(d2, Degrees::new(45.0));
}
}

View File

@ -46,30 +46,29 @@
//! }
//! ```
pub mod builders;
pub mod circle_geometry;
pub mod commands;
pub mod commands_channel;
pub mod drawing;
pub mod execution;
pub mod general;
pub mod shapes;
pub mod state;
pub mod tessellation;
pub mod tweening;
pub(crate) mod builders;
pub(crate) mod circle_geometry;
pub(crate) mod command_behavior;
pub(crate) mod commands;
pub(crate) mod commands_channel;
pub(crate) mod drawing;
pub(crate) mod execution;
pub(crate) mod general;
pub(crate) mod shapes;
pub(crate) mod state;
pub(crate) mod tessellation;
pub(crate) mod tweening;
// Re-export commonly used types
pub use builders::{CurvedMovement, DirectionalMovement, Turnable, TurtlePlan, WithCommands};
pub use commands::{CommandQueue, TurtleCommand};
pub use commands_channel::{turtle_command_channel, TurtleCommandReceiver, TurtleCommandSender};
pub use general::{Angle, AnimationSpeed, Color, Coordinate, Length, Precision};
pub use commands_channel::TurtleCommandSender;
pub use general::{AnimationSpeed, Color, Coordinate, Degrees, Length, Precision, Radians};
pub use shapes::{ShapeType, TurtleShape};
pub use state::{DrawCommand, Turtle, TurtleWorld};
pub use tweening::TweenController;
pub mod export;
#[cfg(feature = "svg")]
pub mod export_svg;
pub(crate) mod export_svg;
// Re-export the turtle_main macro
pub use turtle_lib_macros::turtle_main;
@ -79,6 +78,8 @@ pub use macroquad::prelude::{
vec2, BLACK, BLUE, DARKGRAY, GOLD, GREEN, ORANGE, PURPLE, RED, WHITE, YELLOW,
};
use crate::commands_channel::TurtleCommandReceiver;
use crate::state::TurtleWorld;
use macroquad::prelude::*;
use std::collections::HashMap;
@ -95,9 +96,7 @@ pub struct TurtleApp {
}
impl TurtleApp {
/// Exportiere das aktuelle Drawing in das gewünschte Format
#[allow(unused_variables)]
/// Export the current drawing to a file in the specified format
/// Export the current drawing to a file in the specified format.
///
/// # Errors
///
@ -115,10 +114,10 @@ impl TurtleApp {
let exporter = SvgExporter;
exporter.export(&self.world, filename)
}
// Weitere Formate können hier ergänzt werden
// Additional formats can be registered here.
#[allow(unreachable_patterns)]
_ => Err(export::ExportError::Format(
"Export-Format nicht unterstützt".to_string(),
"Unsupported export format".to_string(),
)),
}
}
@ -290,20 +289,25 @@ impl TurtleApp {
// Update all turtles' tween controllers
for turtle in &mut self.world.turtles {
// Extract draw_commands and controller temporarily to avoid borrow conflicts
// Update the controller
let completed_commands = TweenController::update(turtle);
// Drive this turtle's animation controller for one frame.
// `update_tweens` splits &mut Turtle into disjoint field borrows so
// TweenController::update can be a proper &mut self method.
let completed_commands = turtle.update_tweens();
// Process all completed commands and add to the turtle's commands
for (completed_cmd, tween_start, mut end_state) in completed_commands {
let draw_command = execution::add_draw_for_completed_tween(
for (completed_cmd, tween_start, end_state) in completed_commands {
if let Some(draw_cmd) =
execution::tessellate_command(&completed_cmd, &tween_start, end_state.position)
{
#[cfg(feature = "svg")]
execution::push_svg_for_draw(
&completed_cmd,
&tween_start,
&mut end_state,
end_state.position,
&mut turtle.svg_log,
);
// Add the new draw commands to the turtle
turtle.commands.extend(draw_command);
turtle.commands.push(draw_cmd);
}
}
}
}
@ -367,14 +371,31 @@ impl TurtleApp {
.all(|turtle| turtle.tween_controller.is_complete())
}
/// Check if all animations are complete (alias for is_complete)
#[must_use]
pub fn all_animations_complete(&self) -> bool {
self.is_complete()
}
/// Set the animation speed for all turtles
///
/// # Arguments
/// * `speed` - The animation speed to set for all turtles
pub fn set_all_turtles_speed(&mut self, speed: AnimationSpeed) {
for turtle in &mut self.world.turtles {
turtle.set_speed(speed);
turtle.tween_controller.set_speed(speed);
}
}
/// Get reference to the world state
#[must_use]
pub fn world(&self) -> &TurtleWorld {
pub(crate) fn world(&self) -> &TurtleWorld {
&self.world
}
/// Get mutable reference to the world state
pub fn world_mut(&mut self) -> &mut TurtleWorld {
pub(crate) fn world_mut(&mut self) -> &mut TurtleWorld {
&mut self.world
}
}

View File

@ -1,40 +1,40 @@
//! Turtle state and world state management
use crate::commands::CommandQueue;
use crate::general::{Angle, AnimationSpeed, Color, Coordinate};
use crate::general::{AnimationSpeed, Color, Coordinate};
use crate::shapes::TurtleShape;
use crate::tweening::TweenController;
use macroquad::prelude::*;
/// State during active fill operation
#[derive(Clone, Debug)]
pub struct FillState {
pub(crate) struct FillState {
/// Starting position of the fill
pub start_position: Coordinate,
pub(crate) 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>>,
pub(crate) contours: Vec<Vec<Coordinate>>,
/// Current contour being built (vertices for the active `pen_down` segment)
pub current_contour: Vec<Coordinate>,
pub(crate) current_contour: Vec<Coordinate>,
/// Fill color (cached from when `begin_fill` was called)
pub fill_color: Color,
pub(crate) fill_color: Color,
}
/// Parameters that define a turtle's visual state
#[derive(Clone, Debug)]
pub struct TurtleParams {
pub position: Vec2,
pub heading: f32,
pub pen_down: bool,
pub pen_width: f32,
pub color: Color,
pub fill_color: Option<Color>,
pub visible: bool,
pub shape: crate::shapes::TurtleShape,
pub speed: AnimationSpeed,
pub(crate) struct TurtleParams {
pub(crate) position: Vec2,
pub(crate) heading: f32,
pub(crate) pen_down: bool,
pub(crate) pen_width: f32,
pub(crate) color: Color,
pub(crate) fill_color: Option<Color>,
pub(crate) visible: bool,
pub(crate) shape: crate::shapes::TurtleShape,
pub(crate) speed: AnimationSpeed,
}
impl Default for TurtleParams {
@ -56,18 +56,21 @@ impl Default for TurtleParams {
/// State of a single turtle
#[derive(Clone, Debug)]
pub struct Turtle {
pub turtle_id: usize,
pub params: TurtleParams,
pub(crate) struct Turtle {
pub(crate) turtle_id: usize,
pub(crate) params: TurtleParams,
// Fill tracking
pub filling: Option<FillState>,
pub(crate) filling: Option<FillState>,
// Drawing commands created by this turtle
pub commands: Vec<DrawCommand>,
pub(crate) commands: Vec<DrawCommand>,
// SVG draw-event log — populated alongside `commands`, consumed by the SVG exporter
pub(crate) svg_log: SvgLog,
// Animation controller for this turtle
pub tween_controller: TweenController,
pub(crate) tween_controller: TweenController,
}
impl Default for Turtle {
@ -77,6 +80,7 @@ impl Default for Turtle {
params: TurtleParams::default(),
filling: None,
commands: Vec::new(),
svg_log: SvgLog::default(),
tween_controller: TweenController::new(CommandQueue::new(), AnimationSpeed::default()),
}
}
@ -88,14 +92,15 @@ impl Turtle {
}
#[must_use]
pub fn heading_angle(&self) -> Angle {
Angle::radians(self.params.heading)
pub fn heading_angle(&self) -> crate::general::Radians {
crate::general::Radians::new(self.params.heading)
}
/// Reset turtle to default state (preserves `turtle_id` and queued commands)
pub fn reset(&mut self) {
// Clear all drawings
self.commands.clear();
self.svg_log.clear();
// Clear fill state
self.filling = None;
@ -106,6 +111,27 @@ impl Turtle {
// Keep turtle_id and tween_controller (preserves queued commands)
}
/// Drive the animation controller for one frame.
///
/// Returns `(command, start_params, end_params)` for every command that
/// completed this frame and whose stroke needs to be tessellated by the
/// caller into a `DrawCommand`.
///
/// This method performs the correct disjoint field-borrow split so that
/// `TweenController::update` can be a proper `&mut self` method instead
/// of the old static-method borrow-checker workaround.
pub fn update_tweens(
&mut self,
) -> Vec<(crate::commands::TurtleCommand, TurtleParams, TurtleParams)> {
self.tween_controller.update(
self.turtle_id,
&mut self.params,
&mut self.filling,
&mut self.commands,
&mut self.svg_log,
)
}
/// Start recording fill vertices
pub fn begin_fill(&mut self, fill_color: Color) {
self.filling = Some(FillState {
@ -257,11 +283,75 @@ impl Turtle {
}
}
/// The draw-event log for SVG export.
///
/// When the `svg` feature is **disabled** this is a zero-sized type (ZST) with
/// no fields — it compiles away entirely and adds zero overhead to `Turtle`.
/// When the feature is **enabled** it owns a `Vec<SvgRecord>` that the SVG
/// exporter consumes after rendering.
///
/// All methods are always callable so function signatures that accept
/// `&mut SvgLog` need no feature-gating at the parameter level.
#[derive(Clone, Debug, Default)]
pub(crate) struct SvgLog {
#[cfg(feature = "svg")]
pub(crate) records: Vec<SvgRecord>,
}
impl SvgLog {
pub(crate) fn clear(&mut self) {
#[cfg(feature = "svg")]
self.records.clear();
}
#[cfg(feature = "svg")]
pub(crate) fn push(&mut self, record: SvgRecord) {
self.records.push(record);
}
}
/// A drawing event captured for SVG export.
///
/// Only compiled when the `svg` feature is enabled.
#[cfg(feature = "svg")]
#[derive(Clone, Debug)]
pub(crate) enum SvgRecord {
/// A straight-line stroke.
Line {
start: Vec2,
end: Vec2,
color: Color,
pen_width: f32,
},
/// An arc or full-circle stroke.
Arc {
start_position: Vec2,
start_heading: f32,
radius: crate::general::Precision,
angle: crate::general::Degrees,
direction: crate::circle_geometry::CircleDirection,
color: Color,
pen_width: f32,
},
/// A filled region (potentially with holes via the even-odd rule).
Fill {
contours: Vec<Vec<crate::general::Coordinate>>,
fill_color: Color,
stroke_color: Color,
},
/// A text element.
Text {
text: String,
position: Vec2,
color: Color,
},
}
/// 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>,
pub(crate) struct MeshData {
pub(crate) vertices: Vec<macroquad::prelude::Vertex>,
pub(crate) indices: Vec<u16>,
}
impl MeshData {
@ -275,44 +365,28 @@ impl MeshData {
}
}
/// Drawable elements in the world
/// All drawing is done via Lyon-tessellated meshes for consistency and quality
/// Drawable elements in the world.
/// All drawing is done via Lyon-tessellated meshes for consistency and quality.
#[derive(Clone, Debug)]
pub struct TurtleSource {
pub command: crate::commands::TurtleCommand,
pub color: Color,
pub fill_color: Color,
pub pen_width: f32,
pub start_position: Vec2,
pub end_position: Vec2,
pub start_heading: f32,
pub contours: Option<Vec<Vec<crate::general::Coordinate>>>,
}
#[derive(Clone, Debug)]
pub enum DrawCommand {
/// Pre-tessellated mesh data (lines, arcs, circles, polygons - all use this)
Mesh {
data: MeshData,
source: TurtleSource,
},
/// Text rendering command
pub(crate) enum DrawCommand {
/// Pre-tessellated mesh data (lines, arcs, circles, polygons — all use this).
Mesh { data: MeshData },
/// Text rendering command.
Text {
text: String,
position: Vec2,
heading: f32,
font_size: crate::general::FontSize,
color: Color,
source: TurtleSource,
},
}
/// The complete turtle world containing all drawing state
pub struct TurtleWorld {
pub(crate) struct TurtleWorld {
/// All turtles in the world (indexed by turtle ID)
pub turtles: Vec<Turtle>,
pub camera: Camera2D,
pub background_color: Color,
pub(crate) turtles: Vec<Turtle>,
pub(crate) camera: Camera2D,
pub(crate) background_color: Color,
}
impl TurtleWorld {

View File

@ -14,26 +14,30 @@ use macroquad::prelude::*;
/// Convert macroquad Vec2 to Lyon Point
#[must_use]
pub fn to_lyon_point(v: Vec2) -> Point {
pub(crate) 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 {
pub(crate) 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],
pub(crate) struct SimpleVertex {
pub(crate) position: [f32; 2],
}
/// Build mesh data from Lyon tessellation
#[must_use]
pub fn build_mesh_data(vertices: &[SimpleVertex], indices: &[u16], color: Color) -> MeshData {
pub(crate) fn build_mesh_data(
vertices: &[SimpleVertex],
indices: &[u16],
color: Color,
) -> MeshData {
let verts: Vec<Vertex> = vertices
.iter()
.map(|v| Vertex {
@ -62,7 +66,7 @@ pub fn build_mesh_data(vertices: &[SimpleVertex], indices: &[u16], color: Color)
/// # Errors
///
/// Returns an error if no vertices are provided or if tessellation fails.
pub fn tessellate_polygon(
pub(crate) fn tessellate_polygon(
vertices: &[Vec2],
color: Color,
) -> Result<MeshData, Box<dyn std::error::Error>> {
@ -107,7 +111,7 @@ pub fn tessellate_polygon(
/// # Errors
///
/// Returns an error if no contours are provided or if tessellation fails.
pub fn tessellate_multi_contour(
pub(crate) fn tessellate_multi_contour(
contours: &[Vec<Vec2>],
color: Color,
) -> Result<MeshData, Box<dyn std::error::Error>> {
@ -203,7 +207,7 @@ pub fn tessellate_multi_contour(
/// # Errors
///
/// Returns an error if no vertices are provided or if tessellation fails.
pub fn tessellate_stroke(
pub(crate) fn tessellate_stroke(
vertices: &[Vec2],
color: Color,
width: f32,
@ -249,7 +253,7 @@ pub fn tessellate_stroke(
/// # Errors
///
/// Returns an error if tessellation fails.
pub fn tessellate_circle(
pub(crate) fn tessellate_circle(
center: Vec2,
radius: f32,
color: Color,
@ -295,7 +299,7 @@ pub fn tessellate_circle(
///
/// Returns an error if tessellation fails.
#[allow(clippy::too_many_arguments)]
pub fn tessellate_arc(
pub(crate) fn tessellate_arc(
center: Vec2,
radius: f32,
start_angle_degrees: f32,
@ -305,40 +309,32 @@ pub fn tessellate_arc(
segments: usize,
direction: crate::circle_geometry::CircleDirection,
) -> Result<MeshData, Box<dyn std::error::Error>> {
// Build arc path manually from segments
let mut builder = Path::builder();
use crate::circle_geometry::arc_points;
let start_angle = start_angle_degrees.to_radians();
let arc_angle = arc_angle_degrees.to_radians();
let step = arc_angle / segments as f32;
let sweep_angle = arc_angle_degrees.to_radians();
// Calculate first point
let first_point = point(
let mut builder = Path::builder();
// Start point of the arc (arc_points returns everything *after* this)
builder.begin(point(
center.x + radius * start_angle.cos(),
center.y + radius * start_angle.sin(),
);
builder.begin(first_point);
));
// Add remaining points - direction matters!
for i in 1..=segments {
let angle = match direction {
crate::circle_geometry::CircleDirection::Left => {
// Counter-clockwise: subtract angle
start_angle - step * i as f32
}
crate::circle_geometry::CircleDirection::Right => {
// Clockwise: add angle
start_angle + step * i as f32
}
};
let pt = point(
center.x + radius * angle.cos(),
center.y + radius * angle.sin(),
);
builder.line_to(pt);
// Remaining points — single source of truth for arc sampling
for pt in arc_points(
center,
radius,
start_angle,
sweep_angle,
segments,
direction,
) {
builder.line_to(point(pt.x, pt.y));
}
builder.end(false); // Don't close the arc
builder.end(false); // open arc, not a closed polygon
let path = builder.build();
// Tessellate stroke

View File

@ -2,8 +2,8 @@
use crate::circle_geometry::{CircleDirection, CircleGeometry};
use crate::commands::{CommandQueue, TurtleCommand};
use crate::general::AnimationSpeed;
use crate::state::{Turtle, TurtleParams};
use crate::general::{AnimationSpeed, Radians};
use crate::state::{DrawCommand, FillState, TurtleParams};
use macroquad::prelude::*;
use tween::{CubicInOut, TweenValue, Tweener};
@ -45,22 +45,26 @@ impl From<TweenVec2> for Vec2 {
/// Controls tweening of turtle commands
#[derive(Clone, Debug, Default)]
pub struct TweenController {
pub(crate) struct TweenController {
queue: CommandQueue,
/// Cursor into `queue` — tracks which command executes next.
/// Lives here, not in `CommandQueue`, so that cloning or appending to the
/// queue never silently resets or mid-stream-shifts the execution position.
cursor: usize,
current_tween: Option<CommandTween>,
speed: AnimationSpeed,
}
#[derive(Clone, Debug)]
pub struct CommandTween {
pub turtle_id: usize,
pub command: TurtleCommand,
pub start_time: f64,
pub duration: f64,
pub start_params: TurtleParams,
pub target_params: TurtleParams,
pub current_position: Vec2,
pub current_heading: f32,
pub(crate) struct CommandTween {
pub(crate) turtle_id: usize,
pub(crate) command: TurtleCommand,
pub(crate) start_time: f64,
pub(crate) duration: f64,
pub(crate) start_params: TurtleParams,
pub(crate) target_params: TurtleParams,
pub(crate) current_position: Vec2,
pub(crate) current_heading: f32,
position_tweener: Tweener<TweenVec2, f64, CubicInOut>,
heading_tweener: Tweener<f32, f64, CubicInOut>,
pen_width_tweener: Tweener<f32, f64, CubicInOut>,
@ -71,6 +75,7 @@ impl TweenController {
pub fn new(queue: CommandQueue, speed: AnimationSpeed) -> Self {
Self {
queue,
cursor: 0,
current_tween: None,
speed,
}
@ -80,51 +85,72 @@ impl TweenController {
self.speed = speed;
}
/// Append commands to the queue
/// Append commands to the queue.
///
/// The cursor is **not** reset — commands already consumed remain consumed,
/// and the new commands are picked up naturally as the cursor advances.
pub fn append_commands(&mut self, new_queue: CommandQueue) {
self.queue.extend(new_queue);
}
/// Update the tween, returns `Vec` of (`command`, `start_state`, `end_state`) for all completed commands this frame
/// Also takes commands vec to handle side effects like fill operations
/// Each `command` has its own `start_state` and `end_state` pair
/// Drive the animation controller for one frame.
///
/// Returns `(command, start_params, end_params)` for every command that
/// completed this frame and whose stroke needs to be tessellated by the
/// caller.
///
/// By accepting `params`, `filling`, and `commands` as separate mutable
/// borrows the caller can split `&mut Turtle` into disjoint field borrows,
/// eliminating the old static-method borrow-checker workaround.
#[allow(clippy::too_many_lines)]
pub fn update(state: &mut Turtle) -> Vec<(TurtleCommand, TurtleParams, TurtleParams)> {
pub fn update(
&mut self,
turtle_id: usize,
params: &mut TurtleParams,
filling: &mut Option<FillState>,
commands: &mut Vec<DrawCommand>,
svg_log: &mut crate::state::SvgLog,
) -> Vec<(TurtleCommand, TurtleParams, TurtleParams)> {
// In instant mode, execute commands up to the draw calls per frame limit
if let AnimationSpeed::Instant(max_draw_calls) = state.tween_controller.speed {
if let AnimationSpeed::Instant(max_draw_calls) = self.speed {
let mut completed_commands: Vec<(TurtleCommand, TurtleParams, TurtleParams)> =
Vec::new();
let mut draw_call_count = 0;
// Consume commands from the real queue so the current_index advances
while let Some(command) = state.tween_controller.queue.next() {
// Advance cursor through the queue for each command consumed
while let Some(command) = self.queue.get(self.cursor).cloned() {
self.cursor += 1;
// Handle SetSpeed command to potentially switch modes
if let TurtleCommand::SetSpeed(new_speed) = &command {
state.params.speed = *new_speed;
state.tween_controller.speed = *new_speed;
if matches!(state.tween_controller.speed, AnimationSpeed::Animated(_)) {
params.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) {
if crate::execution::execute_command_side_effects(
&command, turtle_id, params, filling, commands, svg_log,
) {
continue; // Command fully handled
}
// Save start state and compute target state
let start_params = state.params.clone();
let start_params = params.clone();
let target_params = Self::calculate_target_state(&start_params, &command);
// Update state to the target (instant execution)
state.params = target_params.clone();
*params = target_params.clone();
// Record fill vertices AFTER movement
crate::execution::record_fill_vertices_after_movement(
&command,
&start_params,
state,
turtle_id,
params,
filling,
);
// Collect drawable commands (return start and target so caller can create draw meshes)
@ -141,7 +167,7 @@ impl TweenController {
}
// Process current tween
if let Some(ref mut tween) = state.tween_controller.current_tween {
if let Some(ref mut tween) = self.current_tween {
let elapsed = get_time() - tween.start_time;
// Use tweeners to calculate current values
@ -155,10 +181,10 @@ impl TweenController {
direction,
..
} => {
let angle_traveled = angle.to_radians() * progress;
let angle_traveled = angle.as_radians().value() * progress;
calculate_circle_position(
tween.start_params.position,
tween.start_params.heading,
Radians::new(tween.start_params.heading),
*radius,
angle_traveled,
*direction,
@ -170,7 +196,7 @@ impl TweenController {
}
};
state.params.position = current_position;
params.position = current_position;
tween.current_position = current_position;
// Heading changes proportionally with progress for all commands
@ -179,14 +205,14 @@ impl TweenController {
angle, direction, ..
} => match direction {
CircleDirection::Left => {
tween.start_params.heading - angle.to_radians() * progress
tween.start_params.heading - angle.as_radians().value() * progress
}
CircleDirection::Right => {
tween.start_params.heading + angle.to_radians() * progress
tween.start_params.heading + angle.as_radians().value() * progress
}
},
TurtleCommand::Turn(angle) => {
tween.start_params.heading + angle.to_radians() * progress
tween.start_params.heading + angle.as_radians().value() * progress
}
_ => {
// For other commands that change heading, lerp directly
@ -195,18 +221,18 @@ impl TweenController {
}
});
state.params.heading = current_heading;
params.heading = current_heading;
tween.current_heading = current_heading;
state.params.pen_width = tween.pen_width_tweener.move_to(elapsed);
params.pen_width = tween.pen_width_tweener.move_to(elapsed);
// Discrete properties (switch at 50% progress)
let progress = (elapsed / tween.duration).min(1.0);
if progress >= 0.5 {
state.params.pen_down = tween.target_params.pen_down;
state.params.color = tween.target_params.color;
state.params.fill_color = tween.target_params.fill_color;
state.params.visible = tween.target_params.visible;
state.params.shape = tween.target_params.shape.clone();
params.pen_down = tween.target_params.pen_down;
params.color = tween.target_params.color;
params.fill_color = tween.target_params.fill_color;
params.visible = tween.target_params.visible;
params.shape = tween.target_params.shape.clone();
}
// Check if tween is finished (use heading_tweener as it's used by all commands)
@ -215,20 +241,24 @@ impl TweenController {
let target_params = tween.target_params.clone();
let command = tween.command.clone();
// Drop the mutable borrow of tween before mutably borrowing state
state.params = target_params.clone();
// tween borrow ends here (NLL) — safe to reassign self.current_tween below
*params = target_params.clone();
crate::execution::record_fill_vertices_after_movement(
&command,
&start_params,
state,
turtle_id,
params,
filling,
);
state.tween_controller.current_tween = None;
self.current_tween = None;
// Execute side-effect-only commands using centralized helper
if crate::execution::execute_command_side_effects(&command, state) {
return Self::update(state); // Continue to next command
if crate::execution::execute_command_side_effects(
&command, turtle_id, params, filling, commands, svg_log,
) {
return self.update(turtle_id, params, filling, commands, svg_log);
}
// Return drawable commands using the original start and target params
@ -236,43 +266,45 @@ impl TweenController {
return vec![(command, start_params.clone(), target_params.clone())];
}
return Self::update(state); // Continue to next command
return self.update(turtle_id, params, filling, commands, svg_log);
}
return Vec::new();
}
// Start next tween
if let Some(command) = state.tween_controller.queue.next() {
let command_clone = command.clone();
if let Some(command) = self.queue.get(self.cursor).cloned() {
self.cursor += 1;
// Handle commands that should execute immediately (no animation)
match &command_clone {
match &command {
TurtleCommand::SetSpeed(new_speed) => {
state.set_speed(*new_speed);
state.tween_controller.speed = *new_speed;
if matches!(state.tween_controller.speed, AnimationSpeed::Instant(_)) {
return Self::update(state);
params.speed = *new_speed;
self.speed = *new_speed;
if matches!(self.speed, AnimationSpeed::Instant(_)) {
return self.update(turtle_id, params, filling, commands, svg_log);
}
return Self::update(state);
return self.update(turtle_id, params, filling, commands, svg_log);
}
_ => {
// Use centralized helper for side effects
if crate::execution::execute_command_side_effects(&command_clone, state) {
return Self::update(state);
if crate::execution::execute_command_side_effects(
&command, turtle_id, params, filling, commands, svg_log,
) {
return self.update(turtle_id, params, filling, commands, svg_log);
}
}
}
let speed = state.tween_controller.speed; // Extract speed before borrowing self
let duration = Self::calculate_duration_with_state(&command_clone, state, speed);
let speed = self.speed;
let duration = Self::calculate_duration_with_state(&command, params, speed);
// Calculate target state
let target_state = Self::calculate_target_state(&state.params, &command_clone);
let target_state = Self::calculate_target_state(params, &command);
// Create tweeners for smooth animation
let position_tweener = Tweener::new(
TweenVec2::from(state.params.position),
TweenVec2::from(params.position),
TweenVec2::from(target_state.position),
duration,
CubicInOut,
@ -284,21 +316,21 @@ impl TweenController {
);
let pen_width_tweener = Tweener::new(
state.params.pen_width,
params.pen_width,
target_state.pen_width,
duration,
CubicInOut,
);
state.tween_controller.current_tween = Some(CommandTween {
turtle_id: state.turtle_id,
command: command_clone,
self.current_tween = Some(CommandTween {
turtle_id,
command,
start_time: get_time(),
duration,
start_params: state.params.clone(),
start_params: params.clone(),
target_params: target_state.clone(),
current_position: state.params.position,
current_heading: state.params.heading,
current_position: params.position,
current_heading: params.heading,
position_tweener,
heading_tweener,
pen_width_tweener,
@ -310,7 +342,7 @@ impl TweenController {
#[must_use]
pub fn is_complete(&self) -> bool {
self.current_tween.is_none() && self.queue.is_complete()
self.current_tween.is_none() && self.cursor >= self.queue.len()
}
/// Get the current active tween if one is in progress
@ -319,131 +351,35 @@ impl TweenController {
}
fn command_creates_drawing(command: &TurtleCommand) -> bool {
matches!(
command,
TurtleCommand::Move(_) | TurtleCommand::Circle { .. } | TurtleCommand::Goto(_)
)
command.produces_drawing()
}
fn calculate_duration_with_state(
command: &TurtleCommand,
current: &Turtle,
params: &TurtleParams,
speed: AnimationSpeed,
) -> f64 {
let mut speed = speed.value();
// For high speeds, make animation even faster by scaling speed exponentially
if speed > 100.0 {
speed *= speed / 100.0;
}
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.params.position.x;
let dy = target.y - current.params.position.y;
let distance = (dx * dx + dy * dy).sqrt();
distance / speed
}
_ => 0.0, // Instant commands
};
f64::from(base_time.max(0.01)) // Minimum duration
command.animation_duration(params, speed)
}
fn calculate_target_state(current: &TurtleParams, command: &TurtleCommand) -> TurtleParams {
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) => {
// Flip Y coordinate: turtle graphics uses Y+ = up, but Macroquad uses Y+ = down
target.position = vec2(coord.x, -coord.y);
}
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 | TurtleCommand::WriteText { .. } => {
// Fill and text commands don't change turtle state for tweening purposes
// They're handled directly in execution
}
TurtleCommand::Reset => {
// Reset returns to default state
target = TurtleParams::default();
}
}
command.apply_to_params(&mut target);
target
}
}
/// Calculate position on a circular arc
/// Calculate position on a circular arc.
///
/// `start_heading` is in radians (typed as `Radians` to make the unit
/// explicit at every call site). `angle_traveled` is already a raw `f32`
/// radians value produced by multiplying `Degrees::as_radians().value()`
/// by a tween progress scalar.
fn calculate_circle_position(
start_pos: Vec2,
start_heading: f32,
start_heading: Radians,
radius: f32,
angle_traveled: f32, // How much of the total angle we've traveled (in radians)
angle_traveled: f32,
direction: CircleDirection,
) -> Vec2 {
let geom = CircleGeometry::new(start_pos, start_heading, radius, direction);
@ -451,7 +387,7 @@ fn calculate_circle_position(
}
/// Normalize angle to range [-PI, PI] to prevent floating-point drift
fn normalize_angle(angle: f32) -> f32 {
pub(crate) fn normalize_angle(angle: f32) -> f32 {
let two_pi = std::f32::consts::PI * 2.0;
let mut normalized = angle % two_pi;