From d9d33c9e4b56ebbc2f46bb7ad44e299f91efa9c9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 14:09:17 -0800 Subject: [PATCH] Render: the frame after anchors of a long path move updates only the segments that touch them The renderer keeps the path of a path or compound path of 1,024 anchors or more by object id, 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 rewrites only the path elements of the segments that start or end at a moved anchor, and measures again only the runs that hold them and the runs of the segments with length before them. Other edits make the path again as before. The pixels and the cull bounds are the same. - StrokeLayer::bounds_on is unchanged. The miter tip of one joint (miter_tip) and the stroke box with given tips (bounds_with_tips) are new functions beside it, and Appearance::stroked_bounds_by takes the box of each shaped stroke, so the cull bounds of a kept path use the kept tips. - geom::bez::has_length is the predicate reach.rs used for segments that draw, made public. Docs: docs/target-app-parity.md. --- crates/doc/src/reach.rs | 62 +++++- crates/geom/src/bez.rs | 9 + crates/render/src/brush_fx.rs | 10 + crates/render/src/fx.rs | 2 +- crates/render/src/fx/bounds.rs | 17 ++ crates/render/src/kept_paths.rs | 326 ++++++++++++++++++++++++++++ crates/render/src/lib.rs | 4 + crates/render/src/renderer.rs | 4 + crates/render/src/renderer/nodes.rs | 52 ++++- crates/render/src/tests.rs | 1 + crates/render/src/tests/keptpath.rs | 210 ++++++++++++++++++ docs/target-app-parity.md | 1 + 12 files changed, 686 insertions(+), 12 deletions(-) create mode 100644 crates/render/src/kept_paths.rs create mode 100644 crates/render/src/tests/keptpath.rs diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 12965821b..0c4ef497f 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -2,7 +2,7 @@ //! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes //! up to the miter limit, projecting caps and arrowheads. -use vectorcraft_geom::bez::{CORNER_COS, kink, segments, subpaths, tangent, unit}; +use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit}; use vectorcraft_geom::hit::fill_contains; use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest}; use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2}; @@ -16,12 +16,7 @@ const ARCLEN_ACCURACY: f64 = 1e-3; /// The segments of a subpath that have length (zero-length ones, all of whose points coincide, /// have no direction). fn drawn(els: &[vectorcraft_geom::PathEl]) -> Vec { - let moves = |s: &PathSeg| match s { - PathSeg::Line(l) => l.p1 != l.p0, - PathSeg::Quad(q) => q.p1 != q.p0 || q.p2 != q.p0, - PathSeg::Cubic(c) => c.p1 != c.p0 || c.p2 != c.p0 || c.p3 != c.p0, - }; - segments(els).into_iter().filter(moves).collect() + segments(els).into_iter().filter(has_length).collect() } /// The open subpath ends of `segs`: (point, unit direction out of the path) at its start and end. @@ -166,6 +161,47 @@ impl StrokeLayer { out.union(bounds.inflate(side_max, side_max)) } + /// The tip of the miter spike this stroke paints where segment `b` follows segment `a` in a + /// subpath of its path (`closed`: a closed one), both with length ([`has_length`]), or `None` + /// when the join is not a miter, the path does not turn there or the miter limit bevels it. + pub fn miter_tip(&self, closed: bool, a: &PathSeg, b: &PathSeg) -> Option { + let side = self.side_reach(closed); + if self.join != LineJoin::Miter || side <= 0.0 { + return None; + } + miter(a.end(), tangent(a, 1.0), tangent(b, 0.0), side, self.miter_limit).map(|[_, tip, _]| tip) + } + + /// [`Self::bounds_on`] with the miter spikes' tips taken from `tips`, the box of the tips of + /// every joint of `bp` ([`Self::miter_tip`]) or `None` when there are none, instead of measured + /// along `bp`. It measures the projecting caps and arrowheads as [`Self::bounds_on`] does. + pub fn bounds_with_tips(&self, bp: &BezPath, bounds: Rect, tips: Option) -> Rect { + let els = bp.elements(); + let mut out = tips.map_or(bounds, |t| bounds.union(t)); + let mut side_max: f64 = 0.0; + // Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere. + let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed); + for (r, closed) in subpaths(bp) { + let side = self.side_reach(closed); + side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); + if side <= 0.0 { + continue; + } + if self.cap == LineCap::Square && !closed { + let segs = drawn(&els[r]); + for (at, dir) in ends(&segs).into_iter().flatten() { + let n = Vec2::new(-dir.y, dir.x) * side; + out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n); + } + } + } + for (at, _, end) in head_ends(self, bp) { + let r = self.head_reach(end); + out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r))); + } + out.union(bounds.inflate(side_max, side_max)) + } + /// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and /// outside strokes of closed paths count on their own side only (and within `tol` of the /// path); a width profile counts at its width where `p` is nearest. @@ -271,13 +307,21 @@ impl Appearance { pub fn stroked_bounds(&self, bounds: Rect, bp: impl FnOnce() -> BezPath) -> Rect { let mut path = Some(bp); let mut shape = None; + self.stroked_bounds_by(bounds, |s| { + let bp: &BezPath = shape.get_or_insert_with(|| path.take().map(|f| f()).unwrap_or_default()); + s.bounds_on(bp, bounds) + }) + } + + /// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape + /// (miter spikes, projecting caps, arrowheads or an alignment) given by `shaped`. + pub fn stroked_bounds_by(&self, bounds: Rect, mut shaped: impl FnMut(&StrokeLayer) -> Rect) -> Rect { self.painted_strokes().fold(bounds, |acc, s| { if !s.needs_shape() { let o = s.side_reach(false); return acc.union(bounds.inflate(o, o)); } - let bp: &BezPath = shape.get_or_insert_with(|| path.take().map(|f| f()).unwrap_or_default()); - acc.union(s.bounds_on(bp, bounds)) + acc.union(shaped(s)) }) } diff --git a/crates/geom/src/bez.rs b/crates/geom/src/bez.rs index 408f3abdb..8f6b3c4a0 100644 --- a/crates/geom/src/bez.rs +++ b/crates/geom/src/bez.rs @@ -39,6 +39,15 @@ pub fn segments(els: &[PathEl]) -> Vec { kurbo::segments(els.iter().copied()).collect() } +/// Whether `s` has length: a segment all of whose points coincide has none, and no direction. +pub fn has_length(s: &PathSeg) -> bool { + match s { + PathSeg::Line(l) => l.p1 != l.p0, + PathSeg::Quad(q) => q.p1 != q.p0 || q.p2 != q.p0, + PathSeg::Cubic(c) => c.p1 != c.p0 || c.p2 != c.p0 || c.p3 != c.p0, + } +} + /// Unit tangent of `s` at `t`, robust at ends where control points coincide with the end point. pub fn tangent(s: &PathSeg, t: f64) -> Vec2 { let d = match s { diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index 52f045cbb..f412d7249 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -125,6 +125,16 @@ pub(crate) fn cull_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { Some(b.inflate(pad, pad)) } +/// [`cull_bounds`] of path or compound path `n` made from `visual`, its visual bounds, when the +/// renderer measures it by those ([`crate::fx::path_cull_bounds`]); else `None`. +pub(crate) fn path_cull_bounds(symbols: &SymbolBoxes, n: &Node, visual: impl FnOnce() -> Option) -> Option> { + let b = crate::fx::path_cull_bounds(symbols, n, visual)?; + Some(b.map(|b| { + let pad = pad(symbols, n); + b.inflate(pad, pad) + })) +} + /// How far brush art can paint past `n`'s brushed strokes, or past the brushed strokes in the art /// of symbol instance `n`: `16 × w + 8`, where `w` is the widest stroke (at least 1) of `n` when it /// has a visible brushed stroke and of the objects in that art that have one diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 7c53e841c..305ece398 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -70,7 +70,7 @@ mod renderer; #[cfg(test)] pub(crate) use bounds::visual_bounds; -pub(crate) use bounds::{cull_bounds, drawn_outline, painted_reach}; +pub(crate) use bounds::{cull_bounds, drawn_outline, painted_reach, path_cull_bounds}; #[cfg(test)] pub(crate) use entries::Drawn; pub(crate) use entries::{DocInputs, FxGeom, Reads, ShadowEntry}; diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 725a62f92..86f04ffa5 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -239,6 +239,23 @@ pub(crate) fn cull_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { cull_bounds_in(symbols, n, true) } +/// [`cull_bounds`] of path or compound path `n` made from `visual`, its visual bounds as +/// [`Node::visual_bounds_with`] measures them, when [`cull_bounds`] measures `n` by those (it has +/// no crop marks, Vector Halftone, container appearance or effects); else `None`. +pub(crate) fn path_cull_bounds(symbols: &SymbolBoxes, n: &Node, visual: impl FnOnce() -> Option) -> Option> { + let plain = matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && !effects::has_crop_marks(n) + && !effects::addons::halftone::has_halftone(n) + && !effects::has_container_appearance(n) + && !has_fx(n) + && !has_object_fx(n); + plain.then(|| { + let b = visual()?; + let a = live_content_reach(symbols, n); + Some(b.inflate(a, a)) + }) +} + /// [`cull_bounds`] of `n`. With `after_reshape` true, the effects in the art of a symbol instance /// with geometry effects of its own are measured on the art as those effects reshape it /// ([`reshaped_art_effects_reach`]). With `after_reshape` false, they are measured on the art diff --git a/crates/render/src/kept_paths.rs b/crates/render/src/kept_paths.rs new file mode 100644 index 000000000..4525a0f1c --- /dev/null +++ b/crates/render/src/kept_paths.rs @@ -0,0 +1,326 @@ +//! The paths of long paths and compound paths kept by object id, with the boxes of their segments +//! and of their miter tips by runs of segments. When an object's anchors move, the renderer changes +//! only the path elements, boxes and tips of the segments that start or end at a moved anchor, +//! instead of making its path again and measuring it whole. + +use std::sync::Arc; + +use vectorcraft_doc::{LineJoin, Node, NodeKind, StrokeLayer}; +use vectorcraft_geom::bez::has_length; +use vectorcraft_geom::path::segment_bounds; +use vectorcraft_geom::{Anchor, BezPath, PathData, PathEl, PathSeg, Rect, SubPath}; + +/// Paths and compound paths with at least this many anchors are kept ([`KeptPath`]). +pub(crate) const KEPT_ANCHORS: usize = 1024; + +/// Segments measured together into one box. +const RUN: usize = 128; + +/// The boxes and the miter tips' boxes of a subpath's runs ([`KeptPath::runs`]). +#[cfg(test)] +pub(crate) type RunBoxes = (Vec>, Vec>); + +/// A subpath of a kept path. +struct Sub { + /// Where its MoveTo is in the path's elements. + start: usize, + /// Its segments (as [`SubPath::segment_count`] counts them). + segs: usize, + anchors: usize, + closed: bool, + /// The box of each run of [`RUN`] segments ([`segment_bounds`]). + boxes: Vec>, + /// The box of the miter tips of `tips_of` at the joints whose first segment is in each run. + tips: Vec>, +} + +/// The path of a long path or compound path ([`Node::stroke_path`]), with its geometric bounds by +/// runs of segments and the tips of a miter-joined stroke along it by runs of joints. +pub(crate) struct KeptPath { + /// The object the path was made from (kept alive). + pub(crate) node: Arc, + pub(crate) path: Arc, + /// The subpaths of each path of the object: the path itself, or each member of a compound path. + parts: Vec>, + /// The stroke the subpaths' tips were measured for. + tips_of: Option, + /// The last frame that used it. + pub(crate) stamp: u64, +} + +/// The paths of `n`: its own, or those of the members of a compound path whose members are all +/// paths and that is not a Shaper group or an Image Trace object. `None` for anything else. +fn paths(n: &Node) -> Option> { + match &n.kind { + NodeKind::Path { path, .. } => Some(vec![path]), + NodeKind::Compound { children, .. } if n.extras().shaper.is_none() && n.extras().trace.is_none() => { + children.iter().map(|c| c.path_data()).collect() + } + _ => None, + } +} + +/// Whether `n` has a path that is kept ([`KeptPath`]): a path or compound path with at least +/// [`KEPT_ANCHORS`] anchors, none of whose closed subpaths starts at a point that is not a number +/// (its path closes with an extra segment there). +pub(crate) fn kept(n: &Node) -> bool { + let Some(paths) = paths(n) else { return false }; + let subs = || paths.iter().flat_map(|p| &p.subpaths); + subs().map(|s| s.anchors.len()).sum::() >= KEPT_ANCHORS + && subs().filter(|s| s.closed).all(|s| s.anchors.first().is_none_or(|a| !a.p.x.is_nan() && !a.p.y.is_nan())) +} + +/// Whether anchors `a` and `b` have the same points, bit for bit. +fn same(a: &Anchor, b: &Anchor) -> bool { + let bits = |a: &Anchor| [a.p.x, a.p.y, a.h_in.x, a.h_in.y, a.h_out.x, a.h_out.y].map(f64::to_bits); + bits(a) == bits(b) +} + +/// The path element of segment `i` of `sp`, as [`SubPath::to_bezpath_into`] writes it. +fn element(sp: &SubPath, i: usize) -> PathEl { + let c = sp.segment(i); + if sp.segment_is_line(i) { PathEl::LineTo(c.p3) } else { PathEl::CurveTo(c.p1, c.p2, c.p3) } +} + +/// The point a path element ends at (`None` for a ClosePath). +fn end(el: &PathEl) -> Option { + match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) | PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => Some(p), + PathEl::ClosePath => None, + } +} + +impl Sub { + /// Segment `i`, as kurbo's `segments` makes it from the path's elements. + fn seg(&self, els: &[PathEl], i: usize) -> Option { + let p0 = end(els.get(self.start + i)?)?; + Some(match *els.get(self.start + 1 + i)? { + PathEl::LineTo(p) => PathSeg::Line(vectorcraft_geom::Line::new(p0, p)), + PathEl::QuadTo(a, p) => PathSeg::Quad(vectorcraft_geom::kurbo::QuadBez::new(p0, a, p)), + PathEl::CurveTo(a, b, p) => PathSeg::Cubic(vectorcraft_geom::CubicBez::new(p0, a, b, p)), + PathEl::MoveTo(_) | PathEl::ClosePath => return None, + }) + } + + fn runs(&self) -> usize { + self.segs.div_ceil(RUN) + } + + /// The box of the segments of run `r`. + fn run_box(&self, els: &[PathEl], r: usize) -> Option { + (r * RUN..((r + 1) * RUN).min(self.segs)).filter_map(|i| self.seg(els, i)).map(segment_bounds).reduce(|a, b| a.union(b)) + } + + /// The segment with length that follows segment `i` (round to the start when closed), or + /// `None` when there is none other than `i`. + fn next_drawn(&self, els: &[PathEl], i: usize) -> Option { + let after = i + 1..self.segs; + let round = if self.closed { 0..i } else { 0..0 }; + after.chain(round).filter_map(|j| self.seg(els, j)).find(has_length) + } + + /// The segment with length before segment `i` (round from the end when closed), when it is + /// within [`RUN`] segments of `i`: `Ok(None)` when there is none, `Err(())` when the search + /// gave up. + fn prev_drawn(&self, els: &[PathEl], i: usize) -> Result, ()> { + let before = (0..i).rev(); + let round = if self.closed { (i + 1..self.segs).rev() } else { (0..0).rev() }; + for (steps, j) in before.chain(round).enumerate() { + if steps >= RUN { + return Err(()); + } + if self.seg(els, j).is_some_and(|s| has_length(&s)) { + return Ok(Some(j)); + } + } + Ok(None) + } + + /// The box of the miter tips of `st` at the joints whose first segment is in run `r` + /// ([`StrokeLayer::miter_tip`]). + fn run_tips(&self, els: &[PathEl], r: usize, st: &StrokeLayer) -> Option { + let mut out: Option = None; + for i in r * RUN..((r + 1) * RUN).min(self.segs) { + let Some(a) = self.seg(els, i).filter(has_length) else { continue }; + let Some(b) = self.next_drawn(els, i) else { continue }; + if let Some(tip) = st.miter_tip(self.closed, &a, &b) { + out = Some(out.map_or(Rect::from_points(tip, tip), |o| o.union_pt(tip))); + } + } + out + } +} + +/// What [`KeptPath::tips`] measures `st`'s tips by: its reach from an open and a closed path and +/// its miter limit. +fn tips_key(st: &StrokeLayer) -> [u64; 3] { + [st.side_reach(false).to_bits(), st.side_reach(true).to_bits(), st.miter_limit.to_bits()] +} + +impl KeptPath { + /// The kept path of `n`, or `None` when it has none ([`kept`]). + pub(crate) fn new(n: &Arc, stamp: u64) -> Option { + if !kept(n) { + return None; + } + let path = n.stroke_path()?; + let els = path.elements(); + let mut start = 0; + let mut parts = vec![]; + for pd in paths(n)? { + let mut subs = vec![]; + for sp in &pd.subpaths { + let segs = sp.segment_count(); + let mut sub = Sub { start, segs, anchors: sp.anchors.len(), closed: sp.closed, boxes: vec![], tips: vec![] }; + sub.boxes = (0..sub.runs()).map(|r| sub.run_box(els, r)).collect(); + if !sp.anchors.is_empty() { + start += 1 + segs + usize::from(sp.closed); + } + subs.push(sub); + } + parts.push(subs); + } + (start == els.len()).then(|| Self { node: n.clone(), path: Arc::new(path), parts, tips_of: None, stamp }) + } + + /// Bring the path up to date with `n`, a path with the same subpaths and anchor counts as the + /// object it was made from: the elements of the segments that start or end at an anchor that + /// moved are made again, with the boxes of their runs and the tips of those runs and of the + /// runs of the segments with length before them. False, and nothing changed, when `n` has + /// other subpaths, other anchor counts or no kept path. + pub(crate) fn update(&mut self, n: &Arc) -> bool { + let old = self.node.clone(); + let (Some(was), Some(now)) = (paths(&old), paths(n)) else { return false }; + let shape = + |ps: &[&PathData]| ps.iter().map(|p| p.subpaths.iter().map(|s| (s.anchors.len(), s.closed)).collect::>()).collect::>(); + if !kept(n) || shape(&was) != shape(&now) { + return false; + } + // Per subpath whose anchors moved: (member, subpath, whether its first anchor moved, the + // segments that start or end at a moved anchor). + let mut moved: Vec<(usize, usize, bool, Vec)> = vec![]; + for (pi, (w, p)) in was.iter().zip(&now).enumerate() { + if std::ptr::eq(*w, *p) { + continue; + } + for (si, (ws, ps)) in w.subpaths.iter().zip(&p.subpaths).enumerate() { + let Some(sub) = self.parts.get(pi).and_then(|s| s.get(si)) else { return false }; + let (mut first, mut segs) = (false, vec![]); + for (k, (a, b)) in ws.anchors.iter().zip(&ps.anchors).enumerate() { + if same(a, b) { + continue; + } + if k >= 1 && k - 1 < sub.segs { + segs.push(k - 1); + } + if k == 0 { + first = true; + if sub.closed && sub.segs > 0 { + segs.push(sub.segs - 1); + } + } + if k < sub.segs { + segs.push(k); + } + } + if first || !segs.is_empty() { + segs.sort_unstable(); + segs.dedup(); + moved.push((pi, si, first, segs)); + } + } + } + self.node = n.clone(); + if moved.is_empty() { + return true; + } + let path = Arc::make_mut(&mut self.path); + let els = path.elements_mut(); + for (pi, si, first, segs) in &moved { + let (Some(sub), Some(sp)) = (self.parts.get(*pi).and_then(|s| s.get(*si)), now.get(*pi).and_then(|p| p.subpaths.get(*si))) else { + continue; + }; + if *first && let (Some(a), Some(el)) = (sp.anchors.first(), els.get_mut(sub.start)) { + *el = PathEl::MoveTo(a.p); + } + for &i in segs { + if let Some(el) = els.get_mut(sub.start + 1 + i) { + *el = element(sp, i); + } + } + } + let els = path.elements(); + for (pi, si, _, segs) in moved { + let tips_of = self.tips_of.as_ref(); + let Some(sub) = self.parts.get_mut(pi).and_then(|s| s.get_mut(si)) else { continue }; + // Many moved: the subpath is measured again whole. + let all = segs.len() * 4 > sub.segs; + let mut boxes: Vec = if all { (0..sub.runs()).collect() } else { segs.iter().map(|i| i / RUN).collect() }; + boxes.dedup(); + for r in boxes { + sub.boxes[r] = sub.run_box(els, r); + } + let Some(st) = tips_of else { continue }; + // A joint's tip changes with its segments and with which segment with length comes + // next: the runs of the moved segments and of the segments with length before them. + let mut runs = vec![]; + let mut whole = all; + for &i in &segs { + runs.push(i / RUN); + match sub.prev_drawn(els, i) { + Ok(Some(j)) => runs.push(j / RUN), + Ok(None) => {} + Err(()) => whole = true, + } + } + if whole { + runs = (0..sub.runs()).collect(); + } + runs.sort_unstable(); + runs.dedup(); + for r in runs { + sub.tips[r] = sub.run_tips(els, r, st); + } + } + true + } + + /// The object's geometric bounds ([`Node::geometric_bounds`]): each path's as + /// [`PathData::bounds`] measures it, joined. + pub(crate) fn geometric(&self) -> Option { + let els = self.path.elements(); + self.parts.iter().fold(None, |acc, subs| { + let anchors: usize = subs.iter().map(|s| s.anchors).sum(); + let b = match anchors { + 0 => None, + // One anchor: the point itself. + 1 => subs.iter().find(|s| s.anchors == 1).and_then(|s| els.get(s.start)).and_then(end).map(|p| Rect::from_points(p, p)), + _ => Some(subs.iter().flat_map(|s| s.boxes.iter().flatten()).copied().reduce(|a, b| a.union(b)).unwrap_or_default()), + }; + vectorcraft_geom::union_opt(acc, b) + }) + } + + /// The box of the miter tips of `st` at every joint of the path ([`StrokeLayer::miter_tip`]), + /// for [`StrokeLayer::bounds_with_tips`]: measured whole when the tips kept are another + /// stroke's, then kept up to date with the path. + pub(crate) fn tips(&mut self, st: &StrokeLayer) -> Option { + if st.join != LineJoin::Miter { + return None; + } + if self.tips_of.as_ref().is_none_or(|t| t.join != LineJoin::Miter || tips_key(t) != tips_key(st)) { + let els = self.path.elements(); + for sub in self.parts.iter_mut().flatten() { + sub.tips = (0..sub.runs()).map(|r| sub.run_tips(els, r, st)).collect(); + } + self.tips_of = Some(st.clone()); + } + self.parts.iter().flatten().flat_map(|s| s.tips.iter().flatten()).copied().reduce(|a, b| a.union(b)) + } + + /// The boxes and tips kept per run of each subpath, and the stroke the tips were measured for. + #[cfg(test)] + pub(crate) fn runs(&self) -> (Vec, Option) { + (self.parts.iter().flatten().map(|s| (s.boxes.clone(), s.tips.clone())).collect(), self.tips_of.clone()) + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8bf6b0d23..a145ebad1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -60,6 +60,9 @@ pub struct Renderer { /// Worker threads for the multithreaded rasterizer (0 = single-threaded). pub threads: u16, geom: PtrMap, + /// The paths of long paths and compound paths, by object id (see [`Self::kept_path`]): after + /// an edit, `geom` makes the new object's path and bounds from the path kept for its id. + kept: PtrMap, /// The symbols (name and art) of the document last drawn, which `geom`'s bounds measured /// symbol instances by (see [`Self::check_symbols`]). The art is held weakly: the reference /// keeps the art's allocation, so no other art can get its address, and does not keep the @@ -203,6 +206,7 @@ mod bounds; mod clock; mod frame; mod geometry; +mod kept_paths; mod node_checks; mod options; mod renderer; diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 1e07075e7..87c283f32 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -34,6 +34,7 @@ impl Renderer { clips: FrameCache::new(lru::CLIP_BUDGET, 256), threads: default_threads(), geom: PtrMap::default(), + kept: PtrMap::default(), symbols: vec![], plugin_revision: 0, strokes: PtrMap::default(), @@ -180,6 +181,9 @@ impl Renderer { // them, a drag left up to that many old copies of the object alive, each with all its // members (and, for a compound path, its assembled path). self.geom.retain(|_, e| g - e.stamp <= 3); + // A kept path stays while its object is in `geom`, which keeps the objects still drawn. + let geom = &self.geom; + self.kept.retain(|_, e| g - e.stamp <= 3 || geom.get(&(Arc::as_ptr(&e.node) as usize)).is_some_and(|k| Arc::ptr_eq(&k.node, &e.node))); if self.strokes.len() > 1024 { self.strokes.retain(|_, e| g - e.stamp <= 3); } diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index 79455cd75..b69ecb10f 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -4,6 +4,7 @@ use super::{ holds_instance, holds_plugin_effect, images, kurbo, lru, mask_value, painted_items, plain_image, polygon_length, rects_overlap, shape_masked_image, single_plain_fill, text_geom_snapped, white_shape_mask, }; +use crate::kept_paths::{self, KeptPath}; impl Renderer { /// Cached cull bounds of a node (containers union their cached children), symbol instances @@ -28,12 +29,56 @@ impl Renderer { } acc } - _ => brush_fx::cull_bounds(symbols, a), + _ => self.kept_bounds(symbols, a).unwrap_or_else(|| brush_fx::cull_bounds(symbols, a)), }; self.geom.insert(key, GeomEntry { node: a.clone(), bounds: b, path: None, stamp: self.stamp }); b } + /// The kept path of long path or compound path `a` ([`KeptPath`]), made, or brought up to date + /// from the one kept for its id, when it is not `a`'s yet. `None` when `a` has none. + fn kept_path(&mut self, a: &Arc) -> Option<&mut KeptPath> { + if !kept_paths::kept(a) { + return None; + } + let stamp = self.stamp; + let made = match self.kept.get_mut(&a.id) { + Some(k) if Arc::ptr_eq(&k.node, a) => true, + Some(k) => { + // The object the path was made from, when no longer drawn, shares the path through + // its `geom` entry: dropping that entry lets the path change in place. + let old = Arc::as_ptr(&k.node) as usize; + if self.geom.get(&old).is_some_and(|e| Arc::ptr_eq(&e.node, &k.node) && e.stamp != stamp) { + self.geom.remove(&old); + } + k.update(a) + } + None => false, + }; + if !made { + let k = KeptPath::new(a, stamp)?; + self.kept.insert(a.id, k); + } + let k = self.kept.get_mut(&a.id)?; + k.stamp = stamp; + Some(k) + } + + /// Cull bounds of long path or compound path `a` measured from its kept path + /// ([`Self::kept_path`]): the same as [`brush_fx::cull_bounds`]. `None` when it has no kept path + /// or is measured another way ([`brush_fx::path_cull_bounds`]). + fn kept_bounds(&mut self, symbols: &SymbolBoxes, a: &Arc) -> Option> { + let k = self.kept_path(a)?; + brush_fx::path_cull_bounds(symbols, a, || { + let b = k.geometric()?; + let bp = k.path.clone(); + Some(a.appearance.stroked_bounds_by(b, |s| { + let tips = k.tips(s); + s.bounds_with_tips(&bp, b, tips) + })) + }) + } + /// Drop the cached bounds of symbol instances and of the objects that hold them when `doc`'s /// symbols are not those of the document last drawn (by name and art): those bounds measured /// the instances by their symbol's art. @@ -105,7 +150,10 @@ impl Renderer { { return Some(p.clone()); } - let p = Arc::new(a.stroke_path()?); + let p = match self.kept_path(a) { + Some(k) => k.path.clone(), + None => Arc::new(a.stroke_path()?), + }; let bounds = self.bounds_of(symbols, a); self.geom.insert(key, GeomEntry { node: a.clone(), bounds, path: Some(p.clone()), stamp: self.stamp }); Some(p) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index c82daf32d..ffeb6cc6f 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -19,6 +19,7 @@ pub(super) mod fontchange; mod freeform; mod fxzoom; mod isolation; +mod keptpath; mod knockout; mod layeropts; mod longstroke; diff --git a/crates/render/src/tests/keptpath.rs b/crates/render/src/tests/keptpath.rs new file mode 100644 index 000000000..94c5621b9 --- /dev/null +++ b/crates/render/src/tests/keptpath.rs @@ -0,0 +1,210 @@ +//! The paths of long paths and compound paths are kept by object id (`kept_paths`): after an edit, +//! the renderer changes only the parts of the path that the edit moved, and draws and culls the +//! object as a renderer that makes its path again does. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{Anchor, PathData, Point, SubPath, Vec2}; + +use crate::*; + +const SIZE: u32 = 120; + +fn stroke(width: f64, join: LineJoin, cap: LineCap) -> StrokeLayer { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width); + st.join = join; + st.cap = cap; + st.miter_limit = 10.0; + st +} + +/// `n` anchors 8 pt apart from x = `x0`, up and down by 30 pt: sharp corners, every fifth anchor +/// smooth, and every 50th anchor doubled (a segment without length). +fn zigzag(n: usize, x0: f64) -> SubPath { + let mut anchors = vec![]; + for i in 0..n { + let p = Point::new(x0 + i as f64 * 8.0, if i % 2 == 0 { 0.0 } else { 30.0 }); + anchors.push(if i % 5 == 0 { Anchor::smooth(p, p + Vec2::new(3.0, 4.0)) } else { Anchor::corner(p) }); + if i % 50 == 25 { + anchors.push(Anchor::corner(p)); + } + } + SubPath::new(anchors, false) +} + +/// A closed polygon of `n` anchors round a circle of radius `r` about `c`. +fn ring(n: usize, c: Point, r: f64) -> SubPath { + let points: Vec = (0..n).map(|i| c + Vec2::from_angle(i as f64 / n as f64 * std::f64::consts::TAU) * r).collect(); + SubPath::polyline(&points, true) +} + +/// `d` with `n` added to its first layer, and `n`'s id. +fn add(d: &mut Document, n: Node) -> NodeId { + let (id, l) = (n.id, d.layers[0].id); + d.insert(Some(l), 0, n).unwrap(); + id +} + +fn look(items: Vec) -> Appearance { + Appearance { items: items.into_iter().map(AppearanceItem::Stroke).collect(), ..Default::default() } +} + +/// The view with (12,000, 15) in the middle of the frame. +fn view() -> Affine { + Affine::translate((f64::from(SIZE) / 2.0 - 12_000.0, f64::from(SIZE) / 2.0 - 15.0)) +} + +fn render(r: &mut Renderer, d: &Document) -> Rendered { + r.render(d, SIZE, SIZE, view(), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +/// Draw `d` with `r` and check what `r` drew and kept for object `id` against what a new renderer +/// draws and what the object measures without kept paths. +fn check(r: &mut Renderer, d: &Document, id: NodeId, what: &str) { + let img = render(r, d); + assert!(img.pixels == render(&mut Renderer::new(), d).pixels, "{what}: the same pixels as a new renderer"); + let n = d.node(id).unwrap(); + let e = r.geom.get(&(n as *const Node as usize)).unwrap_or_else(|| panic!("{what}: measured")); + assert_eq!(e.bounds, crate::brush_fx::cull_bounds(&d.symbol_boxes(), n), "{what}: the same cull bounds"); + if let Some(p) = &e.path { + assert!(**p == n.stroke_path().unwrap(), "{what}: the same path"); + } + let k = r.kept.get(&id).unwrap_or_else(|| panic!("{what}: its path kept")); + assert!(std::ptr::eq(Arc::as_ptr(&k.node), n), "{what}: its path kept"); + // The boxes and miter tips kept, run by run, are those of the path measured whole. + let (runs, tips_of) = k.runs(); + let mut whole = crate::kept_paths::KeptPath::new(&Arc::new(n.clone()), 0).unwrap(); + if let Some(st) = &tips_of { + whole.tips(st); + } + assert!(runs == whole.runs().0, "{what}: the same boxes and tips as the path measured whole"); +} + +fn path_of(d: &mut Document, id: NodeId) -> &mut PathData { + d.node_mut(id).and_then(|n| n.path_data_mut()).unwrap() +} + +fn moved(a: &mut Anchor, by: Vec2) { + *a = a.transform(Affine::translate(by)); +} + +#[test] +fn a_long_path_draws_and_culls_after_each_edit_as_when_its_path_is_made_again() { + let mut d = Document::new(1000.0, 1000.0); + let path = PathData::new(vec![ + zigzag(3000, 0.0), + SubPath::new(vec![Anchor::corner(Point::new(50.0, 200.0))], false), + SubPath::new(vec![], false), + ring(200, Point::new(12_000.0, 3000.0), 100.0), + ]); + let n = Node::path(d.alloc_id(), path, look(vec![stroke(6.0, LineJoin::Miter, LineCap::Butt), stroke(14.0, LineJoin::Round, LineCap::Round)])); + let id = add(&mut d, n); + let mut r = Renderer::new(); + check(&mut r, &d, id, "drawn first"); + let first = d.clone(); + type Edit = fn(&mut PathData); + let edits: [(&str, Edit); 12] = [ + ("one anchor moved", |p| moved(&mut p.subpaths[0].anchors[1530], Vec2::new(0.0, 40.0))), + ("a handle moved", |p| { + let a = &mut p.subpaths[0].anchors[1532]; + a.h_out = a.p + Vec2::new(10.0, -20.0); + }), + ("an anchor moved onto the one before it", |p| { + let at = p.subpaths[0].anchors[1534].p; + p.subpaths[0].anchors[1535] = Anchor::corner(at); + }), + ("that anchor moved off it", |p| moved(&mut p.subpaths[0].anchors[1535], Vec2::new(5.0, 5.0))), + ("a doubled anchor moved off its twin", |p| moved(&mut p.subpaths[0].anchors[1536], Vec2::new(0.0, -12.0))), + ("1,200 anchors moved", |p| p.subpaths[0].anchors[..1200].iter_mut().for_each(|a| moved(a, Vec2::new(2.0, 1.0)))), + ("an anchor added", |p| _ = p.subpaths[0].insert_anchor(1520, 0.5)), + ("an anchor deleted", |p| _ = p.subpaths[0].anchors.remove(1521)), + ("the subpath closed", |p| p.subpaths[0].closed = true), + ("the first anchor of the closed subpath moved", |p| moved(&mut p.subpaths[0].anchors[0], Vec2::new(0.0, -60.0))), + ("the subpath opened", |p| p.subpaths[0].closed = false), + ("an anchor of another subpath moved into the view", |p| p.subpaths[3].anchors[50] = Anchor::corner(Point::new(11_990.0, 20.0))), + ]; + for (what, edit) in edits { + edit(path_of(&mut d, id)); + check(&mut r, &d, id, what); + } + check(&mut r, &first, id, "undone to the start"); + moved(&mut path_of(&mut d, id).subpaths[1].anchors[0], Vec2::new(5.0, 5.0)); + check(&mut r, &d, id, "redone, the one-anchor subpath moved"); + path_of(&mut d, id).subpaths[2].anchors.push(Anchor::corner(Point::new(12_010.0, 40.0))); + check(&mut r, &d, id, "the empty subpath given an anchor"); +} + +#[test] +fn a_long_compound_path_draws_and_culls_after_each_edit_as_when_its_path_is_made_again() { + let mut d = Document::new(1000.0, 1000.0); + let none = Appearance::default(); + let members = [ + PathData::single(zigzag(1500, 6000.0)), + PathData::single(ring(1500, Point::new(12_000.0, 15.0), 45.0)), + PathData::single(SubPath::new(vec![Anchor::corner(Point::new(11_980.0, 0.0))], false)), + PathData::new(vec![]), + ]; + let ids: Vec = members.iter().map(|_| d.alloc_id()).collect(); + let children = ids.iter().zip(members).map(|(id, p)| Arc::new(Node::path(*id, p, none.clone()))).collect(); + let mut n = Node::new(d.alloc_id(), NodeKind::Compound { children, rule: FillRule::EvenOdd }); + n.appearance = look(vec![stroke(4.0, LineJoin::Miter, LineCap::Square)]).into(); + let id = add(&mut d, n); + let mut r = Renderer::new(); + check(&mut r, &d, id, "drawn first"); + let first = d.clone(); + moved(&mut path_of(&mut d, ids[1]).subpaths[0].anchors[700], Vec2::new(-30.0, 10.0)); + check(&mut r, &d, id, "an anchor of the ring moved"); + moved(&mut path_of(&mut d, ids[0]).subpaths[0].anchors[0], Vec2::new(-30.0, 10.0)); + check(&mut r, &d, id, "the first anchor of the zigzag moved"); + moved(&mut path_of(&mut d, ids[2]).subpaths[0].anchors[0], Vec2::new(-30.0, 10.0)); + check(&mut r, &d, id, "the one-anchor member moved"); + path_of(&mut d, ids[3]).subpaths.push(SubPath::new(vec![Anchor::corner(Point::new(12_020.0, 30.0))], false)); + check(&mut r, &d, id, "the empty member given an anchor"); + check(&mut r, &first, id, "undone to the start"); +} + +#[test] +fn edits_in_runs_of_segments_without_length_measure_as_when_the_path_is_made_again() { + // A closed subpath whose anchors move onto their neighbours and off them again, the joints' + // miter tips skipping the segments without length, round the start too. + let mut d = Document::new(1000.0, 1000.0); + let mut sp = zigzag(1200, 11_900.0); + sp.closed = true; + let n = Node::path(d.alloc_id(), PathData::single(sp), look(vec![stroke(5.0, LineJoin::Miter, LineCap::Butt)])); + let id = add(&mut d, n); + let mut r = Renderer::new(); + check(&mut r, &d, id, "drawn first"); + let mut seed: u64 = 7; + let mut next = |m: usize| { + seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (seed >> 33) as usize % m + }; + for step in 0..60 { + let p = path_of(&mut d, id); + let n = p.subpaths[0].anchors.len(); + // Near the start, the end, or where one run of 128 segments ends and the next starts. + let k = [next(40), n - 1 - next(40), 128 * (1 + next(8)) + next(3)][step % 3]; + let to = match next(3) { + 0 => p.subpaths[0].anchors[(k + n - 1) % n].p, + 1 => p.subpaths[0].anchors[(k + 1) % n].p, + _ => p.subpaths[0].anchors[k].p + Vec2::new(next(20) as f64 - 10.0, next(20) as f64 - 10.0), + }; + p.subpaths[0].anchors[k] = Anchor::corner(to); + check(&mut r, &d, id, &format!("step {step}, anchor {k}")); + } +} + +#[test] +fn moving_one_anchor_of_a_long_path_changes_its_kept_path_in_place() { + let mut d = Document::new(1000.0, 1000.0); + let n = Node::path(d.alloc_id(), PathData::single(zigzag(3000, 0.0)), look(vec![stroke(6.0, LineJoin::Miter, LineCap::Butt)])); + let id = add(&mut d, n); + let mut r = Renderer::new(); + render(&mut r, &d); + let kept = |r: &Renderer| r.kept.get(&id).map(|k| Arc::as_ptr(&k.path) as usize); + let before = kept(&r); + assert!(before.is_some(), "the path is kept"); + moved(&mut path_of(&mut d, id).subpaths[0].anchors[1500], Vec2::new(0.0, 40.0)); + check(&mut r, &d, id, "one anchor moved"); + assert_eq!(kept(&r), before, "the kept path changed in place"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 509cfa546..98a289d62 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1265,6 +1265,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With more than 10,000 type on a path objects selected, the Selection and Direct Selection tools neither draw nor hit-test the objects' brackets. The renderer outlines the stroke of a path object with more than 1,024 path elements, without dashes or a width profile, only in and around the view, with a margin of the view's size on each side, and outlines it again when the view moves past that margin. The pixels are the same. At 1600 × 1200, the frame after one anchor of an open path of 200,000 anchors moves takes 28 to 30 ms instead of 103 ms, and with a closed path of 50,000 anchors 10 ms instead of 30 ms (`cargo run --release -p vectorcraft-engine --example anchor_bench`). The renderer outlines a dashed stroke of such a path the same way, without a width profile, and draws its dashes and dots in the same places as when the whole path is dashed, so the pixels are the same. A pattern too fine to draw is drawn solid, as before, and its stroke is outlined near the view too. At 1600 × 1200, the frame after one anchor moves takes 14 ms instead of 102 ms with a filled closed path of 50,000 anchors and a stroke of 6 pt dashes and 4 pt gaps (`cargo run --release -p vectorcraft-engine --example anchor_bench`, which now includes that document). + 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`). 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). Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs, Transform Each with Copy and each move of an Alt-drag take time in proportion to the size of the document (#1429).