From 4affa5b90cf10c421b63a25df6ad29a1d8b4bb63 Mon Sep 17 00:00:00 2001 From: Sameer Puri Date: Mon, 4 May 2026 20:24:45 -0700 Subject: [PATCH] [star] Add filled polygon support --- g_code/Cargo.toml | 2 + g_code/src/turtle.rs | 194 +++++---- star/src/lib.rs | 2 + star/src/lower/mod.rs | 99 ++--- star/src/lower/path.rs | 64 --- star/src/lower/units.rs | 2 +- star/src/lower/visit.rs | 651 ++++++++++++++++++------------- star/src/turtle/collect.rs | 66 +--- star/src/turtle/dpi.rs | 134 ++++--- star/src/turtle/elements/fill.rs | 291 ++++++++++++++ star/src/turtle/elements/mod.rs | 73 +++- star/src/turtle/elements/tsp.rs | 43 +- star/src/turtle/mod.rs | 297 ++++++++++---- star/src/turtle/preprocess.rs | 59 +-- star/src/turtle/svg_preview.rs | 146 +++---- 15 files changed, 1361 insertions(+), 762 deletions(-) delete mode 100644 star/src/lower/path.rs create mode 100644 star/src/turtle/elements/fill.rs diff --git a/g_code/Cargo.toml b/g_code/Cargo.toml index 1b199c0..818d84e 100644 --- a/g_code/Cargo.toml +++ b/g_code/Cargo.toml @@ -8,7 +8,9 @@ repository.workspace = true license.workspace = true [features] +default = ["image"] serde = ["dep:serde", "g-code/serde", "svg2star/serde"] +image = ["svg2star/image"] [dependencies] svg2star = { path = "../star", version = "0.4.0" } diff --git a/g_code/src/turtle.rs b/g_code/src/turtle.rs index 3c3974a..5c38437 100644 --- a/g_code/src/turtle.rs +++ b/g_code/src/turtle.rs @@ -1,11 +1,11 @@ use std::{borrow::Cow, fmt::Debug}; use ::g_code::{command, emit::Token}; -use lyon_geom::{CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc}; +use lyon_geom::{Point, SvgArc}; use rust_decimal::{Decimal, prelude::*}; use svg2star::turtle::{ Turtle, - elements::{ArcOrLineSegment, FlattenWithArcs}, + elements::{ArcOrLineSegment, DrawCommand, FillPolygon, FlattenWithArcs, Stroke}, }; use crate::machine::Machine; @@ -42,7 +42,7 @@ impl<'input> GCodeTurtle<'input> { /// Tolerance passed to [`lyon_geom`] calls for line segments. /// /// Reserves some headroom so that [`Self::round`] won't take - /// the measurement outside of the overall tolernace bounds. + /// the measurement outside of the overall tolerance bounds. /// /// i.e. e.g. 0.002 & 3 dp returns 0.0015 so we have +/- 0.0005 fn flattening_tolerance(&self) -> f64 { @@ -77,6 +77,15 @@ impl<'input> GCodeTurtle<'input> { } } + fn line_to(&self, to: Point) -> Vec> { + command!(LinearInterpolation { + X: self.round(to.x), + Y: self.round(to.y), + F: self.feedrate, + }) + .into_token_vec() + } + fn circular_interpolation(&self, svg_arc: SvgArc) -> Vec> { debug_assert!((svg_arc.radii.x.abs() - svg_arc.radii.y.abs()).abs() < f64::EPSILON); match (svg_arc.flags.large_arc, svg_arc.flags.sweep) { @@ -129,90 +138,127 @@ impl<'input> Turtle for GCodeTurtle<'input> { self.program.extend(self.machine.program_end()); } - fn comment(&mut self, comment: String) { - self.program.push(Token::Comment { - is_inline: false, - inner: Cow::Owned(comment), - }); - } + fn stroke(&mut self, stroke: Stroke) { + let start = stroke.start_point(); + let mut commands = stroke.into_commands().peekable(); - fn move_to(&mut self, to: Point) { self.tool_off(); + + // Comments should be inline after tool_off but before a rapid move. + while matches!(commands.peek(), Some(DrawCommand::Comment(_))) { + if let Some(DrawCommand::Comment(comment)) = commands.next() { + self.program.push(Token::Comment { + is_inline: false, + inner: Cow::Owned(comment), + }); + } + } + self.program.append( &mut command!(RapidPositioning { - X: self.round(to.x), - Y: self.round(to.y), + X: self.round(start.x), + Y: self.round(start.y), }) .into_token_vec(), ); - } - - fn line_to(&mut self, to: Point) { self.tool_on(); - self.program.append( - &mut command!(LinearInterpolation { - X: self.round(to.x), - Y: self.round(to.y), - F: self.feedrate, - }) - .into_token_vec(), - ); - } - fn arc(&mut self, svg_arc: SvgArc) { - if svg_arc.is_straight_line() { - self.line_to(svg_arc.to); - return; + for command in commands { + match command { + DrawCommand::LineTo { from: _, to } => { + self.program.append( + &mut command!(LinearInterpolation { + X: self.round(to.x), + Y: self.round(to.y), + F: self.feedrate, + }) + .into_token_vec(), + ); + } + DrawCommand::Arc(svg_arc) => { + if self + .machine + .supported_functionality() + .circular_interpolation + { + FlattenWithArcs::flattened(&svg_arc, self.arc_flattening_tolerance()) + .into_iter() + .for_each(|segment| match segment { + ArcOrLineSegment::Arc(arc) => { + self.program.append(&mut self.circular_interpolation(arc)) + } + ArcOrLineSegment::Line(line) => { + self.line_to(line.to); + } + }); + } else { + svg_arc + .to_arc() + .flattened(self.flattening_tolerance()) + .for_each(|point| self.program.append(&mut self.line_to(point))); + }; + } + DrawCommand::CubicBezier(cbs) => { + if self + .machine + .supported_functionality() + .circular_interpolation + { + FlattenWithArcs::::flattened(&cbs, self.arc_flattening_tolerance()) + .into_iter() + .for_each(|segment| match segment { + ArcOrLineSegment::Arc(arc) => { + self.program.append(&mut self.circular_interpolation(arc)) + } + ArcOrLineSegment::Line(line) => { + self.program.append(&mut self.line_to(line.to)) + } + }); + } else { + cbs.flattened(self.flattening_tolerance()) + .for_each(|point| self.program.append(&mut self.line_to(point))); + }; + } + DrawCommand::QuadraticBezier(qbs) => { + if self + .machine + .supported_functionality() + .circular_interpolation + { + FlattenWithArcs::::flattened( + &qbs.to_cubic(), + self.arc_flattening_tolerance(), + ) + .into_iter() + .for_each(|segment| match segment { + ArcOrLineSegment::Arc(arc) => { + self.program.append(&mut self.circular_interpolation(arc)) + } + ArcOrLineSegment::Line(line) => { + self.program.append(&mut self.line_to(line.to)) + } + }); + } else { + qbs.flattened(self.flattening_tolerance()) + .for_each(|point| self.program.append(&mut self.line_to(point))); + }; + } + DrawCommand::Comment(comment) => { + self.program.push(Token::Comment { + is_inline: false, + inner: Cow::Owned(comment), + }); + } + } } - - self.tool_on(); - - if self - .machine - .supported_functionality() - .circular_interpolation - { - FlattenWithArcs::flattened(&svg_arc, self.arc_flattening_tolerance()) - .into_iter() - .for_each(|segment| match segment { - ArcOrLineSegment::Arc(arc) => { - self.program.append(&mut self.circular_interpolation(arc)) - } - ArcOrLineSegment::Line(line) => { - self.line_to(line.to); - } - }); - } else { - svg_arc - .to_arc() - .flattened(self.flattening_tolerance()) - .for_each(|point| self.line_to(point)); - }; } - fn cubic_bezier(&mut self, cbs: CubicBezierSegment) { - self.tool_on(); - - if self - .machine - .supported_functionality() - .circular_interpolation - { - FlattenWithArcs::::flattened(&cbs, self.arc_flattening_tolerance()) - .into_iter() - .for_each(|segment| match segment { - ArcOrLineSegment::Arc(arc) => { - self.program.append(&mut self.circular_interpolation(arc)) - } - ArcOrLineSegment::Line(line) => self.line_to(line.to), - }); - } else { - cbs.flattened(self.flattening_tolerance()) - .for_each(|point| self.line_to(point)); - }; + #[cfg(feature = "image")] + fn image(&mut self, _image: svg2star::turtle::elements::RasterImage) { + // TODO (?) } - fn quadratic_bezier(&mut self, qbs: QuadraticBezierSegment) { - self.cubic_bezier(qbs.to_cubic()); + fn fill_polygon(&mut self, _polygon: FillPolygon) { + // TODO } } diff --git a/star/src/lib.rs b/star/src/lib.rs index f79ddcd..467e61d 100644 --- a/star/src/lib.rs +++ b/star/src/lib.rs @@ -1,4 +1,6 @@ #![deny(unused_crate_dependencies)] +#[cfg(test)] +use serde_json as _; /// Lowers an SVG to an intermediate representation that's easier to work when generating machine code. pub mod lower; diff --git a/star/src/lower/mod.rs b/star/src/lower/mod.rs index 635ebd8..c8009bb 100644 --- a/star/src/lower/mod.rs +++ b/star/src/lower/mod.rs @@ -1,4 +1,4 @@ -use std::fmt::Debug; +use std::{collections::HashMap, fmt::Debug}; use lyon_geom::euclid::default::Transform2D; use roxmltree::{Document, Node}; @@ -22,7 +22,6 @@ use crate::{ #[cfg(feature = "serde")] mod length_serde; -mod path; mod selector; mod transform; mod units; @@ -64,7 +63,7 @@ impl Default for ConversionConfig { extra_attribute_name: None, optimize_path_order: false, selector_filter: None, - starting_point: zero_origin() , + starting_point: zero_origin(), } } } @@ -84,29 +83,37 @@ pub struct ConversionOptions { /// Maps SVG [`Node`]s and their attributes into operations on a [`Terrarium`] #[derive(Debug)] -struct ConversionVisitor<'a, T: Turtle> { +struct ConversionVisitor<'a, 'input, T: Turtle> { terrarium: Terrarium, /// Whether to flip the Y axis to convert from SVG (Y-down) to the output coordinate system. coordinate_system: CoordinateSystem, name_stack: Vec, /// Used to convert percentage values viewport_dim_stack: Vec<[f64; 2]>, - _config: &'a ConversionConfig, + config: &'a ConversionConfig, options: ConversionOptions, /// Parsed CSS include selector — only draw elements that match (or are inside a matching ancestor) selector_filter: Option, + /// Resolved CSS class rules + css_rules: HashMap<&'input str, HashMap<&'input str, &'input str>>, } -impl<'a, T: Turtle> ConversionVisitor<'a, T> { - fn comment(&mut self, node: &Node) { +impl<'a, 'input, T: Turtle> ConversionVisitor<'a, 'input, T> { + fn comment(&mut self) { let mut comment = String::new(); - self.name_stack.iter().for_each(|name| { + self.name_stack + .iter() + // Predecessors only + .take(self.name_stack.len().saturating_sub(1)) + .for_each(|name| { + comment += name; + comment += " > "; + }); + if let Some(name) = self.name_stack.last() { comment += name; - comment += " > "; - }); - comment += &node_name(node, &self._config.extra_attribute_name); + } - self.terrarium.turtle.comment(comment); + self.terrarium.comment(comment); } fn should_draw_node(&self, node: Node) -> bool { @@ -120,11 +127,9 @@ impl<'a, T: Turtle> ConversionVisitor<'a, T> { // Part 1 of converting from SVG (Y-down) to output (Y-up) coordinates self.terrarium.push_transform(Transform2D::scale(1., -1.)); } - self.terrarium.turtle.begin(); } fn end(&mut self) { - self.terrarium.turtle.end(); if self.coordinate_system == CoordinateSystem::YUp { self.terrarium.pop_transform(); } @@ -155,23 +160,24 @@ pub fn svg_to_turtle( let bounding_box_generator = || { let mut visitor = ConversionVisitor { - terrarium: Terrarium::new(DpiConvertingTurtle { - inner: PreprocessTurtle::default(), - dpi: config.dpi, - }), + terrarium: Terrarium::new(DpiConvertingTurtle::new( + config.dpi, + PreprocessTurtle::default(), + )), coordinate_system, - _config: config, + config, options: options.clone(), name_stack: vec![], viewport_dim_stack: vec![], selector_filter: selector_filter.clone(), + css_rules: visit::parse_css(doc), }; visitor.begin(); visit::depth_first_visit(doc, &mut visitor); visitor.end(); - visitor.terrarium.turtle.inner.bounding_box + visitor.terrarium.finish().into_inner().into_inner() }; // Convert from millimeters to user units @@ -201,16 +207,14 @@ pub fn svg_to_turtle( }; let mut conversion_visitor = ConversionVisitor { - terrarium: Terrarium::new(DpiConvertingTurtle { - inner: turtle, - dpi: config.dpi, - }), + terrarium: Terrarium::new(DpiConvertingTurtle::new(config.dpi, turtle)), coordinate_system, - _config: config, + config, options: options.clone(), name_stack: vec![], viewport_dim_stack: vec![], selector_filter: selector_filter.clone(), + css_rules: visit::parse_css(doc), }; conversion_visitor @@ -226,15 +230,9 @@ pub fn svg_to_turtle( origin_transform, selector_filter, coordinate_system, - starting_point, + starting_point, ); - let turtle = &mut conversion_visitor.terrarium.turtle; - for stroke in strokes { - turtle.move_to(stroke.start_point()); - for cmd in stroke.commands() { - cmd.apply(turtle); - } - } + conversion_visitor.terrarium.apply_strokes(strokes); } else { visit::depth_first_visit(doc, &mut conversion_visitor); } @@ -242,7 +240,7 @@ pub fn svg_to_turtle( conversion_visitor.end(); conversion_visitor.terrarium.pop_transform(); - conversion_visitor.terrarium.turtle.inner + conversion_visitor.terrarium.finish().into_inner() } fn svg_to_optimized_strokes( @@ -252,24 +250,25 @@ fn svg_to_optimized_strokes( origin_transform: Transform2D, selector_filter: Option, coordinate_system: CoordinateSystem, - starting_point: [Option; 2] + starting_point: [Option; 2], ) -> Vec { let mut collect_visitor = ConversionVisitor { terrarium: Terrarium::new(StrokeCollectingTurtle::default()), coordinate_system, - _config: config, + config, options, name_stack: vec![], viewport_dim_stack: vec![], selector_filter, + css_rules: visit::parse_css(doc), }; collect_visitor.terrarium.push_transform(origin_transform); collect_visitor.begin(); visit::depth_first_visit(doc, &mut collect_visitor); collect_visitor.end(); collect_visitor.terrarium.pop_transform(); - let strokes = collect_visitor.terrarium.turtle.into_strokes(); - minimize_travel_time(strokes,starting_point) + let strokes = collect_visitor.terrarium.finish().into_strokes(); + minimize_travel_time(strokes, starting_point) } fn node_name(node: &Node, attr_to_print: &Option) -> String { @@ -329,17 +328,19 @@ mod test { #[test] fn serde_conversion_options_with_both_dimensions_is_correct() { - let mut r#struct = ConversionOptions::default(); - r#struct.dimensions = [ - Some(Length { - number: 4., - unit: LengthUnit::Mm, - }), - Some(Length { - number: 10.5, - unit: LengthUnit::In, - }), - ]; + let r#struct = ConversionOptions { + dimensions: [ + Some(Length { + number: 4., + unit: LengthUnit::Mm, + }), + Some(Length { + number: 10.5, + unit: LengthUnit::In, + }), + ], + ..Default::default() + }; let json = r#"{"dimensions":[{"number":4.0,"unit":"Mm"},{"number":10.5,"unit":"In"}]}"#; assert_eq!(serde_json::to_string(&r#struct).unwrap(), json); diff --git a/star/src/lower/path.rs b/star/src/lower/path.rs deleted file mode 100644 index 8b6ddaf..0000000 --- a/star/src/lower/path.rs +++ /dev/null @@ -1,64 +0,0 @@ -use euclid::Angle; -use log::debug; -use lyon_geom::{ArcFlags, point, vector}; -use svgtypes::PathSegment; - -use super::Terrarium; -use crate::turtle::Turtle; - -/// Maps [`PathSegment`]s into concrete operations on the [`Terrarium`] -/// -/// Performs a [`Terrarium::reset`] on each call -pub fn apply_path( - terrarium: &mut Terrarium, - path: impl IntoIterator, -) { - use PathSegment::*; - - terrarium.reset(); - path.into_iter().for_each(|segment| { - debug!("Drawing {:?}", &segment); - match segment { - MoveTo { abs, x, y } => terrarium.move_to(abs, x, y), - ClosePath { abs: _ } => { - // Ignore abs, should have identical effect: [9.3.4. The "closepath" command]("https://www.w3.org/TR/SVG/paths.html#PathDataClosePathCommand) - terrarium.close() - } - LineTo { abs, x, y } => terrarium.line(abs, x, y), - HorizontalLineTo { abs, x } => terrarium.line(abs, x, None), - VerticalLineTo { abs, y } => terrarium.line(abs, None, y), - CurveTo { - abs, - x1, - y1, - x2, - y2, - x, - y, - } => terrarium.cubic_bezier(abs, point(x1, y1), point(x2, y2), point(x, y)), - SmoothCurveTo { abs, x2, y2, x, y } => { - terrarium.smooth_cubic_bezier(abs, point(x2, y2), point(x, y)) - } - Quadratic { abs, x1, y1, x, y } => { - terrarium.quadratic_bezier(abs, point(x1, y1), point(x, y)) - } - SmoothQuadratic { abs, x, y } => terrarium.smooth_quadratic_bezier(abs, point(x, y)), - EllipticalArc { - abs, - rx, - ry, - x_axis_rotation, - large_arc, - sweep, - x, - y, - } => terrarium.elliptical( - abs, - vector(rx, ry), - Angle::degrees(x_axis_rotation), - ArcFlags { large_arc, sweep }, - point(x, y), - ), - } - }); -} diff --git a/star/src/lower/units.rs b/star/src/lower/units.rs index dcc7708..0f40b75 100644 --- a/star/src/lower/units.rs +++ b/star/src/lower/units.rs @@ -20,7 +20,7 @@ pub enum DimensionHint { Other, } -impl<'a, T: Turtle> ConversionVisitor<'a, T> { +impl<'a, 'input, T: Turtle> ConversionVisitor<'a, 'input, T> { /// Convenience function for converting a length attribute to user units pub fn length_attr_to_user_units(&self, node: &Node, attr: &str) -> Option { let l = node diff --git a/star/src/lower/visit.rs b/star/src/lower/visit.rs index 88fb88c..e1fdd40 100644 --- a/star/src/lower/visit.rs +++ b/star/src/lower/visit.rs @@ -1,4 +1,4 @@ -use std::str::FromStr; +use std::{collections::HashMap, str::FromStr}; use euclid::default::Transform2D; use log::{debug, warn}; @@ -10,13 +10,12 @@ use svgtypes::{ use super::{ ConversionVisitor, - path::apply_path, transform::{get_viewport_transform, svg_transform_into_euclid_transform}, units::DimensionHint, }; use crate::{ lower::node_name, - turtle::{CoordinateSystem, Turtle}, + turtle::{CoordinateSystem, Turtle, elements::FillRule}, }; const SVG_TAG_NAME: &str = "svg"; @@ -39,16 +38,97 @@ pub trait XmlVisitor { fn visit_exit(&mut self, node: Node); } +/// Returns the effective value of the given presentation attribute (i.e. stroke), +/// adhering to precedence rules. +/// +/// 1. Inline style attribute +/// 2. CSS class rules +/// 3. Presentation attribute. +fn calculate_presentation_attr<'input>( + node: Node<'input, 'input>, + name: &'input str, + css_rules: &HashMap<&'input str, HashMap<&'input str, &'input str>>, +) -> Option<&'input str> { + if let Some(style) = node.attribute("style") { + let v = style.split(';').find_map(|decl| { + let (k, v) = decl.split_once(':')?; + (k.trim() == name).then(|| v.trim()) + }); + if v.is_some() { + return v; + } + } + if let Some(classes) = node.attribute("class") { + let v = classes + .split_whitespace() + .find_map(|cls| css_rules.get(cls)?.get(name).copied()); + if v.is_some() { + return v; + } + } + node.attribute(name).map(|v| v.trim()) +} + +/// Barebones CSS class rules parser. +/// +/// Returns a map from class name to property + value pairs. Only simple, flat class selectors +/// (`.classname { prop: val; }`) are handled. +pub fn parse_css<'a>(doc: &'a Document) -> HashMap<&'a str, HashMap<&'a str, &'a str>> { + let mut rules: HashMap<&str, HashMap<&str, &str>> = HashMap::new(); + for node in doc.descendants() { + if node.tag_name().name() != "style" { + continue; + } + let Some(css) = node.text() else { continue }; + for block in css.split('}') { + let Some((selector, declarations)) = block.split_once('{') else { + continue; + }; + let props = declarations + .split(';') + .filter_map(|decl| { + let (k, v) = decl.split_once(':')?; + let k = k.trim(); + let v = v.trim(); + (!k.is_empty() && !v.is_empty()).then_some((k, v)) + }) + .collect::>(); + if props.is_empty() { + continue; + } + for subselector in selector.split(',') { + let Some(class) = subselector.trim().strip_prefix('.') else { + continue; + }; + // Strip pseudo-classes and other complexity. + let class = class + .split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_') + .next() + .unwrap_or("") + .trim(); + if !class.is_empty() { + rules.entry(class).or_default().extend(props.iter()); + } + } + } + } + rules +} + /// Used to skip over SVG elements that are explicitly marked as do not render -fn should_render_node(node: Node) -> bool { +fn should_render_node(node: Node, render_symbol: bool) -> bool { node.is_element() && !node .attribute("style") - .is_some_and(|style| style.contains("display:none")) - // - Defs are not rendered + .is_some_and(|style| style.split(';').any(|declaration| { + let mut parts = declaration.splitn(2, ':').map(str::trim); + (parts.next(), parts.next()) == (Some("display"), Some("none")) + })) + // - Defs are not directly rendered // - Markers are not directly rendered - // - Symbols are not directly rendered - && !matches!(node.tag_name().name(), DEFS_TAG_NAME | MARKER_TAG_NAME | SYMBOL_TAG_NAME) + && !matches!(node.tag_name().name(), DEFS_TAG_NAME | MARKER_TAG_NAME) + // - Symbols are not directly rendered, unless referenced by a use + && (render_symbol || !matches!(node.tag_name().name(), SYMBOL_TAG_NAME)) } /// Resolve `href` or `xlink:href` on a `` element to a document node. @@ -67,46 +147,28 @@ fn resolve_use_href<'a, 'input: 'a>( } pub fn depth_first_visit(doc: &Document, visitor: &mut impl XmlVisitor) { - fn visit_node(doc: &Document, node: Node, visitor: &mut V) { - if !should_render_node(node) { + fn visit_node(doc: &Document, node: Node, visitor: &mut V, render_symbol: bool) { + if !should_render_node(node, render_symbol) { return; } visitor.visit_enter(node); if node.tag_name().name() == USE_TAG_NAME && let Some(referenced) = resolve_use_href(doc, node) { - visit_use_referenced_node(doc, referenced, visitor); + visit_node(doc, referenced, visitor, true); } else { node.children() - .for_each(|child| visit_node(doc, child, visitor)); + .for_each(|child| visit_node(doc, child, visitor, false)); } visitor.visit_exit(node); } - /// Special-cased [visit_node] for a node referenced by a `` element to get - /// around the [`should_render_node`] filter that usually prevents symbols from being rendered. - fn visit_use_referenced_node(doc: &Document, node: Node, visitor: &mut V) { - if !node.is_element() { - return; - } - if node - .attribute("style") - .is_some_and(|s| s.contains("display:none")) - { - return; - } - visitor.visit_enter(node); - node.children() - .for_each(|child| visit_node(doc, child, visitor)); - visitor.visit_exit(node); - } - doc.root() .children() - .for_each(|child| visit_node(doc, child, visitor)); + .for_each(|child| visit_node(doc, child, visitor, false)); } -impl<'a, T: Turtle> XmlVisitor for ConversionVisitor<'a, T> { +impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { fn visit_enter(&mut self, node: Node) { use PathSegment::*; @@ -276,253 +338,312 @@ impl<'a, T: Turtle> XmlVisitor for ConversionVisitor<'a, T> { } self.terrarium.push_transform(flattened_transform); + self.name_stack + .push(node_name(&node, &self.config.extra_attribute_name)); - if self.should_draw_node(node) { - match node.tag_name().name() { - PATH_TAG_NAME => { - if let Some(d) = node.attribute("d") { - self.comment(&node); - apply_path( - &mut self.terrarium, - PathParser::from(d) - .map(|segment| segment.expect("could not parse path segment")), - ); - } else { - warn!("There is a path node containing no actual path: {node:?}"); - } + if !self.should_draw_node(node) { + return; + } + + let has_fill_attr = calculate_presentation_attr(node, "fill", &self.css_rules) + .map(|v| v != "none") + .unwrap_or(true); + let fill_rule = calculate_presentation_attr(node, "fill-rule", &self.css_rules) + .map(|v| { + if v == "evenodd" { + FillRule::EvenOdd + } else { + FillRule::NonZero } - name @ (POLYLINE_TAG_NAME | POLYGON_TAG_NAME) => { - if let Some(points) = node.attribute("points") { - self.comment(&node); - - let mut pp = PointsParser::from(points).peekable(); - let path = pp - .peek() - .copied() - .map(|(x, y)| MoveTo { abs: true, x, y }) - .into_iter() - .chain(pp.map(|(x, y)| LineTo { abs: true, x, y })) - .chain( - // Path must be closed if this is a polygon - if name == POLYGON_TAG_NAME { - Some(ClosePath { abs: true }) - } else { - None - }, - ); - - apply_path(&mut self.terrarium, path); - } else { - warn!("There is a {name} node containing no actual path: {node:?}"); + }) + .unwrap_or_default(); + + // TODO: changing the default here to false would be breaking, but is technically correct from an SVG perspective. + let has_stroke_attr = calculate_presentation_attr(node, "stroke", &self.css_rules) + .map(|v| v != "none") + .unwrap_or(true); + + match node.tag_name().name() { + PATH_TAG_NAME => { + let Some(d) = node.attribute("d") else { + warn!("There is a path node containing no actual path: {node:?}"); + return; + }; + let segments = PathParser::from(d) + .map(|segment| segment.expect("could not parse path segment")); + let needs_fill = has_fill_attr; + if needs_fill || has_stroke_attr { + self.comment(); + if needs_fill { + self.terrarium.apply_polygon(segments.clone(), fill_rule); + } + if has_stroke_attr { + self.terrarium.apply_path(segments); } } - RECT_TAG_NAME => { - let x = self.length_attr_to_user_units(&node, "x").unwrap_or(0.); - let y = self.length_attr_to_user_units(&node, "y").unwrap_or(0.); - let width = self.length_attr_to_user_units(&node, "width"); - let height = self.length_attr_to_user_units(&node, "height"); - let rx = self.length_attr_to_user_units(&node, "rx").unwrap_or(0.); - let ry = self.length_attr_to_user_units(&node, "ry").unwrap_or(0.); - let has_radius = rx > 0. && ry > 0.; - - match (width, height) { - (Some(width), Some(height)) => { - self.comment(&node); - apply_path( - &mut self.terrarium, - [ - MoveTo { - abs: true, - x: x + rx, - y, - }, - HorizontalLineTo { - abs: true, - x: x + width - rx, - }, - EllipticalArc { - abs: true, - rx, - ry, - x_axis_rotation: 0., - large_arc: false, - sweep: true, - x: x + width, - y: y + ry, - }, - VerticalLineTo { - abs: true, - y: y + height - ry, - }, - EllipticalArc { - abs: true, - rx, - ry, - x_axis_rotation: 0., - large_arc: false, - sweep: true, - x: x + width - rx, - y: y + height, - }, - HorizontalLineTo { - abs: true, - x: x + rx, - }, - EllipticalArc { - abs: true, - rx, - ry, - x_axis_rotation: 0., - large_arc: false, - sweep: true, - x, - y: y + height - ry, - }, - VerticalLineTo { - abs: true, - y: y + ry, - }, - EllipticalArc { - abs: true, - rx, - ry, - x_axis_rotation: 0., - large_arc: false, - sweep: true, - x: x + rx, - y, - }, - ClosePath { abs: true }, - ] - .into_iter() - .filter(|p| has_radius || !matches!(p, EllipticalArc { .. })), - ) - } - _other => { - warn!("Invalid rectangle node: {node:?}"); - } + } + name @ (POLYLINE_TAG_NAME | POLYGON_TAG_NAME) => { + let Some(points) = node.attribute("points") else { + warn!("There is a {name} node containing no actual path: {node:?}"); + return; + }; + let is_polygon = name == POLYGON_TAG_NAME; + let mut pp = PointsParser::from(points).peekable(); + let segments = pp + .peek() + .copied() + .map(|(x, y)| MoveTo { abs: true, x, y }) + .into_iter() + .chain(pp.map(|(x, y)| LineTo { abs: true, x, y })) + .chain(is_polygon.then_some(ClosePath { abs: true })); + + let needs_fill = has_fill_attr && is_polygon; + if needs_fill || has_stroke_attr { + self.comment(); + if needs_fill { + self.terrarium.apply_polygon(segments.clone(), fill_rule); + } + if has_stroke_attr { + self.terrarium.apply_path(segments); } } - CIRCLE_TAG_NAME | ELLIPSE_TAG_NAME => { - let cx = self.length_attr_to_user_units(&node, "cx").unwrap_or(0.); - let cy = self.length_attr_to_user_units(&node, "cy").unwrap_or(0.); - let r = self.length_attr_to_user_units(&node, "r").unwrap_or(0.); - let rx = self.length_attr_to_user_units(&node, "rx").unwrap_or(r); - let ry = self.length_attr_to_user_units(&node, "ry").unwrap_or(r); - if rx > 0. && ry > 0. { - self.comment(&node); - apply_path( - &mut self.terrarium, - std::iter::once(MoveTo { - abs: true, - x: cx + rx, - y: cy, - }) - .chain( - [(cx, cy + ry), (cx - rx, cy), (cx, cy - ry), (cx + rx, cy)].map( - |(x, y)| EllipticalArc { - abs: true, - rx, - ry, - x_axis_rotation: 0., - large_arc: false, - sweep: true, - x, - y, - }, - ), - ) - .chain(std::iter::once(ClosePath { abs: true })), - ); - } else { - warn!("Invalid {} node: {node:?}", node.tag_name().name()); + } + RECT_TAG_NAME => { + let x = self.length_attr_to_user_units(&node, "x").unwrap_or(0.); + let y = self.length_attr_to_user_units(&node, "y").unwrap_or(0.); + let width = self.length_attr_to_user_units(&node, "width"); + let height = self.length_attr_to_user_units(&node, "height"); + let rx = self.length_attr_to_user_units(&node, "rx").unwrap_or(0.); + let ry = self.length_attr_to_user_units(&node, "ry").unwrap_or(0.); + let has_radius = rx > 0. && ry > 0.; + + let (Some(width), Some(height)) = (width, height) else { + warn!("Invalid rectangle node: {node:?}"); + return; + }; + let segments = [ + MoveTo { + abs: true, + x: x + rx, + y, + }, + HorizontalLineTo { + abs: true, + x: x + width - rx, + }, + EllipticalArc { + abs: true, + rx, + ry, + x_axis_rotation: 0., + large_arc: false, + sweep: true, + x: x + width, + y: y + ry, + }, + VerticalLineTo { + abs: true, + y: y + height - ry, + }, + EllipticalArc { + abs: true, + rx, + ry, + x_axis_rotation: 0., + large_arc: false, + sweep: true, + x: x + width - rx, + y: y + height, + }, + HorizontalLineTo { + abs: true, + x: x + rx, + }, + EllipticalArc { + abs: true, + rx, + ry, + x_axis_rotation: 0., + large_arc: false, + sweep: true, + x, + y: y + height - ry, + }, + VerticalLineTo { + abs: true, + y: y + ry, + }, + EllipticalArc { + abs: true, + rx, + ry, + x_axis_rotation: 0., + large_arc: false, + sweep: true, + x: x + rx, + y, + }, + ClosePath { abs: true }, + ] + .into_iter() + .filter(|p| has_radius || !matches!(p, EllipticalArc { .. })); + + if has_fill_attr || has_stroke_attr { + self.comment(); + if has_fill_attr { + self.terrarium.apply_polygon(segments.clone(), fill_rule); } + if has_stroke_attr { + self.terrarium.apply_path(segments); + } + } + } + CIRCLE_TAG_NAME | ELLIPSE_TAG_NAME => { + let cx = self.length_attr_to_user_units(&node, "cx").unwrap_or(0.); + let cy = self.length_attr_to_user_units(&node, "cy").unwrap_or(0.); + let r = self.length_attr_to_user_units(&node, "r").unwrap_or(0.); + let rx = self.length_attr_to_user_units(&node, "rx").unwrap_or(r); + let ry = self.length_attr_to_user_units(&node, "ry").unwrap_or(r); + if rx <= 0. || ry <= 0. { + warn!("Invalid {} node: {node:?}", node.tag_name().name()); + return; } - LINE_TAG_NAME => { - let x1 = self.length_attr_to_user_units(&node, "x1"); - let y1 = self.length_attr_to_user_units(&node, "y1"); - let x2 = self.length_attr_to_user_units(&node, "x2"); - let y2 = self.length_attr_to_user_units(&node, "y2"); - match (x1, y1, x2, y2) { - (Some(x1), Some(y1), Some(x2), Some(y2)) => { - self.comment(&node); - apply_path( - &mut self.terrarium, - [ - MoveTo { - abs: true, - x: x1, - y: y1, - }, - LineTo { - abs: true, - x: x2, - y: y2, - }, - ], - ); - } - _other => { - warn!("Invalid line node: {node:?}"); - } + let segments = [ + MoveTo { + abs: true, + x: cx + rx, + y: cy, + }, + EllipticalArc { + abs: true, + rx, + ry, + x_axis_rotation: 0., + large_arc: false, + sweep: true, + x: cx, + y: cy + ry, + }, + EllipticalArc { + abs: true, + rx, + ry, + x_axis_rotation: 0., + large_arc: false, + sweep: true, + x: cx - rx, + y: cy, + }, + EllipticalArc { + abs: true, + rx, + ry, + x_axis_rotation: 0., + large_arc: false, + sweep: true, + x: cx, + y: cy - ry, + }, + EllipticalArc { + abs: true, + rx, + ry, + x_axis_rotation: 0., + large_arc: false, + sweep: true, + x: cx + rx, + y: cy, + }, + ClosePath { abs: true }, + ]; + if has_fill_attr || has_stroke_attr { + self.comment(); + if has_fill_attr { + self.terrarium.apply_polygon(segments, fill_rule); } + if has_stroke_attr { + self.terrarium.apply_path(segments); + } + } + } + LINE_TAG_NAME => { + let (Some(x1), Some(y1), Some(x2), Some(y2)) = ( + self.length_attr_to_user_units(&node, "x1"), + self.length_attr_to_user_units(&node, "y1"), + self.length_attr_to_user_units(&node, "x2"), + self.length_attr_to_user_units(&node, "y2"), + ) else { + warn!("Invalid line node: {node:?}"); + return; + }; + if has_stroke_attr { + self.comment(); + self.terrarium.apply_path([ + MoveTo { + abs: true, + x: x1, + y: y1, + }, + LineTo { + abs: true, + x: x2, + y: y2, + }, + ]); } - #[cfg(feature = "image")] - "image" => { - use base64::{Engine, engine::general_purpose::STANDARD}; - - let Some(href) = node - .attribute("href") - .or_else(|| node.attribute(("http://www.w3.org/1999/xlink", "href"))) - else { - warn!("image element has no href: {node:?}"); + } + #[cfg(feature = "image")] + "image" => { + use base64::{Engine, engine::general_purpose::STANDARD}; + + let Some(href) = node + .attribute("href") + .or_else(|| node.attribute(("http://www.w3.org/1999/xlink", "href"))) + else { + warn!("image element has no href: {node:?}"); + return; + }; + let Some(b64) = href + .strip_prefix("data:image/png;base64,") + .or_else(|| href.strip_prefix("data:image/jpeg;base64,")) + else { + warn!("Unsupported image href {href}"); + return; + }; + + let b64_no_whitespace: String = + b64.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + let bytes = match STANDARD.decode(&b64_no_whitespace) { + Ok(bytes) => bytes, + Err(err) => { + warn!("image base64 decode failed: {err}"); return; - }; - let Some(b64) = href - .strip_prefix("data:image/png;base64,") - .or_else(|| href.strip_prefix("data:image/jpeg;base64,")) - else { - warn!("Unsupported image href {href}"); + } + }; + let image = match image::load_from_memory(&bytes) { + Ok(img) => img, + Err(e) => { + warn!("image decode failed: {e}"); return; - }; - - let b64_no_whitespace: String = - b64.chars().filter(|c| !c.is_ascii_whitespace()).collect(); - let bytes = match STANDARD.decode(&b64_no_whitespace) { - Ok(bytes) => bytes, - Err(err) => { - warn!("image base64 decode failed: {err}"); - return; - } - }; - let image = match image::load_from_memory(&bytes) { - Ok(img) => img, - Err(e) => { - warn!("image decode failed: {e}"); - return; - } - }; - - let x = self.length_attr_to_user_units(&node, "x").unwrap_or(0.); - let y = self.length_attr_to_user_units(&node, "y").unwrap_or(0.); - let width = self.length_attr_to_user_units(&node, "width").unwrap_or(0.); - let height = self - .length_attr_to_user_units(&node, "height") - .unwrap_or(0.); - - self.comment(&node); - self.terrarium.image(image, x, y, width, height); - } - // No-op tags - SVG_TAG_NAME | GROUP_TAG_NAME | USE_TAG_NAME | SYMBOL_TAG_NAME => {} - _ => { - debug!("Unknown node: {}", node.tag_name().name()); - } + } + }; + + let x = self.length_attr_to_user_units(&node, "x").unwrap_or(0.); + let y = self.length_attr_to_user_units(&node, "y").unwrap_or(0.); + let width = self.length_attr_to_user_units(&node, "width").unwrap_or(0.); + let height = self + .length_attr_to_user_units(&node, "height") + .unwrap_or(0.); + + self.comment(); + self.terrarium.image(image, x, y, width, height); + } + // No-op tags + SVG_TAG_NAME | GROUP_TAG_NAME | USE_TAG_NAME | SYMBOL_TAG_NAME => {} + other => { + debug!("Unhandled node: {other}"); } } - - self.name_stack - .push(node_name(&node, &self._config.extra_attribute_name)); } fn visit_exit(&mut self, node: Node) { diff --git a/star/src/turtle/collect.rs b/star/src/turtle/collect.rs index 3876e1b..b2d0075 100644 --- a/star/src/turtle/collect.rs +++ b/star/src/turtle/collect.rs @@ -1,35 +1,12 @@ -use lyon_geom::{CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc}; +use super::{Turtle, elements::Stroke}; -use super::{ - Turtle, - elements::{DrawCommand, Stroke}, -}; - -/// Collects drawing commands into [Stroke]s for pre-flattening operations. +/// Collects [Stroke]s for pre-flattening operations. #[derive(Debug, Default)] pub struct StrokeCollectingTurtle { strokes: Vec, - pending: Vec, - stroke_start: Point, - current_pos: Point, } impl StrokeCollectingTurtle { - fn flush(&mut self) { - let has_geometry = self - .pending - .iter() - .any(|c| !matches!(c, DrawCommand::Comment(_))); - if has_geometry { - self.strokes.push(Stroke { - start_point: self.stroke_start, - commands: std::mem::take(&mut self.pending), - }); - } else { - self.pending.clear(); - } - } - pub fn into_strokes(self) -> Vec { self.strokes } @@ -37,41 +14,16 @@ impl StrokeCollectingTurtle { impl Turtle for StrokeCollectingTurtle { fn begin(&mut self) {} + fn end(&mut self) {} - fn end(&mut self) { - self.flush(); - } - - fn comment(&mut self, comment: String) { - self.pending.push(DrawCommand::Comment(comment)); - } - - fn move_to(&mut self, to: Point) { - self.flush(); - self.stroke_start = to; - self.current_pos = to; - } - - fn line_to(&mut self, to: Point) { - self.pending.push(DrawCommand::LineTo { - from: self.current_pos, - to, - }); - self.current_pos = to; + fn stroke(&mut self, stroke: Stroke) { + self.strokes.push(stroke); } - fn arc(&mut self, svg_arc: SvgArc) { - self.pending.push(DrawCommand::Arc(svg_arc)); - self.current_pos = svg_arc.to; - } - - fn cubic_bezier(&mut self, cbs: CubicBezierSegment) { - self.pending.push(DrawCommand::CubicBezier(cbs)); - self.current_pos = cbs.to; - } + #[cfg(feature = "image")] + fn image(&mut self, _image: super::elements::RasterImage) {} - fn quadratic_bezier(&mut self, qbs: QuadraticBezierSegment) { - self.pending.push(DrawCommand::QuadraticBezier(qbs)); - self.current_pos = qbs.to; + fn fill_polygon(&mut self, _polygon: super::elements::FillPolygon) { + // TODO? } } diff --git a/star/src/turtle/dpi.rs b/star/src/turtle/dpi.rs index c08efec..f124e13 100644 --- a/star/src/turtle/dpi.rs +++ b/star/src/turtle/dpi.rs @@ -6,16 +6,27 @@ use uom::si::{ length::{inch, millimeter}, }; -use super::Turtle; +use super::{ + Turtle, + elements::{DrawCommand, FillPolygon, Stroke}, +}; /// Wrapper turtle that converts from user units to millimeters at a given DPI #[derive(Debug)] pub struct DpiConvertingTurtle { - pub dpi: f64, - pub inner: T, + dpi: f64, + inner: T, } impl DpiConvertingTurtle { + pub fn new(dpi: f64, inner: T) -> Self { + Self { dpi, inner } + } + + pub fn into_inner(self) -> T { + self.inner + } + fn to_mm(&self, value: f64) -> f64 { Length::new::(value / self.dpi).get::() } @@ -32,6 +43,53 @@ impl DpiConvertingTurtle { fn box_to_mm(&self, b: lyon_geom::Box2D) -> lyon_geom::Box2D { lyon_geom::Box2D::new(self.point_to_mm(b.min), self.point_to_mm(b.max)) } + + fn stroke_to_mm(&self, stroke: Stroke) -> Stroke { + Stroke::new( + self.point_to_mm(stroke.start_point()), + stroke + .into_commands() + .map(|cmd| match cmd { + DrawCommand::LineTo { from, to } => DrawCommand::LineTo { + from: self.point_to_mm(from), + to: self.point_to_mm(to), + }, + DrawCommand::Arc(SvgArc { + from, + to, + radii, + x_rotation, + flags, + }) => DrawCommand::Arc(SvgArc { + from: self.point_to_mm(from), + to: self.point_to_mm(to), + radii: self.vector_to_mm(radii), + x_rotation, + flags, + }), + DrawCommand::CubicBezier(CubicBezierSegment { + from, + ctrl1, + ctrl2, + to, + }) => DrawCommand::CubicBezier(CubicBezierSegment { + from: self.point_to_mm(from), + ctrl1: self.point_to_mm(ctrl1), + ctrl2: self.point_to_mm(ctrl2), + to: self.point_to_mm(to), + }), + DrawCommand::QuadraticBezier(QuadraticBezierSegment { from, ctrl, to }) => { + DrawCommand::QuadraticBezier(QuadraticBezierSegment { + from: self.point_to_mm(from), + ctrl: self.point_to_mm(ctrl), + to: self.point_to_mm(to), + }) + } + DrawCommand::Comment(s) => DrawCommand::Comment(s), + }) + .collect(), + ) + } } impl Turtle for DpiConvertingTurtle { @@ -43,63 +101,8 @@ impl Turtle for DpiConvertingTurtle { self.inner.end() } - fn comment(&mut self, comment: String) { - self.inner.comment(comment) - } - - fn move_to(&mut self, to: Point) { - self.inner.move_to(self.point_to_mm(to)) - } - - fn line_to(&mut self, to: Point) { - self.inner.line_to(self.point_to_mm(to)) - } - - fn arc( - &mut self, - SvgArc { - from, - to, - radii, - x_rotation, - flags, - }: SvgArc, - ) { - self.inner.arc(SvgArc { - from: self.point_to_mm(from), - to: self.point_to_mm(to), - radii: self.vector_to_mm(radii), - x_rotation, - flags, - }) - } - - fn cubic_bezier( - &mut self, - CubicBezierSegment { - from, - ctrl1, - ctrl2, - to, - }: CubicBezierSegment, - ) { - self.inner.cubic_bezier(CubicBezierSegment { - from: self.point_to_mm(from), - ctrl1: self.point_to_mm(ctrl1), - ctrl2: self.point_to_mm(ctrl2), - to: self.point_to_mm(to), - }) - } - - fn quadratic_bezier( - &mut self, - QuadraticBezierSegment { from, ctrl, to }: QuadraticBezierSegment, - ) { - self.inner.quadratic_bezier(QuadraticBezierSegment { - from: self.point_to_mm(from), - to: self.point_to_mm(to), - ctrl: self.point_to_mm(ctrl), - }) + fn stroke(&mut self, stroke: Stroke) { + self.inner.stroke(self.stroke_to_mm(stroke)); } #[cfg(feature = "image")] @@ -109,4 +112,15 @@ impl Turtle for DpiConvertingTurtle { image: img.image, }) } + + fn fill_polygon(&mut self, polygon: FillPolygon) { + self.inner.fill_polygon(FillPolygon { + outer: self.stroke_to_mm(polygon.outer), + holes: polygon + .holes + .into_iter() + .map(|s| self.stroke_to_mm(s)) + .collect(), + }) + } } diff --git a/star/src/turtle/elements/fill.rs b/star/src/turtle/elements/fill.rs new file mode 100644 index 0000000..c696433 --- /dev/null +++ b/star/src/turtle/elements/fill.rs @@ -0,0 +1,291 @@ +use lyon_geom::{LineSegment, Point}; + +use crate::turtle::elements::{DrawCommand, FillPolygon, FillRule, Stroke}; + +const TOLERANCE: f64 = 0.1; + +fn cross(a: Point, b: Point) -> f64 { + a.to_vector().cross(b.to_vector()) +} + +/// Signed area of a closed subpath using Green's theorem. +/// A negative area means clockwise winding. +/// +/// +/// Lines and beziers follow kurbo's [`ParamCurveArea`] formulas; +/// the elliptical arc contribution is a direct integration of the parametric ellipse. +/// +/// [`ParamCurveArea`]: https://docs.rs/kurbo/latest/kurbo/trait.ParamCurveArea.html +/// +fn signed_area(stroke: &Stroke) -> f64 { + let mut area = 0.0; + + for cmd in stroke.commands() { + match cmd { + DrawCommand::LineTo { from, to } => { + area += cross(*from, *to); + } + DrawCommand::Arc(svg_arc) => { + let arc = svg_arc.to_arc(); + area += arc.radii.x * arc.radii.y * arc.sweep_angle.radians + + arc.center.to_vector().cross(svg_arc.to - svg_arc.from); + } + DrawCommand::QuadraticBezier(qbs) => { + area += (2.0 / 3.0) * cross(qbs.from, qbs.ctrl) + + (1.0 / 3.0) * cross(qbs.from, qbs.to) + + (2.0 / 3.0) * cross(qbs.ctrl, qbs.to); + } + DrawCommand::CubicBezier(cbs) => { + area += (6.0 * cross(cbs.from, cbs.ctrl1) + + 3.0 * cross(cbs.from, cbs.ctrl2) + + cross(cbs.from, cbs.to) + + 3.0 * cross(cbs.ctrl1, cbs.ctrl2) + + 3.0 * cross(cbs.ctrl1, cbs.to) + + 6.0 * cross(cbs.ctrl2, cbs.to)) + / 10.0; + } + DrawCommand::Comment(_) => {} + } + } + + if !stroke.is_closed() { + area += cross(stroke.end_point(), stroke.start_point()); + } + area * 0.5 +} + +/// Returns true if `from` => `to` crosses a ray cast from `point` rightwards. +/// +/// Used as the per-segment primitive for ray-casting. The `>= max_y` exclusion on the upper +/// endpoint ensures a shared vertex between two segments is counted only once. +fn edge_crosses_ray(from: Point, to: Point, point: Point) -> bool { + let (min_y, max_y) = if from.y <= to.y { + (from.y, to.y) + } else { + (to.y, from.y) + }; + if point.y < min_y || point.y >= max_y { + return false; + } + let t = (point.y - from.y) / (to.y - from.y); + from.x + t * (to.x - from.x) > point.x +} + +/// Tests whether `point` lies inside the closed region bounded by `stroke` using the +/// ray-casting algorithm. +/// +/// If the number of boundary crossings is odd, it is inside. +/// For the sake of simplicity, curves are flattened with a set tolerance to do the casting. +/// The closing edge (end→start) is always checked to ensure the path is treated as closed. +/// +/// +fn stroke_contains_point(stroke: &Stroke, point: Point) -> bool { + let mut crossings = 0u32; + for cmd in stroke.commands() { + match cmd { + DrawCommand::LineTo { from, to } => { + if edge_crosses_ray(*from, *to, point) { + crossings += 1; + } + } + DrawCommand::Arc(arc) => { + arc.for_each_flattened(TOLERANCE, &mut |seg: &LineSegment| { + if edge_crosses_ray(seg.from, seg.to, point) { + crossings += 1; + } + }); + } + DrawCommand::CubicBezier(cbs) => { + let mut prev = cbs.from; + for to in cbs.flattened(TOLERANCE) { + if edge_crosses_ray(prev, to, point) { + crossings += 1; + } + prev = to; + } + } + DrawCommand::QuadraticBezier(qbs) => { + let mut prev = qbs.from; + for to in qbs.flattened(TOLERANCE) { + if edge_crosses_ray(prev, to, point) { + crossings += 1; + } + prev = to; + } + } + DrawCommand::Comment(_) => {} + } + } + if !stroke.is_closed() && edge_crosses_ray(stroke.end_point(), stroke.start_point(), point) { + crossings += 1; + } + !crossings.is_multiple_of(2) +} + +/// Partitions raw SVG subpaths into [`FillPolygon`]s — one per outer contour — with holes +/// assigned to their closest enclosing outer. +/// +/// The SVG `fill-rule` is consumed here: for `EvenOdd`, nesting depth determines outer vs. hole +/// (even depth → outer); for `NonZero`, the cumulative signed winding of enclosing subpaths +/// determines it (zero cumulative winding before entering → outer). +pub(crate) fn into_fill_polygons(subpaths: Vec, fill_rule: FillRule) -> Vec { + if subpaths.is_empty() { + return vec![]; + } + + let areas: Vec = subpaths.iter().map(signed_area).collect(); + let starts: Vec> = subpaths.iter().map(|s| s.start_point()).collect(); + + // For each subpath, the indices of all other subpaths that contain its start point. + let containers: Vec> = (0..subpaths.len()) + .map(|i| { + (0..subpaths.len()) + .filter(|&j| j != i) + .filter(|&j| stroke_contains_point(&subpaths[j], starts[i])) + .collect() + }) + .collect(); + + // Classify each subpath as outer (contributes filled area) or hole (removes it). + let mut is_outer = vec![false; subpaths.len()]; + let mut is_hole = vec![false; subpaths.len()]; + + for i in 0..subpaths.len() { + match fill_rule { + FillRule::EvenOdd => { + if containers[i].len().is_multiple_of(2) { + is_outer[i] = true; + } else { + is_hole[i] = true; + } + } + FillRule::NonZero => { + let cumulative_winding: i32 = containers[i] + .iter() + .map(|&j| if areas[j] > 0.0 { 1i32 } else { -1i32 }) + .sum(); + + if cumulative_winding == 0 { + is_outer[i] = true; + } else { + let winding_inside = cumulative_winding + if areas[i] > 0.0 { 1 } else { -1 }; + if winding_inside == 0 { + is_hole[i] = true; + } + } + } + } + } + + // For each outer, collect its direct holes — holes for which this outer is the innermost + // enclosing outer (no other outer sits between them). + (0..subpaths.len()) + .filter(|&i| is_outer[i]) + .map(|i| { + let holes = (0..subpaths.len()) + .filter(|&j| is_hole[j] && stroke_contains_point(&subpaths[i], starts[j])) + .filter(|&j| { + // No other outer k is strictly between outer i and hole j. + !containers[j] + .iter() + .any(|&k| k != i && is_outer[k] && containers[k].contains(&i)) + }) + .map(|j| subpaths[j].clone()) + .collect(); + FillPolygon { + outer: subpaths[i].clone(), + holes, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use lyon_geom::Point; + + use super::*; + use crate::turtle::elements::{DrawCommand, FillRule, Stroke}; + + #[test] + fn test_nonzero_nested_ccw() { + // Subpath 0: CCW square (0,0) to (10,10) + let s0 = Stroke::new( + Point::new(0.0, 0.0), + vec![ + DrawCommand::LineTo { + from: Point::new(0.0, 0.0), + to: Point::new(10.0, 0.0), + }, + DrawCommand::LineTo { + from: Point::new(10.0, 0.0), + to: Point::new(10.0, 10.0), + }, + DrawCommand::LineTo { + from: Point::new(10.0, 10.0), + to: Point::new(0.0, 10.0), + }, + DrawCommand::LineTo { + from: Point::new(0.0, 10.0), + to: Point::new(0.0, 0.0), + }, + ], + ); + + // Subpath 1: CCW square (2,2) to (8,8) + let s1 = Stroke::new( + Point::new(2.0, 2.0), + vec![ + DrawCommand::LineTo { + from: Point::new(2.0, 2.0), + to: Point::new(8.0, 2.0), + }, + DrawCommand::LineTo { + from: Point::new(8.0, 2.0), + to: Point::new(8.0, 8.0), + }, + DrawCommand::LineTo { + from: Point::new(8.0, 8.0), + to: Point::new(2.0, 8.0), + }, + DrawCommand::LineTo { + from: Point::new(2.0, 8.0), + to: Point::new(2.0, 2.0), + }, + ], + ); + + // Subpath 2: CCW square (4,4) to (6,6) + let s2 = Stroke::new( + Point::new(4.0, 4.0), + vec![ + DrawCommand::LineTo { + from: Point::new(4.0, 4.0), + to: Point::new(6.0, 4.0), + }, + DrawCommand::LineTo { + from: Point::new(6.0, 4.0), + to: Point::new(6.0, 6.0), + }, + DrawCommand::LineTo { + from: Point::new(6.0, 6.0), + to: Point::new(4.0, 6.0), + }, + DrawCommand::LineTo { + from: Point::new(4.0, 6.0), + to: Point::new(4.0, 4.0), + }, + ], + ); + + let polygons = + into_fill_polygons(vec![s0.clone(), s1.clone(), s2.clone()], FillRule::NonZero); + + // For NonZero, since all are CCW, the winding numbers are 1, 2, and 3. + // All are non-zero, so the whole area should be filled. + // In terms of FillPolygon, it should be one outer (s0) with no holes. + assert_eq!(polygons.len(), 1); + assert_eq!(polygons[0].outer.start_point(), s0.start_point()); + assert_eq!(polygons[0].holes.len(), 0); + } +} diff --git a/star/src/turtle/elements/mod.rs b/star/src/turtle/elements/mod.rs index 42c81a6..fa2975c 100644 --- a/star/src/turtle/elements/mod.rs +++ b/star/src/turtle/elements/mod.rs @@ -2,13 +2,13 @@ use std::mem::swap; +use lyon_geom::Box2D; pub use lyon_geom::{ArcFlags, CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc, Vector}; pub use self::{ arc::{ArcOrLineSegment, FlattenWithArcs, Transformed}, tsp::minimize_travel_time, }; -use crate::turtle::Turtle; /// Approximate [Bézier curves](https://en.wikipedia.org/wiki/B%C3%A9zier_curve) with [Circular arcs](https://en.wikipedia.org/wiki/Circular_arc) mod arc; @@ -16,6 +16,31 @@ mod arc; /// Reorders strokes to minimize pen-up travel using TSP heuristics mod tsp; +pub(crate) mod fill; + +/// Defines the algorithm used to calculate how a polygon is filled. +/// +/// +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum FillRule { + /// A point is inside if the winding number of the path around it is non-zero (SVG default). + #[default] + NonZero, + /// A point is inside if the number of path crossings is odd. + EvenOdd, +} + +/// A filled region: one outer closed contour and zero or more hole contours punched out of it. +/// +/// The SVG fill rule is resolved such that `outer` is the boundary of the filled area and +/// each entry in `holes` is a region to subtract. +#[derive(Debug, Clone)] +pub struct FillPolygon { + pub outer: Stroke, + /// Regions to subtract from [`Self::outer`]. + pub holes: Vec, +} + /// Raster image decoded from an inline PNG/JPEG. /// /// @@ -29,25 +54,29 @@ pub struct RasterImage { /// Atomic unit of a [Stroke]. #[derive(Debug, Clone)] pub enum DrawCommand { - LineTo { from: Point, to: Point }, + LineTo { + from: Point, + to: Point, + }, Arc(SvgArc), CubicBezier(CubicBezierSegment), QuadraticBezier(QuadraticBezierSegment), + /// Largely for debugging purposes. Comment(String), } impl DrawCommand { - pub fn apply(&self, turtle: &mut impl Turtle) { + pub fn start_point(&self) -> Option> { match self { - Self::LineTo { to, .. } => turtle.line_to(*to), - Self::Arc(arc) => turtle.arc(*arc), - Self::CubicBezier(cbs) => turtle.cubic_bezier(*cbs), - Self::QuadraticBezier(qbs) => turtle.quadratic_bezier(*qbs), - Self::Comment(s) => turtle.comment(s.clone()), + Self::LineTo { from, .. } => Some(*from), + Self::Arc(arc) => Some(arc.from), + Self::CubicBezier(cbs) => Some(cbs.from), + Self::QuadraticBezier(qbs) => Some(qbs.from), + Self::Comment(_) => None, } } - fn end_point(&self) -> Option> { + pub fn end_point(&self) -> Option> { match self { Self::LineTo { to, .. } => Some(*to), Self::Arc(arc) => Some(arc.to), @@ -57,7 +86,17 @@ impl DrawCommand { } } - fn reverse(&mut self) { + pub fn bounding_box(&self) -> Option> { + match self { + Self::LineTo { from, to } => Some(Box2D::from_points([*from, *to])), + Self::Arc(arc) => Some(arc.to_arc().bounding_box()), + Self::CubicBezier(cbs) => Some(cbs.bounding_box()), + Self::QuadraticBezier(qbs) => Some(qbs.bounding_box()), + Self::Comment(_) => None, + } + } + + pub fn reverse(&mut self) { match self { Self::LineTo { from, to } => { swap(from, to); @@ -78,11 +117,11 @@ impl DrawCommand { } } -/// A continuous tool-on sequence with a known [Self::start_point]. +/// A continuous path composed of individual commands with a known start point. #[derive(Debug, Clone)] pub struct Stroke { - pub(super) start_point: Point, - pub(super) commands: Vec, + start_point: Point, + commands: Vec, } impl Stroke { @@ -116,8 +155,12 @@ impl Stroke { self.commands.iter() } - /// Whether the stroke ends at the start. + pub fn into_commands(self) -> impl Iterator { + self.commands.into_iter() + } + + /// Whether the stroke explicitly ends at the start. pub fn is_closed(&self) -> bool { - (self.start_point() - self.end_point()).length() < f64::EPSILON + (self.start_point() - self.end_point()).square_length() < f64::EPSILON } } diff --git a/star/src/turtle/elements/tsp.rs b/star/src/turtle/elements/tsp.rs index 6a1d857..fc90b9f 100644 --- a/star/src/turtle/elements/tsp.rs +++ b/star/src/turtle/elements/tsp.rs @@ -27,22 +27,28 @@ fn dist(a: Point, b: Point) -> f64 { /// /// /// -pub fn minimize_travel_time(strokes: Vec,starting_point: [Option; 2] ) -> Vec { +pub fn minimize_travel_time(strokes: Vec, starting_point: [Option; 2]) -> Vec { if strokes.len() <= 1 { return strokes; } - let the_starting_point : Point = Point::new(starting_point[0].expect("No starting point Y"),starting_point[1].expect("No starting point Y")); + let the_starting_point: Point = Point::new( + starting_point[0].expect("No starting point Y"), + starting_point[1].expect("No starting point Y"), + ); - let path = nearest_neighbor_greedy(strokes,the_starting_point); - local_improvement_with_tabu_search(&path,the_starting_point) + let path = nearest_neighbor_greedy(strokes, the_starting_point); + local_improvement_with_tabu_search(&path, the_starting_point) } /// Greedy nearest-neighbour ordering with flips. /// /// Repeatedly chooses the [Stroke] or [Stroke::reversed] closest to the current point until none remain. -fn nearest_neighbor_greedy(mut remaining: Vec,the_starting_point: Point ) -> Vec { +fn nearest_neighbor_greedy( + mut remaining: Vec, + the_starting_point: Point, +) -> Vec { let mut result = Vec::with_capacity(remaining.len()); - let mut pos : Point = the_starting_point ; + let mut pos: Point = the_starting_point; while !remaining.is_empty() { let mut best_idx = 0; @@ -133,13 +139,17 @@ fn reverse_and_flip(strokes: &mut [Stroke]) { /// - TwoOpt and LinkSwap reversals also flip each stroke in the reversed range. /// - Relocate tries both the normal and reversed orientation of the moved stroke. /// - Distances are `f64` Euclidean rather than squared integers. -fn local_improvement_with_tabu_search(path: &[Stroke],the_starting_point: Point ) -> Vec { +fn local_improvement_with_tabu_search( + path: &[Stroke], + the_starting_point: Point, +) -> Vec { let mut best = path.to_owned(); - let mut best_sum: f64 = stroke_distances(&best).iter().sum::() + dist(the_starting_point,best[0].start_point()) ; + let mut best_sum: f64 = stroke_distances(&best).iter().sum::() + + dist(the_starting_point, best[0].start_point()); let mut current = best.clone(); let mut current_distances = stroke_distances(¤t); - let mut current_sum ; + let mut current_sum; const ITERATIONS: usize = 20000; let mut rng = rand::rng(); @@ -283,8 +293,16 @@ fn local_improvement_with_tabu_search(path: &[Stroke],the_starting_point: Point< // 2 = [first_start, last_end]: both let candidates = [ (0usize, dist(from, last_end)), - (1usize, dist(first_start, to) +dist(the_starting_point,to)-dist(the_starting_point,first_start)), - (2usize, dist(first_start, last_end)+dist(the_starting_point,last_end)-dist(the_starting_point,first_start)), + ( + 1usize, + dist(first_start, to) + dist(the_starting_point, to) + - dist(the_starting_point, first_start), + ), + ( + 2usize, + dist(first_start, last_end) + dist(the_starting_point, last_end) + - dist(the_starting_point, first_start), + ), ]; let (opt, best_new_dist) = candidates .into_iter() @@ -324,7 +342,8 @@ fn local_improvement_with_tabu_search(path: &[Stroke],the_starting_point: Point< } current_distances = stroke_distances(¤t); - current_sum = current_distances.iter().sum::() +dist(the_starting_point,current[0].start_point()) ; + current_sum = current_distances.iter().sum::() + + dist(the_starting_point, current[0].start_point()); if current_sum < best_sum { best = current.clone(); diff --git a/star/src/turtle/mod.rs b/star/src/turtle/mod.rs index a84fd78..a21507e 100644 --- a/star/src/turtle/mod.rs +++ b/star/src/turtle/mod.rs @@ -5,8 +5,10 @@ use lyon_geom::{ euclid::{Angle, default::Transform2D}, point, vector, }; +use svgtypes::PathSegment; -use crate::turtle::elements::Transformed; +use self::elements::{FillPolygon, Transformed}; +use crate::turtle::elements::{DrawCommand, FillRule, Stroke}; mod collect; mod dpi; @@ -40,28 +42,26 @@ pub enum CoordinateSystem { pub trait Turtle: Debug { fn begin(&mut self); fn end(&mut self); - fn comment(&mut self, comment: String); - fn move_to(&mut self, to: Point); - fn line_to(&mut self, to: Point); - fn arc(&mut self, svg_arc: SvgArc); - fn cubic_bezier(&mut self, cbs: CubicBezierSegment); - fn quadratic_bezier(&mut self, qbs: QuadraticBezierSegment); + fn stroke(&mut self, stroke: Stroke); + #[cfg(feature = "image")] - /// This is the only function with a default as most turtles have no way to handle a raster image. - fn image(&mut self, _image: self::elements::RasterImage) {} + fn image(&mut self, image: self::elements::RasterImage); + fn fill_polygon(&mut self, polygon: FillPolygon); } /// Handles SVG complexities outside of [Turtle] scope (transforms, position, offsets, etc.) /// #[derive(Debug)] pub(crate) struct Terrarium { - pub turtle: T, + turtle: T, + has_begun: bool, current_position: Point, initial_position: Point, current_transform: Transform2D, pub transform_stack: Vec>, previous_quadratic_control: Option>, previous_cubic_control: Option>, + comment: Option, } impl Terrarium { @@ -69,87 +69,77 @@ impl Terrarium { pub fn new(turtle: T) -> Self { Self { turtle, + has_begun: false, current_position: Point::zero(), initial_position: Point::zero(), current_transform: Transform2D::identity(), transform_stack: vec![], previous_quadratic_control: None, previous_cubic_control: None, + comment: None, } } /// Move the turtle to the given absolute/relative coordinates in the current transform /// - pub fn move_to(&mut self, abs: bool, x: X, y: Y) - where - X: Into>, - Y: Into>, - { + fn move_to(&mut self, abs: bool, x: f64, y: f64) -> Point { let inverse_transform = self .current_transform .inverse() .expect("transform is invertible"); let original_current_position = inverse_transform.transform_point(self.current_position); - let x = x - .into() - .map(|x| { - if abs { - x - } else { - original_current_position.x + x - } - }) - .unwrap_or(original_current_position.x); - let y = y - .into() - .map(|y| { - if abs { - y - } else { - original_current_position.y + y - } - }) - .unwrap_or(original_current_position.y); + let x = if abs { + x + } else { + original_current_position.x + x + }; + + let y = if abs { + y + } else { + original_current_position.y + y + }; let to = self.current_transform.transform_point(point(x, y)); self.current_position = to; self.initial_position = to; self.previous_quadratic_control = None; self.previous_cubic_control = None; - self.turtle.move_to(to); + to } /// Close an SVG path, cutting back to its initial position /// - pub fn close(&mut self) { + fn close(&mut self) -> Option { // See https://www.w3.org/TR/SVG/paths.html#Segment-CompletingClosePath - // which could result in a G91 G1 X0 Y0 - if !(self.current_position - self.initial_position) + let command = if !(self.current_position - self.initial_position) .abs() .lower_than(vector(f64::EPSILON, f64::EPSILON)) .all() { - self.turtle.line_to(self.initial_position); - } + Some(DrawCommand::LineTo { + from: self.current_position, + to: self.initial_position, + }) + } else { + None + }; self.current_position = self.initial_position; self.previous_quadratic_control = None; self.previous_cubic_control = None; + + command } /// Draw a line from the current position in the current transform to the specified position /// - pub fn line(&mut self, abs: bool, x: X, y: Y) - where - X: Into>, - Y: Into>, - { + fn line(&mut self, abs: bool, x: Option, y: Option) -> DrawCommand { let inverse_transform = self .current_transform .inverse() .expect("transform is invertible"); let original_current_position = inverse_transform.transform_point(self.current_position); let x = x - .into() .map(|x| { if abs { x @@ -159,7 +149,6 @@ impl Terrarium { }) .unwrap_or(original_current_position.x); let y = y - .into() .map(|y| { if abs { y @@ -169,23 +158,24 @@ impl Terrarium { }) .unwrap_or(original_current_position.y); + let from = self.current_position; let to = self.current_transform.transform_point(point(x, y)); self.current_position = to; self.previous_quadratic_control = None; self.previous_cubic_control = None; - self.turtle.line_to(to); + DrawCommand::LineTo { from, to } } /// Draw a cubic curve from the current point to (x, y) with specified control points (x1, y1) and (x2, y2) /// - pub fn cubic_bezier( + fn cubic_bezier( &mut self, abs: bool, mut ctrl1: Point, mut ctrl2: Point, mut to: Point, - ) { + ) -> CubicBezierSegment { let from = self.current_position; if !abs { let inverse_transform = self.current_transform.inverse().unwrap(); @@ -198,7 +188,7 @@ impl Terrarium { ctrl2 = self.current_transform.transform_point(ctrl2); to = self.current_transform.transform_point(to); - let cbs = lyon_geom::CubicBezierSegment { + let cbs = CubicBezierSegment { from, ctrl1, ctrl2, @@ -214,12 +204,17 @@ impl Terrarium { )); self.previous_quadratic_control = None; - self.turtle.cubic_bezier(cbs); + cbs } /// Draw a shorthand/smooth cubic bezier segment, where the first control point was already given /// - pub fn smooth_cubic_bezier(&mut self, abs: bool, mut ctrl2: Point, mut to: Point) { + fn smooth_cubic_bezier( + &mut self, + abs: bool, + mut ctrl2: Point, + mut to: Point, + ) -> CubicBezierSegment { let from = self.current_position; let ctrl1 = self.previous_cubic_control.unwrap_or(self.current_position); if !abs { @@ -234,7 +229,7 @@ impl Terrarium { ctrl2 = self.current_transform.transform_point(ctrl2); to = self.current_transform.transform_point(to); - let cbs = lyon_geom::CubicBezierSegment { + let cbs = CubicBezierSegment { from, ctrl1, ctrl2, @@ -250,12 +245,16 @@ impl Terrarium { )); self.previous_quadratic_control = None; - self.turtle.cubic_bezier(cbs); + cbs } /// Draw a shorthand/smooth cubic bezier segment, where the control point was already given /// - pub fn smooth_quadratic_bezier(&mut self, abs: bool, mut to: Point) { + fn smooth_quadratic_bezier( + &mut self, + abs: bool, + mut to: Point, + ) -> QuadraticBezierSegment { let from = self.current_position; let ctrl = self .previous_quadratic_control @@ -281,12 +280,17 @@ impl Terrarium { )); self.previous_cubic_control = None; - self.turtle.quadratic_bezier(qbs); + qbs } /// Draw a quadratic bezier segment /// - pub fn quadratic_bezier(&mut self, abs: bool, mut ctrl: Point, mut to: Point) { + fn quadratic_bezier( + &mut self, + abs: bool, + mut ctrl: Point, + mut to: Point, + ) -> QuadraticBezierSegment { let from = self.current_position; if !abs { let inverse_transform = self @@ -311,19 +315,19 @@ impl Terrarium { )); self.previous_cubic_control = None; - self.turtle.quadratic_bezier(qbs); + qbs } /// Draw an elliptical arc segment /// - pub fn elliptical( + fn elliptical( &mut self, abs: bool, radii: Vector, x_rotation: Angle, flags: ArcFlags, mut to: Point, - ) { + ) -> SvgArc { let from = self .current_transform .inverse() @@ -346,7 +350,23 @@ impl Terrarium { self.previous_quadratic_control = None; self.previous_cubic_control = None; - self.turtle.arc(svg_arc); + svg_arc + } + + fn begin(&mut self) { + if !self.has_begun { + self.turtle.begin(); + self.has_begun = true; + } + } + + /// Reset the position of the turtle to the origin in the current transform stack + /// Used for starting a new path + fn reset(&mut self) { + self.current_position = self.current_transform.transform_point(Point::zero()); + self.initial_position = self.current_position; + self.previous_quadratic_control = None; + self.previous_cubic_control = None; } /// @@ -385,12 +405,149 @@ impl Terrarium { .expect("pop only called when transforms remain"); } - /// Reset the position of the turtle to the origin in the current transform stack - /// Used for starting a new path - pub fn reset(&mut self) { - self.current_position = self.current_transform.transform_point(Point::zero()); - self.initial_position = self.current_position; - self.previous_quadratic_control = None; - self.previous_cubic_control = None; + pub fn comment(&mut self, comment: String) { + self.comment = Some(comment); + } + + /// Maps [PathSegments](PathSegment) into concrete operations. + pub fn apply_path(&mut self, path: impl IntoIterator) { + use PathSegment::*; + self.begin(); + self.reset(); + + let mut start_point = Point::zero(); + let mut commands = vec![]; + let mut pending_comment = self.comment.take(); + + for segment in path { + match segment { + MoveTo { abs, x, y } => { + if !commands.is_empty() { + self.turtle + .stroke(Stroke::new(start_point, std::mem::take(&mut commands))); + } + start_point = self.move_to(abs, x, y); + if let Some(comment) = pending_comment.take() { + commands.push(DrawCommand::Comment(comment)); + } + } + ClosePath { .. } => { + if let Some(command) = self.close() { + commands.push(command); + } + if !commands.is_empty() { + self.turtle + .stroke(Stroke::new(start_point, std::mem::take(&mut commands))); + } + } + LineTo { abs, x, y } => { + commands.push(self.line(abs, Some(x), Some(y))); + } + HorizontalLineTo { abs, x } => { + commands.push(self.line(abs, Some(x), None)); + } + VerticalLineTo { abs, y } => { + commands.push(self.line(abs, None, Some(y))); + } + CurveTo { + abs, + x1, + y1, + x2, + y2, + x, + y, + } => { + commands.push(DrawCommand::CubicBezier(self.cubic_bezier( + abs, + point(x1, y1), + point(x2, y2), + point(x, y), + ))); + } + SmoothCurveTo { abs, x2, y2, x, y } => { + commands.push(DrawCommand::CubicBezier(self.smooth_cubic_bezier( + abs, + point(x2, y2), + point(x, y), + ))); + } + Quadratic { abs, x1, y1, x, y } => { + commands.push(DrawCommand::QuadraticBezier(self.quadratic_bezier( + abs, + point(x1, y1), + point(x, y), + ))); + } + SmoothQuadratic { abs, x, y } => { + commands.push(DrawCommand::QuadraticBezier( + self.smooth_quadratic_bezier(abs, point(x, y)), + )); + } + EllipticalArc { + abs, + rx, + ry, + x_axis_rotation, + large_arc, + sweep, + x, + y, + } => { + commands.push(DrawCommand::Arc(self.elliptical( + abs, + vector(rx, ry), + Angle::degrees(x_axis_rotation), + ArcFlags { large_arc, sweep }, + point(x, y), + ))); + } + } + } + + if !commands.is_empty() { + self.turtle + .stroke(Stroke::new(start_point, std::mem::take(&mut commands))); + } + } + + pub fn apply_strokes(&mut self, strokes: impl IntoIterator) { + self.begin(); + + for stroke in strokes { + self.reset(); + self.turtle.stroke(stroke); + } + } + + /// Converts an SVG polygon into [FillPolygon(s)](FillPolygon) on a turtle. + pub fn apply_polygon( + &mut self, + segments: impl IntoIterator, + fill_rule: FillRule, + ) { + let mut sub = Terrarium { + has_begun: false, + turtle: StrokeCollectingTurtle::default(), + current_position: self.current_position, + initial_position: self.initial_position, + current_transform: self.current_transform, + transform_stack: self.transform_stack.clone(), + previous_quadratic_control: self.previous_quadratic_control, + previous_cubic_control: self.previous_cubic_control, + comment: self.comment.clone(), + }; + sub.apply_path(segments); + let segments = sub.finish().into_strokes(); + for polygon in self::elements::fill::into_fill_polygons(segments, fill_rule) { + self.turtle.fill_polygon(polygon); + } + } + + pub fn finish(mut self) -> T { + if self.has_begun { + self.turtle.end(); + } + self.turtle } } diff --git a/star/src/turtle/preprocess.rs b/star/src/turtle/preprocess.rs index bd65d19..7c77dbf 100644 --- a/star/src/turtle/preprocess.rs +++ b/star/src/turtle/preprocess.rs @@ -1,11 +1,19 @@ -use lyon_geom::{Box2D, CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc}; +use std::iter; -use super::Turtle; +use lyon_geom::Box2D; + +use super::{Turtle, elements::FillPolygon}; /// Generates a bounding box for all draw operations, used to properly apply [crate::lower::ConversionConfig::origin] #[derive(Debug, Default)] pub struct PreprocessTurtle { - pub bounding_box: Box2D, + bounding_box: Box2D, +} + +impl PreprocessTurtle { + pub fn into_inner(self) -> Box2D { + self.bounding_box + } } impl Turtle for PreprocessTurtle { @@ -13,29 +21,36 @@ impl Turtle for PreprocessTurtle { fn end(&mut self) {} - fn comment(&mut self, _comment: String) {} - - fn move_to(&mut self, to: Point) { - self.bounding_box = Box2D::from_points([self.bounding_box.min, self.bounding_box.max, to]); + #[cfg(feature = "image")] + fn image(&mut self, image: super::elements::RasterImage) { + self.bounding_box = self.bounding_box.union(&image.dimensions); } - fn line_to(&mut self, to: Point) { - self.bounding_box = Box2D::from_points([self.bounding_box.min, self.bounding_box.max, to]); + fn fill_polygon(&mut self, polygon: FillPolygon) { + self.bounding_box = iter::once(polygon.outer) + .chain(polygon.holes) + .flat_map(|s| s.into_commands()) + .filter_map(|c| c.bounding_box()) + .fold(self.bounding_box, |acc, b| { + Box2D::from_points([acc.min, acc.max, b.min, b.max]) + }); } - fn arc(&mut self, svg_arc: SvgArc) { - if svg_arc.is_straight_line() { - self.line_to(svg_arc.to); - } else { - self.bounding_box = self.bounding_box.union(&svg_arc.to_arc().bounding_box()); + fn stroke(&mut self, stroke: super::elements::Stroke) { + self.bounding_box = Box2D::from_points([ + self.bounding_box.min, + self.bounding_box.max, + stroke.start_point(), + ]); + for command in stroke.into_commands() { + if let Some(b) = command.bounding_box() { + self.bounding_box = Box2D::from_points([ + self.bounding_box.min, + self.bounding_box.max, + b.min, + b.max, + ]); + } } } - - fn cubic_bezier(&mut self, cbs: CubicBezierSegment) { - self.bounding_box = self.bounding_box.union(&cbs.bounding_box()); - } - - fn quadratic_bezier(&mut self, qbs: QuadraticBezierSegment) { - self.bounding_box = self.bounding_box.union(&qbs.bounding_box()); - } } diff --git a/star/src/turtle/svg_preview.rs b/star/src/turtle/svg_preview.rs index 021a10d..a55b4a8 100644 --- a/star/src/turtle/svg_preview.rs +++ b/star/src/turtle/svg_preview.rs @@ -1,8 +1,11 @@ use std::fmt::Write; -use lyon_geom::{Box2D, CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc}; +use lyon_geom::{Box2D, Point}; -use super::Turtle; +use super::{ + Turtle, + elements::{DrawCommand, Stroke}, +}; /// Builds an SVG preview of the toolpath: /// - Red solid: tool-on moves (line_to, arc, cubic_bezier, quadratic_bezier) @@ -16,7 +19,6 @@ pub struct SvgPreviewTurtle { rapid_paths: String, bounding_box: Option>, current_pos: Point, - current_tool_on_d: String, } impl SvgPreviewTurtle { @@ -33,20 +35,7 @@ impl SvgPreviewTurtle { self.add_box(Box2D { min: p, max: p }); } - fn flush_tool_on(&mut self) { - if !self.current_tool_on_d.is_empty() { - writeln!( - self.tool_on_paths, - "", - self.current_tool_on_d - ) - .unwrap(); - self.current_tool_on_d.clear(); - } - } - - pub fn into_preview(mut self) -> String { - self.flush_tool_on(); + pub fn into_preview(self) -> String { const PADDING: f64 = 2.0; match self.bounding_box { None => { @@ -70,73 +59,84 @@ impl SvgPreviewTurtle { impl Turtle for SvgPreviewTurtle { fn begin(&mut self) {} - fn end(&mut self) { - self.flush_tool_on(); + fn end(&mut self) {} + + #[cfg(feature = "image")] + fn image(&mut self, _image: super::elements::RasterImage) { + // TODO } - fn comment(&mut self, _: String) {} + fn fill_polygon(&mut self, _polygon: super::elements::FillPolygon) { + // TODO + } - fn move_to(&mut self, to: Point) { - self.flush_tool_on(); - if to != self.current_pos { + fn stroke(&mut self, stroke: Stroke) { + let start = stroke.start_point(); + if start != self.current_pos { writeln!( self.rapid_paths, "", - self.current_pos.x, self.current_pos.y, to.x, to.y, + self.current_pos.x, self.current_pos.y, start.x, start.y, ) .unwrap(); - self.add_point(to); + self.add_point(self.current_pos); } - self.current_pos = to; - write!(self.current_tool_on_d, "M {},{} ", to.x, to.y).unwrap(); - } - - fn line_to(&mut self, to: Point) { - write!(self.current_tool_on_d, "L {},{} ", to.x, to.y).unwrap(); - self.add_point(to); - self.current_pos = to; - } + self.add_point(start); + self.current_pos = start; - fn arc(&mut self, svg_arc: SvgArc) { - if svg_arc.is_straight_line() { - self.line_to(svg_arc.to); - return; + let mut path = String::new(); + write!(path, " { + write!(path, "L {},{} ", to.x, to.y).unwrap(); + self.add_point(to); + self.current_pos = to; + } + DrawCommand::Arc(svg_arc) => { + write!( + path, + "A {},{} {} {} {} {},{} ", + svg_arc.radii.x, + svg_arc.radii.y, + svg_arc.x_rotation.to_degrees(), + if svg_arc.flags.large_arc { 1 } else { 0 }, + if svg_arc.flags.sweep { 1 } else { 0 }, + svg_arc.to.x, + svg_arc.to.y, + ) + .unwrap(); + self.add_box(svg_arc.to_arc().bounding_box()); + self.current_pos = svg_arc.to; + } + DrawCommand::CubicBezier(cbs) => { + write!( + path, + "C {},{} {},{} {},{} ", + cbs.ctrl1.x, cbs.ctrl1.y, cbs.ctrl2.x, cbs.ctrl2.y, cbs.to.x, cbs.to.y, + ) + .unwrap(); + self.add_box(cbs.bounding_box()); + self.current_pos = cbs.to; + } + DrawCommand::QuadraticBezier(qbs) => { + write!( + path, + "Q {},{} {},{} ", + qbs.ctrl.x, qbs.ctrl.y, qbs.to.x, qbs.to.y, + ) + .unwrap(); + self.add_box(qbs.bounding_box()); + self.current_pos = qbs.to; + } + DrawCommand::Comment(_) => {} + } } - write!( - self.current_tool_on_d, - "A {},{} {} {} {} {},{} ", - svg_arc.radii.x, - svg_arc.radii.y, - svg_arc.x_rotation.to_degrees(), - if svg_arc.flags.large_arc { 1 } else { 0 }, - if svg_arc.flags.sweep { 1 } else { 0 }, - svg_arc.to.x, - svg_arc.to.y, - ) - .unwrap(); - self.add_box(svg_arc.to_arc().bounding_box()); - self.current_pos = svg_arc.to; - } - - fn cubic_bezier(&mut self, cbs: CubicBezierSegment) { - write!( - self.current_tool_on_d, - "C {},{} {},{} {},{} ", - cbs.ctrl1.x, cbs.ctrl1.y, cbs.ctrl2.x, cbs.ctrl2.y, cbs.to.x, cbs.to.y, - ) - .unwrap(); - self.add_box(cbs.bounding_box()); - self.current_pos = cbs.to; - } + writeln!( + path, + "\" stroke=\"red\" fill=\"none\" stroke-width=\"1\" vector-effect=\"non-scaling-stroke\"/>" + ).unwrap(); - fn quadratic_bezier(&mut self, qbs: QuadraticBezierSegment) { - write!( - self.current_tool_on_d, - "Q {},{} {},{} ", - qbs.ctrl.x, qbs.ctrl.y, qbs.to.x, qbs.to.y, - ) - .unwrap(); - self.add_box(qbs.bounding_box()); - self.current_pos = qbs.to; + self.tool_on_paths += &path; } }