mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
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>
115 lines
4.3 KiB
Rust
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]);
|
|
}
|