mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 13:47:21 -06:00
Eyedropper: Shift-click samples a blend's steps and an envelope's distorted art
Squashed from #4949 onto main.
This commit is contained in:
+22
-1
@@ -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<Node>], spec: &BlendSpec, symbols: Option<&crate::SymbolBoxes>) -> Option<Rect> {
|
||||
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
|
||||
// =====================================================================================
|
||||
|
||||
+161
-3
@@ -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<Node>;
|
||||
|
||||
/// 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<Node>)>,
|
||||
patterns: Vec<crate::PatternDef>,
|
||||
inputs: u64,
|
||||
art: Rc<Vec<Node>>,
|
||||
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<Vec<EvaluatedLive>> = 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<Vec<Node>> {
|
||||
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<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Topmost editable object under `p`.
|
||||
pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
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<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
|
||||
#[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<HitKind> {
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Point> = (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.
|
||||
|
||||
@@ -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<Rect> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Color> {
|
||||
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<Color> {
|
||||
|
||||
/// 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<Document>], p: Point, opts: HitOptions) -> Option<Color> {
|
||||
let mut passed: HashSet<NodeId> = HashSet::new();
|
||||
@@ -150,6 +153,15 @@ fn color_under(s: &mut Sampler, doc: &Document, levels: &mut [Option<Document>],
|
||||
};
|
||||
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<Document>], n: &Node, p: Point) -> Option<Color> {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user