Files
vectorcraft/crates/engine/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

138 lines
5.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}