diff --git a/crates/render/src/frame.rs b/crates/render/src/frame.rs index 81b719893..715237b06 100644 --- a/crates/render/src/frame.rs +++ b/crates/render/src/frame.rs @@ -30,14 +30,21 @@ pub(crate) type PtrMap = HashMap, pub(crate) outline: Arc, pub(crate) stamp: u64, pub(crate) crop: Option, + pub(crate) line: Option>, } /// The slices of a stroke whose gradient runs along or across it, each with its paint and bounds. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a145ebad1..cace4f165 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -74,6 +74,9 @@ pub struct Renderer { /// Expanded stroke outlines keyed by ([`Self::stroke_owner`], stroke layer address, width bits, /// tolerance level): panning re-renders reuse them instead of re-expanding every stroke. strokes: PtrMap, + /// The key in `strokes` of the latest outline of each stroke of an object, under its + /// [`MovedKey`]: a moved copy of the object draws that outline moved ([`Self::moved_outline`]). + moved_strokes: PtrMap, /// Expanded character stroke outlines, with the glyph geometry kept in `texts` they belong to, /// keyed by ([`Self::stroke_owner`] of that geometry, run, width bits, tolerance level): type /// that doesn't change draws them again instead of expanding its strokes in every frame. Within @@ -215,7 +218,9 @@ mod threads; pub use bounds::{drawn_bounds, drawn_outline, effect_bounds, painted_bounds}; pub use frame::FrameStats; -use frame::{ClipEntry, ClipPaintEntry, Frame, FxArtEntry, GeomEntry, PlacedEntry, PlacedKey, PtrHasher, PtrMap, SliceEntry, StrokeEntry, StrokeKey}; +use frame::{ + ClipEntry, ClipPaintEntry, Frame, FxArtEntry, GeomEntry, MovedKey, PlacedEntry, PlacedKey, PtrHasher, PtrMap, SliceEntry, StrokeEntry, StrokeKey, +}; #[cfg(test)] use frame::{OFFSCREEN_PIXELS, Slices}; pub use geometry::region_pixels; diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 87c283f32..638de74e8 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -1,8 +1,8 @@ use super::blobs::{blob_key, blob_pixmap, image_mip, lookup, mask_value, mip_level, plain_image, shape_masked_image, white_shape_mask}; use super::clock::now; use super::frame::{ - ClipEntry, ClipPaintEntry, Frame, FrameStats, GeomEntry, MAX_INLINE_DEPTH, MISSING_INLINE_LOGGED, PlacedEntry, PlacedKey, PtrMap, SliceEntry, - Slices, StrokeEntry, + ClipEntry, ClipPaintEntry, Frame, FrameStats, GeomEntry, MAX_INLINE_DEPTH, MISSING_INLINE_LOGGED, MovedKey, PlacedEntry, PlacedKey, PtrMap, + SliceEntry, Slices, StrokeEntry, }; use super::geometry::{MAX_GUIDE_DASHES, polygon_length, rects_overlap, region_pixels}; use super::node_checks::{ @@ -24,7 +24,7 @@ mod paint; mod text; #[cfg(test)] -pub(crate) use paint::{CROP_STROKES, crop_line}; +pub(crate) use paint::{CROP_STROKES, STROKES_OUTLINED, crop_line, offset_between}; impl Renderer { pub fn new() -> Self { @@ -38,6 +38,7 @@ impl Renderer { symbols: vec![], plugin_revision: 0, strokes: PtrMap::default(), + moved_strokes: PtrMap::default(), char_strokes: FrameCache::new(lru::CHAR_STROKE_BUDGET, 0), cur: None, made_again: false, @@ -186,6 +187,8 @@ impl Renderer { 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); + let strokes = &self.strokes; + self.moved_strokes.retain(|_, k| strokes.contains_key(k)); } if self.stroke_slices.len() > 256 { self.stroke_slices.retain(|_, e| g - e.stamp <= 3); diff --git a/crates/render/src/renderer/paint.rs b/crates/render/src/renderer/paint.rs index f31863aa1..875b39bca 100644 --- a/crates/render/src/renderer/paint.rs +++ b/crates/render/src/renderer/paint.rs @@ -1,7 +1,7 @@ use super::{ - Affine, AppearanceItem, Arc, BezPath, BlendMode, Compose, FillRule, Frame, Ink, Mix, Node, Rect, RenderContext, Renderer, Shape, SliceEntry, - Slices, StrokeAlign, StrokeEntry, StrokeLayer, TextGeom, blend_mode, drawable, effects, fill_rule, fx, kurbo, lru, owns_stroke, peniko, - rects_overlap, + Affine, AppearanceItem, Arc, BezPath, BlendMode, Compose, FillRule, Frame, Ink, Mix, MovedKey, Node, Rect, RenderContext, Renderer, Shape, + SliceEntry, Slices, StrokeAlign, StrokeEntry, StrokeLayer, TextGeom, blend_mode, drawable, effects, fill_rule, fx, kurbo, lru, owns_stroke, + peniko, rects_overlap, }; use crate::paint; @@ -269,32 +269,90 @@ impl Renderer { e.stamp = self.stamp; return Some(e.outline.clone()); } + // Under its object's id, the stroke's place and the outline's width and tolerance, the key + // 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())); + if let Some(mk) = moved + && let Some((outline, crop)) = self.moved_outline(mk, &node, st, line, v) + { + self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop, line: kept }); + self.keep_latest(mk, key); + return Some(outline); + } + #[cfg(test)] + STROKES_OUTLINED.with(|n| n.set(n.get() + 1)); // A long stroke without a width profile is outlined only in and around the view, with a // margin of the view's own size on each side ([`crop_dashed`] for a dashed one, // [`crop_line`] otherwise). let region = v.inflate(v.width(), v.height()); let tol = 2f64.powi(level); - if crops() + let (outline, crop) = if crops() && line.elements().len() > CROP_ELEMENTS && st.dash.is_some() && st.profile.is_none() && let Some(outline) = crop_dashed(line, st, width, region, crop_reach(st, width, f.px, tol), tol) { - let outline = Arc::new(outline); - self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop: Some(region) }); - return Some(outline); - } - let cropped = (crops() && line.elements().len() > CROP_ELEMENTS && st.dash.is_none() && st.profile.is_none()) - .then(|| crop_line(line, region, crop_reach(st, width, f.px, tol), width)) - .flatten(); - let (outline, crop) = match cropped { - Some(part) => (Arc::new(effects::stroke::line_outline(&part, st, width, tol)), Some(region)), - None => (expand(), None), + (Arc::new(outline), Some(region)) + } else { + let cropped = (crops() && line.elements().len() > CROP_ELEMENTS && st.dash.is_none() && st.profile.is_none()) + .then(|| crop_line(line, region, crop_reach(st, width, f.px, tol), width)) + .flatten(); + match cropped { + Some(part) => (Arc::new(effects::stroke::line_outline(&part, st, width, tol)), Some(region)), + None => (expand(), None), + } }; - self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop }); + self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop, line: kept }); + if let Some(mk) = moved { + self.keep_latest(mk, key); + } Some(outline) } + /// Lists `key` as the latest outline under `mk`, and drops the line kept with the outline listed + /// there before, which no moved copy draws from any more: one line is kept per [`MovedKey`]. + fn keep_latest(&mut self, mk: MovedKey, key: crate::frame::StrokeKey) { + if let Some(before) = self.moved_strokes.insert(mk, key) + && before != key + && let Some(e) = self.strokes.get_mut(&before) + { + e.line = None; + } + } + + /// 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`. + fn moved_outline(&self, mk: MovedKey, node: &Node, st: &StrokeLayer, line: &BezPath, v: Rect) -> Option<(Arc, Option)> { + let e = self.strokes.get(self.moved_strokes.get(&mk)?)?; + let was = e.line.as_deref()?; + if e.node.id != node.id { + return None; + } + let Some(AppearanceItem::Stroke(old)) = e.node.appearance.items.get(mk.1) else { + return None; + }; + let unpainted = |s: &StrokeLayer| StrokeLayer { paint: vectorcraft_color::Paint::None, ..s.clone() }; + if !std::ptr::eq(old, st) && unpainted(old) != unpainted(st) { + return None; + } + let by = offset_between(was, 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; + } + if by == kurbo::Vec2::ZERO { + return Some((e.outline.clone(), crop)); + } + let mut outline = (*e.outline).clone(); + outline.apply_affine(Affine::translate(by)); + Some((Arc::new(outline), crop)) + } + /// What the `strokes`, `char_strokes` and `stroke_slices` keys hold for a stroke owner at /// `address` (`self.cur` or kept glyph geometry): the address, or 0 while art made again in /// every frame is drawn ([`Self::made_again`]). @@ -311,6 +369,38 @@ pub(crate) const CROP_ELEMENTS: usize = 1024; thread_local! { /// Whether long strokes are outlined near the view only on this thread ([`crop_line`]). pub(crate) static CROP_STROKES: std::cell::Cell = const { std::cell::Cell::new(true) }; + /// How many stroke outlines of nodes [`Renderer::cached_stroke`] has made on this thread. + pub(crate) static STROKES_OUTLINED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The offset that moves path `was` onto path `now`, when `now` is `was` moved (each coordinate +/// within a few units in the last place of the moved one, the error that adding the offset and +/// measuring it from the first points can make). `None` otherwise, and for an empty path. +pub(crate) fn offset_between(was: &BezPath, now: &BezPath) -> Option { + use kurbo::PathEl; + let (was, now) = (was.elements(), now.elements()); + if was.len() != now.len() { + 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())), + _ => 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 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), + (PathEl::CurveTo(a0, a1, a2), PathEl::CurveTo(b0, b1, b2)) => near(a0, b0) && near(a1, b1) && near(a2, b2), + (PathEl::ClosePath, PathEl::ClosePath) => true, + _ => false, + }); + moved.then_some(by) } fn crops() -> bool { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ffeb6cc6f..b7884026b 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -24,6 +24,7 @@ mod knockout; mod layeropts; mod longstroke; mod memory; +mod movedstroke; mod objectfx; mod placed_document; mod rasterize; diff --git a/crates/render/src/tests/movedstroke.rs b/crates/render/src/tests/movedstroke.rs new file mode 100644 index 000000000..67b7de905 --- /dev/null +++ b/crates/render/src/tests/movedstroke.rs @@ -0,0 +1,206 @@ +//! A moved object draws its stroke from the outline made for it before it moved, moved by the same +//! offset, with the pixels a new renderer draws. A change other than a move outlines the stroke +//! again. + +use vectorcraft_color::{Color, Gradient, GradientPaint, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer, WidthProfile}; +use vectorcraft_geom::{PathData, Point, Vec2}; + +use crate::renderer::{STROKES_OUTLINED, offset_between}; +use crate::*; + +const SIZE: u32 = 200; + +/// The view that shows `at` in the middle of the 200×200 frame. +fn view_at(at: Point) -> Affine { + Affine::translate((100.0 - at.x, 100.0 - at.y)) +} + +fn render(r: &mut Renderer, d: &Document, at: Point) -> Rendered { + r.render(d, SIZE, SIZE, view_at(at), &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +/// How many stroke outlines the renderers on this thread have made. +fn outlined() -> usize { + STROKES_OUTLINED.with(std::cell::Cell::get) +} + +/// A document holding `path` painted with `items`, and the path's id. +fn doc(path: &BezPath, items: Vec) -> (Document, NodeId) { + let mut d = Document::new(1000.0, 1000.0); + let id = d.alloc_id(); + let n = Node::path(id, PathData::from_bezpath(path), Appearance { items, ..Default::default() }); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + (d, id) +} + +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 +} + +fn dashed(mut st: StrokeLayer, pattern: &[f64]) -> StrokeLayer { + st.dash = Some(Dash { pattern: pattern.to_vec(), offset: 0.0, align_corners: false }); + st +} + +/// `n` anchors 8 pt apart, up and down by 30 pt, every third segment curved. +fn zigzag(n: usize) -> BezPath { + let mut bp = BezPath::new(); + let at = |i: usize| Point::new(i as f64 * 8.0, if i.is_multiple_of(2) { 0.0 } else { 30.0 }); + bp.move_to(at(0)); + for i in 1..n { + if i % 3 == 0 { + bp.curve_to(at(i - 1) + Vec2::new(6.0, 0.0), at(i) + Vec2::new(-6.0, 10.0), at(i)); + } else { + bp.line_to(at(i)); + } + } + bp +} + +/// A closed polygon of `n` anchors round a circle of radius `r` about the origin. +fn ring(n: usize, r: f64) -> BezPath { + let mut bp = BezPath::new(); + for i in 0..n { + let t = i as f64 / n as f64 * std::f64::consts::TAU; + let p = Point::new(r * t.cos(), r * t.sin()); + if i == 0 { bp.move_to(p) } else { bp.line_to(p) } + } + bp.close_path(); + bp +} + +/// 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 +/// 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 gradient = FillLayer::new(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())))); + let only = |st: StrokeLayer| vec![AppearanceItem::Stroke(st)]; + vec![ + ("a short open path", doc(&zigzag(20), only(stroke(5.0, LineJoin::Miter, LineCap::Square))), Point::new(70.0, 15.0)), + ("a short closed path, dashed", doc(&ring(40, 80.0), only(dashed(round.clone(), &[12.0, 7.0]))), Point::new(0.0, 0.0)), + ("a long open path", doc(&zigzag(3000), only(stroke(6.0, LineJoin::Miter, LineCap::Square))), Point::new(12_000.0, 15.0)), + ("a long closed path", doc(&ring(4000, 5000.0), only(round.clone())), Point::new(0.0, 5000.0)), + ( + "a long open path, dashed", + doc(&zigzag(3000), only(dashed(stroke(5.0, LineJoin::Bevel, LineCap::Butt), &[30.0, 12.0]))), + Point::new(8000.0, 15.0), + ), + ( + "a long closed path, dashed, filled with a gradient", + doc(&ring(4000, 5000.0), vec![AppearanceItem::Fill(gradient), AppearanceItem::Stroke(dashed(round, &[25.0, 15.0]))]), + 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)), + ] +} + +/// Draws `d` after `change` with `r` and with a new renderer, checks that the two draw the same +/// pixels, and returns how many stroke outlines `r` made. +fn after(r: &mut Renderer, d: &mut Document, id: NodeId, at: Point, change: impl FnOnce(&mut Node)) -> usize { + change(d.node_mut(id).unwrap()); + let before = outlined(); + let img = render(r, d, at); + let made = outlined() - before; + assert!(img.pixels == render(&mut Renderer::new(), d, at).pixels, "the same pixels as a new renderer"); + assert!(img.pixels.chunks(4).any(|p| p[0] < 128), "the stroke shows"); + made +} + +#[test] +fn a_moved_path_draws_its_stroke_from_the_outline_made_before() { + for (what, (mut d, id), at) in cases() { + let mut r = Renderer::new(); + render(&mut r, &d, at); + for by in [Vec2::new(1.0, 0.0), Vec2::new(0.25, -0.5), Vec2::new(-3.0, 2.0), Vec2::new(0.1, 0.0)] { + let made = after(&mut r, &mut d, id, at, |n| n.transform(Affine::translate(by), true)); + assert_eq!(made, 0, "{what}, moved by {by:?}: no new outline"); + } + // A fill of another color keeps the path where it is. + let made = after(&mut r, &mut d, id, at, |n| { + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.paint = Paint::solid(Color::rgb(0.0, 0.0, 1.0)); + } + }); + 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"); + } +} + +#[test] +fn a_path_changed_otherwise_outlines_its_stroke_again() { + let edit_anchor = |n: &mut Node| { + let NodeKind::Path { path, .. } = &mut n.kind else { + panic!("not a path"); + }; + let mut bp = path.to_bezpath(); + let mut els = bp.elements().to_vec(); + if let Some(kurbo::PathEl::LineTo(p)) = els.get_mut(2) { + *p += Vec2::new(0.0, 3.0); + } + bp = BezPath::from_vec(els); + *path = PathData::from_bezpath(&bp); + }; + for (what, (mut d, id), at) in cases() { + let mut r = Renderer::new(); + render(&mut r, &d, at); + // 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"); + // 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"); + // Another join. + let rejoin = |n: &mut Node| { + for i in n.appearance.items.iter_mut() { + if let AppearanceItem::Stroke(s) = i { + s.join = if s.join == LineJoin::Round { LineJoin::Bevel } else { LineJoin::Round }; + } + } + }; + assert_eq!(after(&mut r, &mut d, id, at, rejoin), 1, "{what}: another join"); + // Back to the first join and moved again: the outline of the join before is not used. + assert_eq!( + after(&mut r, &mut d, id, at, |n| { + rejoin(n); + n.transform(Affine::translate((1.0, 0.0)), true); + }), + 1, + "{what}: the join changed back and moved" + ); + } +} + +#[test] +fn a_small_edit_far_from_the_origin_is_not_taken_for_a_move() { + let far = Affine::translate((1e9, -1e9)); + let was = far * zigzag(20); + let by = Vec2::new(0.25, 3.0); + let moved = Affine::translate(by) * was.clone(); + assert_eq!(offset_between(&was, &moved), Some(moved.elements()[0].end_point().unwrap() - was.elements()[0].end_point().unwrap())); + let mut els = moved.elements().to_vec(); + if let Some(kurbo::PathEl::LineTo(p)) = els.get_mut(2) { + p.y += 0.0005; + } + assert_eq!(offset_between(&was, &BezPath::from_vec(els)), None, "an anchor moved by 0.0005 pt"); +} + +#[test] +fn a_path_moved_out_of_the_region_its_outline_was_cropped_to_is_outlined_again() { + let (mut d, id) = doc(&zigzag(3000), vec![AppearanceItem::Stroke(stroke(6.0, LineJoin::Miter, LineCap::Square))]); + let at = Point::new(12_000.0, 15.0); + let mut r = Renderer::new(); + render(&mut r, &d, at); + assert!(r.strokes.values().any(|e| e.crop.is_some()), "outlined near the view"); + // The outline reaches the view's size past each side of it: 200 pt. + assert_eq!(after(&mut r, &mut d, id, at, |n| n.transform(Affine::translate((150.0, 0.0)), true)), 0, "moved within the region"); + assert_eq!(after(&mut r, &mut d, id, at, |n| n.transform(Affine::translate((150.0, 0.0)), true)), 1, "moved past it"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3042e2fd3..2f4453f16 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1268,6 +1268,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). + 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 128 ms instead of 405 ms with an open path of 200,000 anchors in view (104 ms with nothing changed), and 14 ms instead of 28 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile. 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).