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:
bflatastic
2026-10-30 14:14:33 +07:00
parent 45be6f47bf
commit 427752ce65
43 changed files with 973 additions and 7 deletions
+4 -1
View File
@@ -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
}
+49
View File
@@ -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, &params).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");
}