mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 00:10:11 +00:00
337 lines
14 KiB
Rust
337 lines
14 KiB
Rust
//! `cargo run --release -p vectorcraft-engine --example anchor_bench` prints the wall time of the
|
||
//! engine steps of editing anchors with Direct Selection, on four synthetic documents: one closed path of
|
||
//! 50,000 anchors, 2,000 closed paths of 50 anchors each, one open path of 200,000 anchors, and the closed
|
||
//! path of 50,000 anchors filled and with a dashed stroke.
|
||
//! `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, AppearanceItem, Dash, 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<NodeId>,
|
||
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)),
|
||
}
|
||
}
|
||
|
||
/// [`one_outline`] filled, with a 1 pt stroke of 6 pt dashes and 4 pt gaps.
|
||
fn dashed_outline() -> Shape {
|
||
let mut shape = one_outline();
|
||
let mut look = Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.2)), Paint::solid(Color::BLACK), 1.0);
|
||
for item in &mut look.items {
|
||
if let AppearanceItem::Stroke(st) = item {
|
||
st.dash = Some(Dash { pattern: vec![6.0, 4.0], offset: 0.0, align_corners: false });
|
||
}
|
||
}
|
||
shape.doc.node_mut(shape.ids[0]).unwrap().appearance = look.into();
|
||
shape.name = "one closed path, 50,000 anchors, filled, dashed stroke";
|
||
shape
|
||
}
|
||
|
||
fn many_paths() -> Shape {
|
||
let mut doc = Document::new(1600.0, 1200.0);
|
||
let ids: Vec<NodeId> =
|
||
(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>) -> 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());
|
||
bench(dashed_outline());
|
||
}
|