vectorcraft/crates/effects/src/adjust.rs

494 lines
21 KiB
Rust

//! 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<Adjust>);
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<ColorMap> {
let v: Vec<Adjust> =
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<Self> {
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<String> + '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<String> {
(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<Node> {
let mut m: Option<Node> = 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<Node> {
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<String> {
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());
}
}