Render: a moved path's stroke is matched by its anchors, without a copy of its line

The outline cache kept a copy of each stroke's line to recognize a moved
path, and made that copy on every miss, so an anchor edit of a long path
paid for copying its whole line. The entry now records whether its outline
was made along a line of the object's own path. On a miss, the anchors of
the earlier object are compared with the new one's, stopping at the first
that differs, and the earlier line is made again from the object and its
stroke only when they match.

Strokes with arrowheads keep the reuse: the check uses the untrimmed path.
This commit is contained in:
dotCooCoo
2026-11-05 10:26:08 +07:00
committed by bflatastic
parent e71daf48cc
commit 413fe034f0
3 changed files with 92 additions and 36 deletions
+4 -3
View File
@@ -37,14 +37,15 @@ pub(crate) type MovedKey = (u64, usize, u64, i32);
/// A stroke expanded to a fill outline: (owning node, outline, last frame used, the region a frame
/// must lie in to draw the outline, when the outline leaves out the segments far from that region,
/// `None` when it has every segment, and the line the outline was made from, kept when the entry
/// is listed under a [`MovedKey`]).
/// `None` when it has every segment, and whether the outline was made along a line of the node's
/// own path, the one [`Node::stroke_path`] makes, so that the line can be made again from the
/// node and its stroke).
pub(crate) struct StrokeEntry {
pub(crate) node: Arc<Node>,
pub(crate) outline: Arc<BezPath>,
pub(crate) stamp: u64,
pub(crate) crop: Option<Rect>,
pub(crate) line: Option<Arc<BezPath>>,
pub(crate) own: bool,
}
/// The slices of a stroke whose gradient runs along or across it, each with its paint and bounds.
+77 -30
View File
@@ -134,7 +134,7 @@ impl Renderer {
} else if paint::set_paint(ctx, &st.paint, paint_bounds, f) {
self.fold_alpha(ctx, f.ink, &st.paint);
ctx.set_fill_rule(peniko::Fill::NonZero);
match self.cached_stroke(f, chars, &pieces.line, st, width) {
match self.cached_stroke(f, chars, bp, &pieces.line, st, width) {
Some(o) => ctx.fill_path(&o),
None => ctx.fill_path(&effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)),
}
@@ -210,7 +210,7 @@ impl Renderer {
}
None => make(),
};
let line = match self.cached_stroke(f, chars, &pieces.line, st, width) {
let line = match self.cached_stroke(f, chars, bp, &pieces.line, st, width) {
Some(o) => o,
None => Arc::new(effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)),
};
@@ -237,11 +237,12 @@ impl Renderer {
/// stroke of `self.cur` in `strokes` (under [`Self::stroke_owner`] and the stroke layer). The
/// tolerance (vello's 0.25 device px) is bucketed to powers of two, never coarser than needed,
/// so zooming reuses outlines until the level changes. Other strokes aren't cached: for them
/// this returns `None`.
/// this returns `None`. `line` is the line of `st` along `path` ([`effects::stroke::stroke_pieces`]).
fn cached_stroke(
&mut self,
f: &Frame,
chars: Option<(&Arc<TextGeom>, usize)>,
path: &BezPath,
line: &BezPath,
st: &StrokeLayer,
width: f64,
@@ -274,11 +275,11 @@ impl Renderer {
// of the stroke's latest outline is kept, except for art made again in every frame.
let place = node.appearance.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(s) if std::ptr::eq(s, st)));
let moved = place.filter(|_| !self.made_again).map(|place| (node.id.0, place, width.to_bits(), level));
let kept = moved.map(|_| Arc::new(line.clone()));
let own = moved.is_some() && self.own_path(&node, path);
if let Some(mk) = moved
&& let Some((outline, crop)) = self.moved_outline(mk, &node, st, line, v)
{
self.keep_stroke(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop, line: kept }, moved);
self.keep_stroke(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop, own }, moved);
return Some(outline);
}
#[cfg(test)]
@@ -295,7 +296,7 @@ impl Renderer {
&& let Some(outline) = crop_dashed(line, st, width, region, crop_reach(st, width, f.px, tol), tol)
{
let outline = Arc::new(outline);
self.keep_stroke(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop: Some(region), line: kept }, moved);
self.keep_stroke(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop: Some(region), own }, moved);
return Some(outline);
}
let cropped = (crops() && line.elements().len() > CROP_ELEMENTS && st.dash.is_none() && st.profile.is_none())
@@ -305,33 +306,38 @@ impl Renderer {
Some(part) => (Arc::new(effects::stroke::line_outline(&part, st, width, tol)), Some(region)),
None => (expand(), None),
};
self.keep_stroke(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop, line: kept }, moved);
self.keep_stroke(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop, own }, moved);
Some(outline)
}
/// Whether `path` is the path that `geom` keeps for `node` ([`Self::path_of`]), the path
/// [`Node::stroke_path`] makes from its anchors.
fn own_path(&self, node: &Arc<Node>, path: &BezPath) -> bool {
self.geom
.get(&(Arc::as_ptr(node) as usize))
.is_some_and(|g| Arc::ptr_eq(&g.node, node) && g.path.as_deref().is_some_and(|p| std::ptr::eq(p, path)))
}
/// Keeps outline `e` in `strokes` under `key`, and lists `key` as the latest outline under
/// `moved` ([`MovedKey`]), dropping the line kept with the outline listed there before, which
/// no moved copy draws from any more: one line is kept per [`MovedKey`].
/// `moved` ([`MovedKey`]).
fn keep_stroke(&mut self, key: StrokeKey, e: StrokeEntry, moved: Option<MovedKey>) {
self.strokes.insert(key, e);
if let Some(mk) = moved
&& let Some(before) = self.moved_strokes.insert(mk, key)
&& before != key
&& let Some(e) = self.strokes.get_mut(&before)
{
e.line = None;
if let Some(mk) = moved {
self.moved_strokes.insert(mk, key);
}
}
/// The outline of stroke `st` of `node` along `line`, made from the latest outline kept under
/// `mk` ([`MovedKey`]) when that outline's object is `node` changed, its stroke differs from `st`
/// in its paint at most, and its line is `line` moved (to within rounding): that outline moved
/// by the same offset, with the region it was cropped to moved too. `None` when there is no
/// such outline, or when its region does not hold the view `v`.
/// `mk` ([`MovedKey`]) when that outline was made along a line of its object's own path, its
/// object is `node` changed, its stroke differs from `st` in its paint at most, and its line is
/// `line` moved (to within rounding): that outline moved by the same offset, with the region it
/// was cropped to moved too. The anchors of the two objects are compared first
/// ([`anchors_moved`]), and the earlier line is made again from its object and stroke only when
/// they match. `None` when there is no such outline, or when its region does not hold the view
/// `v`.
fn moved_outline(&self, mk: MovedKey, node: &Node, st: &StrokeLayer, line: &BezPath, v: Rect) -> Option<(Arc<BezPath>, Option<Rect>)> {
let e = self.strokes.get(self.moved_strokes.get(&mk)?)?;
let was = e.line.as_deref()?;
if e.node.id != node.id {
if !e.own || e.node.id != node.id {
return None;
}
let Some(AppearanceItem::Stroke(old)) = e.node.appearance.items.get(mk.1) else {
@@ -341,7 +347,11 @@ impl Renderer {
if !std::ptr::eq(old, st) && unpainted(old) != unpainted(st) {
return None;
}
let by = offset_between(was, line)?;
if !anchors_moved(&e.node, node) {
return None;
}
let path = e.node.stroke_path()?;
let by = offset_between(&effects::stroke::stroke_pieces(&path, old).line, line)?;
let crop = e.crop.map(|r| r + by);
if crop.is_some_and(|r| r.x0 > v.x0 || r.y0 > v.y0 || r.x1 < v.x1 || r.y1 < v.y1) {
return None;
@@ -384,16 +394,10 @@ pub(crate) fn offset_between(was: &BezPath, now: &BezPath) -> Option<kurbo::Vec2
return None;
}
let (by, first) = match (was.first()?, now.first()?) {
(PathEl::MoveTo(a), PathEl::MoveTo(b)) => (*b - *a, a.x.abs().max(b.x.abs()).max(a.y.abs()).max(b.y.abs())),
(PathEl::MoveTo(a), PathEl::MoveTo(b)) => first_offset(*a, *b)?,
_ => return None,
};
if !by.is_finite() {
return None;
}
let near = |a: &kurbo::Point, b: &kurbo::Point| {
let close = |a: f64, by: f64, b: f64| (a + by - b).abs() <= 4.0 * f64::EPSILON * (a.abs() + b.abs() + first);
close(a.x, by.x, b.x) && close(a.y, by.y, b.y)
};
let near = |a: &kurbo::Point, b: &kurbo::Point| moved_by(*a, *b, by, first);
let moved = was.iter().zip(now).all(|pair| match pair {
(PathEl::MoveTo(a), PathEl::MoveTo(b)) | (PathEl::LineTo(a), PathEl::LineTo(b)) => near(a, b),
(PathEl::QuadTo(a0, a1), PathEl::QuadTo(b0, b1)) => near(a0, b0) && near(a1, b1),
@@ -404,6 +408,49 @@ pub(crate) fn offset_between(was: &BezPath, now: &BezPath) -> Option<kurbo::Vec2
moved.then_some(by)
}
/// The offset from first point `a` to first point `b` and the largest size of their coordinates,
/// which [`moved_by`] measures its tolerance with. `None` when the offset is not finite.
fn first_offset(a: kurbo::Point, b: kurbo::Point) -> Option<(kurbo::Vec2, f64)> {
let by = b - a;
by.is_finite().then(|| (by, a.x.abs().max(b.x.abs()).max(a.y.abs()).max(b.y.abs())))
}
/// Whether `b` is `a` moved by `by`, each coordinate within a few units in the last place (see
/// [`offset_between`]; `first` from [`first_offset`]).
fn moved_by(a: kurbo::Point, b: kurbo::Point, by: kurbo::Vec2, first: f64) -> bool {
let close = |a: f64, by: f64, b: f64| (a + by - b).abs() <= 4.0 * f64::EPSILON * (a.abs() + b.abs() + first);
close(a.x, by.x, b.x) && close(a.y, by.y, b.y)
}
/// Whether the anchors and handles of path `now` are those of path `was` moved by one offset,
/// measured as [`offset_between`] measures it, with the same subpaths, anchor counts and closed
/// subpaths. It reads the anchors without making either path ([`Node::stroke_path`]), and stops at
/// the first anchor that differs. `false` for objects other than paths.
fn anchors_moved(was: &Node, now: &Node) -> bool {
let (vectorcraft_doc::NodeKind::Path { path: was, .. }, vectorcraft_doc::NodeKind::Path { path: now, .. }) = (&was.kind, &now.kind) else {
return false;
};
if was.subpaths.len() != now.subpaths.len() {
return false;
}
let mut offset = None;
for (a, b) in was.subpaths.iter().zip(&now.subpaths) {
if a.closed != b.closed || a.anchors.len() != b.anchors.len() {
return false;
}
for (a, b) in a.anchors.iter().zip(&b.anchors) {
if offset.is_none() {
offset = first_offset(a.p, b.p);
}
let Some((by, first)) = offset else { return false };
if !(moved_by(a.p, b.p, by, first) && moved_by(a.h_in, b.h_in, by, first) && moved_by(a.h_out, b.h_out, by, first)) {
return false;
}
}
}
true
}
fn crops() -> bool {
#[cfg(test)]
return CROP_STROKES.with(std::cell::Cell::get);
+11 -3
View File
@@ -3,7 +3,7 @@
//! again.
use vectorcraft_color::{Color, Gradient, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, WidthProfile};
use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, WidthProfile};
use vectorcraft_geom::{PathData, Point, Vec2};
use crate::renderer::{STROKES_OUTLINED, offset_between};
@@ -76,12 +76,16 @@ fn ring(n: usize, r: f64) -> BezPath {
}
/// The documents of the cases: open and closed paths, short and long (outlined near the view), plain,
/// dashed, with a width profile, and filled with a gradient (which a move changes, so the moved
/// dashed, with a width profile, with arrowheads, and filled with a gradient (which a move changes, so the moved
/// object has an appearance of its own), each with the point the view is centered on.
fn cases() -> Vec<(&'static str, (Document, NodeId), Point)> {
let round = stroke(6.0, LineJoin::Round, LineCap::Round);
let mut lens = stroke(10.0, LineJoin::Round, LineCap::Butt);
lens.profile = Some(WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] });
let mut arrows = stroke(4.0, LineJoin::Miter, LineCap::Butt);
arrows.start_arrow = Some(Arrowhead::Triangle);
arrows.end_arrow = Some(Arrowhead::Circle);
arrows.arrow_align = ArrowAlign::Tip;
let gradient = FillLayer::new(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))));
let only = |st: StrokeLayer| vec![AppearanceItem::Stroke(st)];
vec![
@@ -100,6 +104,9 @@ fn cases() -> Vec<(&'static str, (Document, NodeId), Point)> {
Point::new(-5000.0, 0.0),
),
("a long open path, a width profile", doc(&zigzag(3000), only(lens)), Point::new(12_000.0, 15.0)),
// The heads shorten the line, so the line is not the path itself.
("a short open path, arrowheads", doc(&zigzag(20), only(arrows.clone())), Point::new(70.0, 15.0)),
("a long open path, arrowheads", doc(&zigzag(3000), only(arrows)), Point::new(40.0, 15.0)),
]
}
@@ -131,7 +138,6 @@ fn a_moved_path_draws_its_stroke_from_the_outline_made_before() {
}
});
assert_eq!(made, 0, "{what}, a fill changed: no new outline");
assert_eq!(r.strokes.values().filter(|e| e.line.is_some()).count(), 1, "{what}: one line kept");
}
}
@@ -155,6 +161,8 @@ fn a_path_changed_otherwise_outlines_its_stroke_again() {
// A move, then an edit of an anchor near the path's start.
assert_eq!(after(&mut r, &mut d, id, at, |n| n.transform(Affine::translate((0.5, 0.0)), true)), 0, "{what}: moved");
assert_eq!(after(&mut r, &mut d, id, at, edit_anchor), 1, "{what}: an anchor moved");
// A move after the edit uses the outline made for the edited path.
assert_eq!(after(&mut r, &mut d, id, at, |n| n.transform(Affine::translate((0.5, 0.0)), true)), 0, "{what}: moved after the edit");
// Scaled and rotated about the view's center, stroke weights kept.
assert_eq!(after(&mut r, &mut d, id, at, |n| n.transform(Affine::scale_about(1.001, at), false)), 1, "{what}: scaled");
assert_eq!(after(&mut r, &mut d, id, at, |n| n.transform(Affine::rotate_about(0.001, at), false)), 1, "{what}: rotated");