Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

First cut at stroke expansion #286

Merged
merged 9 commits into from
Jun 27, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ features = ["mint", "schemars", "serde"]
default = ["std"]
std = []

[dependencies]
smallvec = "1.10"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think tinyvec would be better if possible since it has no unsafe code.


[dependencies.arrayvec]
version = "0.7.1"
default-features = false
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -130,6 +131,7 @@ pub use crate::rounded_rect::*;
pub use crate::rounded_rect_radii::*;
pub use crate::shape::*;
pub use crate::size::*;
pub use crate::stroke::*;
#[cfg(feature = "std")]
pub use crate::svg::*;
pub use crate::translate_scale::*;
Expand Down
254 changes: 254 additions & 0 deletions src/stroke.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
// Copyright 2023 the Kurbo Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT

use core::borrow::Borrow;

use smallvec::SmallVec;

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)]
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<P>(mut self, offset: f64, pattern: P) -> Self
where
P: IntoIterator,
P::Item: Borrow<f64>,
{
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]>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be better to do three dashes by default, since on 64 bit platforms there would be no wasted stack space compared to a Vec.


/// 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<Item = PathEl>, style: &Stroke, tolerance: f64) -> BezPath {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we want to add an additional options parameter to this? I believe one use was to toggle optimal curve fitting.

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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per f2f this appears to be too strict. For example, there are valid cases where p1 == p0. Same above for the quad case.

let c = CubicBez::new(p0, p1, p2, p3);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

friendly note: .regularize() :)

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 {
derekdreery marked this conversation as resolved.
Show resolved Hide resolved
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;
}
}