From 2ae4986f0d49d63c1f054450ad5d3c7d699dcd56 Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Thu, 18 May 2023 22:04:57 -0700 Subject: [PATCH 1/8] First cut at stroke expansion This is a starting point; no dashes, only butt and miter. Also not tested yet. Will close #285 --- Cargo.toml | 3 + src/lib.rs | 2 + src/stroke.rs | 256 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 src/stroke.rs diff --git a/Cargo.toml b/Cargo.toml index 0bb01e80..ba8ba2fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,9 @@ features = ["mint", "schemars", "serde"] default = ["std"] std = [] +[dependencies] +smallvec = "1.10" + [dependencies.arrayvec] version = "0.7.1" default-features = false diff --git a/src/lib.rs b/src/lib.rs index 203e367b..3c09b4ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -107,6 +107,7 @@ mod rounded_rect_radii; mod shape; pub mod simplify; mod size; +mod stroke; #[cfg(feature = "std")] mod svg; mod translate_scale; @@ -131,6 +132,7 @@ pub use crate::rounded_rect_radii::*; pub use crate::shape::*; pub use crate::size::*; #[cfg(feature = "std")] +pub use crate::stroke::*; pub use crate::svg::*; pub use crate::translate_scale::*; pub use crate::vec2::*; diff --git a/src/stroke.rs b/src/stroke.rs new file mode 100644 index 00000000..657982bf --- /dev/null +++ b/src/stroke.rs @@ -0,0 +1,256 @@ +// Copyright 2023 the Kurbo Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use core::borrow::Borrow; + +use smallvec::SmallVec; + +use crate::{PathEl, BezPath, Point, Vec2, PathSeg, CubicBez, offset::CubicOffset, fit_to_bezpath, QuadBez}; + +/// Defines the connection between two segments of a stroke. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum Join { + /// A straight line connecting the segments. + Bevel, + /// The segments are extended to their natural intersection point. + Miter, + /// An arc between the segments. + Round, +} + +/// Defines the shape to be drawn at the ends of a stroke. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum Cap { + /// Flat cap. + Butt, + /// Square cap with dimensions equal to half the stroke width. + Square, + /// Rounded cap with radius equal to half the stroke width. + Round, +} + +/// Describes the visual style of a stroke. +#[derive(Clone, Debug)] +pub struct Stroke { + /// Width of the stroke. + pub width: f64, + /// Style for connecting segments of the stroke. + pub join: Join, + /// Limit for miter joins. + pub miter_limit: f64, + /// Style for capping the beginning of an open subpath. + pub start_cap: Cap, + /// Style for capping the end of an open subpath. + pub end_cap: Cap, + /// Lengths of dashes in alternating on/off order. + pub dash_pattern: Dashes, + /// Offset of the first dash. + pub dash_offset: f64, + /// True if the stroke width should be affected by the scale of a + /// transform. + /// + /// Discussion question: does this make sense here? + pub scale: bool, +} + +impl Default for Stroke { + fn default() -> Self { + Self { + width: 1.0, + join: Join::Round, + miter_limit: 4.0, + start_cap: Cap::Round, + end_cap: Cap::Round, + dash_pattern: Default::default(), + dash_offset: 0.0, + scale: true, + } + } +} + +impl Stroke { + /// Creates a new stroke with the specified width. + pub fn new(width: f64) -> Self { + Self { + width, + ..Default::default() + } + } + + /// Builder method for setting the join style. + pub fn with_join(mut self, join: Join) -> Self { + self.join = join; + self + } + + /// Builder method for setting the limit for miter joins. + pub fn with_miter_limit(mut self, limit: f64) -> Self { + self.miter_limit = limit; + self + } + + /// Builder method for setting the cap style for the start of the stroke. + pub fn with_start_cap(mut self, cap: Cap) -> Self { + self.start_cap = cap; + self + } + + /// Builder method for setting the cap style for the end of the stroke. + pub fn with_end_cap(mut self, cap: Cap) -> Self { + self.end_cap = cap; + self + } + + /// Builder method for setting the cap style. + pub fn with_caps(mut self, cap: Cap) -> Self { + self.start_cap = cap; + self.end_cap = cap; + self + } + + /// Builder method for setting the dashing parameters. + pub fn with_dashes

