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.
This commit is contained in:
dotCooCoo
2026-11-05 03:55:48 +07:00
committed by bflatastic
parent a2e98ac57c
commit ed3efcbd7d
7 changed files with 301 additions and 33 deletions
+24 -6
View File
@@ -24,7 +24,7 @@ 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 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;
@@ -111,11 +111,7 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
}
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, 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());
@@ -124,6 +120,28 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
}
}
/// [`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
+25 -7
View File
@@ -45,6 +45,14 @@ pub struct Dashed {
/// entry, as in SVG and PDF) or has no positive length (the line is solid), or is too fine to draw
/// (then the line is drawn solid too).
pub fn dash(bp: &BezPath, d: &Dash) -> Option<Dashed> {
dash_kept(bp, d, &|_| true)
}
/// [`dash`] with only the dashes and dots on the segments of `bp` that `keep` accepts. The pattern
/// runs along every segment as in [`dash`], so a kept dash lies where [`dash`] puts it: a dash
/// running onto a segment left out ends where that segment starts and goes on from where the next
/// kept segment starts. Segments left out still count toward the limit on the number of dashes.
pub fn dash_kept(bp: &BezPath, d: &Dash, keep: &dyn Fn(&PathSeg) -> bool) -> Option<Dashed> {
if d.pattern.iter().any(|v| !v.is_finite() || *v < 0.0) || !d.offset.is_finite() {
return None;
}
@@ -82,7 +90,7 @@ pub fn dash(bp: &BezPath, d: &Dash) -> Option<Dashed> {
let phase = Phase { ix: ix0, rem: pat[ix0] - off, on: ix0 % 2 == 0 };
let mut out = Dashed::default();
for plan in &plans {
dash_subpath(&mut out, plan, &pat, phase);
dash_subpath(&mut out, plan, &pat, phase, keep);
}
Some(out)
}
@@ -175,7 +183,7 @@ impl Piece {
}
}
fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: &dyn Fn(&PathSeg) -> bool) {
let first_dot = out.dots.len();
let total: f64 = plan.lens.iter().sum();
// Fraction of the subpath's length at `x` along the plan.
@@ -188,7 +196,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
};
let mut dashes: Vec<Piece> = vec![];
let mut cur = Piece::default();
let (mut cuts, mut acc, mut starts_on) = (0, 0.0, false);
let (mut cuts, mut acc, mut starts_on, mut left_out) = (0, 0.0, false, false);
for (i, (seg, &len)) in plan.segs.iter().zip(&plan.lens).enumerate() {
let k = plan.scale[i];
if plan.restart[i] {
@@ -199,21 +207,30 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
}
ph = Phase { ix: 0, rem: pat[0] * k / 2.0, on: true };
}
// A segment left out ends the dash running onto it; the pattern still advances along it.
let kept = keep(seg);
if !kept {
left_out = true;
if !cur.segs.is_empty() {
cur.end = acc;
dashes.push(std::mem::take(&mut cur));
}
}
if i == 0 {
starts_on = ph.on && ph.rem > 0.0;
starts_on = kept && ph.on && ph.rem > 0.0;
}
let mut s = 0.0;
loop {
let left = len - s;
if ph.rem > left + EPS {
if ph.on && left > EPS {
if kept && ph.on && left > EPS {
cur.push(sub(seg, len, s, len), acc + s);
}
ph.rem -= left;
break;
}
let end = (s + ph.rem).min(len);
if ph.on {
if kept && ph.on {
if pat[ph.ix] > 0.0 {
if end > s {
cur.push(sub(seg, len, s, end), acc + s);
@@ -240,6 +257,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
&& plan.restart[0]
&& pat[0] <= 0.0
&& let Some(seg) = plan.segs.last()
&& keep(seg)
{
push_dot(out, first_dot, Dot { at: seg.end(), dir: tangent(seg, 1.0), t: 1.0 });
}
@@ -249,7 +267,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
dashes.push(cur);
}
if plan.closed {
if cuts == 0 && ends_on {
if cuts == 0 && ends_on && !left_out {
// One dash all the way round: keep it closed so the start gets a join, not caps.
let d = dashes.pop().unwrap_or_default();
write(&mut out.path, &d.segs);
+74
View File
@@ -110,6 +110,80 @@ fn dashes_follow_arc_length_and_join_across_the_start_of_closed_paths() {
assert!(dash(&line(0.0, 1e6), &dashed(&[0.001, 0.001], 0.0)).is_none());
}
/// The dashes of `d` as (start x, end x), for dashes along the x axis.
fn spans_x(d: &Dashed) -> Vec<(f64, f64)> {
let mut out = vec![];
for el in d.path.elements() {
match el {
PathEl::MoveTo(p) => out.push((p.x, p.x)),
PathEl::LineTo(p) => {
if let Some(last) = out.last_mut() {
last.1 = p.x;
}
}
_ => {}
}
}
out
}
/// Ten segments of 10 pt along the x axis from 0 to 100.
fn steps() -> BezPath {
let mut b = BezPath::new();
b.move_to((0.0, 0.0));
for i in 1..=10 {
b.line_to((f64::from(i) * 10.0, 0.0));
}
b
}
/// Whether a segment lies outside x 30..60.
fn outside(s: &PathSeg) -> bool {
s.start().x >= 60.0 || s.end().x <= 30.0
}
#[test]
fn dashes_kept_on_some_segments_keep_the_phase_of_the_whole_line() {
// A period of 7 starting 2 into it: dashes at -2..2, 5..9, 12..16, …
let pattern = dashed(&[4.0, 3.0], 2.0);
let all = dash(&steps(), &pattern).unwrap();
let kept = dash_kept(&steps(), &pattern, &|s| outside(s)).unwrap();
// The dashes of the whole line, cut at x 30 and 60.
let cut: Vec<(f64, f64)> =
spans_x(&all).into_iter().flat_map(|(a, b)| [(a, b.min(30.0)), (a.max(60.0), b)]).filter(|(a, b)| b - a > 1e-9).collect();
let got = spans_x(&kept);
assert_eq!(got.len(), cut.len(), "{got:?} against {cut:?}");
for ((a, b), (c, d)) in got.iter().zip(&cut) {
assert!(near(*a, *c, 1e-9) && near(*b, *d, 1e-9), "{got:?} against {cut:?}");
}
// The dash after the gap starts where the whole line's does, at 61, not at 60.
assert!(got.iter().any(|(a, _)| near(*a, 61.0, 1e-9)), "{got:?}");
// Dots on segments left out are left out (the dot at 60 ends a segment left out).
let dots = dash_kept(&steps(), &dashed(&[0.0, 5.0], 0.0), &|s| outside(s)).unwrap();
let xs: Vec<f64> = dots.dots.iter().map(|d| d.at.x).collect();
let want = [0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 65.0, 70.0, 75.0, 80.0, 85.0, 90.0, 95.0, 100.0];
assert!(xs.len() == want.len() && xs.iter().zip(want).all(|(x, w)| near(*x, w, 1e-9)), "{xs:?}");
// Keeping every segment is the whole line's dashing, and keeping none leaves nothing.
assert_eq!(dash_kept(&steps(), &pattern, &|_| true).unwrap().path, all.path);
let none = dash_kept(&steps(), &pattern, &|_| false).unwrap();
assert!(none.path.elements().is_empty() && none.dots.is_empty());
// A pattern too fine for the whole line draws it solid, however few segments are kept.
assert!(dash_kept(&line(0.0, 1e6), &dashed(&[0.001, 0.001], 0.0), &|s| s.start().x < 1.0).is_none());
}
#[test]
fn a_closed_path_with_segments_left_out_is_dashed_in_open_pieces() {
// One dash all the way round: with the right side left out it is no longer closed.
let keep_left = |s: &PathSeg| s.start().x < 5.0 || s.end().x < 5.0;
let whole = dash_kept(&square(10.0), &dashed(&[100.0, 1.0], 0.0), &keep_left).unwrap();
assert!(!is_closed(&whole.path), "{:?}", whole.path.to_svg());
// The bottom side left out: of the 10 dashes, the one through the start point stays one dash,
// the one into the bottom side is cut and the two on it are left out.
let kept = dash_kept(&square(100.0), &dashed(&[20.0, 20.0], 10.0), &|s| s.start().y < 50.0 || s.end().y < 50.0).unwrap();
assert_eq!(moves(&kept.path), 8, "{}", kept.path.to_svg());
assert!(!kept.path.elements().iter().any(|e| matches!(e, PathEl::MoveTo(p) if p.distance(Point::ZERO) < 1e-9)), "{}", kept.path.to_svg());
}
#[test]
fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() {
let dots = [Dot { at: Point::new(10.0, 0.0), dir: Vec2::new(1.0, 0.0), t: 0.0 }];