Files
vectorcraft/crates/effects/src/stroke.rs
T
bflatastic 1168c319b0 Close the hole whenever the inner offset of a closed stroke has crossed over
The check added for #1396 dropped the inner contour only when the path itself fell in the hole, which takes a stroke more than twice as wide as the shape. Between one and two times the size (a 30 pt ellipse with a 50 pt stroke), and on rectangles with round or bevel joins, the hole stayed.

An offset contour is now left out when no point of it lies half the weight from its subpath: it is then nowhere the edge of the stroke. Subpaths are judged one by one, and only when the stroke is at least as wide as the subpath's bounds, so ordinary strokes take the same single call as before.
2026-10-12 20:22:07 +07:00

190 lines
8.0 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, 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)) => {
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
}
(None, None) => plain_outline(line, &style(st, width), tol),
}
}
/// `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;