From 3194aee4766308b684b91574e72b2b3c6bfca5ff Mon Sep 17 00:00:00 2001 From: Sameer Puri Date: Thu, 28 May 2026 13:50:16 -0700 Subject: [PATCH 1/6] Do not mark unstroked paths --- star/src/lower/visit.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/star/src/lower/visit.rs b/star/src/lower/visit.rs index 4b74776..3d6ce86 100644 --- a/star/src/lower/visit.rs +++ b/star/src/lower/visit.rs @@ -385,10 +385,10 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { }) .unwrap_or_default(); - // TODO: changing the default here to false would be breaking, but is technically correct from an SVG perspective. + // Default to false which is spec compliant. let has_stroke_attr = calculate_presentation_attr(node, "stroke", &self.css_rules) .map(|v| v != "none") - .unwrap_or(true); + .unwrap_or(false); match node.tag_name().name() { PATH_TAG_NAME => { From 8d00167dcc0e93c5ee9c4751602e75bced405c57 Mon Sep 17 00:00:00 2001 From: Sameer Puri Date: Fri, 21 Aug 2026 22:02:36 -0700 Subject: [PATCH 2/6] Implement clipping and patterns --- Cargo.lock | 5 +- Cargo.toml | 3 +- g_code/src/turtle.rs | 25 +- g_code/tests/smooth_curves.svg | 2 +- g_code/tests/transform_origin.svg | 2 +- g_code/tests/transform_origin_equivalent.svg | 2 +- g_code/tests/use_defs.svg | 2 +- g_code/tests/use_symbol.svg | 2 +- g_code/tests/use_xlink_href.svg | 2 +- star/Cargo.toml | 1 + star/src/lower/mod.rs | 22 +- star/src/lower/visit.rs | 443 +++++-- star/src/turtle/dpi.rs | 17 +- star/src/turtle/elements/clip.rs | 331 +++++ star/src/turtle/elements/fill.rs | 131 +- star/src/turtle/elements/image_ops.rs | 2 - star/src/turtle/elements/intersect.rs | 1189 ++++++++++++++++++ star/src/turtle/elements/mod.rs | 126 +- star/src/turtle/mod.rs | 248 +++- star/src/turtle/preprocess.rs | 33 +- star/src/turtle/svg_preview.rs | 5 +- 21 files changed, 2253 insertions(+), 340 deletions(-) create mode 100644 star/src/turtle/elements/clip.rs create mode 100644 star/src/turtle/elements/intersect.rs diff --git a/Cargo.lock b/Cargo.lock index 225ca89..8142d03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1328,9 +1328,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "strsim" @@ -1413,6 +1413,7 @@ dependencies = [ "rustc-hash", "serde", "serde_json", + "smallvec", "svgtypes", "uom", ] diff --git a/Cargo.toml b/Cargo.toml index 800f3b6..7b90725 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,5 +18,6 @@ paste = "1" roxmltree = "0.21" serde = { version = "1", default-features = false } serde_json = "1" +smallvec = "1.16" svgtypes = "0.16" -uom = "0.38" +uom = "0.38" \ No newline at end of file diff --git a/g_code/src/turtle.rs b/g_code/src/turtle.rs index 5c38437..de838ef 100644 --- a/g_code/src/turtle.rs +++ b/g_code/src/turtle.rs @@ -1,7 +1,7 @@ use std::{borrow::Cow, fmt::Debug}; use ::g_code::{command, emit::Token}; -use lyon_geom::{Point, SvgArc}; +use lyon_geom::{LineSegment, Point, SvgArc}; use rust_decimal::{Decimal, prelude::*}; use svg2star::turtle::{ Turtle, @@ -140,18 +140,15 @@ impl<'input> Turtle for GCodeTurtle<'input> { fn stroke(&mut self, stroke: Stroke) { let start = stroke.start_point(); - let mut commands = stroke.into_commands().peekable(); 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), - }); - } + if let Some(comment) = stroke.comment() { + self.program.push(Token::Comment { + is_inline: false, + inner: Cow::Owned(comment.to_owned()), + }); } self.program.append( @@ -163,9 +160,9 @@ impl<'input> Turtle for GCodeTurtle<'input> { ); self.tool_on(); - for command in commands { + for command in stroke.into_commands() { match command { - DrawCommand::LineTo { from: _, to } => { + DrawCommand::LineTo(LineSegment { from: _, to }) => { self.program.append( &mut command!(LinearInterpolation { X: self.round(to.x), @@ -243,12 +240,6 @@ impl<'input> Turtle for GCodeTurtle<'input> { .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), - }); - } } } } diff --git a/g_code/tests/smooth_curves.svg b/g_code/tests/smooth_curves.svg index 16131f7..d0edfa0 100644 --- a/g_code/tests/smooth_curves.svg +++ b/g_code/tests/smooth_curves.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/g_code/tests/transform_origin.svg b/g_code/tests/transform_origin.svg index 2a81764..2964a40 100644 --- a/g_code/tests/transform_origin.svg +++ b/g_code/tests/transform_origin.svg @@ -1,5 +1,5 @@ - + diff --git a/g_code/tests/transform_origin_equivalent.svg b/g_code/tests/transform_origin_equivalent.svg index 1c9a984..7813304 100644 --- a/g_code/tests/transform_origin_equivalent.svg +++ b/g_code/tests/transform_origin_equivalent.svg @@ -1,5 +1,5 @@ - + diff --git a/g_code/tests/use_defs.svg b/g_code/tests/use_defs.svg index 7c1929f..ca6308d 100644 --- a/g_code/tests/use_defs.svg +++ b/g_code/tests/use_defs.svg @@ -1,6 +1,6 @@ - + diff --git a/g_code/tests/use_symbol.svg b/g_code/tests/use_symbol.svg index 2de460c..544830d 100644 --- a/g_code/tests/use_symbol.svg +++ b/g_code/tests/use_symbol.svg @@ -1,7 +1,7 @@ - + diff --git a/g_code/tests/use_xlink_href.svg b/g_code/tests/use_xlink_href.svg index b6d59f5..0a755a6 100644 --- a/g_code/tests/use_xlink_href.svg +++ b/g_code/tests/use_xlink_href.svg @@ -1,6 +1,6 @@ - + diff --git a/star/Cargo.toml b/star/Cargo.toml index 3b4bb4b..db2c835 100644 --- a/star/Cargo.toml +++ b/star/Cargo.toml @@ -21,6 +21,7 @@ log.workspace = true uom.workspace = true roxmltree.workspace = true svgtypes.workspace = true +smallvec.workspace = true [dependencies.serde] workspace = true diff --git a/star/src/lower/mod.rs b/star/src/lower/mod.rs index f9c337f..761fdce 100644 --- a/star/src/lower/mod.rs +++ b/star/src/lower/mod.rs @@ -79,11 +79,18 @@ pub struct ConversionOptions { pub dimensions: [Option; 2], } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TemplatedExpansion { + Use, + Pattern, + Marker, +} + /// Maps SVG [`Node`]s and their attributes into operations on a [`Terrarium`] #[derive(Debug)] 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. + /// Whether to flip the Y axis to convert from SVG (Y-down) to the output coordinate system (Y-up?). coordinate_system: CoordinateSystem, name_stack: Vec, /// Used to convert percentage values @@ -94,6 +101,8 @@ struct ConversionVisitor<'a, 'input, T: Turtle> { selector_filter: Option, /// Resolved CSS class rules css_rules: HashMap<&'input str, HashMap<&'input str, &'input str>>, + /// Stack of active template expansion scopes. + expansion_stack: Vec, } impl<'a, 'input, T: Turtle> ConversionVisitor<'a, 'input, T> { @@ -115,9 +124,11 @@ impl<'a, 'input, T: Turtle> ConversionVisitor<'a, 'input, T> { } fn should_draw_node(&self, node: Node) -> bool { - self.selector_filter - .as_ref() - .is_none_or(|s| s.matches(node)) + !self.expansion_stack.is_empty() + || self + .selector_filter + .as_ref() + .is_none_or(|s| s.matches(node)) } fn begin(&mut self) { @@ -169,6 +180,7 @@ pub fn svg_to_turtle( viewport_dim_stack: vec![], selector_filter: selector_filter.clone(), css_rules: visit::parse_css(doc), + expansion_stack: vec![], }; visitor.begin(); @@ -213,6 +225,7 @@ pub fn svg_to_turtle( viewport_dim_stack: vec![], selector_filter: selector_filter.clone(), css_rules: visit::parse_css(doc), + expansion_stack: vec![], }; conversion_visitor @@ -259,6 +272,7 @@ fn svg_to_optimized_strokes( viewport_dim_stack: vec![], selector_filter, css_rules: visit::parse_css(doc), + expansion_stack: vec![], }; collect_visitor.terrarium.push_transform(origin_transform); collect_visitor.begin(); diff --git a/star/src/lower/visit.rs b/star/src/lower/visit.rs index 3d6ce86..32770ce 100644 --- a/star/src/lower/visit.rs +++ b/star/src/lower/visit.rs @@ -1,16 +1,17 @@ -use std::{collections::HashMap, str::FromStr}; +use std::{collections::HashMap, iter, str::FromStr}; use euclid::default::Transform2D; use log::{debug, warn}; use lyon_geom::{Box2D, Point}; use roxmltree::{Document, Node}; +use smallvec::{SmallVec, smallvec}; use svgtypes::{ AspectRatio, LengthListParser, PathParser, PathSegment, PointsParser, TransformListParser, ViewBox, }; use super::{ - ConversionVisitor, + ConversionVisitor, TemplatedExpansion, transform::{get_viewport_transform, svg_transform_into_euclid_transform}, units::DimensionHint, }; @@ -33,14 +34,29 @@ const DEFS_TAG_NAME: &str = "defs"; const USE_TAG_NAME: &str = "use"; const MARKER_TAG_NAME: &str = "marker"; const SYMBOL_TAG_NAME: &str = "symbol"; +const PATTERN_TAG_NAME: &str = "pattern"; +const TEXT_TAG_NAME: &str = "text"; +const TEXT_PATH_TAG_NAME: &str = "textPath"; +const VIEW_TAG_NAME: &str = "view"; + +pub trait XmlVisitor<'a, 'input> { + fn visit_enter(&mut self, node: Node<'a, 'input>); + fn visit_exit(&mut self, node: Node<'a, 'input>); +} -pub trait XmlVisitor { - fn visit_enter(&mut self, node: Node); - fn visit_exit(&mut self, node: Node); +impl TemplatedExpansion { + pub fn from_tag_name(tag_name: &str) -> Option { + match tag_name { + USE_TAG_NAME => Some(Self::Use), + MARKER_TAG_NAME => Some(Self::Marker), + PATTERN_TAG_NAME => Some(Self::Pattern), + _ => None, + } + } } /// Returns the effective value of the given presentation attribute (i.e. stroke), -/// adhering to precedence rules. +/// adhering to precedence rules and checking inheritance. /// /// 1. Inline style attribute /// 2. CSS class rules @@ -50,24 +66,44 @@ fn calculate_presentation_attr<'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; + for current in iter::successors(Some(node), |n| n.parent()) { + if !current.is_element() { + continue; } - } - 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; + if let Some(val) = get_node_presentation_attr(current, name, css_rules) { + return Some(val); + } + if matches!( + current.tag_name().name(), + SVG_TAG_NAME | DEFS_TAG_NAME | SYMBOL_TAG_NAME | PATTERN_TAG_NAME + ) { + break; } } - node.attribute(name).map(|v| v.trim()) + None +} + +/// Helper for [calculate_presentation_attr] that only checks the current node. +fn get_node_presentation_attr<'input>( + node: Node<'input, 'input>, + name: &'input str, + css_rules: &HashMap<&'input str, HashMap<&'input str, &'input str>>, +) -> Option<&'input str> { + node.attribute("style") + .and_then(|style| { + style.split(';').find_map(|decl| { + let (k, v) = decl.split_once(':')?; + (k.trim() == name).then(|| v.trim()) + }) + }) + .or_else(|| { + node.attribute("class").and_then(|classes| { + classes + .split_whitespace() + .find_map(|cls| css_rules.get(cls)?.get(name).copied()) + }) + }) + .or_else(|| node.attribute(name).map(|attr| attr.trim())) } /// Barebones CSS class rules parser. @@ -117,7 +153,7 @@ pub fn parse_css<'a>(doc: &'a Document) -> HashMap<&'a str, HashMap<&'a str, &'a } /// Used to skip over SVG elements that are explicitly marked as do not render -fn should_render_node(node: Node, render_symbol: bool) -> bool { +fn should_render_node(node: Node, expanding_template: Option) -> bool { node.is_element() && !node .attribute("style") @@ -125,16 +161,23 @@ fn should_render_node(node: Node, render_symbol: bool) -> bool { 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 - && !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)) + // Template elements are not directly rendered, unless referenced + && (expanding_template.is_some() + || !matches!( + node.tag_name().name(), + DEFS_TAG_NAME | MARKER_TAG_NAME | PATTERN_TAG_NAME | SYMBOL_TAG_NAME | CLIP_PATH_TAG_NAME + )) +} + +fn find_node_by_id<'a, 'input>(doc: &'a Document<'input>, id: &str) -> Option> { + doc.root() + .descendants() + .find(|n| n.attribute("id").map(|nid| nid.trim()) == Some(id)) } /// Resolve `href` or `xlink:href` on a `` element to a document node. /// Only fragment references (`#id`) within the same document are supported. -fn resolve_use_href<'a, 'input: 'a>( +fn resolve_href<'a, 'input: 'a>( doc: &'a Document<'input>, node: Node<'a, 'input>, ) -> Option> { @@ -142,35 +185,48 @@ fn resolve_use_href<'a, 'input: 'a>( .attribute("href") .or_else(|| node.attribute(("http://www.w3.org/1999/xlink", "href")))?; let id = href.strip_prefix('#')?; - doc.root() - .descendants() - .find(|n| n.attribute("id") == Some(id)) + find_node_by_id(doc, id) } -pub fn depth_first_visit(doc: &Document, visitor: &mut impl XmlVisitor) { - 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_node(doc, referenced, visitor, true); - } else { - node.children() - .for_each(|child| visit_node(doc, child, visitor, false)); - } - visitor.visit_exit(node); +/// When referenced, a pattern is typically `fill="url(#id)"`. +fn get_pattern_id(attr: &str) -> Option<&str> { + let inner = attr.trim().strip_prefix("url(")?.strip_suffix(")")?.trim(); + inner.trim_matches(['\'', '"']).strip_prefix('#') +} + +fn visit_node<'a, 'input, V: XmlVisitor<'a, 'input>>( + doc: &'a Document<'input>, + node: Node<'a, 'input>, + visitor: &mut V, + expanding_template: Option, +) { + if !should_render_node(node, expanding_template) { + return; } + visitor.visit_enter(node); + if node.tag_name().name() == USE_TAG_NAME + && let Some(referenced) = resolve_href(doc, node) + { + // Expand the target + visit_node(doc, referenced, visitor, Some(TemplatedExpansion::Use)); + } else { + node.children() + .for_each(|child| visit_node(doc, child, visitor, expanding_template)); + } + visitor.visit_exit(node); +} +pub fn depth_first_visit<'a, 'input, V: XmlVisitor<'a, 'input>>( + doc: &'a Document<'input>, + visitor: &mut V, +) { doc.root() .children() - .for_each(|child| visit_node(doc, child, visitor, false)); + .for_each(|child| visit_node(doc, child, visitor, None)); } -impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { - fn visit_enter(&mut self, node: Node) { +impl<'a, 'input, T: Turtle> XmlVisitor<'a, 'input> for ConversionVisitor<'a, 'input, T> { + fn visit_enter(&mut self, node: Node<'a, 'input>) { use PathSegment::*; let mut view_box_opt = None; @@ -178,7 +234,16 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { let mut viewport_size_opt = [1., 1.]; if node.tag_name().name() == CLIP_PATH_TAG_NAME { - warn!("Clip paths are not supported: {:?}", node); + warn!("Clip paths are not supported: {node:?}"); + } + if node.tag_name().name() == TEXT_TAG_NAME { + warn!("Text is not supported: {node:?}"); + } + if node.tag_name().name() == TEXT_PATH_TAG_NAME { + warn!("Text paths are not supported: {node:?}"); + } + if node.tag_name().name() == VIEW_TAG_NAME { + warn!("SVG views are not supported: {node:?}"); } let mut flattened_transform = if let Some(transform) = node.attribute("transform") { @@ -331,8 +396,8 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { (_, _, Some(vb)) => [vb.w, vb.h], _ => *self.viewport_dim_stack.last().unwrap_or(&[1., 1.]), }; - self.viewport_dim_stack.push(viewport_size); if let Some(view_box) = view_box { + self.viewport_dim_stack.push(viewport_size); let viewport_transform = get_viewport_transform( view_box, preserve_aspect_ratio, @@ -353,7 +418,10 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { self.name_stack .push(node_name(&node, &self.config.extra_attribute_name)); - if node.has_tag_name(SVG_TAG_NAME) || node.has_tag_name(SYMBOL_TAG_NAME) { + if matches!( + (node.tag_name().name(), node.has_attribute("viewBox")), + (SVG_TAG_NAME, _) | (SYMBOL_TAG_NAME, true) + ) { let viewport_box = if let Some(vb) = view_box_opt { Box2D::new(Point::new(vb.x, vb.y), Point::new(vb.x + vb.w, vb.y + vb.h)) } else { @@ -364,17 +432,25 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { Point::new(px + viewport_size_opt[0], py + viewport_size_opt[1]), ) }; - self.terrarium.push_viewport_bounds(viewport_box); + if self.is_overflow_hidden(node) { + self.terrarium.push_viewport_clip(viewport_box); + } + } + + let should_draw = self.should_draw_node(node); + + if let Some(expansion) = TemplatedExpansion::from_tag_name(node.tag_name().name()) { + self.expansion_stack.push(expansion); } - if !self.should_draw_node(node) { + if !should_draw { return; } - let has_fill_attr = calculate_presentation_attr(node, "fill", &self.css_rules) - .map(|v| v != "none") - .unwrap_or(true); + let fill_attr = calculate_presentation_attr(node, "fill", &self.css_rules); + // SVGs fill by default + let has_fill_attr = fill_attr.map(|v| v != "none").unwrap_or(true); let fill_rule = calculate_presentation_attr(node, "fill-rule", &self.css_rules) .map(|v| { if v == "evenodd" { @@ -390,24 +466,15 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { .map(|v| v != "none") .unwrap_or(false); - match node.tag_name().name() { + let segments: SmallVec<[PathSegment; 5]> = 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); - } - } + PathParser::from(d) + .map(|segment| segment.expect("could not parse path segment")) + .collect() } name @ (POLYLINE_TAG_NAME | POLYGON_TAG_NAME) => { let Some(points) = node.attribute("points") else { @@ -416,24 +483,15 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { }; let is_polygon = name == POLYGON_TAG_NAME; let mut pp = PointsParser::from(points).peekable(); - let segments = pp + let start = pp .peek() .copied() .map(|(x, y)| MoveTo { abs: true, x, y }) - .into_iter() + .into_iter(); + start .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); - } - } + .chain(is_polygon.then_some(ClosePath { abs: true })) + .collect() } RECT_TAG_NAME => { let x = self.length_attr_to_user_units(&node, "x").unwrap_or(0.); @@ -448,7 +506,7 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { warn!("Invalid rectangle node: {node:?}"); return; }; - let segments = [ + [ MoveTo { abs: true, x: x + rx, @@ -513,17 +571,8 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { 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); - } - } + .filter(move |p| has_radius || !matches!(p, EllipticalArc { .. })) + .collect() } CIRCLE_TAG_NAME | ELLIPSE_TAG_NAME => { let cx = self.length_attr_to_user_units(&node, "cx").unwrap_or(0.); @@ -535,7 +584,7 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { warn!("Invalid {} node: {node:?}", node.tag_name().name()); return; } - let segments = [ + smallvec![ MoveTo { abs: true, x: cx + rx, @@ -582,16 +631,7 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { 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)) = ( @@ -603,21 +643,18 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { 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, - }, - ]); - } + smallvec![ + MoveTo { + abs: true, + x: x1, + y: y1, + }, + LineTo { + abs: true, + x: x2, + y: y2, + }, + ] } #[cfg(feature = "image")] "image" => { @@ -689,21 +726,175 @@ impl<'a, 'input, T: Turtle> XmlVisitor for ConversionVisitor<'a, 'input, T> { self.comment(); self.terrarium .image(image, image_to_user, preserve_aspect_ratio); + + return; } - // No-op tags - SVG_TAG_NAME | GROUP_TAG_NAME | USE_TAG_NAME | SYMBOL_TAG_NAME => {} other => { debug!("Unhandled node: {other}"); + return; + } + }; + + if has_stroke_attr { + self.comment(); + self.terrarium.apply_path(segments.iter().copied()); + } + if has_fill_attr { + self.comment(); + // TODO: no idea what a vectorized gradient fill would mean practically speaking + if let Some(pattern_id) = fill_attr + .and_then(get_pattern_id) + .and_then(|id| find_node_by_id(node.document(), id)) + { + self.visit_pattern(node, pattern_id, segments, fill_rule); + } else { + self.terrarium.apply_polygon(segments, fill_rule); } } } - fn visit_exit(&mut self, node: Node) { + fn visit_exit(&mut self, node: Node<'a, 'input>) { self.terrarium.pop_transform(); self.name_stack.pop(); - if matches!(node.tag_name().name(), SVG_TAG_NAME | SYMBOL_TAG_NAME) { + if let Some(expected) = TemplatedExpansion::from_tag_name(node.tag_name().name()) { + let popped = self.expansion_stack.pop(); + debug_assert_eq!(popped, Some(expected)); + } + if matches!( + (node.tag_name().name(), node.has_attribute("viewBox")), + (SVG_TAG_NAME, _) | (SYMBOL_TAG_NAME, true) + ) { + if self.is_overflow_hidden(node) { + self.terrarium.pop_clip_layer(); + } self.viewport_dim_stack.pop(); self.terrarium.pop_bounds(); } } } + +impl<'a, 'input, T: Turtle> ConversionVisitor<'a, 'input, T> { + fn is_overflow_hidden(&self, node: Node<'a, 'input>) -> bool { + match calculate_presentation_attr(node, "overflow", &self.css_rules) { + Some("hidden" | "scroll") => true, + Some(_) => false, + None => { + let is_root = + node.parent_element().is_none() && node.tag_name().name() == SVG_TAG_NAME; + !is_root + } + } + } + /// Helper function because the logic for patterns is quite complex + fn visit_pattern( + &mut self, + container: Node<'a, 'input>, + pattern_node: Node<'a, 'input>, + container_segments: impl IntoIterator, + fill_rule: FillRule, + ) { + let doc = container.document(); + let container_polygons = self + .terrarium + .to_fill_polygons(container_segments, fill_rule); + let [first, ..] = container_polygons.as_slice() else { + return; + }; + let container_bbox = + container_polygons + .iter() + .skip(1) + .fold(first.outer.bounding_box(), |bbox, fp| { + Box2D::from_points([ + bbox.min, + bbox.max, + fp.outer.bounding_box().min, + fp.outer.bounding_box().max, + ]) + }); + + // TODO: does this handle xlink:href on a pattern correctly? + // seems like we need some kind of recursion here + let href_pattern_node = resolve_href(doc, pattern_node); + + let [Some(width), Some(height), x, y] = ["width", "height", "x", "y"].map(|attr| { + self.length_attr_to_user_units(&pattern_node, attr) + .or_else(|| { + href_pattern_node + .and_then(|href_node| self.length_attr_to_user_units(&href_node, attr)) + }) + }) else { + // width or height of 0 means do not render + return; + }; + if width == 0. || height == 0. { + return; + } + + let pattern_origin_transform = Transform2D::translation(x.unwrap_or(0.), y.unwrap_or(0.)); + + let pattern_transform = pattern_node + .attribute("patternTransform") + .or_else(|| { + href_pattern_node + .as_ref() + .and_then(|n| n.attribute("patternTransform")) + }) + .map(|transform| { + TransformListParser::from(transform) + .map(|token| token.expect("could not parse patternTransform")) + .map(svg_transform_into_euclid_transform) + .fold(Transform2D::identity(), |acc, t| t.then(&acc)) + }) + .unwrap_or_else(Transform2D::identity); + + let target_pattern_node = if pattern_node.children().any(|c| c.is_element()) { + // TODO: unclear what it would mean if there is an href element but the pattern also has its own children + pattern_node + } else if let Some(ref href) = href_pattern_node { + *href + } else { + return; + }; + + let inv_transform = (pattern_origin_transform.then(&pattern_transform)) + .inverse() + .expect("pattern transform is invertible"); + + let (min_col, max_col, min_row, max_row) = { + let inverse_container_bbox = inv_transform.outer_transformed_box(&container_bbox); + ( + (inverse_container_bbox.min.x / width).floor() as i32 - 1, + (inverse_container_bbox.max.x / width).ceil() as i32 + 1, + (inverse_container_bbox.min.y / height).floor() as i32 - 1, + (inverse_container_bbox.max.y / height).ceil() as i32 + 1, + ) + }; + + self.terrarium.push_clip_layer(container_polygons); + + // Apply the pattern by repeated translation. Clipping crops the border patterns to the container. + for col in min_col..=max_col { + for row in min_row..=max_row { + // TODO: use a fast container checks to verify that some point in the pattern bbox is even + // in the container. This is probably wasteful for a polygon with holes. + let offset_transform = + Transform2D::translation(col as f64 * width, row as f64 * height); + let combined = pattern_origin_transform + .then(&offset_transform) + .then(&pattern_transform); + + self.terrarium.push_transform(combined); + visit_node( + doc, + target_pattern_node, + self, + Some(TemplatedExpansion::Pattern), + ); + self.terrarium.pop_transform(); + } + } + + self.terrarium.pop_clip_layer(); + } +} diff --git a/star/src/turtle/dpi.rs b/star/src/turtle/dpi.rs index f124e13..3a1c9d5 100644 --- a/star/src/turtle/dpi.rs +++ b/star/src/turtle/dpi.rs @@ -1,6 +1,8 @@ use std::fmt::Debug; -use lyon_geom::{CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc, Vector, point, vector}; +use lyon_geom::{ + CubicBezierSegment, LineSegment, Point, QuadraticBezierSegment, SvgArc, Vector, point, vector, +}; use uom::si::{ f64::Length, length::{inch, millimeter}, @@ -45,15 +47,18 @@ impl DpiConvertingTurtle { } fn stroke_to_mm(&self, stroke: Stroke) -> Stroke { + let comment = stroke.comment().map(ToString::to_string); 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::LineTo(LineSegment { from, to }) => { + DrawCommand::LineTo(LineSegment { + from: self.point_to_mm(from), + to: self.point_to_mm(to), + }) + } DrawCommand::Arc(SvgArc { from, to, @@ -85,9 +90,9 @@ impl DpiConvertingTurtle { to: self.point_to_mm(to), }) } - DrawCommand::Comment(s) => DrawCommand::Comment(s), }) .collect(), + comment, ) } } diff --git a/star/src/turtle/elements/clip.rs b/star/src/turtle/elements/clip.rs new file mode 100644 index 0000000..d01b918 --- /dev/null +++ b/star/src/turtle/elements/clip.rs @@ -0,0 +1,331 @@ +use std::iter; + +use lyon_geom::{LineSegment, Point}; + +use super::intersect::{Intersections, PARAM_EPSILON}; +use crate::turtle::elements::{DrawCommand, FillPolygon, Stroke}; + +#[derive(Debug, Clone)] +struct ActiveClip { + clip_polygons: Vec, + boundary_commands: Vec, +} + +/// Stack of active polygon clip boundaries. +#[derive(Debug, Clone, Default)] +pub struct ClipStack { + stack: Vec, +} + +impl ClipStack { + pub fn push_clip(&mut self, clip_polygons: Vec) { + self.stack.push(ActiveClip { + boundary_commands: clip_polygons + .iter() + .flat_map(|polygon| { + iter::once(&polygon.outer) + .chain(&polygon.holes) + .flat_map(|stroke| { + stroke.commands().copied().chain( + Some(DrawCommand::LineTo(LineSegment { + from: stroke.end_point(), + to: stroke.start_point(), + })) + .filter(|_| !stroke.is_closed()), + ) + }) + }) + .collect(), + clip_polygons, + }); + } + + pub fn pop_clip(&mut self) -> Option> { + self.stack.pop().map(|c| c.clip_polygons) + } + + /// Returns whether there is any clip. + pub fn is_active(&self) -> bool { + !self.stack.is_empty() + } + + /// Clips the commands in `strokes` against all active clip boundaries. + pub fn clip_strokes( + &self, + strokes: impl IntoIterator, + ) -> impl Iterator { + let mut current: Vec = strokes.into_iter().collect(); + for active in &self.stack { + let mut next = Vec::new(); + for stroke in current { + let mut clipped_cmds = Vec::new(); + for cmd in stroke.into_commands() { + self.clip_draw_command(active, cmd, &mut clipped_cmds); + } + next.extend(group_commands_into_strokes(clipped_cmds)); + } + current = next; + } + current.into_iter() + } + + /// Splits `cmd` into clipped segments on the given active layer. + fn clip_draw_command( + &self, + active: &ActiveClip, + cmd: DrawCommand, + cmds: &mut Vec, + ) { + // Find all the intersections of `cmd` with the clip. + // This creates ranges that may or may not be in a clip polygon. + let mut ts: Vec = vec![0.0, 1.0]; + for clip_cmd in &active.boundary_commands { + for (cmd_t, _boundary_t) in cmd.intersections(clip_cmd) { + ts.push(cmd_t); + } + } + ts.sort_by(|a, b| a.total_cmp(b)); + ts.dedup_by(|a, b| (*a - *b).abs() < PARAM_EPSILON); + + // Draw ranges that are active within some clipping polygon + let is_inside = |pt: Point| active.clip_polygons.iter().any(|p| p.contains_point(pt)); + for (&t_a, &t_b) in ts.iter().zip(ts.iter().skip(1)) { + let t_mid = (t_a + t_b) / 2.0; + if is_inside(cmd.sample(t_mid)) { + cmds.push(cmd.split_range(t_a..t_b)); + } + } + } +} + +/// Groups a sequence of clipped drawing commands into `Stroke` objects, +/// splitting into a new `Stroke` whenever consecutive commands are not contiguous. +fn group_commands_into_strokes(cmds: Vec) -> Vec { + let mut strokes = Vec::new(); + let mut current_cmds = Vec::new(); + let mut current_start = None; + let mut prev_end: Option> = None; + + for cmd in cmds { + let cmd_start = cmd.start_point(); + let cmd_end = cmd.end_point(); + + if let (Some(prev), Some(start)) = (prev_end, cmd_start) { + // If the next command does not start where the previous command ended, + // emit the current stroke and start a new one across the rapid-travel gap. + if (prev - start).square_length() > f64::EPSILON + && let Some(s) = current_start.take() + { + strokes.push(Stroke::new(s, std::mem::take(&mut current_cmds), None)); + } + } + + if current_start.is_none() { + current_start = cmd_start; + } + prev_end = Some(cmd_end); + current_cmds.push(cmd); + } + + if let Some(start) = current_start { + strokes.push(Stroke::new(start, current_cmds, None)); + } + strokes +} + +#[cfg(test)] +mod tests { + use lyon_geom::{ + Angle, ArcFlags, CubicBezierSegment, LineSegment, QuadraticBezierSegment, SvgArc, point, + vector, + }; + + use super::*; + + fn make_rect_clip(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> ClipStack { + let outer = Stroke::new( + point(min_x, min_y), + [ + LineSegment { + from: point(min_x, min_y), + to: point(max_x, min_y), + }, + LineSegment { + from: point(max_x, min_y), + to: point(max_x, max_y), + }, + LineSegment { + from: point(max_x, max_y), + to: point(min_x, max_y), + }, + LineSegment { + from: point(min_x, max_y), + to: point(min_x, min_y), + }, + ] + .map(DrawCommand::LineTo) + .to_vec(), + None, + ); + let poly = FillPolygon { + outer, + holes: vec![], + }; + let mut cs = ClipStack::default(); + cs.push_clip(vec![poly]); + cs + } + + #[test] + fn test_clip_cubic_bezier_preserves_cubic() { + let cs = make_rect_clip(0.0, 0.0, 10.0, 10.0); + let cbs = CubicBezierSegment { + from: point(-5.0, 5.0), + ctrl1: point(-2.0, 8.0), + ctrl2: point(2.0, 8.0), + to: point(5.0, 5.0), + }; + + let mut cmds = Vec::new(); + cs.clip_draw_command( + cs.stack.last().unwrap(), + DrawCommand::CubicBezier(cbs), + &mut cmds, + ); + + assert!(!cmds.is_empty(), "Should output clipped commands"); + assert!( + matches!(cmds[0], DrawCommand::CubicBezier(_)), + "Clipped cubic curve must remain a native DrawCommand::CubicBezier" + ); + } + + #[test] + fn test_clip_quadratic_bezier_preserves_quadratic() { + let cs = make_rect_clip(0.0, 0.0, 10.0, 10.0); + let qbs = QuadraticBezierSegment { + from: point(-5.0, 5.0), + ctrl: point(0.0, 10.0), + to: point(5.0, 5.0), + }; + + let mut cmds = Vec::new(); + cs.clip_draw_command( + cs.stack.last().unwrap(), + DrawCommand::QuadraticBezier(qbs), + &mut cmds, + ); + + assert!(!cmds.is_empty(), "Should output clipped commands"); + assert!( + matches!(cmds[0], DrawCommand::QuadraticBezier(_)), + "Clipped quadratic curve must remain a native DrawCommand::QuadraticBezier" + ); + } + + #[test] + fn test_clip_cubic_bezier_multiple_intersections() { + let cs = make_rect_clip(0.0, 0.0, 10.0, 10.0); + // An S-shaped cubic curve that starts inside, loops outside right, comes back inside, loops outside left, and ends inside. + let cbs = CubicBezierSegment { + from: point(5.0, 1.0), + ctrl1: point(25.0, 3.0), + ctrl2: point(-15.0, 7.0), + to: point(5.0, 9.0), + }; + + let mut cmds = Vec::new(); + cs.clip_draw_command( + cs.stack.last().unwrap(), + DrawCommand::CubicBezier(cbs), + &mut cmds, + ); + + // Curve must yield multiple native CubicBezier sub-segments for the portions inside [0, 10] x [0, 10] + assert!( + cmds.len() >= 2, + "Multiple boundary crossings should produce multiple sub-curves: got {}", + cmds.len() + ); + for cmd in &cmds { + assert!( + matches!(cmd, DrawCommand::CubicBezier(_)), + "All clipped sub-curves must be native DrawCommand::CubicBezier" + ); + } + } + + #[test] + fn test_clip_arc_against_rect() { + let cs = make_rect_clip(0.0, 0.0, 10.0, 10.0); + // Arc starting outside at (-5, 5) and curving inside to (5, 5) + let svg_arc = SvgArc { + from: point(-5.0, 5.0), + to: point(5.0, 5.0), + radii: vector(5.0, 5.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }; + + let mut cmds = Vec::new(); + cs.clip_draw_command( + cs.stack.last().unwrap(), + DrawCommand::Arc(svg_arc), + &mut cmds, + ); + + assert!(!cmds.is_empty(), "Should output clipped arc commands"); + assert!( + matches!(cmds[0], DrawCommand::Arc(_)), + "Clipped arc must remain a native DrawCommand::Arc" + ); + } + + #[test] + fn test_clip_strokes_splits_on_discontinuity() { + let r1 = make_rect_clip(0.0, 0.0, 2.0, 10.0); + let r2 = make_rect_clip(8.0, 0.0, 10.0, 10.0); + let mut cs = ClipStack::default(); + cs.push_clip(vec![ + r1.stack + .into_iter() + .next() + .unwrap() + .clip_polygons + .into_iter() + .next() + .unwrap(), + r2.stack + .into_iter() + .next() + .unwrap() + .clip_polygons + .into_iter() + .next() + .unwrap(), + ]); + + let stroke = Stroke::new( + point(-1.0, 5.0), + vec![DrawCommand::LineTo(LineSegment { + from: point(-1.0, 5.0), + to: point(11.0, 5.0), + })], + None, + ); + + let clipped = cs.clip_strokes(vec![stroke]).collect::>(); + assert_eq!( + clipped.len(), + 2, + "Clipped stroke crossing two disconnected regions must produce 2 distinct strokes" + ); + assert!((clipped[0].start_point().x - 0.0).abs() < 1e-5); + assert!((clipped[0].end_point().x - 2.0).abs() < 1e-5); + assert!((clipped[1].start_point().x - 8.0).abs() < 1e-5); + assert!((clipped[1].end_point().x - 10.0).abs() < 1e-5); + } +} diff --git a/star/src/turtle/elements/fill.rs b/star/src/turtle/elements/fill.rs index 76d0ffc..10fbda6 100644 --- a/star/src/turtle/elements/fill.rs +++ b/star/src/turtle/elements/fill.rs @@ -22,7 +22,7 @@ fn signed_area(stroke: &Stroke) -> f64 { for cmd in stroke.commands() { match cmd { - DrawCommand::LineTo { from, to } => { + DrawCommand::LineTo(LineSegment { from, to }) => { area += cross(*from, *to); } DrawCommand::Arc(svg_arc) => { @@ -44,7 +44,6 @@ fn signed_area(stroke: &Stroke) -> f64 { + 6.0 * cross(cbs.ctrl2, cbs.to)) / 10.0; } - DrawCommand::Comment(_) => {} } } @@ -83,7 +82,7 @@ fn stroke_contains_point(stroke: &Stroke, point: Point) -> bool { let mut crossings = 0u32; for cmd in stroke.commands() { match cmd { - DrawCommand::LineTo { from, to } => { + DrawCommand::LineTo(LineSegment { from, to }) => { if edge_crosses_ray(*from, *to, point) { crossings += 1; } @@ -113,7 +112,6 @@ fn stroke_contains_point(stroke: &Stroke, point: Point) -> bool { prev = to; } } - DrawCommand::Comment(_) => {} } } if !stroke.is_closed() && edge_crosses_ray(stroke.end_point(), stroke.start_point(), point) { @@ -122,6 +120,21 @@ fn stroke_contains_point(stroke: &Stroke, point: Point) -> bool { !crossings.is_multiple_of(2) } +impl FillPolygon { + /// Basic containment check, TODO are there degenerate cases that have to be handled? + pub fn contains_point(&self, point: Point) -> bool { + if !stroke_contains_point(&self.outer, point) { + return false; + } + for hole in &self.holes { + if stroke_contains_point(hole, point) { + return false; + } + } + true + } +} + fn stroke_contains_stroke(outer: &Stroke, inner: &Stroke) -> bool { let outer_bbox = outer.bounding_box(); let inner_bbox = inner.bounding_box(); @@ -132,9 +145,7 @@ fn stroke_contains_stroke(outer: &Stroke, inner: &Stroke) -> bool { return false; } for cmd in inner.commands() { - if let Some(to) = cmd.end_point() - && !stroke_contains_point(outer, to) - { + if !stroke_contains_point(outer, cmd.end_point()) { return false; } } @@ -254,69 +265,72 @@ mod tests { let s0 = Stroke::new( Point::new(0.0, 0.0), vec![ - DrawCommand::LineTo { + DrawCommand::LineTo(LineSegment { from: Point::new(0.0, 0.0), to: Point::new(10.0, 0.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(10.0, 0.0), to: Point::new(10.0, 10.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(10.0, 10.0), to: Point::new(0.0, 10.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(0.0, 10.0), to: Point::new(0.0, 0.0), - }, + }), ], + None, ); // Subpath 1: CCW square (2,2) to (8,8) let s1 = Stroke::new( Point::new(2.0, 2.0), vec![ - DrawCommand::LineTo { + DrawCommand::LineTo(LineSegment { from: Point::new(2.0, 2.0), to: Point::new(8.0, 2.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(8.0, 2.0), to: Point::new(8.0, 8.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(8.0, 8.0), to: Point::new(2.0, 8.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(2.0, 8.0), to: Point::new(2.0, 2.0), - }, + }), ], + None, ); // Subpath 2: CCW square (4,4) to (6,6) let s2 = Stroke::new( Point::new(4.0, 4.0), vec![ - DrawCommand::LineTo { + DrawCommand::LineTo(LineSegment { from: Point::new(4.0, 4.0), to: Point::new(6.0, 4.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(6.0, 4.0), to: Point::new(6.0, 6.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(6.0, 6.0), to: Point::new(4.0, 6.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(4.0, 6.0), to: Point::new(4.0, 4.0), - }, + }), ], + None, ); let polygons = @@ -336,46 +350,48 @@ mod tests { let s0 = Stroke::new( Point::new(0.0, 0.0), vec![ - DrawCommand::LineTo { + DrawCommand::LineTo(LineSegment { from: Point::new(0.0, 0.0), to: Point::new(10.0, 0.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(10.0, 0.0), to: Point::new(10.0, 10.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(10.0, 10.0), to: Point::new(0.0, 10.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(0.0, 10.0), to: Point::new(0.0, 0.0), - }, + }), ], + None, ); // Subpath 1: CCW square from (5,5) to (15,15) - overlapping but not nested let s1 = Stroke::new( Point::new(5.0, 5.0), vec![ - DrawCommand::LineTo { + DrawCommand::LineTo(LineSegment { from: Point::new(5.0, 5.0), to: Point::new(15.0, 5.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(15.0, 5.0), to: Point::new(15.0, 15.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(15.0, 15.0), to: Point::new(5.0, 15.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(5.0, 15.0), to: Point::new(5.0, 5.0), - }, + }), ], + None, ); let polygons = into_fill_polygons(vec![s0.clone(), s1.clone()], FillRule::NonZero); @@ -398,27 +414,28 @@ mod tests { let star = Stroke::new( Point::new(110.0, 45.0), vec![ - DrawCommand::LineTo { + DrawCommand::LineTo(LineSegment { from: Point::new(110.0, 45.0), to: Point::new(162.0, 195.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(162.0, 195.0), to: Point::new(24.0, 100.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(24.0, 100.0), to: Point::new(196.0, 100.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(196.0, 100.0), to: Point::new(58.0, 195.0), - }, - DrawCommand::LineTo { + }), + DrawCommand::LineTo(LineSegment { from: Point::new(58.0, 195.0), to: Point::new(110.0, 45.0), - }, + }), ], + None, ); let evenodd = into_fill_polygons(vec![star.clone()], FillRule::EvenOdd); diff --git a/star/src/turtle/elements/image_ops.rs b/star/src/turtle/elements/image_ops.rs index 6f498e6..2621f71 100644 --- a/star/src/turtle/elements/image_ops.rs +++ b/star/src/turtle/elements/image_ops.rs @@ -72,8 +72,6 @@ pub fn transform_image( // During non-aligned rotation, the corners need to be transparent image = add_alpha_channel(image); - - let scale_x = image_to_final.transform_vector(vector(1.0, 0.0)).length(); let scale_y = image_to_final.transform_vector(vector(0.0, 1.0)).length(); diff --git a/star/src/turtle/elements/intersect.rs b/star/src/turtle/elements/intersect.rs new file mode 100644 index 0000000..d6e51e4 --- /dev/null +++ b/star/src/turtle/elements/intersect.rs @@ -0,0 +1,1189 @@ +use lyon_geom::{ + Angle, Arc, Box2D, CubicBezierSegment, LineSegment, Point, QuadraticBezierSegment, SvgArc, + Transform, Vector, vector, +}; +use smallvec::{SmallVec, smallvec}; + +use crate::turtle::elements::DrawCommand; + +/// Used to exclude intersection endpoints and dedupe intersections. +pub const PARAM_EPSILON: f64 = 1e-6; + +/// Semi-arbitrary floating-point error margin +const ROUNDOFF_EPSILON: f64 = 16.0 * f64::EPSILON; + +/// Tolerance to snap angle offsets near 2*PI back to zero. +const ANGLE_SNAP_EPSILON: Angle = Angle { + radians: ROUNDOFF_EPSILON, +}; + +/// Maximum number of iterations for Newton's method solvers. +const NEWTON_MAX_ITERATIONS: usize = 5; + +/// Convergence tolerance for Newton's method solvers. +const NEWTON_EPSILON: f64 = 1e-12; + +pub trait Intersections { + /// Returns the tuple (t, u) of intersections with respect to `self` and `rhs`. + fn intersections(&self, rhs: &Rhs) -> SmallVec<[(f64, f64); 3]>; +} + +impl Intersections> for LineSegment { + fn intersections(&self, rhs: &LineSegment) -> SmallVec<[(f64, f64); 3]> { + self.intersection_t(rhs).into_iter().collect() + } +} + +impl Intersections> for CubicBezierSegment { + fn intersections(&self, line_seg: &LineSegment) -> SmallVec<[(f64, f64); 3]> { + self.line_segment_intersections_t(line_seg) + .into_iter() + .collect() + } +} + +impl Intersections> for QuadraticBezierSegment { + fn intersections(&self, line_seg: &LineSegment) -> SmallVec<[(f64, f64); 3]> { + self.line_segment_intersections_t(line_seg) + .into_iter() + .collect() + } +} + +impl Intersections> for CubicBezierSegment { + fn intersections(&self, rhs: &CubicBezierSegment) -> SmallVec<[(f64, f64); 3]> { + self.cubic_intersections_t(rhs).into_iter().collect() + } +} + +impl Intersections> for CubicBezierSegment { + fn intersections(&self, rhs: &QuadraticBezierSegment) -> SmallVec<[(f64, f64); 3]> { + self.quadratic_intersections_t(rhs).into_iter().collect() + } +} + +impl Intersections> for QuadraticBezierSegment { + fn intersections(&self, rhs: &CubicBezierSegment) -> SmallVec<[(f64, f64); 3]> { + rhs.intersections(self) + .into_iter() + .map(|(t, u)| (u, t)) + .collect() + } +} + +impl Intersections> for QuadraticBezierSegment { + fn intersections(&self, rhs: &QuadraticBezierSegment) -> SmallVec<[(f64, f64); 3]> { + self.to_cubic().intersections(rhs) + } +} + +impl Intersections> for CubicBezierSegment { + fn intersections(&self, svg_arc: &SvgArc) -> SmallVec<[(f64, f64); 3]> { + svg_arc + .intersections(self) + .into_iter() + .map(|(t, u)| (u, t)) + .collect() + } +} + +impl Intersections> for SvgArc { + fn intersections(&self, qbs: &QuadraticBezierSegment) -> SmallVec<[(f64, f64); 3]> { + self.intersections(&qbs.to_cubic()) + } +} + +impl Intersections> for QuadraticBezierSegment { + fn intersections(&self, svg_arc: &SvgArc) -> SmallVec<[(f64, f64); 3]> { + svg_arc + .intersections(self) + .into_iter() + .map(|(t, u)| (u, t)) + .collect() + } +} + +impl Intersections> for DrawCommand { + fn intersections(&self, line: &LineSegment) -> SmallVec<[(f64, f64); 3]> { + match self { + DrawCommand::LineTo(l) => l.intersections(line), + DrawCommand::CubicBezier(cbs) => cbs.intersections(line), + DrawCommand::QuadraticBezier(qbs) => qbs.intersections(line), + DrawCommand::Arc(arc) => arc.intersections(line), + } + } +} + +/// Coordinate transformation mapping world space into the canonical unit-circle space +/// of an ellipse, coupled with the angular bounds of the arc. +struct CanonicalArcTransform { + to_canonical: Transform, + start_angle: Angle, + sweep_angle: Angle, +} + +impl CanonicalArcTransform { + fn new_arc_to_unit(arc: &Arc) -> Self { + Self { + to_canonical: Transform::identity() + .then_translate(-arc.center.to_vector()) + .then_rotate(-arc.x_rotation) + .then_scale(1.0 / arc.radii.x.abs(), 1.0 / arc.radii.y.abs()), + start_angle: arc.start_angle, + sweep_angle: arc.sweep_angle, + } + } + /// Converts a canonical-space point into a t-parameterized equivalent on the arc. + /// Returns `None` if it does not lie on the arc. + fn canonical_point_to_arc_t(&self, canonical_point: Point) -> Option { + let total_sweep = Angle::radians(self.sweep_angle.radians.abs()); + let point_angle = Angle::radians(canonical_point.y.atan2(canonical_point.x)); + + // Measure the angular offset from start_angle in the direction of sweep: + let angular_offset = if self.sweep_angle.radians.is_sign_positive() { + point_angle - self.start_angle + } else { + self.start_angle - point_angle + } + .positive(); + + // Snap angular offset near 2PI back to 0 if the value was really close to 0 (i.e. -1E-10) + let angular_offset = if angular_offset > Angle::two_pi() - ANGLE_SNAP_EPSILON { + Angle::zero() + } else { + angular_offset + }; + + if angular_offset <= total_sweep + ANGLE_SNAP_EPSILON { + Some((angular_offset / total_sweep).clamp(0.0, 1.0)) + } else { + None + } + } + + /// Converts a world-space point into a t-parameterized equivalent on the arc. + /// Returns `None` if it does not lie on the arc. + fn world_point_to_arc_t(&self, pt: Point) -> Option { + let canonical_point = self.to_canonical.transform_point(pt); + self.canonical_point_to_arc_t(canonical_point) + } + + /// Finds the t-parameterized intersections of the given world-space line segment + /// with the arc if any, returning pairs `(t_arc, u_segment)`. + fn world_line_intersections_t(&self, segment: &LineSegment) -> SmallVec<[(f64, f64); 2]> { + let canonical_segment = segment.transformed(&self.to_canonical); + self.intersect_canonical_line(&canonical_segment) + .into_iter() + .filter_map(|u| { + let canonical_point = canonical_segment.sample(u); + self.canonical_point_to_arc_t(canonical_point) + .map(|t| (t, u)) + }) + .collect() + } + + /// Find intersections of the unit circle with a line segment in canonical space. + /// Returns the parameter values `u` along the line segment where intersections occur. + /// + /// Does not calculate arc t-values. + fn intersect_canonical_line(&self, canonical_seg: &LineSegment) -> SmallVec<[f64; 2]> { + let line_start = canonical_seg.from; + let line_dir = canonical_seg.to_vector(); + let seg_len_sq = canonical_seg.square_length(); + if seg_len_sq == 0.0 { + return smallvec![]; + } + + // calculate u: the parameterized value where the line through from and to is closest to the origin + // + // Helpful math: + // F = from, T = to, O = origin, M = closest point to the origin + // |FM| = u * |FT| Closest point is "u" far along the line + // |FT||FO| cos(theta) = FT . FO Angle between line and start to origin is theta + // |FM| / |FO| = cos(theta) FMO is a right triangle + // + // |FT||FO| * |FM|/|FO| = FT . FO + // |FT|*|FM| = FT . FO + // |FT||FT|*u = FT . FO + // u = (FT . FO) / (|FT|)^2 + let u_closest = { + let to_origin = Point::origin() - line_start; + to_origin.dot(line_dir) / seg_len_sq + }; + let closest_pt = canonical_seg.sample(u_closest); + + let closest_point_dist_sq = (closest_pt - Point::origin()).square_length(); + // Unit circle is x^2 + y^2 = 1 + // If |MO| > 1, it cannot intersect with the unit circle + if closest_point_dist_sq > 1.0 + ROUNDOFF_EPSILON { + return smallvec![]; + } + + // M lies on or within the unit circle. Draw a chord through M along the line + // + // Helpful math: + // O = origin, M = closest point to the origin, R = one of the two chord points on the circle + // |MO|^2 + |MR|^2 = 1^2 + // |MR| = +/- sqrt(1 - |MO|^2) + // |MR| = delta_u_chord * |FT| + // delta_u_chord = |MR| / |FT| = +/- sqrt(1 - |MO|^2) / |FT| + // + // Now we know the u-parameterized distance from M to the chord points, which become: + // u +/- delta_u_chord + let half_chord_len = (1.0 - closest_point_dist_sq).max(0.0).sqrt(); + let half_chord_delta_u = half_chord_len / seg_len_sq.sqrt(); + + let u_line_intersections: SmallVec<[f64; 2]> = if half_chord_delta_u < PARAM_EPSILON { + // No chord, line is tangent + smallvec![u_closest] + } else { + // Line is secant + smallvec![ + u_closest - half_chord_delta_u, + u_closest + half_chord_delta_u + ] + }; + + u_line_intersections + .into_iter() + // We parameterized by u on the line segment FT, filter out + // intersections on the line but not in the segment. + .filter(|&u| (0.0..=1.0).contains(&u)) + .collect() + } + + /// Given a good initial approximation `u` of a Bezier's intersection with the unit circle, + /// refines the approximation to an actual intersection. + fn refine_bezier_intersection(&self, cbs: &CubicBezierSegment, mut u: f64) -> f64 { + // Given some parameterized `u` on the cubic bezier segment that is close to the arc, + // we want to move it to be exactly intersecting with the arc. + // + // In canonical space, we can treat the arc as a unit circle. + // Let b(u) be a function that returns the coordinates in canonical space. + // Let b'(u) be a function that returns the bezier derivative vector in canonical space. + // + // The unit circle is x^2 + y^2 = 1. + // Treating coordinates like vectors, this is |v|^2 = 1. + // + // We want the coordinate to lie on the unit circle in canonical space, which can be + // formulated as a Newton's method approximation. + // Newton's method finds roots so we want |v|^2 - 1 = 0. + // Substituting our bezier function, we get a root finding problem: + // f(u) = |b(u)|^2 - 1 + // The derivative of the magnitude of a vector: + // |b(u)|' = (b(u) . b'(u)) / |b(u)| + // + // The derivative of the squared magnitude by chain + power rules: + // (|b(u)|^2)' = 2 |b(u)| * |b(u)|' + // = 2 |b(u)| * ((b(u) . b'(u)) / |b(u)|) + // = 2 (b(u) . b'(u)) + // + // So we repeatedly calculate + // u_{n+1} = u_n - (|b(u)|^2 - 1) / (2 (b(u) . b'(u))) + for _ in 0..NEWTON_MAX_ITERATIONS { + let point = self.to_canonical.transform_point(cbs.sample(u)); + let derivative = self.to_canonical.transform_vector(cbs.derivative(u)); + + let f_u = point.to_vector().square_length() - 1.0; + let f_u_deriv = 2.0 * point.to_vector().dot(derivative); + + if f_u.abs() < NEWTON_EPSILON || f_u_deriv.abs() < NEWTON_EPSILON { + break; + } + + let step = f_u / f_u_deriv; + u = (u - step).clamp(0.0, 1.0); + if step.abs() < NEWTON_EPSILON { + break; + } + } + u + } +} + +impl Intersections> for SvgArc { + fn intersections(&self, line_seg: &LineSegment) -> SmallVec<[(f64, f64); 3]> { + let arc = self.to_arc(); + let to_canonical = CanonicalArcTransform::new_arc_to_unit(&arc); + to_canonical + .world_line_intersections_t(line_seg) + .into_iter() + .collect() + } +} + +impl Intersections> for SvgArc { + fn intersections(&self, cbs: &CubicBezierSegment) -> SmallVec<[(f64, f64); 3]> { + let arc = self.to_arc(); + let to_canonical = CanonicalArcTransform::new_arc_to_unit(&arc); + let mut results: SmallVec<[(f64, f64); 3]> = SmallVec::new(); + + arc.for_each_cubic_bezier(&mut |arc_sub| { + for (u_cbs, _t_sub) in cbs.cubic_intersections_t(arc_sub) { + let u = to_canonical.refine_bezier_intersection(cbs, u_cbs); + let pt = cbs.sample(u); + if let Some(t_arc) = to_canonical.world_point_to_arc_t(pt) + // This could happen if the intersection is at the joint of two bezier approximations + && !results + .iter() + .any(|(_, existing_u)| (*existing_u - u).abs() < PARAM_EPSILON) + { + results.push((t_arc, u)); + } + } + }); + + results + } +} + +/// Determines if an arc has circular radii +fn is_circular(radii: Vector) -> bool { + let rx = radii.x.abs(); + let ry = radii.y.abs(); + (rx - ry).abs() <= ROUNDOFF_EPSILON * rx +} + +/// Finds the parameterized intersections of two world space circular arcs. +fn intersect_circles(circle1: &Arc, circle2: &Arc) -> SmallVec<[(f64, f64); 3]> { + // Helpful math: + // Consider two circles that intersect at points A, B. + // The line AB is a chord of both circles with midpoint M. + // + // C1 = center of circle 1, C2 = center of circle 2 + // r1 = radius of circle 1, r2 = radius of circle 2 + // A = chord endpoint, M = chord midpoint + // + // A rhombus is formed by connecting C1AC2B, + // but we focus on the triangle C1AC2 formed by drawing the + // segment C1C2 perpendicular to the chord. + // + // The triangle C1AM has side lengths: + // |C1A| = r1, |AM| = f, |MC1| = d + // The triangle AC2M has side lengths: + // |AC2| = r2, |C2M| = l - d, |MA| = f + // + // r1^2 = d^2 + f^2 + // f^2 = r1^2 - d^2 + // + // r2^2 = (l - d)^2 + f^2 + // r2^2 = (l - d)^2 + r1^2 - d^2 + // r2^2 - r1^2 = (l - d)^2 - d^2 + // r2^2 - r1^2 = l^2 - 2dl + d^2 - d^2 + // r2^2 - r1^2 = l^2 - 2dl + // 2dl = l^2 + r1^2 - r2^2 + // d = (l^2 + r1^2 - r2^2) / 2l + // + // |C1M| = d and M lies along C1C2. + // So we can take the unit vector for C1C2 and scale it by + // d to get the coordinates of M. + // M = C1 + d * C1C2/|C1C2| + // + // Now we know d, we can find the coordinates of A and B. + // |AM| = f + // f^2 = r1^2 - d^2 + // f = sqrt(r1^2 - d^2) + // |AM| = sqrt(r1^2 - d^2) + // and since it's a rhombus, |BM| = sqrt(r1^2 - d^2) + // + // A = M + f * perpendicular(C1C2/|C1C2|) + // B = M - f * perependicular(C1C2/|C1C2|) + let center1 = circle1.center; + let center2 = circle2.center; + let r1 = circle1.radii.x.abs(); + let r2 = circle2.radii.x.abs(); + + // C1C2 + let baseline = center2 - center1; + // |C1C2| + let center_dist_sq = baseline.square_length(); + if center_dist_sq <= ROUNDOFF_EPSILON { + // Circles are concentric or identical + return smallvec![]; + } + + let r1_sq = r1.powi(2); + let r2_sq = r2.powi(2); + + let center_dist = center_dist_sq.sqrt(); + + if center_dist - r1 - r2 > ROUNDOFF_EPSILON * (r1 + r2) { + // |C1C2| = r1 + r2 when externally tangent + // |C1C2| < r1 + r2 maybe secant + // |C1C2| > r1 + r2 when externally disjoint + return smallvec![]; + } else if center_dist - (r1 - r2).abs() < -ROUNDOFF_EPSILON * (r1 + r2) { + // if r1 >= r2 + // |C1C2| = r1 - r2 when internally tangent + // |C1C2| < r1 - r2 when internally disjoint + // |C1C2| > r1 - r2 maybe secant + // if r1 < r2 + // |C1C2| = r2 - r1 "" + // |C1C2| < r2 - r1 "" + // |C1C2| > r1 - r2 "" + return smallvec![]; + } + + // d + let chord_midpoint_dist_from_c1 = (center_dist_sq + r1_sq - r2_sq) / (2.0 * center_dist); + // f^2 + let half_chord_sq = r1_sq - chord_midpoint_dist_from_c1.powi(2); + // f + let half_chord_len = half_chord_sq.max(0.0).sqrt(); + // C1C2 / |C1C2| + let baseline_unit_vector = baseline / center_dist; + // M + let chord_midpoint = center1 + baseline_unit_vector * chord_midpoint_dist_from_c1; + // AM unit vector + let chord_norm_unit_vector = vector(-baseline_unit_vector.y, baseline_unit_vector.x); + + let candidate_points: SmallVec<[Point; 2]> = if half_chord_len == 0.0 { + // Tangent when the chord has no length, the intersection points are coincident. + smallvec![chord_midpoint] + } else { + // Circles intersect at two points (secant) + smallvec![ + // A = M + AM + chord_midpoint + chord_norm_unit_vector * half_chord_len, + // B = M - AM + chord_midpoint - chord_norm_unit_vector * half_chord_len, + ] + }; + + let to_canonical1 = CanonicalArcTransform::new_arc_to_unit(circle1); + let to_canonical2 = CanonicalArcTransform::new_arc_to_unit(circle2); + + candidate_points + .into_iter() + // Validate that the arcs themselves intersect, not just the circles + .filter_map(|p| { + if let (Some(t), Some(u)) = ( + to_canonical1.world_point_to_arc_t(p), + to_canonical2.world_point_to_arc_t(p), + ) { + Some((t, u)) + } else { + None + } + }) + .collect() +} + +/// Given a good initial approximation (t, u) of the intersection between two arcs, +/// refine it with 2D Newton's method. +fn refine_arc_arc(arc1: &Arc, arc2: &Arc, mut t: f64, mut u: f64) -> Option<(f64, f64)> { + // Helpful math: + // The overall goal is to find (t, u) such that F1(t) = F2(u), where F is the arc sampling function. + // We are given an approximation done with cubic bezier curves to be refined, because + // F1(t) does not equal F2(u). + // + // Geometrically, F' is the tangent function of the arc. + // Drawing lines from F1(t) and F2(u) with tangent slope respectively, they have an intersection X, + // the linear approximation of refining both t and u. + // X = F1(t) + delta_t * F1'(t) + // X = F2(u) + delta_u * F2'(u) + // + // F1(t) + delta_t * F1'(t) = F2(u) + delta_u * F2'(u) + // F2(u) - F1(t) = delta_t * F1'(t) - delta_u * F2'(u) + // (F2(u) - F1(t)) x F2'(u) = delta_t * F1'(t) x F2'(u) + // delta_t = ((F2(u) - F1(t)) x F2'(u)) / (F1'(t) x F2'(u)) + // + // (F2(u) - F1(t)) x F1'(t) = - delta_u * F2'(u) x F1'(t) + // delta_u = -((F2(u) - F1(t)) x F1'(t)) / (F2'(u) x F1'(t)) + // delta_u = ((F2(u) - F1(t)) x F1'(t)) / (F1'(t) x F2'(u)) + // + // We can repeatedly apply delta_t and delta_u as Newton's method. + let r_max = arc1 + .radii + .x + .abs() + .max(arc1.radii.y.abs()) + .max(arc2.radii.x.abs()) + .max(arc2.radii.y.abs()); + let linear_tolerance = (r_max * ROUNDOFF_EPSILON).max(NEWTON_EPSILON); + let tolerance_sq = linear_tolerance.powi(2); + + for _ in 0..NEWTON_MAX_ITERATIONS { + let f1_t = arc1.sample(t); + let f2_u = arc2.sample(u); + let error = f2_u - f1_t; + if error.square_length() <= tolerance_sq { + return Some((t, u)); + } + + // sample_tangent returns in dF/dtheta, need to scale to dF/dt + let f1_prime = arc1.sample_tangent(t) * arc1.sweep_angle.radians; + let f2_prime = arc2.sample_tangent(u) * arc2.sweep_angle.radians; + let cross = f1_prime.cross(f2_prime); + + if cross.abs() < NEWTON_EPSILON { + break; // Tangent or parallel contact + } + + let dt = error.cross(f2_prime) / cross; + let du = error.cross(f1_prime) / cross; + + t = (t + dt).clamp(0.0, 1.0); + u = (u + du).clamp(0.0, 1.0); + + if dt.abs() < NEWTON_EPSILON && du.abs() < NEWTON_EPSILON { + break; + } + } + + let p1 = arc1.sample(t); + let p2 = arc2.sample(u); + if (p1 - p2).square_length() <= tolerance_sq { + Some((t, u)) + } else { + None + } +} + +impl Intersections> for SvgArc { + fn intersections(&self, rhs: &SvgArc) -> SmallVec<[(f64, f64); 3]> { + let arc1 = self.to_arc(); + let arc2 = rhs.to_arc(); + + if is_circular(arc1.radii) && is_circular(arc2.radii) { + return intersect_circles(&arc1, &arc2); + } + + let to_canonical1 = CanonicalArcTransform::new_arc_to_unit(&arc1); + let to_canonical2 = CanonicalArcTransform::new_arc_to_unit(&arc2); + + let mut results: SmallVec<[(f64, f64); 3]> = SmallVec::new(); + arc1.for_each_cubic_bezier(&mut |sub1| { + arc2.for_each_cubic_bezier(&mut |sub2| { + for (t_sub1, _t_sub2) in sub1.cubic_intersections_t(sub2) { + let pt = sub1.sample(t_sub1); + if let (Some(seed_t1), Some(seed_t2)) = ( + to_canonical1.world_point_to_arc_t(pt), + to_canonical2.world_point_to_arc_t(pt), + ) && let Some((t1, t2)) = refine_arc_arc(&arc1, &arc2, seed_t1, seed_t2) + && !results + .iter() + .any(|(existing_t1, _)| (*existing_t1 - t1).abs() < PARAM_EPSILON) + { + results.push((t1, t2)); + } + } + }); + }); + + results + } +} + +/// Returns true if two bounding boxes overlap with allowance for tangents. +const fn bounding_boxes_overlap(b1: &Box2D, b2: &Box2D) -> bool { + b1.min.x <= b2.max.x + ROUNDOFF_EPSILON + && b2.min.x <= b1.max.x + ROUNDOFF_EPSILON + && b1.min.y <= b2.max.y + ROUNDOFF_EPSILON + && b2.min.y <= b1.max.y + ROUNDOFF_EPSILON +} + +impl Intersections for DrawCommand { + fn intersections(&self, clip_cmd: &DrawCommand) -> SmallVec<[(f64, f64); 3]> { + // Fast check + if !bounding_boxes_overlap(&self.bounding_box(), &clip_cmd.bounding_box()) { + return smallvec![]; + } + + match (self, clip_cmd) { + (DrawCommand::CubicBezier(c1), DrawCommand::CubicBezier(c2)) => c1.intersections(c2), + (DrawCommand::CubicBezier(c), DrawCommand::QuadraticBezier(q)) => c.intersections(q), + (DrawCommand::QuadraticBezier(q), DrawCommand::CubicBezier(c)) => q.intersections(c), + (DrawCommand::QuadraticBezier(q1), DrawCommand::QuadraticBezier(q2)) => { + q1.intersections(q2) + } + (DrawCommand::Arc(a1), DrawCommand::Arc(a2)) => a1.intersections(a2), + (DrawCommand::Arc(a), DrawCommand::CubicBezier(c)) => a.intersections(c), + (DrawCommand::CubicBezier(c), DrawCommand::Arc(a)) => c.intersections(a), + (DrawCommand::Arc(a), DrawCommand::QuadraticBezier(q)) => a.intersections(q), + (DrawCommand::QuadraticBezier(q), DrawCommand::Arc(a)) => q.intersections(a), + (d1, DrawCommand::LineTo(l2)) => d1.intersections(l2), + (DrawCommand::LineTo(l1), d2) => d2 + .intersections(l1) + .into_iter() + .map(|(t, u)| (u, t)) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use lyon_geom::{ + Angle, ArcFlags, CubicBezierSegment, LineSegment, QuadraticBezierSegment, SvgArc, point, + vector, + }; + + use super::*; + + #[test] + fn test_arc_line_intersections() { + let rot30 = Angle::degrees(30.0); + let (sin_rot30, cos_rot30) = rot30.sin_cos(); + let large_r = 1e7; + + let cases = [ + ( + "circle_top_half_vertical_crossing", + SvgArc { + from: point(5.0, 0.0), + to: point(-5.0, 0.0), + radii: vector(5.0, 5.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, // CCW: top half + }, + }, + LineSegment { + from: point(0.0, 10.0), + to: point(0.0, -10.0), + }, + 1, + ), + ( + "rotated_ellipse_crossing", + SvgArc { + from: point(10.0 + 8.0 * cos_rot30, 20.0 + 8.0 * sin_rot30), + to: point(10.0 - 8.0 * cos_rot30, 20.0 - 8.0 * sin_rot30), + radii: vector(8.0, 4.0), + x_rotation: rot30, + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + LineSegment { + from: point(5.0, 15.0), + to: point(18.0, 26.0), + }, + 1, + ), + ( + "endpoints_on_circle_boundary_catastrophic_cancellation", + SvgArc { + from: point(5.0, 0.0), + to: point(0.0, 5.0), + radii: vector(5.0, 5.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + LineSegment { + from: point(5.0, 0.0), + to: point(0.0, 5.0), + }, + 2, + ), + ( + "clockwise_sweep_endpoint_contact", + SvgArc { + from: point(-10.0, 0.0), + to: point(10.0, 0.0), + radii: vector(10.0, 5.0), + x_rotation: Angle::degrees(45.0), + flags: ArcFlags { + large_arc: false, + sweep: false, // Clockwise + }, + }, + LineSegment { + from: point(-10.0, 0.0), + to: point(10.0, 0.0), + }, + 1, + ), + ( + "distant_line_miss", + SvgArc { + from: point(5.0, 0.0), + to: point(-5.0, 0.0), + radii: vector(5.0, 5.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + LineSegment { + from: point(10.0, 10.0), + to: point(10.0, -10.0), + }, + 0, + ), + ( + "tangent_line_contact", + SvgArc { + from: point(5.0, 0.0), + to: point(-5.0, 0.0), + radii: vector(5.0, 5.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + LineSegment { + from: point(-5.0, 5.0), + to: point(5.0, 5.0), + }, + 1, + ), + ( + "large_radius_flat_arc_perpendicular_crossing", + SvgArc { + from: point(large_r, -100.0), + to: point(large_r, 100.0), + radii: vector(large_r, large_r), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + LineSegment { + from: point(large_r - 5.0, 0.0), + to: point(large_r + 5.0, 0.0), + }, + 1, + ), + ( + "zero_length_world_segment_rejected", + SvgArc { + from: point(5.0, 0.0), + to: point(-5.0, 0.0), + radii: vector(5.0, 5.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + LineSegment { + from: point(0.0, 5.0), + to: point(0.0, 5.0), + }, + 0, + ), + ( + "large_radius_parallel_outside_line_miss", + SvgArc { + from: point(large_r, -100.0), + to: point(large_r, 100.0), + radii: vector(large_r, large_r), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + LineSegment { + from: point(large_r + 1.0, -10.0), + to: point(large_r + 1.0, 10.0), + }, + 0, + ), + ]; + + for (name, arc, line, expected_count) in cases { + let isects = arc.intersections(&line); + assert_eq!( + isects.len(), + expected_count, + "[{name}] Expected count {expected_count}, got {}", + isects.len() + ); + + for (t, u) in isects { + let p_arc = arc.to_arc().sample(t); + let p_line = line.sample(u); + assert!( + (p_arc - p_line).square_length() < 1e-10, + "[{name}] Curves do not meet at ({t}, {u}): p_arc={p_arc:?}, p_line={p_line:?}" + ); + } + } + } + + #[test] + fn test_bezier_intersections() { + let cases = [ + ( + "cubic_cubic_perpendicular_crossing", + DrawCommand::CubicBezier(CubicBezierSegment { + from: point(0.0, 5.0), + ctrl1: point(3.0, 8.0), + ctrl2: point(7.0, 8.0), + to: point(10.0, 5.0), + }), + DrawCommand::CubicBezier(CubicBezierSegment { + from: point(5.0, 0.0), + ctrl1: point(5.0, 3.0), + ctrl2: point(5.0, 7.0), + to: point(5.0, 10.0), + }), + 1, + ), + ( + "quadratic_cubic_crossing", + DrawCommand::QuadraticBezier(QuadraticBezierSegment { + from: point(0.0, 5.0), + ctrl: point(5.0, 10.0), + to: point(10.0, 5.0), + }), + DrawCommand::CubicBezier(CubicBezierSegment { + from: point(5.0, 0.0), + ctrl1: point(5.0, 3.0), + ctrl2: point(5.0, 7.0), + to: point(5.0, 10.0), + }), + 1, + ), + ( + "quadratic_quadratic_crossing", + DrawCommand::QuadraticBezier(QuadraticBezierSegment { + from: point(0.0, 5.0), + ctrl: point(5.0, 10.0), + to: point(10.0, 5.0), + }), + DrawCommand::QuadraticBezier(QuadraticBezierSegment { + from: point(5.0, 0.0), + ctrl: point(5.0, 5.0), + to: point(5.0, 10.0), + }), + 1, + ), + ]; + + for (name, cmd1, cmd2, expected_count) in cases { + let isects = cmd1.intersections(&cmd2); + assert_eq!( + isects.len(), + expected_count, + "[{name}] Expected count {expected_count}, got {}", + isects.len() + ); + + for &(t, u) in &isects { + let p1 = cmd1.sample(t); + let p2 = cmd2.sample(u); + assert!( + (p1 - p2).square_length() < 1e-10, + "[{name}] Curves do not meet at ({t}, {u}): p1={p1:?}, p2={p2:?}" + ); + } + + // Test symmetric reverse dispatch + let isects_rev = cmd2.intersections(&cmd1); + assert_eq!( + isects_rev.len(), + expected_count, + "[{name}] Reverse symmetry count mismatch" + ); + } + } + + #[test] + fn test_arc_arc_intersections() { + let cases = [ + ( + "two_circles_secant_two_points", + SvgArc { + from: point(0.0, -5.0), + to: point(0.0, 5.0), + radii: vector(5.0, 5.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + SvgArc { + from: point(6.0, 5.0), + to: point(6.0, -5.0), + radii: vector(5.0, 5.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + 2, + ), + ( + "two_circles_externally_tangent", + SvgArc { + from: point(3.0, 0.0), + to: point(-3.0, 0.0), + radii: vector(3.0, 3.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + SvgArc { + from: point(3.0, 0.0), + to: point(11.0, 0.0), + radii: vector(4.0, 4.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + 1, + ), + ( + "concentric_circles_disjoint", + SvgArc { + from: point(5.0, 0.0), + to: point(-5.0, 0.0), + radii: vector(5.0, 5.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + SvgArc { + from: point(2.0, 0.0), + to: point(-2.0, 0.0), + radii: vector(2.0, 2.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + 0, + ), + ( + "eccentric_nested_circles_disjoint", + SvgArc { + from: point(5.0, 0.0), + to: point(-5.0, 0.0), + radii: vector(5.0, 5.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + SvgArc { + from: point(3.0, 0.0), + to: point(-1.0, 0.0), + radii: vector(2.0, 2.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + 0, + ), + ( + "far_apart_circles_disjoint", + SvgArc { + from: point(5.0, 0.0), + to: point(-5.0, 0.0), + radii: vector(5.0, 5.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + SvgArc { + from: point(55.0, 0.0), + to: point(45.0, 0.0), + radii: vector(5.0, 5.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + 0, + ), + ( + "ellipse_ellipse_2d_newton_refinement", + SvgArc { + from: point(10.0, 0.0), + to: point(-10.0, 0.0), + radii: vector(10.0, 5.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + SvgArc { + from: point(5.0, 0.0), + to: point(-5.0, 0.0), + radii: vector(5.0, 10.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + 2, + ), + ]; + + for (name, arc1, arc2, expected_count) in cases { + let isects = DrawCommand::Arc(arc1).intersections(&DrawCommand::Arc(arc2)); + assert_eq!( + isects.len(), + expected_count, + "[{name}] Expected count {expected_count}, got {}", + isects.len() + ); + + for (t, u) in &isects { + let p1 = arc1.to_arc().sample(*t); + let p2 = arc2.to_arc().sample(*u); + assert!( + (p1 - p2).square_length() < 1e-10, + "[{name}] Arcs do not meet at ({t}, {u}): p1={p1:?}, p2={p2:?}" + ); + } + + let isects_rev = DrawCommand::Arc(arc2).intersections(&DrawCommand::Arc(arc1)); + assert_eq!( + isects_rev.len(), + expected_count, + "[{name}] Reverse symmetry count mismatch" + ); + } + } + + #[test] + fn test_arc_bezier_intersections() { + let cases = [ + ( + "arc_cubic_s_curve_crossing", + SvgArc { + from: point(5.0, 0.0), + to: point(-5.0, 0.0), + radii: vector(5.0, 5.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + CubicBezierSegment { + from: point(0.0, 0.0), + ctrl1: point(0.5, 3.0), + ctrl2: point(-0.5, 7.0), + to: point(0.0, 10.0), + }, + 1, + ), + ( + "arc_curved_cubic_crossing", + SvgArc { + from: point(5.0, 0.0), + to: point(-5.0, 0.0), + radii: vector(5.0, 5.0), + x_rotation: Angle::zero(), + flags: ArcFlags { + large_arc: false, + sweep: true, + }, + }, + CubicBezierSegment { + from: point(0.0, 0.0), + ctrl1: point(1.0, 4.0), + ctrl2: point(3.0, 7.0), + to: point(6.0, 8.0), + }, + 1, + ), + ]; + + for (name, arc, cbs, expected_count) in cases { + let isects = DrawCommand::Arc(arc).intersections(&DrawCommand::CubicBezier(cbs)); + assert_eq!( + isects.len(), + expected_count, + "[{name}] Expected count {expected_count}, got {}", + isects.len() + ); + + for (t, u) in &isects { + let p1 = arc.to_arc().sample(*t); + let p2 = cbs.sample(*u); + assert!( + (p1 - p2).square_length() < 1e-10, + "[{name}] Curves do not meet at ({t}, {u}): p1={p1:?}, p2={p2:?}" + ); + } + + let isects_rev = DrawCommand::CubicBezier(cbs).intersections(&DrawCommand::Arc(arc)); + assert_eq!( + isects_rev.len(), + expected_count, + "[{name}] Reverse symmetry count mismatch" + ); + } + } + + #[test] + fn test_draw_command_bounding_box_filter() { + let cases = [ + ( + "disjoint_lines_early_reject", + DrawCommand::LineTo(LineSegment { + from: point(0.0, 0.0), + to: point(1.0, 1.0), + }), + DrawCommand::LineTo(LineSegment { + from: point(10.0, 10.0), + to: point(11.0, 11.0), + }), + 0, + ), + ( + "flat_zero_thickness_lines_crossing_not_rejected", + DrawCommand::LineTo(LineSegment { + from: point(0.0, 0.0), + to: point(10.0, 0.0), + }), + DrawCommand::LineTo(LineSegment { + from: point(5.0, -5.0), + to: point(5.0, 5.0), + }), + 1, + ), + ]; + + for (name, cmd1, cmd2, expected_count) in cases { + let isects = cmd1.intersections(&cmd2); + assert_eq!( + isects.len(), + expected_count, + "[{name}] Expected count {expected_count}, got {}", + isects.len() + ); + + for (t, u) in &isects { + let p1 = cmd1.sample(*t); + let p2 = cmd2.sample(*u); + assert!( + (p1 - p2).square_length() < 1e-10, + "[{name}] Commands do not meet at ({t}, {u}): p1={p1:?}, p2={p2:?}" + ); + } + } + } +} diff --git a/star/src/turtle/elements/mod.rs b/star/src/turtle/elements/mod.rs index 9e4f3be..f99d156 100644 --- a/star/src/turtle/elements/mod.rs +++ b/star/src/turtle/elements/mod.rs @@ -1,9 +1,9 @@ //! Atomic units operated on by a turtle. -use std::mem::swap; +use std::{mem::swap, ops::Range}; -use lyon_geom::Box2D; pub use lyon_geom::{ArcFlags, CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc, Vector}; +use lyon_geom::{Box2D, LineSegment}; pub use self::{ arc::{ArcOrLineSegment, FlattenWithArcs, Transformed}, @@ -16,6 +16,10 @@ mod arc; /// Reorders strokes to minimize pen-up travel using TSP heuristics mod tsp; +/// Find intersections between curves and lines in parameterized (t, u) form. +mod intersect; + +pub(crate) mod clip; pub(crate) mod fill; #[cfg(feature = "image")] @@ -55,54 +59,64 @@ pub struct RasterImage { } /// Atomic unit of a [Stroke]. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] pub enum DrawCommand { - LineTo { - from: Point, - to: Point, - }, + LineTo(LineSegment), Arc(SvgArc), CubicBezier(CubicBezierSegment), QuadraticBezier(QuadraticBezierSegment), - /// Largely for debugging purposes. - Comment(String), } impl DrawCommand { pub fn start_point(&self) -> Option> { match self { - Self::LineTo { from, .. } => Some(*from), + Self::LineTo(line) => Some(line.from), Self::Arc(arc) => Some(arc.from), Self::CubicBezier(cbs) => Some(cbs.from), Self::QuadraticBezier(qbs) => Some(qbs.from), - Self::Comment(_) => None, } } - pub fn end_point(&self) -> Option> { + pub fn set_start_point(&mut self, pt: Point) { + match self { + Self::LineTo(line) => line.from = pt, + Self::Arc(arc) => arc.from = pt, + Self::CubicBezier(cbs) => cbs.from = pt, + Self::QuadraticBezier(qbs) => qbs.from = pt, + } + } + + pub fn end_point(&self) -> Point { match self { - Self::LineTo { to, .. } => Some(*to), - Self::Arc(arc) => Some(arc.to), - Self::CubicBezier(cbs) => Some(cbs.to), - Self::QuadraticBezier(qbs) => Some(qbs.to), - Self::Comment(_) => None, + Self::LineTo(line) => line.to, + Self::Arc(arc) => arc.to, + Self::CubicBezier(cbs) => cbs.to, + Self::QuadraticBezier(qbs) => qbs.to, } } - pub fn bounding_box(&self) -> Option> { + pub fn set_end_point(&mut self, pt: Point) { 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, + Self::LineTo(line) => line.to = pt, + Self::Arc(arc) => arc.to = pt, + Self::CubicBezier(cbs) => cbs.to = pt, + Self::QuadraticBezier(qbs) => qbs.to = pt, + } + } + + pub fn bounding_box(&self) -> Box2D { + match self { + Self::LineTo(line) => line.bounding_box(), + Self::Arc(arc) => arc.to_arc().bounding_box(), + Self::CubicBezier(cbs) => cbs.bounding_box(), + Self::QuadraticBezier(qbs) => qbs.bounding_box(), } } pub fn reverse(&mut self) { match self { - Self::LineTo { from, to } => { - swap(from, to); + Self::LineTo(line) => { + swap(&mut line.from, &mut line.to); } Self::Arc(arc) => { swap(&mut arc.to, &mut arc.from); @@ -115,7 +129,37 @@ impl DrawCommand { Self::QuadraticBezier(qbs) => { swap(&mut qbs.from, &mut qbs.to); } - Self::Comment(_) => {} + } + } + + pub fn sample(&self, t: f64) -> Point { + match self { + Self::LineTo(line) => line.sample(t), + Self::Arc(arc) => arc.to_arc().sample(t), + Self::CubicBezier(cbs) => cbs.sample(t), + Self::QuadraticBezier(qbs) => qbs.sample(t), + } + } + + /// Returns a sub-curve within the given t-range. + pub fn split_range(&self, range: Range) -> Self { + match self { + Self::LineTo(line) => Self::LineTo(line.split_range(range)), + Self::Arc(svg_arc) => { + let sub_arc = svg_arc.to_arc().split_range(range); + Self::Arc(SvgArc { + from: sub_arc.from(), + to: sub_arc.to(), + radii: svg_arc.radii, + x_rotation: svg_arc.x_rotation, + flags: ArcFlags { + large_arc: sub_arc.sweep_angle.radians.abs() >= std::f64::consts::PI, + sweep: sub_arc.sweep_angle.radians.is_sign_positive(), + }, + }) + } + Self::CubicBezier(cbs) => Self::CubicBezier(cbs.split_range(range)), + Self::QuadraticBezier(qbs) => Self::QuadraticBezier(qbs.split_range(range)), } } } @@ -125,22 +169,27 @@ impl DrawCommand { pub struct Stroke { start_point: Point, commands: Vec, + /// Largely for debugging purposes. + comment: Option, } impl Stroke { - pub fn new(start_point: Point, commands: Vec) -> Self { + pub fn new( + start_point: Point, + commands: Vec, + comment: Option, + ) -> Self { Self { start_point, commands, + comment, } } pub fn end_point(&self) -> Point { self.commands - .iter() - .rev() - .find_map(DrawCommand::end_point) - .unwrap_or(self.start_point) + .last() + .map_or(self.start_point, |last| last.end_point()) } /// Reverses the stroke so it runs from [Self::end_point] to [Self::start_point]. @@ -169,12 +218,13 @@ impl Stroke { /// Calculate the bounding box of the stroke. pub fn bounding_box(&self) -> Box2D { - let mut bbox = Box2D::new(self.start_point, self.start_point); - for command in &self.commands { - if let Some(b) = command.bounding_box() { - bbox = Box2D::from_points([bbox.min, bbox.max, b.min, b.max]); - } - } - bbox + self.commands().map(DrawCommand::bounding_box).fold( + Box2D::new(self.start_point, self.start_point), + |bbox, cmd_bbox| Box2D::from_points([bbox.min, bbox.max, cmd_bbox.min, cmd_bbox.max]), + ) + } + + pub fn comment(&self) -> Option<&str> { + self.comment.as_deref() } } diff --git a/star/src/turtle/mod.rs b/star/src/turtle/mod.rs index d7636a7..ba024f6 100644 --- a/star/src/turtle/mod.rs +++ b/star/src/turtle/mod.rs @@ -1,14 +1,16 @@ use std::fmt::Debug; +use log::warn; use lyon_geom::{ - ArcFlags, Box2D, CubicBezierSegment, Point, QuadraticBezierSegment, SvgArc, Vector, + ArcFlags, Box2D, CubicBezierSegment, LineSegment, Point, QuadraticBezierSegment, SvgArc, + Vector, euclid::{Angle, default::Transform2D}, point, vector, }; use svgtypes::PathSegment; use self::elements::{FillPolygon, Transformed}; -use crate::turtle::elements::{DrawCommand, FillRule, Stroke}; +use crate::turtle::elements::{DrawCommand, FillRule, Stroke, clip::ClipStack}; mod collect; mod dpi; @@ -64,6 +66,7 @@ pub(crate) struct Terrarium { comment: Option, current_bounds: Option>, bounds_stack: Vec>>, + clip_stack: ClipStack, } impl Terrarium { @@ -81,9 +84,18 @@ impl Terrarium { comment: None, current_bounds: None, bounds_stack: vec![], + clip_stack: ClipStack::default(), } } + pub fn push_clip_layer(&mut self, polys: Vec) { + self.clip_stack.push_clip(polys); + } + + pub fn pop_clip_layer(&mut self) -> Option> { + self.clip_stack.pop_clip() + } + pub fn push_viewport_bounds(&mut self, viewport_box: Box2D) { let new_bounds = self.current_transform.outer_transformed_box(&viewport_box); let combined_bounds = match self.current_bounds { @@ -93,6 +105,28 @@ impl Terrarium { self.push_bounds(combined_bounds); } + pub fn push_viewport_clip(&mut self, viewport_box: Box2D) { + // TODO: this is breaking the turtle abstraction a little + let t = self.current_transform(); + let p1 = t.transform_point(point(viewport_box.min.x, viewport_box.min.y)); + let p2 = t.transform_point(point(viewport_box.max.x, viewport_box.min.y)); + let p3 = t.transform_point(point(viewport_box.max.x, viewport_box.max.y)); + let p4 = t.transform_point(point(viewport_box.min.x, viewport_box.max.y)); + self.push_clip_layer(vec![FillPolygon { + outer: Stroke::new( + p1, + vec![ + DrawCommand::LineTo(LineSegment { from: p1, to: p2 }), + DrawCommand::LineTo(LineSegment { from: p2, to: p3 }), + DrawCommand::LineTo(LineSegment { from: p3, to: p4 }), + DrawCommand::LineTo(LineSegment { from: p4, to: p1 }), + ], + None, + ), + holes: vec![], + }]); + } + pub fn push_bounds(&mut self, bounds: Option>) { self.bounds_stack.push(self.current_bounds); self.current_bounds = bounds; @@ -139,10 +173,10 @@ impl Terrarium { .lower_than(vector(f64::EPSILON, f64::EPSILON)) .all() { - Some(DrawCommand::LineTo { + Some(DrawCommand::LineTo(LineSegment { from: self.current_position, to: self.initial_position, - }) + })) } else { None }; @@ -186,7 +220,7 @@ impl Terrarium { self.previous_quadratic_control = None; self.previous_cubic_control = None; - DrawCommand::LineTo { from, to } + DrawCommand::LineTo(LineSegment { from, to }) } /// Draw a cubic curve from the current point to (x, y) with specified control points (x1, y1) and (x2, y2) @@ -197,7 +231,7 @@ impl Terrarium { mut ctrl1: Point, mut ctrl2: Point, mut to: Point, - ) -> CubicBezierSegment { + ) -> DrawCommand { let from = self.current_position; if !abs { let inverse_transform = self.current_transform.inverse().unwrap(); @@ -226,7 +260,7 @@ impl Terrarium { )); self.previous_quadratic_control = None; - cbs + DrawCommand::CubicBezier(cbs) } /// Draw a shorthand/smooth cubic bezier segment, where the first control point was already given @@ -236,7 +270,7 @@ impl Terrarium { abs: bool, mut ctrl2: Point, mut to: Point, - ) -> CubicBezierSegment { + ) -> DrawCommand { let from = self.current_position; let ctrl1 = self.previous_cubic_control.unwrap_or(self.current_position); if !abs { @@ -267,16 +301,12 @@ impl Terrarium { )); self.previous_quadratic_control = None; - cbs + DrawCommand::CubicBezier(cbs) } /// Draw a shorthand/smooth cubic bezier segment, where the control point was already given /// - fn smooth_quadratic_bezier( - &mut self, - abs: bool, - mut to: Point, - ) -> QuadraticBezierSegment { + fn smooth_quadratic_bezier(&mut self, abs: bool, mut to: Point) -> DrawCommand { let from = self.current_position; let ctrl = self .previous_quadratic_control @@ -302,7 +332,7 @@ impl Terrarium { )); self.previous_cubic_control = None; - qbs + DrawCommand::QuadraticBezier(qbs) } /// Draw a quadratic bezier segment @@ -312,7 +342,7 @@ impl Terrarium { abs: bool, mut ctrl: Point, mut to: Point, - ) -> QuadraticBezierSegment { + ) -> DrawCommand { let from = self.current_position; if !abs { let inverse_transform = self @@ -337,7 +367,7 @@ impl Terrarium { )); self.previous_cubic_control = None; - qbs + DrawCommand::QuadraticBezier(qbs) } /// Draw an elliptical arc segment @@ -349,7 +379,7 @@ impl Terrarium { x_rotation: Angle, flags: ArcFlags, mut to: Point, - ) -> SvgArc { + ) -> DrawCommand { let from = self .current_transform .inverse() @@ -372,7 +402,14 @@ impl Terrarium { self.previous_quadratic_control = None; self.previous_cubic_control = None; - svg_arc + if svg_arc.is_straight_line() { + DrawCommand::LineTo(LineSegment { + from: svg_arc.from, + to: svg_arc.to, + }) + } else { + DrawCommand::Arc(svg_arc) + } } fn begin(&mut self) { @@ -438,6 +475,10 @@ impl Terrarium { .expect("pop only called when transforms remain"); } + pub fn current_transform(&self) -> Transform2D { + self.current_transform + } + pub fn comment(&mut self, comment: String) { self.comment = Some(comment); } @@ -450,28 +491,28 @@ impl Terrarium { let mut start_point = Point::zero(); let mut commands = vec![]; - let mut pending_comment = self.comment.take(); + let mut path_comment = self.comment.take(); + let mut strokes = vec![]; + + let mut push_stroke = |stroke_start: Point, commands: &mut Vec| { + if commands.is_empty() { + return; + } + let cmds = std::mem::take(commands); + strokes.push(Stroke::new(stroke_start, cmds, path_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))); - } + push_stroke(start_point, &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))); - } + push_stroke(start_point, &mut commands); } LineTo { abs, x, y } => { commands.push(self.line(abs, Some(x), Some(y))); @@ -491,31 +532,21 @@ impl Terrarium { x, y, } => { - commands.push(DrawCommand::CubicBezier(self.cubic_bezier( + commands.push(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), - ))); + commands.push(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), - ))); + commands.push(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)), - )); + commands.push(self.smooth_quadratic_bezier(abs, point(x, y))); } EllipticalArc { abs, @@ -527,38 +558,44 @@ impl Terrarium { x, y, } => { - commands.push(DrawCommand::Arc(self.elliptical( + commands.push(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))); - } + // Handle unclosed path + push_stroke(start_point, &mut commands); + + self.apply_strokes(strokes); } pub fn apply_strokes(&mut self, strokes: impl IntoIterator) { self.begin(); - - for stroke in strokes { - self.reset(); - self.turtle.stroke(stroke); + if self.clip_stack.is_active() { + let strokes = self.clip_stack.clip_strokes(strokes).collect::>(); + for stroke in strokes { + self.reset(); + self.turtle.stroke(stroke); + } + } else { + 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, + pub fn to_fill_polygons( + &self, segments: impl IntoIterator, fill_rule: FillRule, - ) { + ) -> Vec { let mut sub = Terrarium { has_begun: false, turtle: StrokeCollectingTurtle::default(), @@ -571,10 +608,27 @@ impl Terrarium { comment: self.comment.clone(), current_bounds: self.current_bounds, bounds_stack: vec![], + clip_stack: self.clip_stack.clone(), }; sub.apply_path(segments); - let segments = sub.finish().into_strokes(); - for polygon in self::elements::fill::into_fill_polygons(segments, fill_rule) { + let strokes = sub.finish().into_strokes(); + let polys = self::elements::fill::into_fill_polygons(strokes, fill_rule); + if self.clip_stack.is_active() { + // TODO: polygon clipping is non-trivial + warn!("Polygon clipping is not yet unimplemented, skipping clip"); + polys + } else { + polys + } + } + + /// Converts an SVG polygon into [FillPolygon(s)](FillPolygon) on a turtle. + pub fn apply_polygon( + &mut self, + segments: impl IntoIterator, + fill_rule: FillRule, + ) { + for polygon in self.to_fill_polygons(segments, fill_rule) { self.turtle.fill_polygon(polygon); } } @@ -586,3 +640,73 @@ impl Terrarium { self.turtle } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::lower::{ConversionConfig, ConversionOptions, svg_to_turtle}; + + #[test] + fn test_clip_layer_out_of_bounds_shape_filtered() { + let svg_text = r#" + + + + + + +"#; + let doc = roxmltree::Document::parse(svg_text).unwrap(); + + #[derive(Debug, Default)] + struct TestTurtle { + polygons: Vec, + } + impl Turtle for TestTurtle { + fn begin(&mut self) {} + fn end(&mut self) {} + fn stroke(&mut self, _stroke: Stroke) {} + fn fill_polygon(&mut self, polygon: FillPolygon) { + self.polygons.push(polygon); + } + #[cfg(feature = "image")] + fn image(&mut self, _image: super::elements::RasterImage) {} + } + + let conv = ConversionConfig::default(); + let turtle = TestTurtle::default(); + let result = svg_to_turtle( + &doc, + &conv, + ConversionOptions::default(), + turtle, + CoordinateSystem::YDown, + ); + + // All emitted polygons must have non-zero area and bounding boxes strictly inside [0, 20] x [0, 20] + assert!(!result.polygons.is_empty(), "Should emit clipped polygons"); + for poly in &result.polygons { + let bbox = poly.outer.bounding_box(); + assert!( + bbox.min.x >= -1e-3, + "Min X should be within bounds: {}", + bbox.min.x + ); + assert!( + bbox.max.x <= 20.0 + 1e-3, + "Max X should be within bounds: {}", + bbox.max.x + ); + assert!( + bbox.min.y >= -1e-3, + "Min Y should be within bounds: {}", + bbox.min.y + ); + assert!( + bbox.max.y <= 20.0 + 1e-3, + "Max Y should be within bounds: {}", + bbox.max.y + ); + } + } +} diff --git a/star/src/turtle/preprocess.rs b/star/src/turtle/preprocess.rs index 9676ad3..00b8627 100644 --- a/star/src/turtle/preprocess.rs +++ b/star/src/turtle/preprocess.rs @@ -3,6 +3,7 @@ use std::iter; use lyon_geom::Box2D; use super::{Turtle, elements::FillPolygon}; +use crate::turtle::elements::DrawCommand; /// Generates a bounding box for all draw operations, used to properly apply [crate::lower::ConversionConfig::origin] #[derive(Debug, Default)] @@ -35,27 +36,27 @@ impl Turtle for PreprocessTurtle { self.bounding_box = iter::once(polygon.outer) .chain(polygon.holes) .flat_map(|s| s.into_commands()) - .filter_map(|c| c.bounding_box()) + .map(|c| c.bounding_box()) .fold(self.bounding_box, |acc, b| { Box2D::from_points([acc.min, acc.max, b.min, b.max]) }); } 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, - ]); - } - } + self.bounding_box = stroke.commands().map(DrawCommand::bounding_box).fold( + Box2D::from_points([ + self.bounding_box.min, + self.bounding_box.max, + stroke.start_point(), + ]), + |bounding_box, cmd_bbox| { + Box2D::from_points([ + bounding_box.min, + bounding_box.max, + cmd_bbox.min, + cmd_bbox.max, + ]) + }, + ); } } diff --git a/star/src/turtle/svg_preview.rs b/star/src/turtle/svg_preview.rs index a55b4a8..d55976d 100644 --- a/star/src/turtle/svg_preview.rs +++ b/star/src/turtle/svg_preview.rs @@ -1,6 +1,6 @@ use std::fmt::Write; -use lyon_geom::{Box2D, Point}; +use lyon_geom::{Box2D, LineSegment, Point}; use super::{ Turtle, @@ -88,7 +88,7 @@ impl Turtle for SvgPreviewTurtle { write!(path, " { + DrawCommand::LineTo(LineSegment { from: _, to }) => { write!(path, "L {},{} ", to.x, to.y).unwrap(); self.add_point(to); self.current_pos = to; @@ -129,7 +129,6 @@ impl Turtle for SvgPreviewTurtle { self.add_box(qbs.bounding_box()); self.current_pos = qbs.to; } - DrawCommand::Comment(_) => {} } } writeln!( From d856804a7d9bae32d54f9ab36914f5b8f5858113 Mon Sep 17 00:00:00 2001 From: Sameer Puri Date: Sun, 11 Oct 2026 11:40:43 -0700 Subject: [PATCH 3/6] Address clippy warnings --- star/src/lower/visit.rs | 1 - star/src/turtle/elements/clip.rs | 16 +++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/star/src/lower/visit.rs b/star/src/lower/visit.rs index 32770ce..20d36c3 100644 --- a/star/src/lower/visit.rs +++ b/star/src/lower/visit.rs @@ -29,7 +29,6 @@ const RECT_TAG_NAME: &str = "rect"; const CIRCLE_TAG_NAME: &str = "circle"; const ELLIPSE_TAG_NAME: &str = "ellipse"; const LINE_TAG_NAME: &str = "line"; -const GROUP_TAG_NAME: &str = "g"; const DEFS_TAG_NAME: &str = "defs"; const USE_TAG_NAME: &str = "use"; const MARKER_TAG_NAME: &str = "marker"; diff --git a/star/src/turtle/elements/clip.rs b/star/src/turtle/elements/clip.rs index d01b918..7fd0d3d 100644 --- a/star/src/turtle/elements/clip.rs +++ b/star/src/turtle/elements/clip.rs @@ -26,13 +26,15 @@ impl ClipStack { iter::once(&polygon.outer) .chain(&polygon.holes) .flat_map(|stroke| { - stroke.commands().copied().chain( - Some(DrawCommand::LineTo(LineSegment { - from: stroke.end_point(), - to: stroke.start_point(), - })) - .filter(|_| !stroke.is_closed()), - ) + stroke + .commands() + .copied() + .chain((!stroke.is_closed()).then_some(DrawCommand::LineTo( + LineSegment { + from: stroke.end_point(), + to: stroke.start_point(), + }, + ))) }) }) .collect(), From 8a80e7ac4d90500eae3bf377bce074b1fd425147 Mon Sep 17 00:00:00 2001 From: Sameer Puri Date: Mon, 12 Oct 2026 10:13:13 -0700 Subject: [PATCH 4/6] Fix no movement command on polyline/polygon --- star/src/lower/visit.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/star/src/lower/visit.rs b/star/src/lower/visit.rs index 20d36c3..6c7e53e 100644 --- a/star/src/lower/visit.rs +++ b/star/src/lower/visit.rs @@ -483,8 +483,7 @@ impl<'a, 'input, T: Turtle> XmlVisitor<'a, 'input> for ConversionVisitor<'a, 'in let is_polygon = name == POLYGON_TAG_NAME; let mut pp = PointsParser::from(points).peekable(); let start = pp - .peek() - .copied() + .next() .map(|(x, y)| MoveTo { abs: true, x, y }) .into_iter(); start From 821480226b96960281b9700c237411aafe15050e Mon Sep 17 00:00:00 2001 From: Sameer Puri Date: Mon, 12 Oct 2026 10:15:00 -0700 Subject: [PATCH 5/6] Clippy --- g_code/src/tests.rs | 2 +- web/src/forms/mod.rs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/g_code/src/tests.rs b/g_code/src/tests.rs index 04f7558..effe08c 100644 --- a/g_code/src/tests.rs +++ b/g_code/src/tests.rs @@ -43,7 +43,7 @@ fn assert_close(left: Vec>, right: Vec>) { let mut code = String::new(); ::g_code::emit::format_gcode_fmt(left.iter(), FormatOptions::default(), &mut code).unwrap(); assert_eq!(left.len(), right.len(), "{code}"); - for (i, pair) in left.into_iter().zip(right.into_iter()).enumerate() { + for (i, pair) in left.into_iter().zip(right).enumerate() { match pair { (Token::Field(l), Token::Field(r)) => { assert_eq!(l.letters, r.letters); diff --git a/web/src/forms/mod.rs b/web/src/forms/mod.rs index 34e48a7..90bc5e1 100644 --- a/web/src/forms/mod.rs +++ b/web/src/forms/mod.rs @@ -276,10 +276,10 @@ pub fn import_export_modal() -> Html { &gloo_file::File::from(file), move |res| { let res = res - .map_err(|err| format!("Error reading {}: {}", &filename, err)) + .map_err(|err| format!("Error reading {}: {}", filename, err)) .and_then(|bytes| { serde_json::from_slice::(&bytes) - .map_err(|err| format!("Error parsing {}: {}", &filename, err)) + .map_err(|err| format!("Error parsing {}: {}", filename, err)) }); match res { @@ -429,7 +429,7 @@ pub fn svg_form() -> Html { ) .err() { - Err(format!("Error parsing {}: {}", &filename, err)) + Err(format!("Error parsing {}: {}", filename, err)) } else { Ok(Svg { content: text, @@ -517,7 +517,7 @@ pub fn svg_form() -> Html { { url_input_parsed.set(Some(Err(format!( "Error parsing {}: {}", - &response_url, err + response_url, err )))); } else { app.reduce_mut(|app| { @@ -532,7 +532,7 @@ pub fn svg_form() -> Html { Err(err) => { url_input_parsed.set(Some(Err(format!( "Error fetching {}: {:?}", - &request_url, + request_url, err.dyn_into::().unwrap().message() )))); } From 52bd860db6ff95e803eaa9bf493c8d3af143c0c5 Mon Sep 17 00:00:00 2001 From: Sameer Puri Date: Mon, 12 Oct 2026 12:37:21 -0700 Subject: [PATCH 6/6] Fix bad merge --- g_code/tests/shapes.gcode | 2 -- star/src/lower/visit.rs | 4 ++-- star/src/turtle/mod.rs | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/g_code/tests/shapes.gcode b/g_code/tests/shapes.gcode index c3b6113..b9b45e0 100644 --- a/g_code/tests/shapes.gcode +++ b/g_code/tests/shapes.gcode @@ -930,7 +930,6 @@ G1 X133.707 Y112.443 F300 G1 X133.716 Y112.143 F300 G1 X133.717 Y112.053 F300;svg#svg1 > g#layer1 > polygon#polygon1 G0 X12.575 Y173.877 -G1 X12.575 Y173.877 F300 G1 X8.009 Y179.602 F300 G1 X9.639 Y186.741 F300 G1 X16.236 Y189.919 F300 @@ -939,7 +938,6 @@ G1 X24.463 Y179.602 F300 G1 X42.162 Y169.979 F300 G1 X12.575 Y173.877 F300;svg#svg1 > g#layer1 > polyline#polyline1 G0 X76.339 Y112.397 -G1 X76.339 Y112.397 F300 G1 X71.773 Y118.122 F300 G1 X73.402 Y125.261 F300 G1 X80 Y128.439 F300 diff --git a/star/src/lower/visit.rs b/star/src/lower/visit.rs index b1a6ece..f7286d6 100644 --- a/star/src/lower/visit.rs +++ b/star/src/lower/visit.rs @@ -459,8 +459,8 @@ impl<'a, 'input, T: Turtle> XmlVisitor<'a, 'input> for ConversionVisitor<'a, 'in }); // Default to false which is spec compliant. - let has_stroke_attr = - calculate_presentation_attr(node, "stroke", &self.css_rules) != Some("none"); + let has_stroke_attr = calculate_presentation_attr(node, "stroke", &self.css_rules) + .is_some_and(|attr| attr != "none"); let segments: SmallVec<[PathSegment; 5]> = match node.tag_name().name() { PATH_TAG_NAME => { diff --git a/star/src/turtle/mod.rs b/star/src/turtle/mod.rs index 7877e13..3eab8d6 100644 --- a/star/src/turtle/mod.rs +++ b/star/src/turtle/mod.rs @@ -173,12 +173,12 @@ impl Terrarium { .lower_than(vector(f64::EPSILON, f64::EPSILON)) .all() { + None + } else { Some(DrawCommand::LineTo(LineSegment { from: self.current_position, to: self.initial_position, })) - } else { - None }; self.current_position = self.initial_position; self.previous_quadratic_control = None;