Merge pull request #4861 from dotCooCoo/fix/eyedropper-sample-pattern-mesh-image

Eyedropper: Shift-click samples the color of a pattern or gradient mesh
This commit is contained in:
BFlat
2026-11-01 01:28:46 +07:00
committed by GitHub
4 changed files with 507 additions and 27 deletions
Generated
+1
View File
@@ -6499,6 +6499,7 @@ dependencies = [
"kurbo 0.13.1",
"serde",
"serde_json",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
+6
View File
@@ -110,6 +110,12 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit
hit_test_skipping(doc, p, opt, &|_| false, false)
}
/// [`hit_test_visible`] passing over the nodes `skip` names (and their contents): what lies below
/// them at `p`. A skipped clipping path still clips.
pub fn hit_test_visible_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool) -> Option<Hit> {
hit_test_skipping(doc, p, opt, skip, false)
}
/// The object of `selected` (topmost first) whose own art lies under `p` where a clipping mask
/// hides it: a clip group (or a layer with a clipping mask) holding it doesn't clip to `p`. A
/// click there hits nothing ([`hit_test`]), so the selection tools grab the selection by that
+1
View File
@@ -12,6 +12,7 @@ workspace = true
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
vectorcraft-brush = { workspace = true }
vectorcraft-pathops = { workspace = true }
vectorcraft-effects = { workspace = true }
vectorcraft-text = { workspace = true }
+499 -27
View File
@@ -4,9 +4,10 @@
//! selection and the paint defaults (`appearance.copyFrom`); Alt-click: the selection's attributes
//! 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). Clicking
//! an image samples its pixels (averaged over the options' raster sample size; a CMYK image gives
//! its ink amounts).
//! 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).
//!
//! 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
@@ -15,11 +16,14 @@
//! Brush Options' Key Color eyedropper sets `brushKeyColor` and `stop`: the next click samples into
//! the open dialog (`brushKeyColor` UI request) and switches back.
use std::collections::HashSet;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::hit::{HitKind, hit_test, hit_test_visible};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Point;
use vectorcraft_doc::hit::{HitKind, HitOptions, hit_test, hit_test_visible, hit_test_visible_skipping};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::{Affine, Point, Rect};
use super::paint_owner;
use crate::params::color_json;
@@ -49,18 +53,142 @@ pub fn paint_color_at(paint: &Paint, bounds: Option<vectorcraft_geom::Rect>, p:
}
}
/// How many patterns and symbols deep a sample follows art inside art (tile art painted with a
/// pattern, a symbol whose art holds an instance of another).
const MAX_NESTING: usize = 16;
/// Most tiles of one pattern a sample looks at, front to back, for art that paints the point.
const MAX_TILES: usize = 1_000;
/// Most work one sample does, in objects looked at: each hit test after the first in a document
/// counts the objects of the document and of its symbols, each pattern tile or symbol instance
/// sampled counts the objects of its art, and every hundred tile positions checked count one.
/// Art inside art multiplies them; a sample that would need more gives no color.
const MAX_WORK: usize = 1_000_000;
/// One color sample: the document, the hit options, the raster sample size and the work left.
struct Sampler<'a> {
doc: &'a Document,
opts: HitOptions,
raster_sample: u32,
/// The objects in the art of the document's symbols, which a hit test may measure.
symbol_nodes: usize,
work: usize,
/// The work ran out: the sample gives no color.
exhausted: bool,
}
impl Sampler<'_> {
/// Count `n` units of work, or `None` when that is more than is left.
fn spend(&mut self, n: usize) -> Option<()> {
match self.work.checked_sub(n) {
Some(left) => {
self.work = left;
Some(())
}
None => {
self.work = 0;
self.exhausted = true;
None
}
}
}
}
/// The Eyedropper's hit options: a click inside a fill or among characters samples it too,
/// whatever Object and Type Object Selection by Path Only say.
fn sample_options(cx: &ToolContext) -> HitOptions {
HitOptions { path_only: false, type_path_only: false, ..cx.hit_options() }
}
/// The visible object under `p`, using the Eyedropper's source selection rules.
pub(crate) fn sample_source(cx: &ToolContext, p: Point) -> Option<(vectorcraft_doc::NodeId, HitKind)> {
let opts = vectorcraft_doc::hit::HitOptions { path_only: false, type_path_only: false, ..cx.hit_options() };
let h = hit_test_visible(cx.doc, p, opts)?;
let h = hit_test_visible(cx.doc, p, sample_options(cx))?;
Some((paint_owner(cx.doc, h.leaf), h.kind))
}
/// The visible color under `p`, including image pixels and intermediate gradient colors.
/// The visible color under `p`, including image pixels, pattern tiles, mesh colors and
/// intermediate gradient colors.
pub(crate) fn sample_color_at(cx: &ToolContext, p: Point) -> Option<Color> {
let (id, kind) = sample_source(cx, p)?;
let n = cx.doc.node(id)?;
if matches!(n.kind, NodeKind::Image(_)) { image_color_at(cx, n, p) } else { paint_sample(n, kind == HitKind::Stroke, p) }
let (doc, opts, raster_sample) = (cx.doc, sample_options(cx), cx.raster_sample);
vectorcraft_doc::depth::with_deep_stack(|| {
let symbol_nodes = doc.symbols.iter().map(|s| s.art.count()).sum();
let mut s = Sampler { doc, opts, raster_sample, symbol_nodes, work: MAX_WORK, exhausted: false };
let mut levels: Vec<Option<Document>> = (0..MAX_NESTING).map(|_| None).collect();
let c = color_under(&mut s, doc, &mut levels, p, opts);
c.filter(|_| !s.exhausted)
})
}
/// 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
/// 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();
let mut size = None;
loop {
// The first hit test is the one a click makes anyway; each one after it may visit every
// object again.
let cost =
if passed.is_empty() { 1 } else { *size.get_or_insert_with(|| s.symbol_nodes + doc.layers.iter().map(|l| l.count()).sum::<usize>()) };
s.spend(cost)?;
let h = hit_test_visible_skipping(doc, p, opts, &|id| passed.contains(&id))?;
let id = paint_owner(doc, h.leaf);
let n = doc.node(id)?;
let on_stroke = h.kind == HitKind::Stroke;
if let Some(c) = node_color_at(s, levels, n, on_stroke, p) {
return Some(c);
}
if on_stroke
&& fill_covers(n, p)
&& let Some(c) = node_color_at(s, levels, n, false, p)
{
return Some(c);
}
if !passed.insert(id) {
return None;
}
}
}
/// Whether the fill of path or compound path `n` covers `p`.
fn fill_covers(n: &Node, p: Point) -> bool {
match &n.kind {
NodeKind::Path { path, rule, .. } => fill_contains(&path.to_bezpath(), *rule, p),
NodeKind::Compound { rule, .. } => n.stroke_path().is_some_and(|bp| fill_contains(&bp, *rule, p)),
_ => false,
}
}
/// 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.
fn node_color_at(s: &mut Sampler, levels: &mut [Option<Document>], n: &Node, on_stroke: bool, p: Point) -> Option<Color> {
match &n.kind {
NodeKind::Image(_) => image_color_at(s, n, p),
NodeKind::Mesh(m) => {
let (r, c, u, v) = m.locate(p)?;
Some(m.color_at(r, c, u, v).0)
}
// The instance's fill only stains its art: where the art paints nothing, nothing shows.
NodeKind::SymbolInstance { symbol, xf } => symbol_color_at(s, levels, n, symbol, *xf, p),
_ => paint_sample(s, levels, n, on_stroke, p),
}
}
/// The color the art of symbol `name` shows at `p` as instance `inst` draws it: stained by the
/// instance's fill and placed in the document by `xf`, its strokes and patterns unscaled.
fn symbol_color_at(s: &mut Sampler, levels: &mut [Option<Document>], inst: &Node, name: &str, xf: Affine, p: Point) -> Option<Color> {
if xf.determinant().abs() < 1e-12 {
return None;
}
let doc = s.doc;
let sym = doc.symbols.iter().find(|s| s.name == name)?;
s.spend(sym.art.count())?;
let mut art = vectorcraft_brush::instance_art(&sym.art, inst);
art.transform(xf, false);
art_color_at(s, levels, [art], p)
}
/// Whether the source has fill or stroke paint for an attribute sample.
@@ -69,30 +197,111 @@ pub(crate) fn has_sampleable_paint(cx: &ToolContext, id: vectorcraft_doc::NodeId
}
/// The colour of `n`'s fill (or its stroke, when the stroke was hit or there is no fill) at `p`,
/// sampled in the paint's own space (text space for type runs) and box.
fn paint_sample(n: &Node, on_stroke: bool, p: Point) -> Option<Color> {
/// sampled in the paint's own space (text space for type runs) and box. A pattern gives the color
/// of its tile art at `p` ([`pattern_color_at`]).
fn paint_sample(s: &mut Sampler, levels: &mut [Option<Document>], n: &Node, on_stroke: bool, p: Point) -> Option<Color> {
let fill_none = n.proxy_paint(false, None).is_none_or(|(p, ..)| p.is_none());
let (paint, to_doc, bounds) = n.proxy_paint(on_stroke || fill_none, None)?;
if let Paint::Pattern { pattern, xf } = paint {
return pattern_color_at(s, levels, pattern, to_doc * *xf, p);
}
let p = if to_doc.determinant().abs() > 1e-12 { to_doc.inverse() * p } else { p };
paint_color_at(paint, Some(bounds), p)
}
/// The color of image `n`'s pixels at `p`, averaged over the raster sample size: a CMYK image's
/// ink amounts as they are, other images' sRGB pixels in the working RGB space (the color that
/// shows as that pixel).
fn image_color_at(cx: &ToolContext, n: &Node, p: Point) -> Option<Color> {
/// The color pattern `name`, placed in the document by `xf`, shows at `p`: the color of the
/// front-most tile art there. A point the tiles leave empty has no color.
fn pattern_color_at(s: &mut Sampler, levels: &mut [Option<Document>], name: &str, xf: Affine, p: Point) -> Option<Color> {
if xf.determinant().abs() < 1e-12 {
return None;
}
let doc = s.doc;
let def = doc.patterns.iter().find(|d| d.name == name)?;
// Sampled in pattern space, one tile at a time from the front, among the tiles whose art reaches
// the point (or meets it at an edge). The art moves by whole tiles, and the patterns it is
// painted with move with it.
let q = xf.inverse() * p;
let near = Rect::from_points(q, q).inflate(1e-6, 1e-6);
let (mut art_nodes, mut instances) = (0, false);
for a in &def.art {
a.walk(&mut |n| {
art_nodes += 1;
instances |= matches!(n.kind, NodeKind::SymbolInstance { .. });
});
}
// Symbol instances in the art are measured by their symbols' art.
s.spend(1 + art_nodes + if instances { s.symbol_nodes } else { 0 })?;
let symbols = doc.symbol_boxes();
let bounds: Vec<Option<Rect>> = def.art.iter().map(|a| a.visual_bounds_with(&symbols)).collect();
let reaches = |m: Affine| bounds.iter().any(|b| b.is_none_or(|b| m.transform_rect_bbox(b).inflate(1e-6, 1e-6).contains(q)));
let scaling = vectorcraft_doc::Scaling { patterns: true, ..Default::default() };
let offsets = def.offsets_covering(near);
// Each tile position is checked against each piece of art: a hundred checks count as one unit.
s.spend(offsets.len().saturating_mul(bounds.len().max(1)) / 100)?;
for m in offsets.into_iter().rev().map(|o| def.instance_xf(o)).filter(|m| reaches(*m)).take(MAX_TILES) {
s.spend(1 + art_nodes)?;
let art = def.art.iter().map(|a| {
let mut n = (**a).clone();
vectorcraft_doc::pattern::place_art(&mut n, m, scaling);
n
});
if let Some(c) = art_color_at(s, levels, art, q) {
return Some(c);
}
}
None
}
/// The color the front-most of `art` (in paint order, in the space of `p`) shows at `p`, sampled
/// as [`node_color_at`] samples an object, with no tolerance around outlines and strokes: only
/// what it paints at `p` counts. The art is hit in the first of `levels`, a scratch document with
/// the sampled document's symbols, made on first use and emptied for each new piece of art; art
/// inside it uses the levels after it. With no level left, the art gives no color.
fn art_color_at(s: &mut Sampler, levels: &mut [Option<Document>], art: impl IntoIterator<Item = Node>, p: Point) -> Option<Color> {
let (slot, deeper) = levels.split_first_mut()?;
let doc = s.doc;
let scratch = slot.get_or_insert_with(|| {
let mut d = Document::new(1.0, 1.0);
d.symbols = doc.symbols.clone();
d
});
let layer = scratch.layers.first()?;
let (layer, placed) = (layer.id, layer.children().map(|c| c.iter().map(|n| n.id).collect::<Vec<_>>()).unwrap_or_default());
for id in placed {
scratch.remove(id).ok()?;
}
for mut n in art {
renumber(scratch, &mut n);
scratch.insert(Some(layer), usize::MAX, n).ok()?;
}
let opts = HitOptions { scope: None, tol: 0.0, outline: false, ..s.opts };
color_under(s, scratch, deeper, p, opts)
}
/// Gives `n` and everything in it new ids from `doc` (pattern instances all have id 0).
fn renumber(doc: &mut Document, n: &mut Node) {
n.id = doc.alloc_id();
for c in n.children_mut().into_iter().flatten() {
renumber(doc, std::sync::Arc::make_mut(c));
}
}
/// The color of image `n`'s pixels at `p`, averaged over the raster sample size: a CMYK image's ink
/// amounts as they are, other images' sRGB pixels in the working RGB space (the color that shows
/// as that pixel).
fn image_color_at(s: &Sampler, n: &Node, p: Point) -> Option<Color> {
let NodeKind::Image(im) = &n.kind else { return None };
if im.xf.determinant().abs() < 1e-12 {
return None;
}
let px = im.xf.inverse() * p;
let blob = cx.doc.images.get(&im.key)?;
let blob = s.doc.images.get(&im.key)?;
let object = (im.width, im.height);
if let Some(inks) = blob.sample_cmyk_object(px.x, px.y, object, cx.raster_sample) {
if let Some(inks) = blob.sample_cmyk_object(px.x, px.y, object, s.raster_sample) {
let [c, m, y, k] = inks.map(|v| f32::from(v) / 255.0);
return Some(Color::cmyk(c, m, y, k));
}
let [r, g, b, _] = blob.sample_object(px.x, px.y, object, cx.raster_sample)?;
let [r, g, b, _] = blob.sample_object(px.x, px.y, object, s.raster_sample)?;
let [r, g, b] = vectorcraft_color::cms::active().srgb_to_rgb([r, g, b].map(|v| f32::from(v) / 255.0));
Some(Color::rgb(r, g, b))
}
@@ -106,9 +315,7 @@ impl Tool for EyedropperTool {
if ev.kind != PointerKind::Down {
return vec![];
}
// A click inside a fill or among characters samples it too, whatever Object and Type
// Object Selection by Path Only say.
let opts = vectorcraft_doc::hit::HitOptions { path_only: false, type_path_only: false, ..cx.hit_options() };
let opts = sample_options(cx);
if self.brush_key_color {
let Some(c) = sample_color_at(cx, ev.pos) else { return vec![] };
let back = self.stop.clone().unwrap_or_else(|| "selection".to_string());
@@ -124,7 +331,7 @@ impl Tool for EyedropperTool {
} else {
sample_source(cx, ev.pos)
};
let Some((src, kind)) = source else { return vec![] };
let Some((src, _)) = source else { return vec![] };
let Some(n) = cx.doc.node(src) else { return vec![] };
if ev.mods.alt && stop.is_none() {
let how = if ev.mods.shift { "append" } else { "reverse" };
@@ -134,8 +341,7 @@ impl Tool for EyedropperTool {
if !(ev.mods.shift || stop.is_some() || image) {
return vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0 }))];
}
let sampled = if image { image_color_at(cx, n, ev.pos) } else { paint_sample(n, kind == HitKind::Stroke, ev.pos) };
let Some(c) = sampled else { return vec![] };
let Some(c) = sample_color_at(cx, ev.pos) else { return vec![] };
let color = color_json(&c);
let Some((back, i)) = stop else { return vec![Action::Exec("paint.sampleColor".into(), json!({ "color": color }))] };
let out = vec![Action::Exec("paint.sampleColor".into(), json!({ "color": color, "stop": i })), Action::SwitchTool(back.clone())];
@@ -389,6 +595,272 @@ mod tests {
);
}
/// An unstroked rectangle filled with `fill`, with id 0 (as pattern and symbol art is).
fn filled_rect(r: vectorcraft_geom::Rect, fill: Paint) -> std::sync::Arc<Node> {
let mut a = vectorcraft_doc::Appearance::default();
a.set_fill(fill);
std::sync::Arc::new(Node::path(vectorcraft_doc::NodeId(0), vectorcraft_geom::shapes::rectangle(r), a))
}
/// [`doc_with_rect`] plus the pattern "Halves": a 20 × 10 tile, red on its left half and blue
/// on its right half.
fn doc_with_halves() -> (vectorcraft_doc::Document, vectorcraft_doc::NodeId) {
use vectorcraft_geom::Rect;
let (mut d, id) = doc_with_rect();
let (red, blue) = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
let mut def = vectorcraft_doc::PatternDef::new(
"Halves",
vec![filled_rect(Rect::new(0.0, 0.0, 10.0, 10.0), red), filled_rect(Rect::new(10.0, 0.0, 20.0, 10.0), blue)],
);
def.tile = Rect::new(0.0, 0.0, 20.0, 10.0);
d.patterns.push(def);
(d, id)
}
/// Shift-click on a pattern fill samples the color of the tile art under the cursor, where the
/// pattern's own transform puts it, however small the tiles are.
#[test]
fn shift_click_samples_the_pattern_tile_under_the_cursor() {
use vectorcraft_geom::Affine;
let (mut d, id) = doc_with_halves();
d.node_mut(id).unwrap().appearance.set_fill(Paint::Pattern { pattern: "Halves".into(), xf: Affine::translate((5.0, 0.0)) });
let (s, p) = (Selection::default(), paint());
let shift = Mods { shift: true, ..Default::default() };
let at = |d: &vectorcraft_doc::Document, x: f64| {
EyedropperTool::default().pointer(&cx(d, &s, &p), &PointerEvent::new(PointerKind::Down, x, 155.0).with_mods(shift))
};
// Pattern space x 128 (8 into a tile), then x 137 (17 into a tile).
assert_eq!(sampled_hex(&at(&d, 133.0)), "#ff0000");
assert_eq!(sampled_hex(&at(&d, 142.0)), "#0000ff");
assert_eq!(crate::xform::eyedropper::sample_color_at(&cx(&d, &s, &p), Point::new(142.0, 155.0)).map(|c| c.to_hex()), Some("#0000ff".into()));
// Art 300 pt wide on a 1 pt tile: about 90,000 tiles reach every point.
let mut wide = vectorcraft_doc::PatternDef::new(
"Wide",
vec![filled_rect(vectorcraft_geom::Rect::new(0.0, 0.0, 300.0, 300.0), Paint::solid(Color::rgb(0.0, 1.0, 0.0)))],
);
wide.tile = vectorcraft_geom::Rect::new(0.0, 0.0, 1.0, 1.0);
let mut wide_doc = d.clone();
wide_doc.patterns.push(wide);
wide_doc.node_mut(id).unwrap().appearance.set_fill(vectorcraft_doc::pattern::pattern_paint("Wide"));
assert_eq!(sampled_hex(&at(&wide_doc, 142.0)), "#00ff00");
// Pattern space x 140, on the edge between two tiles.
let edge = sampled_hex(&at(&d, 145.0));
assert!(edge == "#ff0000" || edge == "#0000ff", "{edge}");
// Tiles 0.2 × 0.1 pt, 0.05 pt down: pattern space y 15495, x 15005 (5 into a tile), then
// x 15015 (15 into a tile).
let tiny = Affine::translate((0.0, 0.05)) * Affine::scale(0.01);
d.node_mut(id).unwrap().appearance.set_fill(Paint::Pattern { pattern: "Halves".into(), xf: tiny });
assert_eq!(sampled_hex(&at(&d, 150.05)), "#ff0000");
assert_eq!(sampled_hex(&at(&d, 150.15)), "#0000ff");
}
/// Tile art painted with another pattern is sampled where that tile draws it: the inner
/// pattern moves with each tile. A symbol instance is sampled from its symbol's art.
#[test]
fn shift_click_samples_patterns_and_symbols_inside_art() {
use vectorcraft_geom::{Affine, Rect};
let (mut d, id) = doc_with_halves();
// A 30 pt tile filled with "Halves": every other tile starts halfway through a "Halves" tile.
let mut outer = vectorcraft_doc::PatternDef::new(
"Outer",
vec![filled_rect(Rect::new(0.0, 0.0, 30.0, 10.0), vectorcraft_doc::pattern::pattern_paint("Halves"))],
);
outer.tile = Rect::new(0.0, 0.0, 30.0, 10.0);
d.patterns.push(outer);
d.node_mut(id).unwrap().appearance.set_fill(vectorcraft_doc::pattern::pattern_paint("Outer"));
let (s, p) = (Selection::default(), paint());
let shift = Mods { shift: true, ..Default::default() };
// The tile at x 150 draws "Halves" from x 150: x 165 is 15 into it.
let a = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 165.0, 155.0).with_mods(shift));
assert_eq!(sampled_hex(&a), "#0000ff");
// A symbol of a green square, placed at (300, 300) and doubled.
d.symbols.push(vectorcraft_doc::Symbol {
name: "Green".into(),
art: filled_rect(Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(Color::rgb(0.0, 1.0, 0.0))),
});
let l = d.layers[0].id;
let inst = d.alloc_id();
let kind = NodeKind::SymbolInstance { symbol: "Green".into(), xf: Affine::translate((300.0, 300.0)) * Affine::scale(2.0) };
d.insert(Some(l), usize::MAX, Node::new(inst, kind)).unwrap();
let a = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 310.0, 310.0).with_mods(shift));
assert_eq!(sampled_hex(&a), "#00ff00");
}
/// A symbol instance is sampled as it draws: its art stained by the instance's fill, and a
/// pattern in its art left where the document puts it when the instance moves.
#[test]
fn symbol_samples_follow_the_instances_stain_and_placement() {
use vectorcraft_geom::{Affine, Rect};
let (mut d, _) = doc_with_halves();
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
d.symbols.push(vectorcraft_doc::Symbol { name: "Red".into(), art: filled_rect(Rect::new(0.0, 0.0, 20.0, 10.0), red) });
d.symbols.push(vectorcraft_doc::Symbol {
name: "Tiled".into(),
art: filled_rect(Rect::new(0.0, 0.0, 20.0, 10.0), vectorcraft_doc::pattern::pattern_paint("Halves")),
});
let l = d.layers[0].id;
let place = |d: &mut vectorcraft_doc::Document, name: &str, at: (f64, f64), stain: Option<Color>| {
let id = d.alloc_id();
let mut n = Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::translate(at) });
if let Some(c) = stain {
n.appearance.set_fill(Paint::solid(c));
}
d.insert(Some(l), usize::MAX, n).unwrap();
};
place(&mut d, "Red", (300.0, 300.0), Some(Color::rgb(0.0, 0.0, 1.0)));
place(&mut d, "Tiled", (310.0, 400.0), None);
let (s, p) = (Selection::default(), paint());
let shift = Mods { shift: true, ..Default::default() };
let at = |x: f64, y: f64| {
sampled_hex(&EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, x, y).with_mods(shift)))
};
assert_eq!(at(305.0, 305.0), "#0000ff", "stained");
// Document x 312 is 12 into a "Halves" tile, though only 2 into the instance's art.
assert_eq!(at(312.0, 405.0), "#0000ff", "pattern in place");
}
/// Between the shapes of a stained instance's art, the eyedropper samples what lies below, not
/// the stain; and a pattern tile is sampled wherever its symbol art reaches.
#[test]
fn symbol_art_decides_where_an_instance_paints() {
use std::sync::Arc;
use vectorcraft_doc::NodeId;
use vectorcraft_geom::{Affine, Rect};
let (mut d, id) = doc_with_rect();
let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let pair =
Node::group(NodeId(0), vec![filled_rect(Rect::new(0.0, 0.0, 10.0, 10.0), red()), filled_rect(Rect::new(20.0, 0.0, 30.0, 10.0), red())]);
d.symbols.push(vectorcraft_doc::Symbol { name: "Pair".into(), art: Arc::new(pair) });
let (l, inst) = (d.layers[0].id, d.alloc_id());
let mut n = Node::new(inst, NodeKind::SymbolInstance { symbol: "Pair".into(), xf: Affine::translate((100.0, 140.0)) });
n.appearance.set_fill(Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
d.insert(Some(l), usize::MAX, n).unwrap();
let (s, p) = (Selection::default(), paint());
let shift = Mods { shift: true, ..Default::default() };
let at = |d: &vectorcraft_doc::Document, x: f64, y: f64| {
sampled_hex(&EyedropperTool::default().pointer(&cx(d, &s, &p), &PointerEvent::new(PointerKind::Down, x, y).with_mods(shift)))
};
assert_eq!((at(&d, 105.0, 145.0), at(&d, 115.0, 145.0)), ("#0000ff".to_string(), "#ffffff".to_string()));
// A 200 pt tile holding an instance of a 180 pt green bar, which reaches far past the
// square an instance is measured by without its symbol.
let green = Paint::solid(Color::rgb(0.0, 1.0, 0.0));
d.symbols.push(vectorcraft_doc::Symbol { name: "Bar".into(), art: filled_rect(Rect::new(0.0, 0.0, 180.0, 10.0), green) });
let bar = Arc::new(Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY }));
let mut bars = vectorcraft_doc::PatternDef::new("Bars", vec![bar]);
bars.tile = Rect::new(0.0, 0.0, 200.0, 10.0);
d.patterns.push(bars);
d.node_mut(id).unwrap().appearance.set_fill(vectorcraft_doc::pattern::pattern_paint("Bars"));
assert_eq!(at(&d, 150.0, 155.0), "#00ff00");
}
/// Patterns whose tile art is painted with the next one, on tiles far smaller than their art,
/// symbols whose art holds four instances of the next, and symbols nested far deeper than a
/// sample follows: each sample stops within its work limit and gives no color, instead of
/// trying every tile or instance at every level or running out of stack.
#[test]
fn nested_patterns_and_symbols_sample_within_the_work_limit() {
use std::sync::Arc;
use vectorcraft_doc::pattern::pattern_paint;
use vectorcraft_doc::{NodeId, Symbol};
use vectorcraft_geom::{Affine, Rect};
let (mut d, id) = doc_with_rect();
// "Nest0" to "Nest7": 300 pt art on 1 pt tiles, each painted with the next. "Nest8" doesn't
// exist, so no tile gives a color and every one is tried.
for i in 0..8 {
let mut def = vectorcraft_doc::PatternDef::new(
&format!("Nest{i}"),
vec![filled_rect(Rect::new(0.0, 0.0, 300.0, 300.0), pattern_paint(&format!("Nest{}", i + 1)))],
);
def.tile = Rect::new(0.0, 0.0, 1.0, 1.0);
d.patterns.push(def);
}
d.node_mut(id).unwrap().appearance.set_fill(pattern_paint("Nest0"));
// "Fan0" to "Fan11": four instances of the next symbol; "Fan12" a square filled with "Nest8".
let instance = |name: String| Arc::new(Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name, xf: Affine::IDENTITY }));
for i in 0..12 {
let art = Node::group(NodeId(0), (0..4).map(|_| instance(format!("Fan{}", i + 1))).collect());
d.symbols.push(Symbol { name: format!("Fan{i}"), art: Arc::new(art) });
}
d.symbols.push(Symbol { name: "Fan12".into(), art: filled_rect(Rect::new(300.0, 300.0, 400.0, 400.0), pattern_paint("Nest8")) });
// "Deep0" to "Deep99": one instance of the next; "Deep100" a green square.
for i in 0..100 {
d.symbols.push(Symbol { name: format!("Deep{i}"), art: instance(format!("Deep{}", i + 1)) });
}
let green = Paint::solid(Color::rgb(0.0, 1.0, 0.0));
d.symbols.push(Symbol { name: "Deep100".into(), art: filled_rect(Rect::new(300.0, 0.0, 400.0, 100.0), green) });
let l = d.layers[0].id;
for name in ["Fan0", "Deep0"] {
let inst = d.alloc_id();
d.insert(Some(l), usize::MAX, Node::new(inst, NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY })).unwrap();
}
let (s, p) = (Selection::default(), paint());
let start = std::time::Instant::now();
for at in [Point::new(150.0, 150.0), Point::new(350.0, 350.0), Point::new(350.0, 50.0)] {
assert_eq!(sample_color_at(&cx(&d, &s, &p), at), None, "{at:?}");
}
assert!(start.elapsed() < std::time::Duration::from_secs(60), "{:?}", start.elapsed());
}
/// A gap between a pattern's tile art shows what lies below it, in the document and in the tile
/// art of another pattern.
#[test]
fn a_gap_in_a_pattern_samples_what_lies_below() {
use vectorcraft_doc::pattern::pattern_paint;
use vectorcraft_geom::Rect;
// The white rectangle (100, 100)–(200, 200), under one filled with red 10 pt squares 20 pt apart.
let (mut d, _) = doc_with_rect();
let mut gappy =
vectorcraft_doc::PatternDef::new("Gappy", vec![filled_rect(Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)))]);
gappy.tile = Rect::new(0.0, 0.0, 20.0, 10.0);
d.patterns.push(gappy);
let (l, top) = (d.layers[0].id, d.alloc_id());
let mut over = (*filled_rect(Rect::new(100.0, 100.0, 200.0, 200.0), pattern_paint("Gappy"))).clone();
over.id = top;
d.insert(Some(l), usize::MAX, over).unwrap();
let (s, p) = (Selection::default(), paint());
let shift = Mods { shift: true, ..Default::default() };
let at = |d: &vectorcraft_doc::Document, x: f64| {
sampled_hex(&EyedropperTool::default().pointer(&cx(d, &s, &p), &PointerEvent::new(PointerKind::Down, x, 155.0).with_mods(shift)))
};
// x 125 is 5 into a tile, on a red square; x 133 is 13 into one, between them.
assert_eq!((at(&d, 125.0), at(&d, 133.0)), ("#ff0000".to_string(), "#ffffff".to_string()));
// Tile art: a green rectangle behind one filled with "Gappy".
let green = Paint::solid(Color::rgb(0.0, 1.0, 0.0));
let mut lined = vectorcraft_doc::PatternDef::new(
"Lined",
vec![filled_rect(Rect::new(0.0, 0.0, 20.0, 10.0), green), filled_rect(Rect::new(0.0, 0.0, 20.0, 10.0), pattern_paint("Gappy"))],
);
lined.tile = Rect::new(0.0, 0.0, 20.0, 10.0);
d.patterns.push(lined);
d.node_mut(top).unwrap().appearance.set_fill(pattern_paint("Lined"));
assert_eq!((at(&d, 125.0), at(&d, 133.0)), ("#ff0000".to_string(), "#00ff00".to_string()));
// A blue fill under a 20 pt stroke of "Gappy", 5 pt left: x 107 is 12 into a tile, in a gap
// of the stroke over the fill.
let shape = &mut d.node_mut(top).unwrap().appearance;
shape.set_fill(Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
shape.set_stroke(Paint::Pattern { pattern: "Gappy".into(), xf: vectorcraft_geom::Affine::translate((-5.0, 0.0)) });
shape.stroke_mut().unwrap().width = 20.0;
assert_eq!((at(&d, 103.0), at(&d, 107.0)), ("#ff0000".to_string(), "#0000ff".to_string()));
}
/// Shift-click on a gradient mesh samples the patch color interpolated at the cursor.
#[test]
fn shift_click_samples_the_mesh_color_under_the_cursor() {
use vectorcraft_doc::{Document, GradientMesh};
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
// Black on the left edge, white on the right edge.
let mesh = GradientMesh::from_surface(1, 1, &|u, v| Point::new(100.0 + 100.0 * u, 100.0 + 100.0 * v), &|u, _| {
if u < 0.5 { Color::rgb(0.0, 0.0, 0.0) } else { Color::rgb(1.0, 1.0, 1.0) }
});
d.insert(Some(l), 0, Node::new(id, NodeKind::Mesh(mesh))).unwrap();
let (s, p) = (Selection::default(), paint());
let shift = Mods { shift: true, ..Default::default() };
let a = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 125.0, 150.0).with_mods(shift));
assert_eq!(sampled_hex(&a), "#404040");
}
#[test]
fn gradient_sampled_along_vector() {
let g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default());