diff --git a/crates/effects/src/svg_filter.rs b/crates/effects/src/svg_filter.rs index 3877a60ae9..0c4bff0f1d 100644 --- a/crates/effects/src/svg_filter.rs +++ b/crates/effects/src/svg_filter.rs @@ -16,6 +16,7 @@ use vectorcraft_geom::{Affine, Rect, Vec2}; use crate::pixel::PixelSpace; use crate::util::{num, text}; +pub mod defaults; #[cfg(test)] mod tests; diff --git a/crates/effects/src/svg_filter/defaults.rs b/crates/effects/src/svg_filter/defaults.rs new file mode 100644 index 0000000000..5ce67fac6b --- /dev/null +++ b/crates/effects/src/svg_filter/defaults.rs @@ -0,0 +1,248 @@ +//! The default SVG filters: the filters Effect › SVG Filters and Apply SVG Filter list in every +//! document. The set, the names and the values are VectorCraft's own, written with the filter +//! primitives of SVG 1.1 (). A default is not part of a +//! document until it is applied or edited there: the document then holds a copy under the +//! default's name, and that copy stands in the default's place in the lists. Every default computes +//! in sRGB (`color-interpolation-filters="sRGB"`), so art a filter passes through keeps its colors. + +use vectorcraft_doc::SvgFilter; + +/// A default SVG filter: its name (the `` element's `id`) and the element's XML. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DefaultFilter { + pub name: &'static str, + pub xml: &'static str, +} + +/// The default filters, in the order the lists show them. +pub const DEFAULT_FILTERS: [DefaultFilter; 14] = [ + DefaultFilter { + name: "Soft Blur", + xml: r#""#, + }, + DefaultFilter { + name: "Heavy Blur", + xml: r#""#, + }, + DefaultFilter { + name: "Soft Shadow", + xml: r#""#, + }, + DefaultFilter { + name: "Hard Shadow", + xml: r#""#, + }, + DefaultFilter { + name: "Glow", + xml: r##""##, + }, + DefaultFilter { + name: "Bevel", + xml: r##""##, + }, + DefaultFilter { + name: "Emboss", + xml: r#""#, + }, + DefaultFilter { + name: "Thicken", + xml: r#""#, + }, + DefaultFilter { + name: "Thin", + xml: r#""#, + }, + DefaultFilter { + name: "Rough Edges", + xml: r#""#, + }, + DefaultFilter { + name: "Grain", + xml: r#""#, + }, + DefaultFilter { + name: "Grayscale", + xml: r#""#, + }, + DefaultFilter { + name: "Invert", + xml: r#""#, + }, + DefaultFilter { + name: "Hue Shift", + xml: r#""#, + }, +]; + +/// The default filter named `name`. +pub fn default_filter(name: &str) -> Option { + DEFAULT_FILTERS.iter().copied().find(|f| f.name == name) +} + +impl DefaultFilter { + /// The filter as a document holds it once it is applied or edited there. + pub fn to_filter(self) -> SvgFilter { + SvgFilter { name: self.name.to_string(), xml: self.xml.to_string() } + } +} + +/// A filter of the lists ([`listed`]), and whether it is a default the document does not hold. +#[derive(Clone, Debug, PartialEq)] +pub struct Listed { + pub filter: SvgFilter, + /// A default the document has no filter named for: applying or editing it adds a copy. + pub default: bool, +} + +/// The filters Effect › SVG Filters and Apply SVG Filter list for a document that holds `held`, +/// and how many of them come first as the defaults: each default in [`DEFAULT_FILTERS`]' order (the +/// document's filter of that name in its place), then the document's other filters in their order. +pub fn listed(held: &[SvgFilter]) -> (Vec, usize) { + let mut out: Vec = DEFAULT_FILTERS + .iter() + .map(|d| match held.iter().find(|f| f.name == d.name) { + Some(f) => Listed { filter: f.clone(), default: false }, + None => Listed { filter: d.to_filter(), default: true }, + }) + .collect(); + let defaults = out.len(); + out.extend(held.iter().filter(|f| default_filter(&f.name).is_none()).map(|f| Listed { filter: f.clone(), default: false })); + (out, defaults) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_doc::Effect; + use vectorcraft_geom::{Affine, Point}; + + use super::*; + use crate::pixel::{Channels, PixelSpace}; + use crate::svg_filter::{SVG_FILTER, SvgFilterFx, check, with_id}; + use crate::{PixelFx, RasterFx, raster_effects}; + + /// A `w` × `w` raster whose pixels are one point wide. + fn space(w: usize) -> PixelSpace { + let wd = w as f64; + PixelSpace { + to_doc: Affine::scale(1.0), + px: 1.0, + center: Point::new(wd / 2.0, wd / 2.0), + channels: Channels::Rgb, + line: 1.0, + extent: 0.5 * wd.hypot(wd), + } + } + + /// The default named `name` as the renderer reads it. + fn fx(name: &str) -> SvgFilterFx { + let d = default_filter(name).unwrap_or_else(|| panic!("no default {name}")); + let e = Effect { id: SVG_FILTER.into(), params: json!({"name": d.name, "xml": d.xml}), visible: true }; + match raster_effects(&[e]).first() { + Some(RasterFx::Pixel(PixelFx::Svg(f))) => f.clone(), + other => panic!("{name}: {other:?}"), + } + } + + /// A 40 × 40 raster with an opaque square of `rgb` from 10 to 30, run through default `name`. + fn run(name: &str, rgb: [u8; 3]) -> Vec { + let mut data: Vec = (0..40usize) + .flat_map(|y| (0..40usize).map(move |x| (x, y))) + .flat_map(|(x, y)| if (10..30).contains(&x) && (10..30).contains(&y) { [rgb[0], rgb[1], rgb[2], 255] } else { [0; 4] }) + .collect(); + fx(name).apply(&mut data, 40, 40, &space(40)); + data + } + + fn at(d: &[u8], x: usize, y: usize) -> [u8; 4] { + let i = (y * 40 + x) * 4; + [d[i], d[i + 1], d[i + 2], d[i + 3]] + } + + #[test] + fn every_default_is_a_filter_the_check_takes_under_its_own_name() { + let mut seen = std::collections::BTreeSet::new(); + for d in DEFAULT_FILTERS { + assert_eq!(check(d.xml), Ok(d.name.to_string()), "{}", d.name); + assert!(seen.insert(d.name), "{} is listed twice", d.name); + assert_eq!(d.name, d.name.trim()); + // Edit SVG Filter keeps the name by writing it as the id: a default's code comes back as it is. + assert_eq!(with_id(d.xml, d.name).as_deref(), Some(d.xml), "{}", d.name); + assert_eq!(default_filter(d.name), Some(d)); + assert!(d.xml.contains(r#" color-interpolation-filters="sRGB""#), "{} computes in sRGB", d.name); + } + assert!(DEFAULT_FILTERS.len() <= SvgFilter::MAX); + assert_eq!(default_filter("soft blur"), None, "names match exactly"); + } + + #[test] + fn every_default_draws_and_is_written_to_svg_as_a_filter() { + let plain = { + let mut d = vec![0u8; 40 * 40 * 4]; + for y in 10..30 { + for x in 10..30 { + d[(y * 40 + x) * 4..(y * 40 + x) * 4 + 4].copy_from_slice(&[200, 60, 20, 255]); + } + } + d + }; + for d in DEFAULT_FILTERS { + assert_ne!(run(d.name, [200, 60, 20]), plain, "{} changes what is drawn", d.name); + assert!(fx(d.name).writes_as_svg11(), "{} is written as SVG 1.1", d.name); + } + } + + /// The color defaults against SVG 1.1's own arithmetic, in sRGB as each filter asks. + #[test] + fn the_color_defaults_compute_what_svg_1_1_defines() { + let near = |got: [u8; 4], want: [u8; 4]| got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1); + // feComponentTransfer, type="table" with values 1 0: C' = 1 - C (§15.11). + let inv = at(&run("Invert", [200, 60, 20]), 20, 20); + assert!(near(inv, [55, 195, 235, 255]), "{inv:?}"); + // feColorMatrix type="saturate" values="0": every channel is 0.213 R + 0.715 G + 0.072 B (§15.10). + let grey = at(&run("Grayscale", [200, 60, 20]), 20, 20); + let l = (0.213 * 200.0 + 0.715 * 60.0 + 0.072 * 20.0f64).round() as u8; + assert!(near(grey, [l, l, l, 255]), "{grey:?} for {l}"); + // feColorMatrix type="hueRotate" values="180" (§15.10): cos = -1 and sin = 0 leave + // R' = (2·0.213 - 1) R + 2·0.715 G + 2·0.072 B, and likewise for G' and B'. + let hue = at(&run("Hue Shift", [200, 60, 20]), 20, 20); + let lum2 = 2.0 * (0.213 * 200.0 + 0.715 * 60.0 + 0.072 * 20.0f64); + let turned = [lum2 - 200.0, lum2 - 60.0, lum2 - 20.0].map(|v| v.round().clamp(0.0, 255.0) as u8); + assert!(near(hue, [turned[0], turned[1], turned[2], 255]), "{hue:?} for {turned:?}"); + } + + /// The shape defaults against what their primitives define: an offset copy of the alpha behind + /// the art (feOffset, §15.21), and a shape grown or shrunk by its radius (feMorphology, §15.20). + #[test] + fn the_shape_defaults_move_and_resize_the_alpha() { + let shadow = run("Hard Shadow", [200, 60, 20]); + assert_eq!(at(&shadow, 20, 20), [200, 60, 20, 255], "the art is on top"); + // Right of and below the art by up to 3: black at 60% alpha (premultiplied 153). + assert_eq!(at(&shadow, 31, 31), [0, 0, 0, 153]); + assert_eq!(at(&shadow, 34, 34)[3], 0, "past the offset"); + assert_eq!(at(&shadow, 8, 8)[3], 0, "nothing up and left"); + let thick = run("Thicken", [200, 60, 20]); + assert_eq!((at(&thick, 9, 20)[3], at(&thick, 7, 20)[3]), (255, 0), "grown by its radius"); + let thin = run("Thin", [200, 60, 20]); + assert_eq!((at(&thin, 10, 20)[3], at(&thin, 13, 20)[3]), (0, 255), "shrunk by its radius"); + let soft = run("Soft Shadow", [200, 60, 20]); + assert_eq!(at(&soft, 20, 20), [200, 60, 20, 255]); + let below = at(&soft, 33, 33); + assert!(below[3] > 0 && below[3] < 128 && below[0] == 0, "a blurred, half-strength shadow: {below:?}"); + } + + #[test] + fn the_lists_show_the_defaults_first_with_the_documents_copy_in_place() { + let (all, defaults) = listed(&[]); + assert_eq!((all.len(), defaults), (DEFAULT_FILTERS.len(), DEFAULT_FILTERS.len())); + assert!(all.iter().all(|l| l.default)); + let mine = SvgFilter { name: "Mine".into(), xml: "".into() }; + let glow = SvgFilter { name: "Glow".into(), xml: "".into() }; + let (all, defaults) = listed(&[mine.clone(), glow.clone()]); + assert_eq!((all.len(), defaults), (DEFAULT_FILTERS.len() + 1, DEFAULT_FILTERS.len())); + let at = DEFAULT_FILTERS.iter().position(|d| d.name == "Glow").unwrap(); + assert_eq!(all[at], Listed { filter: glow, default: false }, "the document's Glow where the default was"); + assert_eq!(all.last(), Some(&Listed { filter: mine, default: false })); + assert_eq!(all.iter().filter(|l| l.default).count(), DEFAULT_FILTERS.len() - 1); + } +} diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 65672f8b6b..974d876d2e 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -718,7 +718,7 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result { /// sets its Warp style (`warpStyle`), its 3D type (`threeDType`) or visibility (`visible`), as one /// undo step. They are checked as `effect.apply` checks them, and a visible 3D effect must still /// fit; a hidden one has its given values checked. → `{ids}`. -fn set_params(s: &mut Session, p: &Value) -> Result { +pub(crate) fn set_params(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.setParams"; let index = index_param(p, "index", C)?; let mut params = p.get("params").cloned().unwrap_or(Value::Null); diff --git a/crates/engine/src/cmd/svgfilter.rs b/crates/engine/src/cmd/svgfilter.rs index 98b29a6e27..43af8bce35 100644 --- a/crates/engine/src/cmd/svgfilter.rs +++ b/crates/engine/src/cmd/svgfilter.rs @@ -3,9 +3,14 @@ //! applied effect names the document's filter: editing the filter changes what every object that //! uses it draws, and deleting it leaves their effects in place, drawing nothing, until a filter of //! that name exists again. Each effect holds the definition it draws with, kept in step here. +//! +//! The default filters ([`svg_filter::defaults`]) are listed for every document. Applying or +//! editing one adds a copy to the document under the default's name, and from then on that copy is +//! the filter of that name. Deleting the copy lists the default again. use serde_json::{Value, json}; use vectorcraft_doc::{Appearance, Document, SvgFilter}; +use vectorcraft_render::effects::svg_filter::defaults::{DEFAULT_FILTERS, default_filter}; use vectorcraft_render::effects::{self, svg_filter}; use super::fileio::{absolute_path, read_file_head}; @@ -21,7 +26,7 @@ pub fn specs() -> Vec { "SVG Filters", [], None, - "{} → {filters: [{name, xml}…]} the SVG filters the document holds, in the SVG Filters submenu's order", + "{} → {filters: [{name, xml}…], defaults: [{name, xml, inDocument}…]} filters: the SVG filters the document holds, in the order they were added; defaults: the default filters every document lists (VectorCraft's own set), with inDocument true when the document holds a filter of that name, which then stands in the default's place. The SVG Filters submenu and Apply SVG Filter list the defaults first, then the document's other filters", has_doc, list ), @@ -30,7 +35,7 @@ pub fn specs() -> Vec { "New SVG Filter", [], None, - "{xml: one SVG element (its id is its name; 1..64 primitives; at most 64 KB), replace?: the name of the filter it replaces and keeps (Edit SVG Filter)} add an SVG filter to the document, after the others, or replace one, as one undo step; every object whose svg.filter effect names it draws with the new definition; a name another filter has is refused and a document holds at most 64 filters → {name, filters}", + "{xml: one SVG element (its id is its name; 1..64 primitives; at most 64 KB), replace?: the name of the filter it replaces and keeps (Edit SVG Filter)} add an SVG filter to the document, after the others, or replace one, as one undo step; every object whose svg.filter effect names it draws with the new definition; a name another filter of the document has is refused and a document holds at most 64 filters; replace with the name of a default filter the document does not hold adds the edited copy to the document under that name, and code equal to the default's adds nothing; a new filter may take a default's name, and then stands in its place → {name, filters}", has_doc, define ), @@ -39,7 +44,7 @@ pub fn specs() -> Vec { "Delete SVG Filter", [], None, - "{name} remove an SVG filter from the document, as one undo step; objects whose svg.filter effect names it keep the effect, which draws nothing until a filter of that name is defined or imported again → {filters}", + "{name} remove an SVG filter from the document, as one undo step; objects whose svg.filter effect names it keep the effect, which draws nothing until a filter of that name is defined or imported again, or the default of that name is applied; a default filter the document holds no copy of is refused (the defaults are not deleted), and deleting the document's copy of one lists the default again → {filters}", has_doc, delete ), @@ -57,7 +62,7 @@ pub fn specs() -> Vec { "Apply SVG Filter", [], None, - "{name, ids?|id?, item?} apply the document's SVG filter `name` (svgFilter.list) to ids or the selection as a live svg.filter effect, as one undo step, like effect.apply → {ids, index, item}", + "{name, ids?|id?, item?, index?} apply the document's SVG filter `name`, or the default filter of that name when the document holds none (svgFilter.list), to ids or the selection as a live svg.filter effect, as one undo step, like effect.apply → {ids, index, item}; with index, the filter takes the place of the applied svg.filter effect at index of item, like effect.setParams → {ids}; a default is copied into the document in the same undo step, which is refused when the document already holds 64 filters", has_doc, apply ), @@ -69,7 +74,10 @@ fn filters_json(d: &Document) -> Value { } fn list(s: &mut Session, _: &Value) -> Result { - Ok(json!({ "filters": filters_json(&s.doc()?.doc) })) + let d = &s.doc()?.doc; + let held = |name: &str| d.svg_filters.iter().any(|f| f.name == name); + let defaults: Vec = DEFAULT_FILTERS.iter().map(|f| json!({ "name": f.name, "xml": f.xml, "inDocument": held(f.name) })).collect(); + Ok(json!({ "filters": filters_json(d), "defaults": defaults })) } /// `p`'s `name`, trimmed and not empty. @@ -132,10 +140,12 @@ fn define(s: &mut Session, p: &Value) -> Result { }; svg_filter::check(&xml).map_err(|e| bad(C, e))?; let filters = &s.doc()?.doc.svg_filters; - let at = match &replace { - Some(r) => Some(filters.iter().position(|f| f.name == *r).ok_or_else(|| bad(C, format!("the document has no SVG filter named {r:?}")))?), - None => None, - }; + let at = replace.as_ref().and_then(|r| filters.iter().position(|f| f.name == *r)); + // A default the document does not hold: its edited copy is added under the default's name. + let default = replace.as_ref().filter(|_| at.is_none()).and_then(|r| default_filter(r)); + if let Some(r) = replace.as_ref().filter(|_| at.is_none() && default.is_none()) { + return Err(bad(C, format!("the document has no SVG filter named {r:?}"))); + } // An edited filter keeps its name, whatever id its new code gives it. let xml = match &replace { Some(r) => svg_filter::with_id(&xml, r).ok_or_else(|| bad(C, "give one element"))?, @@ -145,22 +155,28 @@ fn define(s: &mut Session, p: &Value) -> Result { if filters.iter().enumerate().any(|(i, f)| f.name == name && Some(i) != at) { return Err(bad(C, format!("the document already has an SVG filter named {name:?}"))); } + let new = SvgFilter { name: name.clone(), xml }; + // Nothing to record when the filter, and every object that uses it, is already as given: a + // default saved with its own code stays out of the document. + let as_given = match default { + Some(d) => d.to_filter() == new, + None => at.and_then(|i| filters.get(i)) == Some(&new), + }; + if as_given && !out_of_step(&s.doc()?.doc, &new.name, &new.xml) { + return Ok(json!({ "name": name, "filters": filters_json(&s.doc()?.doc) })); + } if at.is_none() && filters.len() >= SvgFilter::MAX { return Err(bad(C, format!("a document holds at most {} SVG filters", SvgFilter::MAX))); } - let new = SvgFilter { name: name.clone(), xml }; - // Nothing to record when the filter, and every object that uses it, is already as given. - if at.and_then(|i| filters.get(i)) != Some(&new) || out_of_step(&s.doc()?.doc, &new.name, &new.xml) { - let label = if at.is_some() { "Edit SVG Filter" } else { "New SVG Filter" }; - s.edit(label, |d, _| { - update_users(d, &new.name, &new.xml); - match at.and_then(|i| d.svg_filters.get_mut(i)) { - Some(f) => *f = new, - None => d.svg_filters.push(new), - } - Ok(()) - })?; - } + let label = if replace.is_some() { "Edit SVG Filter" } else { "New SVG Filter" }; + s.edit(label, |d, _| { + update_users(d, &new.name, &new.xml); + match at.and_then(|i| d.svg_filters.get_mut(i)) { + Some(f) => *f = new, + None => d.svg_filters.push(new), + } + Ok(()) + })?; Ok(json!({ "name": name, "filters": filters_json(&s.doc()?.doc) })) } @@ -168,6 +184,9 @@ fn delete(s: &mut Session, p: &Value) -> Result { const C: &str = "svgFilter.delete"; let name = name_param(p, C)?; if !s.doc()?.doc.svg_filters.iter().any(|f| f.name == name) { + if default_filter(&name).is_some() { + return Err(bad(C, format!("{name:?} is a default SVG filter, and the document holds no copy of it to delete"))); + } return Err(bad(C, format!("the document has no SVG filter named {name:?}"))); } s.edit("Delete SVG Filter", |d, _| { @@ -222,13 +241,79 @@ fn import(s: &mut Session, p: &Value) -> Result { fn apply(s: &mut Session, p: &Value) -> Result { const C: &str = "svgFilter.apply"; let name = name_param(p, C)?; - let f = s.doc()?.doc.svg_filters.iter().find(|f| f.name == name).cloned(); - let f = f.ok_or_else(|| bad(C, format!("the document has no SVG filter named {name:?}")))?; - let mut q = json!({ "effect": effects::SVG_FILTER, "params": { "name": f.name, "xml": f.xml } }); + let filters = &s.doc()?.doc.svg_filters; + // The document's filter of that name, or the default of that name, which the document then takes. + let (f, copied) = match (filters.iter().find(|f| f.name == name), default_filter(&name)) { + (Some(f), _) => (f.clone(), false), + (None, Some(_)) if filters.len() >= SvgFilter::MAX => { + return Err(bad(C, format!("a document holds at most {} SVG filters, and applying a default filter adds it", SvgFilter::MAX))); + } + (None, Some(d)) => (d.to_filter(), true), + (None, None) => return Err(bad(C, format!("the document has no SVG filter named {name:?}"))), + }; + // A given `index`, null included, is checked as an index: only a missing one adds an effect. + let replaces = p.get("index").is_some(); + let mut q = match replaces { + true => json!({ "index": p["index"], "params": { "name": f.name, "xml": f.xml } }), + false => json!({ "effect": effects::SVG_FILTER, "params": { "name": f.name, "xml": f.xml } }), + }; for key in ["ids", "id", "item"] { if let Some(v) = p.get(key) { q[key] = v.clone(); } } - super::effectcmd::apply(s, &q) + if !copied { + return apply_as_given(s, p, &q, replaces, None); + } + // The effect and the copy are two edits joined into one step. The history keeps room for both + // until they are joined, so the application lets one old step go at most, as any edit does. + let limit = s.doc()?.history.limit; + if let Some(st) = s.active_mut() { + st.history.limit = limit.saturating_add(2); + } + let out = apply_as_given(s, p, &q, replaces, Some(f)); + if let Some(st) = s.active_mut() { + st.history.limit = limit; + st.history.trim(); + } + out +} + +/// [`apply`] once the filter is known: the effect added (`q`) or put in the applied filter's place +/// (`replaces`), and `copy`, a default's copy, added to the document in the same undo step. +fn apply_as_given(s: &mut Session, p: &Value, q: &Value, replaces: bool, copy: Option) -> Result { + const C: &str = "svgFilter.apply"; + let newest = s.doc()?.history.step().0; + let out = if replaces { + super::appearance::index_param(p, "index", C)?; + // Only an applied SVG filter gives its place: another effect would take the filter's + // parameters as its own. + let edited = super::effectcmd::set_params_effects(s, q); + if edited.is_empty() || edited.iter().any(|e| e != effects::SVG_FILTER) { + return Err(bad(C, "`index` must be the index of an applied SVG filter")); + } + super::effectcmd::set_params(s, q)? + } else { + super::effectcmd::apply(s, q)? + }; + if let Some(f) = copy { + // The copy joins the step that applied it, under that step's name. That step is found by + // its document, not its place: in an undo group both edits join the group's one step. + let h = &s.doc()?.history; + let applied = h.undo.last().filter(|_| h.step().0 != newest).map(|step| (step.label.clone(), step.doc.clone())); + let label = applied.as_ref().map_or_else(|| "SVG Filter".to_string(), |(l, _)| l.clone()); + s.edit(&label, |d, _| { + update_users(d, &f.name, &f.xml); + d.svg_filters.push(f); + Ok(()) + })?; + let undo = &s.doc()?.history.undo; + let below = undo.len().checked_sub(2); + if let (Some(at), Some((_, doc))) = (below, &applied) + && undo.get(at).is_some_and(|e| std::sync::Arc::ptr_eq(&e.doc, doc)) + { + super::menucmds::squash(s, at, &label); + } + } + Ok(out) } diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index 10f0ad75ca..e42d7f9736 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -715,3 +715,179 @@ fn svg_export_draws_a_filter_svg_1_1_lacks_as_an_image() { assert_eq!((out.contains(" Vec<(String, bool)> { + let listed = run(s, "svgFilter.list", json!({}))["defaults"].as_array().cloned().unwrap_or_default(); + listed.iter().map(|f| (f["name"].as_str().unwrap().to_string(), f["inDocument"].as_bool().unwrap())).collect() +} + +/// Every document lists the default filters without holding them; applying one adds the effect and +/// the document's copy in one undo step. +#[test] +fn a_default_filter_is_listed_and_copied_into_the_document_when_applied() { + use vectorcraft_render::effects::svg_filter::defaults::DEFAULT_FILTERS; + let mut s = session(); + let listed = defaults(&mut s); + assert_eq!(listed.len(), DEFAULT_FILTERS.len()); + assert!(listed.iter().all(|(_, held)| !held), "a new document holds none of them"); + assert!(names(&mut s).is_empty()); + let soft = DEFAULT_FILTERS.iter().find(|f| f.name == "Soft Shadow").unwrap(); + assert_eq!(run(&mut s, "svgFilter.list", json!({}))["defaults"][2], json!({"name": "Soft Shadow", "xml": soft.xml, "inDocument": false})); + let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 50.0)); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let before = steps(&s); + let r = run(&mut s, "svgFilter.apply", json!({"name": "Soft Shadow"})); + assert_eq!((r["ids"].as_array().unwrap().len(), &r["index"]), (2, &json!(0))); + assert_eq!(steps(&s), before + 1, "the effect and the copy are one step"); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "SVG Filter"); + assert_eq!(names(&mut s), ["Soft Shadow"]); + assert_eq!(drawn(&s, a), json!(soft.xml)); + assert!(defaults(&mut s).contains(&("Soft Shadow".to_string(), true))); + // Applied again, the document's copy is the filter: nothing more is added. + run(&mut s, "svgFilter.apply", json!({"name": "Soft Shadow", "ids": [a.0]})); + assert_eq!(names(&mut s), ["Soft Shadow"]); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert!(names(&mut s).is_empty(), "undo takes the copy back with the effect"); + assert!(s.doc().unwrap().doc.node(a).unwrap().appearance.effects.is_empty()); + run(&mut s, "edit.redo", json!({})); + assert_eq!((names(&mut s), drawn(&s, b)), (vec!["Soft Shadow".to_string()], json!(soft.xml))); + // Nothing to apply it to: no copy and no step. + let mut s = session(); + assert!(s.execute("svgFilter.apply", &json!({"name": "Glow"})).is_err(), "nothing selected"); + assert!(names(&mut s).is_empty()); + assert_eq!(steps(&s), 0); + assert!(err(&mut s, "svgFilter.apply", json!({"name": "glow", "ids": []})).contains("no SVG filter named \"glow\""), "names match exactly"); +} + +/// With a full history, applying a default lets one old step go, as any edit does: the copy joins +/// the step that applied it, and one Undo takes both back. +#[test] +fn a_default_applied_with_a_full_history_is_still_one_step() { + let mut s = session(); + s.active_mut().unwrap().history.limit = 3; + let a = rect(&mut s, 10.0); + rect(&mut s, 30.0); + rect(&mut s, 50.0); + assert_eq!(steps(&s), 3); + run(&mut s, "svgFilter.apply", json!({"name": "Invert", "ids": [a.0]})); + // The application takes one place in the history: only the oldest step went. + assert_eq!(steps(&s), 3); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "SVG Filter"); + run(&mut s, "edit.undo", json!({})); + assert!(names(&mut s).is_empty(), "the copy is undone with the effect"); + assert!(s.doc().unwrap().doc.node(a).unwrap().appearance.effects.is_empty()); +} + +/// Edit SVG Filter on a default saves the edited copy in the document under the default's name; +/// the document's filter then stands in the default's place, and deleting it lists the default again. +#[test] +fn an_edited_default_is_saved_in_the_document_and_delete_restores_the_default() { + use vectorcraft_render::effects::svg_filter::defaults::default_filter; + let mut s = session(); + let glow = default_filter("Glow").unwrap(); + // Saved with its own code: nothing to keep, and no undo step. + run(&mut s, "svgFilter.define", json!({"xml": glow.xml, "replace": "Glow"})); + assert_eq!((names(&mut s).len(), steps(&s)), (0, 0)); + let edited = r#""#; + assert_eq!(run(&mut s, "svgFilter.define", json!({"xml": edited, "replace": "Glow"}))["name"], "Glow"); + assert_eq!((names(&mut s), steps(&s)), (vec!["Glow".to_string()], 1)); + assert_eq!(s.doc().unwrap().history.undo[0].label, "Edit SVG Filter"); + let kept = r#""#; + assert_eq!(run(&mut s, "svgFilter.list", json!({}))["filters"][0]["xml"], kept, "it keeps the default's name"); + // Applying the name now applies the document's filter. + let a = rect(&mut s, 10.0); + run(&mut s, "svgFilter.apply", json!({"name": "Glow", "ids": [a.0]})); + assert_eq!(drawn(&s, a), json!(kept)); + // Deleting the copy leaves the effect drawing nothing, as for any filter, and lists the default. + run(&mut s, "svgFilter.delete", json!({"name": "Glow"})); + assert_eq!(drawn(&s, a), json!("")); + assert!(defaults(&mut s).contains(&("Glow".to_string(), false))); + // The default itself is not deleted. + let before = steps(&s); + assert!(err(&mut s, "svgFilter.delete", json!({"name": "Glow"})).contains("is a default SVG filter")); + assert!(err(&mut s, "svgFilter.delete", json!({"name": "Nope"})).contains("no SVG filter named")); + assert_eq!(steps(&s), before); + // Applied again, the default is copied back, and the object that named it draws with it. + let b = rect(&mut s, 50.0); + run(&mut s, "svgFilter.apply", json!({"name": "Glow", "ids": [b.0]})); + assert_eq!((drawn(&s, a), drawn(&s, b)), (json!(glow.xml), json!(glow.xml))); + assert_eq!(steps(&s), before + 2, "the rectangle and one step for the application"); + // A new filter may take a default's name the document does not hold. + let mut s = session(); + run(&mut s, "svgFilter.define", json!({"xml": r#""#})); + assert!(defaults(&mut s).contains(&("Invert".to_string(), true))); + // An unknown name is still refused. + assert!(err(&mut s, "svgFilter.define", json!({"xml": SHIFT, "replace": "Nope"})).contains("no SVG filter named \"Nope\"")); +} + +/// With `index`, the filter takes the place of an applied SVG filter, a default with its copy in +/// the same undo step; any other effect keeps its place. +#[test] +fn apply_with_an_index_replaces_an_applied_filter() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "svgFilter.define", json!({"xml": SHIFT})); + run(&mut s, "svgFilter.apply", json!({"name": "Shift", "ids": [a.0]})); + run(&mut s, "effect.apply", json!({"effect": "stylize.feather", "ids": [a.0]})); + let before = steps(&s); + let r = run(&mut s, "svgFilter.apply", json!({"name": "Invert", "ids": [a.0], "index": 0, "item": null})); + assert_eq!(r["ids"], json!([a.0])); + assert_eq!(steps(&s), before + 1, "one step"); + let fx = s.doc().unwrap().doc.node(a).unwrap().appearance.effects.clone(); + assert_eq!((fx.len(), fx[0].params["name"].as_str(), fx[1].id.as_str()), (2, Some("Invert"), "stylize.feather")); + assert_eq!(names(&mut s), ["Shift", "Invert"]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(names(&mut s), ["Shift"]); + assert_eq!(s.doc().unwrap().doc.node(a).unwrap().appearance.effects[0].params["name"], "Shift"); + // Not an SVG filter, no effect there, or not an index: refused, and the default is not copied. + let before = steps(&s); + for (index, why) in [ + (json!(1), "applied SVG filter"), + (json!(7), "applied SVG filter"), + (json!("x"), "`index`"), + (json!(-1), "`index`"), + (json!(null), "`index`"), + ] { + let e = err(&mut s, "svgFilter.apply", json!({"name": "Invert", "ids": [a.0], "index": index, "item": null})); + assert!(e.contains(why), "{index}: {e}"); + } + assert_eq!((names(&mut s), steps(&s)), (vec!["Shift".to_string()], before)); +} + +/// A default needs room in the document: at 64 filters applying one is refused, and a filter the +/// document holds still applies. +#[test] +fn a_full_document_refuses_a_default_it_does_not_hold() { + let mut s = session(); + let a = rect(&mut s, 10.0); + for i in 0..64 { + run(&mut s, "svgFilter.define", json!({"xml": format!("")})); + } + let before = steps(&s); + assert!(err(&mut s, "svgFilter.apply", json!({"name": "Glow", "ids": [a.0]})).contains("at most 64 SVG filters")); + let edited = r#""#; + assert!(err(&mut s, "svgFilter.define", json!({"xml": edited, "replace": "Glow"})).contains("at most 64 SVG filters")); + assert_eq!(steps(&s), before); + assert!(s.doc().unwrap().doc.node(a).unwrap().appearance.effects.is_empty()); + run(&mut s, "svgFilter.apply", json!({"name": "f3", "ids": [a.0]})); +} + +/// A default applied in one document is written to SVG as a filter and read back from the native +/// format with the document's copy. +#[test] +fn an_applied_default_survives_svg_export_and_a_native_round_trip() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "svgFilter.apply", json!({"name": "Grayscale", "ids": [a.0]})); + let out = svg(&mut s); + assert!(out.contains("", "replace": "Glow"})), + ("svgFilter.delete", json!({"name": "Glow"})), ("svgFilter.delete", json!({"name": ["a"]})), ( "effect.apply", diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 1bc2d14442..04173bfcfb 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1252,7 +1252,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.svgFilterDialog", "Apply SVG Filter…", "", - "{index?, item?} open Apply SVG Filter for the active document (dialog `svgFilter`, fields `selected`: a filter's name, `code`: the code being edited, `editing`: \"\", \"new\" or the name of the filter being edited; with index, for the applied svg.filter effect at index of item, as effect.dialog opens it): its list shows svgFilter.list's filters; OK runs svgFilter.apply with selected, or effect.setParams on the applied effect; New and Edit open the code, Save runs svgFilter.define, Delete runs svgFilter.delete and Import… svgFilter.import with a picked SVG file", + "{index?, item?} open Apply SVG Filter for the active document (dialog `svgFilter`, fields `selected`: a filter's name, `code`: the code being edited, `editing`: \"\", \"new\" or the name of the filter being edited; with index, for the applied svg.filter effect at index of item, as effect.dialog opens it): its list shows svgFilter.list's defaults (the document's filter of a default's name in its place) and then the document's other filters; OK runs svgFilter.apply with selected, and with index and item for the applied effect; New and Edit open the code, Save runs svgFilter.define (replace: the filter edited, a default included), Delete runs svgFilter.delete (off for a default the document holds no copy of) and Import… svgFilter.import with a picked SVG file", ), ( "ui.importSvgFilter", diff --git a/crates/ui-egui/src/dialogs/svg_filter.rs b/crates/ui-egui/src/dialogs/svg_filter.rs index af6849de43..f092a55cc4 100644 --- a/crates/ui-egui/src/dialogs/svg_filter.rs +++ b/crates/ui-egui/src/dialogs/svg_filter.rs @@ -1,16 +1,22 @@ -//! Apply SVG Filter (Effect › SVG Filters › Apply SVG Filter…): the document's SVG filters in a -//! list. OK applies the selected one to the selection as a live effect (`svgFilter.apply`), or, -//! opened from the Appearance panel for an applied SVG filter, puts it in that effect's place -//! (`effect.setParams`). New and Edit open the filter's code under the list; Save adds the filter -//! or replaces the one being edited (`svgFilter.define`). Delete removes the selected filter -//! (`svgFilter.delete`) and Import… adds the filters of an SVG file (`svgFilter.import`). Those -//! act at once. Effect › SVG Filters lists the same filters below its first items. +//! Apply SVG Filter (Effect › SVG Filters › Apply SVG Filter…): the default SVG filters and the +//! document's own in a list, the defaults first. OK applies the selected one to the selection as a +//! live effect, or, opened from the Appearance panel for an applied SVG filter, puts it in that +//! effect's place (`svgFilter.apply`, with `index`). New and Edit open the filter's code under the +//! list; Save adds the filter or replaces the one being edited (`svgFilter.define`). Delete +//! removes the selected filter (`svgFilter.delete`) and Import… adds the filters of an SVG file +//! (`svgFilter.import`). Those act at once. Effect › SVG Filters lists the same filters below its +//! first items. +//! +//! A default is not in the document until it is applied or edited: the document then holds a copy +//! under the default's name, which is listed in the default's place. Delete is off for a default +//! the document holds no copy of. //! //! Fields: `selected` (a filter's name), `code` (the code being edited), `editing` (`""`, `"new"` //! or the name of the filter being edited), and with an applied effect `__index`, `__item` and //! `missing` (the name of a deleted filter the effect names). use serde_json::{Value, json}; +use vectorcraft_effects::svg_filter::defaults::{DEFAULT_FILTERS, Listed, default_filter, listed}; use vectorcraft_engine::doc::SvgFilter; use super::DialogSpec; @@ -25,20 +31,21 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Apply SVG Filter…").trim_end_matches('…').to_string(), body, confirm, - ok_enabled: Some(|app, d| d.str("editing").is_empty() && filters(app).iter().any(|f| f.name == d.str("selected"))), + ok_enabled: Some(|app, d| d.str("editing").is_empty() && filters(app).0.iter().any(|f| f.filter.name == d.str("selected"))), min_width: 440.0, ..DialogSpec::FORM }; -/// The active document's SVG filters. -fn filters(app: &VectorcraftApp) -> Vec { - app.session.active().map(|st| st.doc.svg_filters.clone()).unwrap_or_default() +/// The filters the dialog lists for the active document, and how many come first as the defaults. +fn filters(app: &VectorcraftApp) -> (Vec, usize) { + listed(app.session.active().map(|st| st.doc.svg_filters.as_slice()).unwrap_or_default()) } /// `ui.svgFilterDialog {index?, item?}`: open Apply SVG Filter for the active document; with /// `index`, for the applied SVG filter at `index` of `item` (null: the object's effects): the -/// document's filter of its name selected; when the document has none of its name, the name shown -/// above the list if the filter was deleted, or else its code open as a new filter. +/// document's filter of its name selected, or the default of its name when the effect draws with +/// the default's code or with nothing; when neither has its name, the name shown above the list if +/// the filter was deleted, or else its code open as a new filter. pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { if app.session.active().is_none() { return Err("no document open".into()); @@ -53,17 +60,19 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { let e = applied.get(k as usize).filter(|e| e.id == vectorcraft_effects::SVG_FILTER).ok_or_else(|| format!("no SVG filter at index {k}"))?; let name = e.params.get("name").and_then(Value::as_str).unwrap_or_default().to_string(); let xml = e.params.get("xml").and_then(Value::as_str).unwrap_or_default().to_string(); - let held = filters(app).into_iter().find(|f| f.name == name); - match held { - // The document's filter it names: selected. - Some(_) => fields["selected"] = json!(name), + let held = app.session.active().is_some_and(|st| st.doc.svg_filters.iter().any(|f| f.name == name)); + // A default the document holds no copy of, when the effect draws with its code or with nothing. + let default = default_filter(&name).is_some_and(|d| xml.is_empty() || xml == d.xml); + if held || default { + // The filter it names: selected. + fields["selected"] = json!(name); + } else if xml.is_empty() { // Deleted from the document: shown above the list, not selectable; OK waits for a choice. - None if xml.is_empty() => fields["missing"] = json!(name), + fields["missing"] = json!(name); + } else { // A filter the document doesn't hold (from an opened file): its code, to add it. - None => { - fields["code"] = json!(xml); - fields["editing"] = json!("new"); - } + fields["code"] = json!(xml); + fields["editing"] = json!("new"); } fields["__index"] = json!(k); // As given: `null` is the object's own effects, and none is the active item's. @@ -75,33 +84,39 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { Ok(Value::Null) } -/// OK: the selected filter to the selection, or in the applied effect's place. +/// OK: the selected filter to the selection, or in the applied effect's place. A default the +/// document holds no copy of is copied into it in the same undo step. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let name = d.str("selected"); - let f = filters(app).into_iter().find(|f| f.name == name).ok_or("select an SVG filter")?; - let r = match d.fields.get("__index").and_then(Value::as_u64) { - Some(k) => { - let mut p = json!({ "index": k, "params": { "name": f.name, "xml": f.xml } }); - if let Some(item) = d.fields.get("__item") { - p["item"] = item.clone(); - } - app.run("effect.setParams", p)? + let f = filters(app).0.into_iter().find(|f| f.filter.name == name).ok_or("select an SVG filter")?; + let mut p = json!({ "name": f.filter.name }); + if let Some(k) = d.fields.get("__index").and_then(Value::as_u64) { + p["index"] = json!(k); + if let Some(item) = d.fields.get("__item") { + p["item"] = item.clone(); } - None => app.run("svgFilter.apply", json!({ "name": f.name }))?, - }; + } + let r = app.run("svgFilter.apply", p)?; app.ui.dialog = None; Ok(r) } -/// The active document's filter names, mirrored for the (static) menu tree ([`sync`]). +/// The names of the active document's filters that are not a default's, mirrored for the (static) +/// menu tree ([`sync`]). fn names_store() -> &'static std::sync::RwLock> { static NAMES: std::sync::OnceLock>> = std::sync::OnceLock::new(); NAMES.get_or_init(Default::default) } -/// Mirror the active document's SVG filter names for the menu tree (cheap when unchanged). +/// The names of `held`, a document's filters, that are not a default's, in their order. +fn own_names(held: &[SvgFilter]) -> Vec { + held.iter().filter(|f| default_filter(&f.name).is_none()).map(|f| f.name.clone()).collect() +} + +/// Mirror the active document's SVG filter names for the menu tree (cheap when unchanged). A +/// filter with a default's name is the menu's entry of that name already. pub fn sync(app: &VectorcraftApp) { - let names: Vec = app.session.active().map(|st| st.doc.svg_filters.iter().map(|f| f.name.clone()).collect()).unwrap_or_default(); + let names: Vec = app.session.active().map(|st| own_names(&st.doc.svg_filters)).unwrap_or_default(); let same = names_store().read().map(|v| v.len() == names.len() && v.iter().zip(&names).all(|(a, b)| *a == b)).unwrap_or(true); if !same { if let Ok(mut v) = names_store().write() { @@ -111,20 +126,23 @@ pub fn sync(app: &VectorcraftApp) { } } -/// Effect › SVG Filters: Apply SVG Filter…, Import SVG Filter… and then the active document's -/// filters, each applying itself to the selection. +/// Effect › SVG Filters: Apply SVG Filter…, Import SVG Filter…, the default filters and then the +/// active document's other filters, each applying itself to the selection. pub fn menu_items() -> Vec { items_for(names_store().read().map(|v| v.clone()).unwrap_or_default()) } -/// [`menu_items`] for filters named `names`. +/// [`menu_items`] for a document whose filters that are not a default's are named `names`. fn items_for(names: Vec<&'static str>) -> Vec { use crate::menus::Item; + let apply = |n: &'static str| Item::Cmd(n, "svgFilter.apply", json!({ "name": n })); let mut v = vec![Item::Cmd("Apply SVG Filter…", "ui.svgFilterDialog", Value::Null), Item::Cmd("Import SVG Filter…", "ui.importSvgFilter", Value::Null)]; + v.push(Item::Sep); + v.extend(DEFAULT_FILTERS.iter().map(|d| apply(d.name))); if !names.is_empty() { v.push(Item::Sep); - v.extend(names.into_iter().map(|n| Item::Cmd(n, "svgFilter.apply", json!({ "name": n })))); + v.extend(names.into_iter().map(apply)); } v } @@ -170,7 +188,8 @@ enum Action { } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { - let list = filters(app); + let (list, defaults) = filters(app); + let held = list.iter().filter(|f| !f.default).count(); let selected = d.str("selected"); let editing = d.str("editing"); let mut act = None; @@ -184,7 +203,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if !missing.is_empty() { ui.add_enabled_ui(false, |ui| ui.selectable_label(false, missing.as_str())); } - for f in &list { + for (i, Listed { filter: f, .. }) in list.iter().enumerate() { + // The document's other filters follow the defaults, below a line. + if i == defaults && i > 0 { + ui.separator(); + } let r = ui.selectable_label(f.name == selected, f.name.as_str()); if r.double_clicked() && editing.is_empty() { act = Some(Action::Edit(f.name.clone())); @@ -195,17 +218,20 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { }); }); ui.add_space(6.0); - let has = list.iter().any(|f| f.name == selected); + let chosen = list.iter().find(|f| f.filter.name == selected); + let has = chosen.is_some(); + // A default the document holds no copy of has nothing to delete. + let deletable = chosen.is_some_and(|f| !f.default); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 4.0; let idle = editing.is_empty(); - if ui.add_enabled_ui(idle && list.len() < SvgFilter::MAX, |ui| widgets::flat_button(ui, tl!("New"), 64.0)).inner.clicked() { + if ui.add_enabled_ui(idle && held < SvgFilter::MAX, |ui| widgets::flat_button(ui, tl!("New"), 64.0)).inner.clicked() { act = Some(Action::New); } if ui.add_enabled_ui(idle && has, |ui| widgets::flat_button(ui, tl!("Edit"), 64.0)).inner.clicked() { act = Some(Action::Edit(selected.clone())); } - if ui.add_enabled_ui(idle && has, |ui| widgets::flat_button(ui, tl!("Delete"), 64.0)).inner.clicked() { + if ui.add_enabled_ui(idle && deletable, |ui| widgets::flat_button(ui, tl!("Delete"), 64.0)).inner.clicked() { act = Some(Action::Delete); } if crate::picks::can(app, &PickRequest::Open(pick())) @@ -245,7 +271,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { Ok(()) } Some(Action::Edit(name)) => { - if let Some(f) = list.iter().find(|f| f.name == name) { + if let Some(f) = list.iter().map(|f| &f.filter).find(|f| f.name == name) { d.fields.insert("selected".into(), json!(f.name)); d.fields.insert("editing".into(), json!(f.name)); d.fields.insert("code".into(), json!(f.xml)); @@ -322,7 +348,9 @@ mod tests { crate::menus::invoke(&mut app, "ui.svgFilterDialog", json!({})); let mut d = app.ui.dialog.clone().unwrap(); assert_eq!(d.kind, KIND); - assert!(frame(&mut app, &mut d).iter().any(|t| t == "Shift"), "the filter's name, as it is"); + // Below the defaults, which fill the rows the list shows at first. + assert_eq!(filters(&app).0.last().map(|f| f.filter.name.as_str()), Some("Shift"), "the filter's name, as it is"); + assert!(!frame(&mut app, &mut d).is_empty()); assert!(!(SPEC.ok_enabled.unwrap())(&app, &d), "nothing selected"); d.fields.insert("selected".into(), json!("Shift")); assert!((SPEC.ok_enabled.unwrap())(&app, &d)); @@ -411,11 +439,30 @@ mod tests { use crate::menus::Item; let ids = |items: &[Item]| items.iter().map(|i| if let Item::Cmd(l, c, p) = i { format!("{l} {c} {p}") } else { "—".into() }).collect::>(); - assert_eq!(ids(&items_for(vec![])), ["Apply SVG Filter… ui.svgFilterDialog null", "Import SVG Filter… ui.importSvgFilter null"]); + // The defaults follow the commands in every document, and with no document. + let plain = ids(&items_for(vec![])); assert_eq!( - ids(&items_for(vec!["Shift", "A (1)"]))[2..], - ["—", "Shift svgFilter.apply {\"name\":\"Shift\"}", "A (1) svgFilter.apply {\"name\":\"A (1)\"}"] + plain[..4], + [ + "Apply SVG Filter… ui.svgFilterDialog null", + "Import SVG Filter… ui.importSvgFilter null", + "—", + "Soft Blur svgFilter.apply {\"name\":\"Soft Blur\"}" + ] ); + assert_eq!(plain.len(), 3 + DEFAULT_FILTERS.len()); + assert_eq!(plain.last().map(String::as_str), Some("Hue Shift svgFilter.apply {\"name\":\"Hue Shift\"}")); + // The document's other filters come below them. + let with = ids(&items_for(vec!["Shift", "A (1)"])); + assert_eq!(with[..plain.len()], plain[..]); + assert_eq!(with[plain.len()..], ["—", "Shift svgFilter.apply {\"name\":\"Shift\"}", "A (1) svgFilter.apply {\"name\":\"A (1)\"}"]); + // A document's filter with a default's name is that default's entry. + let held = [ + SvgFilter { name: "Glow".into(), xml: String::new() }, + SvgFilter { name: "Shift".into(), xml: String::new() }, + SvgFilter { name: "glow".into(), xml: String::new() }, + ]; + assert_eq!(own_names(&held), ["Shift", "glow"]); let mut app = app(); assert!(!crate::menus::enabled(&app, "svgFilter.apply"), "nothing selected"); app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); @@ -423,6 +470,88 @@ mod tests { assert_eq!(crate::menus::display_label(&app, "svgFilter.apply", "Shift"), "Shift", "a filter's name as it is"); } + /// The steps in the active document's undo history. + fn steps(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() + } + + #[test] + fn a_default_is_listed_first_and_ok_copies_it_into_the_document() { + let mut app = app(); + app.session.execute("svgFilter.define", &json!({"xml": SHIFT})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + open(&mut app, &json!({})).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + let (list, defaults) = filters(&app); + let names: Vec<&str> = list.iter().map(|f| f.filter.name.as_str()).collect(); + assert_eq!((defaults, names.len()), (DEFAULT_FILTERS.len(), DEFAULT_FILTERS.len() + 1)); + assert_eq!((names[0], names[defaults - 1], names[defaults]), ("Soft Blur", "Hue Shift", "Shift"), "the defaults, then the document's own"); + assert!(frame(&mut app, &mut d).iter().any(|t| t == "Soft Blur"), "a default's name, as it is"); + d.fields.insert("selected".into(), json!("Soft Shadow")); + assert!((SPEC.ok_enabled.unwrap())(&app, &d), "a default can be applied"); + let before = steps(&app); + confirm(&mut app, &d).unwrap(); + assert_eq!(steps(&app), before + 1, "one undo step"); + let doc = &app.session.active().unwrap().doc; + assert_eq!(doc.svg_filters.iter().map(|f| f.name.as_str()).collect::>(), ["Shift", "Soft Shadow"]); + let fx = &doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects; + assert_eq!((fx.len(), fx[0].params["name"].as_str()), (1, Some("Soft Shadow"))); + assert_eq!(fx[0].params["xml"].as_str(), default_filter("Soft Shadow").map(|f| f.xml)); + // Opened for that effect, the document's copy is selected; OK with another default puts it + // in the effect's place and copies it, in one step. + app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.str("selected").as_str(), d.str("editing").as_str()), ("Soft Shadow", "")); + d.fields.insert("selected".into(), json!("Invert")); + let before = steps(&app); + confirm(&mut app, &d).unwrap(); + assert_eq!(steps(&app), before + 1); + let doc = &app.session.active().unwrap().doc; + let fx = &doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects; + assert_eq!((fx.len(), fx[0].params["name"].as_str()), (1, Some("Invert")), "in its place, not added"); + assert_eq!(doc.svg_filters.len(), 3); + } + + /// Edit on a default opens its code, and Save keeps the edited copy in the document under the + /// default's name; an effect that names a default the document no longer holds selects it. + #[test] + fn an_edited_default_is_saved_in_the_document() { + let mut app = app(); + let id = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + // Save, as its button does for the default being edited. + let edited = r#""#; + let r = app.run("svgFilter.define", json!({"xml": edited, "replace": "Thin"})).unwrap(); + assert_eq!(r["name"], "Thin"); + assert_eq!(filters(&app).0.iter().filter(|f| !f.default).map(|f| f.filter.xml.as_str()).collect::>(), [edited]); + assert_eq!(filters(&app).0.len(), DEFAULT_FILTERS.len(), "in the default's place"); + app.session.execute("svgFilter.apply", &json!({"name": "Thin"})).unwrap(); + // Deleted: the default is listed again, and the effect that names it selects the default. + app.session.execute("svgFilter.delete", &json!({"name": "Thin"})).unwrap(); + assert!(filters(&app).0.iter().all(|f| f.default)); + app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.str("selected").as_str(), d.str("missing").as_str(), d.str("editing").as_str()), ("Thin", "", "")); + confirm(&mut app, &d).unwrap(); + let doc = &app.session.active().unwrap().doc; + let fx = &doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects; + assert_eq!(fx[0].params["xml"].as_str(), default_filter("Thin").map(|f| f.xml), "the default again"); + // An applied filter with a default's name and other code opens as code to save. + let e = vectorcraft_doc::Effect { + id: "svg.filter".into(), + params: json!({"name": "Glow", "xml": ""}), + visible: true, + }; + app.session + .edit("Plant", |d, _| { + d.node_mut(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects = vec![e]; + Ok(()) + }) + .unwrap(); + app.run("effect.dialog", json!({"effect": "svg.filter", "index": 0, "item": null})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.str("editing").as_str(), d.str("selected").as_str()), ("new", "")); + } + #[test] fn new_saves_the_code_and_selects_the_filter() { let mut app = app(); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 21b8795662..913dc03abb 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1253,7 +1253,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.svgFilterDialog", "Apply SVG Filter…", "", - "{index?, item?} open Apply SVG Filter for the active document (dialog `svgFilter`, fields `selected`: a filter's name, `code`: the code being edited, `editing`: \"\", \"new\" or the name of the filter being edited; with index, for the applied svg.filter effect at index of item, as effect.dialog opens it): its list shows svgFilter.list's filters; OK runs svgFilter.apply with selected, or effect.setParams on the applied effect; New and Edit open the code, Save runs svgFilter.define, Delete runs svgFilter.delete and Import… svgFilter.import with a picked SVG file", + "{index?, item?} open Apply SVG Filter for the active document (dialog `svgFilter`, fields `selected`: a filter's name, `code`: the code being edited, `editing`: \"\", \"new\" or the name of the filter being edited; with index, for the applied svg.filter effect at index of item, as effect.dialog opens it): its list shows svgFilter.list's defaults (the document's filter of a default's name in its place) and then the document's other filters; OK runs svgFilter.apply with selected, and with index and item for the applied effect; New and Edit open the code, Save runs svgFilter.define (replace: the filter edited, a default included), Delete runs svgFilter.delete (off for a default the document holds no copy of) and Import… svgFilter.import with a picked SVG file", ), ( "ui.importSvgFilter", diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 1457e35bc6..342a5171b6 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -27,7 +27,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 13/13 | Trap traps only solid fills (gradients and patterns hide what is below them but don't trap) and spreads each colour's visible area as a whole; the public page gives no default values, so Trap's 0.25 pt, 100% and 40% are VectorCraft's own | | Rasterize | Rasterize… (as a live effect) | 1/1 | — | | Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble Options' Settings presets | -| SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists the document's own filters: VectorCraft ships no default filters. A filter that is the object's last effect is written to SVG as a `` element; otherwise SVG export draws the object as an image. A filter on a turned or skewed object in an imported SVG is left out with a warning | +| SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists 14 default filters and then the document's own; the public page names no default set, so the set, names and values are VectorCraft's own, and a default is copied into the document when it is applied or edited. A filter that is the object's last effect is written to SVG as a `` element; otherwise SVG export draws the object as an image. A filter on a turned or skewed object in an imported SVG is left out with a warning | | Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; each style bends the bounds as one Bézier patch (`version` 1), so Arc's edges are cubic curves close to concentric arcs; warps saved without a version keep the earlier map, where Arc is a circular arc in the object's own proportions (#1578) | Document Raster Effects Settings exists. When the 64 million pixel cap on an effect image lowers diff --git a/docs/mcp.md b/docs/mcp.md index 9b9dc49ae4..99b0bfef38 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5261,8 +5261,19 @@ it. The native format keeps the events and the files. ## SVG filters -Effect › SVG Filters lists the document's SVG filters below Apply SVG Filter… and Import SVG Filter…; choosing one -applies it to the selection. `svgFilter.list {}` returns them as `filters: [{name, xml}]`. Each is one `` +Effect › SVG Filters lists the default SVG filters and then the document's other filters below Apply SVG Filter… and +Import SVG Filter…; choosing one applies it to the selection +([apply SVG filter effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-svg-filter-effects.html)). +The 14 defaults are VectorCraft's own set, written with [SVG 1.1 filter primitives](https://www.w3.org/TR/SVG11/filters.html): +Soft Blur, Heavy Blur, Soft Shadow, Hard Shadow, Glow, Bevel, Emboss, Thicken, Thin, Rough Edges, Grain, Grayscale, +Invert and Hue Shift. Every document lists them, and a document holds none of them until one is applied or edited +there. `svgFilter.apply` with a default's name copies the default into the document in the same undo step (refused +when the document already holds 64 filters), and `svgFilter.define` with `replace` naming a default adds the edited +copy under that name (code equal to the default's adds nothing). The document's filter of a default's name is then +listed in the default's place. `svgFilter.delete` refuses a default the document holds no copy of; deleting the copy +lists the default again, and applying the default again makes the effects that still name it draw with it. +`svgFilter.list {}` returns the document's filters as `filters: [{name, xml}]` and the defaults as +`defaults: [{name, xml, inDocument}]`. Each filter is one `` element whose `id` is its name, with 1 to 64 primitives and at most 64 KB of XML (`feTurbulence` octaves up to 10, `feConvolveMatrix` kernels up to 9 cells a side, `feMorphology` radii and `kernelUnitLength` up to 100 and `stdDeviation` up to 500 user units, elements nested at most 16 deep). It holds only filter primitives, @@ -5273,8 +5284,9 @@ a filter after the others, or with `replace` replaces the filter of that name, w filter has is refused, and the same filter again records no undo step. `svgFilter.delete {name}` removes one, and `svgFilter.import {path?|svg?}` adds the `` elements of an SVG file (at most 16 MB, not SVGZ, the first 256 filters) or of SVG text, leaving out the names the document already has and the filters `svgFilter.define` would refuse (`{added, skipped}`). Each change is one undo -step. `svgFilter.apply {name, ids?|id?, item?}` applies one as the live effect `svg.filter`, as `effect.apply` does, -in one undo step. The effect (`{name, xml, scale, origin}`) names the document's filter: editing the filter changes +step. `svgFilter.apply {name, ids?|id?, item?, index?}` applies one as the live effect `svg.filter`, as `effect.apply` does, +in one undo step; with `index` it puts the filter in the place of the applied `svg.filter` effect at that index of +`item`, as `effect.setParams` does, and an index that is not an applied SVG filter is an error. The effect (`{name, xml, scale, origin}`) names the document's filter: editing the filter changes what every object that uses it draws, in the edit's one undo step, and an application after an edit takes the edited filter. Deleting a filter that objects use leaves their effects in place, still named, drawing nothing (their `xml` empty) until a filter of that name is defined or imported again. A pasted effect that names one of the document's diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2f95ad9d1b..c6972468cc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1967,7 +1967,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Raster-effect resolution in PDF:** when the 64 million pixel cap on an effect image renders an object's raster effects below the document's raster effects resolution, PDF export warns once per object with the requested and the lowest achieved ppi (`crates/engine/src/tests/pdfraster.rs`). - **Rasterize effect:** Effect › Rasterize… (`rasterize`) draws an object as an image of square pixels at its own resolution or the document's raster effects resolution, with Object › Rasterize's color model, background, anti-aliasing, clipping mask and room around the object, while its paths stay editable. Expand Appearance and PDF and SVG export of an object whose only raster effect is Rasterize give the image Object › Rasterize gives (`crates/engine/src/tests/rastersettings.rs`, `crates/render/src/tests/rasterize.rs`). Behavior reference: [How to save artwork in Illustrator](https://helpx.adobe.com/illustrator/using/saving-artwork.html), [Work with effects](https://helpx.adobe.com/illustrator/using/effects.html). - **Rasterize colour models:** Object › Rasterize with the Grayscale colour model makes a grayscale image (a gray PNG, with alpha over a transparent background), and with Bitmap a 1-bit image (a TIFF), or a black-and-white gray PNG with alpha over a transparent background; `image.info` and the Control bar say Grayscale, and a fill color colorizes the image, in RGB and CMYK documents (#6969). Expand Appearance and PDF export of a Rasterize effect make the same images. Regression coverage: `rasterize_to_grayscale_or_bitmap_makes_a_grayscale_image` (`crates/engine/src/tests/rastersettings.rs`) and `gray_and_bilevel_images_store_gray` (`crates/render/src/encode/tests.rs`). Scores and estimates unchanged. -- **SVG filters:** Effect › SVG Filters lists the document's own SVG filters below Apply SVG Filter… and Import SVG Filter…, and choosing one applies it to the selection as a live effect in one undo step (`svgFilter.apply`). Apply SVG Filter applies the selected filter, New and Edit open its code, and Delete and Import… remove filters or add those of an SVG file (`svgFilter.define`, `svgFilter.delete`, `svgFilter.import`). VectorCraft ships no default filters. The canvas, raster export and PDF draw the filter at the Document Raster Effects resolution; SVG export writes it as a `` element when it is the object's last effect and otherwise draws the object as an image; SVG import keeps filters the other live effects can't express as this effect instead of leaving them out; the native format keeps the filters (`crates/engine/src/tests/svgfilter.rs`, `crates/effects/src/svg_filter/tests.rs`). Behavior reference: [Apply SVG filter effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-svg-filter-effects.html), [CS5 help, Apply SVG effects](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=402), [SVG 1.1 Filter Effects](https://www.w3.org/TR/SVG11/filters.html). +- **SVG filters:** Effect › SVG Filters lists 14 default SVG filters and then the document's own below Apply SVG Filter… and Import SVG Filter…, and choosing one applies it to the selection as a live effect in one undo step (`svgFilter.apply`). Apply SVG Filter applies the selected filter, New and Edit open its code, and Delete and Import… remove filters or add those of an SVG file (`svgFilter.define`, `svgFilter.delete`, `svgFilter.import`). The public page names no default set: the defaults (Soft Blur to Hue Shift, `crates/effects/src/svg_filter/defaults.rs`) are VectorCraft's own, written with SVG 1.1 primitives. A default is copied into the document when it is applied or edited, and Delete does not remove a default. The canvas, raster export and PDF draw the filter at the Document Raster Effects resolution; SVG export writes it as a `` element when it is the object's last effect and otherwise draws the object as an image; SVG import keeps filters the other live effects can't express as this effect instead of leaving them out; the native format keeps the filters (`crates/engine/src/tests/svgfilter.rs`, `crates/effects/src/svg_filter/tests.rs`). Behavior reference: [Apply SVG filter effects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-svg-filter-effects.html), [CS5 help, Apply SVG effects](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=402), [SVG 1.1 Filter Effects](https://www.w3.org/TR/SVG11/filters.html). - **Pattern tiles:** the renderer keeps a tile of each pattern for each scale it rasterizes the pattern at, until the tiles fill the tile cache's budget, and then drops the least recently used tiles first. Objects that show one pattern at different scales, such as a pattern scaled to 25% with Transform Pattern Tiles next to the same pattern at 100%, draw from tiles kept between frames.