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)); + } }