mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 04:11:43 +00:00
Recolor Artwork: the Color Theme Picker picks new colors from the art on the canvas
The Color Theme Picker button beside the Assign and Edit tabs turns on
picking from the canvas. A click takes the color palette of the object a
Selection tool click takes there, a drag takes the palette of the art
inside its marquee, and Shift adds more picks. The rows that are not
excluded take the palette as their new colors and the art previews them;
OK keeps the recolor as one undo step.
- recolor.pickTheme {picks, colors?} returns the palette of the art
picked: visible fills and strokes, gradient stops, text, mesh points,
pattern tiles, symbol art, colorize colors and image pixels. Clip
groups, clipping opacity masks and isolation mode limit it to what
shows, and it changes nothing.
- Each object read weighs the same; similar colors merge (k-means in Lab)
into at most `colors`, most used first. Image reads are bounded (65,536
pixels a pick, 1,048,576 pixels and 64 images in all), and past 4,096
distinct colors, close colors merge before clustering.
- The dialog's themePicker and themePicks fields drive it from
ui.dialog.set. A new Colors count keeps the picks.
This commit is contained in:
@@ -485,6 +485,88 @@ pub fn cluster(colors: &[Color], weights: &[f32], n: usize) -> Vec<Vec<usize>> {
|
||||
rows
|
||||
}
|
||||
|
||||
/// The distinct colors [`theme_palette`] clusters, at most.
|
||||
const THEME_CANDIDATES: usize = 4096;
|
||||
|
||||
/// The color palette of the weighted colors `samples` (color, weight), for Recolor Artwork's Color
|
||||
/// Theme Picker: at most `n` colors. The same color given twice counts once with both weights,
|
||||
/// and past [`THEME_CANDIDATES`] distinct colors, colors close in Lab merge into the heaviest of
|
||||
/// them first.
|
||||
/// The colors are grouped as [`cluster`] groups them, each group shows its heaviest color (a color
|
||||
/// of `samples`, in its own model; on a tie the one given first), and the heaviest group comes
|
||||
/// first (on a tie the group whose first color was given first). Samples without a positive,
|
||||
/// finite weight are left out, so no samples give no colors.
|
||||
pub fn theme_palette(samples: &[(Color, f32)], n: usize) -> Vec<Color> {
|
||||
let mut index: std::collections::HashMap<ColorKey, usize> = std::collections::HashMap::new();
|
||||
let mut colors: Vec<Color> = vec![];
|
||||
let mut weights: Vec<f32> = vec![];
|
||||
for (c, w) in samples {
|
||||
if !(w.is_finite() && *w > 0.0) {
|
||||
continue;
|
||||
}
|
||||
let i = *index.entry(ColorKey::of(c)).or_insert_with(|| {
|
||||
colors.push(*c);
|
||||
weights.push(0.0);
|
||||
colors.len() - 1
|
||||
});
|
||||
weights[i] += w;
|
||||
}
|
||||
if colors.is_empty() || n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
// At most THEME_CANDIDATES colors are clustered: past that, colors in the same cube of Lab
|
||||
// space merge into the heaviest of them, the cubes doubling in size until few enough remain.
|
||||
if colors.len() > THEME_CANDIDATES {
|
||||
let labs: Vec<[f32; 3]> = colors
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let l = c.to_lab();
|
||||
[l.l, l.a, l.b]
|
||||
})
|
||||
.collect();
|
||||
let mut side = 2.0f32;
|
||||
loop {
|
||||
let mut cube: std::collections::HashMap<[i32; 3], usize> = std::collections::HashMap::new();
|
||||
let (mut top, mut sum): (Vec<usize>, Vec<f32>) = (vec![], vec![]);
|
||||
for (i, lab) in labs.iter().enumerate() {
|
||||
let k = lab.map(|v| (v / side).floor() as i32);
|
||||
match cube.get(&k) {
|
||||
Some(&j) => {
|
||||
sum[j] += weights[i];
|
||||
if weights[i] > weights[top[j]] {
|
||||
top[j] = i;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
cube.insert(k, top.len());
|
||||
top.push(i);
|
||||
sum.push(weights[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if top.len() <= THEME_CANDIDATES {
|
||||
colors = top.iter().map(|&i| colors[i]).collect();
|
||||
weights = sum;
|
||||
break;
|
||||
}
|
||||
side *= 2.0;
|
||||
}
|
||||
}
|
||||
let mut groups: Vec<(f32, usize, Color)> = cluster(&colors, &weights, n)
|
||||
.into_iter()
|
||||
.filter_map(|g| {
|
||||
let total: f32 = g.iter().map(|&i| weights[i]).sum();
|
||||
let top = g.iter().copied().fold(None::<usize>, |best, i| match best {
|
||||
Some(b) if weights[b] >= weights[i] => Some(b),
|
||||
_ => Some(i),
|
||||
})?;
|
||||
Some((total, *g.first()?, colors[top]))
|
||||
})
|
||||
.collect();
|
||||
groups.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
|
||||
groups.into_iter().map(|g| g.2).collect()
|
||||
}
|
||||
|
||||
/// A set of colours to snap to (Limit to Library), with their Lab values cached.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Palette(Vec<(Color, Lab)>);
|
||||
@@ -691,6 +773,39 @@ mod tests {
|
||||
assert!(cluster(&[], &[], 3).is_empty());
|
||||
}
|
||||
|
||||
/// The Color Theme Picker's palette: similar colors merge, each group shows its heaviest
|
||||
/// color in its own model, and the heaviest group comes first.
|
||||
#[test]
|
||||
fn theme_palette_takes_the_heaviest_color_of_each_group_heaviest_first() {
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
let pink = Color::rgb(1.0, 0.2, 0.2);
|
||||
let blue = Color::cmyk(1.0, 0.8, 0.0, 0.0);
|
||||
let navy = Color::cmyk(1.0, 0.9, 0.0, 0.3);
|
||||
let samples = [(pink, 1.0), (blue, 2.0), (red, 3.0), (navy, 2.5), (red, 0.5)];
|
||||
// Blue and navy weigh 4.5, and so do red (3 + 0.5, its two samples add up) and pink.
|
||||
assert_eq!(theme_palette(&samples, 2), vec![red, navy], "a tie: the group given first");
|
||||
let samples = [(pink, 1.0), (blue, 2.0), (red, 3.0), (navy, 1.0)];
|
||||
assert_eq!(theme_palette(&samples, 2), vec![red, blue], "heaviest group first, its heaviest color");
|
||||
assert_eq!(theme_palette(&samples, 9).len(), 4, "never more colors than given");
|
||||
assert_eq!(theme_palette(&samples, 1), vec![red]);
|
||||
assert!(theme_palette(&samples, 0).is_empty());
|
||||
assert!(theme_palette(&[(red, 0.0), (blue, f32::NAN), (pink, -1.0)], 3).is_empty(), "no weight, no colors");
|
||||
assert!(theme_palette(&[], 3).is_empty());
|
||||
}
|
||||
|
||||
/// Past 4,096 distinct colors, close colors merge before clustering: the palette still keeps
|
||||
/// a heavy color apart from thousands of light ones.
|
||||
#[test]
|
||||
fn theme_palette_merges_close_colors_past_its_candidate_limit() {
|
||||
let mut samples: Vec<(Color, f32)> = (0..20_000).map(|i| (Color::rgb(0.0, 0.0, 0.3 + 0.7 * i as f32 / 20_000.0), 0.001)).collect();
|
||||
samples.extend((0..20_000).map(|i| (Color::rgb(1.0, 0.8 * i as f32 / 20_000.0, 0.0), 0.0001)));
|
||||
samples.push((Color::rgb(0.0, 1.0, 0.0), 50.0));
|
||||
let p = theme_palette(&samples, 3);
|
||||
assert_eq!(p.len(), 3);
|
||||
assert_eq!(p[0], Color::rgb(0.0, 1.0, 0.0), "the heavy green first, exactly as given");
|
||||
assert!(p[1].to_hsb()[0] > 180.0 && p[2].to_hsb()[0] < 90.0, "then the blues, then the reds and oranges: {p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_shift_with_own_model_puts_every_colour_of_the_row_in_the_new_model() {
|
||||
let to = Color::cmyk(0.0, 1.0, 1.0, 0.0);
|
||||
|
||||
@@ -569,7 +569,7 @@ fn clip_contains(clip: &Node, p: Point) -> bool {
|
||||
|
||||
/// The regions `clip` clips to ([`Node::clip_shapes`]), text counted by its frame as in
|
||||
/// [`clip_contains`].
|
||||
fn clip_regions(clip: &Node) -> Vec<(vectorcraft_geom::BezPath, vectorcraft_geom::FillRule)> {
|
||||
pub fn clip_regions(clip: &Node) -> Vec<(vectorcraft_geom::BezPath, vectorcraft_geom::FillRule)> {
|
||||
let frame = |n: &Node| {
|
||||
let b = n.geometric_bounds()?;
|
||||
Some(Node::path(n.id, vectorcraft_geom::shapes::rectangle(b), Default::default()))
|
||||
@@ -640,7 +640,7 @@ fn source_of(n: &Node, scope: Option<NodeId>) -> bool {
|
||||
/// Do clicks and marquees skip the child `i` of `parent` with `scope` isolated? For a Shaper
|
||||
/// Group that is its original shapes (its first child), or, in Construction mode, when they are
|
||||
/// isolated, the art the group shows.
|
||||
fn shaper_hides(parent: &Node, i: usize, scope: Option<NodeId>) -> bool {
|
||||
pub fn shaper_hides(parent: &Node, i: usize, scope: Option<NodeId>) -> bool {
|
||||
parent.extras().shaper.is_some() && (i == 0) != source_of(parent, scope)
|
||||
}
|
||||
|
||||
@@ -1356,7 +1356,7 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool, wh
|
||||
/// The part of a marquee `r` in which it can touch the children of `parent`. For a clip group or a
|
||||
/// layer with a clipping mask, this is what [`marquee_in_clip`] returns for its clipping path (None
|
||||
/// when the marquee misses it); otherwise it is all of `r`.
|
||||
fn marquee_in(parent: &Node, r: Rect) -> Option<Rect> {
|
||||
pub fn marquee_in(parent: &Node, r: Rect) -> Option<Rect> {
|
||||
match parent.children().and_then(|c| c.first()) {
|
||||
Some(clip) if parent.clips() => marquee_in_clip(clip, r),
|
||||
_ => Some(r),
|
||||
|
||||
@@ -51,7 +51,7 @@ pub mod overprint;
|
||||
pub mod pattern;
|
||||
pub mod perspective;
|
||||
mod pixels;
|
||||
pub use pixels::{GraySource, MAX_RASTER_ALLOC};
|
||||
pub use pixels::{GraySource, MAX_RASTER_ALLOC, PixelPick};
|
||||
pub mod placed_document;
|
||||
pub mod plugin_data;
|
||||
pub mod profiles;
|
||||
|
||||
@@ -11,6 +11,10 @@ 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);
|
||||
|
||||
/// 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
|
||||
/// the header first: the decoder's limit is on the file's samples, and a gray image is a quarter
|
||||
@@ -61,6 +65,49 @@ impl ImageBlob {
|
||||
Some(Self::png(png))
|
||||
}
|
||||
|
||||
/// For each of `picks`, at most `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]>>> {
|
||||
let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
|
||||
let (w, h) = img.dimensions();
|
||||
let (fw, fh) = (f64::from(w), f64::from(h));
|
||||
let span = |a: f64, b: f64, n: f64| {
|
||||
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 [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);
|
||||
if x0 >= x1 || y0 >= y1 {
|
||||
return vec![];
|
||||
}
|
||||
// Rows and columns in proportion to the box, at most `budget` cells in all.
|
||||
let (bw, bh, budget) = (f64::from(x1 - x0), f64::from(y1 - y0), budget.max(1) as f64);
|
||||
let (sx, sy) = if bw * bh <= budget {
|
||||
(1, 1)
|
||||
} else {
|
||||
let rows = (budget * bh / bw).sqrt().floor().clamp(1.0, bh.min(budget));
|
||||
let cols = (budget / rows).floor().clamp(1.0, bw);
|
||||
((bw / cols).ceil() as usize, (bh / rows).ceil() as usize)
|
||||
};
|
||||
let mut out = vec![];
|
||||
for y in (y0..y1).step_by(sy) {
|
||||
for x in (x0..x1).step_by(sx) {
|
||||
if keep((f64::from(x) + 0.5) / fw, (f64::from(y) + 0.5) / fh) {
|
||||
out.push(img.get_pixel(x, y).0);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
Some(picks.iter().map(|(area, keep)| sample(*area, *keep)).collect())
|
||||
}
|
||||
|
||||
/// The image as a grayscale PNG: `f` gives each pixel's gray level from its (straight) RGB, and
|
||||
/// the PNG has an alpha channel only when some pixel isn't opaque. `f` runs once per distinct
|
||||
/// color. `None` when the image can't be decoded, or when its file stores grayscale already
|
||||
@@ -276,6 +323,31 @@ mod tests {
|
||||
image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
|
||||
}
|
||||
|
||||
/// Sampling reads the pixels of a box of the image, spread to about the budget, and only those
|
||||
/// whose centres `keep` takes.
|
||||
#[test]
|
||||
fn sampled_pixels_stay_in_their_box_and_budget() {
|
||||
// 4 × 2: a red left half and a blue right half.
|
||||
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);
|
||||
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");
|
||||
assert!(one(&img, Some([2.0, 0.0, 3.0, 1.0]), 100).is_empty(), "outside the image");
|
||||
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();
|
||||
assert_eq!(both, vec![vec![r; 4], vec![b; 4]], "several picks, each with the centres its function takes");
|
||||
// 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());
|
||||
}
|
||||
|
||||
/// A GIF whose `w` × `h` screen holds one opaque red pixel at the top left. The file holds no
|
||||
/// other pixels, and the decoder makes them transparent.
|
||||
fn gif_with_one_red_pixel(w: u16, h: u16) -> Vec<u8> {
|
||||
|
||||
@@ -467,6 +467,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"perspective.transform",
|
||||
"recolor.apply",
|
||||
"recolor.colors",
|
||||
"recolor.pickTheme",
|
||||
"recolor.randomize",
|
||||
"recolor.reduce",
|
||||
"select.add",
|
||||
|
||||
@@ -11,6 +11,7 @@ use vectorcraft_color::recolor::{ColorKey, GlobalAdjust, Method, Preserve, Rng,
|
||||
use vectorcraft_color::{Color, Paint, Swatch};
|
||||
use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use super::colorcmds::{NotChanged, Recolor, Scope, left_out};
|
||||
use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param};
|
||||
@@ -53,9 +54,827 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
randomize
|
||||
),
|
||||
cmd!(
|
||||
query "recolor.pickTheme",
|
||||
"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",
|
||||
has_doc,
|
||||
pick_theme
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The pixels one read of an image by the Color Theme Picker takes, at most, the pixels all its
|
||||
/// reads share, and the images one pick reads, at most.
|
||||
const THEME_PIXELS: usize = 65_536;
|
||||
const THEME_TOTAL_PIXELS: usize = 1 << 20;
|
||||
const MAX_THEME_IMAGES: usize = 64;
|
||||
/// The image reads one pick queues, at most.
|
||||
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.
|
||||
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;
|
||||
const MAX_THEME_COLORS: u64 = 64;
|
||||
/// The objects the Color Theme Picker reads through, one inside another, at most.
|
||||
const MAX_THEME_DEPTH: usize = 256;
|
||||
|
||||
/// A pick of the Color Theme Picker: a click at a point or a marquee.
|
||||
enum Pick {
|
||||
At(Point),
|
||||
Area(Rect),
|
||||
}
|
||||
|
||||
impl Pick {
|
||||
/// `{at: [x, y]}` or `{rect: [x0, y0, x1, y1]}`, finite numbers.
|
||||
fn param(v: &Value, cmd: &str) -> Result<Self> {
|
||||
let numbers = |a: &Value, n: usize| -> Option<Vec<f64>> {
|
||||
let a = a.as_array().filter(|a| a.len() == n)?;
|
||||
a.iter().map(|x| x.as_f64().filter(|x| x.is_finite())).collect()
|
||||
};
|
||||
match (v.get("at"), v.get("rect")) {
|
||||
(Some(at), None) => {
|
||||
numbers(at, 2).map(|p| Pick::At(Point::new(p[0], p[1]))).ok_or_else(|| bad(cmd, format!("`at` must be [x, y] (got {at})")))
|
||||
}
|
||||
(None, Some(r)) => numbers(r, 4)
|
||||
.map(|r| Pick::Area(Rect::new(r[0], r[1], r[2], r[3]).abs()))
|
||||
.ok_or_else(|| bad(cmd, format!("`rect` must be [x0, y0, x1, y1] (got {r})"))),
|
||||
_ => Err(bad(cmd, format!("each pick is {{at: [x, y]}} or {{rect: [x0, y0, x1, y1]}} (got {v})"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A filled region: its outline and fill rule.
|
||||
type Region = (vectorcraft_geom::BezPath, vectorcraft_geom::FillRule);
|
||||
|
||||
/// An image the Color Theme Picker reads: its pixels, where it lies, the marquee it is read in
|
||||
/// (`None`: all of it) and the region the clip groups around it show (`None`: unclipped). An
|
||||
/// image its colorize paint applies to ([`vectorcraft_doc::ImageBlob::takes_colorize`]) gives that
|
||||
/// paint's colours where a pixel read shows ink (opaque and darker than white).
|
||||
#[derive(Clone)]
|
||||
struct ImageRead<'a> {
|
||||
blob: &'a vectorcraft_doc::ImageBlob,
|
||||
xf: vectorcraft_geom::Affine,
|
||||
size: (f64, f64),
|
||||
area: Option<Rect>,
|
||||
clip: Option<Vec<Region>>,
|
||||
colorize: Option<Vec<Color>>,
|
||||
}
|
||||
|
||||
/// The colours the Color Theme Picker has read: each object weighs 1, shared among its colours.
|
||||
struct Theme<'a> {
|
||||
d: &'a Document,
|
||||
boxes: vectorcraft_doc::SymbolBoxes<'a>,
|
||||
samples: Vec<(Color, f32)>,
|
||||
/// The images to read, each image decoded once for all its reads ([`Self::read_images`]),
|
||||
/// and the images they read.
|
||||
images: Vec<ImageRead<'a>>,
|
||||
blobs: std::collections::HashSet<*const vectorcraft_doc::ImageBlob>,
|
||||
/// 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>>)>,
|
||||
pattern_stack: Vec<String>,
|
||||
/// The region tests [`Self::shows`] has made.
|
||||
work: std::cell::Cell<usize>,
|
||||
/// The object isolated in isolation mode.
|
||||
scope: Option<NodeId>,
|
||||
/// The stroke segments opacity masks may still be outlined along ([`mask_regions`]).
|
||||
mask_budget: usize,
|
||||
objects: usize,
|
||||
}
|
||||
|
||||
/// The region both `a` and `b` (each a union of regions) cover, filled non-zero.
|
||||
fn intersect_regions(a: &[Region], b: &[Region]) -> Vec<Region> {
|
||||
let paths: Vec<vectorcraft_geom::PathData> = a.iter().chain(b).map(|(bp, _)| vectorcraft_geom::PathData::from_bezpath(bp)).collect();
|
||||
let inputs: Vec<(&vectorcraft_geom::PathData, vectorcraft_geom::FillRule)> =
|
||||
paths.iter().zip(a.iter().chain(b)).map(|(p, (_, rule))| (p, *rule)).collect();
|
||||
let split = a.len();
|
||||
let both = vectorcraft_pathops::boolean_n(&inputs, |inside| inside.iter().any(|i| *i < split) && inside.iter().any(|i| *i >= split));
|
||||
if both.subpaths.is_empty() { vec![] } else { vec![(both.to_bezpath(), vectorcraft_geom::FillRule::NonZero)] }
|
||||
}
|
||||
|
||||
impl<'a> Theme<'a> {
|
||||
fn new(d: &'a Document) -> Self {
|
||||
Self {
|
||||
d,
|
||||
boxes: d.symbol_boxes(),
|
||||
samples: vec![],
|
||||
images: vec![],
|
||||
blobs: std::collections::HashSet::new(),
|
||||
clip: None,
|
||||
pattern_colors: HashMap::new(),
|
||||
pattern_stack: vec![],
|
||||
work: std::cell::Cell::new(0),
|
||||
scope: None,
|
||||
mask_budget: MASK_OUTLINE_SEGMENTS,
|
||||
objects: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, colors: Vec<Color>) {
|
||||
if colors.is_empty() {
|
||||
return;
|
||||
}
|
||||
let w = 1.0 / colors.len() as f32;
|
||||
self.samples.extend(colors.into_iter().map(|c| (c, w)));
|
||||
self.objects += 1;
|
||||
}
|
||||
|
||||
/// Read the images queued, each image's pixels decoded once, as colours: the pixels a read
|
||||
/// gives that fall in one cell of 16 levels a channel merge into their average, weighted by how
|
||||
/// 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) {
|
||||
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![];
|
||||
for r in &reads {
|
||||
let key: *const vectorcraft_doc::ImageBlob = r.blob;
|
||||
match by_image.iter().position(|(k, _)| *k == key) {
|
||||
Some(i) => by_image[i].1.push(r),
|
||||
None if by_image.len() < MAX_THEME_IMAGES => by_image.push((key, vec![r])),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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);
|
||||
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)))
|
||||
}) as Box<dyn Fn(f64, f64) -> bool + '_>
|
||||
})
|
||||
.collect();
|
||||
let picks: Vec<vectorcraft_doc::PixelPick> = group
|
||||
.iter()
|
||||
.zip(&keeps)
|
||||
.map(|(r, keep)| {
|
||||
use vectorcraft_geom::kurbo::Shape;
|
||||
let (w, h) = r.size;
|
||||
let inv = r.xf.inverse();
|
||||
// The box sampled: the marquee, cut to the clipping paths' bounds, so a small
|
||||
// region a clip shows of a large image is still sampled densely.
|
||||
let clip_box = r.clip.as_ref().map(|rs| rs.iter().map(|(bp, _)| bp.bounding_box()).reduce(|a, b| a.union(b)).unwrap_or_default());
|
||||
let region = match (r.area, clip_box) {
|
||||
(Some(a), Some(c)) => Some(a.intersect(c)),
|
||||
(a, c) => a.or(c),
|
||||
};
|
||||
let box_uv = region.map(|a| {
|
||||
let uv: Vec<Point> =
|
||||
[(a.x0, a.y0), (a.x1, a.y0), (a.x0, a.y1), (a.x1, a.y1)].into_iter().map(|(x, y)| inv * Point::new(x, y)).collect();
|
||||
let low = |f: fn(&Point) -> f64| uv.iter().map(f).fold(f64::INFINITY, f64::min);
|
||||
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())
|
||||
})
|
||||
.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 };
|
||||
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.
|
||||
if !colors.is_empty() && px.iter().any(|p| p[3] >= 128 && p[..3].iter().any(|v| *v < 255)) {
|
||||
let w = 1.0 / colors.len().max(1) as f32;
|
||||
self.samples.extend(colors.iter().map(|c| (*c, w)));
|
||||
self.objects += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut cells: HashMap<[u8; 3], ([u64; 3], u64)> = HashMap::new();
|
||||
for [r, g, b, _] in px.into_iter().filter(|p| p[3] >= 128) {
|
||||
let cell = cells.entry([r >> 4, g >> 4, b >> 4]).or_default();
|
||||
for (sum, v) in cell.0.iter_mut().zip([r, g, b]) {
|
||||
*sum += u64::from(v);
|
||||
}
|
||||
cell.1 += 1;
|
||||
}
|
||||
let total: u64 = cells.values().map(|c| c.1).sum();
|
||||
if total == 0 {
|
||||
continue;
|
||||
}
|
||||
for (key, (sum, n)) in cells {
|
||||
let cell = pixels.entry(key).or_default();
|
||||
for (acc, s) in cell.0.iter_mut().zip(sum) {
|
||||
*acc += s as f64 / total as f64;
|
||||
}
|
||||
cell.1 += n as f64 / total as f64;
|
||||
}
|
||||
self.objects += 1;
|
||||
}
|
||||
}
|
||||
let mut cells: Vec<_> = pixels.into_iter().collect();
|
||||
// The same order on every run.
|
||||
cells.sort_unstable_by_key(|c| c.0);
|
||||
for (_, (sum, w)) in cells {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// Read `n` and everything visible inside it: each image (its part inside `area` when given,
|
||||
/// and inside the clipping paths around it) and each other object with colours of its own
|
||||
/// (that touches `area` when given, and shows inside the clipping paths around it). The art a
|
||||
/// symbol instance shows (with a dynamic instance's own paints), placed where the instance
|
||||
/// draws it, and the objects a blend, an envelope or a repeat draws elsewhere than they lie,
|
||||
/// are read whole when the instance or the live object touches `area`. `depth` counts the
|
||||
/// objects passed through: deeper art is left out. An enabled opacity mask that clips (and is
|
||||
/// not inverted) shows the object only where its mask art is lighter than black
|
||||
/// ([`mask_regions`]), as a clip group would.
|
||||
fn read(&mut self, n: &Node, area: Option<Rect>, depth: usize) {
|
||||
let Some(mask) = n.extras().mask.as_deref().filter(|m| !m.disabled && m.clip && !m.invert) else {
|
||||
return self.read_unmasked(n, area, depth);
|
||||
};
|
||||
let regions = mask_regions(&mask.art, &mut self.mask_budget);
|
||||
let outer = self.clip.clone();
|
||||
self.clip = Some(match &outer {
|
||||
None => regions,
|
||||
Some(o) => intersect_regions(o, ®ions),
|
||||
});
|
||||
self.read_unmasked(n, area, depth);
|
||||
self.clip = outer;
|
||||
}
|
||||
|
||||
/// [`Self::read`] without `n`'s opacity mask.
|
||||
fn read_unmasked(&mut self, n: &Node, area: Option<Rect>, depth: usize) {
|
||||
// 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, .. }) {
|
||||
return;
|
||||
}
|
||||
let touches = area.is_none_or(|r| self.touches(n, r));
|
||||
match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
if !touches {
|
||||
return;
|
||||
}
|
||||
// A document keeps its images' pixels in its image store.
|
||||
let Some(blob) = self.d.images.get(&im.key) else { return };
|
||||
// An image with a colorize paint shows that paint's color, not its own pixels;
|
||||
// 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)
|
||||
} 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)
|
||||
};
|
||||
let colorize = if colorized {
|
||||
Some(own)
|
||||
} else {
|
||||
if self.shows(n, area) {
|
||||
self.add(own);
|
||||
}
|
||||
None
|
||||
};
|
||||
let size = (f64::from(im.width.max(1)), f64::from(im.height.max(1)));
|
||||
self.queue_image(ImageRead { blob, xf: im.xf, size, area, clip: self.clip.clone(), colorize });
|
||||
return;
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if touches
|
||||
&& 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.
|
||||
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);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
|
||||
if touches && self.shows(n, area) {
|
||||
let own = self.own(n, true);
|
||||
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.
|
||||
let clip = self.clip.take();
|
||||
for c in n.children().into_iter().flatten() {
|
||||
self.read(c, None, depth + 1);
|
||||
}
|
||||
self.clip = clip;
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if touches && self.shows(n, area) {
|
||||
let own = self.own(n, true);
|
||||
self.add(own);
|
||||
}
|
||||
// A compound path's members are its geometry: the compound paints them.
|
||||
if matches!(n.kind, NodeKind::Compound { .. }) {
|
||||
return;
|
||||
}
|
||||
// A clip group's clipping path paints whole, over the art it clips: it is read in the
|
||||
// marquee as given and under the clip groups around it alone.
|
||||
let clip_child = n.children().and_then(|c| c.first()).filter(|_| n.clips());
|
||||
if let Some(first) = clip_child {
|
||||
self.read(first, area, depth + 1);
|
||||
}
|
||||
let inner = match area {
|
||||
Some(r) => match vectorcraft_doc::hit::marquee_in(n, r) {
|
||||
Some(inner) => Some(inner),
|
||||
None => return,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
// A clip group shows its art where its clipping path's regions and the clip groups around
|
||||
// it all show.
|
||||
let outer = self.clip.clone();
|
||||
if n.clips()
|
||||
&& let Some(first) = n.children().and_then(|c| c.first())
|
||||
{
|
||||
let regions: Vec<Region> = vectorcraft_doc::hit::clip_regions(first);
|
||||
self.clip = Some(match &outer {
|
||||
None => regions,
|
||||
Some(o) => intersect_regions(o, ®ions),
|
||||
});
|
||||
}
|
||||
// A Shaper Group's original shapes, which the group keeps but does not show, are left out.
|
||||
for (i, c) in n.children().into_iter().flatten().enumerate().skip(usize::from(clip_child.is_some())) {
|
||||
if !vectorcraft_doc::hit::shaper_hides(n, i, self.scope) {
|
||||
self.read(c, inner, depth + 1);
|
||||
}
|
||||
}
|
||||
self.clip = outer;
|
||||
}
|
||||
|
||||
/// Does `n` show inside the region the clip groups around it show, in `area` when given? Its
|
||||
/// box (cut to `area`) meets a region of it: the region's outline crosses the box, or a point
|
||||
/// on a 9 × 9 grid over the part of the box inside the region's bounds lies in the region.
|
||||
fn shows(&self, n: &Node, area: Option<Rect>) -> bool {
|
||||
use vectorcraft_geom::kurbo::Shape;
|
||||
let Some(regions) = &self.clip else { return true };
|
||||
let Some(b) = n.visual_bounds_with(&self.boxes) else { return false };
|
||||
let b = area.map_or(b, |r| b.intersect(r));
|
||||
let valid = |r: &Rect| r.width() >= 0.0 && r.height() >= 0.0;
|
||||
if !valid(&b) {
|
||||
return false;
|
||||
}
|
||||
// A path or compound path paints along its outline, and inside it where it has a visible
|
||||
// fill: a point along the outline, or (filled) a point of a 9 × 9 grid over the part of the
|
||||
// box inside a region's bounds that lies in both its fill and the region, must show.
|
||||
let filled = has_fill(n);
|
||||
let outline: Option<(Vec<&vectorcraft_geom::PathData>, vectorcraft_geom::FillRule)> = match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => Some((vec![path], *rule)),
|
||||
NodeKind::Compound { children, rule } => Some((children.iter().filter(|c| c.visible).filter_map(|c| c.path_data()).collect(), *rule)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((paths, own_rule)) = outline {
|
||||
use vectorcraft_geom::ParamCurve;
|
||||
use vectorcraft_geom::kurbo::ParamCurveArclen;
|
||||
// The work all these tests do is bounded: past it, objects count as shown.
|
||||
if self.work.get() > THEME_WORK {
|
||||
return true;
|
||||
}
|
||||
let half = stroke_reach(n);
|
||||
let cost: usize = regions.iter().map(|(bp, _)| bp.elements().len().max(1)).sum();
|
||||
let in_region = |p: Point| {
|
||||
// Past the work allowed, the outline counts as shown.
|
||||
if self.work.get() > THEME_WORK {
|
||||
return true;
|
||||
}
|
||||
self.work.set(self.work.get() + cost);
|
||||
regions.iter().any(|(bp, rule)| {
|
||||
vectorcraft_geom::hit::fill_contains(bp, *rule, p) || half > 0.0 && vectorcraft_geom::hit::stroke_contains(bp, 0.0, half, p)
|
||||
})
|
||||
};
|
||||
let mut fill = vectorcraft_geom::BezPath::new();
|
||||
for p in &paths {
|
||||
fill.extend(p.to_bezpath().elements().iter().copied());
|
||||
}
|
||||
// At most 4,096 points along the outline, shared among its segments by length, no
|
||||
// farther apart than half the smallest region's width or height where that allows.
|
||||
let step = regions
|
||||
.iter()
|
||||
.map(|(bp, _)| bp.bounding_box())
|
||||
.chain(area)
|
||||
.map(|rb| rb.width().min(rb.height()))
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
.max(0.01)
|
||||
/ 2.0;
|
||||
// With a marquee, only the outline inside it (within the stroke's reach) counts.
|
||||
let picked = area.map(|r| r.inflate(half, half));
|
||||
let segs: Vec<(vectorcraft_geom::PathSeg, f64)> = fill
|
||||
.segments()
|
||||
.take(4096)
|
||||
.map(|s| {
|
||||
let len = s.arclen(0.5);
|
||||
(s, if len.is_finite() { len } else { 0.0 })
|
||||
})
|
||||
.collect();
|
||||
let total: f64 = segs.iter().map(|(_, l)| l).sum();
|
||||
let points = (total / step).ceil().clamp(64.0, 4096.0);
|
||||
let along = segs.iter().any(|(seg, len)| {
|
||||
let n = if total > 0.0 { (points * len / total).round().clamp(1.0, 4096.0) as u32 } else { 1 };
|
||||
(0..=n).any(|i| {
|
||||
let p = seg.eval(f64::from(i) / f64::from(n));
|
||||
picked.is_none_or(|r| r.contains(p)) && in_region(p)
|
||||
})
|
||||
});
|
||||
return along
|
||||
|| filled
|
||||
&& regions.iter().any(|(bp, _)| {
|
||||
let g = b.intersect(bp.bounding_box());
|
||||
valid(&g)
|
||||
&& (0..81).any(|i| {
|
||||
let p = Point::new(g.x0 + g.width() * f64::from(i % 9) / 8.0, g.y0 + g.height() * f64::from(i / 9) / 8.0);
|
||||
in_region(p) && vectorcraft_geom::hit::fill_contains(&fill, own_rule, p)
|
||||
})
|
||||
});
|
||||
}
|
||||
regions.iter().any(|(bp, rule)| {
|
||||
let g = b.intersect(bp.bounding_box());
|
||||
valid(&g)
|
||||
&& (vectorcraft_geom::hit::intersects_rect(&vectorcraft_geom::PathData::from_bezpath(bp), b)
|
||||
|| (0..81).any(|i| {
|
||||
let p = Point::new(g.x0 + g.width() * f64::from(i % 9) / 8.0, g.y0 + g.height() * f64::from(i / 9) / 8.0);
|
||||
vectorcraft_geom::hit::fill_contains(bp, *rule, p)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Does the marquee `r` touch what `n` paints? A path or a compound path is touched where its
|
||||
/// outline comes within half its widest visible stroke of `r` or, when it has a visible fill,
|
||||
/// where `r`'s centre lies in that fill; any other object is touched by its box.
|
||||
fn touches(&self, n: &Node, r: Rect) -> bool {
|
||||
let boxed = n.visual_bounds_with(&self.boxes).is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1);
|
||||
let filled = || has_fill(n);
|
||||
let half = stroke_reach(n);
|
||||
let near = r.inflate(half, half);
|
||||
let crosses = |paths: &[&vectorcraft_geom::PathData], rule: vectorcraft_geom::FillRule| {
|
||||
paths.iter().any(|p| vectorcraft_geom::hit::intersects_rect(p, near))
|
||||
|| filled() && {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
for p in paths {
|
||||
bp.extend(p.to_bezpath().elements().iter().copied());
|
||||
}
|
||||
// A point on a 5 × 5 grid over the part of the marquee in the path's box lies
|
||||
// in its fill.
|
||||
use vectorcraft_geom::kurbo::Shape;
|
||||
let g = r.intersect(bp.bounding_box());
|
||||
g.width() >= 0.0
|
||||
&& g.height() >= 0.0
|
||||
&& (0..25).any(|i| {
|
||||
let p = Point::new(g.x0 + g.width() * f64::from(i % 5) / 4.0, g.y0 + g.height() * f64::from(i / 5) / 4.0);
|
||||
vectorcraft_geom::hit::fill_contains(&bp, rule, p)
|
||||
})
|
||||
}
|
||||
};
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => boxed && crosses(&[path], *rule),
|
||||
NodeKind::Compound { children, rule } => {
|
||||
let paths: Vec<&vectorcraft_geom::PathData> = children.iter().filter(|c| c.visible).filter_map(|c| c.path_data()).collect();
|
||||
boxed && crosses(&paths, *rule)
|
||||
}
|
||||
_ => boxed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The regions where opacity mask art `art` lets the masked object show: the regions
|
||||
/// ([`vectorcraft_doc::hit::clip_regions`]) of its visible paths and compound paths filled lighter
|
||||
/// than black (a solid color, a gradient with a stop lighter than black, or a pattern), the
|
||||
/// outlines of the strokes lighter than black they have (dashes read as solid), the frames of its
|
||||
/// images, the boxes of its type painted lighter than black, the boxes of art with effects (with
|
||||
/// what the effects reach), and the regions of its clip groups and live objects whole. Black,
|
||||
/// unfilled and unstroked paths without effects hide; how light the art is beyond that is not
|
||||
/// read. Strokes are outlined along at most `budget` segments in all (it counts down); past that
|
||||
/// a stroke counts by its box grown by half its width.
|
||||
fn mask_regions(art: &Node, budget: &mut usize) -> Vec<Region> {
|
||||
// Any color but black lets the masked art show.
|
||||
fn light(p: &Paint) -> bool {
|
||||
let lit = |c: &Color| c.to_rgba8(1.0)[..3].iter().any(|v| *v > 0);
|
||||
match p {
|
||||
Paint::None => false,
|
||||
Paint::Solid { color, .. } => lit(color),
|
||||
Paint::Gradient(g) => g.gradient.stops.iter().any(|s| lit(&s.color)),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
fn walk(n: &Node, depth: usize, out: &mut Vec<Region>, budget: &mut usize) {
|
||||
use vectorcraft_geom::kurbo::Shape;
|
||||
// The mask art's root masks even when it is hidden.
|
||||
// Guides draw nothing.
|
||||
if depth > MAX_THEME_DEPTH || depth > 0 && (!n.visible || n.opacity <= 0.0) || matches!(n.kind, NodeKind::Path { guide: true, .. }) {
|
||||
return;
|
||||
}
|
||||
// Art with effects can show light anywhere near it (a glow on black art): it counts
|
||||
// whole, as a clipping path would.
|
||||
let effects = !n.appearance.effects.is_empty()
|
||||
|| n.appearance.items.iter().any(|it| match it {
|
||||
vectorcraft_doc::AppearanceItem::Fill(l) => l.visible && !l.effects.is_empty(),
|
||||
vectorcraft_doc::AppearanceItem::Stroke(l) => l.visible && !l.effects.is_empty(),
|
||||
});
|
||||
if effects {
|
||||
if let Some(b) = n.visual_bounds() {
|
||||
out.push((b.to_path(0.1), vectorcraft_geom::FillRule::NonZero));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let lit = n
|
||||
.appearance
|
||||
.items
|
||||
.iter()
|
||||
.any(|it| matches!(it, vectorcraft_doc::AppearanceItem::Fill(l) if l.visible && l.opacity > 0.0 && light(&l.paint)));
|
||||
let stroke = n
|
||||
.appearance
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
vectorcraft_doc::AppearanceItem::Stroke(l) if l.visible && l.opacity > 0.0 && l.width > 0.0 && light(&l.paint) => Some(l.width / 2.0),
|
||||
_ => None,
|
||||
})
|
||||
.fold(None, |a: Option<f64>, w| Some(a.map_or(w, |a| a.max(w))));
|
||||
let boxed = |out: &mut Vec<Region>| {
|
||||
if let Some(b) = n.geometric_bounds() {
|
||||
out.push((b.to_path(0.1), vectorcraft_geom::FillRule::NonZero));
|
||||
}
|
||||
};
|
||||
match &n.kind {
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
|
||||
if lit {
|
||||
out.extend(vectorcraft_doc::hit::clip_regions(n));
|
||||
}
|
||||
// A stroke shows the art along it: its outline with round ends and corners, a
|
||||
// dashed stroke read as solid (the most it can show).
|
||||
if let Some(half) = stroke {
|
||||
let paths: Vec<&vectorcraft_geom::PathData> = match &n.kind {
|
||||
NodeKind::Path { path, .. } => vec![path],
|
||||
NodeKind::Compound { children, .. } => children.iter().filter_map(|c| c.path_data()).collect(),
|
||||
_ => vec![],
|
||||
};
|
||||
for p in paths {
|
||||
// Past MASK_OUTLINE_SEGMENTS outlined segments, a stroke counts by its
|
||||
// box grown by half its width.
|
||||
let segments = p.to_bezpath().elements().len();
|
||||
if *budget < segments {
|
||||
if let Some(b) = p.bounds().filter(|_| half.is_finite()) {
|
||||
out.push((b.inflate(half, half).to_path(0.1), vectorcraft_geom::FillRule::NonZero));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
*budget -= segments;
|
||||
let region = vectorcraft_pathops::outline_stroke(
|
||||
p,
|
||||
half * 2.0,
|
||||
vectorcraft_pathops::Cap::Round,
|
||||
vectorcraft_pathops::Join::Round,
|
||||
10.0,
|
||||
);
|
||||
if !region.subpaths.is_empty() {
|
||||
out.push((region.to_bezpath(), vectorcraft_geom::FillRule::NonZero));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Image(_) => out.extend(vectorcraft_doc::hit::clip_regions(n)),
|
||||
NodeKind::Text(t) => {
|
||||
if lit || stroke.is_some() || t.runs.iter().any(|r| light(&r.style.fill) || r.style.stroke_width > 0.0 && light(&r.style.stroke)) {
|
||||
boxed(out);
|
||||
}
|
||||
}
|
||||
NodeKind::Group { clip: false, .. } | NodeKind::Layer { clip: false, .. } => {
|
||||
// A Shaper Group's original shapes are kept but not drawn.
|
||||
for (i, c) in n.children().into_iter().flatten().enumerate() {
|
||||
if !vectorcraft_doc::hit::shaper_hides(n, i, None) {
|
||||
walk(c, depth + 1, out, budget);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clip groups, live objects and placed documents count by the regions they clip to.
|
||||
_ => out.extend(vectorcraft_doc::hit::clip_regions(n)),
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
walk(art, 0, &mut out, budget);
|
||||
out
|
||||
}
|
||||
|
||||
/// Does `n` have a fill item that paints: visible, not fully transparent and not None?
|
||||
fn has_fill(n: &Node) -> bool {
|
||||
n.appearance
|
||||
.items
|
||||
.iter()
|
||||
.any(|it| matches!(it, vectorcraft_doc::AppearanceItem::Fill(l) if l.visible && l.opacity > 0.0 && !matches!(l.paint, Paint::None)))
|
||||
}
|
||||
|
||||
/// How far `n`'s widest painting stroke reaches from its path: half its width (0 without one).
|
||||
fn stroke_reach(n: &Node) -> f64 {
|
||||
let paints = |l: &vectorcraft_doc::StrokeLayer| l.visible && l.opacity > 0.0 && !matches!(l.paint, Paint::None) && l.width.is_finite();
|
||||
n.appearance
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
vectorcraft_doc::AppearanceItem::Stroke(l) if paints(l) => Some(l.width.max(0.0) / 2.0),
|
||||
_ => None,
|
||||
})
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// The paints `m` shows: its visible fill items and its visible strokes wider than 0, its text's
|
||||
/// fills and strokes wider than 0, and its colorize paint.
|
||||
fn shown_paints(m: &Node) -> impl Iterator<Item = &Paint> {
|
||||
let runs = match &m.kind {
|
||||
NodeKind::Text(t) => t.runs.as_slice(),
|
||||
_ => &[],
|
||||
};
|
||||
let items = m.appearance.items.iter().filter_map(|it| match it {
|
||||
vectorcraft_doc::AppearanceItem::Fill(l) if l.visible && l.opacity > 0.0 => Some(&l.paint),
|
||||
vectorcraft_doc::AppearanceItem::Stroke(l) if l.visible && l.opacity > 0.0 && l.width > 0.0 => Some(&l.paint),
|
||||
_ => None,
|
||||
});
|
||||
let text = runs.iter().flat_map(|r| std::iter::once(&r.style.fill).chain((r.style.stroke_width > 0.0).then_some(&r.style.stroke)));
|
||||
items.chain(text).chain(m.extras().colorize.as_deref())
|
||||
}
|
||||
|
||||
/// Add the colours `m` shows itself to `out` (solid colours, gradient stops, its text's colours
|
||||
/// and its mesh points; the paints a dynamic symbol instance keeps for objects of its art only
|
||||
/// when `overrides` is set), and the names of the patterns it paints with to `patterns`, leaving
|
||||
/// out names already there.
|
||||
fn paint_colors_of(m: &Node, overrides: bool, out: &mut Vec<Color>, patterns: &mut Vec<String>) {
|
||||
let kept = override_paints(m).filter(|_| overrides);
|
||||
for p in shown_paints(m).chain(kept) {
|
||||
match p {
|
||||
Paint::Solid { color, .. } => out.push(*color),
|
||||
Paint::Gradient(g) => out.extend(g.gradient.stops.iter().map(|s| s.color)),
|
||||
Paint::Pattern { pattern, .. } if !patterns.contains(pattern) => patterns.push(pattern.clone()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let NodeKind::Mesh(mesh) = &m.kind {
|
||||
out.extend(mesh.points.iter().map(|p| p.color));
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Theme<'a> {
|
||||
/// Queue image read `r`, within the limits of a pick: at most [`MAX_THEME_READS`] reads of
|
||||
/// [`MAX_THEME_IMAGES`] images; nothing inside clip groups that show nothing.
|
||||
fn queue_image(&mut self, r: ImageRead<'a>) {
|
||||
if self.images.len() >= MAX_THEME_READS || r.clip.as_ref().is_some_and(Vec::is_empty) {
|
||||
return;
|
||||
}
|
||||
let key: *const vectorcraft_doc::ImageBlob = r.blob;
|
||||
if !self.blobs.contains(&key) {
|
||||
if self.blobs.len() >= MAX_THEME_IMAGES {
|
||||
return;
|
||||
}
|
||||
self.blobs.insert(key);
|
||||
}
|
||||
self.images.push(r);
|
||||
}
|
||||
|
||||
/// The colours `m` paints itself ([`paint_colors_of`]; a dynamic symbol instance's paints for
|
||||
/// objects of its art only with `overrides`), and the colours its pattern tiles show, read as
|
||||
/// 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> {
|
||||
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) {
|
||||
out.extend(colors);
|
||||
// The tiles' images count again for each use, within the pick's limits.
|
||||
let reads = reads.clone();
|
||||
for r in reads {
|
||||
self.queue_image(r);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if self.pattern_stack.contains(&name) || self.pattern_stack.len() >= MAX_THEME_DEPTH {
|
||||
continue;
|
||||
}
|
||||
let Some(def) = self.d.pattern(&name) else { continue };
|
||||
let mut sub = Theme::new(self.d);
|
||||
sub.pattern_stack = std::mem::take(&mut self.pattern_stack);
|
||||
sub.pattern_stack.push(name.clone());
|
||||
sub.pattern_colors = std::mem::take(&mut self.pattern_colors);
|
||||
// The tiles' images join this pick's image reads, within its limits.
|
||||
sub.images = std::mem::take(&mut self.images);
|
||||
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.mask_budget = self.mask_budget;
|
||||
let before = sub.images.len();
|
||||
for a in &def.art {
|
||||
sub.read(a, None, 0);
|
||||
}
|
||||
sub.pattern_stack.pop();
|
||||
self.pattern_stack = std::mem::take(&mut sub.pattern_stack);
|
||||
self.pattern_colors = std::mem::take(&mut sub.pattern_colors);
|
||||
let reads: Vec<ImageRead<'a>> = sub.images.get(before..).unwrap_or_default().to_vec();
|
||||
self.images = std::mem::take(&mut sub.images);
|
||||
self.blobs = std::mem::take(&mut sub.blobs);
|
||||
self.work.set(sub.work.get());
|
||||
self.mask_budget = sub.mask_budget;
|
||||
let colors: Vec<Color> = sub.samples.iter().map(|(c, _)| *c).collect();
|
||||
out.extend(&colors);
|
||||
self.pattern_colors.insert(name, (colors, reads));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_theme(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "recolor.pickTheme";
|
||||
let picks = match p.get("picks") {
|
||||
Some(Value::Array(a)) if !a.is_empty() && a.len() <= MAX_PICKS => a.iter().map(|v| Pick::param(v, C)).collect::<Result<Vec<_>>>()?,
|
||||
Some(Value::Array(a)) => return Err(bad(C, format!("`picks` must hold 1 to {MAX_PICKS} picks (got {})", a.len()))),
|
||||
Some(v) => return Err(bad(C, format!("`picks` must be an array (got {v})"))),
|
||||
None => return Err(bad(C, "missing `picks`")),
|
||||
};
|
||||
let n = super::menucmds::whole_or(p, "colors", 5, C)?;
|
||||
if !(1..=MAX_THEME_COLORS).contains(&n) {
|
||||
return Err(bad(C, format!("`colors` must be between 1 and {MAX_THEME_COLORS} (got {n})")));
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let mut theme = Theme::new(d);
|
||||
theme.scope = st.isolation;
|
||||
// In isolation mode: the isolated object, everything inside it and the containers around it.
|
||||
let scope: Option<std::collections::HashSet<NodeId>> = st.isolation.and_then(|iso| d.node(iso)).map(|iso| {
|
||||
let mut allowed = std::collections::HashSet::new();
|
||||
iso.walk(&mut |m| {
|
||||
allowed.insert(m.id);
|
||||
});
|
||||
let mut up = d.parent_of(iso.id);
|
||||
while let Some(p) = up {
|
||||
allowed.insert(p);
|
||||
up = d.parent_of(p);
|
||||
}
|
||||
allowed
|
||||
});
|
||||
for pick in picks {
|
||||
match pick {
|
||||
Pick::At(pt) => {
|
||||
// In isolation mode only the isolated art (and the groups around it) can be hit;
|
||||
// isolated Shaper Group originals are hittable then.
|
||||
let opt = vectorcraft_doc::hit::HitOptions { scope: st.isolation, ..Default::default() };
|
||||
let hit = match &scope {
|
||||
Some(allowed) => vectorcraft_doc::hit::hit_test_visible_skipping(d, pt, opt, &|id| !allowed.contains(&id)),
|
||||
None => vectorcraft_doc::hit::hit_test_visible(d, pt, opt),
|
||||
};
|
||||
if let Some(n) = hit.and_then(|h| d.node(h.top_object(st.isolation))) {
|
||||
theme.read(n, None, 0);
|
||||
}
|
||||
}
|
||||
Pick::Area(r) => match st.isolation.and_then(|iso| d.node(iso)) {
|
||||
// In isolation mode a marquee reads the isolated art alone.
|
||||
Some(iso) => theme.read(iso, Some(r), 0),
|
||||
None => {
|
||||
for l in &d.layers {
|
||||
theme.read(l, Some(r), 0);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
theme.read_images();
|
||||
let colors = vectorcraft_color::recolor::theme_palette(&theme.samples, index(n));
|
||||
Ok(json!({"colors": colors.iter().map(key_str).collect::<Vec<_>>(), "objects": theme.objects}))
|
||||
}
|
||||
|
||||
/// A colour the art uses: how often, and the global swatch it is linked to.
|
||||
struct Used {
|
||||
color: Color,
|
||||
|
||||
@@ -1079,3 +1079,272 @@ fn global_adjust_refuses_params_of_the_wrong_form() {
|
||||
run(&mut s, "recolor.apply", json!({"map": map, "globalAdjust": {"saturation": null, "temperature": -100}}));
|
||||
assert_ne!(fill(&s, art).to_hex(), "#ff0000", "an amount at the end of the range applies");
|
||||
}
|
||||
|
||||
/// A placed `w` × `h` px image whose left half is `left` and right half `right` (RGBA), and its
|
||||
/// bounds on the page.
|
||||
fn halves_image(s: &mut Session, w: u32, h: u32, left: [u8; 4], right: [u8; 4]) -> (NodeId, vectorcraft_geom::Rect) {
|
||||
let img = image::RgbaImage::from_fn(w, h, |x, _| image::Rgba(if x < w / 2 { left } else { right }));
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let r = run(s, "file.place", json!({"name": "halves.png", "dataBase64": vectorcraft_format::base64_encode(&png)}));
|
||||
let id = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let b = s.doc().unwrap().doc.node(id).unwrap().visual_bounds().unwrap();
|
||||
(id, b)
|
||||
}
|
||||
|
||||
fn pick_theme(s: &mut Session, p: Value) -> Vec<String> {
|
||||
keys(&run(s, "recolor.pickTheme", p)["colors"])
|
||||
}
|
||||
|
||||
/// A click takes the palette of the object a Selection tool click takes (a whole group), the most
|
||||
/// used color first, each in its own model; a click on empty canvas or on a hidden object takes
|
||||
/// nothing. Locked objects are read. Picking changes nothing.
|
||||
#[test]
|
||||
fn theme_picker_click_takes_the_palette_of_the_whole_object() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, json!("#ff0000"));
|
||||
let b = rect(&mut s, json!({"c": 0, "m": 0, "y": 100, "k": 0}));
|
||||
let c = rect(&mut s, json!("#ff0000"));
|
||||
run(&mut s, "paint.setStroke", json!({"ids": [c.0], "color": "#0000ff"}));
|
||||
// x 70 to 85 and 90 to 105.
|
||||
let hidden = rect(&mut s, json!("#00ff00"));
|
||||
let locked = rect(&mut s, json!("#ff00ff"));
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0, c.0]}));
|
||||
run(&mut s, "object.group", json!({}));
|
||||
run(&mut s, "object.hide", json!({"ids": [hidden.0]}));
|
||||
run(&mut s, "object.lock", json!({"ids": [locked.0]}));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
// A click on `b` (x 30 to 45): the group's three rectangles, red the most used.
|
||||
let r = run(&mut s, "recolor.pickTheme", json!({"picks": [{"at": [37, 17]}]}));
|
||||
assert_eq!(keys(&r["colors"]), ["rgb 255 0 0", "cmyk 0 0 100 0", "rgb 0 0 255"], "{r}");
|
||||
assert_eq!(r["objects"], 3);
|
||||
assert_eq!(pick_theme(&mut s, json!({"picks": [{"at": [37, 17]}], "colors": 2})).len(), 2, "at most `colors`");
|
||||
assert!(pick_theme(&mut s, json!({"picks": [{"at": [300, 150]}]})).is_empty(), "empty canvas");
|
||||
assert!(pick_theme(&mut s, json!({"picks": [{"at": [77, 17]}]})).is_empty(), "a hidden object");
|
||||
assert_eq!(pick_theme(&mut s, json!({"picks": [{"at": [97, 17]}]})), ["rgb 255 0 255"], "a locked object");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &doc), "the document stays as it was");
|
||||
}
|
||||
|
||||
/// A marquee takes the part of an image inside it and every painted object it touches; several
|
||||
/// picks (Shift) add up. A click on an image takes all its pixels.
|
||||
#[test]
|
||||
fn theme_picker_marquee_takes_the_area_and_picks_add_up() {
|
||||
let mut s = session();
|
||||
let (_, b) = halves_image(&mut s, 64, 32, [255, 0, 0, 255], [0, 0, 255, 255]);
|
||||
let (mid, y) = ((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0);
|
||||
let left = json!({"rect": [b.x0 + 1.0, b.y0 + 1.0, mid - 1.0, b.y1 - 1.0]});
|
||||
assert_eq!(pick_theme(&mut s, json!({"picks": [left]})), ["rgb 255 0 0"], "the left half alone");
|
||||
let right_backwards = json!({"rect": [b.x1 - 1.0, b.y1 - 1.0, mid + 1.0, b.y0 + 1.0]});
|
||||
assert_eq!(pick_theme(&mut s, json!({"picks": [right_backwards]})), ["rgb 0 0 255"], "a marquee drawn backwards");
|
||||
let both = pick_theme(&mut s, json!({"picks": [left, right_backwards]}));
|
||||
assert_eq!(both.len(), 2, "Shift adds a second area: {both:?}");
|
||||
assert_eq!(pick_theme(&mut s, json!({"picks": [{"at": [mid, y]}]})).len(), 2, "a click takes the whole image");
|
||||
// Two rectangles away from the image: a marquee touching both takes both.
|
||||
let r1 = rect(&mut s, json!("#00ff00"));
|
||||
let r2 = rect(&mut s, json!("#ffff00"));
|
||||
let (b1, b2) = {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
(d.node(r1).unwrap().visual_bounds().unwrap(), d.node(r2).unwrap().visual_bounds().unwrap())
|
||||
};
|
||||
let over = json!({"rect": [b1.x1 - 2.0, b1.y0 + 2.0, b2.x0 + 2.0, b1.y0 + 4.0]});
|
||||
let mut got = pick_theme(&mut s, json!({"picks": [over]}));
|
||||
got.sort();
|
||||
assert_eq!(got, ["rgb 0 255 0", "rgb 255 255 0"]);
|
||||
}
|
||||
|
||||
/// Pixels less than half opaque are left out, and so is an image whose pixels can't be read.
|
||||
#[test]
|
||||
fn theme_picker_leaves_out_clear_pixels_and_unreadable_images() {
|
||||
let mut s = session();
|
||||
let (_, b) = halves_image(&mut s, 8, 8, [255, 0, 0, 255], [0, 0, 255, 100]);
|
||||
let all = json!({"rect": [b.x0, b.y0, b.x1, b.y1]});
|
||||
assert_eq!(pick_theme(&mut s, json!({"picks": [all]})), ["rgb 255 0 0"]);
|
||||
let cut = super::menucmds::place_undecodable_png(&mut s);
|
||||
let cb = s.doc().unwrap().doc.node(cut).unwrap().visual_bounds().unwrap();
|
||||
let r = run(&mut s, "recolor.pickTheme", json!({"picks": [{"rect": [cb.x0, cb.y0, cb.x1, cb.y1]}]}));
|
||||
assert!(r["colors"].as_array().unwrap().iter().all(|k| k == "rgb 255 0 0"), "the undecodable image adds nothing: {r}");
|
||||
}
|
||||
|
||||
/// A large image is read through about 65,536 pixels: its palette still holds both halves.
|
||||
#[test]
|
||||
fn theme_picker_reads_a_large_image_within_its_pixel_budget() {
|
||||
let mut s = session();
|
||||
let (_, b) = halves_image(&mut s, 2000, 1000, [10, 200, 30, 255], [250, 240, 10, 255]);
|
||||
let (mid, y) = ((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0);
|
||||
let mut got = pick_theme(&mut s, json!({"picks": [{"at": [mid, y]}]}));
|
||||
got.sort();
|
||||
assert_eq!(got, ["rgb 10 200 30", "rgb 250 240 10"]);
|
||||
}
|
||||
|
||||
/// A symbol instance gives the colors of the art it shows, by a click or a marquee; a pattern fill
|
||||
/// gives the colors of its tile and of the patterns the tile uses.
|
||||
#[test]
|
||||
fn theme_picker_reads_symbol_art_and_nested_patterns() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, json!("#00ff00"));
|
||||
run(&mut s, "select.set", json!({"ids": [r.0]}));
|
||||
let inst = NodeId(run(&mut s, "symbol.new", json!({"name": "Dot"}))["id"].as_u64().unwrap());
|
||||
let b = s.doc().unwrap().doc.node(inst).unwrap().visual_bounds().unwrap();
|
||||
let (cx, cy) = ((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0);
|
||||
assert_eq!(pick_theme(&mut s, json!({"picks": [{"at": [cx, cy]}]})), ["rgb 0 255 0"], "a click on the instance");
|
||||
assert_eq!(pick_theme(&mut s, json!({"picks": [{"rect": [cx, cy, cx + 1.0, cy + 1.0]}]})), ["rgb 0 255 0"], "a marquee");
|
||||
select_all(&mut s);
|
||||
run(&mut s, "edit.clear", json!({}));
|
||||
// Pattern "inner" of a red square fills a square, which becomes pattern "outer".
|
||||
let red = rect(&mut s, json!("#ff0000"));
|
||||
let inner = run(&mut s, "object.pattern.make", json!({"ids": [red.0], "edit": false}))["name"].as_str().unwrap().to_string();
|
||||
let tile = rect(&mut s, json!("#0000ff"));
|
||||
run(&mut s, "paint.setFill", json!({"ids": [tile.0], "swatch": inner}));
|
||||
let outer = run(&mut s, "object.pattern.make", json!({"ids": [tile.0], "edit": false}))["name"].as_str().unwrap().to_string();
|
||||
let art = rect(&mut s, json!("#0000ff"));
|
||||
run(&mut s, "paint.setFill", json!({"ids": [art.0], "swatch": outer}));
|
||||
let b = s.doc().unwrap().doc.node(art).unwrap().visual_bounds().unwrap();
|
||||
let got = pick_theme(&mut s, json!({"picks": [{"at": [(b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0]}]}));
|
||||
assert!(got.contains(&"rgb 255 0 0".to_string()), "the inner pattern's red: {got:?}");
|
||||
}
|
||||
|
||||
/// A grayscale image that a fill color colorizes gives that color, not its grays.
|
||||
#[test]
|
||||
fn theme_picker_reads_the_color_of_a_colorized_image() {
|
||||
let mut s = session();
|
||||
let mut png = Vec::new();
|
||||
image::GrayImage::from_pixel(8, 8, image::Luma([90])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let r = run(&mut s, "file.place", json!({"name": "gray.png", "dataBase64": vectorcraft_format::base64_encode(&png)}));
|
||||
let id = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let b = s.doc().unwrap().doc.node(id).unwrap().visual_bounds().unwrap();
|
||||
let at = json!({"picks": [{"at": [(b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0]}]});
|
||||
assert_eq!(pick_theme(&mut s, at.clone()), ["rgb 90 90 90"], "uncolorized: its grays");
|
||||
run(&mut s, "paint.setFill", json!({"ids": [id.0], "color": "#ff0000"}));
|
||||
assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "the fill colorizes the image");
|
||||
assert_eq!(pick_theme(&mut s, at), ["rgb 255 0 0"]);
|
||||
}
|
||||
|
||||
/// A hidden fill gives no color, and a marquee in a clip group reads only what the clipping path
|
||||
/// lets through.
|
||||
#[test]
|
||||
fn theme_picker_leaves_out_hidden_fills_and_clipped_art() {
|
||||
let mut s = session();
|
||||
let shape = |s: &mut Session, x: f64, w: f64, fill: &str| {
|
||||
let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": w, "height": 15}))["id"].as_u64().unwrap());
|
||||
run(s, "paint.setFill", json!({"ids": [id.0], "color": fill}));
|
||||
run(s, "paint.setStroke", json!({"ids": [id.0], "none": true}));
|
||||
id
|
||||
};
|
||||
let hidden = shape(&mut s, 200.0, 15.0, "#ff00ff");
|
||||
run(&mut s, "select.set", json!({"ids": [hidden.0]}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "visible": false}));
|
||||
assert!(pick_theme(&mut s, json!({"picks": [{"rect": [205, 12, 210, 20]}]})).is_empty(), "a hidden fill");
|
||||
// A marquee inside an outlined square without a fill misses it; inside a filled one it takes it.
|
||||
let outline = shape(&mut s, 250.0, 40.0, "#00ffff");
|
||||
run(&mut s, "paint.setFill", json!({"ids": [outline.0], "none": true}));
|
||||
run(&mut s, "paint.setStroke", json!({"ids": [outline.0], "color": "#ffff00"}));
|
||||
assert!(pick_theme(&mut s, json!({"picks": [{"rect": [260, 13, 270, 20]}]})).is_empty(), "inside the outline");
|
||||
assert_eq!(pick_theme(&mut s, json!({"picks": [{"rect": [245, 13, 260, 20]}]})), ["rgb 255 255 0"], "across the outline");
|
||||
run(&mut s, "paint.setFill", json!({"ids": [outline.0], "color": "#00ffff"}));
|
||||
assert_eq!(pick_theme(&mut s, json!({"picks": [{"rect": [260, 13, 270, 20]}]})).len(), 2, "inside the fill");
|
||||
// Red runs from x 10 to 110 and green from 50 to 60; the clipping path lets x 10 to 40 through.
|
||||
let red = shape(&mut s, 10.0, 100.0, "#ff0000");
|
||||
let green = shape(&mut s, 50.0, 10.0, "#00ff00");
|
||||
let clip = shape(&mut s, 10.0, 30.0, "#0000ff");
|
||||
run(&mut s, "select.set", json!({"ids": [red.0, green.0, clip.0]}));
|
||||
run(&mut s, "object.clippingMask.make", json!({}));
|
||||
assert_eq!(pick_theme(&mut s, json!({"picks": [{"rect": [30, 12, 55, 20]}]})), ["rgb 255 0 0"]);
|
||||
}
|
||||
|
||||
/// An image in a clip group gives only the pixels inside its clipping path, by a click or a
|
||||
/// marquee.
|
||||
#[test]
|
||||
fn theme_picker_reads_an_image_inside_its_clipping_path() {
|
||||
let mut s = session();
|
||||
// Red, with blue 6 × 3 px blocks in the corners, which an ellipse inscribed in the image leaves out.
|
||||
let (w, h) = (64, 32);
|
||||
let img = image::RgbaImage::from_fn(w, h, |x, y| {
|
||||
let corner = (x < 6 || x >= w - 6) && (y < 3 || y >= h - 3);
|
||||
image::Rgba(if corner { [0, 0, 255, 255] } else { [255, 0, 0, 255] })
|
||||
});
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let r = run(&mut s, "file.place", json!({"name": "corners.png", "dataBase64": vectorcraft_format::base64_encode(&png)}));
|
||||
let id = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let b = s.doc().unwrap().doc.node(id).unwrap().visual_bounds().unwrap();
|
||||
let all = json!({"picks": [{"rect": [b.x0, b.y0, b.x1, b.y1]}]});
|
||||
assert_eq!(pick_theme(&mut s, all.clone()).len(), 2, "unclipped: red and blue");
|
||||
let e = NodeId(run(&mut s, "shape.ellipse", json!({"x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "select.set", json!({"ids": [id.0, e.0]}));
|
||||
run(&mut s, "object.clippingMask.make", json!({}));
|
||||
assert_eq!(pick_theme(&mut s, all), ["rgb 255 0 0"], "a marquee");
|
||||
let center = json!({"picks": [{"at": [(b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0]}]});
|
||||
assert_eq!(pick_theme(&mut s, center), ["rgb 255 0 0"], "a click");
|
||||
}
|
||||
|
||||
/// An object in a clip group that its clipping path hides gives no color, even inside the
|
||||
/// clipping path's box.
|
||||
#[test]
|
||||
fn theme_picker_leaves_out_art_a_curved_clipping_path_hides() {
|
||||
let mut s = session();
|
||||
let shape = |s: &mut Session, x: f64, y: f64, w: f64, h: f64, fill: &str| {
|
||||
let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap());
|
||||
run(s, "paint.setFill", json!({"ids": [id.0], "color": fill}));
|
||||
run(s, "paint.setStroke", json!({"ids": [id.0], "none": true}));
|
||||
id
|
||||
};
|
||||
// A red square in the middle of a 100 × 100 ellipse, and a green one in its top left corner.
|
||||
let red = shape(&mut s, 40.0, 40.0, 20.0, 20.0, "#ff0000");
|
||||
let green = shape(&mut s, 0.0, 0.0, 8.0, 8.0, "#00ff00");
|
||||
let e = NodeId(run(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"].as_u64().unwrap());
|
||||
let all = json!({"picks": [{"rect": [-5, -5, 105, 105]}]});
|
||||
let mut both = pick_theme(&mut s, all.clone());
|
||||
both.retain(|k| k == "rgb 255 0 0" || k == "rgb 0 255 0");
|
||||
assert_eq!(both.len(), 2, "unclipped: both squares");
|
||||
run(&mut s, "select.set", json!({"ids": [red.0, green.0, e.0]}));
|
||||
run(&mut s, "object.clippingMask.make", json!({}));
|
||||
assert_eq!(pick_theme(&mut s, all), ["rgb 255 0 0"], "the corner square is hidden");
|
||||
}
|
||||
|
||||
/// In isolation mode a click and a marquee read only the isolated art.
|
||||
#[test]
|
||||
fn theme_picker_reads_only_the_isolated_art() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, json!("#ff0000"));
|
||||
let b = rect(&mut s, json!("#0000ff"));
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
|
||||
// Green on top of the blue square (x 30 to 45).
|
||||
let green = NodeId(run(&mut s, "shape.rectangle", json!({"x": 30, "y": 10, "width": 15, "height": 15}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "paint.setFill", json!({"ids": [green.0], "color": "#00ff00"}));
|
||||
run(&mut s, "paint.setStroke", json!({"ids": [green.0], "none": true}));
|
||||
assert_eq!(pick_theme(&mut s, json!({"picks": [{"at": [37, 17]}]})), ["rgb 0 255 0"], "outside isolation: the top object");
|
||||
run(&mut s, "object.isolate", json!({"id": g.0}));
|
||||
let got = pick_theme(&mut s, json!({"picks": [{"at": [37, 17]}]}));
|
||||
assert_eq!(got, ["rgb 0 0 255"], "a click reaches the isolated blue square under the green one");
|
||||
let mut got = pick_theme(&mut s, json!({"picks": [{"rect": [5, 5, 60, 30]}]}));
|
||||
got.sort();
|
||||
assert_eq!(got, ["rgb 0 0 255", "rgb 255 0 0"], "a marquee leaves out the green square");
|
||||
}
|
||||
|
||||
/// Bad parameters are refused with an error naming them.
|
||||
#[test]
|
||||
fn theme_picker_refuses_bad_params() {
|
||||
let mut s = session();
|
||||
rect(&mut s, json!("#ff0000"));
|
||||
let many: Vec<Value> = (0..257).map(|_| json!({"at": [0, 0]})).collect();
|
||||
for (p, msg) in [
|
||||
(json!({}), "missing `picks`"),
|
||||
(json!({"picks": {"at": [0, 0]}}), "`picks` must be an array"),
|
||||
(json!({"picks": []}), "`picks` must hold 1 to 256 picks (got 0)"),
|
||||
(json!({"picks": many}), "`picks` must hold 1 to 256 picks (got 257)"),
|
||||
(json!({"picks": [{"at": [0]}]}), "`at` must be [x, y]"),
|
||||
(json!({"picks": [{"at": ["1", 2]}]}), "`at` must be [x, y]"),
|
||||
(json!({"picks": [{"rect": [0, 0, 10]}]}), "`rect` must be [x0, y0, x1, y1]"),
|
||||
(json!({"picks": [{"at": [0, 0], "rect": [0, 0, 1, 1]}]}), "each pick is"),
|
||||
(json!({"picks": [{"point": [0, 0]}]}), "each pick is"),
|
||||
(json!({"picks": [{"at": [0, 0]}], "colors": 0}), "`colors` must be between 1 and 64 (got 0)"),
|
||||
(json!({"picks": [{"at": [0, 0]}], "colors": 65}), "`colors` must be between 1 and 64 (got 65)"),
|
||||
(json!({"picks": [{"at": [0, 0]}], "colors": 2.5}), "`colors` must be a whole number"),
|
||||
] {
|
||||
let e = s.execute("recolor.pickTheme", &p).unwrap_err().to_string();
|
||||
assert!(e.contains(msg), "{p}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,13 @@
|
||||
//! Opened on colours alone (the Color Guide's Edit or Apply Colors without art), OK saves the new
|
||||
//! colours as a colour group named `groupName`.
|
||||
//!
|
||||
//! The Color Theme Picker button beside the tabs turns on picking from the canvas: a click on an
|
||||
//! object takes the palette of the object a Selection tool click takes, a drag takes the palette
|
||||
//! of the art inside its marquee, and Shift adds to the picks so far (`recolor.pickTheme`). The
|
||||
//! rows that aren't excluded take the picked colors as their new colors, the most used color first
|
||||
//! and repeating when the rows outnumber them, and the art previews them; OK keeps them as one
|
||||
//! undo step. A new count of Colors keeps the picks: the new rows take them again.
|
||||
//!
|
||||
//! Fields (`ui.dialog.set`): `rows` ([{from: [keys], to: key, exclude?, tint?: percent of a spot
|
||||
//! color}]), `colors` (null: Auto, or a count), `method`, `preserveWhite`, `preserveBlack`,
|
||||
//! `preserveGrays`, `limitTo` (library id, "document" for the document's swatches, "" for none),
|
||||
@@ -33,7 +40,9 @@
|
||||
//! a gray and Tint for a spot color; `global`: Global Adjust, on the Edit tab), `globalAdjust`
|
||||
//! (`{saturation, brightness, temperature, luminosity}` in percent, sent as `recolor.apply`'s
|
||||
//! `globalAdjust` in every slider mode), `display` (the Edit tab's `smooth`, `segmented` or `bars`;
|
||||
//! unset: `smooth`). Changing `colors`, a preserve flag or `limitTo` reduces the rows again.
|
||||
//! unset: `smooth`), `themePicker` (the Color Theme Picker is on), `themePicks` (its picks, as
|
||||
//! `recolor.pickTheme`'s `picks`; a new list recolors the rows). Changing `colors`, a preserve
|
||||
//! flag or `limitTo` reduces the rows again.
|
||||
|
||||
use egui::{Rect, Sense, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
@@ -142,7 +151,7 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
"preserveWhite": kept("preserveWhite", true), "preserveBlack": kept("preserveBlack", true),
|
||||
"preserveGrays": kept("preserveGrays", false), "limitTo": limit,
|
||||
"group": group, "groupName": if new_group { Some(NEW_GROUP) } else { group }, "tab": "assign", "rule": Harmony::Complementary.id(), "linked": true,
|
||||
"preview": true, "__art": art, "__assign": assign, "__newGroup": new_group,
|
||||
"preview": true, THEME_PICKER: false, THEME_PICKS: [], "__art": art, "__assign": assign, "__newGroup": new_group,
|
||||
});
|
||||
let mut d = Dialog::new(KIND, fields);
|
||||
// Without art the rows are there from the start: limit them now (art's rows snap as they reduce).
|
||||
@@ -203,6 +212,99 @@ fn sync(app: &mut VectorcraftApp, d: &mut Dialog) {
|
||||
Err(e) => app.status(e.to_string()),
|
||||
}
|
||||
d.fields.insert(DONE.into(), sig);
|
||||
// New rows take the colors picked with the Color Theme Picker again.
|
||||
d.fields.remove(THEME_DONE);
|
||||
}
|
||||
|
||||
/// The fields of the Color Theme Picker: whether a click or drag on the canvas picks colors, and
|
||||
/// the picks so far (`recolor.pickTheme`'s `picks`); the picks the rows last took them from.
|
||||
const THEME_PICKER: &str = "themePicker";
|
||||
const THEME_PICKS: &str = "themePicks";
|
||||
const THEME_DONE: &str = "__themePicked";
|
||||
|
||||
/// When the picks of the Color Theme Picker changed, give the rows that aren't excluded the
|
||||
/// palette of the art picked (`recolor.pickTheme`, as many colors as those rows), in row order;
|
||||
/// with fewer colors than rows, the colors repeat. Picks that touch no art leave the rows as they
|
||||
/// are.
|
||||
fn sync_theme(app: &mut VectorcraftApp, d: &mut Dialog) {
|
||||
let picks = d.fields.get(THEME_PICKS).cloned().unwrap_or(Value::Null);
|
||||
if !picks.as_array().is_some_and(|a| !a.is_empty()) || d.fields.get(THEME_DONE) == Some(&picks) {
|
||||
return;
|
||||
}
|
||||
d.fields.insert(THEME_DONE.into(), picks.clone());
|
||||
let mut rs = rows(d);
|
||||
let active = active_rows(&rs);
|
||||
if active.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The art as it is, not as the preview shows it; the preview runs again afterwards.
|
||||
if d.fields.remove(super::form::PREVIEWED).is_some() {
|
||||
let _ = app.session.cancel_interaction();
|
||||
}
|
||||
let colors = match app.session.execute("recolor.pickTheme", &json!({"picks": picks, "colors": active.len().min(64)})) {
|
||||
Ok(r) => r["colors"].as_array().map(|a| a.iter().filter_map(color_value).collect::<Vec<_>>()).unwrap_or_default(),
|
||||
Err(e) => return app.status(e.to_string()),
|
||||
};
|
||||
if colors.is_empty() {
|
||||
return;
|
||||
}
|
||||
for (n, &i) in active.iter().enumerate() {
|
||||
set_to(&mut rs[i], &colors[n % colors.len()]);
|
||||
}
|
||||
snap(app, d, &mut rs);
|
||||
d.fields.insert("rows".into(), Value::Array(rs));
|
||||
// The rows hold no slider edit.
|
||||
d.fields.remove(COMPS);
|
||||
}
|
||||
|
||||
/// The picks recolor.pickTheme takes at most; the oldest go first.
|
||||
const MAX_PICKS: usize = 256;
|
||||
|
||||
/// While the Color Theme Picker is on: a layer over the canvas, under the dialog, takes the clicks
|
||||
/// and drags there as picks (with Shift they add to the picks so far, else they replace them) and
|
||||
/// draws a drag's marquee, whose sides follow the document's axes.
|
||||
fn theme_overlay(app: &VectorcraftApp, ctx: &egui::Context, d: &mut Dialog) {
|
||||
let (Some(rect), Some(view)) = (app.canvas_rect, app.view()) else { return };
|
||||
let xf = crate::canvas::Xf::new(rect, view);
|
||||
let accent = Tokens::get(ctx).accent;
|
||||
let id = egui::Id::unique("recolor-theme-picker");
|
||||
let shown = egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).movable(false).show(ctx, |ui| {
|
||||
let resp = ui.allocate_rect(rect, Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::Crosshair);
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
// Where a press on the canvas began, kept from the first frame that sees it for the frame
|
||||
// that releases it (the pointer forgets it then).
|
||||
let from = egui::Id::unique("recolor-theme-drag");
|
||||
if let Some(o) = ui.input(|i| i.pointer.press_origin()).filter(|o| rect.contains(*o)) {
|
||||
ui.data_mut(|m| m.insert_temp(from, o));
|
||||
}
|
||||
if resp.clicked() {
|
||||
ui.data_mut(|m| m.remove::<egui::Pos2>(from));
|
||||
let p = xf.to_doc(resp.interact_pointer_pos().or(ui.input(|i| i.pointer.latest_pos()))?);
|
||||
return Some((json!({"at": [p.x, p.y]}), shift));
|
||||
}
|
||||
if !(resp.dragged() || resp.drag_stopped()) {
|
||||
return None;
|
||||
}
|
||||
let a = ui.data(|m| m.get_temp::<egui::Pos2>(from))?;
|
||||
let b = ui.input(|i| i.pointer.latest_pos());
|
||||
let (a, b) = (xf.to_doc(a), xf.to_doc(b?));
|
||||
if resp.drag_stopped() {
|
||||
ui.data_mut(|m| m.remove::<egui::Pos2>(from));
|
||||
return Some((json!({"rect": [a.x, a.y, b.x, b.y]}), shift));
|
||||
}
|
||||
let corners = [(a.x, a.y), (b.x, a.y), (b.x, b.y), (a.x, b.y), (a.x, a.y)];
|
||||
let pts: Vec<egui::Pos2> = corners.iter().map(|&(x, y)| xf.to_screen(vectorcraft_geom::Point::new(x, y))).collect();
|
||||
ui.painter().with_clip_rect(rect).extend(egui::Shape::dashed_line(&pts, egui::Stroke::new(1.0, accent), 4.0, 3.0));
|
||||
None
|
||||
});
|
||||
// Above the backdrop of the dialog (which a click elsewhere raises), still under its window.
|
||||
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id));
|
||||
let Some((pick, add)) = shown.inner else { return };
|
||||
let mut picks = if add { d.fields.get(THEME_PICKS).and_then(Value::as_array).cloned().unwrap_or_default() } else { vec![] };
|
||||
picks.push(pick);
|
||||
let extra = picks.len().saturating_sub(MAX_PICKS);
|
||||
picks.drain(..extra);
|
||||
d.fields.insert(THEME_PICKS.into(), Value::Array(picks));
|
||||
}
|
||||
|
||||
/// With Limit to Library, the rows' new colours snap to the library's nearest colours.
|
||||
@@ -293,7 +395,9 @@ fn apply_params(d: &Dialog) -> Value {
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
sync(app, d);
|
||||
sync_theme(app, d);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let picking = d.bool(THEME_PICKER);
|
||||
ui.horizontal(|ui| {
|
||||
let tab = d.str("tab");
|
||||
for (id, label) in [("assign", tl!("Assign")), ("edit", tl!("Edit"))] {
|
||||
@@ -301,6 +405,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
d.fields.insert("tab".into(), json!(id));
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::icon_button(ui, "pipette", tl!("Color Theme Picker"), picking, 24.0).clicked() {
|
||||
d.fields.insert(THEME_PICKER.into(), json!(!picking));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// The label is drawn after the menu (right-to-left), so it cannot name it by adjacency.
|
||||
if let Some(i) = widgets::dropdown_named(ui, "recolor-preset", Some(tl!("Preset:")), PRESETS[preset_of(d)], &PRESETS, 130.0) {
|
||||
@@ -311,6 +419,12 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
});
|
||||
widgets::divider(ui);
|
||||
ui.add_space(4.0);
|
||||
if d.bool(THEME_PICKER) {
|
||||
widgets::dim_label(ui, tl!("Click or drag across the art to pick its colors; Shift adds more"));
|
||||
ui.add_space(4.0);
|
||||
theme_overlay(app, ui.ctx(), d);
|
||||
sync_theme(app, d);
|
||||
}
|
||||
let mut rs = rows(d);
|
||||
let top = ui.cursor().top();
|
||||
let (changed, pick) = if d.str("tab") == "edit" { (edit_tab(app, ui, d, &mut rs), None) } else { assign_tab(app, ui, d, &mut rs) };
|
||||
@@ -359,6 +473,7 @@ pub(super) fn with_new_color(app: &VectorcraftApp, mut d: Dialog, i: usize, c: C
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let mut d = d.clone();
|
||||
sync(app, &mut d);
|
||||
sync_theme(app, &mut d);
|
||||
if d.bool("__newGroup") {
|
||||
// The new colors with the Global Adjust, snapped to Limit to Library, as `recolor.apply`
|
||||
// gives them to a group.
|
||||
@@ -1408,6 +1523,137 @@ mod tests {
|
||||
assert!(to.as_str().is_some_and(|k| k.starts_with("cmyk ")), "a color picked as RGB is CMYK in a CMYK document: {to}");
|
||||
}
|
||||
|
||||
/// A 15 pt square at (`x`, 10) filled with `fill`, without a stroke.
|
||||
fn square(app: &mut VectorcraftApp, x: f64, fill: &str) -> u64 {
|
||||
let id = app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 15, "height": 15})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("paint.setFill", json!({"ids": [id], "color": fill})).unwrap();
|
||||
app.run("paint.setStroke", json!({"ids": [id], "none": true})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn fill_hex(app: &VectorcraftApp, id: u64) -> String {
|
||||
let doc = &app.session.doc().unwrap().doc;
|
||||
doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill().and_then(|f| f.paint.color()).unwrap().to_hex()
|
||||
}
|
||||
|
||||
/// Red and blue squares selected, and green and yellow squares beside them; Recolor Artwork
|
||||
/// open on the selection with the Exact method, one frame drawn.
|
||||
fn theme_setup(app: &mut VectorcraftApp, ctx: &egui::Context) -> [u64; 2] {
|
||||
app.run("file.new", json!({"width": 300, "height": 100})).unwrap();
|
||||
let art = [square(app, 10.0, "#ff0000"), square(app, 40.0, "#0000ff")];
|
||||
square(app, 100.0, "#00ff00");
|
||||
square(app, 130.0, "#ffff00");
|
||||
app.run("select.set", json!({"ids": art})).unwrap();
|
||||
app.run("ui.recolorDialog", json!({})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("method".into(), json!(Method::Exact.id()));
|
||||
crate::theme::install_fonts(ctx);
|
||||
frame(app, ctx, 0.0, vec![], 0);
|
||||
art
|
||||
}
|
||||
|
||||
fn new_colors(app: &VectorcraftApp) -> Vec<Value> {
|
||||
super::rows(app.ui.dialog.as_ref().unwrap()).iter().map(|r| r["to"].clone()).collect()
|
||||
}
|
||||
|
||||
/// The Color Theme Picker's picks (the `themePicks` field) give the rows that aren't excluded
|
||||
/// the palette of the art picked, in row order, repeating it when the rows outnumber its
|
||||
/// colors, and the art previews them. Picks that touch no art leave the rows as they are, and
|
||||
/// OK keeps the recolor as one undo step.
|
||||
#[test]
|
||||
fn theme_picks_give_the_rows_the_palette_of_the_art_picked() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
let [red, blue] = theme_setup(&mut app, &ctx);
|
||||
let mut start = new_colors(&app);
|
||||
start.sort_by_key(Value::to_string);
|
||||
assert_eq!(start, [json!("rgb 0 0 255"), json!("rgb 255 0 0")]);
|
||||
// The red art's row and the blue art's row.
|
||||
let row_of = |app: &VectorcraftApp, key: &str| super::rows(app.ui.dialog.as_ref().unwrap()).iter().position(|r| r["from"][0] == key).unwrap();
|
||||
let (r, b) = (row_of(&app, "rgb 255 0 0"), row_of(&app, "rgb 0 0 255"));
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
let set = |app: &mut VectorcraftApp, picks: Value| {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(THEME_PICKS.into(), picks);
|
||||
};
|
||||
set(&mut app, json!([{"at": [107, 17]}]));
|
||||
frame(&mut app, &ctx, 0.1, vec![], 0);
|
||||
assert_eq!(new_colors(&app), [json!("rgb 0 255 0"), json!("rgb 0 255 0")], "one color for both rows");
|
||||
assert_eq!((fill_hex(&app, red), fill_hex(&app, blue)), ("#00ff00".into(), "#00ff00".into()), "the art previews it");
|
||||
set(&mut app, json!([{"at": [107, 17]}, {"rect": [125, 5, 150, 30]}]));
|
||||
frame(&mut app, &ctx, 0.2, vec![], 0);
|
||||
assert_eq!(new_colors(&app), [json!("rgb 0 255 0"), json!("rgb 255 255 0")], "a pick added: two colors");
|
||||
// An excluded row (the red art's) keeps its new color.
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
let mut rs = super::rows(d);
|
||||
rs[r]["exclude"] = json!(true);
|
||||
d.fields.insert("rows".into(), Value::Array(rs));
|
||||
let kept = new_colors(&app)[r].clone();
|
||||
set(&mut app, json!([{"at": [47, 17]}]));
|
||||
frame(&mut app, &ctx, 0.3, vec![], 0);
|
||||
let after = new_colors(&app);
|
||||
assert_eq!((&after[r], &after[b]), (&kept, &json!("rgb 0 0 255")));
|
||||
set(&mut app, json!([{"at": [250, 80]}]));
|
||||
frame(&mut app, &ctx, 0.4, vec![], 0);
|
||||
assert_eq!(new_colors(&app), after, "empty canvas: the rows stay");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "nothing is committed before OK");
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!((fill_hex(&app, red), fill_hex(&app, blue)), ("#ff0000".into(), "#0000ff".into()), "the excluded row kept red");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
}
|
||||
|
||||
/// One frame with `modifiers` held.
|
||||
fn frame_with(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec<egui::Event>, modifiers: egui::Modifiers) {
|
||||
let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect();
|
||||
let input = egui::RawInput { time: Some(time), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// With the Color Theme Picker on, a click on the canvas picks the object there and a Shift-drag
|
||||
/// adds the art inside its marquee; with it off the dialog takes no picks from the canvas.
|
||||
#[test]
|
||||
fn the_theme_picker_takes_clicks_and_shift_drags_on_the_canvas() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
let [red, _] = theme_setup(&mut app, &ctx);
|
||||
// Clear of the dialog, which centers itself in the screen.
|
||||
let canvas = Rect::from_min_size(pos2(-1000.0, -1000.0), vec2(3000.0, 3000.0));
|
||||
app.canvas_rect = Some(canvas);
|
||||
let xf = crate::canvas::Xf::new(canvas, app.view().unwrap());
|
||||
let at = |x: f64, y: f64| xf.to_screen(vectorcraft_geom::Point::new(x, y));
|
||||
let button = |pos, pressed, modifiers| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||||
let none = egui::Modifiers::NONE;
|
||||
let click = |app: &mut VectorcraftApp, pos: egui::Pos2, t: f64| {
|
||||
frame_with(app, &ctx, t, vec![egui::Event::PointerMoved(pos), button(pos, true, none)], none);
|
||||
frame_with(app, &ctx, t + 0.05, vec![button(pos, false, none)], none);
|
||||
frame_with(app, &ctx, t + 0.1, vec![], none);
|
||||
};
|
||||
let green = at(107.0, 17.0);
|
||||
assert!(canvas.contains(green), "{green:?}");
|
||||
click(&mut app, green, 1.0);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get(THEME_PICKS), Some(&json!([])), "off: no picks");
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get(THEME_PICKER), Some(&json!(false)), "the fields `ui.dialog.set` can set");
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(THEME_PICKER.into(), json!(true));
|
||||
for n in 0..5 {
|
||||
frame_with(&mut app, &ctx, 2.0 + f64::from(n) * 0.01, vec![], none);
|
||||
}
|
||||
click(&mut app, green, 3.0);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields[THEME_PICKS].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(new_colors(&app), [json!("rgb 0 255 0"), json!("rgb 0 255 0")]);
|
||||
assert_eq!(fill_hex(&app, red), "#00ff00", "the art previews the picked color");
|
||||
// A Shift-drag around the yellow square.
|
||||
let shift = egui::Modifiers::SHIFT;
|
||||
let (a, b) = (at(125.0, 5.0), at(150.0, 30.0));
|
||||
frame_with(&mut app, &ctx, 4.0, vec![egui::Event::PointerMoved(a), button(a, true, shift)], shift);
|
||||
frame_with(&mut app, &ctx, 4.05, vec![egui::Event::PointerMoved(a + (b - a) * 0.5)], shift);
|
||||
frame_with(&mut app, &ctx, 4.1, vec![egui::Event::PointerMoved(b)], shift);
|
||||
frame_with(&mut app, &ctx, 4.15, vec![button(b, false, shift)], shift);
|
||||
frame_with(&mut app, &ctx, 4.2, vec![], none);
|
||||
let picks = app.ui.dialog.as_ref().unwrap().fields[THEME_PICKS].clone();
|
||||
assert_eq!(picks.as_array().map(Vec::len), Some(2), "{picks}");
|
||||
assert!(picks[1].get("rect").is_some(), "{picks}");
|
||||
assert_eq!(new_colors(&app), [json!("rgb 0 255 0"), json!("rgb 255 255 0")]);
|
||||
}
|
||||
|
||||
/// In a CMYK document colors opened alone are saved as a new color group in their own model.
|
||||
#[test]
|
||||
fn a_new_group_from_colors_keeps_their_model_in_a_cmyk_document() {
|
||||
|
||||
@@ -2795,6 +2795,8 @@ digits Malayalam Малаялам
|
||||
Reset Rotate View Нулиране на изгледа на завъртане
|
||||
Rotate View to Selection Завъртане на изгледа спрямо селекцията
|
||||
Reset the rows to the artwork's colors Нулиране на редовете към цветовете на илюстрацията
|
||||
Color Theme Picker Избор на цветова тема
|
||||
Click or drag across the art to pick its colors; Shift adds more Щракнете или плъзнете през илюстрацията, за да вземете цветовете ѝ; Shift добавя още
|
||||
Reset to Default Връщане към стандартните
|
||||
Reset to White, Black Нулиране към бяло, черно
|
||||
Reset with Mesh Нулиране с мрежа
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2823,6 +2823,8 @@ digits Malayalam Malajalam
|
||||
Randomly change saturation and brightness Nasumično promijeni zasićenje i svjetlinu
|
||||
Recolor this row Ponovo oboji ovaj red
|
||||
Reset the rows to the artwork's colors Vrati redove na boje crteža
|
||||
Color Theme Picker Birač teme boja
|
||||
Click or drag across the art to pick its colors; Shift adds more Kliknite ili prevucite preko crteža da uzmete njegove boje; Shift dodaje još
|
||||
Separate colors into rows Razdvoji boje u redove
|
||||
Unlink harmony colors Prekini vezu boja harmonije
|
||||
1 Color Job Posao s 1 bojom
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3573,6 +3573,8 @@ digits Malayalam Malaiàlam
|
||||
Reset Rotate View Restableix la rotació de la vista
|
||||
Reset Ruler Origin Restableix l’origen de les regles
|
||||
Reset the rows to the artwork's colors Restableix les files als colors de l’obra
|
||||
Color Theme Picker Selector de tema de colors
|
||||
Click or drag across the art to pick its colors; Shift adds more Feu clic o arrossegueu sobre l’obra per agafar-ne els colors; Maj n’afegeix més
|
||||
Reset to Default Restableix el valor predeterminat
|
||||
Reset to default Restableix el valor predeterminat
|
||||
Reset to Defaults Restableix els valors predeterminats
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2859,6 +2859,8 @@ digits Malayalam Malajálamské
|
||||
Randomly change saturation and brightness Náhodně změnit sytost a jas
|
||||
Recolor this row Přebarvit tento řádek
|
||||
Reset the rows to the artwork's colors Obnovit řádky na barvy kresby
|
||||
Color Theme Picker Výběr barevného motivu
|
||||
Click or drag across the art to pick its colors; Shift adds more Klepnutím nebo tažením přes kresbu převezmete její barvy; Shift přidá další
|
||||
Separate colors into rows Rozdělit barvy do řádků
|
||||
Unlink harmony colors Zrušit propojení barev harmonie
|
||||
1 Color Job Zakázka s 1 barvou
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2643,6 +2643,8 @@ digits Malayalam Malayalam
|
||||
Randomly change saturation and brightness Ændr mætning og lysstyrke tilfældigt
|
||||
Recolor this row Omfarv denne række
|
||||
Reset the rows to the artwork's colors Nulstil rækkerne til grafikkens farver
|
||||
Color Theme Picker Farvetemavælger
|
||||
Click or drag across the art to pick its colors; Shift adds more Klik eller træk hen over grafikken for at hente dens farver; Skift tilføjer flere
|
||||
Separate colors into rows Opdel farver i rækker
|
||||
Unlink harmony colors Fjern sammenkædning af harmonifarver
|
||||
1 Color Job 1-farvejob
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2841,6 +2841,8 @@ digits Malayalam Malayalam
|
||||
Reset Rotate View Ansichtsdrehung zurücksetzen
|
||||
Rotate View to Selection Ansicht nach Auswahl drehen
|
||||
Reset the rows to the artwork's colors Zeilen auf die Farben der Grafik zurücksetzen
|
||||
Color Theme Picker Farbthema-Auswahl
|
||||
Click or drag across the art to pick its colors; Shift adds more Klicken oder ziehen Sie über die Grafik, um ihre Farben aufzunehmen; Umschalt fügt weitere hinzu
|
||||
Reset to Default Auf Standard zurücksetzen
|
||||
Reset to White, Black Auf Weiß, Schwarz zurücksetzen
|
||||
Reset with Mesh Mit Gitter zurücksetzen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3478,6 +3478,8 @@ digits Malayalam Μαλαγιάλαμ
|
||||
Reset Rotate View Επαναφορά περιστροφής προβολής
|
||||
Rotate View to Selection Περιστροφή προβολής ως προς την επιλογή
|
||||
Reset the rows to the artwork's colors Επαναφορά γραμμών στα χρώματα του σχεδίου
|
||||
Color Theme Picker Επιλογέας χρωματικού θέματος
|
||||
Click or drag across the art to pick its colors; Shift adds more Κάντε κλικ ή σύρετε πάνω στο σχέδιο για να πάρετε τα χρώματά του· το Shift προσθέτει κι άλλα
|
||||
Reset to White, Black Επαναφορά σε λευκό και μαύρο
|
||||
Reset with Mesh Επαναφορά με πλέγμα
|
||||
Reset with Mesh… Επαναφορά με πλέγμα…
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2874,6 +2874,8 @@ digits Malayalam Malayálam
|
||||
Reset Rotate View Restablecer rotación de vista
|
||||
Rotate View to Selection Rotar vista según la selección
|
||||
Reset the rows to the artwork's colors Restablecer las filas a los colores de la ilustración
|
||||
Color Theme Picker Selector de tema de color
|
||||
Click or drag across the art to pick its colors; Shift adds more Haga clic o arrastre sobre la ilustración para tomar sus colores; Mayús añade más
|
||||
Reset to Default Restablecer valores predeterminados
|
||||
Reset to White, Black Restablecer a blanco, negro
|
||||
Reset with Mesh Restablecer con malla
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2803,6 +2803,8 @@ digits Malayalam Malajalam
|
||||
Randomly change saturation and brightness Muuta kylläisyyttä ja kirkkautta satunnaisesti
|
||||
Recolor this row Väritä tämä rivi uudelleen
|
||||
Reset the rows to the artwork's colors Palauta rivit kuvituksen väreihin
|
||||
Color Theme Picker Väriteeman valitsin
|
||||
Click or drag across the art to pick its colors; Shift adds more Napsauta tai vedä kuvituksen yli poimiaksesi sen värit; Vaihto lisää useampia
|
||||
Separate colors into rows Erottele värit riveille
|
||||
Unlink harmony colors Poista harmoniavärien linkitys
|
||||
1 Color Job 1 värin työ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2866,6 +2866,8 @@ digits Malayalam Malayalam
|
||||
Reset Rotate View Réinitialiser la rotation de l’affichage
|
||||
Rotate View to Selection Faire pivoter l’affichage selon la sélection
|
||||
Reset the rows to the artwork's colors Réinitialiser les lignes avec les couleurs de l’illustration
|
||||
Color Theme Picker Sélecteur de thème de couleurs
|
||||
Click or drag across the art to pick its colors; Shift adds more Cliquez ou faites glisser sur l’illustration pour prélever ses couleurs ; Maj en ajoute d’autres
|
||||
Reset to Default Rétablir les valeurs par défaut
|
||||
Reset to White, Black Réinitialiser à blanc, noir
|
||||
Reset with Mesh Réinitialiser avec un filet
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3904,6 +3904,8 @@ antiAlias Sharp शार्प
|
||||
Reset Text Transform टेक्स्ट ट्रांसफॉर्मर को रीसेट करें
|
||||
Reset Text Transform and Scale टेक्स्ट ट्रांसफॉर्म और स्केल को रीसेट करें
|
||||
Reset the rows to the artwork's colors आर्टवर्क के रंग के लिए पंक्तियों को रीसेट करें
|
||||
Color Theme Picker रंग थीम पिकर
|
||||
Click or drag across the art to pick its colors; Shift adds more आर्टवर्क के रंग लेने के लिए उस पर क्लिक करें या खींचें; Shift और जोड़ता है
|
||||
Reset to default डिफ़ॉल्ट पर रीसेट करें
|
||||
Reset to Default डिफ़ॉल्ट पर रीसेट करें
|
||||
Reset to Defaults डिफ़ॉल्ट पर रीसेट करें
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -3425,6 +3425,8 @@ mark-style Japanese Japán
|
||||
Reset Rotate View Nézetforgatás visszaállítása
|
||||
Rotate View to Selection Nézet elforgatása a kijelöléshez
|
||||
Reset the rows to the artwork's colors Sorok visszaállítása a grafika színeire
|
||||
Color Theme Picker Színtéma-választó
|
||||
Click or drag across the art to pick its colors; Shift adds more Kattintson vagy húzzon a grafikán a színei felvételéhez; a Shift továbbiakat ad hozzá
|
||||
Reset to default Alapérték visszaállítása
|
||||
Reset to Default Alapérték visszaállítása
|
||||
Reset to Defaults Alapértékek visszaállítása
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2643,6 +2643,8 @@ digits Malayalam Malayalam
|
||||
Randomly change saturation and brightness Ubah saturasi dan kecerahan secara acak
|
||||
Recolor this row Warnai ulang baris ini
|
||||
Reset the rows to the artwork's colors Setel ulang baris ke warna karya seni
|
||||
Color Theme Picker Pemilih tema warna
|
||||
Click or drag across the art to pick its colors; Shift adds more Klik atau seret melintasi karya seni untuk mengambil warnanya; Shift menambahkan lagi
|
||||
Separate colors into rows Pisahkan warna ke dalam baris
|
||||
Unlink harmony colors Lepaskan tautan warna harmoni
|
||||
1 Color Job Pekerjaan 1 warna
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2388 and column 5.
|
@@ -2917,6 +2917,8 @@ digits Malayalam Malayalam
|
||||
Reset Rotate View Reimposta rotazione vista
|
||||
Rotate View to Selection Ruota vista in base alla selezione
|
||||
Reset the rows to the artwork's colors Reimposta le righe sui colori della grafica
|
||||
Color Theme Picker Selettore tema colore
|
||||
Click or drag across the art to pick its colors; Shift adds more Fai clic o trascina sulla grafica per prelevarne i colori; Maiusc ne aggiunge altri
|
||||
Reset to Default Reimposta valori predefiniti
|
||||
Reset to White, Black Reimposta su bianco, nero
|
||||
Reset with Mesh Reimposta con trama
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2922,6 +2922,8 @@ mark-style Japanese 日本式
|
||||
No settings found 設定が見つかりません
|
||||
Reset Profiles プロファイルをリセット
|
||||
Reset the rows to the artwork's colors 行をアートワークのカラーにリセット
|
||||
Color Theme Picker カラーテーマピッカー
|
||||
Click or drag across the art to pick its colors; Shift adds more アートワークをクリックまたはドラッグしてカラーを取得します。Shift キーで追加します
|
||||
Reset to Default 初期設定に戻す
|
||||
Reset to White, Black 白、黒にリセット
|
||||
Reset {name} {name}をリセット
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2560,6 +2560,8 @@ digits Malayalam მალაიალამური
|
||||
Randomly change saturation and brightness გაჯერებისა და სიკაშკაშის შემთხვევითი შეცვლა
|
||||
Recolor this row ამ სტრიქონის ხელახლა შეფერადება
|
||||
Reset the rows to the artwork's colors სტრიქონებისთვის ნახატის ფერების დაბრუნება
|
||||
Color Theme Picker ფერთა თემის ამრჩევი
|
||||
Click or drag across the art to pick its colors; Shift adds more დააწკაპუნეთ ან გადაატარეთ ნახატზე მისი ფერების ასაღებად; Shift ამატებს კიდევ
|
||||
Separate colors into rows ფერების სტრიქონებად დაყოფა
|
||||
Unlink harmony colors ჰარმონიის ფერების კავშირის გაწყვეტა
|
||||
1 Color Job 1-ფერიანი ბეჭდვა
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2911,6 +2911,8 @@ mark-style Japanese 일본식
|
||||
No settings found 설정을 찾을 수 없음
|
||||
Reset Profiles 프로필 재설정
|
||||
Reset the rows to the artwork's colors 행을 아트웍 색상으로 재설정
|
||||
Color Theme Picker 색상 테마 피커
|
||||
Click or drag across the art to pick its colors; Shift adds more 아트웍을 클릭하거나 드래그하여 색상을 가져옵니다. Shift 키로 더 추가합니다
|
||||
Reset to Default 기본값으로 재설정
|
||||
Reset to White, Black 흰색, 검정으로 재설정
|
||||
Reset {name} {name} 재설정
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2657,6 +2657,8 @@ digits Malayalam Malayalam
|
||||
Randomly change saturation and brightness Verzadiging en helderheid willekeurig wijzigen
|
||||
Recolor this row Deze rij opnieuw kleuren
|
||||
Reset the rows to the artwork's colors De rijen herstellen naar de kleuren van de artwork
|
||||
Color Theme Picker Kleurthemakiezer
|
||||
Click or drag across the art to pick its colors; Shift adds more Klik of sleep over het artwork om de kleuren ervan op te nemen; Shift voegt er meer toe
|
||||
Separate colors into rows Kleuren over rijen verdelen
|
||||
Unlink harmony colors Harmoniekleuren ontkoppelen
|
||||
1 Color Job Opdracht in 1 kleur
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2768,6 +2768,8 @@ digits Malayalam Malajalam
|
||||
Randomly change saturation and brightness Losowo zmień nasycenie i jasność
|
||||
Recolor this row Zmień kolory tego wiersza
|
||||
Reset the rows to the artwork's colors Przywróć w wierszach kolory kompozycji
|
||||
Color Theme Picker Próbnik motywu kolorów
|
||||
Click or drag across the art to pick its colors; Shift adds more Kliknij lub przeciągnij po kompozycji, aby pobrać jej kolory; Shift dodaje kolejne
|
||||
Separate colors into rows Rozdziel kolory na wiersze
|
||||
Unlink harmony colors Odłącz kolory harmonii
|
||||
1 Color Job Praca 1-kolorowa
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2412,6 +2412,8 @@ digits Malayalam Malaiala
|
||||
Reset Rotate View Redefinir Visualização Girada
|
||||
Rotate View to Selection Girar Visualização para a Seleção
|
||||
Reset the rows to the artwork's colors Redefinir as linhas para as cores da arte
|
||||
Color Theme Picker Seletor de Tema de Cores
|
||||
Click or drag across the art to pick its colors; Shift adds more Clique ou arraste sobre a arte para pegar suas cores; Shift adiciona mais
|
||||
Reset to Default Redefinir para o Padrão
|
||||
Reset to White, Black Redefinir para Branco, Preto
|
||||
Reset with Mesh… Redefinir com Malha…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2904,6 +2904,8 @@ digits Malayalam Малаялам
|
||||
Reset Rotate View Сбросить поворот вида
|
||||
Rotate View to Selection Повернуть вид по выделению
|
||||
Reset the rows to the artwork's colors Сбросить строки к цветам рисунка
|
||||
Color Theme Picker Выбор цветовой темы
|
||||
Click or drag across the art to pick its colors; Shift adds more Щёлкните или проведите по рисунку, чтобы взять его цвета; Shift добавляет ещё
|
||||
Reset to Default Сбросить к значениям по умолчанию
|
||||
Reset to White, Black Сбросить к белому, чёрному
|
||||
Reset with Mesh Сбросить с сеткой
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2810,6 +2810,8 @@ digits Malayalam Malayalam
|
||||
Reset Rotate View Återställ Rotera vy
|
||||
Rotate View to Selection Rotera vyn efter markeringen
|
||||
Reset the rows to the artwork's colors Återställ raderna till teckningens färger
|
||||
Color Theme Picker Färgtemaväljare
|
||||
Click or drag across the art to pick its colors; Shift adds more Klicka eller dra över teckningen för att hämta dess färger; Skift lägger till fler
|
||||
Reset to Default Återställ till standard
|
||||
Reset to White, Black Återställ till vitt, svart
|
||||
Reset with Mesh Återställ med nät
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -2679,6 +2679,8 @@ digits Malayalam Malayalam
|
||||
Reset Rotate View Döndürme Görünümünü Sıfırla
|
||||
Rotate View to Selection Görünümü Seçime Döndür
|
||||
Reset the rows to the artwork's colors Satırları resmin renklerine göre sıfırlayın
|
||||
Color Theme Picker Renk Teması Seçici
|
||||
Click or drag across the art to pick its colors; Shift adds more Renklerini almak için resme tıklayın veya üzerinde sürükleyin; Shift daha fazlasını ekler
|
||||
Reset to Default Varsayılana Sıfırla
|
||||
Reset to White, Black Beyaz, Siyah'a Sıfırla
|
||||
Reset with Mesh Mesh ile Sıfırla
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -2909,6 +2909,8 @@ digits Malayalam Малаялам
|
||||
Reset Rotate View Скинути поворот виду
|
||||
Rotate View to Selection Повернути вид за виділенням
|
||||
Reset the rows to the artwork's colors Скинути рядки до кольорів малюнка
|
||||
Color Theme Picker Вибір колірної теми
|
||||
Click or drag across the art to pick its colors; Shift adds more Клацніть або проведіть по малюнку, щоб узяти його кольори; Shift додає ще
|
||||
Reset to Default Скинути до значень за замовчуванням
|
||||
Reset to White, Black Скинути до білого, чорного
|
||||
Reset with Mesh Скинути із сіткою
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 182 and column 5.
|
@@ -3198,6 +3198,8 @@ mark-style Japanese Kiểu Nhật
|
||||
Rotate View to Selection Xoay chế độ xem theo vùng chọn
|
||||
Reset Ruler Origin Đặt lại gốc thước
|
||||
Reset the rows to the artwork's colors Đặt lại các hàng về màu của tác phẩm
|
||||
Color Theme Picker Bộ chọn bộ màu
|
||||
Click or drag across the art to pick its colors; Shift adds more Nhấp hoặc kéo qua tác phẩm để lấy màu của nó; Shift để thêm
|
||||
Reset to Default Đặt lại về mặc định
|
||||
Reset to default Đặt lại về mặc định
|
||||
Reset to White, Black Đặt lại về Trắng, Đen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -2774,6 +2774,8 @@ digits Malayalam 马拉雅拉姆数字
|
||||
Reset Rotate View 重置旋转视图
|
||||
Rotate View to Selection 按所选对象旋转视图
|
||||
Reset the rows to the artwork's colors 将各行重置为图稿的颜色
|
||||
Color Theme Picker 色彩主题拾取器
|
||||
Click or drag across the art to pick its colors; Shift adds more 单击或拖过图稿以拾取其颜色;按住 Shift 可添加更多
|
||||
Reset to Default 恢复默认
|
||||
Reset to White, Black 重置为白、黑
|
||||
Reset with Mesh 以网格重置
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3882,6 +3882,8 @@ digits Malayalam 馬來亞拉姆數字
|
||||
Reset Rotate View 重設旋轉檢視
|
||||
Rotate View to Selection 依選取範圍旋轉檢視
|
||||
Reset the rows to the artwork's colors 將各列重設為圖稿的顏色
|
||||
Color Theme Picker 色彩主題選取器
|
||||
Click or drag across the art to pick its colors; Shift adds more 按一下或拖過圖稿以選取其顏色;按住 Shift 可加入更多
|
||||
Reset to Default 重設為預設值
|
||||
Reset to White, Black 重設為白色、黑色
|
||||
Reset with Mesh 以網格重設
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
+21
@@ -3431,6 +3431,27 @@ the library's colors. Colors that no row holds, such as preserved White and Blac
|
||||
{"name":"run_command","arguments":{"command":"recolor.apply","params":{"map":{"#ff0000":"#ff0000","#0000ff":"#0000ff"},"globalAdjust":{"saturation":-40,"temperature":25}}}}
|
||||
```
|
||||
|
||||
`recolor.pickTheme {picks, colors?}` is the dialog's Color Theme Picker: it returns the color palette of the art picked
|
||||
as `colors` (keys, the most used first) and `objects` (how many objects it read), and changes nothing. Each pick is
|
||||
`{at: [x, y]}`, a click that reads the object a Selection tool click there takes with everything inside it, or
|
||||
`{rect: [x0, y0, x1, y1]}`, a marquee that reads every painted object it touches and the part of each image inside it
|
||||
(document points; 1 to 256 picks, which add up). It reads 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 color of an image it applies to and the pixels of other images: 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, 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
|
||||
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
|
||||
most used color in its own model. Picks that touch no art return no colors. In the Recolor Artwork dialog the picks are the `themePicks` field: the rows that aren't
|
||||
excluded take the palette as their new colors in row order, the colors repeating when the rows outnumber them.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"recolor.pickTheme","params":{"picks":[{"at":[120,80]},{"rect":[200,40,320,140]}],"colors":4}}}
|
||||
```
|
||||
|
||||
The `recolor.*` commands refuse parameters of the wrong form. A `limitTo` or `method` that is not a string gets the
|
||||
error for an unknown library or method. Other wrong-form parameters get an error that names them.
|
||||
`includeImages`, `includePatterns`, a row's `exclude`, `order`, `saturationBrightness` and the `white`, `black` and
|
||||
|
||||
@@ -478,6 +478,8 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
|
||||
|
||||
- [x] Recolor Artwork › Assign: double-click a row's new color to pick it in the Color Picker, in the model it was picked in (part of #4028); a Lab new color is kept but can't be typed yet (the picker's Lab fields work in RGB); Lab entry, swatch libraries and the row menu remain.
|
||||
|
||||
- [x] Recolor Artwork › Color Theme Picker: with the button beside the tabs on, a click on the canvas takes the color palette of an object or image, a drag takes the palette of the art inside its marquee, and Shift adds more picks; the rows that aren't excluded take the palette as their new colors and the art previews them ([Recolor artwork](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/recolor-artwork.html)). The number and order of the picked colors are VectorCraft's own rule (`recolor.pickTheme`). Tests: `theme_picks_give_the_rows_the_palette_of_the_art_picked` and `the_theme_picker_takes_clicks_and_shift_drags_on_the_canvas` in `crates/ui-egui/src/dialogs/recolor.rs`, `theme_picker_*` in `crates/engine/src/tests/recolor.rs`.
|
||||
|
||||
- [x] Recolor Artwork Edit color chips scroll within the screen, preserving the wheel and actions (#3416); 5, 20, 200 and 400 colors, resizing and UI scaling, final-chip editing and preview/history covered by egui_kittest.
|
||||
|
||||
- [x] Status messages and bounded readouts remain stable as values, canvas hover and background jobs change; ordinary values fit all registered languages and units, and optional groups yield in narrow windows (regressions; contributor rebuilt and confirmed the original-file status-bar scenario on 2026-10-19). Sample widths are cached per UI and refreshed only when language, document units or pixels per point changes.
|
||||
|
||||
Reference in New Issue
Block a user