From 8362b7532c13798ea2ff9816dcf291570d84a212 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 09:24:18 +0700 Subject: [PATCH] Eyedropper: Shift-click samples a blend's steps and an envelope's distorted art Squashed from #4949 onto main. --- crates/doc/src/blend.rs | 23 ++- crates/doc/src/hit.rs | 164 +++++++++++++++++- crates/doc/src/hit/tests.rs | 145 ++++++++++++++++ crates/doc/src/node/ops.rs | 8 +- crates/engine/src/session/documents.rs | 5 + crates/tools/src/xform/eyedropper.rs | 224 ++++++++++++++++++++++++- 6 files changed, 558 insertions(+), 11 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index ced9472a7..ce20d29cd 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -20,7 +20,7 @@ use std::sync::Arc; use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint}; use vectorcraft_geom::kurbo::ParamCurveArclen; -use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, SubPath, Vec2}; +use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2}; use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile}; use crate::live::{BlendOrientation, BlendSpacing, BlendSpec}; @@ -1029,6 +1029,27 @@ impl Rail { } } +/// A box round everything a blend of `keys` can paint once [`Rail::place`] puts its keys and steps +/// on its spine, turned to it with Align to Path: the spine's box grown by three times the farthest +/// any key paints from its centre. A step's shape lies within that distance of the point on the +/// spine it is moved to, and so does its own centre, which it turns about. `None` when nothing +/// moves. +pub(crate) fn rail_reach(keys: &[Arc], spec: &BlendSpec, symbols: Option<&crate::SymbolBoxes>) -> Option { + if spec.spine.is_none() && spec.orientation != BlendOrientation::AlignToPath { + return None; + } + let spine = blend_spine(keys, spec)?.0.bounds()?; + let radius = keys + .iter() + .filter_map(|k| { + let (c, b) = (center_of(k), k.visual(symbols)?); + let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)]; + Some(corners.into_iter().map(|q| (q - c).hypot()).fold(0.0, f64::max)) + }) + .fold(0.0, f64::max); + Some(spine.inflate(3.0 * radius, 3.0 * radius)) +} + // ===================================================================================== // Blend evaluation // ===================================================================================== diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index b754e0d56..d20c1ffe4 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -1,7 +1,9 @@ //! Hit testing over the document tree. +use std::cell::RefCell; use std::collections::HashSet; -use std::sync::OnceLock; +use std::rc::Rc; +use std::sync::{Arc, OnceLock}; use vectorcraft_geom::hit::{fill_contains, stroke_contains}; use vectorcraft_geom::{BezPath, FillRule, Point, Rect}; @@ -99,6 +101,128 @@ pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n)) } +/// One level of evaluation of a live object in a document, as the canvas draws it: type in an +/// envelope as its distorted glyph outlines, symbol instances as their art. Effects are evaluated +/// above this crate, so the engine registers one with [`set_live_art`]. It reads the object, the +/// document's symbols and patterns, and what [`LiveInputs`] stands for, and nothing else. +pub type LiveArt = fn(Option<&Document>, &Node) -> Vec; + +/// A number that changes whenever something outside the document that [`LiveArt`] reads changes, +/// such as the installed fonts or the plug-ins. +pub type LiveInputs = fn() -> u64; + +static LIVE_ART: OnceLock<(LiveArt, LiveInputs)> = OnceLock::new(); + +/// Register how a live object's art is evaluated for clicks, and the number that changes with what +/// else it reads (the first registration stays). +pub fn set_live_art(f: LiveArt, inputs: LiveInputs) { + // Already set: the same engine, registered by another session. + let _ = LIVE_ART.set((f, inputs)); +} + +/// The registered [`LiveInputs`] now, 0 while nothing is registered. +fn live_inputs() -> u64 { + LIVE_ART.get().map_or(0, |(_, inputs)| inputs()) +} + +/// A live object evaluated by [`live_art`] on this thread: the object, its document's symbols (by +/// name and art) and patterns and the [`LiveInputs`] as they were, the art and its [`art_size`]. +struct EvaluatedLive { + node: Node, + symbols: Vec<(String, Arc)>, + patterns: Vec, + inputs: u64, + art: Rc>, + size: usize, +} + +impl EvaluatedLive { + /// Is this the evaluation of `n` in `doc`, with inputs `inputs`, as they are now? + fn is_of(&self, doc: &Document, n: &Node, inputs: u64) -> bool { + self.inputs == inputs + && self.symbols.len() == doc.symbols.len() + && self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && Arc::ptr_eq(art, &s.art)) + && self.node == *n + && self.patterns == doc.patterns + } +} + +/// How many evaluations [`live_art`] keeps on each thread: the pointer moving in a blend's or an +/// envelope's stroke band hits it again and again, and an envelope can hold a blend. +const LIVE_KEPT: usize = 4; + +/// Most [`art_size`] the evaluations [`live_art`] keeps on one thread add up to. Art larger than +/// this is not kept. +const LIVE_KEPT_SIZE: usize = 250_000; + +/// The size of `art` for [`LIVE_KEPT_SIZE`]: its objects and the anchors of their paths. +fn art_size(art: &[Node]) -> usize { + let mut size = 0usize; + for n in art { + n.walk(&mut |c| size = size.saturating_add(1 + c.path_data().map_or(0, |p| p.anchor_count()))); + } + size +} + +/// Does `n`, or the art of a symbol or a pattern of `doc`, hold a placed document? Its art changes +/// when the file is read, so [`live_art`] does not keep an evaluation of `n`. +fn holds_placed(doc: &Document, n: &Node) -> bool { + let mut placed = false; + let mut find = |c: &Node| placed |= matches!(c.kind, NodeKind::PlacedDocument(_)); + n.walk(&mut find); + doc.symbols.iter().for_each(|s| s.art.walk(&mut find)); + doc.patterns.iter().flat_map(|p| &p.art).for_each(|a| a.walk(&mut find)); + placed +} + +thread_local! { + /// The evaluations [`live_art`] keeps, the latest first. + static EVALUATED: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// The art live object `n` of `doc` draws ([`set_live_art`]); [`crate::live::expand_live`] while +/// nothing is registered. The art is evaluated again when `n`, a symbol of `doc`, a pattern of +/// `doc` or the [`LiveInputs`] have changed since, and on every click while it or the document's +/// symbols or patterns hold a placed document ([`holds_placed`]). +fn live_art(doc: &Document, n: &Node) -> Rc> { + let inputs = live_inputs(); + let kept = EVALUATED.with_borrow_mut(|kept| { + let i = kept.iter().position(|e| e.is_of(doc, n, inputs))?; + let e = kept.remove(i); + let art = e.art.clone(); + kept.insert(0, e); + Some(art) + }); + if let Some(art) = kept { + return art; + } + #[cfg(test)] + LIVE_EVALUATED.with(|c| c.set(c.get() + 1)); + let art = Rc::new(LIVE_ART.get().map_or_else(|| crate::live::expand_live(n), |(f, _)| f(Some(doc), n))); + let size = art_size(&art); + if size > LIVE_KEPT_SIZE || holds_placed(doc, n) { + return art; + } + let symbols = doc.symbols.iter().map(|s| (s.name.clone(), s.art.clone())).collect(); + let e = EvaluatedLive { node: n.clone(), symbols, patterns: doc.patterns.clone(), inputs, art: art.clone(), size }; + EVALUATED.with_borrow_mut(|kept| { + kept.insert(0, e); + kept.truncate(LIVE_KEPT); + let mut total = 0usize; + kept.retain(|e| { + total = total.saturating_add(e.size); + total <= LIVE_KEPT_SIZE + }); + }); + art +} + +#[cfg(test)] +thread_local! { + /// How many live objects [`live_art`] evaluated on this thread. + static LIVE_EVALUATED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// Topmost editable object under `p`. pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option { hit_test_skipping(doc, p, opt, &|_| false, true) @@ -565,7 +689,15 @@ fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom:: stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline) } +#[cfg(test)] +thread_local! { + /// How many objects [`hit_leaf`] tested on this thread. + static LEAVES_TESTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option { + #[cfg(test)] + LEAVES_TESTED.with(|c| c.set(c.get() + 1)); match &n.kind { NodeKind::Path { path, rule, .. } => { let bp = path.to_bezpath(); @@ -612,9 +744,18 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill), }) } - NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => { + NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } => { n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds) } + // A blend or an envelope is hit within its bounds, and outside them where its evaluated + // art paints: a blend's keys and steps on a spine can reach past the keys and the spine, + // and the strokes of an envelope's distorted contents past its bounds. + NodeKind::Blend { .. } | NodeKind::Envelope { .. } => { + if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) { + return Some(HitKind::Bounds); + } + live_art(symbols.document(), n).iter().find_map(|g| hit_any(g, p, opt, symbols)) + } NodeKind::Repeat(r) => r.expand(Some(symbols.document())).iter().find_map(|g| hit_any(g, p, opt, symbols)), NodeKind::Mesh(m) => { let bp = m.outline().to_bezpath(); @@ -651,8 +792,25 @@ fn on_type_path(n: &Node, t: &crate::TextObject, p: Point, tol: f64) -> bool { /// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]). fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option { + // Hidden art does not hit. + if !n.visible { + return None; + } + // Nor art whose reach does not hold `p`, as in [`may_hit`]: a blend's steps far from `p` are + // passed by without being measured. + if !edits_contents(n) && n.extras().shaper.is_none() && n.reach_bounds_with(symbols).is_some_and(|b| !b.inflate(opt.tol, opt.tol).contains(p)) { + return None; + } match &n.kind { - NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)), + // A clip group only hits inside its clipping path. + NodeKind::Group { children, clip } => { + if *clip && children.first().is_some_and(|c| !clip_contains(c, p)) { + return None; + } + children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)) + } + // An intertwine group hits by its members, as on the canvas. + NodeKind::Intertwine { children } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)), _ => hit_leaf(n, p, opt, symbols), } } diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index e5ee6c4c8..c4d6c565e 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -429,6 +429,151 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() { } } +/// A click just outside a blend's bounds, in the reach of its keys' strokes, tests only the steps +/// whose own reach holds the point, not every step of the evaluated blend. +#[test] +fn a_click_in_a_blends_stroke_band_tests_only_the_steps_that_reach_it() { + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + // 10 pt circles with 4 pt strokes, y 295 to 305, from x 10 to x 890: 200 steps about 4.3 pt apart. + let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0); + let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone())); + let blend = d.alloc_id(); + let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)]; + let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() }; + d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap(); + let count = |p: Point| { + LEAVES_TESTED.with(|c| c.set(0)); + let h = hit_test(&d, p, HitOptions::default()).map(|h| h.leaf); + (h, LEAVES_TESTED.with(std::cell::Cell::get)) + }; + // On the steps' strokes halfway between the keys, 4 pt above the bounds: a few steps reach it. + let (h, tested) = count(Point::new(450.0, 291.0)); + assert_eq!(h, Some(blend)); + assert!(tested < 20, "{tested} objects tested"); + // In the corner of the reach, outside every stroke: nothing hits. + let (h, tested) = count(Point::new(6.0, 291.0)); + assert_eq!(h, None); + assert!(tested < 20, "{tested} objects tested"); +} + +/// An intertwine group in a live object's evaluated art (a blend key a spine moves) hits by its +/// members. +#[test] +fn evaluated_art_hits_an_intertwine_group_by_its_members() { + let d = Document::new(200.0, 200.0); + let square = Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art())); + let group = Node::new(NodeId(8), NodeKind::Intertwine { children: vec![square] }); + let symbols = d.symbol_boxes(); + assert_eq!(hit_any(&group, Point::new(10.0, 10.0), HitOptions::default(), &symbols), Some(HitKind::Fill)); + assert_eq!(hit_any(&group, Point::new(50.0, 50.0), HitOptions::default(), &symbols), None); +} + +/// The pointer moving in a blend's stroke band evaluates the blend once, and again after the blend +/// or a symbol of the document changes. +#[test] +fn clicks_in_a_blends_stroke_band_evaluate_it_once_until_it_changes() { + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0); + let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone())); + let blend = d.alloc_id(); + let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)]; + let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() }; + d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap(); + let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get); + let before = evaluated(); + for x in [448.0, 450.0, 452.0] { + assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend)); + } + assert_eq!(evaluated(), before + 1); + // 100 steps 8.7 pt apart: a step still reaches (450, 291). + if let Some(NodeKind::Blend { spec, .. }) = d.node_mut(blend).map(|n| &mut n.kind) { + spec.spacing = crate::live::BlendSpacing::Steps(100); + } + assert_eq!(hit_test(&d, Point::new(450.0, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend)); + assert_eq!(evaluated(), before + 2); + let art = circle(d.alloc_id(), 0.0); + d.symbols.push(crate::Symbol { dynamic: false, name: "dot".into(), art }); + hit_test(&d, Point::new(450.0, 291.0), HitOptions::default()); + assert_eq!(evaluated(), before + 3); + hit_test(&d, Point::new(450.0, 291.0), HitOptions::default()); + assert_eq!(evaluated(), before + 3); +} + +/// Evaluated art larger than [`LIVE_KEPT_SIZE`] is not kept: each click in the blend's stroke band +/// evaluates it again. +#[test] +fn evaluated_art_past_the_kept_size_is_not_kept() { + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0); + // A 10 pt circle drawn with 2,000 anchors: 202 objects of 2,000 anchors each with 200 steps. + let circle = |id, x: f64| { + let points: Vec = (0..2000) + .map(|i| { + let a = f64::from(i) * std::f64::consts::TAU / 2000.0; + Point::new(x + 5.0 + 5.0 * a.cos(), 300.0 + 5.0 * a.sin()) + }) + .collect(); + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&points, true)); + Arc::new(Node::path(id, path, stroked.clone())) + }; + let blend = d.alloc_id(); + let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)]; + let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() }; + d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap(); + let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get); + let before = evaluated(); + for x in [448.0, 450.0] { + assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend)); + } + assert_eq!(evaluated(), before + 2); +} + +/// While the document's symbols hold a placed document, whose art changes when its file is read, +/// each click in a blend's stroke band evaluates the blend again. +#[test] +fn evaluated_art_is_not_kept_while_a_placed_document_may_show_in_it() { + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0); + let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone())); + let blend = d.alloc_id(); + let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)]; + let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() }; + d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap(); + let placed = crate::PlacedDocument { + link: crate::LinkInfo::new("/art/logo.vectorcraft"), + key: "logo".into(), + bounding: false, + page_box: crate::PageBox::Crop, + width: 80.0, + height: 40.0, + xf: vectorcraft_geom::Affine::IDENTITY, + placement: Default::default(), + }; + let art = Arc::new(Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed)))); + d.symbols.push(crate::Symbol { dynamic: false, name: "logo".into(), art }); + let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get); + let before = evaluated(); + for x in [448.0, 450.0] { + assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend)); + } + assert_eq!(evaluated(), before + 2); +} + +/// A kept evaluation is the evaluation of the object only while the fonts and plug-ins +/// ([`LiveInputs`]) are as they were. +#[test] +fn a_kept_evaluation_is_stale_after_the_live_inputs_change() { + let d = Document::new(100.0, 100.0); + let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + let e = EvaluatedLive { node: n.clone(), symbols: vec![], patterns: vec![], inputs: 7, art: std::rc::Rc::new(vec![]), size: 0 }; + assert!(e.is_of(&d, &n, 7)); + assert!(!e.is_of(&d, &n, 8)); +} + /// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a /// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the /// clipping path and the art. diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index e5c98886a..b5ae03e8b 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -198,7 +198,8 @@ impl Node { /// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in /// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers /// without a clipping mask and Intertwine groups take in their members' boxes, grown by their - /// own strokes. Symbol instances are measured as in [`Node::visual_bounds`]. + /// own strokes. Symbol instances are measured as in [`Node::visual_bounds`]. A blend's box + /// takes in where its spine can put its keys and steps. pub fn reach_bounds(&self) -> Option { self.reach(None) } @@ -234,6 +235,11 @@ impl Node { let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 }; vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o))) }), + // Keys and steps placed on a spine, turned to it with Align to Path, can reach past the + // keys and the spine. + NodeKind::Blend { children, spec } => { + vectorcraft_geom::union_opt(self.visual(symbols), crate::blend::rail_reach(children, spec, symbols)) + } _ => self.visual(symbols), } } diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index b7e7f51fe..0285ae156 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -20,6 +20,11 @@ impl Session { }); // Clicks find an image's own fills and strokes where their effects draw them. vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint); + // Clicks find a blend's steps and an envelope's distorted art where the canvas draws them, + // evaluated again when the fonts or the plug-ins change. + vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || { + vectorcraft_text::FontDb::global().generation().rotate_left(32) ^ vectorcraft_render::effects::plugin_revision() + }); // Exports bake Vector Halftone into the dots the renderer samples. vectorcraft_render::effects::addons::halftone::set_art(vectorcraft_render::addons::halftone::art); // OpenType SVG glyphs are drawn as the SVG importer reads their documents. diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index f2f214935..acba01a74 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -5,9 +5,10 @@ //! onto the clicked object (`reverse`); Shift+Alt-click: add the clicked object's appearance to the //! selection's (`append`). Shift-click: sample only the colour under the cursor into the active //! fill/stroke (`paint.sampleColor`; in a CMYK document an RGB color is stored as CMYK): a -//! gradient's color at that point, a pattern's tile art there (after the pattern's transform), or -//! a gradient mesh's interpolated patch color. Clicking an image samples its pixels (averaged over -//! the options' raster sample size; a CMYK image gives its ink amounts). +//! gradient's color at that point, a pattern's tile art there (after the pattern's transform), a +//! gradient mesh's interpolated patch color, or the art a blend or an envelope draws there (a +//! blend's steps, an envelope's distorted contents). Clicking an image samples its pixels +//! (averaged over the options' raster sample size; a CMYK image gives its ink amounts). //! //! The Gradient panel's eyedropper sets the `stop` option (the tool to return to): the next click //! samples the colour under the cursor into the selected gradient stop (`paint.sampleColor @@ -119,8 +120,8 @@ pub(crate) fn sample_source(cx: &ToolContext, p: Point) -> Option<(vectorcraft_d Some((paint_owner(cx.doc, h.leaf), h.kind)) } -/// The visible color under `p`, including image pixels, pattern tiles, mesh colors and -/// intermediate gradient colors. +/// The visible color under `p`, including image pixels, pattern tiles, mesh colors, intermediate +/// gradient colors, the steps a blend draws and an envelope's distorted contents. pub(crate) fn sample_color_at(cx: &ToolContext, p: Point) -> Option { let (doc, opts, raster_sample) = (cx.doc, sample_options(cx), cx.raster_sample); vectorcraft_doc::depth::with_deep_stack(|| { @@ -134,7 +135,9 @@ pub(crate) fn sample_color_at(cx: &ToolContext, p: Point) -> Option { /// The color the front-most object of `doc` hit at `p` with `opts` shows there. Where a stroke /// gives no color at `p` (a gap between its pattern's tiles), the object's own fill there does; -/// an object that gives none at all is passed over for the ones below it. `levels` holds the +/// an object that gives none at all is passed over for the ones below it. A hit on a blend or an +/// envelope, or on an object it holds, samples the art it draws ([`live_color_at`]), and where +/// that art leaves `p` empty the whole blend or envelope is passed over. `levels` holds the /// scratch documents for art inside art, one for each level deeper. fn color_under(s: &mut Sampler, doc: &Document, levels: &mut [Option], p: Point, opts: HitOptions) -> Option { let mut passed: HashSet = HashSet::new(); @@ -150,6 +153,15 @@ fn color_under(s: &mut Sampler, doc: &Document, levels: &mut [Option], }; s.spend(cost)?; let h = hit_test_visible_skipping(doc, p, opts, &|id| passed.contains(&id))?; + if let Some(live) = drawn_live(doc, &h.ancestry) { + if let Some(c) = live_color_at(s, levels, live, p) { + return Some(c); + } + if !passed.insert(live.id) { + return None; + } + continue; + } let id = paint_owner(doc, h.leaf); let n = doc.node(id)?; let on_stroke = h.kind == HitKind::Stroke; @@ -177,6 +189,23 @@ fn fill_covers(n: &Node, p: Point) -> bool { } } +/// The outermost blend or envelope (other than one whose contents are being edited) in `ancestry`, +/// a hit's path through `doc`: the canvas draws its evaluated art, not the objects it holds. +fn drawn_live<'d>(doc: &'d Document, ancestry: &[NodeId]) -> Option<&'d Node> { + ancestry.iter().filter_map(|id| doc.node(*id)).find(|n| matches!(n.kind, NodeKind::Blend { .. } | NodeKind::Envelope { editing: false, .. })) +} + +/// The color the art blend or envelope `n` draws shows at `p`, sampled as [`art_color_at`] samples +/// art: a blend's keys and steps, an envelope's distorted contents, symbol instances in them by +/// their symbols' art. Evaluating it counts the objects it holds, and the art made counts its +/// objects. +fn live_color_at(s: &mut Sampler, levels: &mut [Option], n: &Node, p: Point) -> Option { + s.spend(n.count())?; + let art = vectorcraft_effects::expand_live_deep(Some(s.doc), n); + s.spend(art.count())?; + art_color_at(s, levels, [art], p) +} + /// The color `n` shows at `p`: an image's pixels, a mesh's interpolated patch color, a symbol /// instance's art, or the color of its fill (or its stroke, when the stroke was hit or there is no /// fill) there. Images, symbols and patterns are looked up in the sampled document. @@ -1198,4 +1227,187 @@ mod tests { assert_eq!(paint_color_at(&paint, None, Point::new(0.0, -50.0)).unwrap().to_hex(), "#000000"); assert_eq!(paint_color_at(&paint, None, Point::new(50.0, 0.0)).unwrap().to_hex(), "#808080"); } + + /// An unstroked rectangle of `d` filled with `color`. + fn solid_rect(d: &mut vectorcraft_doc::Document, r: vectorcraft_geom::Rect, color: Color) -> Node { + let a = vectorcraft_doc::Appearance::basic(Paint::solid(color), Paint::None, 0.0); + Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), a) + } + + /// [`doc_with_rect`] plus a green rectangle (290, 290)–(430, 350) and, over it, `live` made by + /// `make` from the document. + fn doc_with_live_over_green(make: impl FnOnce(&mut vectorcraft_doc::Document) -> Node) -> vectorcraft_doc::Document { + use vectorcraft_geom::Rect; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let green = solid_rect(&mut d, Rect::new(290.0, 290.0, 430.0, 350.0), Color::rgb(0.0, 1.0, 0.0)); + d.insert(Some(l), usize::MAX, green).unwrap(); + let live = make(&mut d); + d.insert(Some(l), usize::MAX, live).unwrap(); + d + } + + /// Shift-click on a blend samples the step drawn under the cursor, and a gap between its steps + /// samples what lies below. + #[test] + fn shift_click_samples_the_blend_step_under_the_cursor() { + use vectorcraft_doc::live::{BlendSpacing, BlendSpec}; + use vectorcraft_geom::Rect; + // Red (300, 300)–(320, 320) and blue (400, 300)–(420, 320) with 1 step, half red and half + // blue, at x 350 to 370. + let d = doc_with_live_over_green(|d| { + let red = solid_rect(d, Rect::new(300.0, 300.0, 320.0, 320.0), Color::rgb(1.0, 0.0, 0.0)); + let blue = solid_rect(d, Rect::new(400.0, 300.0, 420.0, 320.0), Color::rgb(0.0, 0.0, 1.0)); + let spec = BlendSpec { spacing: BlendSpacing::Steps(1), ..Default::default() }; + Node::new(d.alloc_id(), NodeKind::Blend { children: vec![std::sync::Arc::new(red), std::sync::Arc::new(blue)], spec: Box::new(spec) }) + }); + let (s, p) = (Selection::default(), paint()); + let shift = Mods { shift: true, ..Default::default() }; + let at = |x: f64| EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, x, 310.0).with_mods(shift)); + let mid = Color::rgb(0.5, 0.0, 0.5).to_hex(); + assert_eq!(sampled_hex(&at(360.0)), mid); + assert_eq!(crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), Point::new(360.0, 310.0)).map(|c| c.to_hex()), Some(mid)); + assert_eq!(sampled_hex(&at(310.0)), "#ff0000"); + // Between the red key and the step (x 320 to 350), and below the step. + assert_eq!(sampled_hex(&at(335.0)), "#00ff00"); + let below = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 360.0, 325.0).with_mods(shift)); + assert_eq!(sampled_hex(&below), "#00ff00"); + } + + /// A blend of two instances of one symbol draws its steps as instances too: Shift-click on a + /// step samples the symbol's art there, and where that art leaves the point empty, what lies + /// below. + #[test] + fn shift_click_samples_symbol_art_in_a_blends_steps() { + use vectorcraft_doc::live::{BlendSpacing, BlendSpec}; + use vectorcraft_geom::{Affine, Rect}; + // A red 20 pt square at the symbol's origin, placed at x 300 and 400, with 1 step at x 350. + let d = doc_with_live_over_green(|d| { + let art = solid_rect(d, Rect::new(0.0, 0.0, 20.0, 20.0), Color::rgb(1.0, 0.0, 0.0)); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Dot".into(), art: std::sync::Arc::new(art) }); + let mut instance = |x: f64| { + let kind = NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((x, 300.0)) }; + std::sync::Arc::new(Node::new(d.alloc_id(), kind)) + }; + let children = vec![instance(300.0), instance(400.0)]; + let spec = BlendSpec { spacing: BlendSpacing::Steps(1), ..Default::default() }; + Node::new(d.alloc_id(), NodeKind::Blend { children, spec: Box::new(spec) }) + }); + let (s, p) = (Selection::default(), paint()); + let sample = |x: f64, y: f64| crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), Point::new(x, y)).map(|c| c.to_hex()); + assert_eq!(sample(360.0, 310.0), Some("#ff0000".into())); + assert_eq!(sample(335.0, 310.0), Some("#00ff00".into())); + assert_eq!(sample(360.0, 325.0), Some("#00ff00".into())); + } + + /// Align to Path turns a blend's keys and steps to its spine, past the keys' bounds: a click + /// there hits the blend and samples what it draws. + #[test] + fn shift_click_samples_blend_art_turned_past_its_bounds() { + use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec}; + use vectorcraft_geom::Rect; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let green = solid_rect(&mut d, Rect::new(20.0, 20.0, 280.0, 280.0), Color::rgb(0.0, 1.0, 0.0)); + d.insert(Some(l), usize::MAX, green).unwrap(); + // 100 × 10 keys centred on (100, 100) and (200, 200): Align to Path turns them 45°, and the + // red one reaches (70, 66), above the keys' bounds (y 95 to 205). + let red = solid_rect(&mut d, Rect::new(50.0, 95.0, 150.0, 105.0), Color::rgb(1.0, 0.0, 0.0)); + let blue = solid_rect(&mut d, Rect::new(150.0, 195.0, 250.0, 205.0), Color::rgb(0.0, 0.0, 1.0)); + let spec = BlendSpec { spacing: BlendSpacing::Steps(1), orientation: BlendOrientation::AlignToPath, ..Default::default() }; + let id = d.alloc_id(); + let blend = Node::new(id, NodeKind::Blend { children: vec![std::sync::Arc::new(red), std::sync::Arc::new(blue)], spec: Box::new(spec) }); + d.insert(Some(l), usize::MAX, blend).unwrap(); + let (s, p) = (Selection::default(), paint()); + let at = Point::new(70.0, 66.0); + assert!(d.node(id).unwrap().geometric_bounds().is_some_and(|b| !b.contains(at))); + assert_eq!(hit_test(&d, at, cx(&d, &s, &p).hit_options()).map(|h| h.leaf), Some(id)); + assert_eq!(crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), at).map(|c| c.to_hex()), Some("#ff0000".into())); + // Where the turned key leaves the point empty, the click passes the blend by. + assert_eq!(crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), Point::new(60.0, 90.0)).map(|c| c.to_hex()), Some("#00ff00".into())); + // A hidden red key does not hit there. + if let Some(NodeKind::Blend { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) { + std::sync::Arc::make_mut(&mut children[0]).visible = false; + } + assert_eq!(hit_test(&d, at, cx(&d, &s, &p).hit_options()).map(|h| h.leaf), Some(d.layers[0].children().unwrap()[1].id)); + // A red key clipped to its right half, centred on (125, 100): the line to the blue key's + // centre turns it about 53°, and its hidden left half reaches (89, 52). + let mut clip = solid_rect(&mut d, Rect::new(100.0, 95.0, 150.0, 105.0), Color::BLACK); + clip.appearance = Default::default(); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let red = solid_rect(&mut d, Rect::new(50.0, 95.0, 150.0, 105.0), Color::rgb(1.0, 0.0, 0.0)); + let key = Node::new(d.alloc_id(), NodeKind::Group { children: vec![std::sync::Arc::new(clip), std::sync::Arc::new(red)], clip: true }); + if let Some(NodeKind::Blend { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) { + children[0] = std::sync::Arc::new(key); + } + let hidden = Point::new(89.0, 52.0); + assert!(d.node(id).unwrap().geometric_bounds().is_some_and(|b| !b.contains(hidden))); + assert_eq!(hit_test(&d, hidden, cx(&d, &s, &p).hit_options()).map(|h| h.leaf), Some(d.layers[0].children().unwrap()[1].id)); + assert_eq!(crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), hidden).map(|c| c.to_hex()), Some("#00ff00".into())); + } + + /// The stroke of an envelope's distorted contents paints past the envelope's bounds: a click + /// there hits the envelope and samples the stroke. + #[test] + fn shift_click_samples_an_envelopes_stroke_past_its_bounds() { + use vectorcraft_geom::{Affine, Rect}; + let mut id = None; + let d = doc_with_live_over_green(|d| { + let a = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 10.0); + let red = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 300.0, 420.0, 320.0)), a); + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true }; + let content = vec![std::sync::Arc::new(red)]; + let n = Node::new( + d.alloc_id(), + NodeKind::Envelope { + content, + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options: Default::default(), + frame: Box::new(Affine::IDENTITY), + }, + ); + id = Some(n.id); + n + }); + let (s, p) = (Selection::default(), paint()); + // The middle of the band's top edge stays at y 300, and its 10 pt stroke reaches y 295. + let at = Point::new(360.0, 296.0); + assert!(d.node(id.unwrap()).unwrap().geometric_bounds().is_some_and(|b| !b.inflate(3.0, 3.0).contains(at))); + assert_eq!(hit_test(&d, at, cx(&d, &s, &p).hit_options()).map(|h| h.leaf), id); + assert_eq!(crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), at).map(|c| c.to_hex()), Some("#0000ff".into())); + } + + /// Shift-click on an envelope samples its distorted contents where they are drawn, and a point + /// in its bounds that they leave empty samples what lies below. + #[test] + fn shift_click_samples_the_envelopes_distorted_contents() { + use vectorcraft_geom::{Affine, Rect}; + let d = doc_with_live_over_green(|d| { + let red = solid_rect(d, Rect::new(300.0, 300.0, 420.0, 320.0), Color::rgb(1.0, 0.0, 0.0)); + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 50.0, h: 0.0, v: 0.0, horizontal: true }; + let content = vec![std::sync::Arc::new(red)]; + let options = Default::default(); + Node::new( + d.alloc_id(), + NodeKind::Envelope { content, kind: Box::new(kind), fidelity: 50.0, editing: false, options, frame: Box::new(Affine::IDENTITY) }, + ) + }); + let (s, p) = (Selection::default(), paint()); + let sample = |x: f64, y: f64| crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), Point::new(x, y)).map(|c| c.to_hex()); + let art = vectorcraft_effects::expand_live_deep(Some(&d), d.layers[0].children().unwrap().last().unwrap()); + let b = art.geometric_bounds().unwrap(); + // The arc bends the ends of the band down, below the 20 pt it was, and keeps its middle. + assert!(b.y1 > 330.0, "{b:?}"); + assert_eq!(sample(360.0, 310.0), Some("#ff0000".into())); + assert_eq!(sample(310.0, 332.0), Some("#ff0000".into())); + // Only what the art paints counts: just past the bent end's edge, within the click + // tolerance of it, the green below shows. + assert_eq!(sample(305.0, 332.0), Some("#00ff00".into())); + assert_eq!(sample(360.0, 332.0), Some("#00ff00".into())); + assert_eq!(sample(b.x0 + 0.5, b.y0 + 1.0), Some("#00ff00".into())); + } }