From 9f3147104286e446d60c9a6a4bc1192d0ce20740 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 13:47:05 -0700 Subject: [PATCH] Use Preview Bounds: the selection tools measure the selection once per revision With Use Preview Bounds on, every hover frame's cursor and every pointer move measured the selection again, and with Smart Guides on each selected object too, evaluating their geometry effects each time. The session now keeps a PreviewMemo, which ToolContext::preview_memo passes to the tools. selection_bounds, selection_box and the object bounds that Smart Guides find centers in while hovering read it. The memo holds the measurements for one key: the document's uid and revision, the selected objects and Use Preview Bounds. A new key replaces them. VectorcraftApp::selection_box in the UI keys on the uid, the revision and Use Preview Bounds. A ToolContext without a memo measures on every call. --- crates/engine/src/lib.rs | 4 + crates/engine/src/tests_previewbounds.rs | 42 +++++++- crates/engine/src/tooling.rs | 1 + crates/tools/src/lib.rs | 4 + crates/tools/src/select.rs | 128 ++++++++++++++++++++++- 5 files changed, 174 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 535818524..b08053415 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1261,6 +1261,9 @@ pub struct Session { /// lends the other tools. None until one is chosen. pub(crate) last_selection_tool: Option<&'static str>, pub(crate) last_view: ViewInfo, + /// The selection tools' measurements for the active document as it is: a pointer move or a + /// cursor asked for again over an unchanged document and selection measures nothing again. + pub(crate) preview_memo: vectorcraft_tools::select::PreviewMemo, depth: u32, /// Set when the active tool panicked (see [`guard`]); reported by the next tool event. tool_panic: Option, @@ -1383,6 +1386,7 @@ impl Session { lender: None, last_selection_tool: None, last_view: ViewInfo::default(), + preview_memo: Default::default(), depth: 0, tool_panic: None, draw_mode: DrawMode::Normal, diff --git a/crates/engine/src/tests_previewbounds.rs b/crates/engine/src/tests_previewbounds.rs index a187a150d..d35ef0e30 100644 --- a/crates/engine/src/tests_previewbounds.rs +++ b/crates/engine/src/tests_previewbounds.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId, NodeKind, Symbol}; -use vectorcraft_geom::{Affine, Rect, shapes}; -use vectorcraft_tools::{PointerEvent, PointerKind}; +use vectorcraft_geom::{Affine, Point, Rect, shapes}; +use vectorcraft_tools::{Cursor, Mods, PointerEvent, PointerKind}; use super::*; @@ -707,3 +707,41 @@ fn the_bounding_box_handles_sit_on_the_offset_shape() { assert!(drag(true).width() > 150.0, "a scale from the edge of the offset shape"); assert_eq!(drag(false).width(), 100.0, "off, the point is no handle"); } + +#[test] +fn hovering_over_an_unchanged_document_measures_nothing_again() { + // A selected square with Offset Path 10, drawn from 90 to 210, with Use Preview Bounds and Smart + // Guides on: the bounding box and the center Smart Guides label are measured once. + let mut s = session(); + let id = offset_square(&mut s, 100.0); + preview(&mut s, true); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + let hover = |s: &mut Session, x: f64| { + s.pointer(&PointerEvent::new(PointerKind::Move, x, 150.0), v).unwrap(); + s.cursor(Point::new(x, 150.0), Mods::default(), v) + }; + let xs = [210.0, 150.0, 151.0, 180.0, 250.0]; + for x in xs { + hover(&mut s, x); + } + let measured = s.preview_memo.measurements(); + assert!(measured > 0, "the first hover measures"); + for x in xs { + hover(&mut s, x); + } + assert_eq!(s.preview_memo.measurements(), measured, "hovering again measures nothing"); + assert_eq!(hover(&mut s, 210.0), Cursor::ResizeH, "the right edge of the offset shape"); + assert_ne!(hover(&mut s, 230.0), Cursor::ResizeH); + + // Offset Path 30 draws the square from 70 to 230: the box is measured again. + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"offset": 30}, "ids": [id.0], "item": null})); + assert_eq!(hover(&mut s, 230.0), Cursor::ResizeH, "the right edge of the new offset shape"); + let measured_again = s.preview_memo.measurements(); + assert!(measured_again > measured); + + // Moved 50 pt to the right, the square is drawn from 120 to 280. + run(&mut s, "object.move", json!({"dx": 50, "dy": 0})); + assert_eq!(hover(&mut s, 280.0), Cursor::ResizeH, "the right edge of the moved offset shape"); + assert!(s.preview_memo.measurements() > measured_again); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 1a6809688..3a90d47ed 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -215,6 +215,7 @@ impl Session { freeform_point: self.selected_freeform_point(), raster_sample: self.prefs.eyedropper.sample_size, preview_bounds: self.prefs.use_preview_bounds, + preview_memo: Some(&self.preview_memo), unit, stroke_unit, paste_plain_text: self.prefs.paste_text_formatting == "plain", diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 24a6dd88f..caea39dd0 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -275,6 +275,9 @@ pub struct ToolContext<'a> { pub raster_sample: u32, /// General → Use Preview Bounds: the bounding box measures visual bounds (strokes included). pub preview_bounds: bool, + /// The session's memo of the selection tools' measurements for the document as it is + /// ([`select::PreviewMemo`]); without one they measure on every call. + pub preview_memo: Option<&'a select::PreviewMemo>, /// Units ▸ General: the unit measurement labels show lengths in. pub unit: Unit, /// Units ▸ Stroke: the unit measurement labels show stroke widths in. @@ -1010,6 +1013,7 @@ pub(crate) mod testutil { freeform_point: None, raster_sample: 1, preview_bounds: false, + preview_memo: None, unit: Unit::Points, stroke_unit: Unit::Points, paste_plain_text: false, diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index f0d42154e..6a21f9d45 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -22,6 +22,9 @@ //! under the pointer, or into a new frame of the same shape and size at the click on empty canvas, //! and a drag draws a rectangle for the new frame (`text.thread.add`). Escape drops the loaded text. +use std::cell::RefCell; +use std::collections::HashMap; + use serde_json::{Value, json}; use vectorcraft_doc::hit::{hit_test, marquee, objects_at}; use vectorcraft_doc::{NodeId, OrientedBox}; @@ -259,16 +262,103 @@ pub fn matrix_json(a: Affine) -> Value { json!([c[0], c[1], c[2], c[3], c[4], c[5]]) } +/// The bounds the selection tools measured for one state of an open document: [`selection_bounds`], +/// [`selection_box`] and the objects' bounds that Smart Guides find centers in while hovering +/// ([`object_bounds`]). With Use Preview Bounds on, each measurement evaluates the geometry effects +/// of the objects (Offset Path, Pathfinder, Outline Object, blends), and the canvas asks for the +/// cursor on every hover frame. The session keeps one memo ([`ToolContext::preview_memo`]), as the +/// UI keeps one for `VectorcraftApp::selection_box`. The memo holds the measurements for one key +/// (the document's uid and revision, the selected objects and Use Preview Bounds), and a new key +/// replaces them. +#[derive(Default)] +pub struct PreviewMemo(RefCell); + +#[derive(Default)] +struct MemoState { + entry: Option, + /// How many measurements the memo has made. + measurements: u64, +} + +/// The measurements for one key; a slot holds `None` until it is measured. +struct Measured { + revision: (u64, u64), + objects: Vec, + preview: bool, + bounds: Option>, + oriented: Option>, + by_object: HashMap>>, +} + +impl Measured { + fn is_for(&self, cx: &ToolContext) -> bool { + self.revision == cx.revision && self.preview == cx.preview_bounds && self.objects == cx.selection.objects + } +} + +impl PreviewMemo { + /// How many measurements the memo has made: each one it could not answer from the measurements + /// for the document as it is. + pub fn measurements(&self) -> u64 { + self.0.try_borrow().map_or(0, |st| st.measurements) + } + + /// The measurements for the state `cx` shows, after the ones for any other state are dropped. + fn current<'m>(st: &'m mut MemoState, cx: &ToolContext) -> &'m mut Measured { + let fresh = || Measured { + revision: cx.revision, + objects: cx.selection.objects.clone(), + preview: cx.preview_bounds, + bounds: None, + oriented: None, + by_object: HashMap::new(), + }; + if !st.entry.as_ref().is_some_and(|m| m.is_for(cx)) { + st.entry = Some(fresh()); + } + st.entry.get_or_insert_with(fresh) + } + + /// The value `slot` holds for the state `cx` shows, else `measure`'s, which `slot` then holds. + fn remember(&self, cx: &ToolContext, slot: impl Fn(&mut Measured) -> &mut Option, measure: impl FnOnce() -> T) -> T { + if let Ok(mut st) = self.0.try_borrow_mut() + && let Some(v) = slot(Self::current(&mut st, cx)).clone() + { + return v; + } + let v = measure(); + if let Ok(mut st) = self.0.try_borrow_mut() { + st.measurements += 1; + *slot(Self::current(&mut st, cx)) = Some(v.clone()); + } + v + } +} + +/// `measure`'s value, kept in the session's [`PreviewMemo`] in the slot `slot` picks; measured +/// every time without a memo. +fn remembered(cx: &ToolContext, slot: impl Fn(&mut Measured) -> &mut Option, measure: impl FnOnce() -> T) -> T { + match cx.preview_memo { + Some(memo) => memo.remember(cx, slot, measure), + None => measure(), + } +} + /// Selection bounds used for the bounding box: visual bounds as drawn with Use Preview Bounds /// ([`vectorcraft_effects::preview`]), else geometric bounds. pub fn selection_bounds(cx: &ToolContext) -> Option { - vectorcraft_effects::preview::bounds_of(cx.doc, &cx.selection.objects, cx.preview_bounds) + remembered(cx, |m| &mut m.bounds, || vectorcraft_effects::preview::bounds_of(cx.doc, &cx.selection.objects, cx.preview_bounds)) } /// [`selection_bounds`] square to the selection's own angle: the bounding box the Selection tool /// shows and drags (rotated after a rotation). pub fn selection_box(cx: &ToolContext) -> Option { - vectorcraft_effects::preview::oriented_bounds(cx.doc, &cx.selection.objects, cx.preview_bounds) + remembered(cx, |m| &mut m.oriented, || vectorcraft_effects::preview::oriented_bounds(cx.doc, &cx.selection.objects, cx.preview_bounds)) +} + +/// The bounds of object `id`, measured as [`selection_bounds`] measures the selection. +fn object_bounds(cx: &ToolContext, id: NodeId) -> Option { + remembered(cx, |m| m.by_object.entry(id).or_insert(None), || vectorcraft_effects::preview::bounds_of(cx.doc, &[id], cx.preview_bounds)) } /// The objects of `ids` that lie entirely inside marquee `r`, edges included, measured by their @@ -334,7 +424,7 @@ impl SelectionTool { if !cx.smart_guides { return None; } - let c = vectorcraft_effects::preview::bounds_of(cx.doc, &[id], cx.preview_bounds)?.center(); + let c = object_bounds(cx, id)?.center(); (c.distance(p) <= reach).then_some((c, "center")) }) }) @@ -2205,4 +2295,36 @@ mod tests { t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)); assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); } + + /// The memo answers for the revision, the selected objects and Use Preview Bounds it measured + /// for, and measures again when any of them changes. + #[test] + fn the_preview_memo_measures_once_per_revision_selection_and_preview_bounds() { + let (d, id) = doc_with_rect(); + let mut moved = d.clone(); + if let Some(n) = moved.node_mut(id) { + n.transform(Affine::translate((50.0, 0.0)), false); + } + let (mut s, p, memo) = (Selection::default(), paint(), PreviewMemo::default()); + s.set([id]); + let at = |d, s, revision, preview_bounds| { + let c = ToolContext { revision, preview_bounds, preview_memo: Some(&memo), ..cx(d, s, &p) }; + (selection_bounds(&c).map(|r| r.x1), object_bounds(&c, id).map(|r| r.x1)) + }; + assert_eq!(at(&d, &s, (1, 1), false), (Some(200.0), Some(200.0))); + assert_eq!(memo.measurements(), 2); + // The same revision with the moved square: the memo answers with what it measured. + assert_eq!(at(&moved, &s, (1, 1), false), (Some(200.0), Some(200.0))); + assert_eq!(memo.measurements(), 2); + assert_eq!(at(&moved, &s, (1, 2), false), (Some(250.0), Some(250.0)), "a new revision"); + assert_eq!(memo.measurements(), 4); + assert_eq!(at(&moved, &s, (1, 2), true), (Some(250.5), Some(250.5)), "Use Preview Bounds counts the stroke"); + assert_eq!(memo.measurements(), 6); + let none = Selection::default(); + assert_eq!(at(&moved, &none, (1, 2), true), (None, Some(250.5)), "another selection"); + assert_eq!(memo.measurements(), 8); + // Without a memo, every call measures. + let c = cx(&moved, &s, &p); + assert_eq!(selection_bounds(&c).map(|r| r.x1), Some(250.0)); + } }