diff --git a/ROADMAP.md b/ROADMAP.md index 75d3bc2a..64086b0f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,7 +109,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes. - **Geometry and effects:** - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. - - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). + - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). - **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. - **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). @@ -145,7 +145,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 | -| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | +| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 49126d59..3e2ed68f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -24,6 +24,7 @@ pub mod profiles; pub mod range; pub mod rastersettings; mod reach; +pub mod recolor; pub mod selection; pub mod setup; pub mod slices; diff --git a/crates/doc/src/recolor.rs b/crates/doc/src/recolor.rs new file mode 100644 index 00000000..715c1aa9 --- /dev/null +++ b/crates/doc/src/recolor.rs @@ -0,0 +1,111 @@ +//! The colour visitor that recolouring shares: Edit Colors, Recolor Artwork and the live colour +//! adjustment effects walk an object's colours through [`recolor_node`] and decide what each +//! colour, mesh point and embedded image becomes through a [`ColorVisitor`]. + +use std::sync::Arc; + +use vectorcraft_color::{Color, Paint}; + +use crate::{AppearanceItem, Node, NodeKind}; + +/// What a recolouring pass does to the colours it meets. +pub trait ColorVisitor { + /// A fill's or stroke's paint (an object's, or a text run's): true when it changed. + fn paint(&mut self, p: &mut Paint) -> bool; + /// One gradient-mesh point's colour. + fn color(&mut self, c: Color) -> Color; + /// The key of embedded image `key` recoloured, `None` when it stays as it is. + fn image(&mut self, key: &str) -> Option; +} + +/// What a recolouring pass reaches: fills (with gradient-mesh points), strokes and the pixels of +/// embedded images. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Reach { + pub fill: bool, + pub stroke: bool, + pub images: bool, +} + +impl Reach { + /// Every colour. + pub const ALL: Reach = Reach { fill: true, stroke: true, images: true }; +} + +/// Recolour `n` and everything inside it through `v`: the paints of its fills and strokes and of +/// its text runs, its gradient-mesh points and an embedded image's pixels (linked images stay +/// as they are). Members that change are replaced by copies; the others stay shared. Returns the +/// number of paints, meshes and images that changed. +pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usize { + let mut changed = 0; + for it in &mut n.appearance.items { + match it { + AppearanceItem::Fill(l) if reach.fill => changed += v.paint(&mut l.paint) as usize, + AppearanceItem::Stroke(l) if reach.stroke => changed += v.paint(&mut l.paint) as usize, + _ => {} + } + } + match &mut n.kind { + NodeKind::Text(t) => { + for r in &mut t.runs { + if reach.fill { + changed += v.paint(&mut r.style.fill) as usize; + } + if reach.stroke { + changed += v.paint(&mut r.style.stroke) as usize; + } + } + } + NodeKind::Mesh(m) if reach.fill => { + let mut ch = false; + for p in &mut m.points { + let c = v.color(p.color); + ch |= c != p.color; + p.color = c; + } + changed += ch as usize; + } + NodeKind::Image(im) if reach.images && im.link.is_none() => { + if let Some(k) = v.image(&im.key) { + im.key = k; + changed += 1; + } + } + _ => { + for c in n.children_mut().into_iter().flatten() { + let mut m = (**c).clone(); + let k = recolor_node(&mut m, reach, v); + if k > 0 { + *c = Arc::new(m); + changed += k; + } + } + } + } + changed +} + +/// Apply `f` to the solid colour or gradient stops of a paint, with their swatch link and tint +/// ([`Paint::map_links`]); a gradient that changes is no longer its gradient swatch's. Returns +/// whether anything changed. +pub fn map_links(p: &mut Paint, f: &dyn Fn(&mut Color, &mut Option, &mut f32) -> bool) -> bool { + let changed = p.map_links(&mut |c, l, t| f(c, l, t)); + if changed && let Paint::Gradient(g) = p { + g.swatch = None; + } + changed +} + +/// Apply `f` to a paint's colours; a colour that changes loses its swatch link. Returns whether +/// anything changed. +pub fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool { + map_links(p, &|c, link, _| { + let n = f(*c); + if n == *c { + return false; + } + *c = n; + *link = None; + true + }) +} diff --git a/crates/effects/src/adjust.rs b/crates/effects/src/adjust.rs new file mode 100644 index 00000000..85e8a2cd --- /dev/null +++ b/crates/effects/src/adjust.rs @@ -0,0 +1,493 @@ +//! Colour adjustment effects (Effect → Color Adjustments): Brightness/Contrast, Curves, Levels, +//! Hue/Saturation, Shift to Color and Temperature/Tint. +//! +//! They recolour what an object paints with, non-destructively: its fills and strokes (solid +//! colours and gradient stops), type, gradient meshes, embedded images and, on a group or layer, +//! every member — through the colour visitor the Edit Colors commands use +//! ([`vectorcraft_doc::recolor`]). Each colour keeps its colour model. An effect on one fill or +//! stroke recolours that paint only. [`adjust`] evaluates them, innermost first and each list in +//! stack order; the renderer draws the result (caching recoloured copies of embedded images) and +//! [`crate::bake_document`] writes it for export, recoloured images as new embedded images. +//! Pattern tiles, and the shadows and glows of raster effects, keep their colours. + +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_doc::color::{Color, Paint, keep_model}; +use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_paint, recolor_node}; +use vectorcraft_doc::{Document, Effect, Node, NodeKind}; + +use crate::merged_params; +use crate::raster::color_param; +use crate::util::{flag, num, text}; + +/// The colour adjustment effects, in menu order. +pub const ADJUSTMENTS: [&str; 6] = + ["adjust.brightnessContrast", "adjust.curves", "adjust.hueSaturation", "adjust.levels", "adjust.shiftToColor", "adjust.temperatureTint"]; + +/// Is `id` a colour adjustment effect? +pub fn is_adjustment(id: &str) -> bool { + ADJUSTMENTS.contains(&id) +} + +fn visible_adjustment(e: &Effect) -> bool { + e.visible && is_adjustment(&e.id) +} + +/// Does `n` carry a visible colour adjustment, on the object or on one of its fills or strokes? +pub fn has_adjustment(n: &Node) -> bool { + n.appearance.effects.iter().any(visible_adjustment) || n.appearance.items.iter().any(|i| i.effects().iter().any(visible_adjustment)) +} + +/// The channels a tone curve (Curves, Levels) applies to. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Channel { + All, + One(usize), +} + +impl Channel { + fn of(p: &Value) -> Self { + match text(p, "channel", "rgb").to_ascii_lowercase().as_str() { + "red" | "r" => Channel::One(0), + "green" | "g" => Channel::One(1), + "blue" | "b" => Channel::One(2), + _ => Channel::All, + } + } +} + +/// Entries of a sampled tone curve. +const LUT: usize = 256; + +/// One adjustment, its parameters normalised. +#[derive(Clone, Debug, PartialEq)] +enum Adjust { + /// Brightness and contrast, -1..1 each. + BrightnessContrast { brightness: f32, contrast: f32 }, + /// A tone curve (Curves, Levels) sampled at [`LUT`] inputs, on `channel`. + Tone { channel: Channel, lut: Arc<[f32]> }, + /// Hue shift in degrees, saturation and lightness -1..1; `colorize` sets the hue instead. + HueSaturation { hue: f32, saturation: f32, lightness: f32, colorize: bool }, + /// Move colours `amount` (0..1) of the way to `target` (sRGB), keeping their lightness when + /// `preserve`. + ShiftToColor { target: [f32; 3], amount: f32, preserve: bool }, + /// Warm/cool and magenta/green shifts, -1..1. + TemperatureTint { temperature: f32, tint: f32 }, +} + +/// The colour adjustments of an effect list, composed in order. +#[derive(Clone, Debug, PartialEq)] +pub struct ColorMap(Vec); + +impl ColorMap { + /// `self`, then `next`. + pub fn then(&self, next: &ColorMap) -> ColorMap { + ColorMap(self.0.iter().chain(&next.0).cloned().collect()) + } + + /// Adjust display (sRGB) components 0..1. + pub fn apply_rgb(&self, rgb: [f32; 3]) -> [f32; 3] { + self.0.iter().fold(rgb, |c, a| a.apply(c).map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 })) + } + + /// Adjust a colour, keeping its colour model (unchanged when the adjustments leave it as is). + pub fn apply(&self, c: Color) -> Color { + let rgb = c.to_rgb(); + let out = self.apply_rgb(rgb); + if out.iter().zip(rgb).all(|(a, b)| (a - b).abs() < 1e-6) { + return c; + } + keep_model(c, Color::rgb(out[0], out[1], out[2])) + } + + /// Adjust an 8-bit sRGB pixel. + pub fn apply_rgb8(&self, [r, g, b]: [u8; 3]) -> [u8; 3] { + let out = self.apply_rgb([r, g, b].map(|v| v as f32 / 255.0)); + out.map(|v| (v * 255.0).round() as u8) + } +} + +/// The visible colour adjustments of `effects` as one map, `None` when there are none. +pub fn color_map(effects: &[Effect]) -> Option { + let v: Vec = + effects.iter().filter(|e| visible_adjustment(e)).filter_map(|e| Adjust::parse(&e.id, &merged_params(&e.id, &e.params))).collect(); + (!v.is_empty()).then_some(ColorMap(v)) +} + +/// A parameter in -100..100 as -1..1. +fn amount(p: &Value, key: &str) -> f32 { + (num(p, key, 0.0).clamp(-100.0, 100.0) / 100.0) as f32 +} + +/// A level 0..255 as 0..1. +fn level(p: &Value, key: &str, default: f64) -> f32 { + (num(p, key, default).clamp(0.0, 255.0) / 255.0) as f32 +} + +impl Adjust { + fn parse(id: &str, p: &Value) -> Option { + Some(match id { + "adjust.brightnessContrast" => Adjust::BrightnessContrast { brightness: amount(p, "brightness"), contrast: amount(p, "contrast") }, + "adjust.levels" => { + let (ib, iw) = (level(p, "inputBlack", 0.0), level(p, "inputWhite", 255.0)); + let (ob, ow) = (level(p, "outputBlack", 0.0), level(p, "outputWhite", 255.0)); + let gamma = num(p, "gamma", 1.0).clamp(0.1, 10.0) as f32; + let f = |x: f32| { + let t = if iw > ib { + ((x - ib) / (iw - ib)).clamp(0.0, 1.0) + } else if x >= ib { + 1.0 + } else { + 0.0 + }; + ob + t.powf(1.0 / gamma) * (ow - ob) + }; + Adjust::Tone { channel: Channel::of(p), lut: sample(f) } + } + "adjust.curves" => { + let pts = curve_points(p.get("points")); + Adjust::Tone { channel: Channel::of(p), lut: sample(|x| curve_at(&pts, x)) } + } + "adjust.hueSaturation" => Adjust::HueSaturation { + hue: num(p, "hue", 0.0).clamp(-360.0, 360.0) as f32, + saturation: amount(p, "saturation"), + lightness: amount(p, "lightness"), + colorize: flag(p, "colorize", false), + }, + "adjust.shiftToColor" => Adjust::ShiftToColor { + target: color_param(p, Color::rgb(1.0, 0.5, 0.0)).to_rgb(), + amount: (num(p, "amount", 50.0).clamp(0.0, 100.0) / 100.0) as f32, + preserve: flag(p, "preserveLightness", true), + }, + "adjust.temperatureTint" => Adjust::TemperatureTint { temperature: amount(p, "temperature"), tint: amount(p, "tint") }, + _ => return None, + }) + } + + fn apply(&self, [r, g, b]: [f32; 3]) -> [f32; 3] { + match self { + Adjust::BrightnessContrast { brightness, contrast } => { + // Brightness bends the tones (black and white stay), contrast spreads them from the middle. + let gamma = 2f32.powf(-brightness); + let k = if *contrast >= 0.0 { 1.0 / (1.0 - contrast * 0.99) } else { 1.0 + contrast }; + [r, g, b].map(|v| (v.clamp(0.0, 1.0).powf(gamma) - 0.5) * k + 0.5) + } + Adjust::Tone { channel, lut } => { + let f = |v: f32| lookup(lut, v); + match channel { + Channel::All => [f(r), f(g), f(b)], + Channel::One(i) => { + let mut c = [r, g, b]; + if let Some(v) = c.get_mut(*i) { + *v = f(*v); + } + c + } + } + } + Adjust::HueSaturation { hue, saturation, lightness, colorize } => { + let [h, s, l] = to_hsl([r, g, b]); + let (h, s) = if *colorize { + (hue.rem_euclid(360.0), (saturation + 1.0) / 2.0) + } else { + let s = if *saturation >= 0.0 { s / (1.0 - saturation * 0.99) } else { s * (1.0 + saturation) }; + ((h + hue).rem_euclid(360.0), s) + }; + let l = if *lightness >= 0.0 { l + (1.0 - l) * lightness } else { l * (1.0 + lightness) }; + from_hsl([h, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0)]) + } + Adjust::ShiftToColor { target, amount, preserve } => { + let to = if *preserve { + let [th, ts, _] = to_hsl(*target); + from_hsl([th, ts, to_hsl([r, g, b])[2]]) + } else { + *target + }; + let mix = |a: f32, b: f32| a + (b - a) * amount; + [mix(r, to[0]), mix(g, to[1]), mix(b, to[2])] + } + Adjust::TemperatureTint { temperature, tint } => { + let (t, m) = (temperature * 0.3, tint * 0.3); + [r * (1.0 + t), g * (1.0 - m), b * (1.0 - t)] + } + } + } +} + +/// `f` sampled at [`LUT`] inputs 0..1. +fn sample(f: impl Fn(f32) -> f32) -> Arc<[f32]> { + (0..LUT).map(|i| f(i as f32 / (LUT - 1) as f32).clamp(0.0, 1.0)).collect() +} + +/// The tone curve `lut` at `v` (linear between samples). +fn lookup(lut: &[f32], v: f32) -> f32 { + let x = v.clamp(0.0, 1.0) * (LUT - 1) as f32; + let i = (x.floor() as usize).min(LUT - 2); + let (a, b) = (lut.get(i).copied().unwrap_or(v), lut.get(i + 1).copied().unwrap_or(v)); + a + (b - a) * (x - i as f32) +} + +/// Curves' points (0..1 each, sorted by input, one per input) from its `points` parameter: +/// `"x,y x,y …"` or `[[x, y], …]` in 0..255. Missing or unreadable points give the identity line. +pub fn curve_points(v: Option<&Value>) -> Vec<(f32, f32)> { + let mut pts: Vec<(f64, f64)> = match v { + Some(Value::String(s)) => s + .split(|c: char| c.is_whitespace() || c == ';') + .filter_map(|pair| { + let (x, y) = pair.split_once(',')?; + Some((x.trim().parse().ok()?, y.trim().parse().ok()?)) + }) + .collect(), + Some(Value::Array(a)) => a.iter().filter_map(|xy| Some((xy.get(0)?.as_f64()?, xy.get(1)?.as_f64()?))).collect(), + _ => vec![], + }; + pts.retain(|(x, y)| x.is_finite() && y.is_finite()); + // At most a point per level is meaningful. + pts.truncate(256); + let mut pts: Vec<(f32, f32)> = + pts.into_iter().map(|(x, y)| ((x.clamp(0.0, 255.0) / 255.0) as f32, (y.clamp(0.0, 255.0) / 255.0) as f32)).collect(); + pts.sort_by(|a, b| a.0.total_cmp(&b.0)); + pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6); + if pts.len() < 2 { + return vec![(0.0, 0.0), (1.0, 1.0)]; + } + pts +} + +/// Curves' curve through `pts` ([`curve_points`]) at `x` (0..1): a monotone cubic, flat beyond +/// the end points. +pub fn curve_at(pts: &[(f32, f32)], x: f32) -> f32 { + let (Some(&first), Some(&last)) = (pts.first(), pts.last()) else { return x }; + if x <= first.0 { + return first.1; + } + if x >= last.0 { + return last.1; + } + let n = pts.len(); + let slope = |i: usize| -> f32 { + match (pts.get(i), pts.get(i + 1)) { + (Some(a), Some(b)) if b.0 > a.0 => (b.1 - a.1) / (b.0 - a.0), + _ => 0.0, + } + }; + // Tangents: the harmonic mean of the neighbouring slopes, zero at extrema, so the curve never + // overshoots its points. + let tangent = |i: usize| -> f32 { + if i == 0 { + return slope(0); + } + if i + 1 >= n { + return slope(n - 2); + } + let (a, b) = (slope(i - 1), slope(i)); + if a * b <= 0.0 { 0.0 } else { 2.0 * a * b / (a + b) } + }; + let k = pts.windows(2).position(|w| x >= w[0].0 && x <= w[1].0).unwrap_or(0); + let (Some(&(x0, y0)), Some(&(x1, y1))) = (pts.get(k), pts.get(k + 1)) else { return x }; + let h = x1 - x0; + if h <= 0.0 { + return y0; + } + let t = (x - x0) / h; + let (m0, m1) = (tangent(k) * h, tangent(k + 1) * h); + let (t2, t3) = (t * t, t * t * t); + (2.0 * t3 - 3.0 * t2 + 1.0) * y0 + (t3 - 2.0 * t2 + t) * m0 + (-2.0 * t3 + 3.0 * t2) * y1 + (t3 - t2) * m1 +} + +/// HSL with hue in degrees, saturation and lightness 0..1. +fn to_hsl([r, g, b]: [f32; 3]) -> [f32; 3] { + let (max, min) = (r.max(g).max(b), r.min(g).min(b)); + let l = (max + min) / 2.0; + let d = max - min; + if d <= 1e-6 { + return [0.0, 0.0, l]; + } + let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; + let h = if max == r { + (g - b) / d + if g < b { 6.0 } else { 0.0 } + } else if max == g { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }; + [h * 60.0, s, l] +} + +fn from_hsl([h, s, l]: [f32; 3]) -> [f32; 3] { + if s <= 0.0 { + return [l; 3]; + } + let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }; + let p = 2.0 * l - q; + let hue = |t: f32| { + let t = t.rem_euclid(1.0); + if t < 1.0 / 6.0 { + p + (q - p) * 6.0 * t + } else if t < 0.5 { + q + } else if t < 2.0 / 3.0 { + p + (q - p) * (2.0 / 3.0 - t) * 6.0 + } else { + p + } + }; + let h = h / 360.0; + [hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0)] +} + +/// Gives the key of an embedded image recoloured by a map (`None`: leave it as it is). +pub type ImageHook<'a> = dyn FnMut(&str, &ColorMap) -> Option + 'a; + +/// The live visitor: every colour through `map`, embedded images through the hook. +struct Live<'a, 'b> { + map: &'a ColorMap, + images: &'a mut ImageHook<'b>, +} + +impl ColorVisitor for Live<'_, '_> { + fn paint(&mut self, p: &mut Paint) -> bool { + map_paint(p, &|c| self.map.apply(c)) + } + fn color(&mut self, c: Color) -> Color { + self.map.apply(c) + } + fn image(&mut self, key: &str) -> Option { + (self.images)(key, self.map) + } +} + +/// `n` with the colour adjustments in it applied, `None` when it has none: its members' first, +/// then those of its fills and strokes on their paints, then its own followed by `outer` (the +/// adjustments of an object `n` is the art of) on everything it paints. The adjustment effects +/// are gone from the result; embedded images take the keys `images` gives. +pub fn adjust(n: &Node, outer: Option<&ColorMap>, images: &mut ImageHook<'_>) -> Option { + let mut m: Option = None; + if let Some(ch) = n.children() { + for (i, c) in ch.iter().enumerate() { + if let Some(new) = adjust(c, None, images) + && let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i)) + { + *slot = Arc::new(new); + } + } + } + for (i, item) in n.appearance.items.iter().enumerate() { + if let Some(map) = color_map(item.effects()) + && let Some(it) = m.get_or_insert_with(|| n.clone()).appearance.items.get_mut(i) + { + map_paint(it.paint_mut(), &|c| map.apply(c)); + it.effects_mut().retain(|e| !is_adjustment(&e.id)); + } + } + let map = match (color_map(&n.appearance.effects), outer) { + (Some(own), Some(outer)) => Some(own.then(outer)), + (own, None) => own, + (None, Some(outer)) => Some(outer.clone()), + }; + if let Some(map) = map { + let mm = m.get_or_insert_with(|| n.clone()); + mm.appearance.effects.retain(|e| !is_adjustment(&e.id)); + recolor_node(mm, Reach::ALL, &mut Live { map: &map, images }); + } + m +} + +/// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in +/// `d` (named by their content), and a symbol instance stands for its symbol's art. +pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option { + let art; + let src = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => { + let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()); + art = crate::reshape::as_art(n, symbol.as_deref())?; + &art + } + _ => n, + }; + let mut hook = |key: &str, map: &ColorMap| -> Option { + let blob = d.images.get(key)?.map_rgb(|rgb| map.apply_rgb8(rgb))?; + let k = blob.content_key(); + d.images.entry(k.clone()).or_insert(blob); + Some(k) + }; + adjust(src, None, &mut hook) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use vectorcraft_doc::{Appearance, NodeId}; + use vectorcraft_geom::{Rect, shapes}; + + fn map(id: &str, params: Value) -> ColorMap { + color_map(&[Effect { id: id.into(), params, visible: true }]).unwrap() + } + + fn close(a: [f32; 3], b: [f32; 3]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01) + } + + #[test] + fn neutral_settings_change_nothing() { + for id in ADJUSTMENTS { + let m = color_map(&[Effect { id: id.into(), params: json!({"amount": 0}), visible: true }]).unwrap(); + for c in [[0.2, 0.4, 0.6], [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]] { + assert!(close(m.apply_rgb(c), c), "{id} {c:?} → {:?}", m.apply_rgb(c)); + } + } + let cmyk = Color::cmyk(0.1, 0.2, 0.3, 0.4); + assert_eq!(map("adjust.levels", json!({})).apply(cmyk), cmyk, "an untouched colour keeps its exact values"); + } + + #[test] + fn adjustments_move_colours_the_expected_way() { + let grey = [0.5, 0.5, 0.5]; + assert!(map("adjust.brightnessContrast", json!({"brightness": 50})).apply_rgb(grey)[0] > 0.6); + assert!(map("adjust.brightnessContrast", json!({"contrast": 50})).apply_rgb([0.7; 3])[0] > 0.8); + assert!(close(map("adjust.levels", json!({"inputBlack": 64, "inputWhite": 192})).apply_rgb([0.25, 0.5, 0.75]), [0.0, 0.5, 1.0])); + assert!(close(map("adjust.levels", json!({"outputWhite": 128, "channel": "red"})).apply_rgb([1.0; 3]), [0.502, 1.0, 1.0])); + // An S curve darkens the shadows and lightens the highlights, through its points exactly. + let s = map("adjust.curves", json!({"points": "0,0 64,32 192,224 255,255"})); + assert!(close(s.apply_rgb([64.0 / 255.0; 3]), [32.0 / 255.0; 3])); + assert!(s.apply_rgb([0.85; 3])[0] > 0.9); + let arr = map("adjust.curves", json!({"points": [[0, 255], [255, 0]]})); + assert!(close(arr.apply_rgb([0.0, 1.0, 0.25]), [1.0, 0.0, 0.75]), "an inverting curve"); + assert!(close(map("adjust.hueSaturation", json!({"hue": 120})).apply_rgb([1.0, 0.0, 0.0]), [0.0, 1.0, 0.0])); + assert!(close(map("adjust.hueSaturation", json!({"saturation": -100})).apply_rgb([1.0, 0.0, 0.0]), [0.5; 3])); + assert!(close(map("adjust.hueSaturation", json!({"saturation": 100})).apply_rgb(grey), grey), "greys stay grey"); + assert!(close(map("adjust.hueSaturation", json!({"colorize": true, "hue": 240, "saturation": 100})).apply_rgb(grey), [0.0, 0.0, 1.0])); + let shift = map("adjust.shiftToColor", json!({"color": "#0000ff", "amount": 100, "preserveLightness": false})); + assert!(close(shift.apply_rgb([1.0, 1.0, 0.0]), [0.0, 0.0, 1.0])); + let warm = map("adjust.temperatureTint", json!({"temperature": 100})).apply_rgb(grey); + assert!(warm[0] > grey[0] && warm[2] < grey[2]); + // Effects compose in stack order. + let both = map("adjust.levels", json!({"outputWhite": 128})).then(&map("adjust.brightnessContrast", json!({"brightness": 100}))); + assert!(close(both.apply_rgb([1.0; 3]), [(128.0f32 / 255.0).sqrt(); 3])); + } + + #[test] + fn adjust_recolours_paints_members_and_strips_the_effects() { + let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let leaf = |id| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), red.clone())); + let mut g = Node::group(NodeId(1), vec![leaf(2), leaf(3)]); + g.appearance.effects.push(Effect { id: "adjust.hueSaturation".into(), params: json!({"hue": 120}), visible: true }); + assert!(has_adjustment(&g)); + let out = adjust(&g, None, &mut |_, _| None).unwrap(); + assert!(out.appearance.effects.is_empty()); + for c in out.children().unwrap() { + assert_eq!(c.appearance.fill_paint().color().unwrap().to_hex(), "#00ff00"); + } + // A hidden effect does nothing; a stroke's own effect recolours just that stroke. + g.appearance.effects[0].visible = false; + assert!(!has_adjustment(&g) && adjust(&g, None, &mut |_, _| None).is_none()); + let mut p = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + p.appearance.items[1].effects_mut().push(Effect { id: "adjust.levels".into(), params: json!({"outputBlack": 255}), visible: true }); + let out = adjust(&p, None, &mut |_, _| None).unwrap(); + assert_eq!(out.appearance.items[1].paint().color().unwrap().to_hex(), "#ffffff"); + assert_eq!(out.appearance.items[0].paint(), p.appearance.items[0].paint()); + assert!(out.appearance.items[1].effects().is_empty()); + } +} diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index afd2edb8..eb042f9f 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -6,6 +6,8 @@ //! outlines, [`reshape`]) — is evaluated exactly like the renderer does, and so are the own fills, //! strokes and geometry effects of groups and layers ([`evaluate_container`]). Raster effects //! (shadows, glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters). +//! Colour adjustments recolour the objects ([`adjust_in_document`]; recoloured embedded images +//! become images of their own). //! //! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an //! object of its own, strokes turned into filled art by the caller (their outlines or brush art). @@ -17,8 +19,8 @@ use vectorcraft_geom::{FillRule, PathData}; use crate::group::{has_own_paint, paints}; use crate::{ - GeomContext, apply_geometry_with, crop_marks_art, evaluate_container, has_crop_marks, has_geometry, has_pathfinder, is_geometry, needs_outline, - reshape, + GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, + has_pathfinder, is_geometry, needs_outline, reshape, }; fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { @@ -37,7 +39,8 @@ fn clear_item_effects(item: &mut AppearanceItem) { /// Does anything in `n`'s subtree need baking? pub fn needs_bake(n: &Node) -> bool { - has_crop_marks(n) + has_adjustment(n) + || has_crop_marks(n) || has_pathfinder(n) || has_own_paint(n) || has_geometry(&n.appearance.effects) @@ -78,6 +81,12 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { if !needs_bake(n) { return None; } + // Colour adjustments: the object recoloured (inside it too), then baked as it is. + if has_adjustment(n) + && let Some(m) = adjust_in_document(d, n) + { + return Some(bake_node(d, &m).unwrap_or(m)); + } // Crop marks: the object and its marks. if let Some(m) = crop_marks_art(n) { return Some(bake_pieces(d, m)); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 1b907f68..303ab40b 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -21,11 +21,15 @@ //! - **Plug-in effects** (`plugin.`, Effect › Plug-ins) are geometry effects run by an //! installed WebAssembly plug-in (`vectorcraft-plugins`); [`effect_info`] describes them like //! built-in ones, and an effect whose plug-in isn't installed leaves the geometry as it is. +//! - **Colour adjustments** (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, +//! Temperature/Tint) recolour what an object paints with: [`adjust`] evaluates them for the +//! renderer and the exporters. //! //! Everything is deterministic: "random" effects (Roughen, Tweak, Scribble) use a seeded hash //! noise (`seed` parameter, default 0). #![forbid(unsafe_code)] +mod adjust; mod bake; mod clip; mod distort; @@ -47,6 +51,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{BezPath, FillRule, PathData, Rect}; +pub use adjust::{ADJUSTMENTS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment}; pub use bake::{StrokeArt, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake}; pub use clip::clip_outline; pub use group::{ @@ -129,6 +134,7 @@ const WARP: &[&str] = &["Effect", "Warp"]; const BLUR: &[&str] = &["Effect", "Blur"]; const PATHFINDER: &[&str] = &["Effect", "Pathfinder"]; const PLUGINS: &[&str] = &["Effect", "Plug-ins"]; +const ADJUST: &[&str] = &["Effect", "Color Adjustments"]; /// The warp styles in Illustrator's Style menu order: (id suffix, label). pub const WARP_STYLES: [(&str, &str); 15] = [ @@ -258,6 +264,50 @@ pub fn effect_catalog() -> Vec { r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})), r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})), ]; + v.extend([ + g( + "adjust.brightnessContrast", + "Brightness/Contrast…", + ADJUST, + "{brightness: -100..100 (0; bends the tones, black and white stay), contrast: -100..100 (0)} recolours the object's fills, strokes, type, meshes and embedded images (each colour keeps its model)", + json!({"brightness": 0.0, "contrast": 0.0}), + ), + g( + "adjust.curves", + "Curves…", + ADJUST, + "{points: \"x,y x,y …\" or [[x, y], …] (input, output 0..255; a smooth monotone curve through them, flat past the ends; \"0,0 128,128 255,255\"), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does", + json!({"points": "0,0 128,128 255,255", "channel": "rgb"}), + ), + g( + "adjust.hueSaturation", + "Hue/Saturation…", + ADJUST, + "{hue: deg -180..180 (0), saturation: -100..100 (0), lightness: -100..100 (0), colorize: bool (false; true: every colour takes hue `hue` (0..360) at saturation (100 + saturation) / 2 %)} recolours as Brightness/Contrast does", + json!({"hue": 0.0, "saturation": 0.0, "lightness": 0.0, "colorize": false}), + ), + g( + "adjust.levels", + "Levels…", + ADJUST, + "{inputBlack: 0..255 (0), inputWhite: 0..255 (255), gamma: 0.1..10 (1; above 1 lightens the midtones), outputBlack: 0..255 (0), outputWhite: 0..255 (255), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does", + json!({"inputBlack": 0.0, "inputWhite": 255.0, "gamma": 1.0, "outputBlack": 0.0, "outputWhite": 255.0, "channel": "rgb"}), + ), + g( + "adjust.shiftToColor", + "Shift to Color…", + ADJUST, + "{color: \"#rrggbb\" (\"#ff8000\"), amount: 0..100 % (50), preserveLightness: bool (true: colours take the target's hue and saturation and keep their lightness; false: they mix with it)} recolours as Brightness/Contrast does", + json!({"color": "#ff8000", "amount": 50.0, "preserveLightness": true}), + ), + g( + "adjust.temperatureTint", + "Temperature/Tint…", + ADJUST, + "{temperature: -100 (cooler) .. 100 (warmer) (0), tint: -100 (greener) .. 100 (more magenta) (0)} recolours as Brightness/Contrast does", + json!({"temperature": 0.0, "tint": 0.0}), + ), + ]); for (suffix, label) in WARP_STYLES { let id: &'static str = match suffix { "arc" => "warp.arc", @@ -378,11 +428,13 @@ pub fn is_raster(id: &str) -> bool { matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian") } -/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`].) -/// Plug-in effects do, installed or not (a missing plug-in leaves the geometry as it is). +/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`]; colour +/// adjustments recolour, [`adjust`].) Plug-in effects do, installed or not (a missing plug-in +/// leaves the geometry as it is). pub fn is_geometry(id: &str) -> bool { !is_raster(id) && !is_pathfinder(id) + && !is_adjustment(id) && id != CROP_MARKS && (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some()) } diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index f18ba7e6..ed966f54 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -64,7 +64,8 @@ pub fn blend_mode(s: &str) -> BlendMode { BlendMode::ALL.iter().copied().find(|m| format!("{m:?}").to_ascii_lowercase() == key).unwrap_or(BlendMode::Normal) } -fn color(p: &Value, default: Color) -> Color { +/// An effect's `color` parameter (`"#rrggbb"`, `[r, g, b]` 0..1 or a colour object). +pub(crate) fn color_param(p: &Value, default: Color) -> Color { match p.get("color") { Some(Value::String(s)) => Color::from_hex(s).unwrap_or(default), Some(Value::Array(a)) if a.len() >= 3 => { @@ -95,19 +96,19 @@ pub fn raster_effects(effects: &[Effect]) -> Vec { dx: num(&p, "x", 7.0).clamp(-1e4, 1e4), dy: num(&p, "y", 7.0).clamp(-1e4, 1e4), blur, - color: color(&p, Color::BLACK), + color: color_param(&p, Color::BLACK), }, "stylize.outerGlow" => RasterFx::OuterGlow { mode: blend_mode(text(&p, "mode", "screen")), opacity: opacity(&p), blur, - color: color(&p, Color::rgb(1.0, 1.0, 0.0)), + color: color_param(&p, Color::rgb(1.0, 1.0, 0.0)), }, "stylize.innerGlow" => RasterFx::InnerGlow { mode: blend_mode(text(&p, "mode", "screen")), opacity: opacity(&p), blur, - color: color(&p, Color::WHITE), + color: color_param(&p, Color::WHITE), center: text(&p, "source", "edge").eq_ignore_ascii_case("center"), }, "stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) }, diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index ad91e8ff..ea0fbd2a 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -131,7 +131,7 @@ pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option { } /// [`reshape`] without its checks (no geometry effects: just the art). -fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { +pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { let fx = geometry_effects(n); let mut art = match &n.kind { NodeKind::Image(im) => { diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 9536d00f..01b07a51 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -13,7 +13,9 @@ use vectorcraft_color::harmony::{Guide, GuideOptions, Harmony, Variation}; use vectorcraft_color::{Color, Paint, keep_model}; use vectorcraft_doc::live::lerp_color; use vectorcraft_doc::pattern::PatternDef; -use vectorcraft_doc::{AppearanceItem, Document, ImageBlob, Node, NodeId, NodeKind}; +pub(crate) use vectorcraft_doc::recolor::map_paint; +use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node}; +use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind}; use super::edit::selected_roots; use super::*; @@ -144,30 +146,6 @@ fn gray_level(c: Color) -> f32 { /// gradient stop): `(colour, link, tint)`, true when it changed them. pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option, &mut f32) -> bool + 'a; -/// Apply `f` to the solid colour or gradient stops of a paint ([`Paint::map_links`]); a gradient -/// that changes is no longer its gradient swatch's. Returns whether anything changed. -fn map_links(p: &mut Paint, f: &LinkMap) -> bool { - let changed = p.map_links(&mut |c, l, t| f(c, l, t)); - if changed && let Paint::Gradient(g) = p { - g.swatch = None; - } - changed -} - -/// Apply `f` to a paint's colours; a colour that changes loses its swatch link. Returns whether -/// anything changed. -pub(crate) fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool { - map_links(p, &|c, link, _| { - let n = f(*c); - if n == *c { - return false; - } - *c = n; - *link = None; - true - }) -} - /// The identity colour map (a pass that only maps linked colours). fn same(c: Color) -> Color { c @@ -258,68 +236,24 @@ impl<'a> Recolor<'a> { out } - /// `n` recoloured, or `None` when nothing changed. + /// `n` recoloured (through the shared colour visitor), or `None` when nothing changed. fn subtree(&mut self, d: &Document, n: &Node) -> Option { - let before = self.changed; - let mut m = n.clone(); - self.node(d, &mut m); - (self.changed > before).then_some(m) - } - - fn node(&mut self, d: &Document, n: &mut Node) { let Scope { fill, stroke, images, .. } = self.scope; - for it in &mut n.appearance.items { - match it { - AppearanceItem::Fill(l) if fill => self.paint(d, &mut l.paint), - AppearanceItem::Stroke(l) if stroke => self.paint(d, &mut l.paint), - _ => {} - } - } - match &mut n.kind { - NodeKind::Text(t) => { - for r in &mut t.runs { - if fill { - self.paint(d, &mut r.style.fill); - } - if stroke { - self.paint(d, &mut r.style.stroke); - } - } - } - NodeKind::Mesh(m) if fill => { - let mut ch = false; - for p in &mut m.points { - let c = (self.f)(p.color); - ch |= c != p.color; - p.color = c; - } - self.changed += ch as usize; - } - NodeKind::Image(im) if images && im.link.is_none() => { - if let Some(k) = self.image(d, &im.key) { - im.key = k; - self.changed += 1; - } - } - _ => { - for c in n.children_mut().into_iter().flatten() { - if let Some(new) = self.subtree(d, c) { - *c = Arc::new(new); - } - } - } - } + let mut m = n.clone(); + let changed = recolor_node(&mut m, Reach { fill, stroke, images }, &mut Visit { pass: self, d }); + self.changed += changed; + (changed > 0).then_some(m) } - fn paint(&mut self, d: &Document, p: &mut Paint) { - let changed = match p { + /// Recolour one paint: true when it changed. + fn paint(&mut self, d: &Document, p: &mut Paint) -> bool { + match p { Paint::Pattern { pattern, .. } if self.scope.patterns => self.pattern(d, pattern).map(|new| *pattern = new).is_some(), _ => match self.links { Some(f) => map_links(p, f), None => map_paint(p, self.f), }, - }; - self.changed += changed as usize; + } } /// The key of image `key` recoloured (one copy per pass), `None` when no pixel changes. @@ -370,6 +304,24 @@ impl<'a> Recolor<'a> { } } +/// What a pass over document `d` does to each colour the shared visitor meets. +struct Visit<'p, 'a> { + pass: &'p mut Recolor<'a>, + d: &'p Document, +} + +impl ColorVisitor for Visit<'_, '_> { + fn paint(&mut self, p: &mut Paint) -> bool { + self.pass.paint(self.d, p) + } + fn color(&mut self, c: Color) -> Color { + (self.pass.f)(c) + } + fn image(&mut self, key: &str) -> Option { + self.pass.image(self.d, key) + } +} + pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result { run_pass(s, p, label, Recolor::new(Scope::of(p), f)) } diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 2dde733c..e07ac02f 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -322,11 +322,25 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects: /// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects /// are baked, raster effects become an embedded image, and the object keeps its transparency. fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec) { - let Some(n) = d.node(id).cloned() else { return }; - let container = is_container(&n); + let Some(mut n) = d.node(id).cloned() else { return }; if !expandable(&n) { return; } + // Colour adjustments: the colours inside become the adjusted ones (an embedded image a + // recoloured copy), then the rest of the appearance expands. + if effects::has_adjustment(&n) + && let Some(m) = effects::adjust_in_document(d, &n) + && let Some(slot) = d.node_mut(id) + { + *slot = m.clone(); + out.push(id); + n = m; + if !expandable(&n) { + return; + } + } + // (A symbol instance has become its art, a group.) + let container = is_container(&n); // Crop Marks: a group of the object and its marks, then the object expands on its own. if let Some(mut m) = effects::crop_marks_art(&n) { let mut seen = std::collections::HashSet::from([id]); @@ -386,7 +400,9 @@ fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush && let Some(slot) = d.node_mut(id) { *slot = m; - out.push(id); + if out.last() != Some(&id) { + out.push(id); + } } if container { let children: Vec = d.node(id).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2d03675a..708bb7a3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1100,6 +1100,8 @@ impl Session { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_adjust; +#[cfg(test)] mod tests_appearance; #[cfg(test)] mod tests_assets; diff --git a/crates/engine/src/tests_adjust.rs b/crates/engine/src/tests_adjust.rs new file mode 100644 index 00000000..040a4924 --- /dev/null +++ b/crates/engine/src/tests_adjust.rs @@ -0,0 +1,137 @@ +//! Live colour adjustments (Effect → Color Adjustments) through the commands: applied, listed, +//! exported, expanded and kept in the native file. + +use serde_json::json; +use vectorcraft_doc::{Node, NodeKind}; + +use super::*; + +pub(crate) fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +pub(crate) fn red_rect(s: &mut Session) -> NodeId { + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + id +} + +pub(crate) fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +pub(crate) fn fill_hex(n: &Node) -> String { + n.appearance.fill_paint().color().unwrap().to_hex() +} + +/// A selected embedded 4×4 red image. +fn red_image(s: &mut Session) -> NodeId { + let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.edit("Place", |d, sel| { + d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { + key: "pic".into(), + width: 4, + height: 4, + xf: vectorcraft_geom::Affine::scale(10.0), + link: None, + placement: Default::default(), + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?; + sel.set([id]); + Ok(id) + }) + .unwrap() +} + +#[test] +fn the_effects_are_in_the_catalog_under_color_adjustments() { + let mut s = session(); + let v = s.execute("effect.list", &json!({})).unwrap(); + let ids: Vec<&str> = + v["catalog"].as_array().unwrap().iter().filter(|e| e["menu"][1] == "Color Adjustments").map(|e| e["id"].as_str().unwrap()).collect(); + for id in vectorcraft_render::effects::ADJUSTMENTS { + assert!(ids.contains(&id), "{id} in {ids:?}"); + } +} + +#[test] +fn an_adjustment_is_live_and_one_undo_step() { + let mut s = session(); + let id = red_rect(&mut s); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("effect.apply", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + // Non-destructive: the fill stays red, the effect sits on the object. + let n = node(&s, id); + assert_eq!(fill_hex(&n), "#ff0000"); + assert_eq!(n.appearance.effects.len(), 1); + // Drawn green. + let img = + vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true); + assert_eq!(img.pixel(50, 50), [0, 255, 0, 255]); + // Editing the parameters changes it live. + s.execute("effect.setParams", &json!({"index": 0, "item": null, "params": {"hue": -120}})).unwrap(); + let img = + vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true); + assert_eq!(img.pixel(50, 50), [0, 0, 255, 255]); +} + +#[test] +fn export_and_expand_write_the_adjusted_colours() { + let mut s = session(); + let id = red_rect(&mut s); + s.execute("effect.apply", &json!({"effect": "adjust.levels", "params": {"outputWhite": 128, "channel": "red"}})).unwrap(); + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + assert!(svg.contains("#800000") && !svg.contains("#ff0000"), "{svg}"); + // Expand Appearance makes it permanent. + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let n = node(&s, id); + assert!(n.appearance.effects.is_empty()); + assert_eq!(fill_hex(&n), "#800000"); +} + +#[test] +fn an_embedded_image_is_adjusted_for_export_without_changing_its_pixels() { + let mut s = session(); + let id = red_image(&mut s); + s.execute("effect.apply", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let baked = vectorcraft_render::effects::bake_document(&doc).unwrap(); + let NodeKind::Image(im) = &baked.node(id).unwrap().kind else { panic!("not an image") }; + assert_ne!(im.key, "pic", "a recoloured copy"); + let px = image::load_from_memory(&baked.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!(px.get_pixel(1, 1).0, [0, 255, 0, 255]); + assert_eq!(doc.images.len(), 1, "the document keeps its one image"); + // Through Expand Appearance too. + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let NodeKind::Image(im) = &node(&s, id).kind else { panic!("not an image") }; + let px = image::load_from_memory(&s.doc().unwrap().doc.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!(px.get_pixel(0, 0).0, [0, 255, 0, 255]); +} + +#[test] +fn adjustments_survive_the_native_file() { + let mut s = session(); + red_rect(&mut s); + s.execute("effect.apply", &json!({"effect": "adjust.curves", "params": {"points": "0,0 64,32 255,255", "channel": "green"}})).unwrap(); + let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": saved})).unwrap(); + let mut found = None; + o.doc().unwrap().doc.walk(|n| { + if let Some(e) = n.appearance.effects.first() { + found = Some(e.clone()); + } + }); + let e = found.expect("the effect is kept"); + assert_eq!(e.id, "adjust.curves"); + assert_eq!(e.params["points"], "0,0 64,32 255,255"); +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index f82974f3..30fd7ce4 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -49,10 +49,13 @@ pub(crate) fn has_fx(n: &Node) -> bool { } /// Does `n` carry visible object-level effects that only apply through its art (type, images, -/// symbol instances, live objects; Crop Marks on anything), or is it a group or layer with an -/// appearance of its own (see [`Renderer::draw_object_fx`])? +/// symbol instances, live objects; Crop Marks and colour adjustments on anything), or is it a group +/// or layer with an appearance of its own (see [`Renderer::draw_object_fx`])? pub(crate) fn has_object_fx(n: &Node) -> bool { - (effects::needs_outline(n) && visible(&n.appearance.effects)) || effects::has_container_appearance(n) || effects::has_crop_marks(n) + (effects::needs_outline(n) && visible(&n.appearance.effects)) + || effects::has_container_appearance(n) + || effects::has_crop_marks(n) + || effects::has_adjustment(n) } /// Is `n` a group or layer (whose evaluated art keeps its knockout setting)? @@ -399,9 +402,10 @@ impl Renderer { } /// The art [`Self::draw_object_fx`] draws for `a` (cached by `Arc` identity when `cache`): - /// reshaped by its geometry effects, else type and images as themselves and the others as - /// their evaluated art (groups and layers: [`effects::evaluate_container`], keeping their - /// knockout setting); without the object's transparency and effects. + /// reshaped by its geometry effects, else type, images and paths as themselves and the others + /// as their evaluated art (groups and layers: [`effects::evaluate_container`], keeping their + /// knockout setting), recoloured by the colour adjustments in it ([`Self::adjusted_art`]); + /// without the object's transparency and effects (a path keeps its geometry effects). fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc, cache: bool) -> Arc { let key = Arc::as_ptr(a) as usize; if cache @@ -420,9 +424,13 @@ impl Renderer { let art = match effects::crop_marks_art(a).or_else(|| effects::reshape(a, symbol.as_ref())) { Some(r) => r, None if container => effects::evaluate_container(a).unwrap_or_else(|| (**a).clone()), - None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_)) => (**a).clone(), + None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::Path { .. } | NodeKind::Compound { .. }) => (**a).clone(), None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), }; + let mut art = self.adjusted_art(doc, art, effects::color_map(&a.appearance.effects).as_ref()); + // A path's geometry effects still apply when it is drawn. + let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); + art.appearance.effects.retain(|e| path && effects::is_geometry(&e.id)); let art = Arc::new(Node { opacity: 1.0, blend: Default::default(), @@ -431,7 +439,6 @@ impl Renderer { // its knockout setting says). knockout: if container { a.knockout } else { vectorcraft_doc::Knockout::Off }, mask: None, - appearance: vectorcraft_doc::Appearance { effects: vec![], ..art.appearance }, ..art }); if cache { @@ -443,6 +450,25 @@ impl Renderer { art } + /// `art` with the colour adjustments inside it applied, then `outer` (its object's own) on + /// everything ([`effects::adjust`]). Embedded images become recoloured copies, cached with the + /// decoded images under a key naming the adjustment. + fn adjusted_art(&mut self, doc: &vectorcraft_doc::Document, mut art: Node, outer: Option<&effects::ColorMap>) -> Node { + art.appearance.effects.retain(|e| !effects::is_adjustment(&e.id)); + let mut wanted: Vec<(String, String, effects::ColorMap)> = vec![]; + let adjusted = effects::adjust(&art, outer, &mut |key, map| { + let mut h = std::hash::DefaultHasher::new(); + std::hash::Hash::hash(&format!("{map:?}"), &mut h); + let derived = format!("{key}\u{0}adjust\u{0}{:016x}", std::hash::Hasher::finish(&h)); + wanted.push((key.to_string(), derived.clone(), map.clone())); + Some(derived) + }); + for (key, derived, map) in wanted { + self.adjusted_image(doc, &key, derived, &map); + } + adjusted.unwrap_or(art) + } + /// Paint `content` with raster effects `rfx` (see the module docs): shadows and outer glows /// below it, the content blurred or feathered, inner glows above it (clipped to its outline). /// `paint` draws the content in the frame it is given. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3b4007a9..74e25e36 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -324,6 +324,9 @@ pub struct Renderer { ink_images: ink::InkImages, /// Gradients along or across strokes, keyed like `strokes` (see [`Self::fill_path_gradient`]). stroke_slices: PtrMap<(usize, i32), SliceEntry>, + /// The keys and sizes of the recoloured images colour adjustments made in `images`, oldest + /// first (see [`Self::adjusted_image`]). + adjusted: std::collections::VecDeque<(String, usize)>, } #[derive(Clone, Copy, Debug, Default)] @@ -398,6 +401,7 @@ impl Renderer { clip_paints: PtrMap::default(), ink_images: Default::default(), stroke_slices: PtrMap::default(), + adjusted: Default::default(), } } @@ -1278,6 +1282,38 @@ impl Renderer { } } + /// Cache the pixels of image blob `key` recoloured by `map` as `derived` (the key the adjusted + /// art's image takes). The latest ones are kept up to a memory budget: dropping one drops the + /// cached art too, so it is made again. + fn adjusted_image(&mut self, doc: &Document, key: &str, derived: String, map: &vectorcraft_effects::ColorMap) { + const BUDGET: usize = 256 << 20; + if self.images.contains_key(&derived) { + return; + } + let Some(src) = self.image_pixmap(doc, key, key, false) else { return }; + let mut pm = (*src).clone(); + let mut memo: HashMap<[u8; 4], [u8; 3]> = HashMap::new(); + for px in pm.data_mut().iter_mut().filter(|p| p.a > 0) { + let a = px.a as u32; + let [r, g, b] = *memo.entry([px.r, px.g, px.b, px.a]).or_insert_with(|| { + // Premultiplied to straight, adjusted, then premultiplied again. + let straight = [px.r, px.g, px.b].map(|v| ((v as u32 * 255 + a / 2) / a).min(255) as u8); + map.apply_rgb8(straight).map(|v| ((v as u32 * a + 127) / 255) as u8) + }); + (px.r, px.g, px.b) = (r, g, b); + } + let bytes = pm.data().len() * 4; + self.images.insert(derived.clone(), Arc::new(pm)); + self.adjusted.push_back((derived, bytes)); + let mut total: usize = self.adjusted.iter().map(|(_, b)| b).sum(); + while total > BUDGET && self.adjusted.len() > 1 { + let Some((old, b)) = self.adjusted.pop_front() else { break }; + self.images.remove(&old); + self.fx_arts.clear(); + total -= b; + } + } + /// The decoded pixels of image blob `key` (cached as `cache_key`), or a greyscale copy of them. fn image_pixmap(&mut self, doc: &Document, key: &str, cache_key: &str, grey: bool) -> Option> { let colour = match self.images.get(cache_key) { @@ -1480,6 +1516,8 @@ fn now() -> u64 { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_adjust; +#[cfg(test)] mod tests_blend; #[cfg(test)] mod tests_charstroke; diff --git a/crates/render/src/tests_adjust.rs b/crates/render/src/tests_adjust.rs new file mode 100644 index 00000000..8e5690a6 --- /dev/null +++ b/crates/render/src/tests_adjust.rs @@ -0,0 +1,114 @@ +//! Live colour adjustments (Effect → Color Adjustments) as drawn: on paths, type, embedded images, +//! groups and symbol instances, alone and with other effects. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::text::{CharStyle, TextObject}; +use vectorcraft_doc::{Appearance, Effect, ImageBlob, ImageObject, Node, NodeKind, Symbol}; +use vectorcraft_geom::{Point, shapes}; + +use super::*; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +/// Hue +120°: red becomes green, green blue. +fn hue() -> Effect { + fx("adjust.hueSaturation", json!({"hue": 120})) +} + +fn doc_with(nodes: Vec) -> Document { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + for mut n in nodes { + n.id = d.alloc_id(); + d.insert(Some(l), 0, n).unwrap(); + } + d +} + +fn render(d: &Document) -> Rendered { + let mut r = Renderer::new(); + r.threads = 0; + r.render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +fn red_square(x: f64, y: f64) -> Node { + Node::path( + NodeId(0), + shapes::rectangle(Rect::new(x, y, x + 30.0, y + 30.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ) +} + +fn red_image() -> (String, ImageBlob) { + let img = image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + ("red".into(), ImageBlob::new("image/png", png)) +} + +#[test] +fn a_path_takes_its_adjusted_colour_and_keeps_its_other_effects() { + let mut n = red_square(10.0, 10.0); + n.appearance.effects = vec![hue(), fx("distort.transform", json!({"moveH": 40}))]; + let img = render(&doc_with(vec![n])); + assert_eq!(img.pixel(65, 25), [0, 255, 0, 255], "green, and moved by the geometry effect"); + assert_eq!(img.pixel(25, 25), [255, 255, 255, 255]); + // Hidden, it changes nothing. + let mut n = red_square(10.0, 10.0); + n.appearance.effects = vec![Effect { visible: false, ..hue() }]; + assert_eq!(render(&doc_with(vec![n])).pixel(25, 25), [255, 0, 0, 255]); +} + +#[test] +fn a_group_recolours_its_members_and_type() { + let style = CharStyle { size: 60.0, fill: Paint::solid(Color::rgb(1.0, 0.0, 0.0)), ..Default::default() }; + let text = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(50.0, 90.0), "I", style)))); + let mut g = Node::group(NodeId(0), vec![Arc::new(red_square(5.0, 5.0)), Arc::new(text)]); + g.appearance.effects.push(hue()); + let d = doc_with(vec![g]); + let img = render(&d); + assert_eq!(img.pixel(20, 20), [0, 255, 0, 255]); + let inked: Vec<[u8; 4]> = + (50..70).flat_map(|x| (50..90).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)).filter(|p| p[0] < 128 || p[2] < 128).collect(); + assert!(inked.contains(&[0, 255, 0, 255]), "the type turns green too"); + assert!(inked.iter().all(|p| p[1] == 255 && p[0] == p[2]), "no red left: {:?}", inked.iter().find(|p| p[1] != 255 || p[0] != p[2])); +} + +#[test] +fn an_embedded_image_is_recoloured_and_the_original_pixels_stay() { + let mut d = doc_with(vec![]); + let (key, blob) = red_image(); + d.images.insert(key.clone(), blob.clone()); + let mut n = Node::new( + NodeId(0), + NodeKind::Image(ImageObject { key, width: 30, height: 30, xf: Affine::translate((20.0, 20.0)), link: None, placement: Default::default() }), + ); + n.appearance.effects.push(hue()); + d = { + let l = d.layers[0].id; + n.id = d.alloc_id(); + d.insert(Some(l), 0, n).unwrap(); + d + }; + let img = render(&d); + assert_eq!(img.pixel(35, 35), [0, 255, 0, 255]); + assert_eq!(d.images.get("red"), Some(&blob), "non-destructive"); +} + +#[test] +fn a_symbol_instance_recolours_its_art() { + let mut d = Document::new(100.0, 100.0); + let art = red_square(-15.0, -15.0); + d.symbols.push(Symbol { name: "Red".into(), art: Arc::new(art) }); + let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Red".into(), xf: Affine::translate((50.0, 50.0)) }); + n.appearance.effects.push(hue()); + let l = d.layers[0].id; + n.id = d.alloc_id(); + d.insert(Some(l), 0, n).unwrap(); + assert_eq!(render(&d).pixel(50, 50), [0, 255, 0, 255]); +} diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 2a116c94..071370c2 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -9,10 +9,13 @@ use serde_json::{Map, Value, json}; +use vectorcraft_color::Color; + use super::{DialogSpec, form}; -use crate::VectorcraftApp; +use crate::panels::c32; use crate::state::Dialog; use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, ..DialogSpec::FORM }; @@ -112,9 +115,10 @@ fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let (id, relative) = (d.str("__effect"), d.bool("relative")); - // Plug-in effects get fields from their parameter schema. + // Plug-in effects get fields from their parameter schema; colour adjustments their sliders. let changed = match vectorcraft_plugins::effect::installed(&id) { Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params), + None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d), None => form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), app.session.general_unit()), }; ui.add_space(6.0); @@ -132,6 +136,168 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// A slider of a colour adjustment dialog: (effect, field, label, range, rail colour at 0..1). +type Slider = (&'static str, &'static str, &'static str, (f64, f64), fn(f32) -> egui::Color32); + +fn rgb(r: f32, g: f32, b: f32) -> egui::Color32 { + c32(&Color::rgb(r, g, b)) +} + +fn grey_rail(x: f32) -> egui::Color32 { + rgb(x, x, x) +} + +fn hue_rail(x: f32) -> egui::Color32 { + c32(&Color::from_hsb(((x - 0.5) * 360.0).rem_euclid(360.0), 0.8, 0.9)) +} + +fn saturation_rail(x: f32) -> egui::Color32 { + c32(&Color::from_hsb(0.0, x, 0.9)) +} + +fn temperature_rail(x: f32) -> egui::Color32 { + rgb(0.35 + 0.65 * x, 0.55 + 0.05 * x, 1.0 - 0.8 * x) +} + +fn tint_rail(x: f32) -> egui::Color32 { + rgb(0.3 + 0.6 * x, 0.85 - 0.55 * x, 0.3 + 0.6 * x) +} + +const SLIDERS: [Slider; 12] = [ + ("adjust.brightnessContrast", "brightness", "Brightness", (-100.0, 100.0), grey_rail), + ("adjust.brightnessContrast", "contrast", "Contrast", (-100.0, 100.0), grey_rail), + ("adjust.hueSaturation", "hue", "Hue", (-180.0, 180.0), hue_rail), + ("adjust.hueSaturation", "saturation", "Saturation", (-100.0, 100.0), saturation_rail), + ("adjust.hueSaturation", "lightness", "Lightness", (-100.0, 100.0), grey_rail), + ("adjust.levels", "inputBlack", "Input Black", (0.0, 255.0), grey_rail), + ("adjust.levels", "inputWhite", "Input White", (0.0, 255.0), grey_rail), + ("adjust.levels", "outputBlack", "Output Black", (0.0, 255.0), grey_rail), + ("adjust.levels", "outputWhite", "Output White", (0.0, 255.0), grey_rail), + ("adjust.shiftToColor", "amount", "Amount", (0.0, 100.0), grey_rail), + ("adjust.temperatureTint", "temperature", "Temperature", (-100.0, 100.0), temperature_rail), + ("adjust.temperatureTint", "tint", "Tint", (-100.0, 100.0), tint_rail), +]; + +/// The colour adjustment dialogs (Effect → Color Adjustments): a slider per amount, then the +/// channel as a menu and the other values (Curves' points, Levels' gamma, the target colour) in +/// the same label column, and the options. Returns whether the values differ from those last +/// previewed. +fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool { + const LABEL_W: f32 = 84.0; + let id = d.str("__effect"); + for (_, key, label, (min, max), rail) in SLIDERS.iter().filter(|s| s.0 == id) { + form::slider_w(ui, d, (key, label, LABEL_W), *min..=*max, "", rail); + } + if d.fields.contains_key("channel") { + form::choice(ui, d, "channel", "Channel", (LABEL_W, 120.0), &[("rgb", "RGB"), ("red", "Red"), ("green", "Green"), ("blue", "Blue")]); + } + if d.fields.contains_key("points") { + ui.horizontal(|ui| { + ui.add_space(LABEL_W + ui.spacing().item_spacing.x); + curve_graph(ui, d); + }); + } + for (key, label, width) in [("points", "Points", 220.0), ("color", "Color", 120.0)] { + if d.fields.contains_key(key) { + widgets::label_row(ui, label, LABEL_W, |ui| { + form::text(ui, d, key, width); + }); + } + } + if d.fields.contains_key("gamma") { + widgets::label_row(ui, "Gamma", LABEL_W, |ui| { + let mut g = d.f64("gamma", 1.0); + if ui.add(egui::DragValue::new(&mut g).speed(0.01).range(0.1..=10.0).max_decimals(2)).changed() { + d.fields.insert("gamma".into(), json!(g)); + } + }); + } + for (key, label) in [("colorize", "Colorize"), ("preserveLightness", "Preserve Lightness")] { + if d.fields.contains_key(key) { + form::check(ui, d, key, label); + } + } + let params = form::params(d); + let changed = d.fields.get(form::PREVIEWED) != Some(¶ms); + d.fields.insert(form::PREVIEWED.into(), params); + changed +} + +/// Curves' graph of its `points` (input across, output up, 0..255): drag a point to move it +/// between its neighbours, press on the graph away from the points to add one there, drag one +/// off the graph to remove it (two always stay). +fn curve_graph(ui: &mut egui::Ui, d: &mut Dialog) { + const SIZE: f32 = 200.0; + /// How near (screen points) a press must be to take a point. + const GRAB: f32 = 8.0; + let t = Tokens::get(ui.ctx()); + let mut pts = vectorcraft_effects::curve_points(d.fields.get("points")); + let (rect, resp) = ui.allocate_exact_size(egui::vec2(SIZE, SIZE), egui::Sense::click_and_drag()); + let screen = |(x, y): (f32, f32)| egui::pos2(rect.left() + x * SIZE, rect.bottom() - y * SIZE); + let unit = |p: egui::Pos2| (((p.x - rect.left()) / SIZE).clamp(0.0, 1.0), ((rect.bottom() - p.y) / SIZE).clamp(0.0, 1.0)); + let held = ui.id().with("curve-point"); + let mut grabbed: Option = ui.data(|m| m.get_temp(held)).flatten(); + let mut changed = false; + if let Some(p) = resp.interact_pointer_pos() + && resp.drag_started() + { + let near = + pts.iter().enumerate().map(|(i, q)| (i, screen(*q).distance(p))).filter(|(_, dist)| *dist <= GRAB).min_by(|a, b| a.1.total_cmp(&b.1)); + grabbed = match near { + Some((i, _)) => Some(i), + None => { + let q = unit(p); + let i = pts.partition_point(|a| a.0 < q.0); + pts.insert(i, q); + changed = true; + Some(i) + } + }; + } + if let (Some(i), Some(p)) = (grabbed, resp.interact_pointer_pos()) + && resp.dragged() + { + if !rect.expand(24.0).contains(p) && pts.len() > 2 { + pts.remove(i.min(pts.len() - 1)); + grabbed = None; + } else { + let step = 1.0 / 255.0; + let lo = if i == 0 { 0.0 } else { pts.get(i - 1).map_or(0.0, |a| a.0 + step) }; + let hi = pts.get(i + 1).map_or(1.0, |a| a.0 - step); + let (x, y) = unit(p); + if let Some(q) = pts.get_mut(i) { + *q = (x.clamp(lo, hi.max(lo)), y); + } + } + changed = true; + } + if resp.drag_stopped() { + grabbed = None; + } + ui.data_mut(|m| m.insert_temp(held, grabbed)); + // The graph: quarter grid, the identity line, the curve and its points. + let painter = ui.painter_at(rect.expand(4.0)); + painter.rect_filled(rect, 2.0, t.input); + let faint = egui::Stroke::new(1.0, t.input_border); + for k in 1..4 { + let f = k as f32 / 4.0; + painter.line_segment([screen((f, 0.0)), screen((f, 1.0))], faint); + painter.line_segment([screen((0.0, f)), screen((1.0, f))], faint); + } + painter.line_segment([screen((0.0, 0.0)), screen((1.0, 1.0))], faint); + let curve: Vec = (0..=64).map(|k| k as f32 / 64.0).map(|x| screen((x, vectorcraft_effects::curve_at(&pts, x)))).collect(); + painter.add(egui::Shape::line(curve, egui::Stroke::new(1.5, t.text))); + for (i, q) in pts.iter().enumerate() { + let fill = if Some(i) == grabbed { t.accent } else { t.panel }; + painter.circle(screen(*q), 4.0, fill, egui::Stroke::new(1.5, t.text)); + } + painter.rect_stroke(rect, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); + if changed { + let text: Vec = pts.iter().map(|(x, y)| format!("{},{}", (x * 255.0).round(), (y * 255.0).round())).collect(); + d.fields.insert("points".into(), json!(text.join(" "))); + } +} + /// What OK runs: `effect.setParams` on the edited effect (`__index`), else `effect.apply` of the /// dialog's effect with its values on the target item. fn command(d: &Dialog) -> (&'static str, Value) { diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 22a9d152..b77483a2 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -286,13 +286,25 @@ pub(super) fn slider( range: std::ops::RangeInclusive, suffix: &str, track: &dyn Fn(f32) -> egui::Color32, +) { + slider_w(ui, d, (key, label, SLIDER_LABEL), range, suffix, track); +} + +/// [`slider`] with its label column `label_w` wide: (`key`, `label`, `label_w`). +pub(super) fn slider_w( + ui: &mut egui::Ui, + d: &mut Dialog, + (key, label, label_w): (&str, &str, f32), + range: std::ops::RangeInclusive, + suffix: &str, + track: &dyn Fn(f32) -> egui::Color32, ) { let t = Tokens::get(ui.ctx()); let (min, max) = (*range.start(), *range.end()); let v = d.f64(key, 0.0).clamp(min, max); let mut new = None; ui.horizontal(|ui| { - ui.add_sized([SLIDER_LABEL, 22.0], egui::Label::new(egui::RichText::new(label).color(t.text))); + ui.add_sized([label_w, 22.0], egui::Label::new(egui::RichText::new(label).color(t.text))); if let (Some(x), _) = crate::widgets::color_slider(ui, ("dlg-slider", key), ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track) { new = Some((min + x as f64 * (max - min)).round()); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 82c37820..6215d7e7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -37,6 +37,8 @@ pub mod unsaved; pub mod widgets; pub mod workspaces; +#[cfg(test)] +mod tests_adjust; #[cfg(test)] mod tests_aisave; #[cfg(test)] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 14d2b760..4b4329b8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2254,8 +2254,19 @@ fn effect_menu() -> Vec { Item::Header("Vector Effects"), ]; // Submenus (and Crop Marks, an item of its own) in the reference app's order. - let order = - ["3D and Materials", "Convert to Shape", "Crop Marks", "Distort & Transform", "Path", "Pathfinder", "Stylize", "SVG Filters", "Warp", "Blur"]; + let order = [ + "3D and Materials", + "Color Adjustments", + "Convert to Shape", + "Crop Marks", + "Distort & Transform", + "Path", + "Pathfinder", + "Stylize", + "SVG Filters", + "Warp", + "Blur", + ]; let top_level = |e: &vectorcraft_effects::EffectInfo| e.menu == ["Effect"] && order.contains(&e.label); for sub_name in order { if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) { diff --git a/crates/ui-egui/src/tests_adjust.rs b/crates/ui-egui/src/tests_adjust.rs new file mode 100644 index 00000000..15a673e2 --- /dev/null +++ b/crates/ui-egui/src/tests_adjust.rs @@ -0,0 +1,99 @@ +//! The colour adjustment effect dialogs (Effect → Color Adjustments) in the menus. + +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::{VectorcraftApp, dialogs, menus, tests_labels::painted_text}; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app +} + +fn dialog_text(app: &mut VectorcraftApp) -> String { + painted_text(app, |app, ui| dialogs::show(app, ui.ctx())) +} + +#[test] +fn the_effect_menu_lists_the_adjustments() { + let tree = menus::menu_tree(); + let items = |menu: &str| format!("{:?}", tree.iter().find(|(t, _)| *t == menu).unwrap().1); + let effect = items("Effect"); + for label in ["Color Adjustments", "Brightness/Contrast…", "Curves…", "Hue/Saturation…", "Levels…", "Shift to Color…", "Temperature/Tint…"] + { + assert!(effect.contains(label), "{label}"); + } +} + +#[test] +fn an_adjustment_dialog_has_sliders_and_previews_live() { + let mut app = app(); + app.run("effect.dialog", json!({"effect": "adjust.hueSaturation"})).unwrap(); + let text = dialog_text(&mut app); + for s in ["Hue/Saturation", "Hue", "Saturation", "Lightness", "Colorize", "Preview"] { + assert!(text.lines().any(|l| l == s), "{s} in {text}"); + } + assert!(app.session.in_interaction(), "the preview runs"); + // A value set from outside (ui.dialog.set) previews too. + app.ui.dialog.as_mut().unwrap().fields.insert("hue".into(), json!(90)); + dialog_text(&mut app); + let before = app.session.doc().unwrap().history.undo.len(); + dialogs::confirm(&mut app).unwrap(); + assert!(!app.session.in_interaction()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before + 1, "one undo step"); + let st = app.session.active().unwrap(); + let n = st.doc.node(st.selection.objects[0]).unwrap(); + assert_eq!(n.appearance.effects[0].id, "adjust.hueSaturation"); + assert_eq!(n.appearance.effects[0].params["hue"], 90.0); +} + +#[test] +fn levels_and_curves_show_their_channel_and_values() { + let mut app = app(); + app.run("effect.dialog", json!({"effect": "adjust.levels"})).unwrap(); + let text = dialog_text(&mut app); + for s in ["Input Black", "Output White", "Channel", "RGB", "Gamma"] { + assert!(text.lines().any(|l| l == s), "{s} in {text}"); + } + app.ui.dialog = None; + let _ = app.session.cancel_interaction(); + app.run("effect.dialog", json!({"effect": "adjust.curves"})).unwrap(); + let text = dialog_text(&mut app); + assert!(text.lines().any(|l| l == "Points") && text.contains("0,0 128,128 255,255"), "{text}"); +} + +#[test] +fn dragging_on_the_curves_graph_adds_and_moves_a_point() { + use egui::{Event, PointerButton, Pos2, Rect, vec2}; + let mut app = app(); + app.run("effect.dialog", json!({"effect": "adjust.curves"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 1000.0))); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let input = egui::RawInput { events, screen_rect, ..Default::default() }; + ctx.run_ui(input, |ui| dialogs::show(app, ui.ctx())).textures_delta.clear(); + }; + for _ in 0..3 { + frame(&mut app, vec![]); + } + let graph = + ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| w.rect.size() == vec2(200.0, 200.0)).map(|w| w.rect)); + let graph = graph.expect("the curve graph"); + // Press above the identity line a quarter of the way in and drag up: a new point, raised. + let at = |x: f32, y: f32| Pos2::new(graph.left() + x * 200.0, graph.bottom() - y * 200.0); + let (from, to) = (at(0.25, 0.35), at(0.25, 0.6)); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![Event::PointerMoved(from), button(from, true)]); + for k in 1..=5 { + frame(&mut app, vec![Event::PointerMoved(from + (to - from) * (k as f32 / 5.0))]); + } + frame(&mut app, vec![button(to, false)]); + let points = app.ui.dialog.as_ref().unwrap().str("points"); + let pts: Vec<(f32, f32)> = vectorcraft_effects::curve_points(Some(&json!(points))); + assert_eq!(pts.len(), 4, "{points}"); + let (x, y) = pts[1]; + assert!((x - 0.25).abs() < 0.01 && (y - 0.6).abs() < 0.01, "{points}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8111dd6e..e4dcc50f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -436,3 +436,8 @@ as one undo step (`ui.key` Escape cancels). They run `path.mirrorCut`, `path.lin agent can call directly. Mirror & Cut's tool options (`tool.setOption`, shown in the Control bar) are `axis` (`free` | `vertical` | `horizontal`; a constrained axis follows the pointer and a click places it) and `keep` (`left` | `right` | `top` | `bottom`); Alt on release keeps the other side. + +Colour adjustment effects (Effect → Color Adjustments) open the `effect` dialog like the other effects +(`effect.dialog {effect: "adjust.hueSaturation"}`): its fields are the effect's parameters (sliders for the amounts, +`channel` for Curves and Levels, `points` for Curves, `color` for Shift to Color) and they preview live; set them with +`ui.dialog.set` and `ui.dialog.confirm` to apply (Curves also has a graph: drag its points). diff --git a/docs/mcp.md b/docs/mcp.md index d6367f90..ed2f3d47 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1693,3 +1693,14 @@ id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearanc {"name":"run_command","arguments":{"command":"plugin.run","params":{"id":"org.vectorcraft.example.desaturate","params":{"amount":60}}}} {"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"plugin.org.example.wobble","params":{"size":4}}}} ``` + +## Colour adjustments + +Effect → Color Adjustments holds live effects that recolour what an object paints +with (fills and strokes, gradient stops, type, gradient meshes, embedded images; on a group or layer every member), +each colour keeping its colour model: `adjust.brightnessContrast`, `adjust.curves` (`points` as `"x,y x,y …"` or +`[[x, y], …]`, 0–255), `adjust.levels`, `adjust.hueSaturation`, `adjust.shiftToColor` and `adjust.temperatureTint` +(`effect.list` documents their parameters). Apply them with `effect.apply` like any effect, or with `item` to recolour +one fill or stroke; they stay editable (`effect.setParams`), export with the adjusted colours (an adjusted image as a +recoloured copy) and become permanent with `effect.expandAppearance`. Patterns and the shadows and glows of raster +effects keep their colours.