mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
#1172: edit a large selection in time linear in the document, not quadratic
Every lookup by id walked the whole tree, and the edit path did one per selected id, so editing a selection of n objects cost O(n²). Measured with the new `perf --deep` rows at 25,000 paths: - Select All: 458 → 3.5 ms - nudge the selection: 3,517 → 34 ms - drag it with the Selection tool, per pointer move: 3,413 → 35 ms - undo / redo: 407 → 3.1 ms - vectorcraft_doc: Document::index_paths (every id's index path, in one walk; the first in tree order for a duplicated id, as index_path), node_at, node_mut_at (copy-on-write, as node_mut) and ancestry_at. - apply_transform, roots_of, transform_patterns, doc_sane, Selection::prune and Document::paint_order look their ids up with one walk instead of one each. - Selection::set dedups with a hash set instead of Vec::contains per id. - vectorcraft-cli perf --deep: tracked rows for editing a whole selection (select, nudge, drag, undo, redo), live effects, every Pathfinder operation, typing in a long story, and memory. tests_lookups checks the batched lookups against the one-id ones on nested trees with missing and duplicated ids; storing the last duplicate instead of the first fails it. The doc and engine suites (2,000 tests) pass unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
This commit is contained in:
1 parent
d1a6926f63
commit
4b260c364d
8 files changed
+297
-24
No files matched your search
@@ -113,11 +113,117 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
|
||||
Ok((handle, segment))
|
||||
}
|
||||
|
||||
/// Resident memory of this process in MB (`ps`), if available.
|
||||
fn rss_mb() -> Option<f64> {
|
||||
let pid = std::process::id().to_string();
|
||||
let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &pid]).output().ok().filter(|o| o.status.success())?;
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse::<f64>().ok().map(|kb| kb / 1024.0)
|
||||
}
|
||||
|
||||
/// `--deep`: tracked rows (no budget yet) for work the budgets above don't reach: editing a whole
|
||||
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory.
|
||||
fn deep(n: usize) -> Result<(), String> {
|
||||
let err = |e: vectorcraft_engine::EngineError| e.to_string();
|
||||
let mut rows: Vec<(String, f64)> = vec![];
|
||||
let mut mem: Vec<(String, f64)> = vec![];
|
||||
let mut note = |what: &str| {
|
||||
if let Some(m) = rss_mb() {
|
||||
mem.push((what.to_string(), m));
|
||||
}
|
||||
};
|
||||
note("start");
|
||||
// Editing every object of an n-path document.
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.add_document(synthetic(n)?, None);
|
||||
note("document built");
|
||||
rows.push((format!("select all, {n} paths"), median_ms(3, || drop(s.execute("select.all", &json!({}))))));
|
||||
rows.push((format!("nudge the selection ({n} paths)"), median_ms(5, || drop(s.execute("object.move", &json!({"dx": 1, "dy": 0}))))));
|
||||
rows.push((format!("undo a nudge ({n} paths)"), median_ms(5, || drop(s.execute("edit.undo", &json!({}))))));
|
||||
rows.push((format!("redo a nudge ({n} paths)"), median_ms(5, || drop(s.execute("edit.redo", &json!({}))))));
|
||||
// Dragging the whole selection with the Selection tool: one pointer move (the preview).
|
||||
{
|
||||
use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind};
|
||||
use vectorcraft_geom::Vec2;
|
||||
let view = vectorcraft_engine::ViewInfo { zoom: 1.0, ..Default::default() };
|
||||
let first = s.doc().map_err(err)?.selection.objects.first().copied();
|
||||
let at = first.and_then(|id| s.doc().ok()?.doc.node(id)?.geometric_bounds()).map(|b| b.center());
|
||||
if let Some(down) = at {
|
||||
let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), pressure: 1.0 };
|
||||
s.select_tool("selection", view).map_err(err)?;
|
||||
s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?;
|
||||
let mut dx = 0.0;
|
||||
rows.push((
|
||||
format!("drag the selection, per move ({n} paths)"),
|
||||
median_ms(5, || {
|
||||
dx += 2.0;
|
||||
let _ = s.pointer(&ev(PointerKind::Drag, dx), view);
|
||||
}),
|
||||
));
|
||||
s.pointer(&ev(PointerKind::Up, dx), view).map_err(err)?;
|
||||
}
|
||||
}
|
||||
for _ in 0..40 {
|
||||
s.execute("object.move", &json!({"dx": 1, "dy": 0})).map_err(err)?;
|
||||
}
|
||||
note("after 45 whole-selection edits");
|
||||
drop(s);
|
||||
// Live effects on 2,000 paths: render a page whose every object has the effect.
|
||||
let opts = RenderOptions::default();
|
||||
for (fx, params) in [("path.offsetPath", json!({"offset": 3})), ("distort.roughen", json!({"size": 5, "relative": true}))] {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.add_document(synthetic(2000)?, None);
|
||||
s.execute("select.all", &json!({})).map_err(err)?;
|
||||
s.execute("effect.apply", &json!({"effect": fx, "params": params})).map_err(err)?;
|
||||
let doc = s.doc().map_err(err)?.doc.clone();
|
||||
let mut r = Renderer::new();
|
||||
r.render(&doc, 2880, 1800, Affine::scale(1.6), &opts);
|
||||
rows.push((format!("render, 2,000 paths with {fx} (warm)"), median_ms(5, || drop(r.render(&doc, 2880, 1800, Affine::scale(1.6), &opts)))));
|
||||
}
|
||||
note("after live effects");
|
||||
// Every Pathfinder operation on 300 overlapping paths.
|
||||
for op in ["unite", "intersect", "exclude", "minusFront", "minusBack", "divide", "trim", "merge", "crop", "outline"] {
|
||||
let ms = median_ms(1, || {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
if let Ok(d) = synthetic(300) {
|
||||
s.add_document(d, None);
|
||||
let _ = s.execute("select.all", &json!({})).and_then(|_| s.execute(&format!("object.pathfinder.{op}"), &json!({})));
|
||||
}
|
||||
});
|
||||
rows.push((format!("Pathfinder {op}, 300 paths"), ms));
|
||||
}
|
||||
note("after Pathfinder");
|
||||
// Typing one character in the middle of a 20,000-character area-type story.
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.add_document(Document::new(1600.0, 1200.0), None);
|
||||
let text: String = (0..2000).map(|i| if i % 12 == 11 { "paragraph.\n" } else { "lorem ips " }).collect();
|
||||
let id = s.execute("text.create", &json!({"x": 20, "y": 20, "text": text, "size": 10, "area": {"width": 1500, "height": 1100}})).map_err(err)?;
|
||||
let id = id.get("id").cloned().unwrap_or(json!(0));
|
||||
let mut at = 10_000;
|
||||
rows.push((
|
||||
"type one character, 20,000-character area text".into(),
|
||||
median_ms(9, || {
|
||||
at += 1;
|
||||
let _ = s.execute("text.editRange", &json!({"id": id, "start": at, "end": at, "insert": "x"}));
|
||||
}),
|
||||
));
|
||||
note("after typing");
|
||||
outln!(" tracked (no budget yet)");
|
||||
for (name, ms) in &rows {
|
||||
outln!(" {name:<52} {ms:>10.2} ms");
|
||||
}
|
||||
for (what, mb) in &mem {
|
||||
outln!(" memory {what:<45} {mb:>8.0} MB");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut n = 50_000usize;
|
||||
let mut want_deep = false;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--deep" => want_deep = true,
|
||||
"--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?,
|
||||
other => return Err(format!("unknown perf option `{other}`")),
|
||||
}
|
||||
@@ -200,6 +306,9 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
if let Some(e) = failed {
|
||||
return Err(e);
|
||||
}
|
||||
if want_deep {
|
||||
deep(n)?;
|
||||
}
|
||||
|
||||
let mut over = 0;
|
||||
outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
|
||||
|
||||
Reference in new issue
Block a user