(mut self, offset: f64, pattern: P) -> Self + where + P: IntoIterator, + P::Item: Borrow, + { + self.dash_offset = offset; + self.dash_pattern.clear(); + self.dash_pattern + .extend(pattern.into_iter().map(|dash| *dash.borrow())); + self + } + + /// Builder method for setting whether or not the stroke should be affected + /// by the scale of any applied transform. + pub fn with_scale(mut self, yes: bool) -> Self { + self.scale = yes; + self + } +} + +/// Collection of values representing lengths in a dash pattern. +pub type Dashes = SmallVec<[f64; 4]>; + +/// Internal structure used for creating strokes. +#[derive(Default)] +struct StrokeCtx { + // Probably don't need both output and forward, can just concat + output: BezPath, + forward_path: BezPath, + backward_path: BezPath, + last_pt: Point, + last_tan: Vec2, +} + +/// Expand a stroke into a fill. +pub fn stroke( + path: impl IntoIterator, + style: &Stroke, + tolerance: f64, +) -> BezPath { + let mut ctx = StrokeCtx::default(); + for el in path { + let p0 = ctx.last_pt; + match el { + PathEl::MoveTo(p) => { + ctx.finish(); + ctx.last_pt = p; + } + PathEl::LineTo(p1) => { + if p1 != ctx.last_pt { + let tangent = p1 - p0; + ctx.do_tangents(style, tangent, tangent, p1); + ctx.do_line(style, tangent, p1); + } + } + PathEl::QuadTo(p1, p2) => { + if p1 != p0 && p2 != p0 { + let q = QuadBez::new(p0, p1, p2); + let (tan0, tan1) = PathSeg::Quad(q).tangents(); + ctx.do_tangents(style, tan0, tan1, p2); + ctx.do_cubic(style, q.raise(), tolerance); + } + } + PathEl::CurveTo(p1, p2, p3) => { + if p1 != p0 && p2 != p0 && p3 != p0 { + let c = CubicBez::new(p0, p1, p2, p3); + let (tan0, tan1) = PathSeg::Cubic(c).tangents(); + ctx.do_tangents(style, tan0, tan1, p3); + ctx.do_cubic(style, c, tolerance); + } + } + _ => todo!(), + } + } + todo!() +} + +fn get_end(el: &PathEl) -> Point { + match el { + PathEl::MoveTo(p) => *p, + PathEl::LineTo(p1) => *p1, + PathEl::QuadTo(_, p2) => *p2, + PathEl::CurveTo(_, _, p3) => *p3, + _ => unreachable!(), + } +} + +impl StrokeCtx { + /// Append forward and backward paths to output. + fn finish(&mut self) { + if self.forward_path.is_empty() { + return; + } + self.output.extend(&self.forward_path); + let back_els = self.backward_path.elements(); + // TODO: this is "butt" end, but we want to do other styles + self.output.line_to(get_end(&back_els[back_els.len() - 1])); + for i in (1..back_els.len()).rev() { + let end = get_end(&back_els[i - 1]); + match back_els[i] { + PathEl::LineTo(_) => self.output.line_to(end), + PathEl::QuadTo(p1, _) => self.output.quad_to(p1, end), + PathEl::CurveTo(p1, p2, _) => self.output.curve_to(p2, p1, end), + _ => unreachable!(), + } + } + // Same, this is butt end + self.output.close_path(); + + self.forward_path.truncate(0); + self.backward_path.truncate(0); + } + + fn do_tangents(&mut self, style: &Stroke, tan0: Vec2, tan1: Vec2, p1: Point) { + let scale = 0.5 * style.width / tan0.hypot(); + let norm = scale * Vec2::new(-tan0.y, tan0.x); + if self.forward_path.is_empty() { + self.forward_path.move_to(p1 + norm); + self.backward_path.move_to(p1 - norm); + } else { + // TODO: this represents miter joins, handle other styles + self.forward_path.line_to(p1 + norm); + self.backward_path.line_to(p1 - norm); + } + self.last_tan = tan1; + } + + fn do_line(&mut self, style: &Stroke, tangent: Vec2, p1: Point) { + let scale = 0.5 * style.width / tangent.hypot(); + let norm = scale * Vec2::new(-tangent.y, tangent.x); + self.forward_path.line_to(p1 + norm); + self.backward_path.line_to(p1 - norm); + self.last_pt = p1; + } + + fn do_cubic(&mut self, style: &Stroke, c: CubicBez, tolerance: f64) { + let co = CubicOffset::new(c, 0.5 * style.width); + let forward = fit_to_bezpath(&co, tolerance); + self.forward_path.extend(forward.into_iter().skip(1)); + let co = CubicOffset::new(c, -0.5 * style.width); + let backward = fit_to_bezpath(&co, tolerance); + self.backward_path.extend(backward.into_iter().skip(1)); + self.last_pt = c.p3; + } +} From 7d044fab871df7072d296d4a02ffdbfdb35bf058 Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Sat, 20 May 2023 07:21:24 -0700 Subject: [PATCH 2/8] Rustfmt and fix no-std build --- src/lib.rs | 2 +- src/stroke.rs | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 3c09b4ab..e4f4676e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -131,8 +131,8 @@ pub use crate::rounded_rect::*; pub use crate::rounded_rect_radii::*; pub use crate::shape::*; pub use crate::size::*; -#[cfg(feature = "std")] pub use crate::stroke::*; +#[cfg(feature = "std")] pub use crate::svg::*; pub use crate::translate_scale::*; pub use crate::vec2::*; diff --git a/src/stroke.rs b/src/stroke.rs index 657982bf..49a15a27 100644 --- a/src/stroke.rs +++ b/src/stroke.rs @@ -5,7 +5,9 @@ use core::borrow::Borrow; use smallvec::SmallVec; -use crate::{PathEl, BezPath, Point, Vec2, PathSeg, CubicBez, offset::CubicOffset, fit_to_bezpath, QuadBez}; +use crate::{ + fit_to_bezpath, offset::CubicOffset, BezPath, CubicBez, PathEl, PathSeg, Point, QuadBez, Vec2, +}; /// Defines the connection between two segments of a stroke. #[derive(Copy, Clone, PartialEq, Eq, Debug)] @@ -144,11 +146,7 @@ struct StrokeCtx { } /// Expand a stroke into a fill. -pub fn stroke( - path: impl IntoIterator, - style: &Stroke, - tolerance: f64, -) -> BezPath { +pub fn stroke(path: impl IntoIterator, style: &Stroke, tolerance: f64) -> BezPath { let mut ctx = StrokeCtx::default(); for el in path { let p0 = ctx.last_pt; From 6674355aa48635dccda3d406e9b354a3917187f5 Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Wed, 24 May 2023 17:57:19 -0700 Subject: [PATCH 3/8] Refine stroke implementation. Fix some todo() items. Reverse forward and backward so area is positive. Implement line caps and joins. Make end_point a public method of `PathEl`, as suggested. Dashes remain, as well as closed paths. --- src/bezpath.rs | 11 ++++ src/stroke.rs | 146 ++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 126 insertions(+), 31 deletions(-) diff --git a/src/bezpath.rs b/src/bezpath.rs index 7008bb1e..ab2f03cd 100644 --- a/src/bezpath.rs +++ b/src/bezpath.rs @@ -1250,6 +1250,17 @@ impl PathEl { PathEl::ClosePath => false, } } + + /// Get the end point of the path element, if it exists. + pub fn end_point(&self) -> Option { + match self { + PathEl::MoveTo(p) => Some(*p), + PathEl::LineTo(p1) => Some(*p1), + PathEl::QuadTo(_, p2) => Some(*p2), + PathEl::CurveTo(_, _, p3) => Some(*p3), + _ => None, + } + } } /// Implements [`Shape`] for a slice of [`PathEl`], provided that the first element of the slice is diff --git a/src/stroke.rs b/src/stroke.rs index 49a15a27..1c0ce9ef 100644 --- a/src/stroke.rs +++ b/src/stroke.rs @@ -1,12 +1,16 @@ // Copyright 2023 the Kurbo Authors // SPDX-License-Identifier: Apache-2.0 OR MIT -use core::borrow::Borrow; +use core::{borrow::Borrow, f64::consts::PI}; use smallvec::SmallVec; +#[cfg(not(feature = "std"))] +use crate::common::FloatFuncs; + use crate::{ - fit_to_bezpath, offset::CubicOffset, BezPath, CubicBez, PathEl, PathSeg, Point, QuadBez, Vec2, + fit_to_bezpath, offset::CubicOffset, Affine, Arc, BezPath, CubicBez, PathEl, PathSeg, Point, + QuadBez, Vec2, }; /// Defines the connection between two segments of a stroke. @@ -141,6 +145,8 @@ struct StrokeCtx { output: BezPath, forward_path: BezPath, backward_path: BezPath, + start_pt: Point, + start_norm: Vec2, last_pt: Point, last_tan: Vec2, } @@ -152,13 +158,14 @@ pub fn stroke(path: impl IntoIterator, style: &Stroke, tolerance: let p0 = ctx.last_pt; match el { PathEl::MoveTo(p) => { - ctx.finish(); + ctx.finish(style); + ctx.start_pt = p; ctx.last_pt = p; } PathEl::LineTo(p1) => { - if p1 != ctx.last_pt { + if p1 != p0 { let tangent = p1 - p0; - ctx.do_tangents(style, tangent, tangent, p1); + ctx.do_tangents(style, tangent, tangent); ctx.do_line(style, tangent, p1); } } @@ -166,7 +173,7 @@ pub fn stroke(path: impl IntoIterator, style: &Stroke, tolerance: if p1 != p0 && p2 != p0 { let q = QuadBez::new(p0, p1, p2); let (tan0, tan1) = PathSeg::Quad(q).tangents(); - ctx.do_tangents(style, tan0, tan1, p2); + ctx.do_tangents(style, tan0, tan1); ctx.do_cubic(style, q.raise(), tolerance); } } @@ -174,38 +181,63 @@ pub fn stroke(path: impl IntoIterator, style: &Stroke, tolerance: if p1 != p0 && p2 != p0 && p3 != p0 { let c = CubicBez::new(p0, p1, p2, p3); let (tan0, tan1) = PathSeg::Cubic(c).tangents(); - ctx.do_tangents(style, tan0, tan1, p3); + ctx.do_tangents(style, tan0, tan1); ctx.do_cubic(style, c, tolerance); } } _ => todo!(), } } - todo!() + ctx.finish(style); + ctx.output +} + +fn round_cap(out: &mut BezPath, tolerance: f64, center: Point, norm: Vec2) { + round_join(out, tolerance, center, norm, PI); +} + +fn round_join(out: &mut BezPath, tolerance: f64, center: Point, norm: Vec2, angle: f64) { + let a = Affine::new([norm.x, norm.y, -norm.y, norm.x, center.x, center.y]); + let arc = Arc::new(Point::ORIGIN, (1.0, 1.0), PI - angle, angle, 0.0); + arc.to_cubic_beziers(tolerance, |p1, p2, p3| out.curve_to(a * p1, a * p2, a * p3)); } -fn get_end(el: &PathEl) -> Point { - match el { - PathEl::MoveTo(p) => *p, - PathEl::LineTo(p1) => *p1, - PathEl::QuadTo(_, p2) => *p2, - PathEl::CurveTo(_, _, p3) => *p3, - _ => unreachable!(), +fn round_join_rev(out: &mut BezPath, tolerance: f64, center: Point, norm: Vec2, angle: f64) { + let a = Affine::new([norm.x, norm.y, norm.y, -norm.x, center.x, center.y]); + let arc = Arc::new(Point::ORIGIN, (1.0, 1.0), PI - angle, angle, 0.0); + arc.to_cubic_beziers(tolerance, |p1, p2, p3| out.curve_to(a * p1, a * p2, a * p3)); +} + +fn square_cap(out: &mut BezPath, close: bool, center: Point, norm: Vec2) { + let a = Affine::new([norm.x, norm.y, -norm.y, norm.x, center.x, center.y]); + out.line_to(a * Point::new(1.0, 1.0)); + out.line_to(a * Point::new(-1.0, 1.0)); + if close { + out.close_path(); + } else { + out.line_to(a * Point::new(-1.0, 0.0)); } } impl StrokeCtx { /// Append forward and backward paths to output. - fn finish(&mut self) { + fn finish(&mut self, style: &Stroke) { + // TODO: scale + let tolerance = 1e-3; if self.forward_path.is_empty() { return; } self.output.extend(&self.forward_path); let back_els = self.backward_path.elements(); - // TODO: this is "butt" end, but we want to do other styles - self.output.line_to(get_end(&back_els[back_els.len() - 1])); + let return_p = back_els[back_els.len() - 1].end_point().unwrap(); + let d = self.last_pt - return_p; + match style.end_cap { + Cap::Butt => self.output.line_to(return_p), + Cap::Round => round_cap(&mut self.output, tolerance, self.last_pt, d), + Cap::Square => square_cap(&mut self.output, false, self.last_pt, d), + } for i in (1..back_els.len()).rev() { - let end = get_end(&back_els[i - 1]); + let end = back_els[i - 1].end_point().unwrap(); match back_els[i] { PathEl::LineTo(_) => self.output.line_to(end), PathEl::QuadTo(p1, _) => self.output.quad_to(p1, end), @@ -213,23 +245,75 @@ impl StrokeCtx { _ => unreachable!(), } } - // Same, this is butt end - self.output.close_path(); + match style.start_cap { + Cap::Butt => self.output.close_path(), + Cap::Round => round_cap(&mut self.output, tolerance, self.start_pt, self.start_norm), + Cap::Square => square_cap(&mut self.output, true, self.start_pt, self.start_norm), + } self.forward_path.truncate(0); self.backward_path.truncate(0); } - fn do_tangents(&mut self, style: &Stroke, tan0: Vec2, tan1: Vec2, p1: Point) { + fn do_tangents(&mut self, style: &Stroke, tan0: Vec2, tan1: Vec2) { + // TODO: scale + let tolerance = 1e-3; let scale = 0.5 * style.width / tan0.hypot(); let norm = scale * Vec2::new(-tan0.y, tan0.x); + let p0 = self.last_pt; if self.forward_path.is_empty() { - self.forward_path.move_to(p1 + norm); - self.backward_path.move_to(p1 - norm); + self.forward_path.move_to(p0 - norm); + self.backward_path.move_to(p0 + norm); + self.start_norm = norm; } else { - // TODO: this represents miter joins, handle other styles - self.forward_path.line_to(p1 + norm); - self.backward_path.line_to(p1 - norm); + // TODO: suppress if G1 continuous + match style.join { + Join::Bevel => { + self.forward_path.line_to(p0 - norm); + self.backward_path.line_to(p0 + norm); + } + Join::Miter => { + let ab = self.last_tan; + let cd = tan0; + let cross = ab.cross(cd); + let dot = ab.dot(cd); + let hypot = cross.hypot(dot); + if 2.0 * hypot < (hypot + dot) * style.miter_limit.powi(2) { + // TODO: maybe better to store last_norm or derive from path? + let last_scale = 0.5 * style.width / ab.hypot(); + let last_norm = last_scale * Vec2::new(-ab.y, ab.x); + if cross > 0.0 { + let fp_last = p0 - last_norm; + let fp_this = p0 - norm; + let h = ab.cross(fp_this - fp_last) / cross; + let miter_pt = fp_this - cd * h; + self.forward_path.line_to(miter_pt); + } else if cross < 0.0 { + let fp_last = p0 + last_norm; + let fp_this = p0 + norm; + let h = ab.cross(fp_this - fp_last) / cross; + let miter_pt = fp_this - cd * h; + self.backward_path.line_to(miter_pt); + } + } + self.forward_path.line_to(p0 - norm); + self.backward_path.line_to(p0 + norm); + } + Join::Round => { + let ab = self.last_tan; + let cd = tan0; + let cross = ab.cross(cd); + let dot = ab.dot(cd); + let angle = cross.atan2(dot); + if cross > 0.0 { + self.backward_path.line_to(p0 + norm); + round_join(&mut self.forward_path, tolerance, p0, norm, angle); + } else { + self.forward_path.line_to(p0 - norm); + round_join_rev(&mut self.backward_path, tolerance, p0, -norm, -angle); + } + } + } } self.last_tan = tan1; } @@ -237,16 +321,16 @@ impl StrokeCtx { fn do_line(&mut self, style: &Stroke, tangent: Vec2, p1: Point) { let scale = 0.5 * style.width / tangent.hypot(); let norm = scale * Vec2::new(-tangent.y, tangent.x); - self.forward_path.line_to(p1 + norm); - self.backward_path.line_to(p1 - norm); + self.forward_path.line_to(p1 - norm); + self.backward_path.line_to(p1 + norm); self.last_pt = p1; } fn do_cubic(&mut self, style: &Stroke, c: CubicBez, tolerance: f64) { - let co = CubicOffset::new(c, 0.5 * style.width); + let co = CubicOffset::new(c, -0.5 * style.width); let forward = fit_to_bezpath(&co, tolerance); self.forward_path.extend(forward.into_iter().skip(1)); - let co = CubicOffset::new(c, -0.5 * style.width); + let co = CubicOffset::new(c, 0.5 * style.width); let backward = fit_to_bezpath(&co, tolerance); self.backward_path.extend(backward.into_iter().skip(1)); self.last_pt = c.p3; From be41b0d9daf3574e42531015c82f86187ed39733 Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Tue, 30 May 2023 13:05:25 -0700 Subject: [PATCH 4/8] Implement closed subpaths Handle stroking of closed subpaths. This generates two closed subpaths as output, one for the inner contour, one for the outer. Also apply a threshold for joins. --- src/stroke.rs | 147 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 89 insertions(+), 58 deletions(-) diff --git a/src/stroke.rs b/src/stroke.rs index 1c0ce9ef..f9ee99cf 100644 --- a/src/stroke.rs +++ b/src/stroke.rs @@ -147,13 +147,17 @@ struct StrokeCtx { backward_path: BezPath, start_pt: Point, start_norm: Vec2, + start_tan: Vec2, last_pt: Point, last_tan: Vec2, + // if hypot < (hypot + dot) * bend_thresh, omit join altogether + join_thresh: f64, } /// Expand a stroke into a fill. pub fn stroke(path: impl IntoIterator, style: &Stroke, tolerance: f64) -> BezPath { let mut ctx = StrokeCtx::default(); + ctx.join_thresh = 2.0 * tolerance / style.width; for el in path { let p0 = ctx.last_pt; match el { @@ -165,7 +169,8 @@ pub fn stroke(path: impl IntoIterator, style: &Stroke, tolerance: PathEl::LineTo(p1) => { if p1 != p0 { let tangent = p1 - p0; - ctx.do_tangents(style, tangent, tangent); + ctx.do_join(style, tangent); + ctx.last_tan = tangent; ctx.do_line(style, tangent, p1); } } @@ -173,7 +178,8 @@ pub fn stroke(path: impl IntoIterator, style: &Stroke, tolerance: if p1 != p0 && p2 != p0 { let q = QuadBez::new(p0, p1, p2); let (tan0, tan1) = PathSeg::Quad(q).tangents(); - ctx.do_tangents(style, tan0, tan1); + ctx.do_join(style, tan0); + ctx.last_tan = tan1; ctx.do_cubic(style, q.raise(), tolerance); } } @@ -181,11 +187,20 @@ pub fn stroke(path: impl IntoIterator, style: &Stroke, tolerance: if p1 != p0 && p2 != p0 && p3 != p0 { let c = CubicBez::new(p0, p1, p2, p3); let (tan0, tan1) = PathSeg::Cubic(c).tangents(); - ctx.do_tangents(style, tan0, tan1); + ctx.do_join(style, tan0); + ctx.last_tan = tan1; ctx.do_cubic(style, c, tolerance); } } - _ => todo!(), + PathEl::ClosePath => { + if p0 != ctx.start_pt { + let tangent = ctx.start_pt - p0; + ctx.do_join(style, tangent); + ctx.last_tan = tangent; + ctx.do_line(style, tangent, ctx.start_pt); + } + ctx.finish_closed(style); + } } } ctx.finish(style); @@ -219,6 +234,18 @@ fn square_cap(out: &mut BezPath, close: bool, center: Point, norm: Vec2) { } } +fn extend_reversed(out: &mut BezPath, elements: &[PathEl]) { + for i in (1..elements.len()).rev() { + let end = elements[i - 1].end_point().unwrap(); + match elements[i] { + PathEl::LineTo(_) => out.line_to(end), + PathEl::QuadTo(p1, _) => out.quad_to(p1, end), + PathEl::CurveTo(p1, p2, _) => out.curve_to(p2, p1, end), + _ => unreachable!(), + } + } +} + impl StrokeCtx { /// Append forward and backward paths to output. fn finish(&mut self, style: &Stroke) { @@ -236,15 +263,7 @@ impl StrokeCtx { Cap::Round => round_cap(&mut self.output, tolerance, self.last_pt, d), Cap::Square => square_cap(&mut self.output, false, self.last_pt, d), } - for i in (1..back_els.len()).rev() { - let end = back_els[i - 1].end_point().unwrap(); - match back_els[i] { - PathEl::LineTo(_) => self.output.line_to(end), - PathEl::QuadTo(p1, _) => self.output.quad_to(p1, end), - PathEl::CurveTo(p1, p2, _) => self.output.curve_to(p2, p1, end), - _ => unreachable!(), - } - } + extend_reversed(&mut self.output, back_els); match style.start_cap { Cap::Butt => self.output.close_path(), Cap::Round => round_cap(&mut self.output, tolerance, self.start_pt, self.start_norm), @@ -255,7 +274,21 @@ impl StrokeCtx { self.backward_path.truncate(0); } - fn do_tangents(&mut self, style: &Stroke, tan0: Vec2, tan1: Vec2) { + /// Finish a closed path + fn finish_closed(&mut self, style: &Stroke) { + self.do_join(style, self.start_tan); + self.output.extend(&self.forward_path); + self.output.close_path(); + let back_els = self.backward_path.elements(); + let last_pt = back_els[back_els.len() - 1].end_point().unwrap(); + self.output.move_to(last_pt); + extend_reversed(&mut self.output, back_els); + self.output.close_path(); + self.forward_path.truncate(0); + self.backward_path.truncate(0); + } + + fn do_join(&mut self, style: &Stroke, tan0: Vec2) { // TODO: scale let tolerance = 1e-3; let scale = 0.5 * style.width / tan0.hypot(); @@ -264,58 +297,56 @@ impl StrokeCtx { if self.forward_path.is_empty() { self.forward_path.move_to(p0 - norm); self.backward_path.move_to(p0 + norm); + self.start_tan = tan0; self.start_norm = norm; } else { - // TODO: suppress if G1 continuous - match style.join { - Join::Bevel => { - self.forward_path.line_to(p0 - norm); - self.backward_path.line_to(p0 + norm); - } - Join::Miter => { - let ab = self.last_tan; - let cd = tan0; - let cross = ab.cross(cd); - let dot = ab.dot(cd); - let hypot = cross.hypot(dot); - if 2.0 * hypot < (hypot + dot) * style.miter_limit.powi(2) { - // TODO: maybe better to store last_norm or derive from path? - let last_scale = 0.5 * style.width / ab.hypot(); - let last_norm = last_scale * Vec2::new(-ab.y, ab.x); - if cross > 0.0 { - let fp_last = p0 - last_norm; - let fp_this = p0 - norm; - let h = ab.cross(fp_this - fp_last) / cross; - let miter_pt = fp_this - cd * h; - self.forward_path.line_to(miter_pt); - } else if cross < 0.0 { - let fp_last = p0 + last_norm; - let fp_this = p0 + norm; - let h = ab.cross(fp_this - fp_last) / cross; - let miter_pt = fp_this - cd * h; - self.backward_path.line_to(miter_pt); - } - } - self.forward_path.line_to(p0 - norm); - self.backward_path.line_to(p0 + norm); - } - Join::Round => { - let ab = self.last_tan; - let cd = tan0; - let cross = ab.cross(cd); - let dot = ab.dot(cd); - let angle = cross.atan2(dot); - if cross > 0.0 { + let ab = self.last_tan; + let cd = tan0; + let cross = ab.cross(cd); + let dot = ab.dot(cd); + let hypot = cross.hypot(dot); + // possible TODO: a minor speedup could be squaring both sides + if cross.abs() >= hypot * self.join_thresh { + match style.join { + Join::Bevel => { + self.forward_path.line_to(p0 - norm); self.backward_path.line_to(p0 + norm); - round_join(&mut self.forward_path, tolerance, p0, norm, angle); - } else { + } + Join::Miter => { + if 2.0 * hypot < (hypot + dot) * style.miter_limit.powi(2) { + // TODO: maybe better to store last_norm or derive from path? + let last_scale = 0.5 * style.width / ab.hypot(); + let last_norm = last_scale * Vec2::new(-ab.y, ab.x); + if cross > 0.0 { + let fp_last = p0 - last_norm; + let fp_this = p0 - norm; + let h = ab.cross(fp_this - fp_last) / cross; + let miter_pt = fp_this - cd * h; + self.forward_path.line_to(miter_pt); + } else if cross < 0.0 { + let fp_last = p0 + last_norm; + let fp_this = p0 + norm; + let h = ab.cross(fp_this - fp_last) / cross; + let miter_pt = fp_this - cd * h; + self.backward_path.line_to(miter_pt); + } + } self.forward_path.line_to(p0 - norm); - round_join_rev(&mut self.backward_path, tolerance, p0, -norm, -angle); + self.backward_path.line_to(p0 + norm); + } + Join::Round => { + let angle = cross.atan2(dot); + if cross > 0.0 { + self.backward_path.line_to(p0 + norm); + round_join(&mut self.forward_path, tolerance, p0, norm, angle); + } else { + self.forward_path.line_to(p0 - norm); + round_join_rev(&mut self.backward_path, tolerance, p0, -norm, -angle); + } } } } } - self.last_tan = tan1; } fn do_line(&mut self, style: &Stroke, tangent: Vec2, p1: Point) { From af22c87503dfe6480f7cf3099f96cbf2f854b9f4 Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Fri, 26 May 2023 17:21:44 -0700 Subject: [PATCH 5/8] Add dashing This should handle closed paths and phase, but do need to validate. --- src/stroke.rs | 269 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 265 insertions(+), 4 deletions(-) diff --git a/src/stroke.rs b/src/stroke.rs index f9ee99cf..8eb83adb 100644 --- a/src/stroke.rs +++ b/src/stroke.rs @@ -3,14 +3,16 @@ use core::{borrow::Borrow, f64::consts::PI}; +use alloc::vec::Vec; + use smallvec::SmallVec; #[cfg(not(feature = "std"))] use crate::common::FloatFuncs; use crate::{ - fit_to_bezpath, offset::CubicOffset, Affine, Arc, BezPath, CubicBez, PathEl, PathSeg, Point, - QuadBez, Vec2, + fit_to_bezpath, offset::CubicOffset, Affine, Arc, BezPath, CubicBez, Line, ParamCurve, + ParamCurveArclen, PathEl, PathSeg, Point, QuadBez, Vec2, }; /// Defines the connection between two segments of a stroke. @@ -156,8 +158,24 @@ struct StrokeCtx { /// Expand a stroke into a fill. pub fn stroke(path: impl IntoIterator, style: &Stroke, tolerance: f64) -> BezPath { - let mut ctx = StrokeCtx::default(); - ctx.join_thresh = 2.0 * tolerance / style.width; + if style.dash_pattern.is_empty() { + stroke_undashed(path, style, tolerance) + } else { + let dashed = DashIterator::new(path.into_iter(), style.dash_offset, &style.dash_pattern); + stroke_undashed(dashed, style, tolerance) + } +} + +/// Version of stroke expansion for styles with no dashes. +fn stroke_undashed( + path: impl IntoIterator, + style: &Stroke, + tolerance: f64, +) -> BezPath { + let mut ctx = StrokeCtx { + join_thresh: 2.0 * tolerance / style.width, + ..Default::default() + }; for el in path { let p0 = ctx.last_pt; match el { @@ -367,3 +385,246 @@ impl StrokeCtx { self.last_pt = c.p3; } } + +/// Iterator for dashing. +pub struct DashIterator<'a, T> { + inner: T, + input_done: bool, + closepath_pending: bool, + dashes: &'a [f64], + dash_ix: usize, + init_dash_ix: usize, + init_dash_remaining: f64, + init_is_active: bool, + is_active: bool, + state: DashState, + current_seg: PathSeg, + t: f64, + dash_remaining: f64, + seg_remaining: f64, + start_pt: Point, + last_pt: Point, + stash: Vec, + stash_ix: usize, +} + +#[derive(PartialEq, Eq)] +enum DashState { + NeedInput, + ToStash, + Working, + FromStash, +} + +impl<'a, T: Iterator> Iterator for DashIterator<'a, T> { + type Item = PathEl; + + fn next(&mut self) -> Option { + loop { + match self.state { + DashState::NeedInput => { + if self.input_done { + return None; + } + self.get_input(); + if self.input_done { + return None; + } + self.state = DashState::ToStash; + } + DashState::ToStash => { + if let Some(el) = self.step() { + self.stash.push(el); + } + } + DashState::Working => { + if let Some(el) = self.step() { + return Some(el); + } + } + DashState::FromStash => { + if let Some(el) = self.stash.get(self.stash_ix) { + self.stash_ix += 1; + return Some(*el); + } else { + self.stash.clear(); + self.stash_ix = 0; + if self.input_done { + return None; + } + if self.closepath_pending { + self.closepath_pending = false; + self.state = DashState::NeedInput; + } else { + self.state = DashState::ToStash; + } + } + } + } + } + } +} + +fn seg_to_el(el: &PathSeg) -> PathEl { + match el { + PathSeg::Line(l) => PathEl::LineTo(l.p1), + PathSeg::Quad(q) => PathEl::QuadTo(q.p1, q.p2), + PathSeg::Cubic(c) => PathEl::CurveTo(c.p1, c.p2, c.p3), + } +} + +const DASH_ACCURACY: f64 = 1e-6; + +impl<'a, T: Iterator> DashIterator<'a, T> { + /// Create a new dashing iterator. + pub fn new(inner: T, dash_offset: f64, dashes: &'a [f64]) -> Self { + let mut dash_ix = 0; + let mut dash_remaining = dash_offset; + let mut is_active = true; + // Find place in dashes array for initial offset. + while dash_remaining > 0.0 { + let dash_len = dashes[dash_ix]; + if dash_remaining < dash_len { + break; + } + dash_remaining -= dash_len; + dash_ix = (dash_ix + 1) % dashes.len(); + is_active = !is_active; + } + DashIterator { + inner, + input_done: false, + closepath_pending: false, + dashes, + dash_ix, + init_dash_ix: dash_ix, + init_dash_remaining: dash_remaining, + init_is_active: is_active, + is_active, + state: DashState::NeedInput, + current_seg: PathSeg::Line(Line::new(Point::ORIGIN, Point::ORIGIN)), + t: 0.0, + dash_remaining, + seg_remaining: 0.0, + start_pt: Point::ORIGIN, + last_pt: Point::ORIGIN, + stash: Vec::new(), + stash_ix: 0, + } + } + + fn get_input(&mut self) { + loop { + if self.closepath_pending { + self.handle_closepath(); + break; + } + let Some(next_el) = self.inner.next() else { + self.input_done = true; + self.state = DashState::FromStash; + return; + }; + let p0 = self.last_pt; + match next_el { + PathEl::MoveTo(p) => { + if !self.stash.is_empty() { + self.state = DashState::FromStash; + } + self.start_pt = p; + self.last_pt = p; + self.reset_phase(); + continue; + } + PathEl::LineTo(p1) => { + let l = Line::new(p0, p1); + self.seg_remaining = l.arclen(DASH_ACCURACY); + self.current_seg = PathSeg::Line(l); + self.last_pt = p1; + } + PathEl::QuadTo(p1, p2) => { + let q = QuadBez::new(p0, p1, p2); + self.seg_remaining = q.arclen(DASH_ACCURACY); + self.current_seg = PathSeg::Quad(q); + self.last_pt = p2; + } + PathEl::CurveTo(p1, p2, p3) => { + let c = CubicBez::new(p0, p1, p2, p3); + self.seg_remaining = c.arclen(DASH_ACCURACY); + self.current_seg = PathSeg::Cubic(c); + self.last_pt = p3; + } + PathEl::ClosePath => { + self.closepath_pending = true; + if p0 != self.start_pt { + let l = Line::new(p0, self.start_pt); + self.seg_remaining = l.arclen(DASH_ACCURACY); + self.current_seg = PathSeg::Line(l); + self.last_pt = self.start_pt; + } else { + self.handle_closepath(); + } + } + } + break; + } + self.t = 0.0; + } + + /// Move arc length forward to next event. + fn step(&mut self) -> Option { + let mut result = None; + if self.state == DashState::ToStash && self.stash.is_empty() { + if self.is_active { + result = Some(PathEl::MoveTo(self.current_seg.start())); + } else { + self.state = DashState::Working; + } + } else if self.dash_remaining < self.seg_remaining { + // next transition is a dash transition + let seg = self.current_seg.subsegment(self.t..1.0); + let t1 = seg.inv_arclen(self.dash_remaining, DASH_ACCURACY); + if self.is_active { + let subseg = seg.subsegment(0.0..t1); + result = Some(seg_to_el(&subseg)); + self.state = DashState::Working; + } else { + let p = seg.eval(t1); + result = Some(PathEl::MoveTo(p)); + } + self.is_active = !self.is_active; + self.t += t1 * (1.0 - self.t); + self.seg_remaining -= self.dash_remaining; + self.dash_ix += 1; + if self.dash_ix == self.dashes.len() { + self.dash_ix = 0; + } + self.dash_remaining = self.dashes[self.dash_ix]; + } else { + if self.is_active { + let seg = self.current_seg.subsegment(self.t..1.0); + result = Some(seg_to_el(&seg)); + } + self.dash_remaining -= self.seg_remaining; + self.get_input(); + } + result + } + + fn handle_closepath(&mut self) { + if self.state == DashState::ToStash { + // Have looped back without breaking a dash, just play it back + self.stash.push(PathEl::ClosePath); + } else if self.is_active { + // connect with path in stash, skip MoveTo. + self.stash_ix = 1; + } + self.state = DashState::FromStash; + self.reset_phase(); + } + + fn reset_phase(&mut self) { + self.dash_ix = self.init_dash_ix; + self.dash_remaining = self.init_dash_remaining; + self.is_active = self.init_is_active; + } +} From 6e1b2bb99b610eabc2314c1ea8c88f4f0ca2e293 Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Mon, 5 Jun 2023 16:04:02 -0700 Subject: [PATCH 6/8] Add optimization options to strokes Add an additional argument to stroke expansion. It's designed so it can be Default::default() in the common case, and uses the builder pattern to add more options as needed without breaking semver. --- src/stroke.rs | 61 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/src/stroke.rs b/src/stroke.rs index 8eb83adb..c06e7165 100644 --- a/src/stroke.rs +++ b/src/stroke.rs @@ -11,8 +11,8 @@ use smallvec::SmallVec; use crate::common::FloatFuncs; use crate::{ - fit_to_bezpath, offset::CubicOffset, Affine, Arc, BezPath, CubicBez, Line, ParamCurve, - ParamCurveArclen, PathEl, PathSeg, Point, QuadBez, Vec2, + fit_to_bezpath, fit_to_bezpath_opt, offset::CubicOffset, Affine, Arc, BezPath, CubicBez, Line, + ParamCurve, ParamCurveArclen, PathEl, PathSeg, Point, QuadBez, Vec2, }; /// Defines the connection between two segments of a stroke. @@ -61,6 +61,26 @@ pub struct Stroke { pub scale: bool, } +/// Options for path stroking. +pub struct StrokeOpts { + opt_level: StrokeOptLevel, +} + +/// Optimization level for computing +pub enum StrokeOptLevel { + /// Adaptively subdivide segments in half. + Subdivide, + /// Compute optimized subdivision points to minimize error. + Optimized, +} + +impl Default for StrokeOpts { + fn default() -> Self { + let opt_level = StrokeOptLevel::Subdivide; + StrokeOpts { opt_level } + } +} + impl Default for Stroke { fn default() -> Self { Self { @@ -137,6 +157,14 @@ impl Stroke { } } +impl StrokeOpts { + /// Set optimization level for computing stroke outlines. + pub fn opt_level(mut self, opt_level: StrokeOptLevel) -> Self { + self.opt_level = opt_level; + self + } +} + /// Collection of values representing lengths in a dash pattern. pub type Dashes = SmallVec<[f64; 4]>; @@ -157,12 +185,17 @@ struct StrokeCtx { } /// Expand a stroke into a fill. -pub fn stroke(path: impl IntoIterator, style: &Stroke, tolerance: f64) -> BezPath { +pub fn stroke( + path: impl IntoIterator, + style: &Stroke, + opts: &StrokeOpts, + tolerance: f64, +) -> BezPath { if style.dash_pattern.is_empty() { - stroke_undashed(path, style, tolerance) + stroke_undashed(path, style, tolerance, opts) } else { let dashed = DashIterator::new(path.into_iter(), style.dash_offset, &style.dash_pattern); - stroke_undashed(dashed, style, tolerance) + stroke_undashed(dashed, style, tolerance, opts) } } @@ -171,6 +204,7 @@ fn stroke_undashed( path: impl IntoIterator, style: &Stroke, tolerance: f64, + opts: &StrokeOpts, ) -> BezPath { let mut ctx = StrokeCtx { join_thresh: 2.0 * tolerance / style.width, @@ -198,7 +232,7 @@ fn stroke_undashed( let (tan0, tan1) = PathSeg::Quad(q).tangents(); ctx.do_join(style, tan0); ctx.last_tan = tan1; - ctx.do_cubic(style, q.raise(), tolerance); + ctx.do_cubic(style, q.raise(), tolerance, opts); } } PathEl::CurveTo(p1, p2, p3) => { @@ -207,7 +241,7 @@ fn stroke_undashed( let (tan0, tan1) = PathSeg::Cubic(c).tangents(); ctx.do_join(style, tan0); ctx.last_tan = tan1; - ctx.do_cubic(style, c, tolerance); + ctx.do_cubic(style, c, tolerance, opts); } } PathEl::ClosePath => { @@ -264,6 +298,13 @@ fn extend_reversed(out: &mut BezPath, elements: &[PathEl]) { } } +fn fit_with_opts(co: &CubicOffset, tolerance: f64, opts: &StrokeOpts) -> BezPath { + match opts.opt_level { + StrokeOptLevel::Subdivide => fit_to_bezpath(co, tolerance), + StrokeOptLevel::Optimized => fit_to_bezpath_opt(co, tolerance), + } +} + impl StrokeCtx { /// Append forward and backward paths to output. fn finish(&mut self, style: &Stroke) { @@ -375,12 +416,12 @@ impl StrokeCtx { self.last_pt = p1; } - fn do_cubic(&mut self, style: &Stroke, c: CubicBez, tolerance: f64) { + fn do_cubic(&mut self, style: &Stroke, c: CubicBez, tolerance: f64, opts: &StrokeOpts) { let co = CubicOffset::new(c, -0.5 * style.width); - let forward = fit_to_bezpath(&co, tolerance); + let forward = fit_with_opts(&co, tolerance, opts); self.forward_path.extend(forward.into_iter().skip(1)); let co = CubicOffset::new(c, 0.5 * style.width); - let backward = fit_to_bezpath(&co, tolerance); + let backward = fit_with_opts(&co, tolerance, opts); self.backward_path.extend(backward.into_iter().skip(1)); self.last_pt = c.p3; } From 9d5fc8817f1b7fef26d24b73cdca78487fb3cb3b Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Tue, 27 Jun 2023 07:00:15 -0700 Subject: [PATCH 7/8] Refine stroke expansion Wire up regularization in parallel curve generation (now merged into branch). Fix zero length segment detection (it was overly eager, also triggering on degenerate tangents). In response to review comments. --- src/stroke.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/stroke.rs b/src/stroke.rs index c06e7165..1b7d5709 100644 --- a/src/stroke.rs +++ b/src/stroke.rs @@ -227,7 +227,7 @@ fn stroke_undashed( } } PathEl::QuadTo(p1, p2) => { - if p1 != p0 && p2 != p0 { + if p1 != p0 || p2 != p0 { let q = QuadBez::new(p0, p1, p2); let (tan0, tan1) = PathSeg::Quad(q).tangents(); ctx.do_join(style, tan0); @@ -236,7 +236,7 @@ fn stroke_undashed( } } PathEl::CurveTo(p1, p2, p3) => { - if p1 != p0 && p2 != p0 && p3 != p0 { + if p1 != p0 || p2 != p0 || p3 != p0 { let c = CubicBez::new(p0, p1, p2, p3); let (tan0, tan1) = PathSeg::Cubic(c).tangents(); ctx.do_join(style, tan0); @@ -420,7 +420,11 @@ impl StrokeCtx { let co = CubicOffset::new(c, -0.5 * style.width); let forward = fit_with_opts(&co, tolerance, opts); self.forward_path.extend(forward.into_iter().skip(1)); - let co = CubicOffset::new(c, 0.5 * style.width); + // A tuning parameter for regularization. A value too large may distort the curve, + // while a value too small may fail to generate smooth curves. This is a somewhat + // arbitrary value, and should be revisited. + const DIM_TUNE: f64 = 0.25; + let co = CubicOffset::new_regularized(c, 0.5 * style.width, tolerance * DIM_TUNE); let backward = fit_with_opts(&co, tolerance, opts); self.backward_path.extend(backward.into_iter().skip(1)); self.last_pt = c.p3; From 063839e859c87787e88fbfcf31b128f9bf09bb4e Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Tue, 27 Jun 2023 09:43:31 -0700 Subject: [PATCH 8/8] Do regularization consistently --- src/stroke.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/stroke.rs b/src/stroke.rs index 1b7d5709..91f56646 100644 --- a/src/stroke.rs +++ b/src/stroke.rs @@ -417,14 +417,15 @@ impl StrokeCtx { } fn do_cubic(&mut self, style: &Stroke, c: CubicBez, tolerance: f64, opts: &StrokeOpts) { - let co = CubicOffset::new(c, -0.5 * style.width); - let forward = fit_with_opts(&co, tolerance, opts); - self.forward_path.extend(forward.into_iter().skip(1)); // A tuning parameter for regularization. A value too large may distort the curve, // while a value too small may fail to generate smooth curves. This is a somewhat // arbitrary value, and should be revisited. const DIM_TUNE: f64 = 0.25; - let co = CubicOffset::new_regularized(c, 0.5 * style.width, tolerance * DIM_TUNE); + let dimension = tolerance * DIM_TUNE; + let co = CubicOffset::new_regularized(c, -0.5 * style.width, dimension); + let forward = fit_with_opts(&co, tolerance, opts); + self.forward_path.extend(forward.into_iter().skip(1)); + let co = CubicOffset::new_regularized(c, 0.5 * style.width, dimension); let backward = fit_with_opts(&co, tolerance, opts); self.backward_path.extend(backward.into_iter().skip(1)); self.last_pt = c.p3;