Files
vectorcraft/crates/render/src/tests_adjust.rs
T
bflatasticandClaude Opus 5.5 9920156bb6 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>
2026-10-06 00:42:26 +07:00

115 lines
4.3 KiB
Rust

//! 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]);
}