mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 21:57:12 -06:00
Add-ons: Color Effects — live exposure, desaturate and invert, apply as filter, panel
A built-in add-on in Effect › Color Effects and Object › Plug-ins › Color Effects: - Exposure (stops of linear light, offset and gamma), Desaturate and Invert Colors as live colour adjustments, evaluated in stack order with those of Effect › Color Adjustments. - addon.color.filter applies any colour adjustment to the colours themselves, through the Edit Colors pass, as one undo step. - A panel picks an adjustment and its values, then adds it as a live effect or applies it as a filter. The add-on lives in its own modules (effects, engine and ui-egui addons folders) and leaves the existing effects, commands and menus as they were.
This commit is contained in:
@@ -2,10 +2,13 @@
|
||||
//! with commands under `addon.<name>.*` (see `docs/mcp.md`).
|
||||
|
||||
mod cleanup;
|
||||
mod color;
|
||||
|
||||
use super::CommandSpec;
|
||||
|
||||
/// The commands of every built-in add-on.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
cleanup::specs()
|
||||
let mut v = cleanup::specs();
|
||||
v.extend(color::specs());
|
||||
v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Color Effects (`addon.color.*`), a built-in add-on: its live effects (Exposure, Desaturate and
|
||||
//! Invert Colors, in Effect › Color Effects) are the effects crate's; here, Apply as Filter writes
|
||||
//! any colour adjustment into the colours themselves. Its panel and its submenu, Object › Plug-ins ›
|
||||
//! Color Effects, are the UI's.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::super::colorcmds::recolor;
|
||||
use super::super::{CommandSpec, bad, checked_ids_or_selection, cmd, has_selection, text_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"addon.color.filter",
|
||||
"Apply as Filter",
|
||||
["Object", "Plug-ins", "Color Effects"],
|
||||
None,
|
||||
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay → {changed}",
|
||||
has_selection,
|
||||
filter
|
||||
)]
|
||||
}
|
||||
|
||||
fn filter(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.color.filter";
|
||||
let id = text_param(p, "effect", C)?.ok_or_else(|| bad(C, "missing `effect`"))?;
|
||||
if !effects::is_adjustment(id) {
|
||||
return Err(bad(C, format!("`{id}` is not a colour adjustment")));
|
||||
}
|
||||
let params = match p.get("params") {
|
||||
None | Some(Value::Null) => json!({}),
|
||||
Some(v @ Value::Object(_)) => v.clone(),
|
||||
Some(_) => return Err(bad(C, "`params` must be an object")),
|
||||
};
|
||||
let fx = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
|
||||
let ids = checked_ids_or_selection(s, p, C)?;
|
||||
// Settings that change nothing still make the pass, which then leaves every colour as it was.
|
||||
let Some(map) = effects::color_map(&[fx]) else { return Ok(json!({ "changed": 0 })) };
|
||||
let mut given = p.clone();
|
||||
if let Some(o) = given.as_object_mut() {
|
||||
o.insert("ids".into(), json!(ids.iter().map(|i| i.0).collect::<Vec<_>>()));
|
||||
}
|
||||
let label = effects::effect_info(id).map_or("Apply as Filter", |e| e.label.trim_end_matches('…'));
|
||||
recolor(s, &given, label, &|c| map.apply(c))
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Color Effects: its live effects through effect.apply, and Apply as Filter.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A document with a selected red rectangle, unstroked.
|
||||
fn red_rect() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
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();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn fill_hex(s: &Session, id: NodeId) -> String {
|
||||
s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint().color().unwrap().to_hex()
|
||||
}
|
||||
|
||||
fn pixel(s: &Session) -> [u8; 4] {
|
||||
vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true).pixel(50, 50)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_live_effects_draw_without_changing_the_colours() {
|
||||
for (effect, params, drawn) in [
|
||||
("addon.color.invert", json!({}), [0, 255, 255, 255]),
|
||||
("addon.color.desaturate", json!({}), [128, 128, 128, 255]),
|
||||
("addon.color.exposure", json!({"exposure": -20}), [0, 0, 0, 255]),
|
||||
] {
|
||||
let (mut s, id) = red_rect();
|
||||
s.execute("effect.apply", &json!({"effect": effect, "params": params})).unwrap();
|
||||
assert_eq!(fill_hex(&s, id), "#ff0000", "{effect}");
|
||||
assert_eq!(pixel(&s), drawn, "{effect}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filter_recolours_the_object_as_one_undo_step() {
|
||||
let (mut s, id) = red_rect();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let v = s.execute("addon.color.filter", &json!({"effect": "addon.color.invert"})).unwrap();
|
||||
assert_eq!(v["changed"], 1);
|
||||
assert_eq!(fill_hex(&s, id), "#00ffff");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
assert!(n.appearance.effects.is_empty(), "no effect is added");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(fill_hex(&s, id), "#ff0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filter_takes_any_colour_adjustment_with_its_params() {
|
||||
let (mut s, id) = red_rect();
|
||||
s.execute("addon.color.filter", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap();
|
||||
assert_eq!(fill_hex(&s, id), "#00ff00");
|
||||
// Live effects already on the object stay live, on top of the new colours.
|
||||
s.execute("effect.apply", &json!({"effect": "addon.color.invert"})).unwrap();
|
||||
s.execute("addon.color.filter", &json!({"effect": "addon.color.desaturate", "ids": [id.0]})).unwrap();
|
||||
assert_eq!(fill_hex(&s, id), "#808080");
|
||||
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.effects.len(), 1);
|
||||
// The stroke only: the fill stays.
|
||||
s.execute("addon.color.filter", &json!({"effect": "addon.color.invert", "fill": false})).unwrap();
|
||||
assert_eq!(fill_hex(&s, id), "#808080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_requests_are_refused() {
|
||||
let (mut s, id) = red_rect();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"effect": 3}),
|
||||
json!({"effect": "distort.roughen"}),
|
||||
json!({"effect": "adjust.nothing"}),
|
||||
json!({"effect": "addon.color.invert", "params": [1]}),
|
||||
json!({"effect": "addon.color.invert", "ids": [999_999]}),
|
||||
json!({"effect": "addon.color.invert", "ids": ["x"]}),
|
||||
] {
|
||||
assert!(s.execute("addon.color.filter", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(fill_hex(&s, id), "#ff0000");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("addon.color.filter", &json!({"effect": "addon.color.invert"})).is_err(), "nothing selected");
|
||||
}
|
||||
Reference in New Issue
Block a user