Files
vectorcraft/crates/effects/src/stroke.rs
T
dotCooCoo ed3efcbd7d Render: a long dashed stroke is outlined only near the view after an edit
A dashed stroke of a path with more than 1,024 path elements was outlined
whole in every frame after an edit. It is now outlined in and around the
view like a solid stroke. dash_kept runs the pattern along every segment
and keeps only the dashes and dots on the segments near the view, so they
sit where dashing the whole path puts them and the pixels are the same.
A pattern too fine to draw is still drawn solid, and that solid stroke is
cropped too.

At 1600 x 1200 the frame after one anchor moves takes 14 ms instead of
102 ms on a filled closed path of 50,000 anchors with 6 pt dashes and
4 pt gaps. anchor_bench now includes that document.
2026-11-05 03:55:48 +07:00

234 lines
10 KiB
Rust

//! Stroke geometry shared by the canvas, the PDF/SVG exporters and Outline Stroke, so every
//! consumer paints the same shapes:
//!
//! - [`stroke_pieces`]: the centre line (trimmed under the arrowheads) and the arrowheads as
//! filled outlines (hollow kinds are rings), placed by [`ArrowAlign`](vectorcraft_doc::ArrowAlign);
//! - [`dash`]: the dash pattern, exact or fitted to corners and path ends; zero-length dashes
//! become [`Dot`]s that a round or projecting cap turns into discs or squares ([`dot_outline`]);
//! - [`width_outline`]: variable-width (profile) strokes, with the stroke's joins at corners;
//! - [`line_outline`]: the line part of a stroke as one filled outline (profile, dashes, dots,
//! caps and joins);
//! - [`for_writer`] and [`outline_region`]: strokes for the SVG/PDF writers and Outline Stroke;
//! - [`gradient_slices`] and [`gradient_meshes`]: gradients laid along or across a stroke.
mod arrow;
mod dash;
mod gradient;
mod outline;
mod width;
use std::borrow::Cow;
use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Shape};
use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
pub(crate) use vectorcraft_geom::bez::{kink, segments, subpaths, tangent, unit};
pub use arrow::Arrow;
pub use dash::{Dashed, Dot, dash, dash_kept, dot_outline};
pub use gradient::{Slice, WrittenSlices, gradient_meshes, gradient_slices, written_slices};
pub use outline::{OUTLINE_TOL, Written, WrittenShape, for_writer, is_plain, outline_region};
pub use width::width_outline;
/// Arc-length accuracy for dashing and trimming (document points).
const ARCLEN_ACCURACY: f64 = 1e-6;
/// The geometry one stroke paints, in the coordinate space of its path.
#[derive(Clone, Debug)]
pub struct StrokePieces<'a> {
/// The centre line to stroke: the path with its ends trimmed under the arrowheads (borrowed
/// when nothing is trimmed). Empty when the heads cover the whole path.
pub line: Cow<'a, BezPath>,
/// The arrowheads, start first. Closed ends get none.
pub heads: Vec<Arrow>,
}
/// The centre line and arrowheads of stroke `st` along `bp`.
pub fn stroke_pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> {
if st.start_arrow.is_none() && st.end_arrow.is_none() {
return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] };
}
arrow::pieces(bp, st)
}
/// Does `bp` end with a `ClosePath`?
pub fn is_closed(bp: &BezPath) -> bool {
bp.elements().last().is_some_and(|e| matches!(e, PathEl::ClosePath))
}
/// The width actually stroked: inside/outside strokes of closed paths are drawn twice as wide
/// and clipped to one side; open paths always stroke centred.
pub fn aligned_width(st: &StrokeLayer, closed: bool) -> f64 {
match st.align {
StrokeAlign::Inside | StrokeAlign::Outside if closed => st.width * 2.0,
_ => st.width,
}
}
/// How far a cap reaches past the end of a stroke of `width`.
pub fn cap_extent(cap: LineCap, width: f64) -> f64 {
match cap {
LineCap::Butt => 0.0,
LineCap::Round | LineCap::Square => width / 2.0,
}
}
/// The stroke style (cap, join, miter limit) of `st` at `width`, without dashes.
pub fn style(st: &StrokeLayer, width: f64) -> kurbo::Stroke {
kurbo::Stroke::new(width)
.with_join(match st.join {
LineJoin::Miter => kurbo::Join::Miter,
LineJoin::Round => kurbo::Join::Round,
LineJoin::Bevel => kurbo::Join::Bevel,
})
.with_caps(match st.cap {
LineCap::Butt => kurbo::Cap::Butt,
LineCap::Round => kurbo::Cap::Round,
LineCap::Square => kurbo::Cap::Square,
})
.with_miter_limit(st.miter_limit)
}
/// The filled outline of the line part of `st` along `line` (usually [`StrokePieces::line`]) at
/// `width` (see [`aligned_width`]), flattened/fitted to `tol`: the dash pattern with its dots and
/// the width profile (each dash and dot as wide as the profile where it sits along the path), with
/// the stroke's caps and joins. Fill it with the non-zero rule. Arrowheads are not included.
pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> BezPath {
if line.elements().is_empty() || width <= 0.0 || !width.is_finite() {
return BezPath::new();
}
let tol = tol.max(1e-4);
let dashed = st.dash.as_ref().and_then(|d| dash(line, d));
match (st.profile.as_ref(), dashed) {
(Some(profile), None) => width_outline(line, width, profile, st, tol),
(Some(profile), Some(d)) => {
let mut out = width::outline_spans(&d.path, &d.spans, width, profile, st, tol);
for dot in &d.dots {
// The dot spans the profile's left and right widths there.
let (l, r) = profile.at(dot.t);
let (l, r) = (l.max(0.0), r.max(0.0));
let at = dot.at + width::left(dot.dir) * (width * (l - r) / 4.0);
out.extend(dot_outline(&[Dot { at, ..*dot }], width * (l + r) / 2.0, st.cap, tol).iter());
}
out
}
(None, Some(d)) => dashed_outline(&d, st, width, tol),
(None, None) => {
let mut out = plain_outline(line, &style(st, width), tol);
out.extend(dot_outline(&point_dots(line), width, st.cap, tol).iter());
out
}
}
}
/// [`line_outline`] of a dashed line without a width profile, made of only the dashes and dots on
/// the segments of `line` that `keep` accepts ([`dash_kept`]). `None` when `st` has no dash
/// pattern or has a width profile, or when its pattern draws the line solid.
pub fn dashed_line_outline_kept(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64, keep: &dyn Fn(&PathSeg) -> bool) -> Option<BezPath> {
if st.profile.is_some() {
return None;
}
let d = dash_kept(line, st.dash.as_ref()?, keep)?;
if width <= 0.0 || !width.is_finite() {
return Some(BezPath::new());
}
Some(dashed_outline(&d, st, width, tol.max(1e-4)))
}
/// The filled outline of the dashes and dots `d` of a stroke `st` without a width profile, at
/// `width` and flattened/fitted to `tol`.
fn dashed_outline(d: &Dashed, st: &StrokeLayer, width: f64, tol: f64) -> BezPath {
let mut out = kurbo::stroke(d.path.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol);
out.extend(dot_outline(&d.dots, width, st.cap, tol).iter());
out
}
/// The subpaths of `line` that never leave their first point (`m` then `L` to the same point, as
/// `.ai` files write dots, #1613), as dots: kurbo strokes nothing for them, where PDF and SVG
/// viewers paint the cap. They lie along the x axis, which a projecting cap's square follows
/// (SVG 2, "Path data": zero-length path segments).
fn point_dots(line: &BezPath) -> Vec<Dot> {
let els = line.elements();
subpaths(line)
.into_iter()
.filter_map(|(r, _)| {
let sub = els.get(r)?;
let at = sub.first()?.end_point()?;
let still = sub.iter().all(|e| match e {
PathEl::MoveTo(p) | PathEl::LineTo(p) => *p == at,
PathEl::QuadTo(a, b) => *a == at && *b == at,
PathEl::CurveTo(a, b, c) => *a == at && *b == at && *c == at,
PathEl::ClosePath => true,
});
still.then_some(Dot { at, dir: kurbo::Vec2::new(1.0, 0.0), t: 0.0 })
})
.collect()
}
/// `line` stroked with `style`, to fill with the non-zero rule.
///
/// kurbo offsets a closed subpath to both sides, as two contours. Once half the weight is more
/// than the shape can hold (its inradius), the inner one has crossed the middle and comes out
/// the far side: wound against the outer contour it would cut a hole where the pen in fact
/// overlaps itself (#1396). Such a contour is left out, so the stroke paints solid.
// shortcut: closed subpaths of plain strokes only. An open path that turns back on itself inside
// a wider stroke (and dashes on such a shape) can still cancel; fix it in the offsetting if reported.
fn plain_outline(line: &BezPath, style: &kurbo::Stroke, tol: f64) -> BezPath {
let stroke = |els: &[PathEl]| kurbo::stroke(els.iter().copied(), style, &kurbo::StrokeOpts::default(), tol);
let els = line.elements();
// Only a stroke at least as wide as a closed subpath's bounds can cross over: everything
// else takes the one call it always did.
let may_cross = |sub: &[PathEl]| {
let b = BezPath::from_vec(sub.to_vec()).bounding_box();
style.width >= b.width().min(b.height())
};
let subs = subpaths(line);
if !subs.iter().any(|(r, closed)| *closed && els.get(r.clone()).is_some_and(may_cross)) {
return stroke(els);
}
let reach = style.width / 2.0 - 2.0 * tol;
let mut out = BezPath::new();
for (r, closed) in subs {
let Some(sub) = els.get(r) else { continue };
let outline = stroke(sub);
if !(closed && may_cross(sub)) {
out.extend(outline.iter());
continue;
}
let sub = BezPath::from_vec(sub.to_vec());
for (c, _) in subpaths(&outline) {
let Some(contour) = outline.elements().get(c) else { continue };
if reaches_edge(contour, &sub, reach) {
out.extend(contour.iter().copied());
}
}
}
out
}
/// Whether some point of `contour` lies at least `reach` from `path`: an offset contour that
/// never does is nowhere the edge of the stroke.
fn reaches_edge(contour: &[PathEl], path: &BezPath, reach: f64) -> bool {
let far = |p: kurbo::Point| path.segments().all(|s| s.nearest(p, 1e-6).distance_sq >= reach * reach);
kurbo::segments(contour.iter().copied()).any(|s| far(s.eval(0.0)) || far(s.eval(0.5)))
}
/// Append `seg` to `out` (which must already have a current point at its start).
pub(crate) fn push_seg(out: &mut BezPath, seg: &PathSeg) {
match seg {
PathSeg::Line(l) => out.line_to(l.p1),
PathSeg::Quad(q) => out.quad_to(q.p1, q.p2),
PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3),
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_arrows;
#[cfg(test)]
mod tests_fit;
#[cfg(test)]
mod tests_gradient;
#[cfg(test)]
mod tests_reach;