From 4fefdaf95a3e2af37c7462a93790fd0fd6120394 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 02:33:52 -0800 Subject: [PATCH] Canvas: moving anchors of a long path redraws only around them When only anchors of a path or compound path move, the area an edit changed is now the box of the segments that start or end at a moved anchor (and the line that closes an open subpath's fill at a moved end), before and after, grown by the farthest the strokes reach. The canvas then draws that part of the frame again instead of the whole object. Objects with a non-solid paint, dashes, a width profile, arrowheads, a brush, effects or an opacity mask still count whole. At 1600 x 1200, single-threaded, the frame after one anchor of a filled closed path moves takes 16.6 million CPU cycles instead of 56.4 million with 50,000 anchors, and 65.6 million instead of 180.0 million with 200,000 anchors. --- crates/render/src/changed.rs | 156 ++++++++++++++++++++++++++- crates/render/src/tests/changed.rs | 165 ++++++++++++++++++++++++++++- docs/target-app-parity.md | 1 + 3 files changed, 319 insertions(+), 3 deletions(-) diff --git a/crates/render/src/changed.rs b/crates/render/src/changed.rs index 8516d5b0b..01fd6da01 100644 --- a/crates/render/src/changed.rs +++ b/crates/render/src/changed.rs @@ -131,8 +131,9 @@ impl Diff<'_> { } /// A group or layer that only its members changed in, without a clipping mask or effects of - /// its own that draw from all of them, changed where they did. Any other object that changed - /// counts whole, before and after. + /// its own that draw from all of them, changed where they did. A path or compound path that + /// only had anchors moved changed around them ([`moved_anchors`]). Any other object that + /// changed counts whole, before and after. fn node(&mut self, old: &Arc, new: &Arc) { if Arc::ptr_eq(old, new) { return; @@ -141,6 +142,15 @@ impl Diff<'_> { && same_but_members(old, new) { self.children(a, b); + } else if let Some((area, moved, anchors)) = moved_anchors(old, new) { + if moved <= FEW_MOVED && anchors >= MANY_ANCHORS { + self.add(area); + } else { + // The object's box before and after holds every pixel that changed too, and so + // does where the two overlap. + let whole = vectorcraft_geom::union_opt(crate::brush_fx::cull_bounds(&self.old, old), crate::brush_fx::cull_bounds(&self.new, new)); + self.add(area.zip(whole).map(|(a, w)| a.intersect(w))); + } } else { self.add_old(Some(old)); self.add_new(Some(new)); @@ -180,3 +190,145 @@ fn same_but_members(a: &Node, b: &Node) -> bool { }; strip(a) == strip(b) } + +/// The largest size of a coordinate, in points, that [`moved_anchors`] measures: past it, a path +/// drawn at a high zoom may stop being drawn at all (`crate::node_checks::drawable`). +const MOVED_LIMIT: f64 = 1e6; + +/// The most anchors of an object that may move for [`Diff::node`] to take the area round them +/// without also measuring the whole object before and after, which costs as much as its anchors. +const FEW_MOVED: usize = 64; + +/// The fewest anchors of an object that [`Diff::node`] leaves unmeasured whole when at most +/// [`FEW_MOVED`] of them moved. +const MANY_ANCHORS: usize = 1024; + +/// What path or compound path `old` changed in `new` when only anchors of its paths moved (in +/// place, with as many anchors in each subpath, each closed or open as before): the box of the +/// anchors and handles of every segment that starts or ends at a moved anchor, and of the line +/// that closes the fill of an open subpath at a moved end, before and after, grown by the farthest +/// its strokes reach (`None` when no anchor moved), how many anchors moved and how many it has. +/// Outside that box the path's segments, their fill and their strokes' joints stay as they were. +/// None when anything else changed, when it paints with something its whole shape decides +/// (effects, an opacity mask, a paint other than a solid color, dashes, a width profile, +/// arrowheads or a brush), or when a coordinate measured is not finite or larger in size than +/// [`MOVED_LIMIT`]. +fn moved_anchors(old: &Node, new: &Node) -> Option<(Option, usize, usize)> { + if !same_but_anchors(old, new) || !local_paint(old) { + return None; + } + let (area, moved, anchors) = match (&old.kind, &new.kind) { + (NodeKind::Path { path: a, .. }, NodeKind::Path { path: b, .. }) => moved_in(a, b)?, + (NodeKind::Compound { children: a, .. }, NodeKind::Compound { children: b, .. }) => { + let (mut area, mut moved, mut anchors) = (None, 0, 0); + for (x, y) in a.iter().zip(b) { + if Arc::ptr_eq(x, y) { + anchors += y.path_data().map_or(0, |p| p.anchor_count()); + continue; + } + match (&x.kind, &y.kind) { + (NodeKind::Path { path: p, .. }, NodeKind::Path { path: q, .. }) if same_but_anchors(x, y) => { + let (a, m, n) = moved_in(p, q)?; + (area, moved, anchors) = (vectorcraft_geom::union_opt(area, a), moved + m, anchors + n); + } + _ => return None, + } + } + (area, moved, anchors) + } + _ => return None, + }; + let reach = old.appearance.outset(); + Some((area.map(|r| r.inflate(reach, reach)), moved, anchors)) +} + +/// Are `a` and `b` the same but for where the anchors of their paths are: two paths, or two +/// compound paths with the same members by id, without extras, live shapes, guides or clipping? +fn same_but_anchors(a: &Node, b: &Node) -> bool { + let Node { id, name, visible, locked, opacity, blend, isolate, knockout, knockout_shape, appearance, kind, bbox_angle, extras } = a; + let kinds = match (kind, &b.kind) { + ( + NodeKind::Path { path: p, rule: r, live: None, clipping: false, guide: false }, + NodeKind::Path { path: q, rule: s, live: None, clipping: false, guide: false }, + ) => { + r == s + && p.subpaths.len() == q.subpaths.len() + && p.subpaths.iter().zip(&q.subpaths).all(|(x, y)| x.anchors.len() == y.anchors.len() && x.closed == y.closed) + } + (NodeKind::Compound { children: c, rule: r }, NodeKind::Compound { children: d, rule: s }) => { + r == s && c.len() == d.len() && c.iter().zip(d).all(|(x, y)| x.id == y.id) + } + _ => false, + }; + let none = vectorcraft_doc::Extras::default(); + kinds + && *id == b.id + && *name == b.name + && *visible == b.visible + && *locked == b.locked + && *opacity == b.opacity + && *blend == b.blend + && *isolate == b.isolate + && *knockout == b.knockout + && *knockout_shape == b.knockout_shape + && *appearance == b.appearance + && *bbox_angle == b.bbox_angle + && *extras == none + && b.extras == none +} + +/// Does `n` paint each pixel from the segments near it alone: no effects, every visible fill and +/// stroke a solid color, and no stroke with dashes, a width profile, pressure, tilt, bearing or +/// rotation, arrowheads, a brush or effects? +fn local_paint(n: &Node) -> bool { + use vectorcraft_doc::AppearanceItem; + let solid = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None); + n.appearance.effects.is_empty() + && !crate::fx::has_fx(n) + && !crate::fx::has_object_fx(n) + && n.appearance.items.iter().all(|i| match i { + AppearanceItem::Fill(f) => solid(&f.paint) && f.effects.is_empty(), + AppearanceItem::Stroke(s) => { + solid(&s.paint) + && s.effects.is_empty() + && s.dash.is_none() + && s.profile.is_none() + && s.pressure.is_none() + && s.tilt.is_none() + && s.bearing.is_none() + && s.rotation.is_none() + && s.start_arrow.is_none() + && s.end_arrow.is_none() + && s.brush.is_none() + } + }) +} + +/// The box of the anchors and handles of the segments that start or end at an anchor that moved +/// from `a` to `b` (paths with the same subpaths, by anchor count and closing), and the ends of +/// the line that closes the fill of an open subpath at a moved end, in both (`None` when none +/// moved), how many moved and how many anchors `b` has. None when one of those coordinates is not +/// finite or larger in size than [`MOVED_LIMIT`]. +fn moved_in(a: &vectorcraft_geom::PathData, b: &vectorcraft_geom::PathData) -> Option<(Option, usize, usize)> { + let (mut area, mut moved, mut anchors): (Option, usize, usize) = (None, 0, 0); + for (x, y) in a.subpaths.iter().zip(&b.subpaths) { + let n = x.anchors.len(); + anchors += n; + for i in (0..n).filter(|&i| x.anchors[i] != y.anchors[i]) { + moved += 1; + // The anchors before and after it, round the end of an open subpath too: its fill + // closes it with a line from the last anchor to the first. + let (before, after) = ((i + n - 1) % n, (i + 1) % n); + for sp in [x, y] { + let (at, from, to) = (&sp.anchors[i], &sp.anchors[before], &sp.anchors[after]); + for p in [at.p, at.h_in, at.h_out, from.p, from.h_out, to.p, to.h_in] { + if !(p.x.abs() <= MOVED_LIMIT && p.y.abs() <= MOVED_LIMIT) { + return None; + } + area = Some(area.map_or(Rect::from_points(p, p), |r| r.union_pt(p))); + } + } + } + } + Some((area, moved, anchors)) +} diff --git a/crates/render/src/tests/changed.rs b/crates/render/src/tests/changed.rs index e5f1f913d..459397149 100644 --- a/crates/render/src/tests/changed.rs +++ b/crates/render/src/tests/changed.rs @@ -3,7 +3,7 @@ use crate::*; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::Appearance; -use vectorcraft_geom::{Point, shapes}; +use vectorcraft_geom::{Anchor, PathData, Point, SubPath, Vec2, shapes}; const W: u32 = 160; const H: u32 = 120; @@ -258,3 +258,166 @@ fn moving_stretched_type_changes_where_its_stretched_strokes_reach() { new.node_mut(text).unwrap().transform(Affine::translate((9.0, 7.0)), false); area(redraw(&old, &new)); } + +/// `n` anchors round an ellipse at (55, 40) with radii `rx` and `ry` and a ripple, every third a +/// corner and the others smooth with handles along the ellipse. +fn wavy(n: usize, rx: f64, ry: f64) -> SubPath { + let anchors = (0..n) + .map(|i| { + let t = i as f64 / n as f64 * std::f64::consts::TAU; + let k = 1.0 + 0.06 * (t * 12.0).sin(); + let p = Point::new(55.0 + rx * k * t.cos(), 40.0 + ry * k * t.sin()); + if i % 3 == 0 { + return Anchor::corner(p); + } + let along = Vec2::new(-rx * t.sin(), ry * t.cos()) * (0.4 / n as f64); + Anchor { h_in: p - along, h_out: p + along, ..Anchor::corner(p) } + }) + .collect(); + SubPath::new(anchors, true) +} + +/// The seven-pointed star {7/3} round (55, 40), each edge split by 39 anchors on it: its edges +/// cross, so parts of it are covered more than once. +fn star() -> SubPath { + let tip = |i: usize| { + let t = (i * 3 % 7) as f64 / 7.0 * std::f64::consts::TAU; + Point::new(55.0 + 45.0 * t.sin(), 40.0 - 35.0 * t.cos()) + }; + let anchors = (0..7).flat_map(|i| (0..40).map(move |j| Anchor::corner(tip(i).lerp(tip(i + 1), j as f64 / 40.0)))).collect(); + SubPath::new(anchors, true) +} + +/// A document with path `sub` filled with `rule`, stroked `stroke` points wide when that is more +/// than 0, and its id. +fn long_path(sub: SubPath, rule: FillRule, stroke: f64) -> (Document, NodeId) { + let mut d = Document::new(110.0, 80.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = if stroke > 0.0 { Paint::solid(Color::BLACK) } else { Paint::None }; + let mut n = Node::path(id, PathData::single(sub), Appearance::basic(Paint::solid(Color::rgb(0.1, 0.5, 0.8)), line, stroke)); + if let NodeKind::Path { rule: r, .. } = &mut n.kind { + *r = rule; + } + let id = d.insert(Some(l), 0, n).unwrap(); + (d, id) +} + +/// `d` with anchor `i` of the first subpath of path `id` and its handles moved by `by`. +fn move_anchor(d: &Document, id: NodeId, i: usize, by: Vec2) -> Document { + let mut d = d.clone(); + if let Some(NodeKind::Path { path, .. }) = d.node_mut(id).map(|n| &mut n.kind) { + let a = &mut path.subpaths[0].anchors[i]; + (a.p, a.h_in, a.h_out) = (a.p + by, a.h_in + by, a.h_out + by); + } + d +} + +/// Moving one anchor of a long filled path changes the fill only round the segments at that +/// anchor, with either fill rule, a smooth or a corner anchor, and a closing segment. +#[test] +fn moving_an_anchor_of_a_long_path_changes_only_round_its_segments() { + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + let (old, id) = long_path(wavy(2000, 45.0, 32.0), rule, 0.0); + for i in [0, 700, 1001] { + let a = area(redraw(&old, &move_anchor(&old, id, i, Vec2::new(4.0, -3.0)))); + assert!(a.width() < 8.0 && a.height() < 8.0, "{rule:?}, anchor {i}: {a:?}"); + } + } +} + +/// The fill of an open path closes it with a line from its last anchor to its first: moving an +/// end changes where that line was and is too. +#[test] +fn moving_an_end_of_an_open_filled_path_changes_the_line_that_closes_its_fill() { + let mut half = wavy(3000, 45.0, 32.0); + half.anchors.truncate(1500); + half.closed = false; + let (old, id) = long_path(half, FillRule::NonZero, 0.0); + for i in [0, 1499] { + redraw(&old, &move_anchor(&old, id, i, Vec2::new(0.0, 12.0))); + } +} + +/// Where a path covers its middle twice, the two fill rules draw it differently: an anchor moved +/// across that part changes only round its segments. +#[test] +fn moving_an_anchor_where_a_path_crosses_itself_changes_only_round_its_segments() { + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + let (old, id) = long_path(star(), rule, 0.0); + let a = area(redraw(&old, &move_anchor(&old, id, 20, Vec2::new(-6.0, 5.0)))); + assert!(a.width() < 12.0 && a.height() < 12.0, "{rule:?}: {a:?}"); + } +} + +/// A stroke with miter joints changes round the moved anchor's segments and as far as its spikes +/// reach from them, and the open end of a stroke with square caps as far as its cap's corners. +#[test] +fn moving_an_anchor_of_a_stroked_path_changes_as_far_as_its_joints_reach() { + let (old, id) = long_path(star(), FillRule::NonZero, 1.5); + let a = area(redraw(&old, &move_anchor(&old, id, 60, Vec2::new(3.0, 4.0)))); + assert!(a.width() < 30.0, "{a:?}"); + let mut open = star(); + open.closed = false; + let (old, id) = long_path(open, FillRule::NonZero, 3.0); + let mut sq = old.clone(); + if let Some(AppearanceItem::Stroke(s)) = sq.node_mut(id).unwrap().appearance.items.get_mut(1) { + s.cap = vectorcraft_doc::LineCap::Square; + } + let a = area(redraw(&sq, &move_anchor(&sq, id, 279, Vec2::new(5.0, 2.0)))); + assert!(a.width() < 50.0, "{a:?}"); +} + +/// A compound path with a hole changes only round the moved anchor of the member it moved in. +#[test] +fn moving_an_anchor_of_a_compound_path_changes_only_round_its_segments() { + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + let mut old = Document::new(110.0, 80.0); + let l = old.layers[0].id; + let (outer, inner) = (old.alloc_id(), old.alloc_id()); + let mut hole = wavy(600, 25.0, 15.0); + if rule == FillRule::NonZero { + // Wound the other way, so it is a hole by either rule. + hole.anchors.reverse(); + for a in &mut hole.anchors { + std::mem::swap(&mut a.h_in, &mut a.h_out); + } + } + let members = vec![ + Arc::new(Node::path(outer, PathData::single(wavy(1500, 45.0, 32.0)), Appearance::default())), + Arc::new(Node::path(inner, PathData::single(hole), Appearance::default())), + ]; + let mut compound = Node::new(old.alloc_id(), NodeKind::Compound { children: members, rule }); + compound.appearance = Appearance::basic(Paint::solid(Color::rgb(0.8, 0.3, 0.1)), Paint::None, 0.0).into(); + old.insert(Some(l), 0, compound).unwrap(); + let new = move_anchor(&old, inner, 150, Vec2::new(3.0, 3.0)); + let a = area(redraw(&old, &new)); + assert!(a.width() < 8.0 && a.height() < 8.0, "{rule:?}: {a:?}"); + } +} + +/// A gradient fill and a dashed stroke are laid out by the whole path: moving one anchor changes +/// all of it, as does adding an anchor. +#[test] +fn a_path_painted_by_its_whole_shape_changes_whole_when_an_anchor_moves() { + let (plain, id) = long_path(wavy(400, 45.0, 32.0), FillRule::NonZero, 1.0); + let mut graded = plain.clone(); + let stops = vec![vectorcraft_color::GradientStop::new(0.0, Color::BLACK), vectorcraft_color::GradientStop::new(1.0, Color::WHITE)]; + graded.node_mut(id).unwrap().appearance.fill_mut().unwrap().paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new( + vectorcraft_color::Gradient::new(vectorcraft_color::GradientKind::Linear, stops), + ))); + let mut dashed = plain.clone(); + if let Some(AppearanceItem::Stroke(s)) = dashed.node_mut(id).unwrap().appearance.items.get_mut(1) { + s.dash = Some(vectorcraft_doc::Dash { pattern: vec![3.0, 2.0], ..Default::default() }); + } + for old in [graded, dashed] { + let a = area(redraw(&old, &move_anchor(&old, id, 100, Vec2::new(2.0, 2.0)))); + assert!(a.width() > 80.0 && a.height() > 60.0, "{a:?}"); + } + let mut added = plain.clone(); + if let Some(NodeKind::Path { path, .. }) = added.node_mut(id).map(|n| &mut n.kind) { + path.subpaths[0].anchors.insert(10, Anchor::corner(Point::new(50.0, 40.0))); + } + let a = area(redraw(&plain, &added)); + assert!(a.width() > 80.0 && a.height() > 60.0, "{a:?}"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6a75a918b..d411baa06 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1365,6 +1365,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When a path object moves without changing shape, the renderer draws its stroke from the outline it made before the move, moved by the same offset. It outlines the stroke again when the path is scaled, rotated or edited, when the stroke changes, and when the move takes the view past the part of a long path it outlined. The pixels are the same. At 1600 × 1200, the frame after a 1 pt nudge takes 123 ms instead of 403 ms with an open path of 200,000 anchors in view (103 ms with nothing changed), and 13 ms instead of 26 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile. When the stroke of a path moved this way has a gradient along or across it, the renderer also draws the gradient from the slices it made before the move, moved by the same offset. It makes the slices again when the path is scaled, rotated or edited, and when the stroke or its gradient changes. The pixels are the same. On Windows at 1600 × 1000, the frame after a 1 pt nudge of an open path of 200,000 anchors takes 75 ms instead of 1,237 ms with the gradient along the stroke and 69 ms instead of 1,420 ms with it across, and with an open path of 50,000 anchors 22 ms instead of 313 ms and 21 ms instead of 352 ms (medians of five interleaved runs). The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`). + When only anchors of a path or compound path move, the canvas draws again only around the segments that start or end at a moved anchor, grown by the farthest its strokes reach, instead of the whole object before and after. This holds while every fill and stroke is a solid color, and the strokes have no dashes, width profile, arrowheads or brush, and the object has no effects or opacity mask; any other object still counts whole. On a loaded Windows machine at 1600 × 1200, single-threaded in a development build, the frame after one anchor of a filled closed path moves takes 16.6 million CPU cycles instead of 56.4 million with 50,000 anchors and 65.6 million instead of 180.0 million with 200,000 anchors (medians of 15 edits, the two builds run in turn three times). Opening a document keeps the layouts it makes for its type objects, up to 128 MiB, and the first frame uses them instead of laying those objects out again. It does not keep a layout with bitmap or color glyphs or a text object with a gradient. The frame uses a layout only for a text object equal to the one laid out, and only while the fonts and the hyphenation exceptions have not changed. It uses each layout once. The renderer drops the rest when a frame that used one ends, and all of them after eight frames that use none. The frames of a threaded story are laid out while the file is read, and the first frame lays them out again. The pixels are the same. On Windows at 1600 × 1200, in medians of seven interleaved rounds of a local example, counted in the main thread's CPU cycles because other builds were running: with 5,000 point type objects in view the first frame takes 409 million cycles instead of 894 million and the open 816 million instead of 665 million, so the two together take 1,225 million instead of 1,559 million; with one area type object of 500 paragraphs the first frame takes 697 million instead of 1,193 million and the open 535 million instead of 568 million. Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms. A selection with more than 100,000 points to outline shows its bounds instead of each object's outline and anchors. Paths count their anchors. Area type and type on a path count the anchors of their frame or path, and point type counts six: its box's corners and the ends of its baseline marker. Images, symbol instances, placed documents, and area type or type on a path in perspective count the four corners of their box, and gradient meshes and envelopes count their points and the ends of their mesh lines. The hover highlight leaves out an object past that count, and such an object shows its bounds as the key object. With 32,768 type objects, images, symbol instances, gradient meshes or envelopes selected, the selection's outlines and anchors add fewer than 100 vertices to the canvas's mesh (#2460).