Merge pull request #168 from storytold/fix/issue-122-color-adjust

Fix #122: colour adjustment live effects (Effect > Color Adjustments)
This commit is contained in:
BFlat 2026-10-06 00:56:14 +07:00 committed by GitHub
commit d0aeeb0076
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1364 additions and 106 deletions

View File

@ -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 `<mask>` 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 `<pattern>`/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 |

View File

@ -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;

111
crates/doc/src/recolor.rs Normal file
View File

@ -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<String>;
}
/// 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<String>, &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
})
}

View File

@ -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<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());
}
}

View File

@ -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<Node> {
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));

View File

@ -21,11 +21,15 @@
//! - **Plug-in effects** (`plugin.<plug-in id>`, 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<EffectInfo> {
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())
}

View File

@ -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<RasterFx> {
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) },

View File

@ -131,7 +131,7 @@ pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
}
/// [`reshape`] without its checks (no geometry effects: just the art).
fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
let fx = geometry_effects(n);
let mut art = match &n.kind {
NodeKind::Image(im) => {

View File

@ -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<String>, &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<Node> {
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<String> {
self.pass.image(self.d, key)
}
}
pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
run_pass(s, p, label, Recolor::new(Scope::of(p), f))
}

View File

@ -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<NodeId>) {
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<NodeId> = d.node(id).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default();

View File

@ -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;

View File

@ -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");
}

View File

@ -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<Node>, cache: bool) -> Arc<Node> {
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.

View File

@ -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<Arc<Pixmap>> {
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;

View File

@ -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<Node>) -> 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]);
}

View File

@ -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<Value, String>
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(&params);
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<usize> = 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<egui::Pos2> = (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<String> = 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) {

View File

@ -286,13 +286,25 @@ pub(super) fn slider(
range: std::ops::RangeInclusive<f64>,
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<f64>,
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());
}

View File

@ -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)]

View File

@ -2254,8 +2254,19 @@ fn effect_menu() -> Vec<Item> {
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) {

View File

@ -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<Event>| {
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}");
}

View File

@ -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).

View File

@ -1693,3 +1693,14 @@ id `plugin.<id>`: `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.