diff --git a/crates/engine/examples/anchor_bench.rs b/crates/engine/examples/anchor_bench.rs new file mode 100644 index 000000000..a46060220 --- /dev/null +++ b/crates/engine/examples/anchor_bench.rs @@ -0,0 +1,320 @@ +//! `cargo run --release -p vectorcraft-engine --example anchor_bench` — wall time of the engine +//! steps of editing anchors with Direct Selection, on three synthetic documents: one closed path of +//! 50,000 anchors, 2,000 closed paths of 50 anchors each, and one open path of 200,000 anchors. +//! `ITERS` sets the runs per step (default 7); each row is the median in ms. +// Example (dev tool): unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::hint::black_box; +use std::time::Instant; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId}; +use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind}; +use vectorcraft_engine::{Session, ViewInfo}; +use vectorcraft_geom::{Affine, Anchor, PathData, Point, SubPath, Vec2}; +use vectorcraft_render::{RenderOptions, Renderer}; + +/// Anchors are 10 pt apart and their handles 3 pt long, along the path. +const STEP: f64 = 10.0; +const HANDLE: f64 = 3.0; + +/// A test document: its paths, an anchor to hover, click and drag, a point on a segment, and a +/// marquee from an empty point over about 10,000 anchors. +struct Shape { + name: &'static str, + doc: Document, + ids: Vec, + anchor: (NodeId, usize), + segment: Point, + marquee: (Point, Point), +} + +/// A closed path of `n` smooth anchors around `center`. +fn circle(center: Point, n: usize) -> PathData { + let r = n as f64 * STEP / std::f64::consts::TAU; + let anchors = (0..n) + .map(|i| { + let t = i as f64 / n as f64 * std::f64::consts::TAU; + let p = center + Vec2::new(r * t.cos(), r * t.sin()); + Anchor::smooth(p, p + Vec2::new(-t.sin(), t.cos()) * HANDLE) + }) + .collect(); + PathData::single(SubPath::new(anchors, true)) +} + +fn add(doc: &mut Document, path: PathData) -> NodeId { + let l = doc.layers.first().map(|l| l.id); + let id = doc.alloc_id(); + doc.insert(l, usize::MAX, Node::path(id, path, Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0))).unwrap(); + id +} + +fn one_outline() -> Shape { + let n = 50_000; + let mut doc = Document::new(1600.0, 1200.0); + let id = add(&mut doc, circle(Point::ZERO, n)); + let r = n as f64 * STEP / std::f64::consts::TAU; + let a = 72f64.to_radians(); + let mid = (n / 2) as f64 / n as f64 * std::f64::consts::TAU; + let next = (n / 2 + 1) as f64 / n as f64 * std::f64::consts::TAU; + let segment = Point::new(r * (mid.cos() + next.cos()) / 2.0, r * (mid.sin() + next.sin()) / 2.0); + Shape { + name: "one closed path, 50,000 anchors", + doc, + ids: vec![id], + anchor: (id, n / 2), + segment, + marquee: (Point::new(r + 50.0, -5.0), Point::new(r * a.cos() - 1.0, r * a.sin() + 1.0)), + } +} + +fn many_paths() -> Shape { + let mut doc = Document::new(1600.0, 1200.0); + let ids: Vec = + (0..2000).map(|i| add(&mut doc, circle(Point::new((i % 50) as f64 * 200.0 + 100.0, (i / 50) as f64 * 200.0 + 100.0), 50))).collect(); + let r = 50.0 * STEP / std::f64::consts::TAU; + // Path 1,025 (row 20, column 25), its anchors 0 and 1. + let c = Point::new(25.0 * 200.0 + 100.0, 20.0 * 200.0 + 100.0); + let t = std::f64::consts::TAU / 50.0; + let segment = c + Vec2::new(r * (1.0 + t.cos()) / 2.0, r * t.sin() / 2.0); + Shape { + name: "2,000 closed paths, 50 anchors each", + doc, + anchor: (ids[1025], 0), + ids, + segment, + marquee: (Point::ZERO, Point::new(10_000.0, 800.0)), + } +} + +fn long_open_path() -> Shape { + let n = 200_000; + let mut doc = Document::new(1600.0, 1200.0); + let anchors = (0..n) + .map(|i| { + let p = Point::new(i as f64 * STEP, 0.0); + Anchor::smooth(p, p + Vec2::new(HANDLE, 0.0)) + }) + .collect(); + let id = add(&mut doc, PathData::single(SubPath::new(anchors, false))); + Shape { + name: "one open path, 200,000 anchors", + doc, + ids: vec![id], + anchor: (id, n / 2), + segment: Point::new((n / 2) as f64 * STEP + STEP / 2.0, 0.0), + marquee: (Point::new(-5.0, -20.0), Point::new(99_995.0, 30.0)), + } +} + +fn iters() -> usize { + std::env::var("ITERS").ok().and_then(|v| v.parse().ok()).unwrap_or(7).max(1) +} + +fn median(mut ms: Vec) -> f64 { + ms.sort_by(f64::total_cmp); + ms[ms.len() / 2] +} + +fn time(mut f: impl FnMut()) -> f64 { + median( + (0..iters()) + .map(|_| { + let t = Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1000.0 + }) + .collect(), + ) +} + +fn ev(kind: PointerKind, p: Point) -> PointerEvent { + PointerEvent::new(kind, p.x, p.y) +} + +fn exec(s: &mut Session, cmd: &str, p: Value) { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")); +} + +fn anchor_at(s: &Session, (id, ai): (NodeId, usize)) -> Anchor { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors[ai] +} + +/// The anchors selected: those direct-selected, and every anchor of a path selected whole. +fn selected_anchors(s: &Session) -> usize { + let st = s.doc().unwrap(); + let count = |id: &NodeId| st.doc.node(*id).and_then(|n| n.path_data()).map_or(0, |pd| pd.anchors().count()); + st.selection.objects.iter().map(|id| st.selection.partial(*id).map_or_else(|| count(id), |set| set.len())).sum() +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn row(name: &str, ms: f64) { + println!(" {name:<58} {ms:>10.3} ms"); +} + +/// Select `count` anchors: the first ones of the first path, or the first anchors of as many +/// paths as needed. +fn select_count(s: &mut Session, shape: &Shape, count: usize) { + exec(s, "select.none", json!({})); + let mut left = count; + for id in &shape.ids { + if left == 0 { + break; + } + let n = s.doc().unwrap().doc.node(*id).unwrap().path_data().unwrap().subpaths[0].anchors.len(); + let take = left.min(n); + let refs: Vec<[usize; 2]> = (0..take).map(|i| [0, i]).collect(); + exec(s, "select.anchors", json!({"id": id.0, "anchors": refs, "mode": "add"})); + left -= take; + } +} + +fn bench(shape: Shape) { + println!("{}", shape.name); + let view = ViewInfo { smart_guides: false, ..ViewInfo::default() }; + let mut s = Session::new(); + s.add_document(shape.doc.clone(), None); + s.select_tool("directSelection", view).unwrap(); + let a = anchor_at(&s, shape.anchor).p; + let none = Mods::default(); + { + let doc = s.doc().unwrap().doc.clone(); + let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0, clip_outlines: true, ..Default::default() }; + row("hit test at an anchor", time(|| drop(black_box(vectorcraft_doc::hit::hit_test(&doc, a, opt))))); + row("hit test at a segment", time(|| drop(black_box(vectorcraft_doc::hit::hit_test(&doc, shape.segment, opt))))); + row("hit test beside the path", time(|| drop(black_box(vectorcraft_doc::hit::hit_test(&doc, shape.segment + Vec2::new(0.0, 40.0), opt))))); + } + + // Hover with nothing selected, then with one anchor of the path selected. + for selected in [false, true] { + if selected { + exec(&mut s, "select.anchors", json!({"id": shape.anchor.0.0, "anchors": [[0, shape.anchor.1]], "mode": "set"})); + } + let what = if selected { "one anchor selected" } else { "nothing selected" }; + row(&format!("hover over an anchor, {what}"), time(|| drop(s.pointer(&ev(PointerKind::Move, a), view).unwrap()))); + row(&format!("cursor over an anchor, {what}"), time(|| _ = black_box(s.cursor(a, none, view)))); + row(&format!("hover over a segment, {what}"), time(|| drop(s.pointer(&ev(PointerKind::Move, shape.segment), view).unwrap()))); + row(&format!("cursor over a segment, {what}"), time(|| _ = black_box(s.cursor(shape.segment, none, view)))); + } + + // A click that selects one anchor. + exec(&mut s, "select.none", json!({})); + row( + "click that selects one anchor", + time(|| { + s.pointer(&ev(PointerKind::Down, a), view).unwrap(); + s.pointer(&ev(PointerKind::Up, a), view).unwrap(); + }), + ); + assert_eq!(selected_anchors(&s), 1, "the click selects one anchor"); + + // One anchor dragged: each pointer move, then the release. + let undo = undo_len(&s); + s.pointer(&ev(PointerKind::Down, a), view).unwrap(); + s.pointer(&ev(PointerKind::Drag, a + Vec2::new(0.0, 20.0)), view).unwrap(); + let mut y = 20.0; + row( + "anchor drag, one pointer move", + time(|| { + y += 1.0; + s.pointer(&ev(PointerKind::Drag, a + Vec2::new(0.0, y)), view).unwrap(); + }), + ); + let t = Instant::now(); + s.pointer(&ev(PointerKind::Up, a + Vec2::new(0.0, y)), view).unwrap(); + row("anchor drag, release (commit)", t.elapsed().as_secs_f64() * 1000.0); + assert_eq!(undo_len(&s), undo + 1, "the anchor drag records one step"); + exec(&mut s, "edit.undo", json!({})); + + // The selected anchor's outgoing handle dragged. + exec(&mut s, "select.anchors", json!({"id": shape.anchor.0.0, "anchors": [[0, shape.anchor.1]], "mode": "set"})); + let h = anchor_at(&s, shape.anchor).h_out; + let undo = undo_len(&s); + s.pointer(&ev(PointerKind::Down, h), view).unwrap(); + s.pointer(&ev(PointerKind::Drag, h + Vec2::new(0.0, 20.0)), view).unwrap(); + let mut y = 20.0; + row( + "handle drag, one pointer move", + time(|| { + y += 1.0; + s.pointer(&ev(PointerKind::Drag, h + Vec2::new(0.0, y)), view).unwrap(); + }), + ); + let t = Instant::now(); + s.pointer(&ev(PointerKind::Up, h + Vec2::new(0.0, y)), view).unwrap(); + row("handle drag, release (commit)", t.elapsed().as_secs_f64() * 1000.0); + assert_eq!(undo_len(&s), undo + 1, "the handle drag records one step"); + assert_eq!(anchor_at(&s, shape.anchor).p, a, "the handle drag leaves the anchor in place"); + exec(&mut s, "edit.undo", json!({})); + + // A marquee over about 10,000 anchors. + let (m0, m1) = shape.marquee; + let ms = time(|| { + exec(&mut s, "select.none", json!({})); + s.pointer(&ev(PointerKind::Down, m0), view).unwrap(); + s.pointer(&ev(PointerKind::Drag, m0.lerp(m1, 0.5)), view).unwrap(); + s.pointer(&ev(PointerKind::Drag, m1), view).unwrap(); + s.pointer(&ev(PointerKind::Up, m1), view).unwrap(); + }); + row(&format!("marquee over {} anchors (press to release)", selected_anchors(&s)), ms); + + // Select All with Direct Selection. + row("Select All", time(|| exec(&mut s, "select.all", json!({})))); + row("tool overlays, everything selected", time(|| drop(s.overlays(view)))); + + // Nudges of 1, 1,000 and every anchor, and Undo and Redo of each. + let total: usize = shape.ids.iter().map(|id| s.doc().unwrap().doc.node(*id).unwrap().path_data().unwrap().subpaths[0].anchors.len()).sum(); + for count in [1, 1000, total] { + select_count(&mut s, &shape, count); + row(&format!("nudge of {count} anchors"), time(|| exec(&mut s, "path.moveAnchors", json!({"dx": 1, "dy": 0})))); + row(&format!("undo of a nudge of {count} anchors"), time(|| exec(&mut s, "edit.undo", json!({})))); + row(&format!("redo of a nudge of {count} anchors"), time(|| exec(&mut s, "edit.redo", json!({})))); + } + + // The render of the document after one anchor moves (the renderer warm). + exec(&mut s, "select.anchors", json!({"id": shape.anchor.0.0, "anchors": [[0, shape.anchor.1]], "mode": "set"})); + let opts = RenderOptions::default(); + let mut r = Renderer::new(); + let at = Affine::translate(Vec2::new(800.0 - a.x, 600.0 - a.y)); + r.render(&s.doc().unwrap().doc, 1600, 1200, at, &opts); + let ms = median( + (0..iters()) + .map(|_| { + exec(&mut s, "path.moveAnchors", json!({"dx": 0, "dy": 1})); + let doc = s.doc().unwrap().doc.clone(); + let t = Instant::now(); + drop(r.render(&doc, 1600, 1200, at, &opts)); + t.elapsed().as_secs_f64() * 1000.0 + }) + .collect(), + ); + row("render 1600x1200 around the anchor after it moves", ms); + let doc = s.doc().unwrap().doc.clone(); + row("render 1600x1200 around the anchor, nothing changed", time(|| drop(r.render(&doc, 1600, 1200, at, &opts)))); + + // Delete of one anchor, undone after each run. + let ms = median( + (0..iters()) + .map(|_| { + exec(&mut s, "select.anchors", json!({"id": shape.anchor.0.0, "anchors": [[0, shape.anchor.1]], "mode": "set"})); + let t = Instant::now(); + exec(&mut s, "edit.clear", json!({})); + let ms = t.elapsed().as_secs_f64() * 1000.0; + exec(&mut s, "edit.undo", json!({})); + ms + }) + .collect(), + ); + row("Delete of one anchor", ms); +} + +fn main() { + bench(one_outline()); + bench(many_paths()); + bench(long_open_path()); +} diff --git a/crates/render/src/frame.rs b/crates/render/src/frame.rs index c01f27a3f..000995762 100644 --- a/crates/render/src/frame.rs +++ b/crates/render/src/frame.rs @@ -30,11 +30,14 @@ pub(crate) type PtrMap = HashMap, pub(crate) outline: Arc, pub(crate) stamp: u64, + pub(crate) crop: 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/renderer.rs b/crates/render/src/renderer.rs index db5ab2d6b..a8a67f270 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -23,6 +23,9 @@ mod nodes; mod paint; mod text; +#[cfg(test)] +pub(crate) use paint::{CROP_STROKES, crop_line}; + impl Renderer { pub fn new() -> Self { Self { diff --git a/crates/render/src/renderer/paint.rs b/crates/render/src/renderer/paint.rs index 3214ffe58..e39f106e8 100644 --- a/crates/render/src/renderer/paint.rs +++ b/crates/render/src/renderer/paint.rs @@ -253,14 +253,27 @@ impl Renderer { } let node = self.cur.clone().filter(|n| owns_stroke(n, st))?; let key = (self.stroke_owner(Arc::as_ptr(&node) as usize), st as *const StrokeLayer as usize, width.to_bits(), level); + let v = f.visible; + let holds = |crop: Option| crop.is_none_or(|r| r.x0 <= v.x0 && r.y0 <= v.y0 && r.x1 >= v.x1 && r.y1 >= v.y1); if let Some(e) = self.strokes.get_mut(&key) && Arc::ptr_eq(&e.node, &node) + && holds(e.crop) { e.stamp = self.stamp; return Some(e.outline.clone()); } - let outline = expand(); - self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp }); + // A long stroke without dashes or a width profile is outlined only in and around the view, + // with a margin of the view's own size on each side ([`crop_line`]). + let region = v.inflate(v.width(), v.height()); + let tol = 2f64.powi(level); + 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), + }; + self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp, crop }); Some(outline) } @@ -271,3 +284,95 @@ impl Renderer { if self.made_again { 0 } else { address } } } + +/// The stroke lines with more path elements than this are outlined in and around the view only +/// ([`crop_line`]). +pub(crate) const CROP_ELEMENTS: usize = 1024; + +#[cfg(test)] +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) }; +} + +fn crops() -> bool { + #[cfg(test)] + return CROP_STROKES.with(std::cell::Cell::get); + #[cfg(not(test))] + true +} + +/// How far stroke `st` at `width` can paint from its path, at a join or a cap, with two output +/// pixels (`px`) and the outline's tolerance `tol` added. +pub(crate) fn crop_reach(st: &StrokeLayer, width: f64, px: f64, tol: f64) -> f64 { + let corner = if st.join == vectorcraft_doc::LineJoin::Miter { st.miter_limit.max(std::f64::consts::SQRT_2) } else { std::f64::consts::SQRT_2 }; + width / 2.0 * corner + 2.0 * px + tol +} + +/// The stroke line `line` without the segments whose control points' box lies more than `reach` +/// from `region`, for outlining a stroke of `width` that is drawn only in `region`. A subpath that +/// loses some of its segments is replaced by its runs of kept segments, each an open subpath; a +/// closed one is walked from a removed segment, so that a run passes through the point where it +/// starts and ends. A closed subpath that a stroke of `width` can cross over (`width` at least the +/// smaller side of its bounds) keeps all its segments, and so does a subpath with no segments. +/// `None` when every segment is kept. +pub(crate) fn crop_line(line: &BezPath, region: Rect, reach: f64, width: f64) -> Option { + use kurbo::{PathEl, PathSeg}; + let near = region.inflate(reach, reach); + let keep = |s: &PathSeg| { + let pts: &[kurbo::Point] = match s { + PathSeg::Line(l) => &[l.p0, l.p1], + PathSeg::Quad(q) => &[q.p0, q.p1, q.p2], + PathSeg::Cubic(c) => &[c.p0, c.p1, c.p2, c.p3], + }; + // A coordinate that is not a number keeps the segment. + !(pts.iter().all(|p| p.x < near.x0) + || pts.iter().all(|p| p.x > near.x1) + || pts.iter().all(|p| p.y < near.y0) + || pts.iter().all(|p| p.y > near.y1)) + }; + let els = line.elements(); + let mut starts: Vec = els.iter().enumerate().filter(|(_, e)| matches!(e, PathEl::MoveTo(_))).map(|(i, _)| i).collect(); + if starts.first() != Some(&0) { + starts.insert(0, 0); + } + let mut out = BezPath::new(); + let mut cut = false; + for (k, &start) in starts.iter().enumerate() { + let Some(sub) = els.get(start..starts.get(k + 1).copied().unwrap_or(els.len())) else { continue }; + let segs = || kurbo::segments(sub.iter().copied()); + let (total, kept) = segs().fold((0, 0), |(t, n), s| (t + 1, n + usize::from(keep(&s)))); + let closed = sub.iter().any(|e| matches!(e, PathEl::ClosePath)); + let crosses = || { + let b = BezPath::from_vec(sub.to_vec()).bounding_box(); + width >= b.width().min(b.height()) + }; + if kept == total || closed && crosses() { + out.extend(sub.iter().copied()); + continue; + } + cut = true; + let first = if closed { segs().position(|s| !keep(&s)).map_or(0, |i| i + 1) } else { 0 }; + let mut open = false; + for s in segs().skip(first).chain(segs().take(first)) { + if !keep(&s) { + open = false; + continue; + } + if !open { + out.move_to(match s { + PathSeg::Line(l) => l.p0, + PathSeg::Quad(q) => q.p0, + PathSeg::Cubic(c) => c.p0, + }); + open = true; + } + match s { + PathSeg::Line(l) => out.line_to(l.p1), + PathSeg::Quad(q) => out.quad_to(q.p1, q.p2), + PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3), + } + } + } + cut.then_some(out) +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index d97de7a3c..aa16311e0 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -21,6 +21,7 @@ mod fxzoom; mod isolation; mod knockout; mod layeropts; +mod longstroke; mod memory; mod objectfx; mod placed_document; diff --git a/crates/render/src/tests/longstroke.rs b/crates/render/src/tests/longstroke.rs new file mode 100644 index 000000000..b8c7e7cf6 --- /dev/null +++ b/crates/render/src/tests/longstroke.rs @@ -0,0 +1,166 @@ +//! A long stroke without dashes or a width profile is outlined in and around the view only +//! (`crop_line`), and draws the same pixels as when it is outlined whole. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, LineCap, LineJoin, Node, StrokeLayer}; +use vectorcraft_geom::{PathData, Point, Vec2}; + +use crate::renderer::{CROP_STROKES, crop_line}; +use crate::*; + +const SIZE: u32 = 200; + +/// A document holding `path` stroked with `st` and no fill. +fn doc(path: &BezPath, st: StrokeLayer) -> Document { + let mut d = Document::new(1000.0, 1000.0); + let n = Node::path(d.alloc_id(), PathData::from_bezpath(path), Appearance { items: vec![AppearanceItem::Stroke(st)], ..Default::default() }); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +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 +} + +/// 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() }) +} + +/// What a renderer that outlines every stroke whole draws. +fn whole(d: &Document, at: Point) -> Rendered { + CROP_STROKES.with(|c| c.set(false)); + let mut r = Renderer::new(); + let img = render(&mut r, d, at); + CROP_STROKES.with(|c| c.set(true)); + assert!(r.strokes.values().all(|e| e.crop.is_none()), "the reference outlines the stroke whole"); + img +} + +/// 3,000 anchors 8 pt apart, up and down by 30 pt, every third segment curved: sharp miter joins. +fn zigzag() -> 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..3000 { + 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 +} + +#[test] +fn a_long_stroke_draws_the_same_pixels_outlined_near_the_view() { + let mut inside = stroke(8.0, LineJoin::Round, LineCap::Butt); + inside.align = vectorcraft_doc::StrokeAlign::Inside; + // The ring starts at (5000, 0): the second view is on its start and end. + let cases = [ + ("zigzag, miter joins, square caps", doc(&zigzag(), stroke(6.0, LineJoin::Miter, LineCap::Square)), Point::new(12_000.0, 15.0), true), + ("ring, round joins", doc(&ring(4000, 5000.0), stroke(12.0, LineJoin::Round, LineCap::Round)), Point::new(0.0, 5000.0), true), + ("ring through its start, bevel joins", doc(&ring(4000, 5000.0), stroke(5.0, LineJoin::Bevel, LineCap::Butt)), Point::new(5000.0, 0.0), true), + ("ring, inside stroke", doc(&ring(4000, 5000.0), inside), Point::new(-5000.0, 0.0), true), + ]; + for (what, d, at, crops) in &cases { + let mut r = Renderer::new(); + let img = render(&mut r, d, *at); + assert_eq!(r.strokes.values().any(|e| e.crop.is_some()), *crops, "{what}: outlined near the view"); + assert!(img.pixels == whole(d, *at).pixels, "{what}: the same pixels as the whole outline"); + // A view inside the region kept around the first one reuses the outline; one beyond it + // outlines the stroke again. + for step in [Point::new(60.0, 40.0), Point::new(450.0, 0.0), Point::new(-900.0, 300.0)] { + let next = *at + step.to_vec2(); + assert!(render(&mut r, d, next).pixels == whole(d, next).pixels, "{what}, moved by {step:?}: the same pixels"); + } + } +} + +#[test] +fn a_long_stroke_is_outlined_near_the_view_only() { + let d = doc(&zigzag(), 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); + let near = r.strokes.values().map(|e| e.outline.elements().len()).sum::(); + CROP_STROKES.with(|c| c.set(false)); + let mut full = Renderer::new(); + render(&mut full, &d, at); + CROP_STROKES.with(|c| c.set(true)); + let all = full.strokes.values().map(|e| e.outline.elements().len()).sum::(); + assert!(near > 0 && near * 10 < all, "{near} outline elements near the view, {all} for the whole stroke"); +} + +#[test] +fn a_stroke_a_wide_stroke_crosses_over_stays_whole() { + // A closed sliver 4 pt tall: a 10 pt stroke covers it from side to side. + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + for i in 1..=2000 { + bp.line_to((f64::from(i) * 10.0, 0.0)); + } + bp.line_to((20_000.0, 4.0)); + bp.line_to((0.0, 4.0)); + bp.close_path(); + let d = doc(&bp, stroke(10.0, LineJoin::Miter, LineCap::Butt)); + let at = Point::new(20_000.0, 2.0); + let mut r = Renderer::new(); + let img = render(&mut r, &d, at); + assert!(r.strokes.values().all(|e| e.crop.is_none())); + assert!(img.pixels == whole(&d, at).pixels); +} + +fn path(svg: &str) -> BezPath { + BezPath::from_svg(svg).unwrap() +} + +#[test] +fn crop_line_keeps_the_runs_of_segments_near_the_region() { + let region = Rect::new(0.0, 0.0, 100.0, 100.0); + let crop = |svg: &str| crop_line(&path(svg), region, 5.0, 1.0).map(|bp| bp.to_svg()); + // Nothing to leave out: an empty path, one segment, a path in the region. + assert_eq!(crop(""), None); + assert_eq!(crop("M10,10 L90,90"), None); + assert_eq!(crop("M10,10 L90,90 L10,90 Z"), None); + // A segment within the reach of the region stays; one beyond it goes, and the open path is cut. + assert_eq!(crop("M50,50 L104,50 L200,50 L300,50").as_deref(), Some("M50,50 L104,50 L200,50")); + assert_eq!(crop("M50,50 L200,50 L300,50 L400,50").as_deref(), Some("M50,50 L200,50")); + // A curve whose control point reaches the region stays. + assert_eq!(crop("M200,0 C50,50 300,0 400,0 L500,0").as_deref(), Some("M200,0 C50,50 300,0 400,0")); + // Two runs, each its own open subpath. + assert_eq!(crop("M50,10 L300,10 L300,90 L50,90").as_deref(), Some("M50,10 L300,10 M300,90 L50,90")); + // Every segment goes. + assert_eq!(crop("M300,300 L400,400").as_deref(), Some("")); + // A closed subpath is walked from a segment it loses: its start stays inside one run. + assert_eq!(crop("M50,50 L50,500 L500,500 L500,50 Z").as_deref(), Some("M500,50 L50,50 L50,500")); + // A subpath with no segments stays, and so does an untouched subpath beside a cut one. + assert_eq!(crop("M900,900 M10,10 L20,20 M50,50 L300,50 L400,50").as_deref(), Some("M900,900 M10,10 L20,20 M50,50 L300,50")); + // A closed subpath that the stroke crosses over keeps all its segments. + assert_eq!(crop_line(&path("M50,0 L50,2 L500,2 L500,0 Z"), region, 5.0, 3.0), None); + // A coordinate that is not a number keeps its segment. + let mut nan = path("M50,50 L300,50 L400,50"); + nan.line_to((f64::NAN, 50.0)); + assert!(crop_line(&nan, region, 5.0, 1.0).is_some_and(|bp| bp.elements().iter().any(|e| e.end_point().is_some_and(|p| p.x.is_nan())))); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 57e3defab..7b5f5deaa 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1242,6 +1242,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With Overprint Preview or Separations Preview on, the renderer keeps the art it prepared for the preview from one frame to the next. It prepares again the objects that changed, and all of the art when Discard White Overprint changes or, with Separations Preview, when the plates shown, the proof setup, the swatches, the Spot Colors option or the color settings change or a profile is loaded. It draws the other objects from the art, geometry and stroke outlines it kept for them. The Pathfinder panel, Offset Path, Clean Up, Divide Objects Below, Make and Release Blend, Envelope Distort's Make and Release, Repeat (Radial, Grid, Mirror and Release), Vector Halftone, New and Redefine Symbol, Delete Symbol, erasing with the Symbol Sprayer, Rasterize, Convert to Artboards, Flatten Transparency, Clear Guides, Shape Builder, Live Paint, Release Image Trace, Make and Release Opacity Mask, Delete with direct-selected anchor points, Remove Anchor Points, Cut Path, and the Shaper, Knife, Eraser, Path Eraser, Line Cut, Rectangle Cut, Mirror & Cut and Blob Brush tools add and remove objects in one pass over each layer or group they change, and Outline Stroke and Expand write each new object in the place of the object it replaces (#1429). 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 a stroke of 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`). 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).