diff --git a/Cargo.lock b/Cargo.lock index aeec6db30..9362671b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6499,6 +6499,7 @@ dependencies = [ "kurbo 0.13.1", "serde", "serde_json", + "vectorcraft-brush", "vectorcraft-color", "vectorcraft-doc", "vectorcraft-effects", diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index eb436e971..b754e0d56 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -110,6 +110,12 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option bool) -> Option { + 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 diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index 1a0f8e633..042a6f517 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -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 } diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index 5586b987d..5620be4d5 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -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, 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 { - 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> = (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], p: Point, opts: HitOptions) -> Option { + let mut passed: HashSet = 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::()) }; + 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], n: &Node, on_stroke: bool, p: Point) -> Option { + 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], inst: &Node, name: &str, xf: Affine, p: Point) -> Option { + 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 { +/// 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], n: &Node, on_stroke: bool, p: Point) -> Option { 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 { +/// 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], name: &str, xf: Affine, p: Point) -> Option { + 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> = 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], art: impl IntoIterator, p: Point) -> Option { + 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::>()).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 { 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 { + 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| { + 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());