Recolor Artwork: the Color Theme Picker keeps symbol art and clipped image reads within their limits

The symbol instances the picker reads now share one limit of 16,000,000
placed objects, charged through charge_symbol_art and counted as the
renderer counts them, so deeply nested or heavily repeated instances
stop there. An instance whose art would pass the limit is left out with
its own paints, as the canvas draws none of it. Pattern tiles carry the
count in and out, and their art is read one level deeper than the
object the pattern fills, so a chain of patterns shares the depth limit
with the art around it; a pattern cut short there is read again where
it is used less deep.

The pixels read inside clip groups are now tested against at most
4,000,000 clipping-path elements in all. The clipped reads of one image
share the work, a read outside clip groups keeps its own pixel budget,
and when the work left can't test a pixel of every clipped read, the
reads that fit are kept in order and the rest are left out.
ImageBlob::sample_pixels takes a pixel budget for each pick.
This commit is contained in:
dotCooCoo
2026-11-12 17:40:51 -08:00
parent f7a03f7899
commit 93155b0e3f
3 changed files with 360 additions and 57 deletions
+14 -12
View File
@@ -11,9 +11,9 @@ use crate::ImageBlob;
/// address and aborts when an allocation fails, so it keeps the decoder's own 512 MB.
pub const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
/// A box of an image to sample, in 0 to 1 units of its width and height (`None`: all of it), and
/// the pixel centres to take from it ([`ImageBlob::sample_pixels`]).
pub type PixelPick<'a> = (Option<[f64; 4]>, &'a dyn Fn(f64, f64) -> bool);
/// A box of an image to sample, in 0 to 1 units of its width and height (`None`: all of it), the
/// pixels to sample from it at most, and the pixel centres to take ([`ImageBlob::sample_pixels`]).
pub type PixelPick<'a> = (Option<[f64; 4]>, usize, &'a dyn Fn(f64, f64) -> bool);
/// The pixels of encoded image `bytes` as RGBA; `None` when they can't be decoded, would take
/// more than `max` bytes as RGBA, or need more than `max` bytes to decode. The size is read from
@@ -65,13 +65,13 @@ impl ImageBlob {
Some(Self::png(png))
}
/// For each of `picks`, at most `budget` of the image's pixels (at least one), spread evenly
/// For each of `picks`, at most its budget of the image's pixels (at least one), spread evenly
/// in rows and columns over the pick's box (`[u0, v0, u1, v1]`, where 0 to 1 spans the
/// image's width and height from the top left; clipped to the image; `None`: the whole image),
/// as straight RGBA. Only pixels whose centres the pick's function takes (in the same 0 to 1
/// units) are given. The image is decoded once for all the picks. `None` when it can't be
/// decoded within [`MAX_RASTER_ALLOC`].
pub fn sample_pixels(&self, picks: &[PixelPick<'_>], budget: usize) -> Option<Vec<Vec<[u8; 4]>>> {
/// units) are given, and the function is called once for each pixel sampled. The image is
/// decoded once for all the picks. `None` when it can't be decoded within [`MAX_RASTER_ALLOC`].
pub fn sample_pixels(&self, picks: &[PixelPick<'_>]) -> Option<Vec<Vec<[u8; 4]>>> {
let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
let (w, h) = img.dimensions();
let (fw, fh) = (f64::from(w), f64::from(h));
@@ -79,7 +79,7 @@ impl ImageBlob {
let (a, b) = (a.min(b), a.max(b));
((a * n).floor().clamp(0.0, n) as u32, (b * n).ceil().clamp(0.0, n) as u32)
};
let sample = |area: Option<[f64; 4]>, keep: &dyn Fn(f64, f64) -> bool| {
let sample = |area: Option<[f64; 4]>, budget: usize, keep: &dyn Fn(f64, f64) -> bool| {
let [u0, v0, u1, v1] = area.unwrap_or([0.0, 0.0, 1.0, 1.0]);
let (x0, x1) = span(u0, u1, fw);
let (y0, y1) = span(v0, v1, fh);
@@ -105,7 +105,7 @@ impl ImageBlob {
}
out
};
Some(picks.iter().map(|(area, keep)| sample(*area, *keep)).collect())
Some(picks.iter().map(|(area, budget, keep)| sample(*area, *budget, *keep)).collect())
}
/// The image as a grayscale PNG: `f` gives each pixel's gray level from its (straight) RGB, and
@@ -331,7 +331,7 @@ mod tests {
let (r, b) = ([255, 0, 0, 255], [0, 0, 255, 255]);
let img = blob(&[r, r, b, b, r, r, b, b], 4, 2);
let any = |_: f64, _: f64| true;
let one = |b: &ImageBlob, area: Option<[f64; 4]>, budget: usize| b.sample_pixels(&[(area, &any)], budget).unwrap().remove(0);
let one = |b: &ImageBlob, area: Option<[f64; 4]>, budget: usize| b.sample_pixels(&[(area, budget, &any)]).unwrap().remove(0);
assert_eq!(one(&img, None, 100).len(), 8, "the budget covers every pixel");
assert_eq!(one(&img, Some([0.5, 0.0, 1.0, 1.0]), 100), vec![b; 4], "the right half");
assert_eq!(one(&img, Some([1.0, 1.0, 0.5, 0.0]), 100), vec![b; 4], "a box given backwards");
@@ -339,13 +339,15 @@ mod tests {
assert_eq!(one(&img, None, 2).len(), 2, "every second pixel of every second row");
assert_eq!(one(&img, None, 0), vec![r], "a budget of 0 counts as 1");
let left = |u: f64, _: f64| u < 0.5;
let both = img.sample_pixels(&[(None, &left), (Some([0.5, 0.0, 1.0, 1.0]), &any)], 100).unwrap();
let both = img.sample_pixels(&[(None, 100, &left), (Some([0.5, 0.0, 1.0, 1.0]), 100, &any)]).unwrap();
assert_eq!(both, vec![vec![r; 4], vec![b; 4]], "several picks, each with the centres its function takes");
let sizes: Vec<usize> = img.sample_pixels(&[(None, 2, &any), (None, 100, &any)]).unwrap().iter().map(Vec::len).collect();
assert_eq!(sizes, [2, 8], "each pick keeps to its own budget");
// A narrow image keeps to the budget too.
let tall = blob(&vec![r; 5000], 1, 5000);
let n = one(&tall, None, 100).len();
assert!((50..=100).contains(&n), "{n} pixels");
assert!(ImageBlob::png(b"not a png".to_vec()).sample_pixels(&[(None, &any)], 100).is_none());
assert!(ImageBlob::png(b"not a png".to_vec()).sample_pixels(&[(None, 100, &any)]).is_none());
}
/// A GIF whose `w` × `h` screen holds one opaque red pixel at the top left. The file holds no
+343 -44
View File
@@ -59,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Color Theme Picker",
[],
None,
"{picks: [{at: [x, y]} (a click: the object a Selection tool click there takes, with everything inside it) | {rect: [x0, y0, x1, y1]} (a marquee: every painted object it touches, and the part of each image inside it)] (1 to 256, in document points), colors?: 5 (1 to 64)} → {colors: [colour keys, the most used first], objects: the number of objects read} the colour palette of the art picked, for Recolor Artwork's Color Theme Picker: visible fills and strokes, gradient stops, text, mesh points, pattern tiles (with the patterns they use), the art of symbol instances (placed through their 9-slice guides), the colorize colour of an image it applies to and other images' pixels (at most 65,536 pixels of each pick of an image, spread evenly, and 1,048,576 pixels of at most 64 images in all; pixels less than half opaque and images that can't be decoded are left out). In a clip group, or under an opacity mask that clips, only what shows inside the clipping path or the mask art is read; a blend, envelope or repeat a marquee touches is read whole. Hidden or fully transparent objects and fill or stroke items are left out; locked objects are read. Each object read weighs the same, shared among its colours (an image's among its pixels; the vector art a symbol instance places counts as one object, each image it places on its own); similar colours merge (k-means in Lab) into at most `colors`, each shown by its most used colour in its own model. Picks that touch no art give no colours. Changes nothing",
"{picks: [{at: [x, y]} (a click: the object a Selection tool click there takes, with everything inside it) | {rect: [x0, y0, x1, y1]} (a marquee: every painted object it touches, and the part of each image inside it)] (1 to 256, in document points), colors?: 5 (1 to 64)} → {colors: [colour keys, the most used first], objects: the number of objects read} the colour palette of the art picked, for Recolor Artwork's Color Theme Picker: visible fills and strokes, gradient stops, text, mesh points, pattern tiles (with the patterns they use), the art of symbol instances (placed through their 9-slice guides), the colorize colour of an image it applies to and other images' pixels (at most 65,536 pixels of each pick of an image, spread evenly, and 1,048,576 pixels of at most 64 images in all; pixels less than half opaque and images that can't be decoded are left out). In a clip group, or under an opacity mask that clips, only what shows inside the clipping path or the mask art is read; a blend, envelope or repeat a marquee touches is read whole. The symbol instances read place at most 16,000,000 objects in all (an instance whose art would pass that is left out), and the pixels read inside clip groups are tested against at most 4,000,000 clipping-path elements in all (the picks of an image inside clip groups that would pass that are left out). Hidden or fully transparent objects and fill or stroke items are left out; locked objects are read. Each object read weighs the same, shared among its colours (an image's among its pixels; the vector art a symbol instance places counts as one object, each image it places on its own); similar colours merge (k-means in Lab) into at most `colors`, each shown by its most used colour in its own model. Picks that touch no art give no colours. Changes nothing",
has_doc,
pick_theme
),
@@ -75,8 +75,8 @@ const MAX_THEME_IMAGES: usize = 64;
const MAX_THEME_READS: usize = 4096;
/// The stroke segments the opacity masks one pick reads are outlined along, at most.
const MASK_OUTLINE_SEGMENTS: usize = 20_000;
/// The point-in-region tests one pick makes to find whether outlines show through clip groups,
/// and the clipping-path elements the pixels of one image are tested against, at most.
/// The point-in-region tests the picks make to find whether outlines show through clip groups,
/// and the clipping-path elements the pixels of their images are tested against, at most.
const THEME_WORK: usize = 4_000_000;
/// The picks one `recolor.pickTheme` takes, and the colours it gives, at most.
const MAX_PICKS: usize = 256;
@@ -126,6 +126,10 @@ struct ImageRead<'a> {
colorize: Option<Vec<Color>>,
}
/// A pattern's colours, the image reads of its tiles, and the depth its tiles were read at when
/// [`MAX_THEME_DEPTH`] cut them short.
type PatternRead<'a> = (Vec<Color>, Vec<ImageRead<'a>>, Option<usize>);
/// The colours the Color Theme Picker has read: each object weighs 1, shared among its colours.
struct Theme<'a> {
d: &'a Document,
@@ -138,11 +142,17 @@ struct Theme<'a> {
/// What the clip groups being read show, in document space: the union of these regions
/// (none: nothing shows); `None` outside clip groups.
clip: Option<Vec<Region>>,
/// The colours of the patterns read so far, and the patterns whose tiles are being read.
pattern_colors: HashMap<String, (Vec<Color>, Vec<ImageRead<'a>>)>,
/// The colours of the patterns read so far (with the depth their tiles were read at when
/// [`MAX_THEME_DEPTH`] cut them short), and the patterns whose tiles are being read.
pattern_colors: HashMap<String, PatternRead<'a>>,
pattern_stack: Vec<String>,
/// Whether art was left out for passing [`MAX_THEME_DEPTH`].
cut: bool,
/// The region tests [`Self::shows`] has made.
work: std::cell::Cell<usize>,
/// The objects the symbol instances read have placed, of [`vectorcraft_doc::MAX_SYMBOL_NODES`]
/// ([`vectorcraft_doc::charge_symbol_art`]).
symbol_nodes: u64,
/// The object isolated in isolation mode.
scope: Option<NodeId>,
/// The stroke segments opacity masks may still be outlined along ([`mask_regions`]).
@@ -170,8 +180,10 @@ impl<'a> Theme<'a> {
blobs: std::collections::HashSet::new(),
clip: None,
pattern_colors: HashMap::new(),
cut: false,
pattern_stack: vec![],
work: std::cell::Cell::new(0),
symbol_nodes: 0,
scope: None,
mask_budget: MASK_OUTLINE_SEGMENTS,
objects: 0,
@@ -192,8 +204,10 @@ impl<'a> Theme<'a> {
/// many there are, and the cells of all the reads add up, so the images give at most 16³
/// colours. Images that can't be decoded give none. The first [`MAX_THEME_IMAGES`] images
/// are read, and all the reads share [`THEME_TOTAL_PIXELS`] pixels (each read at least one, at
/// most [`THEME_PIXELS`]).
fn read_images(&mut self) {
/// most [`THEME_PIXELS`]). The pixels read inside clip groups are tested against
/// [`THEME_WORK`] clipping-path elements in all; the reads of an image inside clip groups
/// that would pass it are left out. Gives the clipping-path elements tested.
fn read_images(&mut self) -> usize {
let reads = std::mem::take(&mut self.images);
// The reads of each image, in the order its first read came.
let mut by_image: Vec<(*const vectorcraft_doc::ImageBlob, Vec<&ImageRead>)> = vec![];
@@ -208,15 +222,42 @@ impl<'a> Theme<'a> {
let count: usize = by_image.iter().map(|(_, g)| g.len()).sum();
let budget = (THEME_TOTAL_PIXELS / count.max(1)).clamp(1, THEME_PIXELS);
let mut pixels: HashMap<[u8; 3], ([f64; 3], f64)> = HashMap::new();
for (_, group) in by_image {
// The clipping-path elements the pixels read have been tested against.
let tested = std::cell::Cell::new(0usize);
let elements =
|r: &ImageRead| r.clip.as_ref().map_or(0, |rs| rs.iter().map(|(bp, _)| bp.elements().len().max(1)).fold(0, usize::saturating_add));
for (_, mut group) in by_image {
// Each pixel sampled for a read inside clip groups is tested against its clipping
// regions: the clipped reads of one image take at most `clipped` pixels each, so all
// of them keep within the work left, and the other reads take `budget`. When the work
// left can't test one pixel of each, the clipped reads are kept in order while their
// pixel fits, and the rest are left out.
let left = THEME_WORK.saturating_sub(tested.get());
if group.iter().map(|r| elements(r)).fold(0, usize::saturating_add) > left {
let mut room = left;
group.retain(|r| {
let c = elements(r);
let fits = c <= room;
room = room.saturating_sub(if fits { c } else { 0 });
fits
});
}
let cost = group.iter().map(|r| elements(r)).fold(0, usize::saturating_add);
let clipped = left.checked_div(cost).map_or(budget, |w| budget.min(w));
if group.is_empty() {
continue;
}
let keeps: Vec<Box<dyn Fn(f64, f64) -> bool + '_>> = group
.iter()
.map(|r| {
let (xf, (w, h), area, clip) = (r.xf, r.size, r.area, &r.clip);
let (xf, (w, h), area, clip, cost, tested) = (r.xf, r.size, r.area, &r.clip, elements(r), &tested);
Box::new(move |u: f64, v: f64| {
let p = xf * Point::new(u * w, v * h);
area.is_none_or(|a| a.contains(p))
&& clip.as_ref().is_none_or(|rs| rs.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p)))
&& clip.as_ref().is_none_or(|rs| {
tested.set(tested.get().saturating_add(cost));
rs.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p))
})
}) as Box<dyn Fn(f64, f64) -> bool + '_>
})
.collect();
@@ -241,15 +282,10 @@ impl<'a> Theme<'a> {
let high = |f: fn(&Point) -> f64| uv.iter().map(f).fold(f64::NEG_INFINITY, f64::max);
[low(|p| p.x) / w, low(|p| p.y) / h, high(|p| p.x) / w, high(|p| p.y) / h]
});
(box_uv, keep.as_ref())
(box_uv, if r.clip.is_some() { clipped } else { budget }, keep.as_ref())
})
.collect();
// Each sampled pixel inside a clip group is tested against the clipping regions: the
// pixels one image gives keep that work within THEME_WORK path elements.
let elements =
group.iter().map(|r| r.clip.as_ref().map_or(0, |rs| rs.iter().map(|(bp, _)| bp.elements().len()).sum::<usize>())).max().unwrap_or(0);
let budget = THEME_WORK.checked_div(elements).map_or(budget, |w| budget.min(w.max(1)));
let Some(each) = group[0].blob.sample_pixels(&picks, budget) else { continue };
let Some(each) = group[0].blob.sample_pixels(&picks) else { continue };
for (px, read) in each.into_iter().zip(&group) {
if let Some(colors) = &read.colorize {
// Ink: an opaque pixel darker than white takes the colorize paint.
@@ -289,6 +325,7 @@ impl<'a> Theme<'a> {
let [r, g, b] = sum.map(|s| (s / w).round().clamp(0.0, 255.0) as u8);
self.samples.push((Color::rgb8(r, g, b), w as f32));
}
tested.get()
}
/// Read `n` and everything visible inside it: each image (its part inside `area` when given,
@@ -316,8 +353,12 @@ impl<'a> Theme<'a> {
/// [`Self::read`] without `n`'s opacity mask.
fn read_unmasked(&mut self, n: &Node, area: Option<Rect>, depth: usize) {
if depth > MAX_THEME_DEPTH {
self.cut = true;
return;
}
// Hidden and fully transparent objects show nothing.
if depth > MAX_THEME_DEPTH || !n.visible || n.opacity <= 0.0 || matches!(n.kind, NodeKind::Path { guide: true, .. }) {
if !n.visible || n.opacity <= 0.0 || matches!(n.kind, NodeKind::Path { guide: true, .. }) {
return;
}
let touches = area.is_none_or(|r| self.touches(n, r));
@@ -332,13 +373,13 @@ impl<'a> Theme<'a> {
// other paints its appearance has (with effects that draw them) count as well.
let colorized = n.extras().colorize.is_some() && blob.takes_colorize();
let own = if colorized || n.extras().colorize.is_none() {
self.own(n, true)
self.own(n, true, depth)
} else {
// A colorize paint the image does not take (a linked image's preview)
// shows nothing.
let mut plain = n.clone();
plain.extras_mut().colorize = None;
self.own(&plain, true)
self.own(&plain, true, depth)
};
let colorize = if colorized {
Some(own)
@@ -357,31 +398,43 @@ impl<'a> Theme<'a> {
&& self.shows(n, area)
&& let Some(s) = self.d.symbols.iter().find(|s| s.name == *symbol)
{
// The instance's own paints; its overrides show in its art.
let own = self.own(n, false);
self.add(own);
// The art as the instance places it: through the symbol's 9-slice guides when
// it has them, else by the instance's transform. Art that would place too many
// objects is left out, as the renderer leaves it out.
// it has them, else by the instance's transform. All the instances read share
// one limit on the objects they place, counted as the renderer counts them: an
// instance whose art would pass it shows nothing and is left out.
let dynamic = (!n.extras().symbol_overrides.is_empty()).then(|| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n));
if let Some(placed) = self.d.place_symbol_art(symbol, dynamic.as_ref().unwrap_or(&s.art), *xf) {
// The vector art counts as one object, each of its colours once, so an
// object the 9-slice guides cut into several copies does not count more.
// Each image placed is read on its own, as images are elsewhere.
let (samples, objects) = (self.samples.len(), self.objects);
self.read(&placed, None, depth + 1);
let added = self.samples.split_off(samples);
self.objects = objects;
let mut seen = std::collections::HashSet::new();
let colors: Vec<Color> = added.into_iter().map(|(c, _)| c).filter(|c| seen.insert(ColorKey::of(c))).collect();
self.add(colors);
}
let (d, art) = (self.d, dynamic.as_ref().unwrap_or(&s.art));
let placed = match d.symbol_slice_map(symbol, *xf) {
Some(m) => vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, m.most_placed(art, d), || {
let placed = d.place_symbol_art(symbol, art, *xf);
let n = placed.as_ref().map_or(0, |a| a.count_with_masks() as u64);
(placed, n)
}),
None => {
let count = art.count() as u64;
vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, count, || (d.place_symbol_art(symbol, art, *xf), count))
}
};
let Some(Some(placed)) = placed else { return };
// The instance's own paints; its overrides show in its art.
let own = self.own(n, false, depth);
self.add(own);
// The vector art counts as one object, each of its colours once, so an
// object the 9-slice guides cut into several copies does not count more.
// Each image placed is read on its own, as images are elsewhere.
let (samples, objects) = (self.samples.len(), self.objects);
self.read(&placed, None, depth + 1);
let added = self.samples.split_off(samples);
self.objects = objects;
let mut seen = std::collections::HashSet::new();
let colors: Vec<Color> = added.into_iter().map(|(c, _)| c).filter(|c| seen.insert(ColorKey::of(c))).collect();
self.add(colors);
}
return;
}
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
if touches && self.shows(n, area) {
let own = self.own(n, true);
let own = self.own(n, true, depth);
self.add(own);
// The copies are drawn elsewhere than the objects lie: the clipping paths
// around the live object, tested above, do not apply to the objects.
@@ -396,7 +449,7 @@ impl<'a> Theme<'a> {
_ => {}
}
if touches && self.shows(n, area) {
let own = self.own(n, true);
let own = self.own(n, true, depth);
self.add(own);
}
// A compound path's members are its geometry: the compound paints them.
@@ -767,13 +820,16 @@ impl<'a> Theme<'a> {
/// document art is read (clip groups and symbols included; the tiles' images join this pick's
/// image reads). Each pattern's colours
/// are read once; a pattern whose tile uses itself, directly or through other patterns, adds
/// nothing more the second time.
fn own(&mut self, m: &Node, overrides: bool) -> Vec<Color> {
/// nothing more the second time. `depth` is `m`'s ([`Self::read`]): the tiles' art is read one
/// deeper, and a pattern whose tiles [`MAX_THEME_DEPTH`] cut short is read again when it is
/// used less deep.
fn own(&mut self, m: &Node, overrides: bool, depth: usize) -> Vec<Color> {
let mut out = vec![];
let mut patterns: Vec<String> = vec![];
paint_colors_of(m, overrides, &mut out, &mut patterns);
for name in patterns {
if let Some((colors, reads)) = self.pattern_colors.get(&name) {
if let Some((colors, reads, cut)) = self.pattern_colors.get(&name).filter(|e| e.2.is_none_or(|at| depth >= at)) {
self.cut |= cut.is_some();
out.extend(colors);
// The tiles' images count again for each use, within the pick's limits.
let reads = reads.clone();
@@ -795,10 +851,11 @@ impl<'a> Theme<'a> {
sub.blobs = std::mem::take(&mut self.blobs);
// The tiles share this pick's limits on work too.
sub.work.set(self.work.get());
sub.symbol_nodes = self.symbol_nodes;
sub.mask_budget = self.mask_budget;
let before = sub.images.len();
for a in &def.art {
sub.read(a, None, 0);
sub.read(a, None, depth + 1);
}
sub.pattern_stack.pop();
self.pattern_stack = std::mem::take(&mut sub.pattern_stack);
@@ -807,10 +864,12 @@ impl<'a> Theme<'a> {
self.images = std::mem::take(&mut sub.images);
self.blobs = std::mem::take(&mut sub.blobs);
self.work.set(sub.work.get());
self.symbol_nodes = sub.symbol_nodes;
self.mask_budget = sub.mask_budget;
self.cut |= sub.cut;
let colors: Vec<Color> = sub.samples.iter().map(|(c, _)| *c).collect();
out.extend(&colors);
self.pattern_colors.insert(name, (colors, reads));
self.pattern_colors.insert(name, (colors, reads, sub.cut.then_some(depth)));
}
out
}
@@ -1529,3 +1588,243 @@ fn randomize(_: &mut Session, p: &Value) -> Result<Value> {
.collect();
Ok(json!({ "map": map }))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use vectorcraft_doc::{Appearance, ImageBlob, MAX_SYMBOL_NODES, PatternDef, Symbol};
use vectorcraft_geom::{Affine, BezPath, FillRule, shapes};
use super::*;
const RED: Color = Color::Rgb { r: 1.0, g: 0.0, b: 0.0 };
const BLUE: Color = Color::Rgb { r: 0.0, g: 0.0, b: 1.0 };
/// A 10 pt square from (`x`, 0) painted `fill`.
fn square(d: &mut Document, x: f64, fill: Paint) -> Arc<Node> {
let p = shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0));
Arc::new(Node::path(d.alloc_id(), p, Appearance::basic(fill, Paint::None, 0.0)))
}
/// Symbol `name` whose art is a group of `art`, and an instance of it.
fn symbol(d: &mut Document, name: &str, art: Vec<Arc<Node>>) -> Arc<Node> {
let group = Node::group(d.alloc_id(), art);
d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(group) });
Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY }))
}
fn has(t: &Theme, c: Color) -> bool {
t.samples.iter().any(|(s, _)| ColorKey::of(s) == ColorKey::of(&c))
}
/// The symbol instances read share one limit on the objects they place: with room for one
/// instance's art (a group and a square, 2 objects), the first instance is read and the
/// second, of another symbol, is left out.
#[test]
fn symbol_instances_share_one_limit_on_placed_objects() {
let mut d = Document::new(100.0, 100.0);
let red = square(&mut d, 0.0, Paint::solid(RED));
let a = symbol(&mut d, "Red", vec![red]);
let blue = square(&mut d, 20.0, Paint::solid(BLUE));
let b = symbol(&mut d, "Blue", vec![blue]);
let mut t = Theme::new(&d);
t.symbol_nodes = MAX_SYMBOL_NODES - 3;
t.read(&a, None, 0);
t.read(&b, None, 0);
assert!(has(&t, RED), "the first instance fits");
assert!(!has(&t, BLUE), "the second does not");
assert_eq!(t.symbol_nodes, MAX_SYMBOL_NODES - 1);
}
/// An instance left out at the limit shows nothing, so its own paints are left out too.
#[test]
fn an_instance_past_the_limit_gives_none_of_its_own_paints() {
let mut d = Document::new(100.0, 100.0);
let red = square(&mut d, 0.0, Paint::solid(RED));
let mut inst = (*symbol(&mut d, "Red", vec![red])).clone();
inst.appearance = Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0).into();
let mut t = Theme::new(&d);
t.read(&inst, None, 0);
assert!(has(&t, BLUE), "the instance's own fill, with room for its art");
let mut t = Theme::new(&d);
t.symbol_nodes = MAX_SYMBOL_NODES - 1;
t.read(&inst, None, 0);
assert!(t.samples.is_empty() && t.objects == 0, "nothing past the limit: {:?}", t.samples);
}
/// An instance without 9-slice guides is counted as the renderer counts it, without the art of
/// its opacity masks: a square with a mask of 6 objects places 2.
#[test]
fn an_instance_is_counted_without_its_mask_art() {
let mut d = Document::new(100.0, 100.0);
let leaf = square(&mut d, 0.0, Paint::solid(BLUE));
let mut mask = vectorcraft_doc::OpacityMask::new(Node::group(d.alloc_id(), vec![leaf; 5]), true);
mask.disabled = true;
let mut red = (*square(&mut d, 0.0, Paint::solid(RED))).clone();
red.extras_mut().mask = Some(Box::new(mask));
let inst = symbol(&mut d, "Masked", vec![Arc::new(red)]);
let mut t = Theme::new(&d);
t.symbol_nodes = MAX_SYMBOL_NODES - 3;
t.read(&inst, None, 0);
assert!(has(&t, RED));
assert_eq!(t.symbol_nodes, MAX_SYMBOL_NODES - 1);
}
/// Nested instances charge the limit at every level: a tree of four leaf instances (5
/// objects) whose leaves place 2 objects each places 13 objects in all, and with 9 objects of
/// room it places the tree and two of its leaves.
#[test]
fn nested_symbol_instances_charge_every_level() {
let mut d = Document::new(100.0, 100.0);
let red = square(&mut d, 0.0, Paint::solid(RED));
let leaf = symbol(&mut d, "Leaf", vec![red]);
let tree = symbol(&mut d, "Tree", vec![leaf; 4]);
let mut t = Theme::new(&d);
t.read(&tree, None, 0);
assert_eq!(t.symbol_nodes, 13);
let mut t = Theme::new(&d);
t.symbol_nodes = MAX_SYMBOL_NODES - 9;
t.read(&tree, None, 0);
assert_eq!(t.symbol_nodes, MAX_SYMBOL_NODES, "the tree and two leaves");
assert!(has(&t, RED));
}
/// Patterns `P0` to `P{n}`: each tile a group holding a square filled with the next pattern,
/// the last a red square. Gives a square filled with `P0`.
fn pattern_chain(d: &mut Document, n: usize) -> Arc<Node> {
let fill = |i: usize| Paint::Pattern { pattern: format!("P{i}"), xf: Affine::IDENTITY };
for i in 0..=n {
let inner = if i == n { square(d, 0.0, Paint::solid(RED)) } else { square(d, 0.0, fill(i + 1)) };
let tile = Arc::new(Node::group(d.alloc_id(), vec![inner]));
d.patterns.push(PatternDef::new(&format!("P{i}"), vec![tile]));
}
square(d, 0.0, fill(0))
}
/// Pattern tiles are read one level deeper than the object their pattern fills, so a chain of
/// patterns shares one limit on depth with the art around it: the end of a chain of 50 is
/// read, the end of a chain of 200 (two levels for each tile) is not.
#[test]
fn pattern_tiles_share_one_depth_limit() {
let mut d = Document::new(100.0, 100.0);
let short = pattern_chain(&mut d, 50);
let mut t = Theme::new(&d);
t.read(&short, None, 0);
assert!(has(&t, RED), "a chain of 50");
let mut d = Document::new(100.0, 100.0);
let long = pattern_chain(&mut d, 200);
let p100 = square(&mut d, 0.0, Paint::Pattern { pattern: "P100".into(), xf: Affine::IDENTITY });
let mut t = Theme::new(&d);
t.read(&long, None, 0);
assert!(!has(&t, RED), "a chain of 200 passes {MAX_THEME_DEPTH} levels");
// The patterns cut short there are read again when used less deep: from P100 the end is
// 100 patterns away.
t.read(&p100, None, 0);
assert!(has(&t, RED), "P100 read again at the top");
}
/// A pattern tile's symbol instances charge the same limit as the art around it.
#[test]
fn pattern_tiles_charge_the_symbol_limit() {
let mut d = Document::new(100.0, 100.0);
let red = square(&mut d, 0.0, Paint::solid(RED));
let inst = symbol(&mut d, "Red", vec![red]);
d.patterns.push(PatternDef::new("Dots", vec![inst]));
let art = square(&mut d, 0.0, Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY });
let mut t = Theme::new(&d);
t.read(&art, None, 0);
assert!(has(&t, RED));
assert_eq!(t.symbol_nodes, 2, "the tile's instance placed a group and a square");
let mut t = Theme::new(&d);
t.symbol_nodes = MAX_SYMBOL_NODES - 1;
t.read(&art, None, 0);
assert!(!has(&t, RED), "no room for the tile's instance");
}
/// A `size` px image of one color `rgba`.
fn blob(rgba: [u8; 4], size: u32) -> ImageBlob {
let mut png = Vec::new();
image::RgbaImage::from_pixel(size, size, image::Rgba(rgba)).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
ImageBlob::png(png)
}
/// A read of all of `blob`, placed at the origin 1 pt a pixel, inside a clipping path over
/// it of `elements` path elements.
fn clipped(blob: &ImageBlob, size: f64, elements: usize) -> ImageRead<'_> {
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
let steps = elements.saturating_sub(4).max(1);
for i in 1..=steps {
bp.line_to((size * i as f64 / steps as f64, 0.0));
}
bp.line_to((size, size));
bp.line_to((0.0, size));
bp.close_path();
ImageRead { blob, xf: Affine::IDENTITY, size: (size, size), area: None, clip: Some(vec![(bp, FillRule::NonZero)]), colorize: None }
}
/// The reads of one image inside clip groups share the work limit: three reads of a 64 px
/// image, each inside a clipping path of 3,000 elements, test at most [`THEME_WORK`] elements
/// in all and still read the image.
#[test]
fn reads_of_one_image_share_the_clip_work_limit() {
let d = Document::new(100.0, 100.0);
let red = blob([255, 0, 0, 255], 64);
let mut t = Theme::new(&d);
t.images = (0..3).map(|_| clipped(&red, 64.0, 3000)).collect();
let tested = t.read_images();
assert!(tested > 0 && tested <= THEME_WORK, "{tested} elements tested");
assert!(has(&t, RED));
}
/// A read of an image outside clip groups keeps its own pixel budget when another read of the
/// same image inside a clip group gets only two pixels: the blue 8 px block in the corner of a
/// red 64 px image is still read.
#[test]
fn unclipped_reads_keep_their_budget_beside_clipped_reads() {
let d = Document::new(100.0, 100.0);
let red = blob([255, 0, 0, 255], 64);
let mut png = Vec::new();
image::RgbaImage::from_fn(64, 64, |x, y| image::Rgba(if x >= 56 && y >= 56 { [0, 0, 255, 255] } else { [255, 0, 0, 255] }))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let corner = ImageBlob::png(png);
let mut t = Theme::new(&d);
let whole = ImageRead { clip: None, ..clipped(&corner, 64.0, 4) };
// The red read leaves 6,400 elements of work; 3,200 elements a pixel leave two pixels.
t.images = vec![clipped(&red, 64.0, 3900), whole, clipped(&corner, 64.0, 3200)];
let tested = t.read_images();
assert!(tested <= THEME_WORK, "{tested} elements tested");
assert!(has(&t, BLUE), "the read outside clip groups samples the whole image");
}
/// When the work left can't test a pixel of every clipped read of an image, the reads that fit
/// are still read: with 6,400 elements left, a read of a blue image inside a clipping path
/// of 5,000 elements is read beside one of 2,000 elements, which is left out.
#[test]
fn clipped_reads_that_fit_the_work_left_are_read() {
let d = Document::new(100.0, 100.0);
let (red, blue) = (blob([255, 0, 0, 255], 64), blob([0, 0, 255, 255], 64));
let mut t = Theme::new(&d);
t.images = vec![clipped(&red, 64.0, 3900), clipped(&blue, 64.0, 5000), clipped(&blue, 64.0, 2000)];
let tested = t.read_images();
assert!(tested <= THEME_WORK, "{tested} elements tested");
assert!(has(&t, BLUE), "the read that fits is read");
}
/// Past the work limit, the reads of an image inside clip groups are left out: a red image
/// inside a clipping path of 3,900 elements takes nearly all of it, and a blue image after it,
/// inside one of 100,000 elements, is not read.
#[test]
fn clipped_image_reads_past_the_work_limit_are_left_out() {
let d = Document::new(100.0, 100.0);
let (red, blue) = (blob([255, 0, 0, 255], 64), blob([0, 0, 255, 255], 64));
let mut t = Theme::new(&d);
t.images = vec![clipped(&red, 64.0, 3900), clipped(&blue, 64.0, 100_000)];
let tested = t.read_images();
assert!(tested <= THEME_WORK, "{tested} elements tested");
assert!(has(&t, RED));
assert!(!has(&t, BLUE), "the blue image is past the limit");
}
}
+3 -1
View File
@@ -3678,7 +3678,9 @@ colorize color of an image it applies to and the pixels of other images: at most
image, spread evenly, and 1,048,576 pixels of at most 64 images in all, leaving out pixels less than half opaque and
images it can't decode; each image is decoded once for all its picks. In a clip group, or under an opacity mask that
clips, it reads only what shows inside the clipping path or the mask art, and a blend, envelope or repeat that a
marquee touches is read whole. Hidden or fully transparent objects and fill or stroke items are left out, and locked
marquee touches is read whole. The symbol instances read place at most 16,000,000 objects in all, as on the canvas,
and an instance whose art would pass that is left out. The pixels read inside clip groups are tested against at most
4,000,000 clipping-path elements in all; the picks of an image inside clip groups that would pass that are left out. Hidden or fully transparent objects and fill or stroke items are left out, and locked
objects are read. As VectorCraft's own rule, each object read weighs the same, shared among its colors (an image's
among its pixels; the vector art a symbol instance places counts as one object, and each image it places counts on
its own), and similar colors merge (k-means in Lab) into at most `colors` (1 to 64, default 5), each shown by its