diff --git a/turtle-lib/src/drawing.rs b/turtle-lib/src/drawing.rs index 8aca780..c31c095 100644 --- a/turtle-lib/src/drawing.rs +++ b/turtle-lib/src/drawing.rs @@ -33,7 +33,7 @@ pub(crate) 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 { @@ -42,7 +42,6 @@ pub(crate) fn render_world_with_tweens(world: &TurtleWorld, zoom_level: f32) { heading, font_size, color, - source: _, } => { draw_text_command(text, *position, *heading, *font_size, *color); } diff --git a/turtle-lib/src/execution.rs b/turtle-lib/src/execution.rs index 62b8476..c8bead6 100644 --- a/turtle-lib/src/execution.rs +++ b/turtle-lib/src/execution.rs @@ -81,6 +81,7 @@ pub(crate) fn execute_command_side_effects( params: &mut TurtleParams, filling: &mut Option, commands: &mut Vec, + svg_log: &mut crate::state::SvgLog, ) -> bool { match command { TurtleCommand::BeginFill => { @@ -131,18 +132,12 @@ pub(crate) fn execute_command_side_effects( contours = fill_state.contours.len(), "Successfully created fill mesh - persisting to commands" ); - commands.push(DrawCommand::Mesh { - data: mesh_data, - source: crate::state::TurtleSource { - command: crate::commands::TurtleCommand::EndFill, - color: params.color, - fill_color: fill_state.fill_color, - pen_width: params.pen_width, - start_position: fill_state.start_position, - end_position: fill_state.start_position, - start_heading: params.heading, - contours: Some(fill_state.contours.clone()), - }, + 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, + stroke_color: params.color, }); } else { tracing::error!(turtle_id, "Failed to tessellate contours"); @@ -177,6 +172,7 @@ pub(crate) fn execute_command_side_effects( TurtleCommand::Reset => { commands.clear(); + svg_log.clear(); *filling = None; *params = TurtleParams::default(); true @@ -189,16 +185,12 @@ pub(crate) fn execute_command_side_effects( heading: params.heading, font_size: *font_size, color: params.color, - source: crate::state::TurtleSource { - command: command.clone(), - color: params.color, - fill_color: params.fill_color.unwrap_or(BLACK), - pen_width: params.pen_width, - start_position: params.position, - end_position: params.position, - start_heading: params.heading, - contours: None, - }, + }); + #[cfg(feature = "svg")] + svg_log.push(crate::state::SvgRecord::Text { + text: text.clone(), + position: params.position, + color: params.color, }); true } @@ -340,19 +332,7 @@ pub(crate) fn tessellate_command( ) .ok()?; - Some(DrawCommand::Mesh { - data: mesh_data, - source: crate::state::TurtleSource { - command: command.clone(), - color: start.color, - fill_color: start.fill_color.unwrap_or(BLACK), - pen_width: start.pen_width, - start_position: start.position, - end_position, - start_heading: start.heading, - contours: None, - }, - }) + Some(DrawCommand::Mesh { data: mesh_data }) } TurtleCommand::Circle { @@ -380,19 +360,7 @@ pub(crate) fn tessellate_command( ) .ok()?; - Some(DrawCommand::Mesh { - data: mesh_data, - source: crate::state::TurtleSource { - command: command.clone(), - color: start.color, - fill_color: start.fill_color.unwrap_or(BLACK), - pen_width: start.pen_width, - start_position: start.position, - end_position, - start_heading: start.heading, - contours: None, - }, - }) + Some(DrawCommand::Mesh { data: mesh_data }) } // `produces_drawing()` guards entry — this arm is only reachable if @@ -401,6 +369,48 @@ pub(crate) fn tessellate_command( } } +/// 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) { @@ -412,6 +422,7 @@ pub(crate) fn execute_command(command: &TurtleCommand, state: &mut Turtle) { &mut state.params, &mut state.filling, &mut state.commands, + &mut state.svg_log, ) { return; } @@ -429,8 +440,15 @@ pub(crate) fn execute_command(command: &TurtleCommand, state: &mut Turtle) { &mut state.filling, ); - // Phase 4: tessellate and persist the committed drawing + // 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); } } @@ -479,6 +497,7 @@ mod tests { }, filling: None, commands: Vec::new(), + svg_log: crate::state::SvgLog::default(), tween_controller: TweenController::default(), }; diff --git a/turtle-lib/src/export_svg.rs b/turtle-lib/src/export_svg.rs index e5815a5..d2c0a3a 100644 --- a/turtle-lib/src/export_svg.rs +++ b/turtle-lib/src/export_svg.rs @@ -2,12 +2,11 @@ #[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,207 +36,145 @@ 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(_) => { - // Straight line — emit as SVG - let start = source.start_position; - let end = source.end_position; - update_bounds( - &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, - ); - 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); - doc = doc.add(line); - } - TurtleCommand::Circle { + 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, + ); + update_bounds( + &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(*color)) + .set("stroke-width", *pen_width); + doc = doc.add(line); + } + + 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( + *start_position, + Radians::new(*start_heading), + *radius, + *direction, + ); + let center = geom.center; + // Include the bounding box of the full circle so partial arcs + // are never clipped. + 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, + ); + + if (angle.value() - 360.0).abs() < 1e-3 { + // Full circle — emit as + let circle = Circle::new() + .set("cx", center.x) + .set("cy", center.y) + .set("r", *radius) + .set("stroke", color_to_svg(*color)) + .set("stroke-width", *pen_width) + .set("fill", "none"); + doc = doc.add(circle); + } else { + // Partial arc — emit as + 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_position.x, + start_position.y, radius, - angle, - direction, - .. - } => { - use crate::circle_geometry::CircleGeometry; - use crate::general::Radians; - let geom = CircleGeometry::new( - source.start_position, - Radians::new(source.start_heading), - *radius, - *direction, - ); - let center = geom.center; - if (angle.value() - 360.0).abs() < 1e-3 { - // Full circle — emit as SVG - 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 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("fill", "none"); - doc = doc.add(circle); - } else { - // Partial arc — emit as SVG with A command - 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.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, - radius, - radius, - large_arc, - sweep, - end.x, - 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("fill", "none"); - doc = doc.add(path); - } - } - TurtleCommand::EndFill => { - // Fill contours — emit as SVG with evenodd fill rule - if let Some(contours) = &source.contours { - 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, - ); - } - } - let mut d = String::new(); - for (i, contour) in contours.iter().enumerate() { - if !contour.is_empty() { - if i > 0 { - d.push(' '); - } - 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(" Z"); - } - } - if !d.is_empty() { - let path = svg::node::element::Path::new() - .set("d", d) - .set("fill", color_to_svg(source.fill_color)) - .set("fill-rule", "evenodd") - .set("stroke", color_to_svg(source.color)); - doc = doc.add(path); - } - } else { - // Fallback: no contour data — emit a 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); - } - } - _ => {} + radius, + large_arc, + sweep, + end.x, + end.y, + ); + let path = svg::node::element::Path::new() + .set("d", d) + .set("stroke", color_to_svg(*color)) + .set("stroke-width", *pen_width) + .set("fill", "none"); + doc = doc.add(path); } } - DrawCommand::Text { + + 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, + ); + } + } + let mut d = String::new(); + for (i, contour) in contours.iter().enumerate() { + if !contour.is_empty() { + if i > 0 { + d.push(' '); + } + 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(" Z"); + } + } + if !d.is_empty() { + let path = svg::node::element::Path::new() + .set("d", d) + .set("fill", color_to_svg(*fill_color)) + .set("fill-rule", "evenodd") + .set("stroke", color_to_svg(*stroke_color)); + doc = doc.add(path); + } + } + + SvgRecord::Text { text, position, - source, - .. + color, } => { update_bounds( &mut min_x, &mut max_x, &mut min_y, &mut max_y, position.x, @@ -246,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); } @@ -261,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"); } diff --git a/turtle-lib/src/lib.rs b/turtle-lib/src/lib.rs index 5f4303c..d572f3e 100644 --- a/turtle-lib/src/lib.rs +++ b/turtle-lib/src/lib.rs @@ -299,6 +299,13 @@ impl TurtleApp { 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, + end_state.position, + &mut turtle.svg_log, + ); turtle.commands.push(draw_cmd); } } diff --git a/turtle-lib/src/state.rs b/turtle-lib/src/state.rs index f46e077..e383dfd 100644 --- a/turtle-lib/src/state.rs +++ b/turtle-lib/src/state.rs @@ -66,6 +66,9 @@ pub(crate) struct Turtle { // Drawing commands created by this turtle pub(crate) commands: Vec, + // SVG draw-event log — populated alongside `commands`, consumed by the SVG exporter + pub(crate) svg_log: SvgLog, + // Animation controller for this turtle pub(crate) tween_controller: TweenController, } @@ -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()), } } @@ -96,6 +100,7 @@ impl Turtle { pub fn reset(&mut self) { // Clear all drawings self.commands.clear(); + self.svg_log.clear(); // Clear fill state self.filling = None; @@ -123,6 +128,7 @@ impl Turtle { &mut self.params, &mut self.filling, &mut self.commands, + &mut self.svg_log, ) } @@ -277,6 +283,70 @@ 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` 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, +} + +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>, + 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(crate) struct MeshData { @@ -295,35 +365,19 @@ impl MeshData { } } -/// Drawable elements in the world -/// All drawing is done via Lyon-tessellated meshes for consistency and quality -#[derive(Clone, Debug)] -pub(crate) struct TurtleSource { - pub(crate) command: crate::commands::TurtleCommand, - pub(crate) color: Color, - pub(crate) fill_color: Color, - pub(crate) pen_width: f32, - pub(crate) start_position: Vec2, - pub(crate) end_position: Vec2, - pub(crate) start_heading: f32, - pub(crate) contours: Option>>, -} - +/// Drawable elements in the world. +/// All drawing is done via Lyon-tessellated meshes for consistency and quality. #[derive(Clone, Debug)] pub(crate) enum DrawCommand { - /// Pre-tessellated mesh data (lines, arcs, circles, polygons - all use this) - Mesh { - data: MeshData, - source: TurtleSource, - }, - /// Text rendering command + /// 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, }, } diff --git a/turtle-lib/src/tweening.rs b/turtle-lib/src/tweening.rs index d36b936..cc6cb59 100644 --- a/turtle-lib/src/tweening.rs +++ b/turtle-lib/src/tweening.rs @@ -109,6 +109,7 @@ impl TweenController { params: &mut TurtleParams, filling: &mut Option, commands: &mut Vec, + 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) = self.speed { @@ -131,7 +132,7 @@ impl TweenController { // Execute side-effect-only commands using centralized helper if crate::execution::execute_command_side_effects( - &command, turtle_id, params, filling, commands, + &command, turtle_id, params, filling, commands, svg_log, ) { continue; // Command fully handled } @@ -255,9 +256,9 @@ impl TweenController { // Execute side-effect-only commands using centralized helper if crate::execution::execute_command_side_effects( - &command, turtle_id, params, filling, commands, + &command, turtle_id, params, filling, commands, svg_log, ) { - return self.update(turtle_id, params, filling, commands); + return self.update(turtle_id, params, filling, commands, svg_log); } // Return drawable commands using the original start and target params @@ -265,7 +266,7 @@ impl TweenController { return vec![(command, start_params.clone(), target_params.clone())]; } - return self.update(turtle_id, params, filling, commands); + return self.update(turtle_id, params, filling, commands, svg_log); } return Vec::new(); @@ -281,16 +282,16 @@ impl TweenController { params.speed = *new_speed; self.speed = *new_speed; if matches!(self.speed, AnimationSpeed::Instant(_)) { - return self.update(turtle_id, params, filling, commands); + return self.update(turtle_id, params, filling, commands, svg_log); } - return self.update(turtle_id, params, filling, commands); + return self.update(turtle_id, params, filling, commands, svg_log); } _ => { // Use centralized helper for side effects if crate::execution::execute_command_side_effects( - &command, turtle_id, params, filling, commands, + &command, turtle_id, params, filling, commands, svg_log, ) { - return self.update(turtle_id, params, filling, commands); + return self.update(turtle_id, params, filling, commands, svg_log); } } }