mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -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>
138 lines
5.8 KiB
Rust
138 lines
5.8 KiB
Rust
//! 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");
|
||
}
|