Fix #122: colour adjustment live effects (Effect > Color Adjustments)

The issue asked for non-destructive colour adjustments in the Effect menu
and the Appearance panel that work on vector paints, live type and
embedded images. Only the destructive Edit Colors commands existed, and
the live-effect pipeline only knew geometry and raster effects.

Effect > Color Adjustments adds six effects (adjust.*). They recolour,
in stack order, everything an object paints with: fills and strokes
(gradient stops too), type, gradient meshes, embedded images and a
group's members, each colour keeping its model. The Edit Colors walk over
an object's colours moved to vectorcraft_doc::recolor so the commands
and the live effects share it. The renderer draws adjusted art through
its object-effect path (recoloured images cached within a memory
budget), exports bake the adjusted colours (images as recoloured
copies), and Expand Appearance makes them permanent. The effect dialog
shows sliders, a channel menu and a draggable Curves graph, all previewed
live as one undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-06 00:30:31 +07:00
parent a844503e4c
commit 9920156bb6
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.