Compare commits
23 Commits
9831008b9f
...
59d6bc164e
| Author | SHA1 | Date | |
|---|---|---|---|
| 59d6bc164e | |||
| ece26bfe04 | |||
| 156301f272 | |||
| 96b02f61be | |||
| 998cffdcbf | |||
| 6b558ca8a0 | |||
| 3c076fdd03 | |||
| 44046abe12 | |||
| a7570911d8 | |||
| cd589b2513 | |||
|
|
4ce176f44d | ||
|
|
ef6e8e4c76 | ||
| 8a4ed0bf32 | |||
| 402a8be205 | |||
| 3820f20048 | |||
| 6f29d97bb6 | |||
| cadc5a6798 | |||
|
|
9568bc10d9 | ||
|
|
cbe249b9b7 | ||
|
|
f5140361d5 | ||
|
|
c806570156 | ||
|
|
d85a9c7d26 | ||
|
|
2b64be29a8 |
201
LICENSE-APACHE
Normal file
201
LICENSE-APACHE
Normal 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
21
LICENSE-MIT
Normal 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.
|
||||
31
README.md
31
README.md
@ -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
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
//! Beispiel: Exportiere ein SVG aus einer einfachen Zeichnung
|
||||
|
||||
#[cfg(feature = "svg")]
|
||||
use turtle_lib::*;
|
||||
|
||||
#[cfg(feature = "svg")]
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
36
turtle-lib/examples/test_svg_export.rs
Normal file
36
turtle-lib/examples/test_svg_export.rs
Normal 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();
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
150
turtle-lib/src/command_behavior.rs
Normal file
150
turtle-lib/src/command_behavior.rs
Normal 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(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
) {
|
||||
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 })
|
||||
}
|
||||
|
||||
// `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,
|
||||
) {
|
||||
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 = {}",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 Default for AngleUnit {
|
||||
fn default() -> Self {
|
||||
Self::Degrees(0.0)
|
||||
impl Degrees {
|
||||
/// Construct from a raw degrees value.
|
||||
#[must_use]
|
||||
pub fn new(v: Precision) -> Self {
|
||||
Self(v)
|
||||
}
|
||||
|
||||
/// 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),
|
||||
}
|
||||
fn neg(self) -> Self {
|
||||
Self(-self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Div<Precision> for Angle {
|
||||
type Output = Self;
|
||||
|
||||
fn div(self, rhs: Precision) -> Self::Output {
|
||||
match self.value {
|
||||
AngleUnit::Degrees(v) => Self::degrees(v / rhs),
|
||||
AngleUnit::Radians(v) => Self::radians(v / rhs),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Neg for Angle {
|
||||
type Output = Self;
|
||||
|
||||
fn neg(self) -> Self::Output {
|
||||
match self.value {
|
||||
AngleUnit::Degrees(v) => Self::degrees(-v),
|
||||
AngleUnit::Radians(v) => Self::radians(-v),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Neg for &Angle {
|
||||
type Output = Angle;
|
||||
|
||||
fn neg(self) -> Self::Output {
|
||||
match self.value {
|
||||
AngleUnit::Degrees(v) => Angle::degrees(-v),
|
||||
AngleUnit::Radians(v) => Angle::radians(-v),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for Angle {
|
||||
type Output = Angle;
|
||||
|
||||
fn add(self, rhs: Self) -> Self::Output {
|
||||
match (self.value, rhs.value) {
|
||||
(AngleUnit::Degrees(v), AngleUnit::Degrees(o)) => Self::degrees(v + o),
|
||||
(AngleUnit::Degrees(v), AngleUnit::Radians(o)) => Self::radians(v.to_radians() + o),
|
||||
(AngleUnit::Radians(v), AngleUnit::Degrees(o)) => Self::radians(v + o.to_radians()),
|
||||
(AngleUnit::Radians(v), AngleUnit::Radians(o)) => Self::radians(v + o),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub for Angle {
|
||||
type Output = Angle;
|
||||
|
||||
fn sub(self, rhs: Self) -> Self::Output {
|
||||
match (self.value, rhs.value) {
|
||||
(AngleUnit::Degrees(v), AngleUnit::Degrees(o)) => Self::degrees(v - o),
|
||||
(AngleUnit::Degrees(v), AngleUnit::Radians(o)) => Self::radians(v.to_radians() - o),
|
||||
(AngleUnit::Radians(v), AngleUnit::Degrees(o)) => Self::radians(v - o.to_radians()),
|
||||
(AngleUnit::Radians(v), AngleUnit::Radians(o)) => Self::radians(v - o),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Angle {
|
||||
#[must_use]
|
||||
pub fn degrees(value: Precision) -> Self {
|
||||
Self {
|
||||
value: AngleUnit::Degrees(value),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn radians(value: Precision) -> Self {
|
||||
Self {
|
||||
value: AngleUnit::Radians(value),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn value(&self) -> Precision {
|
||||
match self.value {
|
||||
AngleUnit::Degrees(v) | AngleUnit::Radians(v) => v,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn to_radians(self) -> Self {
|
||||
match self.value {
|
||||
AngleUnit::Degrees(v) => Self::radians(v.to_radians()),
|
||||
AngleUnit::Radians(_) => self,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn to_degrees(self) -> Self {
|
||||
match self.value {
|
||||
AngleUnit::Degrees(_) => self,
|
||||
AngleUnit::Radians(v) => Self::degrees(v.to_degrees()),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn limit_smaller_than_full_circle(self) -> Self {
|
||||
use std::f32::consts::PI;
|
||||
match self.value {
|
||||
AngleUnit::Degrees(v) => Self::degrees(v % 360.0),
|
||||
AngleUnit::Radians(v) => Self::radians(v % (2.0 * PI)),
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user